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)