mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 22:35:08 +00:00
fix: settings the display ignored, a memory leak, and the plugin card handler (#605)
* fix(errors): stop affected_plugins growing without bound Each repeat of an error pattern appended every plugin in the time window to the pattern's list again, so a plugin failing in a loop grew the display process's memory without limit: 3,000 errors from three plugins reached 2.5 million entries. Keep the list unique. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(fonts): load a BDF font at its native size instead of PIL's default FreeType rejects any size but a BDF strike's own, and FontManager answered that with ImageFont.load_default() -- a different typeface -- so 5x7.bdf requested at 8 or 10px rendered as PIL's default font. Retry at the native strike, as element_style already does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): plugin toggle failures no longer claim "operation in progress" Every exception in POST /plugins/toggle was mapped to PLUGIN_OPERATION_CONFLICT, so any failure told the user "A plugin operation is already in progress". Report the failure as what it is, and record the plugin id in the operation history for form posts too (it read a `data` variable that only the JSON path set). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): route plugin card clicks through handlePluginAction The document-level delegation checked `typeof handlePluginAction`, which is scoped inside the plugin-manager IIFE and so never visible to it. Every card click took a copied fallback that stopped propagation (the grid's own listener never ran), confirmed an uninstall twice, and sent Starlark app uninstalls to POST /plugins/uninstall instead of DELETE /starlark/apps/<id>. Expose the handler on window and delegate to it. Also run every test/js/unit suite under pytest: they need only node, but CI ran one of the eight. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(display): apply Rotation durations, WiFi messages and Vegas settings Three settings the web UI saves never reached the display: - Rotation & Durations: display.display_durations was never read. Every plugin inherits get_display_duration() and the plugin was asked first. A saved value now wins. The page shows unsaved screens blank with the plugin's own duration as a placeholder, and saving a blank removes the override, so one save no longer pins every screen. - WiFi status overlay: the controller looked for wifi_status.json one directory above the repo. Both sides now use wifi_manager.get_wifi_status_path(). The message is written by rename so the display never reads it half-written, and the resumed plugin redraws the whole panel afterwards. - Vegas: nothing called coordinator.update_config(), so saved Vegas settings never reached a running scroll. They are now queued when display.vegas_scroll changes, and applied while Vegas is stopped too, so a disable then re-enable works. The follower's scroll-speed default (75) now matches VegasModeConfig's (50). Also throttles Vegas's per-frame live-priority scan to 4Hz. It cost 139us per frame on a Pi 4 with two scoreboards (1.7% of a 125fps frame) and grows with each plugin. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: keep affected_plugins order when serialized; guard non-Element targets ErrorPattern.to_dict() ran the now-ordered list through set(), so get_error_summary() listed plugins in an unstable order. The document-level card-action listener called event.target.closest() without checking the target is an Element. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+56
-39
@@ -26,7 +26,6 @@ import json
|
||||
import threading
|
||||
import types
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List, Optional, Callable
|
||||
from datetime import datetime
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed # pylint: disable=no-name-in-module
|
||||
@@ -63,9 +62,6 @@ _vegas_mode_imported = False
|
||||
VegasModeCoordinator = None
|
||||
DEFAULT_DYNAMIC_DURATION_CAP = 180.0
|
||||
|
||||
# WiFi status message file path (same as used in wifi_manager.py)
|
||||
WIFI_STATUS_FILE = None # Will be initialized in __init__
|
||||
|
||||
class DisplayController:
|
||||
"""
|
||||
Top-level controller that owns the LED display run loop.
|
||||
@@ -231,13 +227,11 @@ class DisplayController:
|
||||
# once live priority ends.
|
||||
self._live_resume_index: Optional[int] = None
|
||||
|
||||
# WiFi status message tracking
|
||||
global WIFI_STATUS_FILE
|
||||
if WIFI_STATUS_FILE is None:
|
||||
# Resolve project root (same logic as wifi_manager.py)
|
||||
project_root = Path(__file__).parent.parent.parent.resolve()
|
||||
WIFI_STATUS_FILE = project_root / "config" / "wifi_status.json"
|
||||
self.wifi_status_file = WIFI_STATUS_FILE
|
||||
# WiFi status message tracking. The path comes from wifi_manager so
|
||||
# reader and writer can't drift: this used to resolve three levels up
|
||||
# from src/, one above the repo, and never saw a message.
|
||||
from src.wifi_manager import get_wifi_status_path
|
||||
self.wifi_status_file = get_wifi_status_path()
|
||||
self.wifi_status_active = False
|
||||
self.wifi_status_expires_at: Optional[float] = None
|
||||
# _check_wifi_status_message throttle state (checked at frame rate,
|
||||
@@ -428,9 +422,7 @@ class DisplayController:
|
||||
self._normal_brightness: int = (
|
||||
self.config.get('display', {}).get('hardware', {}).get('brightness', 90)
|
||||
)
|
||||
self._scroll_speed: float = (
|
||||
self.config.get('display', {}).get('vegas_scroll', {}).get('scroll_speed', 75)
|
||||
)
|
||||
self._scroll_speed: float = self._vegas_scroll_speed(self.config)
|
||||
|
||||
# Brightness state tracking for dim schedule
|
||||
self.current_brightness = self._normal_brightness
|
||||
@@ -450,16 +442,7 @@ class DisplayController:
|
||||
# Register controller-level hot-reload callback so cached config values
|
||||
# (_normal_brightness, _scroll_speed, _tz, minute-gates) stay in sync
|
||||
# when the user saves settings via the web UI.
|
||||
def _controller_config_change(old_config: Dict[str, Any], new_config: Dict[str, Any]) -> None:
|
||||
self._refresh_config_cache(new_config)
|
||||
# If a plugin was enabled/disabled, flag a reconcile for the main
|
||||
# loop to apply (loading/unloading off the watcher thread is unsafe).
|
||||
if (self._enabled_set_changed(old_config, new_config)
|
||||
or self._enabled_plugin_not_running(new_config)):
|
||||
with self._reconcile_flag_lock:
|
||||
self._pending_plugin_reconcile = True
|
||||
|
||||
self.config_service.subscribe(_controller_config_change)
|
||||
self.config_service.subscribe(self._controller_config_change)
|
||||
|
||||
# Publish initial on-demand state
|
||||
try:
|
||||
@@ -581,6 +564,9 @@ class DisplayController:
|
||||
"""Check if Vegas mode should be running."""
|
||||
if not self.vegas_coordinator:
|
||||
return False
|
||||
# A stopped coordinator never reaches run_frame(), where queued config
|
||||
# is applied, so re-enabling Vegas from the web UI would never land.
|
||||
self.vegas_coordinator.apply_pending_config_if_idle()
|
||||
if not self.vegas_coordinator.is_enabled:
|
||||
return False
|
||||
if self.on_demand_active:
|
||||
@@ -1068,16 +1054,22 @@ class DisplayController:
|
||||
self._tick_plugin_updates()
|
||||
|
||||
def _get_display_duration(self, mode_key):
|
||||
"""Get display duration for a mode."""
|
||||
# Check plugin-specific duration first
|
||||
if mode_key in self.plugin_modes:
|
||||
plugin_instance = self.plugin_modes[mode_key]
|
||||
if hasattr(plugin_instance, 'get_display_duration'):
|
||||
return plugin_instance.get_display_duration()
|
||||
|
||||
# Fall back to config
|
||||
display_durations = self.config.get('display', {}).get('display_durations', {})
|
||||
return display_durations.get(mode_key, 30)
|
||||
"""Seconds to show a mode: the Rotation & Durations page's value for it
|
||||
(display.display_durations), else the plugin's own duration.
|
||||
|
||||
The saved value has to win. Every plugin inherits
|
||||
get_display_duration(), so checking the plugin first meant the page's
|
||||
values were never read.
|
||||
"""
|
||||
display_durations = self.config.get('display', {}).get('display_durations', {}) or {}
|
||||
override = display_durations.get(mode_key)
|
||||
if isinstance(override, (int, float)) and not isinstance(override, bool) and override > 0:
|
||||
return float(override)
|
||||
|
||||
plugin_instance = self.plugin_modes.get(mode_key)
|
||||
if plugin_instance is not None and hasattr(plugin_instance, 'get_display_duration'):
|
||||
return plugin_instance.get_display_duration()
|
||||
return 30
|
||||
|
||||
def _get_global_dynamic_cap(self) -> Optional[float]:
|
||||
"""Return global fallback dynamic duration cap."""
|
||||
@@ -2012,8 +2004,9 @@ class DisplayController:
|
||||
if wifi_status_data:
|
||||
# Display WiFi status message and skip normal rotation
|
||||
if self._display_wifi_status_message(wifi_status_data):
|
||||
# Sleep for a short time to show the message
|
||||
# Use a short sleep to allow for quick updates
|
||||
# The plugin that resumes afterwards must redraw
|
||||
# the whole panel, not paint over the message.
|
||||
self.force_change = True
|
||||
self._sleep_with_plugin_updates(0.5)
|
||||
continue # Skip to next iteration, don't rotate
|
||||
else:
|
||||
@@ -3226,6 +3219,24 @@ class DisplayController:
|
||||
self.current_mode_index %= len(self.available_modes)
|
||||
self.current_display_mode = self.available_modes[self.current_mode_index]
|
||||
|
||||
def _controller_config_change(self, old_config: Dict[str, Any], new_config: Dict[str, Any]) -> None:
|
||||
"""ConfigService subscriber: runs on the config-watcher thread."""
|
||||
self._refresh_config_cache(new_config)
|
||||
# Vegas keeps its own parsed copy of display.vegas_scroll. Queue the
|
||||
# new one only when it changed: applying it rebuilds the strip.
|
||||
# (getattr: this can fire before __init__ creates the coordinator.)
|
||||
vegas = getattr(self, 'vegas_coordinator', None)
|
||||
if vegas is not None and (
|
||||
(old_config.get('display', {}) or {}).get('vegas_scroll')
|
||||
!= (new_config.get('display', {}) or {}).get('vegas_scroll')):
|
||||
vegas.update_config(new_config)
|
||||
# If a plugin was enabled/disabled, flag a reconcile for the main
|
||||
# loop to apply (loading/unloading off the watcher thread is unsafe).
|
||||
if (self._enabled_set_changed(old_config, new_config)
|
||||
or self._enabled_plugin_not_running(new_config)):
|
||||
with self._reconcile_flag_lock:
|
||||
self._pending_plugin_reconcile = True
|
||||
|
||||
def _refresh_config_cache(self, new_config: Dict[str, Any]) -> None:
|
||||
"""Refresh all config-derived caches when a hot-reload fires.
|
||||
|
||||
@@ -3238,9 +3249,7 @@ class DisplayController:
|
||||
self._normal_brightness = (
|
||||
self.config.get('display', {}).get('hardware', {}).get('brightness', 90)
|
||||
)
|
||||
self._scroll_speed = (
|
||||
self.config.get('display', {}).get('vegas_scroll', {}).get('scroll_speed', 75)
|
||||
)
|
||||
self._scroll_speed = self._vegas_scroll_speed(self.config)
|
||||
# Force the timezone to be re-derived from the new config on next schedule check
|
||||
self._tz = None
|
||||
# Invalidate minute-gates so the new schedule/dim times take effect immediately
|
||||
@@ -3250,6 +3259,14 @@ class DisplayController:
|
||||
logger.debug("Config cache refreshed (brightness=%s, scroll_speed=%s)",
|
||||
self._normal_brightness, self._scroll_speed)
|
||||
|
||||
@staticmethod
|
||||
def _vegas_scroll_speed(config: Dict[str, Any]) -> float:
|
||||
"""Vegas scroll speed in px/s. The default must match VegasModeConfig's
|
||||
(50): a follower dead-reckons with this value between the leader's
|
||||
position packets, so a different default made it run 50% fast."""
|
||||
vegas_cfg = (config.get('display', {}) or {}).get('vegas_scroll', {}) or {}
|
||||
return float(vegas_cfg.get('scroll_speed', 50.0))
|
||||
|
||||
def cleanup(self):
|
||||
"""Clean up resources."""
|
||||
# Stop the async update worker first so no in-flight update() call
|
||||
|
||||
Reference in New Issue
Block a user