mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-10 17:16:36 +00:00
fix(starlark): blank app locations use the device location, not San Francisco
A Starlark (Tidbyt) app whose Location field is blank rendered at its author's hard-coded DEFAULT_LOCATION -- usually San Francisco -- even with the device city set under General settings. A user in Charlotte, NC got San Francisco weather and radar with nothing in config.json to explain it. src/device_location.py fills unset location fields at render time (display plugin and the web standalone render): the device city is geocoded once via Open-Meteo, preferring a match in the configured state/country, and cached permanently. A saved location always wins; if the lookup fails the field is dropped so the app uses its own default, and the failure is not retried for 30 minutes. Also fixes the config form: clearing a location omitted the key, and the save merges, so the old value could never be removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,12 @@ 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. 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.
|
||||||
|
|
||||||
## 3.5.0
|
## 3.5.0
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ tooling against it.
|
|||||||
| `web_display_autostart` | bool, `true` | Whether the web interface service starts with the system | `scripts/utils/start_web_conditionally.py` |
|
| `web_display_autostart` | bool, `true` | Whether the web interface service starts with the system | `scripts/utils/start_web_conditionally.py` |
|
||||||
| `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. | `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,11 @@ 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). Without that, most community apps fall back to their
|
||||||
|
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
|
||||||
|
|||||||
@@ -18,6 +18,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
|
||||||
|
|
||||||
@@ -228,6 +229,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")
|
||||||
@@ -816,6 +821,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')
|
||||||
@@ -1719,6 +1720,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).
|
||||||
|
|
||||||
@@ -1790,6 +1817,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).</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