mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
* 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>
331 lines
13 KiB
Python
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
|