From 16e965b8382d63dc45598d5e62f11338d6a075ba Mon Sep 17 00:00:00 2001 From: Ron Date: Mon, 15 Jun 2026 22:05:03 -0700 Subject: [PATCH] feat(plugins): apply config edits live without a display restart Four installed plugins had no on_config_change override, so the base BasePlugin only swapped self.config and their derived settings (and the components that captured config at construction) kept startup values until the display service was restarted. This adds on_config_change to each so the 2s config-file watcher applies edits immediately: - ledmatrix-flights: re-derive scalars and rebuild the data-source fetcher, route enrichment and renderer; invalidate the cached map so a new center/radius/zoom re-tiles. - ledmatrix-elections: re-derive filters, rebuild providers + race store, reconfigure the scroll helper, and force a re-fetch. - baseball-scoreboard / football-scoreboard: re-derive league/display settings and rebuild the per-league managers (cleaning up old HTTP sessions first), league registry, scroll manager and rotation modes so favorite teams, league enable/disable, durations and live priority apply live. Per-game progress tracking is reset since manager keys may change. Each plugin gains a regression test that fails against the base behavior and passes with the override. Versions bumped + plugins.json synced. Co-Authored-By: Claude Opus 4.8 --- plugins.json | 18 +-- plugins/baseball-scoreboard/manager.py | 105 +++++++++++++++ plugins/baseball-scoreboard/manifest.json | 10 +- .../baseball-scoreboard/test_config_reload.py | 103 +++++++++++++++ plugins/football-scoreboard/manager.py | 103 +++++++++++++++ plugins/football-scoreboard/manifest.json | 10 +- .../football-scoreboard/test_config_reload.py | 103 +++++++++++++++ plugins/ledmatrix-elections/manager.py | 62 +++++++++ plugins/ledmatrix-elections/manifest.json | 10 +- .../ledmatrix-elections/test_config_reload.py | 92 ++++++++++++++ plugins/ledmatrix-flights/manager.py | 120 ++++++++++++++++++ plugins/ledmatrix-flights/manifest.json | 10 +- .../ledmatrix-flights/test_config_reload.py | 114 +++++++++++++++++ 13 files changed, 843 insertions(+), 17 deletions(-) create mode 100644 plugins/baseball-scoreboard/test_config_reload.py create mode 100644 plugins/football-scoreboard/test_config_reload.py create mode 100644 plugins/ledmatrix-elections/test_config_reload.py create mode 100644 plugins/ledmatrix-flights/test_config_reload.py diff --git a/plugins.json b/plugins.json index 1859e01f..def1d04c 100644 --- a/plugins.json +++ b/plugins.json @@ -1,6 +1,6 @@ { "version": "1.0.0", - "last_updated": "2026-06-14", + "last_updated": "2026-06-15", "plugins": [ { "id": "7-segment-clock", @@ -46,10 +46,10 @@ "plugin_path": "plugins/baseball-scoreboard", "stars": 0, "downloads": 0, - "last_updated": "2026-06-13", + "last_updated": "2026-06-15", "verified": true, "screenshot": "", - "latest_version": "1.6.5" + "latest_version": "1.7.0" }, { "id": "basketball-scoreboard", @@ -210,10 +210,10 @@ "plugin_path": "plugins/football-scoreboard", "stars": 0, "downloads": 0, - "last_updated": "2026-05-15", + "last_updated": "2026-06-15", "verified": true, "screenshot": "", - "latest_version": "2.3.5" + "latest_version": "2.4.0" }, { "id": "geochron", @@ -358,10 +358,10 @@ "plugin_path": "plugins/ledmatrix-flights", "stars": 0, "downloads": 0, - "last_updated": "2026-06-14", + "last_updated": "2026-06-15", "verified": true, "screenshot": "", - "latest_version": "1.9.2" + "latest_version": "1.10.0" }, { "id": "march-madness", @@ -895,10 +895,10 @@ "plugin_path": "plugins/ledmatrix-elections", "stars": 0, "downloads": 0, - "last_updated": "2026-06-06", + "last_updated": "2026-06-15", "verified": false, "screenshot": "", - "latest_version": "1.0.2" + "latest_version": "1.1.0" }, { "id": "ledmatrix-dresden-departures", diff --git a/plugins/baseball-scoreboard/manager.py b/plugins/baseball-scoreboard/manager.py index 8435f8ed..76c565ed 100644 --- a/plugins/baseball-scoreboard/manager.py +++ b/plugins/baseball-scoreboard/manager.py @@ -247,6 +247,111 @@ def __init__( # Sequential block display shows all games from one league before moving to the next, # which is simpler and more predictable than the sticky manager approach + def on_config_change(self, new_config: Dict[str, Any]) -> None: + """Apply config edits live, without restarting the display. + + Re-derives the league/display settings and rebuilds the per-league + managers, league registry, scroll manager and rotation modes so changes + like favorite teams, enabling/disabling a league, durations or live + priority take effect immediately. The old managers are cleaned up (HTTP + sessions closed) before they are replaced. Per-game progress tracking is + reset since manager keys may have changed. + """ + self.config = new_config or {} + + # Resolve timezone the same way __init__ does so sub-managers inherit it. + if not self.config.get("timezone"): + global_tz = None + config_manager = getattr(self.cache_manager, "config_manager", None) + if config_manager is not None: + try: + global_tz = config_manager.get_timezone() + except (AttributeError, TypeError): + self.logger.debug("Global timezone unavailable; falling back to UTC") + except Exception: + self.logger.exception( + "Failed to read global timezone from config_manager.get_timezone(); falling back to UTC." + ) + self.config["timezone"] = global_tz or "UTC" + + # Re-derive scalar settings. + self.enabled = self.config.get("enabled", getattr(self, "enabled", True)) + self.is_enabled = self.config.get("enabled", True) + self.mlb_enabled = self.config.get("mlb", {}).get("enabled", False) + self.milb_enabled = self.config.get("milb", {}).get("enabled", False) + self.ncaa_baseball_enabled = self.config.get("ncaa_baseball", {}).get("enabled", False) + self.display_duration = float(self.config.get("display_duration", 30)) + self.game_display_duration = float(self.config.get("game_display_duration", 15)) + self.mlb_live_priority = self.config.get("mlb", {}).get("live_priority", False) + self.milb_live_priority = self.config.get("milb", {}).get("live_priority", False) + self.ncaa_baseball_live_priority = self.config.get("ncaa_baseball", {}).get("live_priority", False) + self._display_mode_settings = self._parse_display_mode_settings() + + # Tear down the existing managers (close HTTP sessions) before rebuilding. + self._cleanup_managers() + + # Rebuild managers + registry so favorite teams, intervals, filtering and + # league enable/disable all apply. + self._initialize_managers() + self._initialize_league_registry() + + # Rebuild the scroll display manager so it sees the new config. + self._scroll_manager = None + if SCROLL_AVAILABLE and ScrollDisplayManager: + try: + self._scroll_manager = ScrollDisplayManager( + self.display_manager, self.config, self.logger + ) + except Exception as e: + self.logger.warning(f"Could not rebuild scroll display manager: {e}") + self._scroll_manager = None + self.enable_scrolling = self._scroll_manager is not None + self._scroll_active = {} + self._scroll_prepared = {} + + # Rebuild rotation modes and reset cycling state. + self.modes = self._get_available_modes() + self.current_mode_index = 0 + self.last_mode_switch = 0 + + # Reset dynamic-duration tracking (manager keys may have changed). + self._dynamic_cycle_seen_modes = set() + self._dynamic_mode_to_manager_key = {} + self._dynamic_manager_progress = {} + self._dynamic_managers_completed = set() + self._dynamic_cycle_complete = False + self._single_game_manager_start_times = {} + self._game_id_start_times = {} + self._display_mode_to_managers = {} + self._current_display_league = None + self._current_display_mode_type = None + self._last_display_mode = None + self._last_display_mode_time = 0.0 + self._current_active_display_mode = None + self._current_game_tracking = {} + self._mode_start_time = {} + + self.logger.info( + "Baseball config updated live - MLB:%s MiLB:%s NCAA:%s, modes=%s", + self.mlb_enabled, self.milb_enabled, self.ncaa_baseball_enabled, self.modes, + ) + + def _cleanup_managers(self) -> None: + """Close HTTP sessions / clear caches on the current league managers.""" + for attr in ( + "mlb_live", "mlb_recent", "mlb_upcoming", + "milb_live", "milb_recent", "milb_upcoming", + "ncaa_baseball_live", "ncaa_baseball_recent", "ncaa_baseball_upcoming", + ): + manager = getattr(self, attr, None) + if manager is not None and hasattr(manager, "cleanup"): + try: + manager.cleanup() + except Exception as e: + self.logger.debug(f"Error cleaning up manager {attr}: {e}") + if hasattr(self, attr): + setattr(self, attr, None) + def _initialize_managers(self): """Initialize all manager instances.""" try: diff --git a/plugins/baseball-scoreboard/manifest.json b/plugins/baseball-scoreboard/manifest.json index c96eca99..38f6bb52 100644 --- a/plugins/baseball-scoreboard/manifest.json +++ b/plugins/baseball-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "baseball-scoreboard", "name": "Baseball Scoreboard", - "version": "1.6.5", + "version": "1.7.0", "author": "ChuckBuilds", "description": "Live, recent, and upcoming baseball games across MLB, MiLB, and NCAA Baseball with real-time scores and schedules", "category": "sports", @@ -30,6 +30,12 @@ "branch": "main", "plugin_path": "plugins/baseball-scoreboard", "versions": [ + { + "released": "2026-06-15", + "version": "1.7.0", + "notes": "Apply config changes live via on_config_change (favorite teams, league enable/disable, durations, live priority, display modes); rebuilds the league managers, registry, scroll manager and rotation so edits take effect without a display restart.", + "ledmatrix_min": "2.0.0" + }, { "released": "2026-06-13", "version": "1.6.5", @@ -145,7 +151,7 @@ "ledmatrix_min_version": "2.0.0" } ], - "last_updated": "2026-06-13", + "last_updated": "2026-06-15", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/baseball-scoreboard/test_config_reload.py b/plugins/baseball-scoreboard/test_config_reload.py new file mode 100644 index 00000000..83b81639 --- /dev/null +++ b/plugins/baseball-scoreboard/test_config_reload.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +""" +Regression test: baseball-scoreboard applies config edits live. + +Before this fix the plugin had no on_config_change override, so the base +BasePlugin only swapped self.config: league enables, durations, live priority, +display-mode settings, the per-league managers and the rotation mode list all +kept their startup values until a display restart. This test fails against the +base behavior and passes once the plugin re-derives state on config change. + +Leagues are left disabled so no network managers are constructed, but the full +re-derive / rebuild path still runs. + +Run with the core venv from within a LEDMatrix tree, or set LEDMATRIX_CORE: + LEDMATRIX_CORE=/path/to/LEDMatrix .venv/bin/python \ + plugins/baseball-scoreboard/test_config_reload.py +""" + +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) + +_core = os.environ.get("LEDMATRIX_CORE") +if _core and _core not in sys.path: + sys.path.insert(0, _core) + +from manager import BaseballScoreboardPlugin # noqa: E402 + +_passed = 0 +_failed = 0 + + +def check(cond, msg): + global _passed, _failed + if cond: + _passed += 1 + print(f" PASS: {msg}") + else: + _failed += 1 + print(f" FAIL: {msg}") + + +class _Matrix: + width = 128 + height = 32 + + +class _DisplayManager: + matrix = _Matrix() + + +def _make_plugin(config): + return BaseballScoreboardPlugin( + "baseball-scoreboard", + config, + display_manager=_DisplayManager(), + cache_manager=object(), + plugin_manager=object(), + ) + + +def test_on_config_change_applies_live(): + print("[baseball-scoreboard on_config_change]") + plugin = _make_plugin( + { + "display_duration": 30, + "mlb": {"enabled": False, "live_priority": False, + "display_modes": {"live_display_mode": "switch"}}, + } + ) + check(plugin.display_duration == 30.0, "initial display_duration derived") + check(plugin.mlb_live_priority is False, "initial mlb live_priority derived") + check(plugin._display_mode_settings["mlb"]["live"] == "switch", "initial display mode setting derived") + + # Prime cycling state so we can confirm it is reset on change. + plugin.current_mode_index = 5 + + plugin.on_config_change( + { + "display_duration": 45, + "game_display_duration": 20, + "mlb": { + "enabled": False, + "live_priority": True, + "display_modes": {"live_display_mode": "scroll"}, + }, + } + ) + + check(plugin.display_duration == 45.0, "display_duration updated live") + check(plugin.game_display_duration == 20.0, "game_display_duration updated live") + check(plugin.mlb_live_priority is True, "mlb live_priority updated live") + check(plugin._display_mode_settings["mlb"]["live"] == "scroll", "display mode setting updated live") + check(plugin.current_mode_index == 0, "mode cycling reset") + check(isinstance(plugin.modes, list) and len(plugin.modes) > 0, "rotation modes rebuilt without crashing") + + +if __name__ == "__main__": + test_on_config_change_applies_live() + print(f"\n{_passed} passed, {_failed} failed") + sys.exit(1 if _failed else 0) diff --git a/plugins/football-scoreboard/manager.py b/plugins/football-scoreboard/manager.py index 88167215..f93a4db9 100644 --- a/plugins/football-scoreboard/manager.py +++ b/plugins/football-scoreboard/manager.py @@ -227,6 +227,109 @@ def __init__( # Sequential block display shows all games from one league before moving to the next, # which is simpler and more predictable than the sticky manager approach + def on_config_change(self, new_config: Dict[str, Any]) -> None: + """Apply config edits live, without restarting the display. + + Re-derives the league/display settings and rebuilds the per-league + managers, league registry, scroll manager and rotation modes so changes + like favorite teams, enabling/disabling a league, durations or live + priority take effect immediately. The old managers are cleaned up (HTTP + sessions closed) before they are replaced. Per-game progress tracking is + reset since manager keys may have changed. + """ + self.config = new_config or {} + + # Resolve timezone the same way the managers expect (cache_manager owns + # the global timezone); _adapt_config_for_manager re-reads it per league. + if not self.config.get("timezone"): + global_tz = None + config_manager = getattr(self.cache_manager, "config_manager", None) + if config_manager is not None: + try: + global_tz = config_manager.get_timezone() + except (AttributeError, TypeError): + self.logger.debug("Global timezone unavailable; falling back to UTC") + except Exception: + self.logger.exception( + "Failed to read global timezone from config_manager.get_timezone(); falling back to UTC." + ) + self.config["timezone"] = global_tz or "UTC" + + # Re-derive scalar settings. + self.enabled = self.config.get("enabled", getattr(self, "enabled", True)) + self.is_enabled = self.config.get("enabled", True) + self.nfl_enabled = self.config.get("nfl", {}).get("enabled", False) + self.ncaa_fb_enabled = self.config.get("ncaa_fb", {}).get("enabled", False) + self.display_duration = float(self.config.get("display_duration", 30)) + self.game_display_duration = float(self.config.get("game_display_duration", 15)) + self.nfl_live_priority = self.config.get("nfl", {}).get("live_priority", False) + self.ncaa_fb_live_priority = self.config.get("ncaa_fb", {}).get("live_priority", False) + self._display_mode_settings = self._parse_display_mode_settings() + + # Tear down the existing managers (close HTTP sessions) before rebuilding. + self._cleanup_managers() + + # Rebuild managers + registry so favorite teams, intervals, filtering and + # league enable/disable all apply. + self._initialize_managers() + self._initialize_league_registry() + + # Rebuild the scroll display manager so it sees the new config. + self._scroll_manager = None + if SCROLL_AVAILABLE and ScrollDisplayManager: + try: + self._scroll_manager = ScrollDisplayManager( + self.display_manager, self.config, self.logger + ) + except Exception as e: + self.logger.warning(f"Could not rebuild scroll display manager: {e}") + self._scroll_manager = None + self.enable_scrolling = self._scroll_manager is not None + self._scroll_active = {} + self._scroll_prepared = {} + + # Rebuild rotation modes and reset cycling state. + self.modes = self._get_available_modes() + self.current_mode_index = 0 + self.last_mode_switch = 0 + + # Reset dynamic-duration tracking (manager keys may have changed). + self._dynamic_cycle_seen_modes = set() + self._dynamic_mode_to_manager_key = {} + self._dynamic_manager_progress = {} + self._dynamic_managers_completed = set() + self._dynamic_cycle_complete = False + self._single_game_manager_start_times = {} + self._game_id_start_times = {} + self._display_mode_to_managers = {} + self._current_display_league = None + self._current_display_mode_type = None + self._last_display_mode = None + self._last_display_mode_time = 0.0 + self._current_active_display_mode = None + self._current_game_tracking = {} + self._mode_start_time = {} + + self.logger.info( + "Football config updated live - NFL:%s NCAA_FB:%s, modes=%s", + self.nfl_enabled, self.ncaa_fb_enabled, self.modes, + ) + + def _cleanup_managers(self) -> None: + """Close HTTP sessions / clear caches on the current league managers.""" + for attr in ( + "nfl_live", "nfl_recent", "nfl_upcoming", + "ncaa_fb_live", "ncaa_fb_recent", "ncaa_fb_upcoming", + ): + manager = getattr(self, attr, None) + if manager is not None and hasattr(manager, "cleanup"): + try: + manager.cleanup() + except Exception as e: + self.logger.debug(f"Error cleaning up manager {attr}: {e}") + if hasattr(self, attr): + setattr(self, attr, None) + def _initialize_managers(self): """Initialize all manager instances.""" try: diff --git a/plugins/football-scoreboard/manifest.json b/plugins/football-scoreboard/manifest.json index f0be2a05..08271e86 100644 --- a/plugins/football-scoreboard/manifest.json +++ b/plugins/football-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "football-scoreboard", "name": "Football Scoreboard", - "version": "2.3.5", + "version": "2.4.0", "author": "ChuckBuilds", "class_name": "FootballScoreboardPlugin", "description": "Standalone plugin for live, recent, and upcoming football games across NFL and NCAA Football with real-time scores, down/distance, possession, and game status. Now with organized nested config!", @@ -24,6 +24,12 @@ "ncaa_fb_live" ], "versions": [ + { + "released": "2026-06-15", + "version": "2.4.0", + "notes": "Apply config changes live via on_config_change (favorite teams, league enable/disable, durations, live priority, display modes); rebuilds the league managers, registry, scroll manager and rotation so edits take effect without a display restart.", + "ledmatrix_min": "2.0.0" + }, { "released": "2026-05-15", "version": "2.3.5", @@ -245,7 +251,7 @@ "ledmatrix_min_version": "2.0.0" } ], - "last_updated": "2026-05-15", + "last_updated": "2026-06-15", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/football-scoreboard/test_config_reload.py b/plugins/football-scoreboard/test_config_reload.py new file mode 100644 index 00000000..cc0915b8 --- /dev/null +++ b/plugins/football-scoreboard/test_config_reload.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +""" +Regression test: football-scoreboard applies config edits live. + +Before this fix the plugin had no on_config_change override, so the base +BasePlugin only swapped self.config: league enables, durations, live priority, +display-mode settings, the per-league managers and the rotation mode list all +kept their startup values until a display restart. This test fails against the +base behavior and passes once the plugin re-derives state on config change. + +Leagues are left disabled so no network managers are constructed, but the full +re-derive / rebuild path still runs. + +Run with the core venv from within a LEDMatrix tree, or set LEDMATRIX_CORE: + LEDMATRIX_CORE=/path/to/LEDMatrix .venv/bin/python \ + plugins/football-scoreboard/test_config_reload.py +""" + +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) + +_core = os.environ.get("LEDMATRIX_CORE") +if _core and _core not in sys.path: + sys.path.insert(0, _core) + +from manager import FootballScoreboardPlugin # noqa: E402 + +_passed = 0 +_failed = 0 + + +def check(cond, msg): + global _passed, _failed + if cond: + _passed += 1 + print(f" PASS: {msg}") + else: + _failed += 1 + print(f" FAIL: {msg}") + + +class _Matrix: + width = 128 + height = 32 + + +class _DisplayManager: + matrix = _Matrix() + + +def _make_plugin(config): + return FootballScoreboardPlugin( + "football-scoreboard", + config, + display_manager=_DisplayManager(), + cache_manager=object(), + plugin_manager=object(), + ) + + +def test_on_config_change_applies_live(): + print("[football-scoreboard on_config_change]") + plugin = _make_plugin( + { + "display_duration": 30, + "nfl": {"enabled": False, "live_priority": False, + "display_modes": {"live_display_mode": "switch"}}, + } + ) + check(plugin.display_duration == 30.0, "initial display_duration derived") + check(plugin.nfl_live_priority is False, "initial nfl live_priority derived") + check(plugin._display_mode_settings["nfl"]["live"] == "switch", "initial display mode setting derived") + + # Prime cycling state so we can confirm it is reset on change. + plugin.current_mode_index = 5 + + plugin.on_config_change( + { + "display_duration": 45, + "game_display_duration": 20, + "nfl": { + "enabled": False, + "live_priority": True, + "display_modes": {"live_display_mode": "scroll"}, + }, + } + ) + + check(plugin.display_duration == 45.0, "display_duration updated live") + check(plugin.game_display_duration == 20.0, "game_display_duration updated live") + check(plugin.nfl_live_priority is True, "nfl live_priority updated live") + check(plugin._display_mode_settings["nfl"]["live"] == "scroll", "display mode setting updated live") + check(plugin.current_mode_index == 0, "mode cycling reset") + check(isinstance(plugin.modes, list) and len(plugin.modes) > 0, "rotation modes rebuilt without crashing") + + +if __name__ == "__main__": + test_on_config_change_applies_live() + print(f"\n{_passed} passed, {_failed} failed") + sys.exit(1 if _failed else 0) diff --git a/plugins/ledmatrix-elections/manager.py b/plugins/ledmatrix-elections/manager.py index 8714f8c0..0e20bd35 100644 --- a/plugins/ledmatrix-elections/manager.py +++ b/plugins/ledmatrix-elections/manager.py @@ -116,6 +116,68 @@ def __init__(self, plugin_id, config, display_manager, cache_manager, plugin_man self.state, [p.name for p in self.providers], ) + def on_config_change(self, new_config: dict) -> None: + """Apply config edits live, without restarting the display. + + Re-derives every config-driven setting, rebuilds the providers and the + race store (so a changed state, provider list, or vote-override takes + effect), reconfigures the scroll helper in place, and clears the cached + races/segments so the ticker re-fetches and rebuilds on the next update. + """ + self.config = new_config or {} + self.enabled = self.config.get("enabled", self.enabled) + config = self.config + + self.state = (config.get("state") or "").upper() or None + self.only_my_state = config.get("only_my_state", True) + self.race_types = config.get("race_types") or None + self.local_districts = self._build_local_districts(config) + self.update_interval = int(config.get("update_interval", 60)) + self.display_duration = float(config.get("display_duration", 30)) + self.hide_called_after = float(config.get("hide_called_after_seconds", 86400)) + self.test_mode = config.get("test_mode", False) + + self.override_cfg = config.get("override", {}) or {} + self.calendar_events_cfg = config.get("calendar_events", []) or [] + self._fetch_state = self.state + + interrupt_cfg = config.get("interrupt", {}) or {} + self.interrupt_enabled = interrupt_cfg.get("enabled", True) + self.interrupt_duration = float(interrupt_cfg.get("duration_seconds", 12)) + self.interrupt_my_state_only = interrupt_cfg.get("my_state_only", True) + self.interrupt_max_age = float(interrupt_cfg.get("max_age_seconds", 300)) + + ca_cfg = (config.get("providers", {}) or {}).get("ca_sos", {}) or {} + override_votes = ca_cfg.get("override_nyt_votes", True) + + # Rebuild providers + store so a changed state / provider list / vote + # override applies. The store is reseeded on the next update(). + self.providers = create_providers(config, self.cache_manager) + self.store = RaceStore(override_votes=override_votes, cache_manager=self.cache_manager) + + # Reconfigure the scroll helper in place (panel dimensions are unchanged). + self.scroll_speed = float(config.get("scroll_speed", 1.0)) + self.scroll_delay = float(config.get("scroll_delay", 0.01)) + self.scroll_helper.set_scroll_speed(self.scroll_speed) + self.scroll_helper.set_scroll_delay(self.scroll_delay) + self.scroll_helper.set_dynamic_duration_settings( + enabled=True, min_duration=int(self.display_duration), max_duration=300 + ) + + # Force a re-fetch + segment rebuild with the new filters/sources. + self.races = [] + self._segments = [] + self._last_update = 0.0 + self._scroll_ready = False + self._pending_calls = [] + self._current_call = None + self._showing_called = False + + self.logger.info( + "Elections config updated live: state=%s providers=%s", + self.state, [p.name for p in self.providers], + ) + # -- Update loop -------------------------------------------------------- def update(self) -> None: diff --git a/plugins/ledmatrix-elections/manifest.json b/plugins/ledmatrix-elections/manifest.json index 75c02ff3..f8aee428 100644 --- a/plugins/ledmatrix-elections/manifest.json +++ b/plugins/ledmatrix-elections/manifest.json @@ -1,7 +1,7 @@ { "id": "ledmatrix-elections", "name": "Election Results", - "version": "1.0.2", + "version": "1.1.0", "author": "rpierce99", "description": "Live election results: a scrolling ticker of important races plus a full-screen interrupt when a race is newly called. Auto-activates for your state's primary + general (dormant otherwise), drains stale calls from the ticker, and survives restarts. Filterable to your state. NYT baseline provider + optional California SoS county/city rollup.", "entry_point": "manager.py", @@ -22,6 +22,12 @@ "cache_manager" ], "versions": [ + { + "released": "2026-06-15", + "version": "1.1.0", + "notes": "Apply config changes live via on_config_change (state, filters, providers, scroll speed); rebuilds providers and the race store and re-fetches so edits take effect without a display restart.", + "ledmatrix_min": "2.0.0" + }, { "released": "2026-06-06", "version": "1.0.2", @@ -40,7 +46,7 @@ "ledmatrix_min": "2.0.0" } ], - "last_updated": "2026-06-06", + "last_updated": "2026-06-15", "stars": 0, "downloads": 0, "verified": false, diff --git a/plugins/ledmatrix-elections/test_config_reload.py b/plugins/ledmatrix-elections/test_config_reload.py new file mode 100644 index 00000000..f96eb4f6 --- /dev/null +++ b/plugins/ledmatrix-elections/test_config_reload.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +""" +Regression test: ledmatrix-elections applies config edits live. + +Before this fix the plugin had no on_config_change override, so the base +BasePlugin only swapped self.config: the state/filter settings, the providers +and race store, and the scroll speed all kept their startup values until a +display restart. This test fails against the base behavior and passes once the +plugin re-derives state on config change. + +Run with the core venv from within a LEDMatrix tree, or set LEDMATRIX_CORE: + LEDMATRIX_CORE=/path/to/LEDMatrix .venv/bin/python \ + plugins/ledmatrix-elections/test_config_reload.py +""" + +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) + +_core = os.environ.get("LEDMATRIX_CORE") +if _core and _core not in sys.path: + sys.path.insert(0, _core) + +from manager import ElectionPlugin # noqa: E402 + +_passed = 0 +_failed = 0 + + +def check(cond, msg): + global _passed, _failed + if cond: + _passed += 1 + print(f" PASS: {msg}") + else: + _failed += 1 + print(f" FAIL: {msg}") + + +def _make_plugin(config): + return ElectionPlugin( + "ledmatrix-elections", + config, + display_manager=object(), + cache_manager=object(), + plugin_manager=object(), + ) + + +def test_on_config_change_applies_live(): + print("[ledmatrix-elections on_config_change]") + plugin = _make_plugin( + { + "state": "CA", + "only_my_state": True, + "update_interval": 60, + "scroll_speed": 1.0, + } + ) + check(plugin.state == "CA", "initial state derived") + providers_before = plugin.providers + store_before = plugin.store + + # Prime runtime state so we can confirm it is reset on change. + plugin._last_update = 999999.0 + + plugin.on_config_change( + { + "state": "WA", + "only_my_state": False, + "update_interval": 30, + "scroll_speed": 2.0, + "test_mode": True, + } + ) + + check(plugin.state == "WA", "state updated live") + check(plugin.only_my_state is False, "only_my_state updated live") + check(plugin.update_interval == 30, "update_interval updated live") + check(plugin.test_mode is True, "test_mode updated live") + check(plugin.scroll_speed == 2.0, "scroll_speed updated live") + check(plugin.providers is not providers_before, "providers rebuilt for new state") + check(plugin.store is not store_before, "race store rebuilt") + check(plugin._last_update == 0.0, "update timer reset so it re-fetches") + + +if __name__ == "__main__": + test_on_config_change_applies_live() + print(f"\n{_passed} passed, {_failed} failed") + sys.exit(1 if _failed else 0) diff --git a/plugins/ledmatrix-flights/manager.py b/plugins/ledmatrix-flights/manager.py index 36fec2f5..ed8effae 100644 --- a/plugins/ledmatrix-flights/manager.py +++ b/plugins/ledmatrix-flights/manager.py @@ -328,6 +328,126 @@ def __init__(self, plugin_id: str, config: Dict[str, Any], display_manager, cach self.modes = self._get_available_modes() self.logger.info(f"[Flight Tracker] Rotation modes: {self.modes}") + def on_config_change(self, new_config: Dict[str, Any]) -> None: + """Apply config edits live, without restarting the display. + + Re-derives every config-driven setting, rebuilds the components that + capture config at construction (data-source fetcher, route enrichment, + renderer) and the rotation mode list, and invalidates the cached map + background so a new center/radius/zoom redraws. Runtime state — tracked + aircraft, trails, API counters, records, the proximity state machine — + is left untouched. + """ + self.config = new_config or {} + self._normalize_flightaware_config(self.config) + + self.enabled = self.config.get('enabled', False) + self.update_interval = self.config.get('update_interval', 5) + self.skyaware_url = self.config.get('skyaware_url', 'http://192.168.86.30/skyaware/data/aircraft.json') + + # FlightAware + self.flight_plan_enabled = self._fa_config('enabled', False) + self.flightaware_api_key = self._fa_config('api_key', '') + self.max_api_calls_per_hour = self._fa_config('max_api_calls_per_hour', 20) + self.cache_ttl_seconds = self._fa_config('cache_ttl_hours', 12) * 3600 + self.min_callsign_length = self._fa_config('min_callsign_length', 4) + self.daily_api_budget = self._fa_config('daily_api_budget', 60) + self.airline_callsign_prefixes = self._fa_config('airline_callsign_prefixes', [ + 'AAL', 'UAL', 'DAL', 'SWA', 'JBU', 'ASQ', 'ENY', 'FFT', 'NKS', 'F9', 'G4', 'B6', 'WN', 'AA', 'UA', 'DL' + ]) + + # Location + self.center_lat = self.config.get('center_latitude', 27.9506) + self.center_lon = self.config.get('center_longitude', -82.4572) + self.map_radius_miles = self.config.get('map_radius_miles', 10) + self.zoom_factor = self.config.get('zoom_factor', 1.0) + + # Map background + self.map_bg_config = self.config.get('map_background', {}) + self.map_bg_enabled = self.map_bg_config.get('enabled', True) + self.tile_provider = self.map_bg_config.get('tile_provider', 'osm') + self.tile_size = self.map_bg_config.get('tile_size', 256) + self.cache_ttl_hours = self.map_bg_config.get('cache_ttl_hours', 8760) + self.fade_intensity = self.map_bg_config.get('fade_intensity', 0.3) + self.map_brightness = self.map_bg_config.get('brightness', 1.0) + self.map_contrast = self.map_bg_config.get('contrast', 1.0) + self.map_saturation = self.map_bg_config.get('saturation', 1.0) + self.disable_on_cache_error = self.map_bg_config.get('disable_on_cache_error', False) + self.custom_tile_server = self.map_bg_config.get('custom_tile_server', None) + + # Trails + self.show_trails = self.config.get('show_trails', False) + self.trail_length = self.config.get('trail_length', 10) + + # Proximity alert / overhead live-priority + self.proximity_config = self.config.get('proximity_alert', {}) + self.proximity_enabled = self.proximity_config.get('enabled', True) + self.proximity_distance_miles = self.proximity_config.get('distance_miles', 0.1) + self.proximity_duration = self.proximity_config.get('duration_seconds', 30) + self.proximity_cooldown = self.proximity_config.get('cooldown_seconds', 30) + self.live_priority_enabled = self.config.get('live_priority', False) + self.live_update_interval = self.config.get('live_update_interval', 2) + + # Background service for flight plan data + bg_svc = self._fa_config('background_service', {}) + self.background_service_enabled = bg_svc.get('enabled', True) + self.background_fetch_interval = bg_svc.get('fetch_interval_hours', 4) * 3600 + self.max_background_calls_per_run = bg_svc.get('max_calls_per_run', 10) + + # Data source / enrichment + self.data_source = self.config.get('data_source', 'skyaware') + self.fr24_enrichment = self.config.get('fr24_enrichment', True) + self.fr24_enrichment_interval = self.config.get('fr24_enrichment_interval', 60) + + # Display / filtering + self.display_mode = self.config.get('display_mode', 'auto') + self.units_system = self.config.get('units', 'imperial') + self.max_aircraft = self.config.get('max_aircraft', 5) + self.min_altitude_ft = self.config.get('min_altitude_ft', 0) + self.max_altitude_ft = self.config.get('max_altitude_ft', 0) + self.aircraft_categories = self.config.get('aircraft_categories', []) + self.tracked_flights_cfg = self.config.get('tracked_flights', []) + self.anchor_airport = self.config.get('anchor_airport', '') + self.route_cache_ttl = self.config.get('route_cache_ttl', 300) + + # Offline aircraft database + self.use_offline_db = self.config.get('use_offline_database', True) + self.offline_db_auto_update = self.config.get('offline_database_auto_update', True) + self.offline_db_update_interval_days = self.config.get('offline_database_update_interval_days', 30) + + # Start loading records if they were just enabled. + was_records_enabled = getattr(self, 'flight_records_enabled', False) + self.flight_records_enabled = self.config.get('flight_records', {}).get('enabled', True) + if self.flight_records_enabled and not was_records_enabled: + try: + self._load_flight_records() + except Exception as e: + self.logger.warning(f"[Flight Tracker] Could not load flight records on config change: {e}") + + # Rebuild components that captured the config at construction so the new + # data source / enrichment / renderer config takes effect immediately. + try: + self._fetcher = create_fetcher(self.config, self.cache_manager) + self._enrichment = create_enrichment_provider(self.config, self.cache_manager) + self._renderer = FlightRenderer(self.display_manager, self.fonts, self.config) + except Exception as e: + self.logger.error(f"[Flight Tracker] Error rebuilding components on config change: {e}", exc_info=True) + + # Invalidate the cached map background so center/radius/zoom/appearance + # changes are re-tiled on the next render. + self.cached_map_bg = None + self.last_map_center = None + self.last_map_zoom = None + self.cached_pixels_per_mile = None + + # Rebuild the rotation slots in case enabled views changed. + self.modes = self._get_available_modes() + self.logger.info( + f"[Flight Tracker] Config updated live - source={self.data_source}, " + f"center=({self.center_lat}, {self.center_lon}), radius={self.map_radius_miles}mi, " + f"modes={self.modes}" + ) + @property def display_width(self) -> int: return self._display_manager_ref.matrix.width diff --git a/plugins/ledmatrix-flights/manifest.json b/plugins/ledmatrix-flights/manifest.json index 0818545b..dab2a9c6 100644 --- a/plugins/ledmatrix-flights/manifest.json +++ b/plugins/ledmatrix-flights/manifest.json @@ -1,7 +1,7 @@ { "id": "ledmatrix-flights", "name": "Flight Tracker", - "version": "1.9.2", + "version": "1.10.0", "description": "Real-time aircraft tracking with ADS-B/FlightRadar24/OpenSky/adsb.fi/adsb.lol data, map backgrounds, area mode, flight tracking, anchor airport, and flight records", "author": "ChuckBuilds", "entry_point": "manager.py", @@ -34,6 +34,12 @@ "min_ledmatrix_version": "2.0.0", "max_ledmatrix_version": "3.0.0", "versions": [ + { + "released": "2026-06-15", + "version": "1.10.0", + "notes": "Apply config changes live via on_config_change (data source, center/radius/zoom, units, filters, proximity); rebuilds the fetcher, route enrichment and renderer and invalidates the cached map so edits take effect without a display restart.", + "ledmatrix_min": "2.0.0" + }, { "released": "2026-06-14", "version": "1.9.2", @@ -128,5 +134,5 @@ "ledmatrix_min_version": "2.0.0" } ], - "last_updated": "2026-06-14" + "last_updated": "2026-06-15" } diff --git a/plugins/ledmatrix-flights/test_config_reload.py b/plugins/ledmatrix-flights/test_config_reload.py new file mode 100644 index 00000000..53c70de5 --- /dev/null +++ b/plugins/ledmatrix-flights/test_config_reload.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +""" +Regression test: ledmatrix-flights applies config edits live. + +Before this fix the plugin had no on_config_change override, so the base +BasePlugin only swapped self.config and the derived settings (center, radius, +data source, units, ...) plus the config-bound fetcher/enrichment/renderer +kept their startup values until a display restart. This test fails against the +base behavior and passes once the plugin re-derives state on config change. + +Run with the core venv from within a LEDMatrix tree, or set LEDMATRIX_CORE: + LEDMATRIX_CORE=/path/to/LEDMatrix .venv/bin/python \ + plugins/ledmatrix-flights/test_config_reload.py +""" + +import os +import sys +import tempfile + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) + +_core = os.environ.get("LEDMATRIX_CORE") +if _core and _core not in sys.path: + sys.path.insert(0, _core) + +from manager import FlightTrackerPlugin # noqa: E402 + +_passed = 0 +_failed = 0 + + +def check(cond, msg): + global _passed, _failed + if cond: + _passed += 1 + print(f" PASS: {msg}") + else: + _failed += 1 + print(f" FAIL: {msg}") + + +class _Matrix: + width = 128 + height = 64 + + +class _DisplayManager: + matrix = _Matrix() + + +class _CacheManager: + def __init__(self, cache_dir): + self.cache_dir = cache_dir + + +def _make_tracker(config): + return FlightTrackerPlugin( + "ledmatrix-flights", + config, + display_manager=_DisplayManager(), + cache_manager=_CacheManager(tempfile.mkdtemp()), + plugin_manager=object(), + ) + + +def test_on_config_change_applies_live(): + print("[ledmatrix-flights on_config_change]") + tracker = _make_tracker( + { + "data_source": "skyaware", + "center_latitude": 27.95, + "center_longitude": -82.45, + "map_radius_miles": 10, + "units": "imperial", + } + ) + check(tracker.data_source == "skyaware", "initial data_source derived") + check(tracker.center_lat == 27.95, "initial center derived") + fetcher_before = tracker._fetcher + renderer_before = tracker._renderer + + # Prime the cached map so we can confirm it is invalidated on change. + tracker.cached_map_bg = "SENTINEL" + tracker.last_map_center = (27.95, -82.45) + + tracker.on_config_change( + { + "data_source": "adsbfi", + "center_latitude": 47.61, + "center_longitude": -122.33, + "map_radius_miles": 25, + "units": "metric", + "max_aircraft": 9, + "live_priority": True, + } + ) + + check(tracker.data_source == "adsbfi", "data_source updated live") + check(tracker.center_lat == 47.61 and tracker.center_lon == -122.33, "center updated live") + check(tracker.map_radius_miles == 25, "radius updated live") + check(tracker.units_system == "metric", "units updated live") + check(tracker.max_aircraft == 9, "max_aircraft updated live") + check(tracker.live_priority_enabled is True, "live_priority updated live") + check(tracker._fetcher is not fetcher_before, "fetcher rebuilt for new data source") + check(tracker._renderer is not renderer_before, "renderer rebuilt with new config") + check(tracker.cached_map_bg is None, "cached map invalidated so it re-tiles") + check(tracker.last_map_center is None, "cached map center invalidated") + + +if __name__ == "__main__": + test_on_config_change_applies_live() + print(f"\n{_passed} passed, {_failed} failed") + sys.exit(1 if _failed else 0)