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