mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-10 17:16:36 +00:00
Merge origin/main into claude/deprecate-unused-plugin-api
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
|
||||
|
||||
@@ -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
@@ -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}")
|
||||
|
||||
@@ -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
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user