fix: web UI and src.common bugs (wifi wrong-password, plugin icon, starlark toggle, API caching, scroll/logo/font helpers) (#646)

- wifi: keep the "wrong_password:" prefix through the restore/AP fallback so
  the UI's incorrect-password prompt fires again.
- /plugins/installed returns the manifest's icon (string only).
- /starlark/apps/<id>/toggle coerces `enabled` and delegates to
  _toggle_starlark_app (disk before memory, no KeyError, "false" is false).
- /api/v3/ JSON GETs are sent Cache-Control: no-store; non-JSON keeps 5s.
- ScrollHelper.set_scrolling_image converts non-RGB input (alpha onto black);
  create/set_scrolling_image reset last_update_time like reset_scroll.
- LogoHelper backs off a failed download per path for
  MISSING_LOGO_RECHECK_SECONDS; cleared on invalidate/clear_cache.
- refresh_placeholder_timestamp saves atomically.
- FontManager.clear_cache / _clear_plugin_font_cache bump cache_generation.
- Odds manager: per-game logs to DEBUG; JSON decode error caught before
  RequestException (same cooldown).
- element_style mangled continuations; startup validator skips null plugin
  blocks and reuses the controller's discovery.
- src/common/README lists frame_timing, json_body, render_gate.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-28 08:26:05 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent b518c51679
commit 7eb7a58d0c
27 changed files with 764 additions and 64 deletions
+12
View File
@@ -37,6 +37,18 @@ accepts both, but the store flags the old spelling as deprecated
- `check_system_compatibility.sh` no longer reports installed packages as missing. - `check_system_compatibility.sh` no longer reports installed packages as missing.
- A network failure fetching GitHub repo info logs a warning, not an error. - A network failure fetching GitHub repo info logs a warning, not an error.
- Web UI and `src.common` fixes:
- A wrong Wi-Fi password is reported as one again ("Incorrect password for ..."); the fallback that restores the old network or brings up the setup AP was replacing the signal.
- Plugin tabs show the manifest's `icon`: `/api/v3/plugins/installed` now includes it.
- `POST /api/v3/starlark/apps/<id>/toggle` goes through the same code as `/plugins/toggle`: `"false"` disables, a failed save no longer leaves the running app out of step with disk, and a loaded app with no manifest entry no longer answers 500.
- `/api/v3/` JSON responses are sent `Cache-Control: no-store`, so a reload right after an install, toggle or Wi-Fi connect shows the new state. Non-JSON files served through the API keep the 5 s cache.
- `ScrollHelper.set_scrolling_image()` accepts RGBA, L and palette images (transparent pixels become black), and a new scrolling image no longer jumps ahead by the time the helper sat idle.
- `LogoHelper.load_logo_with_download()` waits an hour before retrying a download that failed for a missing logo, instead of retrying (with a 30 s timeout) on every call.
- Restamping a placeholder logo writes the file atomically.
- `FontManager.clear_cache()` and unregistering a plugin's fonts bump `cache_generation`, so cached layouts are rebuilt.
- The odds manager logs cache hits, misses and fetches at DEBUG, and a bad JSON body is logged as a parse error rather than a failed fetch.
- Startup plugin validation no longer gives up on a `null` plugin block, and plugins are discovered once at startup instead of twice.
- `src/common/README.md` lists `frame_timing`, `json_body` and `render_gate`.
- Plugin system: - Plugin system:
- A plugin whose `on_enable()` raises is no longer left registered: the next load retries it instead of reporting "already loaded" for a plugin that never ran. - A plugin whose `on_enable()` raises is no longer left registered: the next load retries it instead of reporting "already loaded" for a plugin that never ran.
- One plugin's `get_info()` raising no longer breaks the installed-plugins list; it is logged and shown with empty runtime info. - One plugin's `get_info()` raising no longer breaks the installed-plugins list; it is logged and shown with empty runtime info.
+5 -7
View File
@@ -5,11 +5,9 @@
A plugin can name an icon for its tab in the web interface's second nav row A plugin can name an icon for its tab in the web interface's second nav row
(next to **Plugin Manager**) with the `icon` field in `manifest.json`. (next to **Plugin Manager**) with the `icon` field in `manifest.json`.
> **Status:** the tab code honors `icon`, but `GET /api/v3/plugins/installed` `GET /api/v3/plugins/installed` passes the manifest's `icon` through (a
> (`web_interface/blueprints/api_v3/plugins.py`) does not currently include non-string value comes back as `null`), and a plugin without one gets the
> the manifest's `icon` in its response, so every tab shows the default default puzzle piece.
> puzzle piece. Setting `icon` is harmless and will take effect once the API
> passes it through again.
## Font Awesome classes only ## Font Awesome classes only
@@ -55,8 +53,8 @@ With no `icon` (or an empty one) the tab shows `fas fa-puzzle-piece`.
or misspelled class renders as a blank space. or misspelled class renders as a blank space.
2. Include the style prefix (`fas`, `far` or `fab`) as well as the icon 2. Include the style prefix (`fas`, `far` or `fab`) as well as the icon
class. class.
3. See the status note above: the icon is currently not passed through by 3. The manifest is re-read on each plugin list load; reload the page after
the API. editing `icon`.
## Related Documentation ## Related Documentation
+14 -7
View File
@@ -123,8 +123,10 @@ class BaseOddsManager:
# Check cache first # Check cache first
cached_data = self.cache_manager.get_with_auto_strategy(cache_key) cached_data = self.cache_manager.get_with_auto_strategy(cache_key)
# Per-game chatter, logged on every update of every game on the
# slate: debug, not the journal.
if cached_data: if cached_data:
self.logger.info(f"Using cached odds from ESPN for {cache_key}") self.logger.debug(f"Using cached odds from ESPN for {cache_key}")
return cached_data return cached_data
if time.monotonic() < self._skip_network_until: if time.monotonic() < self._skip_network_until:
@@ -137,7 +139,7 @@ class BaseOddsManager:
self._skip_network_until - time.monotonic()) self._skip_network_until - time.monotonic())
return None return None
self.logger.info(f"Cache miss - fetching fresh odds from ESPN for {cache_key}") self.logger.debug(f"Cache miss - fetching fresh odds from ESPN for {cache_key}")
try: try:
# Map league names to ESPN API format # Map league names to ESPN API format
@@ -151,7 +153,7 @@ class BaseOddsManager:
espn_league = league_mapping.get(league, league) espn_league = league_mapping.get(league, league)
url = f"{self.base_url}/{sport}/leagues/{espn_league}/events/{event_id}/competitions/{event_id}/odds" url = f"{self.base_url}/{sport}/leagues/{espn_league}/events/{event_id}/competitions/{event_id}/odds"
self.logger.info(f"Requesting odds from URL: {url}") self.logger.debug(f"Requesting odds from URL: {url}")
response = self.session.get(url, timeout=self.request_timeout) response = self.session.get(url, timeout=self.request_timeout)
response.raise_for_status() response.raise_for_status()
@@ -163,9 +165,9 @@ class BaseOddsManager:
odds_data = self._extract_espn_data(raw_data) odds_data = self._extract_espn_data(raw_data)
if odds_data: if odds_data:
self.logger.info(f"Successfully extracted odds data: {odds_data}") self.logger.debug(f"Successfully extracted odds data: {odds_data}")
self.cache_manager.set(cache_key, odds_data, ttl=interval) self.cache_manager.set(cache_key, odds_data, ttl=interval)
self.logger.info(f"Saved odds data to cache for {cache_key} with TTL {interval}s") self.logger.debug(f"Saved odds data to cache for {cache_key} with TTL {interval}s")
else: else:
self.logger.debug(f"No odds data available for {cache_key}") self.logger.debug(f"No odds data available for {cache_key}")
# Cache the absence too, so the game is not re-requested # Cache the absence too, so the game is not re-requested
@@ -174,14 +176,19 @@ class BaseOddsManager:
return odds_data return odds_data
# Before RequestException: requests' JSONDecodeError subclasses it, so
# listed second this branch never ran and a bad body was reported as a
# failed fetch. It holds off like a failed fetch did, so only the
# message changes.
except (json.JSONDecodeError, requests.exceptions.JSONDecodeError):
self._skip_network_until = time.monotonic() + self._FAILURE_COOLDOWN
self.logger.error(f"Error decoding JSON response from ESPN API for {cache_key}.")
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
self._skip_network_until = time.monotonic() + self._FAILURE_COOLDOWN self._skip_network_until = time.monotonic() + self._FAILURE_COOLDOWN
self.logger.error( self.logger.error(
"Error fetching odds from ESPN API for %s: %s. Holding off on odds " "Error fetching odds from ESPN API for %s: %s. Holding off on odds "
"for %.0fs so a slate of games does not pay this timeout each.", "for %.0fs so a slate of games does not pay this timeout each.",
cache_key, e, self._FAILURE_COOLDOWN) cache_key, e, self._FAILURE_COOLDOWN)
except json.JSONDecodeError:
self.logger.error(f"Error decoding JSON response from ESPN API for {cache_key}.")
return self.cache_manager.get_with_auto_strategy(cache_key) return self.cache_manager.get_with_auto_strategy(cache_key)
+29
View File
@@ -27,9 +27,12 @@ Rules for the package:
| [`bdf_font`](#bdf_font) | Load and draw BDF bitmap fonts | Yes, if drawing BDF text directly | Unreleased | | [`bdf_font`](#bdf_font) | Load and draw BDF bitmap fonts | Yes, if drawing BDF text directly | Unreleased |
| [`espn_dates`](#espn_dates) | Fetch ESPN scoreboards across a date range | Yes (scoreboards) | 3.5.0 | | [`espn_dates`](#espn_dates) | Fetch ESPN scoreboards across a date range | Yes (scoreboards) | 3.5.0 |
| [`font_layout`](#font_layout) | Reproducible TrueType loading, crisp sizes | Yes | 3.4.0 | | [`font_layout`](#font_layout) | Reproducible TrueType loading, crisp sizes | Yes | 3.4.0 |
| [`frame_timing`](#frame_timing) | Timing of every presented frame, stall watchdog | No, core-internal | n/a |
| [`json_body`](#json_body) | Parse a response body as JSON, with orjson if installed | Optional (large payloads) | Unreleased |
| [`logo_helper`](#logo_helper) | Load, resize and cache team logos | Yes | — | | [`logo_helper`](#logo_helper) | Load, resize and cache team logos | Yes | — |
| [`path_safety`](#path_safety) | Turn request-supplied names into safe paths | No, core-internal | n/a | | [`path_safety`](#path_safety) | Turn request-supplied names into safe paths | No, core-internal | n/a |
| [`permission_utils`](#permission_utils) | File modes and shared-group ownership | Rarely | — | | [`permission_utils`](#permission_utils) | File modes and shared-group ownership | Rarely | — |
| [`render_gate`](#render_gate) | Keep background Python off the GIL while the panel swaps | No, core-internal | n/a |
| [`scroll_config`](#scroll_config) | Plugin scroll config → configured `ScrollHelper` | Yes (scrollers) | 3.4.0 | | [`scroll_config`](#scroll_config) | Plugin scroll config → configured `ScrollHelper` | Yes (scrollers) | 3.4.0 |
| [`scroll_helper`](#scroll_helper) | Pre-rendered horizontal scrolling | Yes | — | | [`scroll_helper`](#scroll_helper) | Pre-rendered horizontal scrolling | Yes | — |
| [`snapshot_policy`](#snapshot_policy) | When to write the web preview frame | No, core-internal | n/a | | [`snapshot_policy`](#snapshot_policy) | When to write the web preview frame | No, core-internal | n/a |
@@ -107,6 +110,24 @@ the size a bundled face renders on whole pixels at. `resolve_asset_path()`
resolves `assets/fonts/...` against the install root rather than the resolves `assets/fonts/...` against the install root rather than the
working directory. working directory.
### frame_timing
[`frame_timing.py`](frame_timing.py). Core-internal. `DisplayManager`
records every presented frame in a `FrameTimingRecorder`, which writes
cumulative late-frame counters and histograms to `/dev/shm` for
`scripts/frame_soak.py` and `scripts/render_bench.py`. `StallWatchdog` logs
the stack of whatever holds up a scroll. See
[docs/SCROLL_PERFORMANCE.md](../../docs/SCROLL_PERFORMANCE.md).
### json_body
[`json_body.py`](json_body.py). `response_json(response)` is
`response.json()` parsed by orjson when it is installed, falling back to the
stdlib parser (and requests' own error) otherwise. For multi-MB payloads such
as a season schedule, where the parse holds the GIL and freezes the display.
A plugin that also runs on older cores should guard the import, as
`espn_dates` does.
### logo_helper ### logo_helper
[`logo_helper.py`](logo_helper.py). `LogoHelper(display_width, [`logo_helper.py`](logo_helper.py). `LogoHelper(display_width,
@@ -134,6 +155,14 @@ let the root display service and the web user share files:
already call these; a plugin needs them only when it creates its own files already call these; a plugin needs them only when it creates its own files
outside the cache. See [docs/PERMISSIONS.md](../../docs/PERMISSIONS.md). outside the cache. See [docs/PERMISSIONS.md](../../docs/PERMISSIONS.md).
### render_gate
[`render_gate.py`](render_gate.py). Core-internal. `RenderGate` is opened by
the render thread around each vsync swap; a background thread inside
`gate.yielding()` (Vegas's prefetch) parks while the gate is closed, so the
render thread finds the GIL free when its refresh arrives. It never parks a
thread holding a guarded lock or inside logging, threading or import code.
### scroll_config ### scroll_config
[`scroll_config.py`](scroll_config.py). `configure(scroll_helper, [`scroll_config.py`](scroll_config.py). `configure(scroll_helper,
+14 -1
View File
@@ -80,6 +80,11 @@ class LogoHelper:
# Time-bounded rather than permanent so a logo that appears later (the # Time-bounded rather than permanent so a logo that appears later (the
# downloader writes them at runtime) is still picked up. # downloader writes them at runtime) is still picked up.
self._missing_logos: Dict[str, float] = {} self._missing_logos: Dict[str, float] = {}
# Failed downloads by logo path. A logo that is absent (not a stale
# placeholder) has no on-disk timestamp to back off on, so without this
# every call retried the download -- up to a 30s timeout each time.
self._download_failures: Dict[str, float] = {}
# Session for HTTP requests # Session for HTTP requests
self.session = requests.Session() self.session = requests.Session()
@@ -204,7 +209,12 @@ class LogoHelper:
return self.load_logo(team_abbr, logo_path, max_width, max_height, return self.load_logo(team_abbr, logo_path, max_width, max_height,
scale) scale)
# Download if URL provided and file doesn't exist # Download if URL provided and file doesn't exist, unless the last
# attempt for this path failed recently.
failed_at = self._download_failures.get(str(logo_path))
if (logo_url and failed_at is not None
and time.time() - failed_at < MISSING_LOGO_RECHECK_SECONDS):
logo_url = None
if logo_url: if logo_url:
try: try:
self.logger.info(f"Downloading logo for {team_abbr} from {logo_url}") self.logger.info(f"Downloading logo for {team_abbr} from {logo_url}")
@@ -218,6 +228,7 @@ class LogoHelper:
scale) scale)
except Exception as e: except Exception as e:
self.logger.error(f"Failed to download logo for {team_abbr}: {e}") self.logger.error(f"Failed to download logo for {team_abbr}: {e}")
self._download_failures[str(logo_path)] = time.time()
# The retry failed, so restart the back-off. The stale # The retry failed, so restart the back-off. The stale
# placeholder is still on disk with its old timestamp, and # placeholder is still on disk with its old timestamp, and
# leaving it there means the next call retries immediately -- # leaving it there means the next call retries immediately --
@@ -240,6 +251,7 @@ class LogoHelper:
# leaving it would hide a logo we just downloaded. # leaving it would hide a logo we just downloaded.
for key in [k for k in self._missing_logos if k.startswith(prefix)]: for key in [k for k in self._missing_logos if k.startswith(prefix)]:
del self._missing_logos[key] del self._missing_logos[key]
self._download_failures.pop(str(logo_path), None)
@staticmethod @staticmethod
def _refresh_stale_placeholder(logo_path: Path) -> None: def _refresh_stale_placeholder(logo_path: Path) -> None:
@@ -332,6 +344,7 @@ class LogoHelper:
self._logo_cache.clear() self._logo_cache.clear()
self._cache_order.clear() self._cache_order.clear()
self._missing_logos.clear() self._missing_logos.clear()
self._download_failures.clear()
self.logger.debug("Logo cache cleared") self.logger.debug("Logo cache cleared")
def get_cache_stats(self) -> Dict[str, int]: def get_cache_stats(self) -> Dict[str, int]:
+18
View File
@@ -254,6 +254,9 @@ class ScrollHelper:
now = time.time() now = time.time()
self.scroll_start_time = now self.scroll_start_time = now
self.last_progress_log_time = now self.last_progress_log_time = now
# The position just went back to 0; the first update must not advance
# it by however long the helper sat idle (off-screen) before this.
self.last_update_time = now
self.logger.info( self.logger.info(
"Dynamic duration target set to %ds (min=%ds, max=%ds, buffer=%.2f)", "Dynamic duration target set to %ds (min=%ds, max=%ds, buffer=%.2f)",
self.calculated_duration, self.calculated_duration,
@@ -776,6 +779,18 @@ class ScrollHelper:
self.clear_cache() self.clear_cache()
return return
# Every frame is cut from cached_array with Image.frombytes('RGB', ...),
# which reads a 4-channel (RGBA) array as garbage and raises on a
# 1-channel (L) one. Transparent pixels go to black, the panel's
# background, rather than to whatever colour hides under the alpha.
if image.mode != 'RGB':
if 'A' in image.mode or 'transparency' in image.info:
rgba = image.convert('RGBA')
image = Image.new('RGB', rgba.size, (0, 0, 0))
image.paste(rgba, (0, 0), rgba)
else:
image = image.convert('RGB')
# Set the cached image # Set the cached image
self.cached_image = image self.cached_image = image
@@ -802,6 +817,9 @@ class ScrollHelper:
self.scroll_start_time = now self.scroll_start_time = now
self.last_progress_log_time = now self.last_progress_log_time = now
self.last_step_time = now # Initialize step timer for frame-based scrolling self.last_step_time = now # Initialize step timer for frame-based scrolling
# The position just went back to 0; the first update must not advance
# it by however long the helper sat idle before this image arrived.
self.last_update_time = now
self.logger.debug("Set scrolling image: %dx%d, total_scroll_width=%d", self.logger.debug("Set scrolling image: %dx%d, total_scroll_width=%d",
image.width, image.height, self.total_scroll_width) image.width, image.height, self.total_scroll_width)
+7 -5
View File
@@ -318,13 +318,19 @@ class DisplayController:
except Exception as e: except Exception as e:
logger.warning("Could not enable plugin health/resource monitoring: %s", e) logger.warning("Could not enable plugin health/resource monitoring: %s", e)
# Discover plugins. Before the plugin checks so they can reuse the
# list: each discover_plugins() call rescans the plugins directory
# and logs every plugin again.
discovered_plugins = self.plugin_manager.discover_plugins()
logger.info("Discovered %d plugin(s)", len(discovered_plugins))
# Only the plugin checks: validate_all() above has run the rest, # Only the plugin checks: validate_all() above has run the rest,
# and running it again logged every config warning twice. # and running it again logged every config warning twice.
try: try:
from src.startup_validator import StartupValidator from src.startup_validator import StartupValidator
validator = StartupValidator(self.config_manager, self.plugin_manager, validator = StartupValidator(self.config_manager, self.plugin_manager,
cache_manager=self.cache_manager) cache_manager=self.cache_manager)
validator._validate_plugins() validator._validate_plugins(discovered_plugins=discovered_plugins)
for warning in validator.warnings: for warning in validator.warnings:
logger.warning("Plugin validation warning: %s", warning) logger.warning("Plugin validation warning: %s", warning)
if validator.errors: if validator.errors:
@@ -333,10 +339,6 @@ class DisplayController:
except Exception as e: except Exception as e:
logger.warning("Plugin validation could not be completed: %s", e) logger.warning("Plugin validation could not be completed: %s", e)
# Discover plugins
discovered_plugins = self.plugin_manager.discover_plugins()
logger.info("Discovered %d plugin(s)", len(discovered_plugins))
# Check for on-demand plugin filter from cache # Check for on-demand plugin filter from cache
on_demand_config = self.cache_manager.get('display_on_demand_config', max_age=3600) on_demand_config = self.cache_manager.get('display_on_demand_config', max_age=3600)
enabled_plugins = self._select_startup_plugins(discovered_plugins, on_demand_config) enabled_plugins = self._select_startup_plugins(discovered_plugins, on_demand_config)
+2 -2
View File
@@ -840,7 +840,7 @@ def defaults_from_schema(schema: Dict[str, Any]) -> Dict[str, Any]:
defaults['align'] = align_spec['default'] defaults['align'] = align_spec['default']
scale_spec = spec.get('scale') scale_spec = spec.get('scale')
if isinstance(scale_spec, dict) and 'default' in scale_spec: if isinstance(scale_spec, dict) and 'default' in scale_spec:
layout.setdefault(element_key, {})['scale'] = scale_spec['default'] layout.setdefault(element_key, {})['scale'] = scale_spec['default']
elif scale_spec is True: elif scale_spec is True:
layout.setdefault(element_key, {})['scale'] = 1.0 layout.setdefault(element_key, {})['scale'] = 1.0
if defaults: if defaults:
@@ -856,7 +856,7 @@ def defaults_from_schema(schema: Dict[str, Any]) -> Dict[str, Any]:
continue continue
scale_prop = (block.get('properties') or {}).get('scale') scale_prop = (block.get('properties') or {}).get('scale')
if isinstance(scale_prop, dict) and 'default' in scale_prop: if isinstance(scale_prop, dict) and 'default' in scale_prop:
layout.setdefault(element_key, {})['scale'] = scale_prop['default'] layout.setdefault(element_key, {})['scale'] = scale_prop['default']
for element_key, block in properties.items(): for element_key, block in properties.items():
if element_key in ('layout', 'modes') or element_key in elements: if element_key in ('layout', 'modes') or element_key in elements:
continue continue
+6
View File
@@ -426,6 +426,9 @@ class FontManager:
keys_to_remove = [key for key in self.font_cache.keys() if key.startswith(f"{plugin_id}::")] keys_to_remove = [key for key in self.font_cache.keys() if key.startswith(f"{plugin_id}::")]
for key in keys_to_remove: for key in keys_to_remove:
del self.font_cache[key] del self.font_cache[key]
if keys_to_remove:
# Font objects someone may hold were dropped; see cache_generation.
self.cache_generation += 1
@deprecated("3.7.0") @deprecated("3.7.0")
def get_plugin_fonts(self, plugin_id: str) -> List[str]: def get_plugin_fonts(self, plugin_id: str) -> List[str]:
@@ -732,6 +735,9 @@ class FontManager:
"""Clear font and metrics cache.""" """Clear font and metrics cache."""
self.font_cache.clear() self.font_cache.clear()
self.metrics_cache.clear() self.metrics_cache.clear()
# Holders of derived caches (layout fits, font usage) key off this;
# without the bump they kept serving results for the dropped fonts.
self.cache_generation += 1
logger.info("Font cache cleared") logger.info("Font cache cleared")
@deprecated("3.7.0", "read font_catalog") @deprecated("3.7.0", "read font_catalog")
+4 -1
View File
@@ -235,7 +235,10 @@ def refresh_placeholder_timestamp(filepath: Path) -> bool:
metadata = PngInfo() metadata = PngInfo()
metadata.add_text(PLACEHOLDER_MARKER, str(time.time())) metadata.add_text(PLACEHOLDER_MARKER, str(time.time()))
with Image.open(filepath) as img: with Image.open(filepath) as img:
img.copy().save(filepath, "PNG", pnginfo=metadata) image = img.copy()
# Atomically, like every other logo write: a renderer can open this
# file at any moment, and an in-place save exposes a truncated PNG.
save_png_atomically(image, filepath, pnginfo=metadata)
return True return True
except Exception: except Exception:
logger.debug("Could not refresh placeholder timestamp for %s", filepath, logger.debug("Could not refresh placeholder timestamp for %s", filepath,
+14 -4
View File
@@ -268,15 +268,21 @@ class StartupValidator:
except Exception as e: except Exception as e:
self.warnings.append(f"Could not validate display configuration: {e}") self.warnings.append(f"Could not validate display configuration: {e}")
def _validate_plugins(self) -> None: def _validate_plugins(self, discovered_plugins=None) -> None:
"""Validate plugin configurations and dependencies.""" """Validate plugin configurations and dependencies.
``discovered_plugins`` is a list the caller already got from
``discover_plugins()``; passing it skips a second directory scan (and
its duplicate log lines) at startup.
"""
if not self.plugin_manager: if not self.plugin_manager:
return return
try: try:
# Get enabled plugins from config # Get enabled plugins from config
config = self.config_manager.get_config() config = self.config_manager.get_config()
discovered_plugins = self.plugin_manager.discover_plugins() if discovered_plugins is None:
discovered_plugins = self.plugin_manager.discover_plugins()
# Check for enabled plugins that don't exist # Check for enabled plugins that don't exist
for plugin_id, plugin_config in config.items(): for plugin_id, plugin_config in config.items():
@@ -294,7 +300,11 @@ class StartupValidator:
# Validate plugin configurations # Validate plugin configurations
for plugin_id in discovered_plugins: for plugin_id in discovered_plugins:
plugin_config = config.get(plugin_id, {}) plugin_config = config.get(plugin_id)
# A null block ("my-plugin": null) is not an enabled plugin;
# .get() on it raised and abandoned every remaining check.
if not isinstance(plugin_config, dict):
continue
if plugin_config.get('enabled', False): if plugin_config.get('enabled', False):
# Check if plugin can be loaded (without actually loading it) # Check if plugin can be loaded (without actually loading it)
plugin_dir = self.plugin_manager.get_plugin_directory(plugin_id) plugin_dir = self.plugin_manager.get_plugin_directory(plugin_id)
+14 -5
View File
@@ -1317,6 +1317,15 @@ class WiFiManager:
if self.has_nmcli: if self.has_nmcli:
success, message = self._connect_nmcli(ssid, password) success, message = self._connect_nmcli(ssid, password)
# Keep the "wrong_password:" prefix on whatever failure message
# the recovery below returns: the web UI keys its "incorrect
# password" prompt off it, and the restore/AP messages would
# otherwise erase it.
wrong_password = (not success and isinstance(message, str)
and message.startswith("wrong_password:"))
def _fail(msg):
return False, (f"wrong_password: {msg}" if wrong_password else msg)
# If connection failed, try to restore original connection # If connection failed, try to restore original connection
if not success and original_connection and original_ssid: if not success and original_connection and original_ssid:
@@ -1327,18 +1336,18 @@ class WiFiManager:
if restore_success: if restore_success:
logger.info(f"Successfully restored original connection: {original_ssid}") logger.info(f"Successfully restored original connection: {original_ssid}")
self._show_led_message("Restored!", duration=3) self._show_led_message("Restored!", duration=3)
return False, f"Failed to connect to {ssid}, restored {original_ssid}" return _fail(f"Failed to connect to {ssid}, restored {original_ssid}")
else: else:
logger.error(f"Failed to restore original connection: {original_ssid}") logger.error(f"Failed to restore original connection: {original_ssid}")
return self._failsafe_ap( return _fail(self._failsafe_ap(
"Connection failed and restoration failed. AP mode enabled.", "Connection failed and restoration failed. AP mode enabled.",
"Connection failed, restoration failed, and AP mode failed") "Connection failed, restoration failed, and AP mode failed")[1])
# If connection failed and no original connection to restore, enable AP mode # If connection failed and no original connection to restore, enable AP mode
elif not success: elif not success:
logger.warning(f"Connection to {ssid} failed and no original connection to restore") logger.warning(f"Connection to {ssid} failed and no original connection to restore")
return self._failsafe_ap("Connection failed. AP mode enabled.", return _fail(self._failsafe_ap("Connection failed. AP mode enabled.",
"Connection failed and AP mode failed") "Connection failed and AP mode failed")[1])
return success, message return success, message
else: else:
+46
View File
@@ -0,0 +1,46 @@
"""GET /api/v3/plugins/installed carries the manifest's `icon`.
The tab nav sets ``iconEl.className = plugin.icon || 'fas fa-puzzle-piece'``
(app-shell.js, app-early.js), but the route never included `icon`, so every
plugin tab showed the default puzzle piece.
"""
from unittest.mock import MagicMock
import pytest
from test._api_v3_test_helpers import ( # noqa: F401 - fixtures
api_v3_client, api_v3_module,
)
@pytest.fixture
def installed(api_v3_module, api_v3_client, tmp_path):
def _get(manifest_extra):
api = api_v3_module.api_v3
info = {'id': 'demo', 'name': 'Demo', 'version': '1.0.0', 'loaded': False}
info.update(manifest_extra)
api.plugin_manager.plugins_dir = str(tmp_path) # no manifest on disk
api.plugin_manager.get_all_plugin_info = MagicMock(return_value=[info])
api.plugin_manager.get_plugin = MagicMock(return_value=None)
api.plugin_store_manager.get_registry_info = MagicMock(return_value=None)
api.config_manager.load_config = MagicMock(return_value={})
response = api_v3_client.get('/api/v3/plugins/installed')
assert response.status_code == 200
plugins = [p for p in response.get_json()['data']['plugins'] if p['id'] == 'demo']
assert len(plugins) == 1
return plugins[0]
return _get
def test_the_manifest_icon_is_passed_through(installed):
assert installed({'icon': 'fas fa-cloud-sun'})['icon'] == 'fas fa-cloud-sun'
def test_no_icon_comes_back_as_null(installed):
# The JS falls back to the puzzle piece on a falsy value.
assert installed({})['icon'] is None
def test_a_non_string_icon_is_not_passed_through(installed):
assert installed({'icon': {'class': 'fas fa-star'}})['icon'] is None
+29
View File
@@ -13,6 +13,7 @@ its requests through a session so it can identify itself to ESPN, so patching
the module-level requests.get would no longer intercept anything. the module-level requests.get would no longer intercept anything.
""" """
import logging
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest import pytest
@@ -161,6 +162,34 @@ class TestGetOdds:
assert result == {'stale': True} assert result == {'stale': True}
assert cache_manager.get_with_auto_strategy.call_count == 2 assert cache_manager.get_with_auto_strategy.call_count == 2
def test_a_bad_body_is_reported_as_a_parse_error(
self, manager, cache_manager, mock_get, caplog):
# requests' JSONDecodeError is a RequestException, so the JSON branch
# sat unreachable behind it and a bad body read as a failed fetch.
response = _make_response(None)
response.json.side_effect = requests.exceptions.JSONDecodeError('x', 'doc', 0)
mock_get.return_value = response
cache_manager.get_with_auto_strategy.side_effect = [None, {'stale': True}]
with caplog.at_level(logging.ERROR, logger=manager.logger.name):
result = manager.get_odds('football', 'nfl', '401')
assert result == {'stale': True}
messages = [r.getMessage() for r in caplog.records]
assert any('decoding JSON' in m for m in messages), messages
assert not any('Error fetching odds' in m for m in messages), messages
# The hold-off is unchanged: a bad body still backs off like a failed
# fetch did.
assert manager._skip_network_until > 0
def test_routine_fetches_log_nothing_at_info(
self, manager, cache_manager, mock_get, caplog):
with caplog.at_level(logging.INFO, logger=manager.logger.name):
manager.get_odds('football', 'nfl', '401') # miss + fetch
cache_manager.get_with_auto_strategy.return_value = {'spread': 1}
manager.get_odds('football', 'nfl', '401') # hit
assert [r for r in caplog.records if r.levelno == logging.INFO] == []
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# _extract_espn_data # _extract_espn_data
@@ -0,0 +1,21 @@
"""src/common/README.md says it lists every module; keep it true.
frame_timing, json_body and render_gate landed without rows, so the page a
plugin author reads to see what core offers was missing three modules.
"""
from pathlib import Path
COMMON = Path(__file__).resolve().parent.parent / "src" / "common"
def _modules():
return sorted(p.stem for p in COMMON.glob("*.py") if p.stem != "__init__")
def test_every_module_has_a_table_row_and_a_section():
readme = (COMMON / "README.md").read_text(encoding="utf-8")
missing_rows = [m for m in _modules() if f"| [`{m}`](#{m}) |" not in readme]
missing_sections = [m for m in _modules() if f"\n### {m}\n" not in readme]
assert not missing_rows, f"no summary-table row for: {missing_rows}"
assert not missing_sections, f"no '### <module>' section for: {missing_sections}"
@@ -25,8 +25,9 @@ class RecordingValidator:
self.warnings = ['config warning'] self.warnings = ['config warning']
return True, [], list(self.warnings) return True, [], list(self.warnings)
def _validate_plugins(self): def _validate_plugins(self, discovered_plugins=None):
RecordingValidator.calls.append(('plugins',)) RecordingValidator.calls.append(('plugins',))
RecordingValidator.discovered = discovered_plugins
self.warnings = ['plugin warning'] self.warnings = ['plugin warning']
@@ -75,3 +76,19 @@ def test_each_check_runs_once(mock_config_manager, mock_display_manager,
assert any('plugin warning' in r.getMessage() for r in caplog.records) assert any('plugin warning' in r.getMessage() for r in caplog.records)
finally: finally:
controller.cleanup() controller.cleanup()
def test_plugins_are_discovered_once(mock_config_manager, mock_display_manager,
mock_cache_manager, test_config_with_plugins,
emulator_mode):
"""The plugin checks reuse the controller's discovery rather than rescanning
the plugins directory (and logging every plugin) a second time."""
RecordingValidator.calls = []
RecordingValidator.discovered = None
controller = _build_controller(mock_config_manager, mock_display_manager,
mock_cache_manager, test_config_with_plugins)
try:
assert controller.plugin_manager.discover_plugins.call_count == 1
assert RecordingValidator.discovered == []
finally:
controller.cleanup()
+18
View File
@@ -137,6 +137,24 @@ class TestCacheLifecycle:
assert fm.cache_generation == gen_before + 1 assert fm.cache_generation == gen_before + 1
assert not fm.font_cache assert not fm.font_cache
def test_clear_cache_bumps_generation(self, fm):
# Layout contexts and font-usage results are keyed off
# cache_generation; clear_cache used to drop the fonts without
# telling them.
gen_before = fm.cache_generation
fm.clear_cache()
assert fm.cache_generation == gen_before + 1
def test_clearing_a_plugins_cached_fonts_bumps_generation(self, fm):
fm.font_cache["demo::tiny_8"] = object()
gen_before = fm.cache_generation
fm._clear_plugin_font_cache("demo")
assert "demo::tiny_8" not in fm.font_cache
assert fm.cache_generation == gen_before + 1
# Nothing to drop, nothing to rebuild.
fm._clear_plugin_font_cache("demo")
assert fm.cache_generation == gen_before + 1
class TestPluginFonts: class TestPluginFonts:
"""plugin:// sources resolve against the plugin's own directory, which """plugin:// sources resolve against the plugin's own directory, which
+12
View File
@@ -356,6 +356,18 @@ class TestRefreshPlaceholderTimestamp:
def test_missing_file_is_not_an_error(self, tmp_path): def test_missing_file_is_not_an_error(self, tmp_path):
assert refresh_placeholder_timestamp(tmp_path / "nope.png") is False assert refresh_placeholder_timestamp(tmp_path / "nope.png") is False
def test_the_restamp_is_atomic(self, tmp_path):
# It used to save over the file in place, so a renderer opening the
# logo mid-write read a truncated PNG. A failed write must now leave
# the previous placeholder intact and no temp file behind.
assert LogoDownloader().create_placeholder_logo("COLL", str(tmp_path))
path = tmp_path / "COLL.png"
before = path.read_bytes()
with patch("src.logo_downloader.os.replace", side_effect=OSError("disk full")):
assert refresh_placeholder_timestamp(path) is False
assert path.read_bytes() == before
assert sorted(p.name for p in tmp_path.iterdir()) == ["COLL.png"]
class TestFailurePaths: class TestFailurePaths:
def test_a_team_without_logos_is_a_failed_download(self, tmp_path): def test_a_team_without_logos_is_a_failed_download(self, tmp_path):
+89
View File
@@ -0,0 +1,89 @@
"""A failed logo download is not retried on every call.
A logo that is simply absent (not a stale placeholder) had nothing to back
off on: every load_logo_with_download() call retried the download, each with
a 30s timeout, for as long as the host was down or the URL was dead.
"""
import io
import logging
import time
from unittest.mock import MagicMock
import pytest
import requests
from PIL import Image
from src.common.logo_helper import MISSING_LOGO_RECHECK_SECONDS, LogoHelper
@pytest.fixture(autouse=True)
def _no_real_chmod(monkeypatch):
monkeypatch.setattr("src.common.logo_helper.ensure_directory_permissions", MagicMock())
monkeypatch.setattr("src.logo_downloader.ensure_file_permissions", MagicMock())
@pytest.fixture
def helper():
h = LogoHelper(display_width=64, display_height=32,
logger=logging.getLogger("test.logo_backoff"))
h.session.get = MagicMock(side_effect=requests.ConnectionError("down"))
return h
def _png_response():
buf = io.BytesIO()
Image.new("RGBA", (20, 20), (0, 128, 0, 255)).save(buf, "PNG")
data = buf.getvalue()
response = MagicMock()
response.__enter__.return_value = response
response.__exit__.return_value = False
response.raise_for_status = MagicMock()
response.headers = {"content-type": "image/png"}
response.iter_content = lambda *a, **k: iter([data])
return response
def _load(helper, path):
return helper.load_logo_with_download("PHI", path, "http://x/logo.png",
max_width=20, max_height=20)
def test_a_second_call_does_not_retry(helper, tmp_path):
path = tmp_path / "PHI.png"
assert _load(helper, path) is not None # placeholder
assert _load(helper, path) is not None
assert helper.session.get.call_count == 1
def test_it_retries_after_the_recheck_window(helper, tmp_path):
path = tmp_path / "PHI.png"
_load(helper, path)
helper._download_failures[str(path)] -= MISSING_LOGO_RECHECK_SECONDS + 1
_load(helper, path)
assert helper.session.get.call_count == 2
def test_other_paths_are_not_held_back(helper, tmp_path):
_load(helper, tmp_path / "PHI.png")
_load(helper, tmp_path / "DAL.png")
assert helper.session.get.call_count == 2
def test_clear_cache_forgets_failures(helper, tmp_path):
path = tmp_path / "PHI.png"
_load(helper, path)
helper.clear_cache()
_load(helper, path)
assert helper.session.get.call_count == 2
def test_invalidate_forgets_the_failure(helper, tmp_path):
path = tmp_path / "PHI.png"
_load(helper, path)
helper._invalidate_cached_logo("PHI", path)
helper.session.get = MagicMock(return_value=_png_response())
logo = _load(helper, path)
helper.session.get.assert_called_once()
assert path.exists()
assert logo.getpixel((0, 0))[:3] == (0, 128, 0)
+87
View File
@@ -0,0 +1,87 @@
"""ScrollHelper: what a new scrolling image does on the way in.
- set_scrolling_image cached a non-RGB image as-is, and every frame is cut
with Image.frombytes('RGB', ...): an RGBA strip came out as garbage and an
L strip raised.
- Only reset_scroll() reset last_update_time. In time-based mode the first
update after a new image (e.g. the plugin coming back on screen) advanced
the position by the whole idle gap, skipping the start of the content.
"""
import time
import pytest
from PIL import Image
from src.common.scroll_helper import ScrollHelper
W, H = 16, 8
@pytest.fixture
def helper():
return ScrollHelper(display_width=W, display_height=H)
class TestNonRgbImages:
def test_rgba_is_composited_onto_black(self, helper):
img = Image.new('RGBA', (40, H), (0, 0, 0, 0))
for x in range(10):
for y in range(H):
img.putpixel((x, y), (255, 0, 0, 255))
# Hidden colour under full transparency must not leak through.
img.putpixel((20, 0), (0, 255, 0, 0))
helper.set_scrolling_image(img)
assert helper.cached_image.mode == 'RGB'
frame = helper.get_visible_portion()
assert frame.size == (W, H)
assert frame.getpixel((0, 0)) == (255, 0, 0)
assert frame.getpixel((12, 0)) == (0, 0, 0)
helper.scroll_position = 20.0
assert helper.get_visible_portion().getpixel((0, 0)) == (0, 0, 0)
def test_greyscale_does_not_raise(self, helper):
helper.set_scrolling_image(Image.new('L', (40, H), 200))
frame = helper.get_visible_portion()
assert frame.getpixel((0, 0)) == (200, 200, 200)
def test_palette_with_transparency(self, helper):
img = Image.new('P', (40, H), 1)
img.putpalette([0, 0, 0, 0, 0, 255] + [0] * 762)
img.info['transparency'] = 1
helper.set_scrolling_image(img)
assert helper.get_visible_portion().getpixel((0, 0)) == (0, 0, 0)
def test_an_rgb_image_is_kept_as_the_same_object(self, helper):
img = Image.new('RGB', (40, H), (1, 2, 3))
helper.set_scrolling_image(img)
assert helper.cached_image is img
class TestNoJumpAfterIdle:
def _idle_then_new_image(self, helper, install):
helper.scroll_speed = 100.0 # px/s, time-based
install(helper)
helper.update_scroll_position()
# Off screen for 10 seconds, then a fresh image.
helper.last_update_time = time.time() - 10.0
install(helper)
helper.update_scroll_position()
return helper.scroll_position
def test_set_scrolling_image(self, helper):
pos = self._idle_then_new_image(
helper, lambda h: h.set_scrolling_image(Image.new('RGB', (5000, H))))
assert pos < 50, f"jumped {pos}px on the first update of a new image"
def test_create_scrolling_image(self, helper):
pos = self._idle_then_new_image(
helper, lambda h: h.create_scrolling_image([Image.new('RGB', (5000, H))]))
assert pos < 50, f"jumped {pos}px on the first update of a new image"
def test_fixed_step_mode_still_steps_exactly(self, helper):
helper.set_pixels_per_frame(2)
helper.set_scrolling_image(Image.new('RGB', (5000, H)))
helper.update_scroll_position()
helper.update_scroll_position()
assert helper.scroll_position == 4.0
+22
View File
@@ -242,6 +242,28 @@ class TestPlugins:
assert is_valid is True assert is_valid is True
assert errors == [] assert errors == []
def test_a_null_plugin_block_does_not_abandon_the_checks(self, good_cache, tmp_path):
# config.get(id, {}) returns None for "known": null, and .get() on it
# raised -- caught as "Could not validate plugins", skipping every
# plugin after it.
pm = MagicMock()
pm.discover_plugins.return_value = ['nulled', 'known']
plugin_dir = tmp_path / "known"
plugin_dir.mkdir() # no manifest.json
pm.get_plugin_directory.return_value = str(plugin_dir)
config = dict(GOOD_CONFIG, nulled=None, known={'enabled': True})
validator = StartupValidator(make_config_manager(config), pm)
validator._validate_plugins()
assert not any('Could not validate plugins' in w for w in validator.warnings)
assert "Plugin 'known' manifest.json not found" in validator.errors
def test_a_passed_discovery_is_reused(self, good_cache):
pm = MagicMock()
validator = StartupValidator(make_config_manager(dict(GOOD_CONFIG)), pm)
validator._validate_plugins(discovered_plugins=[])
pm.discover_plugins.assert_not_called()
class TestIdempotence: class TestIdempotence:
"""validate_all() resets error/warning state each run (the fixed bug).""" """validate_all() resets error/warning state each run (the fixed bug)."""
+78
View File
@@ -0,0 +1,78 @@
"""A rejected passphrase must reach the web UI as "wrong_password:".
_connect_nmcli prefixes the message with "wrong_password:" and the api_v3
wifi route turns that into ``error_type == 'wrong_password'``, which the
setup pages key their "incorrect password" prompt off. _connect_validated
used to replace the message on every failure branch (restore the old
network, or bring the setup AP up), so the prefix never survived.
"""
import sys
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent))
from src.wifi_manager import WiFiManager # noqa: E402
def _manager(connected_ssid=None):
manager = WiFiManager.__new__(WiFiManager) # no __init__: no real host access
manager.has_nmcli = True
manager._wifi_interface = "wlan0"
manager.get_wifi_status = MagicMock(return_value=SimpleNamespace(
connected=bool(connected_ssid), ssid=connected_ssid, ip_address=None))
manager._is_ap_mode_active = MagicMock(return_value=False)
manager._ensure_wifi_radio_enabled = MagicMock(return_value=True)
manager._show_led_message = MagicMock()
manager.disconnect_from_network = MagicMock(return_value=(True, "ok"))
manager._wait_for_device_idle = MagicMock(return_value=True)
manager.enable_ap_mode = MagicMock(return_value=(True, "AP up"))
return manager
def _run(manager, nmcli_result):
manager._connect_nmcli = MagicMock(return_value=nmcli_result)
nmcli_out = SimpleNamespace(returncode=0, stdout="GENERAL.CONNECTION:OldNet\n")
with patch("src.wifi_manager.subprocess.run", return_value=nmcli_out), \
patch("src.wifi_manager.time.sleep"):
return manager._connect_validated("HomeNet", "hunter22")
WRONG = (False, "wrong_password: Secrets were required, but not provided")
class TestWrongPasswordSurvivesRecovery:
def test_when_the_setup_ap_comes_back_up(self):
ok, message = _run(_manager(), WRONG)
assert ok is False
assert message.startswith("wrong_password:")
def test_when_the_setup_ap_fails_too(self):
manager = _manager()
manager.enable_ap_mode.return_value = (False, "hostapd missing")
ok, message = _run(manager, WRONG)
assert ok is False
assert message.startswith("wrong_password:")
assert "hostapd missing" in message
@pytest.mark.parametrize("restored", [True, False])
def test_when_the_previous_network_is_restored_or_not(self, restored):
manager = _manager(connected_ssid="OldNet")
manager._restore_original_connection = MagicMock(return_value=restored)
ok, message = _run(manager, WRONG)
assert ok is False
assert message.startswith("wrong_password:")
class TestOtherFailuresAreUnchanged:
def test_a_generic_failure_gets_no_prefix(self):
ok, message = _run(_manager(), (False, "Connection timed out"))
assert ok is False
assert message == "Connection failed. AP mode enabled."
def test_success_passes_through(self):
assert _run(_manager(), (True, "Connected to HomeNet")) == (True, "Connected to HomeNet")
@@ -0,0 +1,51 @@
"""/api/v3 JSON GETs must not be served from the browser cache.
They carried ``Cache-Control: private, max-age=5``, so a fetch() made right
after an install, a toggle or a Wi-Fi connect could be answered with the copy
from before it. Non-JSON files served through the API keep the short cache.
"""
from flask import Flask, Response, jsonify
import pytest
@pytest.fixture
def client():
import web_interface.app as web_app
# The real after_request hook on a throwaway app, so the header logic is
# tested without depending on any real endpoint's managers.
app = Flask(__name__)
app.after_request(web_app.add_security_headers)
app.add_url_rule('/api/v3/probe/json', 'probe_json',
lambda: jsonify({'status': 'success'}))
app.add_url_rule('/api/v3/probe/file', 'probe_file',
lambda: Response('body{}', mimetype='text/css'))
app.add_url_rule('/static/v3/probe.css', 'probe_static',
lambda: Response('body{}', mimetype='text/css'))
with app.test_client() as c:
yield c
def test_json_gets_are_not_stored(client):
resp = client.get('/api/v3/probe/json')
assert resp.headers['Cache-Control'] == 'no-store'
def test_non_json_files_keep_the_short_cache(client):
resp = client.get('/api/v3/probe/file')
assert resp.headers['Cache-Control'] == 'private, max-age=5, must-revalidate'
def test_static_assets_are_still_long_cached(client):
resp = client.get('/static/v3/probe.css')
assert 'immutable' in resp.headers.get('Cache-Control', '')
def test_the_real_app_marks_an_api_json_answer_no_store():
import web_interface.app as web_app
web_app.app.config['TESTING'] = True
with web_app.app.test_client() as c:
resp = c.get('/api/v3/no-such-endpoint-for-cache-test')
assert resp.mimetype == 'application/json'
assert resp.headers['Cache-Control'] == 'no-store'
@@ -0,0 +1,116 @@
"""POST /api/v3/starlark/apps/<app_id>/toggle shares _toggle_starlark_app.
The route carried its own copy of the toggle with the bugs the shared helper
had already fixed: it changed the loaded app before the disk write (so a
failed save left the two disagreeing), indexed ``manifest['apps'][app_id]``
(a KeyError, answered as a 500, for a loaded app with no on-disk entry), and
stored ``"false"`` -- a truthy string -- as the new state.
"""
import contextlib
from unittest.mock import MagicMock, patch
import pytest
PKG = 'web_interface.blueprints.api_v3'
@pytest.fixture
def client():
from web_interface.app import app
app.config['TESTING'] = True
with app.test_client() as c:
yield c
def _loaded_plugin(enabled=True, save_ok=True, disk=None):
app = MagicMock()
app.manifest = {'enabled': enabled}
app.is_enabled.return_value = enabled
plugin = MagicMock()
plugin.apps = {'clock': app}
disk = {'apps': {}} if disk is None else disk
def _update(fn):
if not save_ok:
return False
fn(disk)
return True
plugin._update_manifest_safe.side_effect = _update
return plugin, app, disk
def _post(client, body):
return client.post('/api/v3/starlark/apps/clock/toggle', json=body)
class TestLoadedApp:
def test_a_missing_disk_entry_is_created_not_a_500(self, client):
plugin, app, disk = _loaded_plugin(enabled=True)
with patch(f'{PKG}._get_starlark_plugin', return_value=plugin):
resp = _post(client, {'enabled': False})
body = resp.get_json()
assert resp.status_code == 200, body
assert body == {'status': 'success', 'message': body['message'], 'enabled': False}
assert disk['apps']['clock']['enabled'] is False
assert app.manifest['enabled'] is False
def test_a_failed_save_leaves_the_loaded_app_alone(self, client):
plugin, app, _ = _loaded_plugin(enabled=True, save_ok=False)
with patch(f'{PKG}._get_starlark_plugin', return_value=plugin):
resp = _post(client, {'enabled': False})
assert resp.status_code == 500
assert resp.get_json()['status'] == 'error'
assert app.manifest['enabled'] is True
@pytest.mark.parametrize('sent', ['false', 'False', 0, '0'])
def test_a_false_string_or_zero_disables(self, client, sent):
plugin, app, disk = _loaded_plugin(enabled=True)
with patch(f'{PKG}._get_starlark_plugin', return_value=plugin):
resp = _post(client, {'enabled': sent})
assert resp.get_json()['enabled'] is False
assert disk['apps']['clock']['enabled'] is False
def test_an_unrecognised_value_is_refused(self, client):
plugin, app, disk = _loaded_plugin(enabled=True)
with patch(f'{PKG}._get_starlark_plugin', return_value=plugin):
resp = _post(client, {'enabled': 'maybe'})
assert resp.status_code == 400
assert disk == {'apps': {}}
def test_no_enabled_flips_the_current_state(self, client):
plugin, app, disk = _loaded_plugin(enabled=True)
with patch(f'{PKG}._get_starlark_plugin', return_value=plugin):
resp = _post(client, {})
assert resp.get_json()['enabled'] is False
class TestStandalone:
def _run(self, client, body, manifest):
written = {}
# The real lock is fcntl-based (Linux only); the toggle logic is what
# is under test here.
with patch(f'{PKG}._get_starlark_plugin', return_value=None), \
patch(f'{PKG}._starlark_manifest_lock', contextlib.nullcontext), \
patch(f'{PKG}._read_starlark_manifest', return_value=manifest), \
patch(f'{PKG}._write_starlark_manifest',
side_effect=lambda m: written.update(m) or True):
resp = _post(client, body)
return resp, written
def test_toggle_is_written(self, client):
resp, written = self._run(client, {'enabled': 'false'},
{'apps': {'clock': {'enabled': True}}})
assert resp.get_json()['enabled'] is False
assert written['apps']['clock']['enabled'] is False
def test_no_enabled_flips_the_manifest_state(self, client):
resp, _ = self._run(client, {}, {'apps': {'clock': {'enabled': False}}})
assert resp.get_json()['enabled'] is True
@pytest.mark.parametrize('body', [{}, {'enabled': True}])
def test_an_unknown_app_is_404(self, client, body):
resp, written = self._run(client, body, {'apps': {}})
assert resp.status_code == 404
assert 'clock' in resp.get_json()['message']
assert written == {}
+9 -3
View File
@@ -570,10 +570,16 @@ def add_security_headers(response):
response.headers['Cache-Control'] = 'public, max-age=31536000, immutable' response.headers['Cache-Control'] = 'public, max-age=31536000, immutable'
response.headers['Expires'] = (datetime.now() + timedelta(days=365)).strftime('%a, %d %b %Y %H:%M:%S GMT') response.headers['Expires'] = (datetime.now() + timedelta(days=365)).strftime('%a, %d %b %Y %H:%M:%S GMT')
elif request.path.startswith('/api/v3/'): elif request.path.startswith('/api/v3/'):
# Short cache for API responses (5 seconds) to allow for quick updates
# but reduce server load for repeated requests
if request.method == 'GET' and 'stream' not in request.path: if request.method == 'GET' and 'stream' not in request.path:
response.headers['Cache-Control'] = 'private, max-age=5, must-revalidate' if response.mimetype == 'application/json':
# JSON is live state. A cached copy made a fetch() right after
# an install, toggle or Wi-Fi connect show the state from
# before it for up to the cache lifetime.
response.headers['Cache-Control'] = 'no-store'
else:
# Files served through the API (plugin web UI assets, backup
# downloads) keep the short cache.
response.headers['Cache-Control'] = 'private, max-age=5, must-revalidate'
else: else:
# No cache for HTML pages to ensure fresh content # No cache for HTML pages to ensure fresh content
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate' response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
@@ -153,6 +153,9 @@ def get_installed_plugins():
'category': plugin_info.get('category', 'General'), 'category': plugin_info.get('category', 'General'),
'description': plugin_info.get('description', 'No description available'), 'description': plugin_info.get('description', 'No description available'),
'tags': plugin_info.get('tags', []), 'tags': plugin_info.get('tags', []),
# The tab nav uses this as the <i> element's Font Awesome class
# (app-shell.js / app-early.js); only a string can be one.
'icon': plugin_info.get('icon') if isinstance(plugin_info.get('icon'), str) else None,
'enabled': enabled, 'enabled': enabled,
'verified': verified, 'verified': verified,
'loaded': plugin_info.get('loaded', False), 'loaded': plugin_info.get('loaded', False),
+26 -28
View File
@@ -21,6 +21,7 @@ from web_interface.blueprints.api_v3 import (
subprocess, tempfile, subprocess, tempfile,
) )
import web_interface.blueprints.api_v3 as _pkg import web_interface.blueprints.api_v3 as _pkg
from web_interface.blueprints.api_v3.wifi import _parse_bool_ish
# Read through the module rather than bound by value: tests patch these # Read through the module rather than bound by value: tests patch these
# as module attributes, and a value binding would not see the patch. # as module attributes, and a value binding would not see the patch.
# Several are also called from helpers that live in __init__, so the # Several are also called from helpers that live in __init__, so the
@@ -547,36 +548,33 @@ def toggle_starlark_app(app_id):
try: try:
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
starlark_plugin = _pkg._get_starlark_plugin() # The write itself goes through _toggle_starlark_app, the same helper
if starlark_plugin: # /plugins/toggle uses: it saves to disk before touching the loaded
app = starlark_plugin.apps.get(app_id) # app, and tolerates a loaded app with no on-disk entry. This route
if not app: # used to carry its own copy without either fix, and stored "false"
return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404 # (a truthy string) as-is.
enabled = data.get('enabled') enabled = data.get('enabled')
if enabled is None: if enabled is None:
_, err = _validate_starlark_app_path(app_id)
if err:
return jsonify({'status': 'error', 'message': err}), 400
# No explicit state: flip the current one.
starlark_plugin = _pkg._get_starlark_plugin()
app = starlark_plugin.apps.get(app_id) if starlark_plugin else None
if app is not None:
enabled = not app.is_enabled() enabled = not app.is_enabled()
app.manifest['enabled'] = enabled
# Use safe manifest update to prevent race conditions
def update_fn(manifest):
manifest['apps'][app_id]['enabled'] = enabled
starlark_plugin._update_manifest_safe(update_fn)
return jsonify({'status': 'success', 'message': f"App {'enabled' if enabled else 'disabled'}", 'enabled': enabled})
# Standalone: update manifest directly
with _starlark_manifest_lock():
manifest = _pkg._read_starlark_manifest()
app_data = manifest.get('apps', {}).get(app_id)
if not app_data:
return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404
enabled = data.get('enabled')
if enabled is None:
enabled = not app_data.get('enabled', True)
app_data['enabled'] = enabled
if _pkg._write_starlark_manifest(manifest):
return jsonify({'status': 'success', 'message': f"App {'enabled' if enabled else 'disabled'}", 'enabled': enabled})
else: else:
return jsonify({'status': 'error', 'message': 'Failed to save'}), 500 app_data = _pkg._read_starlark_manifest().get('apps', {}).get(app_id)
if not app_data:
return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404
enabled = not app_data.get('enabled', True)
else:
enabled = _parse_bool_ish(enabled)
if enabled is None:
return jsonify({'status': 'error',
'message': 'enabled must be true or false'}), 400
return _pkg._toggle_starlark_app(app_id, enabled)
except Exception as e: except Exception as e:
logger.exception("[Starlark] toggle_starlark_app failed") logger.exception("[Starlark] toggle_starlark_app failed")