diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e07b552..1afa4095 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,14 @@ accepts both, but the store flags the old spelling as deprecated that will now render the font it asked for. - `src.wifi_manager.get_wifi_status_path()` — where WiFi status messages for the display are written (`config/wifi_status.json`). +- `src.device_location` — a blank `Location` field on a Starlark (Tidbyt) app + now renders at the device's City / State / Country (geocoded once via + Open-Meteo and cached) instead of the app author's hard-coded default, + usually San Francisco. A location saved on the app still wins. With no + device city set, or when the lookup fails or finds no match, the app keeps + its own default (a failed lookup is retried after 30 minutes). Clearing an + app's location in the web UI now actually clears it; the save used to drop + the blank field, so the old value stayed. Deprecated, removed in 3.7.0 (each logs a warning on first use; see `docs/PLUGIN_API_REFERENCE.md#deprecated-apis` for replacements). Nothing in diff --git a/docs/CONFIG_REFERENCE.md b/docs/CONFIG_REFERENCE.md index 58eb7df7..ac6b1486 100644 --- a/docs/CONFIG_REFERENCE.md +++ b/docs/CONFIG_REFERENCE.md @@ -19,7 +19,7 @@ tooling against it. | `auto_update.enabled` | bool, `false` | Weekly automatic updates: LEDMatrix code first (health-checked, rolled back on failure), then installed plugins. Toggle in the General tab or install with `first_time_install.sh --enable-auto-update` | `web_interface/auto_update.py`, `src/auto_update_setup.py` (`is_enabled()`) | | `timezone` | string, `"America/New_York"` | IANA timezone for schedules and displays | `ConfigManager.get_timezone()` | | `target_fps` | int, `100` | Legacy "Scroll Frame Rate". Core scrolling no longer reads it: scroll frames are presented at `display.hardware.limit_refresh_rate_hz` divided by each scroll's frame hold, and speed comes from each plugin's scroll settings. Still exposed to plugins via `BasePlugin.global_config` | `src/plugin_system/base_plugin.py` | -| `location` | object | `city` / `state` / `country`. Supplies the **default** for a plugin's own `location_city` / `location_state` / `location_country` setting, so weather, radar and friends follow this device without being configured twice. A value saved on the plugin itself still overrides it. | `SchemaManager.apply_device_location()`, then plugins via merged config | +| `location` | object | `city` / `state` / `country`. Supplies the **default** for a plugin's own `location_city` / `location_state` / `location_country` setting, so weather, radar and friends follow this device without being configured twice. A value saved on the plugin itself still overrides it. Starlark (Tidbyt) apps get the same treatment: a `Location` field left blank on the app renders at this city (geocoded once via Open-Meteo, coordinates cached permanently) instead of the app author's default, which is usually San Francisco. If the city can't be looked up (no match, or the geocoder is unreachable; retried after 30 minutes), the app keeps its own default. | `SchemaManager.apply_device_location()`, then plugins via merged config; `src/device_location.py` for Starlark apps | ## `schedule` — display on/off hours diff --git a/docs/STARLARK_APPS_GUIDE.md b/docs/STARLARK_APPS_GUIDE.md index 522b275e..efd5b77d 100644 --- a/docs/STARLARK_APPS_GUIDE.md +++ b/docs/STARLARK_APPS_GUIDE.md @@ -127,7 +127,7 @@ Verify installation: 3. Filter by category: Weather, Sports, Finance, Games, Clocks, etc. 4. Click **Install** on desired apps 5. Configure each app: - - Set location/timezone + - Set location/timezone (optional: blank uses this device's location) - Enter API keys if required - Customize display preferences @@ -137,7 +137,12 @@ Each app may have different configuration options: #### Common Configuration Types -- **Location** (lat/lng/timezone): For weather, clocks, transit +- **Location** (lat/lng/timezone): For weather, clocks, transit. Left blank, + the app renders at this device's location (City / State / Country under + General settings). If no city is set there, or the city can't be looked up + (no match, or the geocoder is unreachable -- retried after 30 minutes), the + app gets no location and falls back to its author's hard-coded default, + usually San Francisco. Fill it in only to point one app somewhere else. - **API Keys**: For services like weather, stocks, sports scores - **Display Preferences**: Colors, units, layouts - **Dropdown Options**: Team selections, language, themes diff --git a/plugin-repos/starlark-apps/manager.py b/plugin-repos/starlark-apps/manager.py index e078ec55..fae59a97 100644 --- a/plugin-repos/starlark-apps/manager.py +++ b/plugin-repos/starlark-apps/manager.py @@ -19,6 +19,7 @@ from PIL import Image from src.plugin_system.base_plugin import BasePlugin, VegasDisplayMode from src.logging_config import get_logger +from src.device_location import DeviceLocationResolver, apply_device_location from pixlet_renderer import PixletRenderer from frame_extractor import FrameExtractor @@ -229,6 +230,10 @@ class StarlarkAppsPlugin(BasePlugin): self.current_app: Optional[StarlarkApp] = None self.last_update_check = 0 + # Unset location fields render at the device's location, not the + # app author's default (usually San Francisco). + self.device_location = DeviceLocationResolver(cache_manager, self.logger) + # Check Pixlet availability if not self.pixlet.is_available(): self.logger.error("Pixlet not available - Starlark apps will not work") @@ -882,6 +887,10 @@ class StarlarkAppsPlugin(BasePlugin): # Filter out LEDMatrix-internal timing/sizing keys before passing to pixlet INTERNAL_KEYS = {'render_interval', 'display_duration', 'render_width', 'render_height'} pixlet_config = {k: v for k, v in app.config.items() if k not in INTERNAL_KEYS} + # Applied here rather than saved into config.json, so a later + # change to the device location reaches the next render. + pixlet_config = apply_device_location( + pixlet_config, app.schema, self.device_location, self.global_config) success, error = self.pixlet.render( star_file=str(app.star_file), diff --git a/src/device_location.py b/src/device_location.py new file mode 100644 index 00000000..1742b94c --- /dev/null +++ b/src/device_location.py @@ -0,0 +1,330 @@ +""" +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 diff --git a/test/test_device_location.py b/test/test_device_location.py new file mode 100644 index 00000000..8d6ea5f4 --- /dev/null +++ b/test/test_device_location.py @@ -0,0 +1,315 @@ +""" +Starlark app location fields default to the device's own location. + +The bug this pins: a user set Charlotte, North Carolina under General settings +(and in ledmatrix-weather), but a Tidbyt weather/radar app kept showing San +Francisco -- the ``DEFAULT_LOCATION`` its author hard-coded -- because the +app's own Location field was blank and nothing filled it. There was no San +Francisco anywhere in config.json to explain it. +""" + +import importlib +import json +import sys +import types +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from src.device_location import ( + FAILURE_RETRY_SECONDS, + DeviceLocationResolver, + apply_device_location, + location_field_ids, + parse_location, + pick_geocode_result, +) + +CHARLOTTE = {"lat": 35.22709, "lng": -80.84313, "timezone": "America/New_York"} +DEVICE_CONFIG = { + "timezone": "America/Chicago", + "location": {"city": "Charlotte", "state": "North Carolina", "country": "US"}, +} +SCHEMA = {"version": "1", "schema": [ + {"typeOf": "location", "id": "location", "name": "Location"}, + {"typeOf": "text", "id": "api_key", "name": "API key"}, +]} +SAVED_BROOKLYN = json.dumps({"lat": "40.6782", "lng": "-73.9442", + "timezone": "America/New_York"}) + + +class FakeCache: + def __init__(self): + self.store = {} + + def get(self, key, max_age=300, memory_ttl=None): + return self.store.get(key) + + def set(self, key, data, ttl=None): + self.store[key] = data + + +class CountingGeocoder: + def __init__(self, result=CHARLOTTE, error=None): + self.result = result + self.error = error + self.calls = [] + + def __call__(self, city, state, country): + self.calls.append((city, state, country)) + if self.error: + raise self.error + return self.result + + +class Clock: + def __init__(self): + self.now = 1_000_000.0 + + def __call__(self): + return self.now + + +def resolver(geocoder=None, cache=None, clock=None): + return DeviceLocationResolver(cache if cache is not None else FakeCache(), + MagicMock(), geocoder or CountingGeocoder(), + clock or Clock()) + + +class TestApplyDeviceLocation: + def test_an_unset_location_renders_at_the_device_location(self): + out = apply_device_location({"api_key": "k"}, SCHEMA, resolver(), DEVICE_CONFIG) + loc = json.loads(out["location"]) + assert (loc["lat"], loc["lng"]) == ("35.2271", "-80.8431") + assert loc["locality"] == "Charlotte" + assert out["api_key"] == "k" + + @pytest.mark.parametrize("blank", ["", " ", None, "{}", "not json", + json.dumps({"timezone": "America/Denver"})]) + def test_a_blank_or_lat_lng_less_value_counts_as_unset(self, blank): + out = apply_device_location({"location": blank}, SCHEMA, resolver(), DEVICE_CONFIG) + assert json.loads(out["location"])["lat"] == "35.2271" + + def test_a_saved_location_wins(self): + geocoder = CountingGeocoder() + out = apply_device_location({"location": SAVED_BROOKLYN}, SCHEMA, + resolver(geocoder), DEVICE_CONFIG) + assert out["location"] == SAVED_BROOKLYN + assert geocoder.calls == [], "nothing to resolve, so no network" + + def test_the_input_is_not_mutated(self): + config = {"location": ""} + apply_device_location(config, SCHEMA, resolver(), DEVICE_CONFIG) + assert config == {"location": ""} + + def test_a_timezone_typed_without_coordinates_is_kept(self): + partial = json.dumps({"timezone": "America/Denver"}) + out = apply_device_location({"location": partial}, SCHEMA, resolver(), DEVICE_CONFIG) + assert json.loads(out["location"])["timezone"] == "America/Denver" + + def test_the_timezone_is_the_citys_own(self): + """The device timezone (Chicago here) is only a fallback.""" + out = apply_device_location({}, SCHEMA, resolver(), DEVICE_CONFIG) + assert json.loads(out["location"])["timezone"] == "America/New_York" + + def test_the_device_timezone_fills_in_when_the_geocoder_has_none(self): + geocoder = CountingGeocoder({"lat": 35.2, "lng": -80.8, "timezone": None}) + out = apply_device_location({}, SCHEMA, resolver(geocoder), DEVICE_CONFIG) + assert json.loads(out["location"])["timezone"] == "America/Chicago" + + def test_geocode_failure_falls_back_to_the_apps_own_default(self): + """Dropped rather than passed blank: an app decoding "" would crash.""" + failing = resolver(CountingGeocoder(error=OSError("network down"))) + out = apply_device_location({"location": "", "api_key": "k"}, SCHEMA, + failing, DEVICE_CONFIG) + assert "location" not in out + assert out["api_key"] == "k" + + def test_no_match_falls_back_too(self): + out = apply_device_location({}, SCHEMA, resolver(CountingGeocoder(result=None)), + DEVICE_CONFIG) + assert "location" not in out + + @pytest.mark.parametrize("device", [{}, None, {"location": {}}, + {"location": {"city": " "}}, + {"location": "Charlotte"}]) + def test_no_device_location_falls_back(self, device): + geocoder = CountingGeocoder() + out = apply_device_location({}, SCHEMA, resolver(geocoder), device) + assert "location" not in out + assert geocoder.calls == [] + + def test_an_app_without_a_location_field_is_untouched(self): + schema = {"schema": [{"typeOf": "text", "id": "location"}]} + out = apply_device_location({"location": ""}, schema, resolver(), DEVICE_CONFIG) + assert out == {"location": ""} + + def test_an_app_without_a_schema_is_untouched(self): + assert apply_device_location({"a": 1}, None, resolver(), DEVICE_CONFIG) == {"a": 1} + + +class TestGeocodingIsCached: + def test_the_city_is_geocoded_once(self): + geocoder = CountingGeocoder() + r = resolver(geocoder) + for _ in range(3): + apply_device_location({}, SCHEMA, r, DEVICE_CONFIG) + assert len(geocoder.calls) == 1 + + def test_the_cache_survives_a_restart(self): + cache = FakeCache() + resolver(CountingGeocoder(), cache).coordinates(DEVICE_CONFIG["location"]) + geocoder = CountingGeocoder() + coords = resolver(geocoder, cache).coordinates(DEVICE_CONFIG["location"]) + assert coords["lat"] == CHARLOTTE["lat"] + assert geocoder.calls == [] + + def test_a_new_device_city_is_looked_up(self): + geocoder = CountingGeocoder() + r = resolver(geocoder) + r.coordinates({"city": "Charlotte", "state": "NC", "country": "US"}) + r.coordinates({"city": "Tampa", "state": "Florida", "country": "US"}) + assert [c[0] for c in geocoder.calls] == ["Charlotte", "Tampa"] + + def test_state_spellings_share_one_cache_entry(self): + """"North_Carolina", "north carolina" and "NC" are the same place.""" + geocoder = CountingGeocoder() + r = resolver(geocoder) + for state in ("North Carolina", "North_Carolina", "NC"): + r.coordinates({"city": "Charlotte", "state": state, "country": "US"}) + assert len(geocoder.calls) == 1 + + def test_a_failure_is_not_retried_on_every_render(self): + clock = Clock() + geocoder = CountingGeocoder(error=OSError("down")) + r = resolver(geocoder, clock=clock) + for _ in range(5): + assert r.coordinates(DEVICE_CONFIG["location"]) is None + assert len(geocoder.calls) == 1 + + clock.now += FAILURE_RETRY_SECONDS + 1 + geocoder.error = None + assert r.coordinates(DEVICE_CONFIG["location"])["lat"] == CHARLOTTE["lat"] + assert len(geocoder.calls) == 2 + + def test_a_broken_cache_does_not_stop_the_lookup(self): + cache = MagicMock() + cache.get.side_effect = OSError("disk") + cache.set.side_effect = OSError("disk") + assert resolver(cache=cache).coordinates(DEVICE_CONFIG["location"]) is not None + + +class TestPickGeocodeResult: + RESULTS = [ + {"latitude": 42.56, "longitude": -84.84, "country_code": "US", "admin1": "Michigan"}, + {"latitude": 35.23, "longitude": -80.84, "country_code": "US", "admin1": "North Carolina"}, + {"latitude": 18.34, "longitude": -64.93, "country_code": "VI", "admin1": "St Thomas"}, + ] + + @pytest.mark.parametrize("state", ["North Carolina", "north_carolina", "NC", "nc"]) + def test_the_configured_state_wins_over_the_first_hit(self, state): + assert pick_geocode_result(self.RESULTS, state, "US")["admin1"] == "North Carolina" + + @pytest.mark.parametrize("country", ["US", "us", "USA", "United States"]) + def test_country_spellings_match(self, country): + best = pick_geocode_result(self.RESULTS, "", country) + assert best["country_code"] == "US" + + def test_an_unknown_state_falls_back_to_the_country(self): + assert pick_geocode_result(self.RESULTS, "Ontario", "US")["admin1"] == "Michigan" + + def test_with_nothing_to_match_the_first_hit_is_used(self): + assert pick_geocode_result(self.RESULTS, "", "")["admin1"] == "Michigan" + + def test_no_results(self): + assert pick_geocode_result([], "NC", "US") is None + + +def test_location_field_ids_reads_both_schema_shapes(): + assert location_field_ids({"fields": [{"typeOf": "location", "id": "a"}]}) == ["a"] + assert location_field_ids({"schema": [{"type": "Location", "id": "b"}, + {"typeOf": "location_based", "id": "c"}]}) == ["b"] + + +def test_parse_location_accepts_a_dict(): + assert parse_location({"lat": 1, "lng": 2}) == {"lat": 1, "lng": 2} + assert parse_location({"lat": "x", "lng": 2}) is None + + +# --------------------------------------------------------------------------- +# The display plugin's render path +# --------------------------------------------------------------------------- + +PLUGIN_DIR = Path(__file__).resolve().parent.parent / "plugin-repos" / "starlark-apps" + + +@pytest.fixture(scope="module") +def manager_module(): + if not PLUGIN_DIR.exists(): + pytest.skip("starlark-apps plugin is not checked out") + sys.path.insert(0, str(PLUGIN_DIR)) + # See test_starlark_display_contract.py: fcntl is POSIX-only and unused here. + injected_fcntl = "fcntl" not in sys.modules + if injected_fcntl: + stub = types.ModuleType("fcntl") + stub.LOCK_EX, stub.LOCK_UN = 2, 8 + stub.flock = lambda *a, **kw: None + sys.modules["fcntl"] = stub + try: + spec = importlib.util.spec_from_file_location( + "starlark_manager_location_test", PLUGIN_DIR / "manager.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + except Exception as e: # noqa: BLE001 - optional deps may be absent + pytest.skip(f"starlark-apps manager is not importable here: {e}") + finally: + sys.path.remove(str(PLUGIN_DIR)) + if injected_fcntl: + sys.modules.pop("fcntl", None) + + +def _render(manager_module, tmp_path, app_config, geocoder): + """Run _render_app and return the config Pixlet was handed.""" + plugin_cls = manager_module.StarlarkAppsPlugin + plugin = plugin_cls.__new__(plugin_cls) + plugin.logger = MagicMock() + plugin.config = {} + plugin.calculated_magnify = 1 + plugin.pixlet = MagicMock() + plugin.pixlet.render.return_value = (True, None) + plugin._load_frames_from_cache = MagicMock(return_value=True) + plugin.device_location = resolver(geocoder) + plugin.global_config = DEVICE_CONFIG + + app_cls = manager_module.StarlarkApp + app = app_cls.__new__(app_cls) + app.app_id = "weather" + app.config = dict(app_config) + app.schema = SCHEMA + app.star_file = tmp_path / "weather.star" + app.cache_file = tmp_path / "cached_render.webp" + app.last_render_time = 0 + + assert plugin._render_app(app, force=True) is True + return plugin.pixlet.render.call_args.kwargs["config"], app + + +class TestTheDisplayPluginRender: + def test_an_unset_location_is_rendered_at_the_device_location(self, manager_module, tmp_path): + config, app = _render(manager_module, tmp_path, {"render_interval": 300}, + CountingGeocoder()) + assert json.loads(config["location"])["lat"] == "35.2271" + assert "render_interval" not in config + assert "location" not in app.config, "never written back to the app's config" + + def test_a_saved_location_wins(self, manager_module, tmp_path): + config, _ = _render(manager_module, tmp_path, {"location": SAVED_BROOKLYN}, + CountingGeocoder()) + assert config["location"] == SAVED_BROOKLYN + + def test_geocode_failure_still_renders_with_the_apps_default(self, manager_module, tmp_path): + config, _ = _render(manager_module, tmp_path, {"location": ""}, + CountingGeocoder(error=OSError("down"))) + assert "location" not in config diff --git a/test/web_interface/test_starlark_pixlet_routes.py b/test/web_interface/test_starlark_pixlet_routes.py index 275859f2..ae2ab55c 100644 --- a/test/web_interface/test_starlark_pixlet_routes.py +++ b/test/web_interface/test_starlark_pixlet_routes.py @@ -969,3 +969,59 @@ class TestPixletEditorHostDefaultsButDoesNotOverride: def test_keeps_an_operator_configured_loopback_host(self, client, app_dir, tmp_path): env = self._start(client, app_dir, tmp_path, operator_host='127.0.0.1') assert env['PIXLET_EDITOR_HOST'] == '127.0.0.1' + + +class TestStandaloneRenderUsesTheDeviceLocation: + """The web-service render (plugin not loaded) fills a blank Location field + the same way the display plugin does -- see test/test_device_location.py. + """ + + SCHEMA = {"schema": [{"typeOf": "location", "id": "location"}]} + + @pytest.fixture + def app_dir(self, tmp_path, monkeypatch): + from web_interface.blueprints import api_v3 as module + apps_dir = tmp_path / "starlark-apps" + app_dir = apps_dir / "weather" + app_dir.mkdir(parents=True) + (app_dir / "weather.star").write_text("# app") + (app_dir / "schema.json").write_text(json.dumps(self.SCHEMA)) + monkeypatch.setattr(module, '_STARLARK_APPS_DIR', apps_dir) + monkeypatch.setattr(module, '_STARLARK_MANIFEST_FILE', apps_dir / 'manifest.json') + (apps_dir / 'manifest.json').write_text(json.dumps( + {'apps': {'weather': {'star_file': 'weather.star'}}})) + config_manager = MagicMock() + config_manager.load_config.return_value = { + 'timezone': 'America/New_York', + 'location': {'city': 'Charlotte', 'state': 'North Carolina', 'country': 'US'}, + } + monkeypatch.setattr(module.api_v3, 'config_manager', config_manager, raising=False) + monkeypatch.setattr(module, '_find_pixlet_binary', lambda _p=None: '/usr/bin/pixlet') + from src.device_location import DeviceLocationResolver + geocoder = MagicMock(return_value={'lat': 35.22709, 'lng': -80.84313, + 'timezone': 'America/New_York'}) + monkeypatch.setattr(module, '_starlark_device_location', + DeviceLocationResolver(None, MagicMock(), geocoder)) + return app_dir + + def _render_args(self, app_dir): + from web_interface.blueprints import api_v3 as module + + def fake_run(cmd, **kwargs): + (app_dir / 'cached_render.webp').write_bytes(b'webp') + return MagicMock(returncode=0, stderr='') + + with patch.object(module.subprocess, 'run', side_effect=fake_run) as run: + ok, status, err = module._standalone_render_starlark_app('weather') + assert ok, err + return [a for a in run.call_args.args[0] if a.startswith('location=')] + + def test_a_blank_location_renders_at_the_device_city(self, app_dir): + (app_dir / 'config.json').write_text(json.dumps({'location': ''})) + [arg] = self._render_args(app_dir) + assert json.loads(arg[len('location='):])['lat'] == '35.2271' + + def test_a_saved_location_wins(self, app_dir): + saved = json.dumps({'lat': '40.6782', 'lng': '-73.9442'}) + (app_dir / 'config.json').write_text(json.dumps({'location': saved})) + assert self._render_args(app_dir) == [f'location={saved}'] diff --git a/web_interface/blueprints/api_v3/__init__.py b/web_interface/blueprints/api_v3/__init__.py index aa7f2d03..6d26a980 100644 --- a/web_interface/blueprints/api_v3/__init__.py +++ b/web_interface/blueprints/api_v3/__init__.py @@ -49,6 +49,7 @@ from src.web_interface.validators import ( from src.error_aggregator import get_error_aggregator from src.common.permission_utils import install_requirements_file from src.common.path_safety import resolve_under +from src.device_location import DeviceLocationResolver, apply_device_location _SUDO = shutil.which('sudo') _JOURNALCTL = shutil.which('journalctl') _GIT = shutil.which('git') @@ -1703,6 +1704,32 @@ def _validate_starlark_app_path(app_id: str) -> Tuple[Optional[Path], Optional[s except OSError as e: logger.warning("Path validation error for app_id %r: %s", app_id, e) return None, "Invalid app_id" +_starlark_device_location: Optional[DeviceLocationResolver] = None + + +def _get_starlark_device_location() -> DeviceLocationResolver: + """One resolver per process, so a geocode failure's backoff is shared.""" + global _starlark_device_location + if _starlark_device_location is None: + _starlark_device_location = DeviceLocationResolver( + getattr(api_v3, 'cache_manager', None) or _ensure_cache_manager(), logger) + return _starlark_device_location + + +def _read_starlark_schema(app_dir: Path) -> Optional[Dict[str, Any]]: + """An installed app's schema.json, or None if it has none or it's unreadable.""" + schema_file = app_dir / 'schema.json' + if not schema_file.exists(): + return None + try: + with open(schema_file) as f: + schema = json.load(f) + except (OSError, json.JSONDecodeError) as e: + logger.warning("Could not read schema.json at %s: %s", schema_file, e) + return None + return schema if isinstance(schema, dict) else None + + def _standalone_render_starlark_app(app_id: str) -> Tuple[bool, int, Optional[str]]: """Render a Starlark app via pixlet directly (no plugin required). @@ -1774,6 +1801,9 @@ def _standalone_render_starlark_app(app_id: str) -> Tuple[bool, int, Optional[st INTERNAL_KEYS = {'render_interval', 'display_duration'} pixlet_config = {k: v for k, v in app_config.items() if k not in INTERNAL_KEYS} + pixlet_config = apply_device_location( + pixlet_config, _read_starlark_schema(app_dir), + _get_starlark_device_location(), full_config) output_path = str(app_dir / 'cached_render.webp') cmd = [pixlet_path, 'render', str(star_file)] diff --git a/web_interface/templates/v3/partials/starlark_config.html b/web_interface/templates/v3/partials/starlark_config.html index bd7af5f6..e099ce1a 100644 --- a/web_interface/templates/v3/partials/starlark_config.html +++ b/web_interface/templates/v3/partials/starlark_config.html @@ -221,6 +221,7 @@ data-starlark-location-key="timezone"> +
Leave latitude and longitude blank to use this device's location (City / State / Country in General settings). If no city is set there, or it can't be looked up, the app uses its own default.
{% if field_desc %}{{ field_desc }}
{% endif %} @@ -421,9 +422,10 @@ function saveStarlarkConfig(appId) { var locKey = sub.getAttribute('data-starlark-location-key'); if (sub.value) loc[locKey] = sub.value; }); - if (Object.keys(loc).length > 0) { - config[fieldId] = JSON.stringify(loc); - } + // Blank still has to be sent: the save merges into the stored config, + // so leaving the key out kept the old location with no way to clear + // it back to the device's. + config[fieldId] = Object.keys(loc).length > 0 ? JSON.stringify(loc) : ''; }); fetch('/api/v3/starlark/apps/' + encodeURIComponent(appId) + '/config', {