mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user