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
40 changes: 16 additions & 24 deletions plugins.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,8 @@
"last_updated": "2026-10-05",
"verified": true,
"screenshot": "",
"latest_version": "1.59.0",
"ledmatrix_min_version": "3.8.1",
"commit": "751e973768d3205b420e9bed9c03df8601678601"
"latest_version": "1.59.1",
"ledmatrix_min_version": "3.8.1"
},
{
"id": "basketball-scoreboard",
Expand All @@ -107,9 +106,8 @@
"last_updated": "2026-10-05",
"verified": true,
"screenshot": "",
"latest_version": "1.44.0",
"ledmatrix_min_version": "3.8.1",
"commit": "751e973768d3205b420e9bed9c03df8601678601"
"latest_version": "1.44.1",
"ledmatrix_min_version": "3.8.1"
},
{
"id": "calendar",
Expand Down Expand Up @@ -385,10 +383,9 @@
"last_updated": "2026-10-05",
"verified": true,
"screenshot": "",
"latest_version": "1.44.0",
"latest_version": "1.44.1",
"icon": "fas fa-hockey-puck",
"ledmatrix_min_version": "3.8.1",
"commit": "751e973768d3205b420e9bed9c03df8601678601"
"ledmatrix_min_version": "3.8.1"
},
{
"id": "lacrosse-scoreboard",
Expand All @@ -411,10 +408,9 @@
"last_updated": "2026-10-05",
"verified": true,
"screenshot": "",
"latest_version": "1.37.0",
"latest_version": "1.37.1",
"icon": "fas fa-baseball-ball",
"ledmatrix_min_version": "3.8.1",
"commit": "540fd24022abf04f66b0657d923cac8ee0453aad"
"ledmatrix_min_version": "3.8.1"
},
{
"id": "birdnet-go",
Expand Down Expand Up @@ -847,9 +843,8 @@
"last_updated": "2026-10-05",
"verified": true,
"screenshot": "",
"latest_version": "2.41.0",
"ledmatrix_min_version": "3.8.1",
"commit": "751e973768d3205b420e9bed9c03df8601678601"
"latest_version": "2.41.1",
"ledmatrix_min_version": "3.8.1"
},
{
"id": "static-image",
Expand Down Expand Up @@ -1007,10 +1002,9 @@
"last_updated": "2026-10-05",
"verified": true,
"screenshot": "",
"latest_version": "1.20.0",
"latest_version": "1.20.1",
"icon": "fas fa-fist-raised",
"ledmatrix_min_version": "3.8.1",
"commit": "540fd24022abf04f66b0657d923cac8ee0453aad"
"ledmatrix_min_version": "3.8.1"
},
{
"id": "weather",
Expand Down Expand Up @@ -1164,10 +1158,9 @@
"downloads": 0,
"verified": true,
"screenshot": "",
"latest_version": "1.37.0",
"latest_version": "1.37.1",
"last_updated": "2026-10-05",
"ledmatrix_min_version": "3.8.1",
"commit": "751e973768d3205b420e9bed9c03df8601678601"
"ledmatrix_min_version": "3.8.1"
},
{
"id": "tidbyt-baseball-scoreboard",
Expand Down Expand Up @@ -1213,9 +1206,8 @@
"last_updated": "2026-10-05",
"verified": true,
"screenshot": "",
"latest_version": "1.36.0",
"ledmatrix_min_version": "3.8.1",
"commit": "751e973768d3205b420e9bed9c03df8601678601"
"latest_version": "1.36.1",
"ledmatrix_min_version": "3.8.1"
},
{
"id": "jellyfin-now-playing",
Expand Down
13 changes: 13 additions & 0 deletions plugins/afl-scoreboard/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# Changelog

## [1.37.1] - 2026-10-05

### Fixed
- A board showing only its favourites' live games no longer wakes for other
teams' games. The live loop handed every game on the scoreboard to core's
idle back-off as a start to wake for, and each one holds the poll at the
live update interval for 15 minutes, so on a busy slate the board fetched
the whole scoreboard about every 30 s for games it then filtered out. Only
games the live filter would show are offered now (football-scoreboard 3.18.7
made the same change). Excluded teams' games are never offered. Boards
showing every live game are unchanged.
`test_kickoff_offer_follows_the_live_filter.py` covers the filter.

## [1.37.0] - 2026-10-05

### Changed
Expand Down
8 changes: 7 additions & 1 deletion plugins/afl-scoreboard/manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"id": "afl-scoreboard",
"name": "AFL Scoreboard",
"version": "1.37.0",
"version": "1.37.1",
"author": "ChuckBuilds",
"description": "Live, recent, and upcoming AFL (Australian Football League) games with real-time scores and game status.",
"category": "sports",
Expand All @@ -18,6 +18,12 @@
"afl_upcoming"
],
"versions": [
{
"version": "1.37.1",
"released": "2026-10-05",
"ledmatrix_min_version": "3.8.1",
"notes": "A favourites-only board no longer wakes for other teams' games. Every game the live fetch saw held the live poll at 30 s for 15 minutes, so on a busy slate it fetched the whole scoreboard about every 30 s for games it then filtered out. Only games the live filter would show are offered now, and excluded teams are honoured the same way. Boards that show every live game are unchanged."
},
{
"version": "1.37.0",
"released": "2026-10-05",
Expand Down
11 changes: 10 additions & 1 deletion plugins/afl-scoreboard/sports.py
Original file line number Diff line number Diff line change
Expand Up @@ -3626,9 +3626,18 @@ def update(self):
# starts, so it cannot sleep through a kickoff.
# getattr-guarded: the core version floor is
# advisory, so an older core must stay loadable.
#
# Only a game this board would show once it is live.
# Every kickoff holds the poll at the live cadence for a
# quarter of an hour, so offering the games the live
# filter then drops kept a favourites-only board polling
# the whole scoreboard all day (football 3.18.7).
_note_start = getattr(
self, "_note_scheduled_start_candidate", None)
if _note_start is not None:
if (_note_start is not None and details
and self._classify_live_game(
details.get("home_abbr"),
details.get("away_abbr"))):
_note_start(details)
if details:
# Log game status for debugging
Expand Down
201 changes: 201 additions & 0 deletions plugins/afl-scoreboard/test_kickoff_offer_follows_the_live_filter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
#!/usr/bin/env python3
"""
Tests that the live loop offers the idle back-off only the kickoffs it would show.

The core mixin clamps the idle back-off to the next known kickoff and then
holds the live cadence for a quarter of an hour after it
(_clamp_to_scheduled_start / _note_scheduled_start_candidate, tested in the
core's test_sports_shared.py). It learns the kickoffs from this loop, which
offers the games the live fetch already downloaded. What is pinned here is
which games it offers:

* every game the board would show once it is live -- a favourites-only
board offers only its favourites' games, and an excluded team's game is
never offered. Each kickoff holds the poll at live cadence for 15
minutes, so offering games the board then filters out kept a
favourites-only board polling the whole scoreboard all day on a busy
slate (football-scoreboard 3.18.7 found it; this is the same rule);
* a board that shows every live game is unchanged: it offers every game,
upcoming ones included, even when nothing is live;
* the call is getattr-guarded, so a core without the method still loads.

Built on the same real-SportsLive scaffolding as football-scoreboard's
test_kickoff_wakes_the_idle_poll.py.

Run: <core-venv>/bin/python plugins/afl-scoreboard/test_kickoff_offer_follows_the_live_filter.py
"""

# A test harness: it reaches into protected members on purpose, builds a
# concrete subclass at runtime, and accepts arguments only to match the
# signatures it stands in for -- none of which pylint can see as intentional.
# pylint: disable=protected-access,abstract-class-instantiated,unused-argument
# pylint: disable=broad-exception-caught

import os
import sys
import threading
from pathlib import Path

plugin_dir = Path(__file__).resolve().parent
sys.path.insert(0, str(plugin_dir))
_core = os.environ.get("LEDMATRIX_CORE")
if _core and (Path(_core) / "src").is_dir():
sys.path.insert(0, _core)

try:
import sports # noqa: E402
except ImportError as exc: # no core checkout on the path
print("SKIP: cannot import sports.py without a LEDMatrix core (%s)" % exc)
sys.exit(2)

SPORT_KEY = "afl"
#: The keys a game's two sides are matched on by this plugin's live filter.
HOME, AWAY = "home_abbr", "away_abbr"
FAV, FAV_OPP, OTHER_H, OTHER_A = ("GEEL", "COLL", "CARL", "ESS")
#: The abbreviation each side shows, where it is not the key itself.
ABBR = {}


class _Logger:
def info(self, *a, **k): pass
def debug(self, *a, **k): pass
def warning(self, *a, **k): pass
def error(self, *a, **k): pass
def exception(self, *a, **k): pass


class _StubCore:
def __init__(self, config, display_manager, cache_manager, logger, sport_key):
self.logger = logger
self.config = config
self.mode_config = {}


_Concrete = type("_ConcreteLive", (sports.SportsLive,),
{name: (lambda self, *a, **k: None)
for name in getattr(sports.SportsLive,
"__abstractmethods__", ())})


def _live():
real_init = sports.SportsCore.__init__
sports.SportsCore.__init__ = _StubCore.__init__
try:
return _Concrete({}, object(), object(), _Logger(), SPORT_KEY)
finally:
sports.SportsCore.__init__ = real_init


failures = []


def check(label, ok):
print((" PASS " if ok else " FAIL ") + label)
if not ok:
failures.append(label)


def _prime(live):
"""The SportsCore/SportsLive state update() reads, which _StubCore skips."""
live.is_enabled = True
live.live_games = []
live.last_update = 0
live.show_ranking = False
live.test_mode = False
live.no_data_interval = 300
live.update_interval = 30
live.live_idle_max_interval = 900
live._empty_live_streak = 0
live.sport_key = SPORT_KEY
live.league = SPORT_KEY
live.sport = SPORT_KEY
live._games_lock = threading.RLock()
live.favorite_teams = []
live.exclude_teams = []
live.show_all_live = True
live.show_favorite_teams_only = False
live.tournament_mode = False
live.show_odds = False
live._rotation_schedule = []
live.current_game_index = 0
live.current_game = None
live.last_game_switch = 0
live.game_update_timestamps = {}
live.last_log_time = 0
live.log_interval = 300
live.stale_game_timeout = 600
live._check_for_score = lambda *a, **k: None
return live


def _drive(events, **settings):
"""Run one update() pass over a canned payload; return the offered ids."""
live = _prime(_live())
for name, value in settings.items():
setattr(live, name, value)
offered = []
live._fetch_data = lambda *a, **k: {"events": list(events)}
live._extract_game_details = lambda game: dict(game)
live._note_scheduled_start_candidate = lambda details: offered.append(details)
try:
live.update()
except Exception as exc: # noqa: BLE001
print(" (update raised: %r)" % (exc,))
return sorted(d.get("id") for d in offered if isinstance(d, dict)), live


def _game(gid, home, away, **extra):
game = {"id": gid, HOME: home, AWAY: away, "home_abbr": ABBR.get(home, home),
"away_abbr": ABBR.get(away, away), "is_live": False, "is_halftime": False,
"is_final": False}
game.update(extra)
return game


def main():
games = [_game("fav", FAV, FAV_OPP), _game("other", OTHER_H, OTHER_A)]

print("a board showing every live game offers every game")
ids, live = _drive(games)
check("show_all_live offers every game, upcoming ones included",
ids == ["fav", "other"])
check("and no game was treated as live", live.live_games == [])
ids, _ = _drive(games, favorite_teams=[FAV], show_all_live=True,
show_favorite_teams_only=True)
check("show_all_live still offers every game with favourites set",
ids == ["fav", "other"])
ids, _ = _drive(games, favorite_teams=[FAV], show_all_live=False,
show_favorite_teams_only=False)
check("favourites-only off offers every game", ids == ["fav", "other"])
ids, _ = _drive(games, favorite_teams=[], show_all_live=False,
show_favorite_teams_only=True)
check("favourites-only with no favourites offers every game",
ids == ["fav", "other"])

print("\na favourites-only board offers only its favourites' games")
ids, _ = _drive(games, favorite_teams=[FAV], show_all_live=False,
show_favorite_teams_only=True)
check("only the favourite's game was offered (%s)" % ids, ids == ["fav"])
ids, _ = _drive(games, exclude_teams=[OTHER_A])
check("an excluded team's game is never offered (%s)" % ids, ids == ["fav"])
print("\nthe call is getattr-guarded")
live2 = _prime(_live())
live2._note_scheduled_start_candidate = None
live2._fetch_data = lambda *a, **k: {"events": [games[0]]}
live2._extract_game_details = lambda game: dict(game)
raised = None
try:
live2.update()
except Exception as exc: # noqa: BLE001
raised = exc
check("a core without the method does not raise (%r)" % (raised,), raised is None)

if failures:
print("\n%d check(s) failed" % len(failures))
return 1
print("\nall checks passed")
return 0


if __name__ == "__main__":
sys.exit(main())
20 changes: 20 additions & 0 deletions plugins/baseball-scoreboard/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
# Changelog

## [1.59.1] - 2026-10-05

### Fixed
- A board showing only its favourites' live games no longer wakes for other
teams' games. The live loop handed every game on the scoreboard to core's
idle back-off as a start to wake for, and each one holds the poll at the
live update interval for 15 minutes, so on a busy slate the board fetched
the whole scoreboard about every 30 s for games it then filtered out. Only
games the live filter would show are offered now (football-scoreboard 3.18.7
made the same change). Excluded teams' games are never offered. Boards
showing every live game are unchanged. The live filter moved out of
`update()` into `_classify_live_game()`, as in the other scoreboards, so the
live list and the kickoff offer share one rule. Replaying Friday 2026-09-25
under frozen time (09:00-03:00 ET, MLB): favourite LAD (one 22:15 game) 927
-> 620 scoreboard requests, 19.6 -> 13.1 MB on the wire (273 -> 183 MB
decoded); favourites NYY and LAD 1,361 -> 1,293. Favourites' games showed
from the first look after they went live either way. Show-all: the same
1,683 requests and the same games shown.
`test_kickoff_offer_follows_the_live_filter.py` covers the filter.

## [1.59.0] - 2026-10-05

### Changed
Expand Down
Loading
Loading