mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
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>
This commit is contained in:
@@ -25,6 +25,14 @@ accepts both, but the store flags the old spelling as deprecated
|
|||||||
that will now render the font it asked for.
|
that will now render the font it asked for.
|
||||||
- `src.wifi_manager.get_wifi_status_path()` — where WiFi status messages for
|
- `src.wifi_manager.get_wifi_status_path()` — where WiFi status messages for
|
||||||
the display are written (`config/wifi_status.json`).
|
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
|
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
|
`docs/PLUGIN_API_REFERENCE.md#deprecated-apis` for replacements). Nothing in
|
||||||
|
|||||||
@@ -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()`) |
|
| `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()` |
|
| `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` |
|
| `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
|
## `schedule` — display on/off hours
|
||||||
|
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ Verify installation:
|
|||||||
3. Filter by category: Weather, Sports, Finance, Games, Clocks, etc.
|
3. Filter by category: Weather, Sports, Finance, Games, Clocks, etc.
|
||||||
4. Click **Install** on desired apps
|
4. Click **Install** on desired apps
|
||||||
5. Configure each app:
|
5. Configure each app:
|
||||||
- Set location/timezone
|
- Set location/timezone (optional: blank uses this device's location)
|
||||||
- Enter API keys if required
|
- Enter API keys if required
|
||||||
- Customize display preferences
|
- Customize display preferences
|
||||||
|
|
||||||
@@ -137,7 +137,12 @@ Each app may have different configuration options:
|
|||||||
|
|
||||||
#### Common Configuration Types
|
#### 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
|
- **API Keys**: For services like weather, stocks, sports scores
|
||||||
- **Display Preferences**: Colors, units, layouts
|
- **Display Preferences**: Colors, units, layouts
|
||||||
- **Dropdown Options**: Team selections, language, themes
|
- **Dropdown Options**: Team selections, language, themes
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from PIL import Image
|
|||||||
|
|
||||||
from src.plugin_system.base_plugin import BasePlugin, VegasDisplayMode
|
from src.plugin_system.base_plugin import BasePlugin, VegasDisplayMode
|
||||||
from src.logging_config import get_logger
|
from src.logging_config import get_logger
|
||||||
|
from src.device_location import DeviceLocationResolver, apply_device_location
|
||||||
from pixlet_renderer import PixletRenderer
|
from pixlet_renderer import PixletRenderer
|
||||||
from frame_extractor import FrameExtractor
|
from frame_extractor import FrameExtractor
|
||||||
|
|
||||||
@@ -229,6 +230,10 @@ class StarlarkAppsPlugin(BasePlugin):
|
|||||||
self.current_app: Optional[StarlarkApp] = None
|
self.current_app: Optional[StarlarkApp] = None
|
||||||
self.last_update_check = 0
|
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
|
# Check Pixlet availability
|
||||||
if not self.pixlet.is_available():
|
if not self.pixlet.is_available():
|
||||||
self.logger.error("Pixlet not available - Starlark apps will not work")
|
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
|
# Filter out LEDMatrix-internal timing/sizing keys before passing to pixlet
|
||||||
INTERNAL_KEYS = {'render_interval', 'display_duration', 'render_width', 'render_height'}
|
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}
|
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(
|
success, error = self.pixlet.render(
|
||||||
star_file=str(app.star_file),
|
star_file=str(app.star_file),
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -969,3 +969,59 @@ class TestPixletEditorHostDefaultsButDoesNotOverride:
|
|||||||
def test_keeps_an_operator_configured_loopback_host(self, client, app_dir, tmp_path):
|
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')
|
env = self._start(client, app_dir, tmp_path, operator_host='127.0.0.1')
|
||||||
assert env['PIXLET_EDITOR_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}']
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ from src.web_interface.validators import (
|
|||||||
from src.error_aggregator import get_error_aggregator
|
from src.error_aggregator import get_error_aggregator
|
||||||
from src.common.permission_utils import install_requirements_file
|
from src.common.permission_utils import install_requirements_file
|
||||||
from src.common.path_safety import resolve_under
|
from src.common.path_safety import resolve_under
|
||||||
|
from src.device_location import DeviceLocationResolver, apply_device_location
|
||||||
_SUDO = shutil.which('sudo')
|
_SUDO = shutil.which('sudo')
|
||||||
_JOURNALCTL = shutil.which('journalctl')
|
_JOURNALCTL = shutil.which('journalctl')
|
||||||
_GIT = shutil.which('git')
|
_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:
|
except OSError as e:
|
||||||
logger.warning("Path validation error for app_id %r: %s", app_id, e)
|
logger.warning("Path validation error for app_id %r: %s", app_id, e)
|
||||||
return None, "Invalid app_id"
|
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]]:
|
def _standalone_render_starlark_app(app_id: str) -> Tuple[bool, int, Optional[str]]:
|
||||||
"""Render a Starlark app via pixlet directly (no plugin required).
|
"""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'}
|
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 = {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')
|
output_path = str(app_dir / 'cached_render.webp')
|
||||||
cmd = [pixlet_path, 'render', str(star_file)]
|
cmd = [pixlet_path, 'render', str(star_file)]
|
||||||
|
|||||||
@@ -221,6 +221,7 @@
|
|||||||
data-starlark-location-key="timezone">
|
data-starlark-location-key="timezone">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">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.</p>
|
||||||
{% if field_desc %}
|
{% if field_desc %}
|
||||||
<p class="text-xs text-gray-400 mt-1">{{ field_desc }}</p>
|
<p class="text-xs text-gray-400 mt-1">{{ field_desc }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -421,9 +422,10 @@ function saveStarlarkConfig(appId) {
|
|||||||
var locKey = sub.getAttribute('data-starlark-location-key');
|
var locKey = sub.getAttribute('data-starlark-location-key');
|
||||||
if (sub.value) loc[locKey] = sub.value;
|
if (sub.value) loc[locKey] = sub.value;
|
||||||
});
|
});
|
||||||
if (Object.keys(loc).length > 0) {
|
// Blank still has to be sent: the save merges into the stored config,
|
||||||
config[fieldId] = JSON.stringify(loc);
|
// 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', {
|
fetch('/api/v3/starlark/apps/' + encodeURIComponent(appId) + '/config', {
|
||||||
|
|||||||
Reference in New Issue
Block a user