Merge origin/main into claude/deprecate-unused-plugin-api

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-23 10:49:55 -04:00
co-authored by Claude Opus 5.5
28 changed files with 773 additions and 208 deletions
+56 -39
View File
@@ -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
+6 -3
View File
@@ -64,7 +64,7 @@ class ErrorPattern:
"count": self.count,
"first_seen": self.first_seen.isoformat(),
"last_seen": self.last_seen.isoformat(),
"affected_plugins": list(set(self.affected_plugins)),
"affected_plugins": list(dict.fromkeys(self.affected_plugins)),
"sample_messages": self.sample_messages[:3], # Keep only 3 samples
"severity": self.severity
}
@@ -195,7 +195,9 @@ class ErrorAggregator:
severity = "warning"
# Collect affected plugins
affected_plugins = [r.plugin_id for r in recent_same_type if r.plugin_id]
# Unique, in first-seen order. Each repeat re-scans the whole
# window, so merging duplicates in below grew without bound.
affected_plugins = list(dict.fromkeys(r.plugin_id for r in recent_same_type if r.plugin_id))
# Collect sample messages
sample_messages = list(set(r.message for r in recent_same_type[:5]))
@@ -233,7 +235,8 @@ class ErrorAggregator:
self._patterns[pattern_key].count = count
self._patterns[pattern_key].last_seen = record.timestamp
self._patterns[pattern_key].severity = severity
self._patterns[pattern_key].affected_plugins.extend(affected_plugins)
known = self._patterns[pattern_key].affected_plugins
known.extend(p for p in affected_plugins if p not in known)
def on_pattern_detected(self, callback: Callable[[ErrorPattern], None]) -> None:
"""
+11 -2
View File
@@ -510,8 +510,17 @@ class FontManager:
"%spx", font_path, size_px, native_size
)
face = freetype.Face(font_path)
# Set character size (width, height) in 1/64th of points
face.set_char_size(size_px * 64, size_px * 64, 72, 72)
try:
# Character size in 1/64th points at 72dpi == pixel size.
face.set_char_size(size_px * 64, size_px * 64, 72, 72)
except freetype.FT_Exception:
# FreeType rejects any size but the strike's own, and get_font
# used to answer that with PIL's default font -- a different
# typeface. Use the native strike, as element_style does.
if native_size is None or native_size == size_px:
raise
face = freetype.Face(font_path)
face.set_char_size(native_size * 64, native_size * 64, 72, 72)
return face
except Exception as e:
logger.error(f"Error loading BDF font {font_path}: {e}")
+29 -3
View File
@@ -42,6 +42,12 @@ _FPS_HEALTHY_FRACTION = 0.9
#: rather than fine.
_FPS_HEARTBEAT_INTERVAL = 300.0
#: Seconds between live-priority scans while scrolling. The scan asks every
#: plugin mode has_live_priority() / has_live_content(): 139us per call on a
#: Pi 4 with two scoreboards (9 modes), 1.7% of a 125fps frame, growing with
#: every plugin. Game state doesn't change within a quarter second.
_LIVE_PRIORITY_CHECK_INTERVAL = 0.25
def _percentile(ordered: List[float], fraction: float) -> float:
"""Nearest-rank percentile of an already-sorted list.
@@ -70,6 +76,9 @@ class VegasModeCoordinator:
- Provide status and control interface
"""
# Class-level so coordinators built without __init__ (tests) have it.
_last_live_check: float = float('-inf')
def __init__(
self,
config: Dict[str, Any],
@@ -336,9 +345,14 @@ class VegasModeCoordinator:
# Check for config updates (synchronized access)
has_pending_update = self._pending_config_update
# Check for live priority
if self._check_live_priority():
return False
# Check for live priority (throttled; see _LIVE_PRIORITY_CHECK_INTERVAL).
# Only a negative result is ever reused: a positive one pauses Vegas,
# and run_frame() returns early while paused.
now = time.monotonic()
if now - self._last_live_check >= _LIVE_PRIORITY_CHECK_INTERVAL:
self._last_live_check = now
if self._check_live_priority():
return False
# Apply pending config update outside lock
if has_pending_update:
@@ -633,6 +647,18 @@ class VegasModeCoordinator:
logger.debug("Config update queued (version %d)", self._config_version)
def apply_pending_config_if_idle(self) -> None:
"""Apply a queued config update while Vegas isn't running.
run_frame() applies updates between frames but returns early once
Vegas has stopped, so without this a disable followed by a re-enable
would never take effect. Call from the display thread only.
"""
with self._state_lock:
if self._is_active or not self._pending_config_update:
return
self._apply_pending_config()
def _apply_pending_config(self) -> None:
"""Apply pending configuration update."""
# Atomically grab pending config and clear it to avoid losing concurrent updates
+11 -1
View File
@@ -58,6 +58,12 @@ def get_wifi_config_path():
return Path(project_root) / "config" / "wifi_config.json"
def get_wifi_status_path() -> Path:
"""The status-message file WiFiManager writes and the display controller
reads (config/wifi_status.json, next to wifi_config.json)."""
return get_wifi_config_path().parent / "wifi_status.json"
HOSTAPD_CONFIG_PATH = Path("/etc/hostapd/hostapd.conf")
DNSMASQ_CONFIG_PATH = Path("/etc/dnsmasq.d/ledmatrix-captive.conf")
# Drop-in config for NetworkManager's built-in dnsmasq (ipv4.method=shared).
@@ -182,8 +188,12 @@ class WiFiManager:
'duration': duration
}
LED_STATUS_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(LED_STATUS_FILE, 'w') as f:
# Write-then-rename: the display reads this at ~1 Hz and deletes
# a file it can't parse, so a half-written one would lose the message.
tmp_path = LED_STATUS_FILE.with_name(LED_STATUS_FILE.name + '.tmp')
with open(tmp_path, 'w') as f:
json.dump(status, f)
os.replace(tmp_path, LED_STATUS_FILE)
logger.info(f"LED message: {message}")
except Exception as e:
logger.debug(f"Could not write LED status message: {e}")