Files
LEDMatrix/src/device_location.py
T
ChuckandClaude Opus 5.5 9a1f94f793 fix(starlark): blank app locations use the device location, not San Francisco (#617)
* fix(starlark): blank app locations use the device location, not San Francisco

A Starlark (Tidbyt) app whose Location field is blank rendered at its
author's hard-coded DEFAULT_LOCATION -- usually San Francisco -- even with
the device city set under General settings. A user in Charlotte, NC got San
Francisco weather and radar with nothing in config.json to explain it.

src/device_location.py fills unset location fields at render time (display
plugin and the web standalone render): the device city is geocoded once via
Open-Meteo, preferring a match in the configured state/country, and cached
permanently. A saved location always wins; if the lookup fails the field is
dropped so the app uses its own default, and the failure is not retried for
30 minutes.

Also fixes the config form: clearing a location omitted the key, and the
save merges, so the old value could never be removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(starlark): say what happens when the device location can't be used

A blank app Location only renders at the device's city when one is set and
the Open-Meteo lookup finds it. With no city, no match, or the geocoder
unreachable (retried after 30 minutes), the app gets no location and keeps
its author's default. The guide, the config page hint, CONFIG_REFERENCE and
the CHANGELOG entry now say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 16:48:02 -04:00

331 lines
13 KiB
Python

"""
The device's own location, in the shape a Starlark (Tidbyt/Pixlet) app expects.
A Pixlet ``schema.Location`` field is a JSON string -- ``{"lat": "35.2271",
"lng": "-80.8431", "timezone": "America/New_York", ...}`` -- and an app whose
field is left unset falls back to whatever its author hard-coded. Most
community apps hard-code San Francisco, so a user who set Charlotte under
General settings got San Francisco weather and a San Francisco radar map with
nothing in config.json to explain it.
Regular plugins already default their ``location_city``/``location_state``/
``location_country`` keys to the device location
(``SchemaManager.apply_device_location``). This module is the Starlark
equivalent. The device ``location`` block only has city/state/country, so the
city is geocoded once (Open-Meteo, the same service ledmatrix-weather uses)
and the coordinates are cached permanently -- cities don't move, so the
geocoder is only hit on a cache miss.
The substitution is applied at render time and never written into an app's
config.json, so a later change to the device location is picked up by the
next render. A location saved on the app itself always wins.
"""
import json
import logging
import time
from typing import Any, Callable, Dict, Iterable, List, Optional
GEOCODE_URL = "https://geocoding-api.open-meteo.com/v1/search"
GEOCODE_TIMEOUT = 10
#: More than the handful ledmatrix-weather asks for: a common name
#: (Springfield, Charlotte, Portland) has several US matches, and the one in
#: the configured state has to be among the results to be picked.
GEOCODE_RESULT_COUNT = 10
#: Coordinates for a fixed city never go stale.
COORDS_MAX_AGE = 10 * 365 * 24 * 3600
#: After a failed lookup, renders use the app's own default until this has
#: passed, so a geocoder outage costs one timeout, not one per render.
FAILURE_RETRY_SECONDS = 30 * 60
CACHE_KEY_PREFIX = "device_location:coords"
LOCATION_FIELD_TYPES = ("location",)
# Open-Meteo reports the full state name in ``admin1``; the device state may be
# typed either way.
US_STATE_NAMES = {
"AL": "alabama", "AK": "alaska", "AZ": "arizona", "AR": "arkansas",
"CA": "california", "CO": "colorado", "CT": "connecticut",
"DE": "delaware", "DC": "district of columbia", "FL": "florida",
"GA": "georgia", "HI": "hawaii", "ID": "idaho", "IL": "illinois",
"IN": "indiana", "IA": "iowa", "KS": "kansas", "KY": "kentucky",
"LA": "louisiana", "ME": "maine", "MD": "maryland",
"MA": "massachusetts", "MI": "michigan", "MN": "minnesota",
"MS": "mississippi", "MO": "missouri", "MT": "montana",
"NE": "nebraska", "NV": "nevada", "NH": "new hampshire",
"NJ": "new jersey", "NM": "new mexico", "NY": "new york",
"NC": "north carolina", "ND": "north dakota", "OH": "ohio",
"OK": "oklahoma", "OR": "oregon", "PA": "pennsylvania",
"PR": "puerto rico", "RI": "rhode island", "SC": "south carolina",
"SD": "south dakota", "TN": "tennessee", "TX": "texas", "UT": "utah",
"VT": "vermont", "VA": "virginia", "WA": "washington",
"WV": "west virginia", "WI": "wisconsin", "WY": "wyoming",
}
_COUNTRY_ALIASES = {"usa": "us", "united states": "us",
"united states of america": "us", "uk": "gb",
"united kingdom": "gb"}
logger = logging.getLogger(__name__)
def _norm(value: Any) -> str:
"""Lower-case, with ``_``/``-`` read as spaces ("North_Carolina")."""
if not isinstance(value, str):
return ""
return " ".join(value.replace("_", " ").replace("-", " ").lower().split())
def _norm_state(value: Any) -> str:
state = _norm(value)
return US_STATE_NAMES.get(state.upper(), state)
def _norm_country(value: Any) -> str:
country = _norm(value)
return _COUNTRY_ALIASES.get(country, country)
def location_field_ids(schema: Optional[Dict[str, Any]]) -> List[str]:
"""Ids of a Starlark app schema's ``location`` fields."""
if not isinstance(schema, dict):
return []
fields = schema.get("fields") or schema.get("schema") or []
ids = []
for field in fields:
if not isinstance(field, dict) or not field.get("id"):
continue
# "typeOf" from both extractors; "type" if a raw pixlet schema slipped
# through unremapped.
field_type = field.get("typeOf", field.get("type"))
if isinstance(field_type, str) and field_type.lower() in LOCATION_FIELD_TYPES:
ids.append(field["id"])
return ids
def parse_location(value: Any) -> Optional[Dict[str, Any]]:
"""The saved location as a dict, or None if it has no usable lat/lng.
Blank, missing, unparseable, or lat/lng-less values (the config form sends
``{"timezone": ...}`` when only the timezone box is filled) all mean the
user has not given the app a place.
"""
if isinstance(value, dict):
loc = value
elif isinstance(value, str) and value.strip():
try:
loc = json.loads(value)
except (TypeError, ValueError):
return None
else:
return None
if not isinstance(loc, dict):
return None
try:
float(loc["lat"])
float(loc["lng"])
except (KeyError, TypeError, ValueError):
return None
return loc
def pick_geocode_result(results: Iterable[Dict[str, Any]], state: Any,
country: Any) -> Optional[Dict[str, Any]]:
"""Best geocoder hit: same country and state, then same country, then first."""
results = [r for r in results if isinstance(r, dict)
and "latitude" in r and "longitude" in r]
if not results:
return None
want_state = _norm_state(state)
want_country = _norm_country(country)
def country_matches(r):
return bool(want_country) and want_country in (
_norm_country(r.get("country_code")), _norm_country(r.get("country")))
def state_matches(r):
return bool(want_state) and _norm_state(r.get("admin1")) == want_state
for test in (lambda r: country_matches(r) and state_matches(r),
country_matches,
state_matches):
for r in results:
if test(r):
return r
return results[0]
def geocode(city: str, state: Any = None, country: Any = None,
timeout: float = GEOCODE_TIMEOUT) -> Optional[Dict[str, Any]]:
"""Look the city up on Open-Meteo. Raises on a network/HTTP failure."""
import requests
response = requests.get(GEOCODE_URL, params={
"name": city, "count": GEOCODE_RESULT_COUNT,
"language": "en", "format": "json",
}, timeout=timeout)
response.raise_for_status()
best = pick_geocode_result(response.json().get("results") or [], state, country)
if best is None:
return None
return {
"lat": best["latitude"],
"lng": best["longitude"],
"timezone": best.get("timezone"),
}
class DeviceLocationResolver:
"""Resolves the device location to a Pixlet location JSON string.
Cached coordinates live in ``cache_manager`` (shared on disk by the
display and web processes) and in memory. A failed lookup is remembered
for ``FAILURE_RETRY_SECONDS`` so renders in the meantime fall straight
back to the app's own default.
"""
def __init__(self, cache_manager: Any = None,
log: Optional[logging.Logger] = None,
geocoder: Callable[..., Optional[Dict[str, Any]]] = geocode,
clock: Callable[[], float] = time.time):
self.cache_manager = cache_manager
self.logger = log or logger
self._geocode = geocoder
self._clock = clock
self._coords: Dict[str, Dict[str, Any]] = {}
self._failed_at: Dict[str, float] = {}
@staticmethod
def _cache_key(city: str, state: str, country: str) -> str:
# The key is a filename on disk: no spaces.
parts = (_norm(city), _norm_state(state), _norm_country(country))
return ":".join((CACHE_KEY_PREFIX,) + tuple(p.replace(" ", "_") for p in parts))
def _cached(self, key: str) -> Optional[Dict[str, Any]]:
if key in self._coords:
return self._coords[key]
if self.cache_manager is None:
return None
try:
cached = self.cache_manager.get(key, max_age=COORDS_MAX_AGE)
except Exception:
self.logger.debug("Could not read cached device coordinates", exc_info=True)
return None
if isinstance(cached, dict) and "lat" in cached and "lng" in cached:
self._coords[key] = cached
return cached
return None
def coordinates(self, device_location: Any) -> Optional[Dict[str, Any]]:
"""``{"lat", "lng", "timezone"}`` for the device city, or None."""
if not isinstance(device_location, dict):
return None
city = device_location.get("city")
if not isinstance(city, str) or not city.strip():
return None
city = city.strip()
state = device_location.get("state") or ""
country = device_location.get("country") or ""
key = self._cache_key(city, state, country)
cached = self._cached(key)
if cached is not None:
return cached
failed_at = self._failed_at.get(key)
if failed_at is not None and self._clock() - failed_at < FAILURE_RETRY_SECONDS:
return None
try:
coords = self._geocode(city, state, country)
except Exception as e:
self._failed_at[key] = self._clock()
self.logger.warning(
"Could not geocode device location %r: %s - Starlark apps "
"without a saved location use their own default", city, e)
return None
if not coords:
self._failed_at[key] = self._clock()
self.logger.warning(
"Geocoder found no match for device location %r, %r, %r - "
"Starlark apps without a saved location use their own default",
city, state, country)
return None
self._failed_at.pop(key, None)
self._coords[key] = coords
if self.cache_manager is not None:
try:
self.cache_manager.set(key, coords, ttl=COORDS_MAX_AGE)
except Exception:
self.logger.debug("Could not cache device coordinates", exc_info=True)
return coords
def location_json(self, device_location: Any,
device_timezone: Optional[str] = None,
saved: Optional[Dict[str, Any]] = None) -> Optional[str]:
"""The device location as a Pixlet location string, or None.
The timezone is the city's own (from the geocoder) when known, since
it belongs to the coordinates; the device timezone is the fallback. A
timezone the user typed into the app's location form (with no lat/lng)
is kept.
"""
coords = self.coordinates(device_location)
if coords is None:
return None
city = str(device_location.get("city", "")).strip()
state = str(device_location.get("state") or "").replace("_", " ").strip()
country = str(device_location.get("country") or "").strip()
timezone = ((saved or {}).get("timezone") or coords.get("timezone")
or device_timezone or "UTC")
return json.dumps({
"lat": f"{float(coords['lat']):.4f}",
"lng": f"{float(coords['lng']):.4f}",
"locality": city,
"description": ", ".join(p for p in (city, state, country) if p),
"timezone": timezone,
})
def apply_device_location(pixlet_config: Dict[str, Any],
schema: Optional[Dict[str, Any]],
resolver: DeviceLocationResolver,
device_config: Optional[Dict[str, Any]]) -> Dict[str, Any]:
"""Fill a Starlark app's unset location fields with the device location.
Returns a new dict. A usable saved location is left alone. An unset field
gets the device location, or -- when that can't be resolved -- is dropped,
so the app sees no value and uses its own default instead of failing to
decode an empty string. Never raises.
"""
config = dict(pixlet_config)
unset = [fid for fid in location_field_ids(schema)
if parse_location(config.get(fid)) is None]
if not unset:
return config
device_config = device_config if isinstance(device_config, dict) else {}
for field_id in unset:
saved = config.get(field_id)
partial = None
if isinstance(saved, str) and saved.strip():
try:
partial = json.loads(saved)
except (TypeError, ValueError):
partial = None
try:
value = resolver.location_json(
device_config.get("location"), device_config.get("timezone"),
partial if isinstance(partial, dict) else None)
except Exception:
logger.warning("Could not build device location for %s", field_id, exc_info=True)
value = None
if value is None:
config.pop(field_id, None)
else:
config[field_id] = value
return config