diff --git a/plugins.json b/plugins.json index 3953885..99f8f19 100644 --- a/plugins.json +++ b/plugins.json @@ -1,6 +1,6 @@ { "version": "1.0.0", - "last_updated": "2026-08-06", + "last_updated": "2026-08-09", "plugins": [ { "id": "cricket-scoreboard", @@ -335,7 +335,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.9.0", + "latest_version": "1.9.1", "icon": "fas fa-hockey-puck" }, { @@ -359,7 +359,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.9.0", + "latest_version": "1.9.1", "icon": "fas fa-baseball-ball" }, { diff --git a/plugins/hockey-scoreboard/manifest.json b/plugins/hockey-scoreboard/manifest.json index 2b25c7d..b6fe31d 100644 --- a/plugins/hockey-scoreboard/manifest.json +++ b/plugins/hockey-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "hockey-scoreboard", "name": "Hockey Scoreboard", - "version": "1.9.0", + "version": "1.9.1", "author": "ChuckBuilds", "description": "Live, recent, and upcoming hockey games across NHL, NCAA Men's, and NCAA Women's hockey with real-time scores and schedules", "homepage": "https://github.com/ChuckBuilds/ledmatrix-plugins/tree/main/plugins/hockey-scoreboard", @@ -54,6 +54,12 @@ } ], "versions": [ + { + "version": "1.9.1", + "released": "2026-08-09", + "notes": "Stop an out-of-season or empty mode holding a blank panel for its full display duration. This plugin's bundled sports.py was a stale fork of the core's and its three display() methods had lost their return values (the base was annotated -> None). The manager's dispatcher treats a non-boolean as success, so every mode reported content whether or not it had any, and the display controller -- which skips a mode whose display() returns False -- never skipped it. The no-games branch clears the display, so the result was a blank screen held for the whole duration. Reported against NHL recent and live in August, when both are empty while upcoming still has the preseason schedule. The methods now return True when a game was drawn and False when there was nothing to show, matching the core and the six other sports plugins.", + "ledmatrix_min_version": "2.0.0" + }, { "version": "1.9.0", "released": "2026-08-06", diff --git a/plugins/hockey-scoreboard/sports.py b/plugins/hockey-scoreboard/sports.py index 8820996..f083224 100644 --- a/plugins/hockey-scoreboard/sports.py +++ b/plugins/hockey-scoreboard/sports.py @@ -209,10 +209,18 @@ def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: f"Error in base _draw_scorebug_layout: {e}", exc_info=True ) - def display(self, force_clear: bool = False) -> None: - """Common display method for all NCAA FB managers""" # Updated docstring + def display(self, force_clear: bool = False) -> bool: + """ + Common display method for all managers. + + Returns True when a game was drawn and False when there was nothing to + show. The caller uses that to skip an empty mode: returning None here + left the display controller unable to tell "drew a game" from "drew + nothing", so an out-of-season league held a blank panel for its whole + display duration instead of being rotated past. + """ if not self.is_enabled: # Check if module is enabled - return + return False if not self.current_game: # Clear the display so old content doesn't persist @@ -227,17 +235,19 @@ def display(self, force_clear: bool = False) -> None: f"No game data available to display in {self.__class__.__name__}" ) setattr(self, "_last_warning_time", current_time) - return + return False try: self._draw_scorebug_layout(self.current_game, force_clear) # display_manager.update_display() should be called within subclass draw methods # or after calling display() in the main loop. Let's keep it out of the base display. + return True except Exception as e: self.logger.error( f"Error during display call in {self.__class__.__name__}: {e}", exc_info=True, ) + return False def _load_custom_font_from_element_config( self, @@ -1480,10 +1490,16 @@ def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: f"Error displaying upcoming game: {e}", exc_info=True ) # Changed log prefix - def display(self, force_clear=False): - """Display upcoming games, handling switching.""" + def display(self, force_clear=False) -> bool: + """ + Display upcoming games, handling switching. + + Returns True when a game was drawn, False when there was nothing to + show, so the caller can rotate past an empty mode instead of holding a + blank panel for its full display duration. + """ if not self.is_enabled: - return + return False if not self.games_list: # Clear the display so old content doesn't persist @@ -1499,7 +1515,7 @@ def display(self, force_clear=False): "No upcoming games found for favorite teams to display." ) # Changed log prefix self.last_warning_time = current_time - return # Skip display update + return False # Skip display update try: current_time = time.time() @@ -1536,12 +1552,15 @@ def display(self, force_clear=False): if self.current_game: self._draw_scorebug_layout(self.current_game, force_clear) - # update_display() is called within _draw_scorebug_layout for upcoming + # update_display() is called within _draw_scorebug_layout + return True + return False except Exception as e: self.logger.error( f"Error in display loop: {e}", exc_info=True ) # Changed log prefix + return False class SportsRecent(SportsCore): @@ -1983,8 +2002,14 @@ def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: f"Error displaying recent game: {e}", exc_info=True ) # Changed log prefix - def display(self, force_clear=False): - """Display recent games, handling switching.""" + def display(self, force_clear=False) -> bool: + """ + Display recent games, handling switching. + + Returns True when a game was drawn, False when there was nothing to + show, so the caller can rotate past an empty mode instead of holding a + blank panel for its full display duration. + """ if not self.is_enabled or not self.games_list: # If disabled or no games, clear the display so old content doesn't persist if force_clear or not self.games_list: @@ -1992,7 +2017,7 @@ def display(self, force_clear=False): self.display_manager.update_display() if not self.games_list and self.current_game: self.current_game = None # Clear internal state if list becomes empty - return + return False try: current_time = time.time() @@ -2029,12 +2054,15 @@ def display(self, force_clear=False): if self.current_game: self._draw_scorebug_layout(self.current_game, force_clear) - # update_display() is called within _draw_scorebug_layout for recent + # update_display() is called within _draw_scorebug_layout + return True + return False except Exception as e: self.logger.error( f"Error in display loop: {e}", exc_info=True ) # Changed log prefix + return False class SportsLive(SportsCore): diff --git a/plugins/hockey-scoreboard/test_empty_mode_signals_no_content.py b/plugins/hockey-scoreboard/test_empty_mode_signals_no_content.py new file mode 100644 index 0000000..fef0453 --- /dev/null +++ b/plugins/hockey-scoreboard/test_empty_mode_signals_no_content.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +""" +Tests that a mode with no games reports "no content" instead of "displayed". + +Regression under test: this plugin's bundled sports.py was a stale fork of the +core's, and its three display() methods had lost their return values -- the +base was even annotated ``-> None``. The manager's dispatcher treats a +non-boolean as success ("Result is None or other - assume success"), so every +mode reported content whether or not it had any. + +The display controller skips a mode whose display() returns False. Reporting +success for an empty mode meant it was never skipped, so an out-of-season +league sat on a blank panel -- the no-games branch calls display_manager.clear() +-- for its entire display duration. Reported for NHL recent and live in August, +when those are empty while upcoming still has the preseason schedule. + +The methods are exercised against a stand-in ``self`` rather than a constructed +manager, so the test needs no display hardware, no network and no cache. + +Run: /bin/python plugins/hockey-scoreboard/test_empty_mode_signals_no_content.py +""" + +import sys +import threading +from pathlib import Path + +plugin_dir = Path(__file__).parent +sys.path.insert(0, str(plugin_dir)) + +import sports # noqa: E402 + + +class _Logger: + def warning(self, *a, **k): pass + def info(self, *a, **k): pass + def debug(self, *a, **k): pass + def error(self, *a, **k): pass + + +class _DisplayManager: + def __init__(self): + self.clears = 0 + self.updates = 0 + + def clear(self): + self.clears += 1 + + def update_display(self): + self.updates += 1 + + +class _Manager: + """A stand-in ``self`` carrying only what display() touches.""" + + def __init__(self, games, enabled=True): + self.is_enabled = enabled + self.games_list = list(games) + self.current_game = games[0] if games else None + self.display_manager = _DisplayManager() + self.logger = _Logger() + self.sport_key = 'nhl' + self._games_lock = threading.Lock() + self.current_game_index = 0 + self.last_game_switch = 0.0 + self.game_display_duration = 1e9 # never switch mid-test + self.last_warning_time = 0.0 + self.warning_cooldown = 1e9 + self._last_warning_time = 0.0 + self.draws = 0 + + def _draw_scorebug_layout(self, game, force_clear=False): + self.draws += 1 + self.display_manager.update_display() + + +GAME = {'id': 'g1', 'away_abbr': 'TB', 'home_abbr': 'BOS'} + +# SportsLive has no display() of its own; it inherits SportsCore's, so covering +# SportsCore covers the live mode the bug was reported against. +CASES = [ + ('SportsCore', sports.SportsCore.display), + ('SportsUpcoming', sports.SportsUpcoming.display), + ('SportsRecent', sports.SportsRecent.display), +] + +failures = [] + + +def check(name, actual, expected): + if actual == expected: + print(" PASS %s" % name) + else: + print(" FAIL %s: expected %r, got %r" % (name, expected, actual)) + failures.append(name) + + +def main(): + print("an empty mode reports no content, so the controller can skip it") + for label, display in CASES: + mgr = _Manager([]) + result = display(mgr, force_clear=False) + check("%s with no games returns False" % label, result, False) + check("%s with no games draws nothing" % label, mgr.draws, 0) + + print("\nthe same is true when the manager is disabled") + for label, display in CASES: + mgr = _Manager([GAME], enabled=False) + check("%s disabled returns False" % label, display(mgr), False) + + print("\na mode that does have a game still reports success") + for label, display in CASES: + mgr = _Manager([GAME]) + result = display(mgr, force_clear=False) + check("%s with a game returns True" % label, result, True) + check("%s with a game draws it" % label, mgr.draws, 1) + + print("\nthe result is a real bool, not something merely truthy") + # The dispatcher branches on `result is True` / `result is False`, so a truthy + # non-bool would fall through to the "assume success" path and reintroduce this. + for label, display in CASES: + check("%s empty -> bool" % label, type(display(_Manager([]))), bool) + check("%s populated -> bool" % label, + type(display(_Manager([GAME]))), bool) + + print("\n%s" % ("FAILED: %d" % len(failures) if failures else "All checks passed")) + sys.exit(1 if failures else 0) + + +if __name__ == "__main__": + main() diff --git a/plugins/lacrosse-scoreboard/manifest.json b/plugins/lacrosse-scoreboard/manifest.json index 5ee7d8f..6d20784 100644 --- a/plugins/lacrosse-scoreboard/manifest.json +++ b/plugins/lacrosse-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "lacrosse-scoreboard", "name": "Lacrosse Scoreboard", - "version": "1.9.0", + "version": "1.9.1", "author": "ChuckBuilds", "description": "Live, recent, and upcoming NCAA men's and women's lacrosse games with real-time scores and schedules", "homepage": "https://github.com/ChuckBuilds/ledmatrix-plugins/tree/main/plugins/lacrosse-scoreboard", @@ -50,6 +50,12 @@ } ], "versions": [ + { + "version": "1.9.1", + "released": "2026-08-09", + "notes": "Stop an out-of-season or empty mode holding a blank panel for its full display duration. This plugin's bundled sports.py was a stale fork of the core's and its three display() methods had lost their return values (the base was annotated -> None). The manager's dispatcher treats a non-boolean as success, so every mode reported content whether or not it had any, and the display controller -- which skips a mode whose display() returns False -- never skipped it. The no-games branch clears the display, so the result was a blank screen held for the whole duration. Found alongside the same fault in hockey-scoreboard, where it was reported against NHL recent and live. The methods now return True when a game was drawn and False when there was nothing to show, matching the core and the six other sports plugins.", + "ledmatrix_min_version": "2.0.0" + }, { "version": "1.9.0", "released": "2026-08-06", diff --git a/plugins/lacrosse-scoreboard/sports.py b/plugins/lacrosse-scoreboard/sports.py index ea991e1..31d4029 100644 --- a/plugins/lacrosse-scoreboard/sports.py +++ b/plugins/lacrosse-scoreboard/sports.py @@ -210,10 +210,18 @@ def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: f"Error in base _draw_scorebug_layout: {e}", exc_info=True ) - def display(self, force_clear: bool = False) -> None: - """Common display method for all NCAA FB managers""" # Updated docstring + def display(self, force_clear: bool = False) -> bool: + """ + Common display method for all managers. + + Returns True when a game was drawn and False when there was nothing to + show. The caller uses that to skip an empty mode: returning None here + left the display controller unable to tell "drew a game" from "drew + nothing", so an out-of-season league held a blank panel for its whole + display duration instead of being rotated past. + """ if not self.is_enabled: # Check if module is enabled - return + return False if not self.current_game: # Clear the display so old content doesn't persist @@ -228,17 +236,19 @@ def display(self, force_clear: bool = False) -> None: f"No game data available to display in {self.__class__.__name__}" ) setattr(self, "_last_warning_time", current_time) - return + return False try: self._draw_scorebug_layout(self.current_game, force_clear) # display_manager.update_display() should be called within subclass draw methods # or after calling display() in the main loop. Let's keep it out of the base display. + return True except Exception as e: self.logger.error( f"Error during display call in {self.__class__.__name__}: {e}", exc_info=True, ) + return False def _load_custom_font_from_element_config( self, @@ -1481,10 +1491,16 @@ def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: f"Error displaying upcoming game: {e}", exc_info=True ) # Changed log prefix - def display(self, force_clear=False): - """Display upcoming games, handling switching.""" + def display(self, force_clear=False) -> bool: + """ + Display upcoming games, handling switching. + + Returns True when a game was drawn, False when there was nothing to + show, so the caller can rotate past an empty mode instead of holding a + blank panel for its full display duration. + """ if not self.is_enabled: - return + return False if not self.games_list: # Clear the display so old content doesn't persist @@ -1500,7 +1516,7 @@ def display(self, force_clear=False): "No upcoming games found for favorite teams to display." ) # Changed log prefix self.last_warning_time = current_time - return # Skip display update + return False # Skip display update try: current_time = time.time() @@ -1537,12 +1553,15 @@ def display(self, force_clear=False): if self.current_game: self._draw_scorebug_layout(self.current_game, force_clear) - # update_display() is called within _draw_scorebug_layout for upcoming + # update_display() is called within _draw_scorebug_layout + return True + return False except Exception as e: self.logger.error( f"Error in display loop: {e}", exc_info=True ) # Changed log prefix + return False class SportsRecent(SportsCore): @@ -1982,8 +2001,14 @@ def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: f"Error displaying recent game: {e}", exc_info=True ) # Changed log prefix - def display(self, force_clear=False): - """Display recent games, handling switching.""" + def display(self, force_clear=False) -> bool: + """ + Display recent games, handling switching. + + Returns True when a game was drawn, False when there was nothing to + show, so the caller can rotate past an empty mode instead of holding a + blank panel for its full display duration. + """ if not self.is_enabled or not self.games_list: # If disabled or no games, clear the display so old content doesn't persist if force_clear or not self.games_list: @@ -1991,7 +2016,7 @@ def display(self, force_clear=False): self.display_manager.update_display() if not self.games_list and self.current_game: self.current_game = None # Clear internal state if list becomes empty - return + return False try: current_time = time.time() @@ -2028,12 +2053,15 @@ def display(self, force_clear=False): if self.current_game: self._draw_scorebug_layout(self.current_game, force_clear) - # update_display() is called within _draw_scorebug_layout for recent + # update_display() is called within _draw_scorebug_layout + return True + return False except Exception as e: self.logger.error( f"Error in display loop: {e}", exc_info=True ) # Changed log prefix + return False def _swrr_schedule(weighted_ids: List[Tuple[str, int]]) -> List[str]: diff --git a/plugins/lacrosse-scoreboard/test_empty_mode_signals_no_content.py b/plugins/lacrosse-scoreboard/test_empty_mode_signals_no_content.py new file mode 100644 index 0000000..d0e86a3 --- /dev/null +++ b/plugins/lacrosse-scoreboard/test_empty_mode_signals_no_content.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +""" +Tests that a mode with no games reports "no content" instead of "displayed". + +Regression under test: this plugin's bundled sports.py was a stale fork of the +core's, and its three display() methods had lost their return values -- the +base was even annotated ``-> None``. The manager's dispatcher treats a +non-boolean as success ("Result is None or other - assume success"), so every +mode reported content whether or not it had any. + +The display controller skips a mode whose display() returns False. Reporting +success for an empty mode meant it was never skipped, so an out-of-season +league sat on a blank panel -- the no-games branch calls display_manager.clear() +-- for its entire display duration. Found alongside the same fault in hockey-scoreboard, which was reported for +NHL recent and live in August, when those are empty while upcoming is not. + +The methods are exercised against a stand-in ``self`` rather than a constructed +manager, so the test needs no display hardware, no network and no cache. + +Run: /bin/python plugins/lacrosse-scoreboard/test_empty_mode_signals_no_content.py +""" + +import sys +import threading +from pathlib import Path + +plugin_dir = Path(__file__).parent +sys.path.insert(0, str(plugin_dir)) + +import sports # noqa: E402 + + +class _Logger: + def warning(self, *a, **k): pass + def info(self, *a, **k): pass + def debug(self, *a, **k): pass + def error(self, *a, **k): pass + + +class _DisplayManager: + def __init__(self): + self.clears = 0 + self.updates = 0 + + def clear(self): + self.clears += 1 + + def update_display(self): + self.updates += 1 + + +class _Manager: + """A stand-in ``self`` carrying only what display() touches.""" + + def __init__(self, games, enabled=True): + self.is_enabled = enabled + self.games_list = list(games) + self.current_game = games[0] if games else None + self.display_manager = _DisplayManager() + self.logger = _Logger() + self.sport_key = 'ncaa_mens_lacrosse' + self._games_lock = threading.Lock() + self.current_game_index = 0 + self.last_game_switch = 0.0 + self.game_display_duration = 1e9 # never switch mid-test + self.last_warning_time = 0.0 + self.warning_cooldown = 1e9 + self._last_warning_time = 0.0 + self.draws = 0 + + def _draw_scorebug_layout(self, game, force_clear=False): + self.draws += 1 + self.display_manager.update_display() + + +GAME = {'id': 'g1', 'away_abbr': 'TB', 'home_abbr': 'BOS'} + +# SportsLive has no display() of its own; it inherits SportsCore's, so covering +# SportsCore covers the live mode the bug was reported against. +CASES = [ + ('SportsCore', sports.SportsCore.display), + ('SportsUpcoming', sports.SportsUpcoming.display), + ('SportsRecent', sports.SportsRecent.display), +] + +failures = [] + + +def check(name, actual, expected): + if actual == expected: + print(" PASS %s" % name) + else: + print(" FAIL %s: expected %r, got %r" % (name, expected, actual)) + failures.append(name) + + +def main(): + print("an empty mode reports no content, so the controller can skip it") + for label, display in CASES: + mgr = _Manager([]) + result = display(mgr, force_clear=False) + check("%s with no games returns False" % label, result, False) + check("%s with no games draws nothing" % label, mgr.draws, 0) + + print("\nthe same is true when the manager is disabled") + for label, display in CASES: + mgr = _Manager([GAME], enabled=False) + check("%s disabled returns False" % label, display(mgr), False) + + print("\na mode that does have a game still reports success") + for label, display in CASES: + mgr = _Manager([GAME]) + result = display(mgr, force_clear=False) + check("%s with a game returns True" % label, result, True) + check("%s with a game draws it" % label, mgr.draws, 1) + + print("\nthe result is a real bool, not something merely truthy") + # The dispatcher branches on `result is True` / `result is False`, so a truthy + # non-bool would fall through to the "assume success" path and reintroduce this. + for label, display in CASES: + check("%s empty -> bool" % label, type(display(_Manager([]))), bool) + check("%s populated -> bool" % label, + type(display(_Manager([GAME]))), bool) + + print("\n%s" % ("FAILED: %d" % len(failures) if failures else "All checks passed")) + sys.exit(1 if failures else 0) + + +if __name__ == "__main__": + main()