Skip to content
Merged
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
6 changes: 3 additions & 3 deletions plugins.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"version": "1.0.0",
"last_updated": "2026-08-06",
"last_updated": "2026-08-09",
"plugins": [
{
"id": "cricket-scoreboard",
Expand Down Expand Up @@ -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"
},
{
Expand All @@ -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"
},
{
Expand Down
8 changes: 7 additions & 1 deletion plugins/hockey-scoreboard/manifest.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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",
Expand Down
54 changes: 41 additions & 13 deletions plugins/hockey-scoreboard/sports.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Comment on lines 240 to +250

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.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate files =="
git ls-files | rg 'plugins/(hockey-scoreboard|lacrosse-scoreboard)/sports\.py$|plugins/.*(test|tests?|.*test).*\.py$' || true

echo
echo "== hockey sports outline =="
ast-grep outline plugins/hockey-scoreboard/sports.py --view compact || true

echo
echo "== lacrosse sports outline =="
ast-grep outline plugins/lacrosse-scoreboard/sports.py --view compact || true

echo
echo "== targeted hockey slices =="
sed -n '210,270p;1520,1580p;2025,2080p;30,80p' plugins/hockey-scoreboard/sports.py
echo
echo "== targeted lacrosse slices =="
sed -n '211,271p;1521,1581p;2025,2080p;30,80p' plugins/lacrosse-scoreboard/sports.py

Repository: ChuckBuilds/ledmatrix-plugins

Length of output: 25058


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import ast, re, sys

for path in [
    "plugins/hockey-scoreboard/sports.py",
    "plugins/lacrosse-scoreboard/sports.py",
]:
    print(f"\n== {path} ==")
    text = open(path, encoding="utf-8").read().splitlines()
    for start in [1553, 240, 2055]:
        end = start + 18
        print(f"\n--- lines {start}-{end} ---")
        for i in range(start, min(end + 1, len(text) + 1)):
            print(f"{i}: {text[i-1]}")
PY

echo
echo "== draw_scorebug_layout definitions/calls =="
rg -n "def _draw_scorebug_layout|def draw.*scorebug|_draw_scorebug_layout|update_display|try:" plugins/hockey-scoreboard/sports.py plugins/lacrosse-scoreboard/sports.py

echo
echo "== structural verifier for exception swallowing in display helpers =="
python3 - <<'PY'
import ast
paths = [
    "plugins/hockey-scoreboard/sports.py",
    "plugins/lacrosse-scoreboard/sports.py",
]
for path in paths:
    tree = ast.parse(open(path, encoding="utf-8").read())
    print(f"\n== {path} ==")
    for node in ast.walk(tree):
        if isinstance(node, ast.FunctionDef) and node.name == "_draw_scorebug_layout":
            print(f"FunctionDef {node.name} lines {node.lineno}-{node.end_lineno}")
            has_try = any(isinstance(sub, ast.Try) for sub in ast.walk(node))
            has_exception_handlers = any(isinstance(h, ast.ExceptHandler) for h in node.body)
            print(f"  has_try_or_exception_handler={has_try or has_exception_handlers}")
            for sub in ast.walk(node):
                if isinstance(sub, ast.Try):
                    print(f"  Try lines {sub.lineno}-{sub.end_lineno}")
                    for handler in sub.handlers:
                        print(f"    handler exception types={[ast.dump(e) for e in handler.type] if handler.type else ['bare']}")
                        print(f"    within_try_exits_return={bool(sub.body) and isinstance(sub.body[-1], ast.Return)}")
PY

Repository: ChuckBuilds/ledmatrix-plugins

Length of output: 13851


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import ast

paths = [
    "plugins/hockey-scoreboard/sports.py",
    "plugins/lacrosse-scoreboard/sports.py",
]
for path in paths:
    print(f"\n== {path} ==")
    tree = ast.parse(open(path, encoding="utf-8").read())
    for node in ast.walk(tree):
        if isinstance(node, ast.FunctionDef) and node.name == "_draw_scorebug_layout":
            print(f"FunctionDef {node.name} lines {node.lineno}-{node.end_lineno}")
            has_try = any(isinstance(sub, ast.Try) and hasattr(sub, "body") for sub in ast.walk(node))
            has_handlers = bool(node.type_comment) or any(isinstance(sub, ast.Try) and sub.handlers for sub in ast.walk(node))
            print(f"  has_try_or_exception_handler={has_try or has_handlers}")
            for sub in ast.walk(node):
                if isinstance(sub, ast.Try):
                    if sub.handlers:
                        print(f"    Try line {sub.lineno} handlers={[ast.dump(h.type) if h.type else 'bare'] for h in sub.handlers}")
                    print(f"    final_inside_try_is_return={isinstance(sub.body[-1], ast.Return) if sub.body else False}")
PY

echo
echo "== base display slices =="
sed -n '180,235p;1295,1340p;1817,1860p;13030,13055p;' plugins/hockey-scoreboard/sports.py plugins/lacrosse-scoreboard/sports.py

Repository: ChuckBuilds/ledmatrix-plugins

Length of output: 7828


Return False when drawing fails.

Each display() path calls _draw_scorebug_layout() and then returns True, but the render helpers catch display exceptions and continue normally. Return an explicit render status from the draw helpers, or let rendering failures propagate to display() before returning True.

📍 Affects 2 files
  • plugins/hockey-scoreboard/sports.py#L240-L250 (this comment)
  • plugins/hockey-scoreboard/sports.py#L1553-L1563
  • plugins/hockey-scoreboard/sports.py#L2055-L2065
  • plugins/lacrosse-scoreboard/sports.py#L241-L251
  • plugins/lacrosse-scoreboard/sports.py#L1554-L1564
  • plugins/lacrosse-scoreboard/sports.py#L2055-L2064
🤖 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/hockey-scoreboard/sports.py` around lines 240 - 250, Ensure display()
reports rendering failures as False instead of returning True after exceptions
are swallowed by _draw_scorebug_layout() helpers. Apply the fix at
plugins/hockey-scoreboard/sports.py lines 240-250, 1553-1563, and 2055-2065, and
plugins/lacrosse-scoreboard/sports.py lines 241-251, 1554-1564, and 2055-2064:
either have each draw helper return an explicit success status that display()
propagates, or allow rendering exceptions to reach display() before its success
return; preserve existing error logging and successful True results.


def _load_custom_font_from_element_config(
self,
Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -1983,16 +2002,22 @@ 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:
self.display_manager.clear()
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()
Expand Down Expand Up @@ -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):
Expand Down
130 changes: 130 additions & 0 deletions plugins/hockey-scoreboard/test_empty_mode_signals_no_content.py
Original file line number Diff line number Diff line change
@@ -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: <core-venv>/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()
8 changes: 7 additions & 1 deletion plugins/lacrosse-scoreboard/manifest.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading