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
+14 -7
View File
@@ -123,8 +123,10 @@ class BaseOddsManager:
# Check cache first
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:
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
if time.monotonic() < self._skip_network_until:
@@ -137,7 +139,7 @@ class BaseOddsManager:
self._skip_network_until - time.monotonic())
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:
# Map league names to ESPN API format
@@ -151,7 +153,7 @@ class BaseOddsManager:
espn_league = league_mapping.get(league, league)
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.raise_for_status()
@@ -163,9 +165,9 @@ class BaseOddsManager:
odds_data = self._extract_espn_data(raw_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.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:
self.logger.debug(f"No odds data available for {cache_key}")
# Cache the absence too, so the game is not re-requested
@@ -174,14 +176,19 @@ class BaseOddsManager:
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:
self._skip_network_until = time.monotonic() + self._FAILURE_COOLDOWN
self.logger.error(
"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.",
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)
+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 |
| [`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 |
| [`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 | — |
| [`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 | — |
| [`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_helper`](#scroll_helper) | Pre-rendered horizontal scrolling | Yes | — |
| [`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
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.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
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.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
# downloader writes them at runtime) is still picked up.
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
self.session = requests.Session()
@@ -204,7 +209,12 @@ class LogoHelper:
return self.load_logo(team_abbr, logo_path, max_width, max_height,
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:
try:
self.logger.info(f"Downloading logo for {team_abbr} from {logo_url}")
@@ -218,6 +228,7 @@ class LogoHelper:
scale)
except Exception as 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
# placeholder is still on disk with its old timestamp, and
# leaving it there means the next call retries immediately --
@@ -240,6 +251,7 @@ class LogoHelper:
# leaving it would hide a logo we just downloaded.
for key in [k for k in self._missing_logos if k.startswith(prefix)]:
del self._missing_logos[key]
self._download_failures.pop(str(logo_path), None)
@staticmethod
def _refresh_stale_placeholder(logo_path: Path) -> None:
@@ -332,6 +344,7 @@ class LogoHelper:
self._logo_cache.clear()
self._cache_order.clear()
self._missing_logos.clear()
self._download_failures.clear()
self.logger.debug("Logo cache cleared")
def get_cache_stats(self) -> Dict[str, int]:
+18
View File
@@ -254,6 +254,9 @@ class ScrollHelper:
now = time.time()
self.scroll_start_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(
"Dynamic duration target set to %ds (min=%ds, max=%ds, buffer=%.2f)",
self.calculated_duration,
@@ -776,6 +779,18 @@ class ScrollHelper:
self.clear_cache()
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
self.cached_image = image
@@ -802,6 +817,9 @@ class ScrollHelper:
self.scroll_start_time = now
self.last_progress_log_time = now
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",
image.width, image.height, self.total_scroll_width)
+7 -5
View File
@@ -318,13 +318,19 @@ class DisplayController:
except Exception as 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,
# and running it again logged every config warning twice.
try:
from src.startup_validator import StartupValidator
validator = StartupValidator(self.config_manager, self.plugin_manager,
cache_manager=self.cache_manager)
validator._validate_plugins()
validator._validate_plugins(discovered_plugins=discovered_plugins)
for warning in validator.warnings:
logger.warning("Plugin validation warning: %s", warning)
if validator.errors:
@@ -333,10 +339,6 @@ class DisplayController:
except Exception as 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
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)
+2 -2
View File
@@ -840,7 +840,7 @@ def defaults_from_schema(schema: Dict[str, Any]) -> Dict[str, Any]:
defaults['align'] = align_spec['default']
scale_spec = spec.get('scale')
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:
layout.setdefault(element_key, {})['scale'] = 1.0
if defaults:
@@ -856,7 +856,7 @@ def defaults_from_schema(schema: Dict[str, Any]) -> Dict[str, Any]:
continue
scale_prop = (block.get('properties') or {}).get('scale')
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():
if element_key in ('layout', 'modes') or element_key in elements:
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}::")]
for key in keys_to_remove:
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")
def get_plugin_fonts(self, plugin_id: str) -> List[str]:
@@ -732,6 +735,9 @@ class FontManager:
"""Clear font and metrics cache."""
self.font_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")
@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.add_text(PLACEHOLDER_MARKER, str(time.time()))
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
except Exception:
logger.debug("Could not refresh placeholder timestamp for %s", filepath,
+14 -4
View File
@@ -268,15 +268,21 @@ class StartupValidator:
except Exception as e:
self.warnings.append(f"Could not validate display configuration: {e}")
def _validate_plugins(self) -> None:
"""Validate plugin configurations and dependencies."""
def _validate_plugins(self, discovered_plugins=None) -> None:
"""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:
return
try:
# Get enabled plugins from 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
for plugin_id, plugin_config in config.items():
@@ -294,7 +300,11 @@ class StartupValidator:
# Validate plugin configurations
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):
# Check if plugin can be loaded (without actually loading it)
plugin_dir = self.plugin_manager.get_plugin_directory(plugin_id)
+14 -5
View File
@@ -1317,6 +1317,15 @@ class WiFiManager:
if self.has_nmcli:
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 not success and original_connection and original_ssid:
@@ -1327,18 +1336,18 @@ class WiFiManager:
if restore_success:
logger.info(f"Successfully restored original connection: {original_ssid}")
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:
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, 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
elif not success:
logger.warning(f"Connection to {ssid} failed and no original connection to restore")
return self._failsafe_ap("Connection failed. AP mode enabled.",
"Connection failed and AP mode failed")
return _fail(self._failsafe_ap("Connection failed. AP mode enabled.",
"Connection failed and AP mode failed")[1])
return success, message
else: