Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions plugins.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"version": "1.0.0",
"last_updated": "2026-06-14",
"last_updated": "2026-06-15",
"plugins": [
{
"id": "7-segment-clock",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
105 changes: 105 additions & 0 deletions plugins/baseball-scoreboard/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines +278 to +279

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Keep runtime enable flags consistent during partial config reloads.

Line 278 preserves prior state when enabled is omitted, but Line 279 hard-defaults to True. That can unintentionally re-enable runtime behavior on partial config payloads.

Suggested fix
-        self.enabled = self.config.get("enabled", getattr(self, "enabled", True))
-        self.is_enabled = self.config.get("enabled", True)
+        enabled_flag = self.config.get("enabled", getattr(self, "is_enabled", True))
+        self.enabled = enabled_flag
+        self.is_enabled = enabled_flag
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/baseball-scoreboard/manager.py` around lines 278 - 279, The
inconsistency between how self.enabled and self.is_enabled are initialized
during config reloads can unintentionally re-enable runtime behavior on partial
config updates. Line 278 correctly preserves the prior state of self.enabled
when the "enabled" key is missing by using getattr(self, "enabled", True) as a
fallback, but line 279 hard-defaults self.is_enabled to True instead. Fix this
by updating line 279 to follow the same pattern as line 278: use getattr(self,
"is_enabled", True) as the fallback value instead of just True, so that
self.is_enabled also preserves its prior state when the config key is omitted.

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:
Expand Down
10 changes: 8 additions & 2 deletions plugins/baseball-scoreboard/manifest.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down
103 changes: 103 additions & 0 deletions plugins/baseball-scoreboard/test_config_reload.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading