Files
LEDMatrix/src/device_location.py
T
ChuckandClaude Opus 5.5 b8c01c69fb ci: mypy ratchet -- keep type-clean modules clean (71 modules, 536 -> 442 errors) (#661)
* ci: mypy ratchet -- keep type-clean modules clean

mypy-clean.txt lists the 71 modules under src/ that type-check clean;
scripts/check_types.py runs mypy (--follow-imports=silent) on exactly
those files and fails on any error or a missing/unsorted/duplicate entry.
A new "Type check (mypy ratchet)" CI job runs it with mypy 1.20.2 and
pinned stubs; the manual pre-commit mypy hook now runs the same script
(a local hook, so mypy sees the installed requirements like CI does).

35 modules were made clean with annotation-only fixes: hints, typing.cast,
TYPE_CHECKING imports, implicit-Optional defaults made explicit, and
annotations widened (never guards removed) where mypy called a defensive
isinstance check unreachable. No runtime behaviour change.

mypy.ini: numpy and orjson are treated as Any (follow_imports=skip, also
for stubs). numpy 2.3+ stubs use 3.12 `type` statements that mypy won't
parse at python_version 3.10, and orjson is optional, so seeing its stubs
made the result depend on whether it was installed.

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

* chore: annotate check_types.py's list-form mypy subprocess

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 15:01:35 -04:00

332 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, cast
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.
"""
loc: Any
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=cast(Dict[str, Any], {
"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