diff --git a/plugins.json b/plugins.json index 65d4a96c..f2a50b1c 100644 --- a/plugins.json +++ b/plugins.json @@ -1,6 +1,6 @@ { "version": "1.0.0", - "last_updated": "2026-08-18", + "last_updated": "2026-08-19", "plugins": [ { "id": "cricket-scoreboard", @@ -76,7 +76,7 @@ "last_updated": "2026-08-13", "verified": true, "screenshot": "", - "latest_version": "1.26.1" + "latest_version": "1.27.0" }, { "id": "basketball-scoreboard", @@ -101,7 +101,7 @@ "last_updated": "2026-08-13", "verified": true, "screenshot": "", - "latest_version": "1.14.1" + "latest_version": "1.15.0" }, { "id": "calendar", @@ -240,7 +240,7 @@ "last_updated": "2026-08-13", "verified": true, "screenshot": "", - "latest_version": "2.16.1" + "latest_version": "2.17.0" }, { "id": "geochron", @@ -335,7 +335,7 @@ "last_updated": "2026-08-13", "verified": true, "screenshot": "", - "latest_version": "1.11.1", + "latest_version": "1.12.0", "icon": "fas fa-hockey-puck" }, { @@ -359,7 +359,7 @@ "last_updated": "2026-08-13", "verified": true, "screenshot": "", - "latest_version": "1.11.1", + "latest_version": "1.12.0", "icon": "fas fa-baseball-ball" }, { @@ -760,7 +760,7 @@ "last_updated": "2026-08-13", "verified": true, "screenshot": "", - "latest_version": "2.11.1" + "latest_version": "2.12.0" }, { "id": "static-image", @@ -905,7 +905,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.5.1", + "latest_version": "1.6.0", "icon": "fas fa-fist-raised" }, { @@ -1048,7 +1048,7 @@ "downloads": 0, "verified": true, "screenshot": "", - "latest_version": "1.8.1", + "latest_version": "1.9.0", "last_updated": "2026-08-13" }, { @@ -1095,7 +1095,7 @@ "last_updated": "2026-08-13", "verified": true, "screenshot": "", - "latest_version": "1.8.1" + "latest_version": "1.9.0" }, { "id": "jellyfin-now-playing", diff --git a/plugins/afl-scoreboard/config_schema.json b/plugins/afl-scoreboard/config_schema.json index 513a3b57..a6beea3e 100644 --- a/plugins/afl-scoreboard/config_schema.json +++ b/plugins/afl-scoreboard/config_schema.json @@ -1271,6 +1271,24 @@ "title": "Schedule lookahead (days)", "description": "How far ahead to fetch games for the Upcoming screen. Raise it for a league that plays weekly: a fixture just beyond this horizon is not fetched at all, so it never reaches the board even though the date is known.", "x-advanced": true + }, + "no_data_interval_seconds": { + "type": "integer", + "default": 300, + "minimum": 5, + "maximum": 86400, + "title": "Idle live-check interval (seconds)", + "description": "How long to wait between checks for live games when there are none. Backs off further the longer nothing is found, up to the cap below.", + "x-advanced": true + }, + "live_idle_max_interval_seconds": { + "type": "integer", + "default": 900, + "minimum": 5, + "maximum": 86400, + "title": "Maximum idle live-check interval (seconds)", + "description": "Ceiling for the back-off above. Raise it to poll an out-of-season league even less; lower it to notice the first game after a quiet spell sooner.", + "x-advanced": true } }, "additionalProperties": false, diff --git a/plugins/afl-scoreboard/manager.py b/plugins/afl-scoreboard/manager.py index 020cbb1f..a1aff837 100644 --- a/plugins/afl-scoreboard/manager.py +++ b/plugins/afl-scoreboard/manager.py @@ -55,6 +55,15 @@ from afl_timezone import resolve_timezone_name from afl_favorite_check import FavoriteTeamCheck + +_ROOT_CONFIG_KEYS = ( + "schedule_lookback_days", + "schedule_lookahead_days", + "no_data_interval_seconds", + "live_idle_max_interval_seconds", +) + + # Which ESPN endpoint backs the league, for the favorite-team diagnostic. FAVORITE_CHECK_KEY = 'afl' FAVORITE_CHECK_LEAGUES = {FAVORITE_CHECK_KEY: ('AFL', 'australian-football/afl')} @@ -308,13 +317,15 @@ def _adapt_config_for_manager(self) -> Dict[str, Any]: "customization": customization_config, }) - # The schedule-window settings live at the plugin config root, and - # SportsCore reads them from the root of the config it is handed. This - # adapter builds its output key by key, so anything not named here is - # dropped -- which silently pinned every user to the defaults. - for _window_key in ("schedule_lookback_days", "schedule_lookahead_days"): - if _window_key in self.config: - manager_config[_window_key] = self.config[_window_key] + # Plugin-root settings that SportsCore reads from the root of the config + # it is handed. This adapter builds its output key by key, so anything + # not named here is dropped -- which is how the schedule window silently + # pinned every user to the defaults, and would have done the same to the + # idle-poll settings. Generalised to a list so the next one added to + # SportsCore only has to be named once. + for _root_key in _ROOT_CONFIG_KEYS: + if _root_key in self.config: + manager_config[_root_key] = self.config[_root_key] return manager_config def _parse_display_mode_settings(self) -> Dict[str, str]: diff --git a/plugins/afl-scoreboard/manifest.json b/plugins/afl-scoreboard/manifest.json index 45937aca..13d1d7d4 100644 --- a/plugins/afl-scoreboard/manifest.json +++ b/plugins/afl-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "afl-scoreboard", "name": "AFL Scoreboard", - "version": "1.8.1", + "version": "1.9.0", "author": "ChuckBuilds", "description": "Live, recent, and upcoming AFL (Australian Football League) games with real-time scores and game status.", "category": "sports", @@ -18,6 +18,13 @@ "afl_upcoming" ], "versions": [ + { + "version": "1.9.0", + "released": "2026-08-19", + "ledmatrix_min_version": "2.0.0", + "notes": "Make the schedule fetch window configurable. How far back and forward games are fetched was hard-coded at 14 days back and 7 forward. Days is the wrong unit for a league that plays weekly -- a whole matchweek can fall just past a short horizon and never reach the board -- so both are now advanced settings, bounded 1-60 days, with a stray or non-numeric value falling back to the default rather than raising. Defaults keep today's behaviour.", + "changes": "Back off the live-game poll while a league has nothing on, and let both the idle interval and its ceiling be configured. Saves API calls out of season and between games without delaying an in-progress game, which still resets the interval on the first fetch that finds one." + }, { "version": "1.8.1", "released": "2026-08-18", diff --git a/plugins/afl-scoreboard/sports.py b/plugins/afl-scoreboard/sports.py index 09051491..02b11344 100644 --- a/plugins/afl-scoreboard/sports.py +++ b/plugins/afl-scoreboard/sports.py @@ -101,6 +101,28 @@ def _clamp_window(value: Any, fallback: int) -> int: return max(_MIN_WINDOW_DAYS, min(_MAX_WINDOW_DAYS, days)) +# Backing off the live poll while a league has nothing on. Gentle at first -- +# a gap between games in a live season should cost little -- then firmer, so a +# league months out of season stops polling on a live cadence altogether. +_IDLE_SHORT_STREAK = 6 +_IDLE_SHORT_FACTOR = 2 +_IDLE_LONG_STREAK = 24 +_IDLE_LONG_FACTOR = 6 +_DEFAULT_LIVE_IDLE_MAX_SECONDS = 900 + + +def _clamp_seconds(value: Any, fallback: int, low: int = 5, + high: int = 86400) -> int: + """An interval in seconds, or the fallback when the value is unusable.""" + try: + seconds = int(value) + except (TypeError, ValueError, OverflowError): + # OverflowError: json parses bare Infinity by default and int(inf) + # raises -- the same gap _clamp_window above already covers. + return fallback + return max(low, min(high, seconds)) + + class SportsCore(ABC): def __init__( self, @@ -2493,7 +2515,20 @@ def __init__( ): super().__init__(config, display_manager, cache_manager, logger, sport_key) self.update_interval = self.mode_config.get("live_update_interval", 15) - self.no_data_interval = 300 + # Read from the config root, where the schema declares them and the web + # UI writes them -- not from mode_config, which is the per-league block + # ({sport}_scoreboard) and never carries these keys. Looking them up + # there meant the saved value was invisible and every user silently kept + # the default. mode_config is still consulted as a fallback so a + # hand-placed per-league value keeps working. + self.no_data_interval = _clamp_seconds( + self.config.get("no_data_interval_seconds", + self.mode_config.get("no_data_interval_seconds")), 300) + self.live_idle_max_interval = _clamp_seconds( + self.config.get("live_idle_max_interval_seconds", + self.mode_config.get("live_idle_max_interval_seconds")), + _DEFAULT_LIVE_IDLE_MAX_SECONDS) + self._empty_live_streak = 0 # Log the configured interval for debugging self.logger.info( f"SportsLive initialized: live_update_interval={self.update_interval}s, " @@ -3032,6 +3067,43 @@ def _detect_stale_games(self, games: List[Dict]) -> None: if game_id in self.game_update_timestamps: del self.game_update_timestamps[game_id] + def _idle_live_interval(self) -> int: + """How long to wait before looking for live games again, when there are none. + + Escalates the longer nothing turns up, and any live game resets it, so + an in-season gap between games costs at most one escalated wait while + an out-of-season league stops polling on a live cadence entirely. + + Capped rather than unbounded: the cost of backing off is how late the + first game after a quiet spell is noticed, and past the cap the saving + stops being worth that. + """ + streak = getattr(self, "_empty_live_streak", 0) + base = self.no_data_interval + ceiling = getattr(self, "live_idle_max_interval", + _DEFAULT_LIVE_IDLE_MAX_SECONDS) + # The ceiling bounds the un-escalated interval too. The two settings are + # independent integers with no cross-validation, so base > ceiling is a + # reachable config -- and returning base unclamped there made the wait + # *shrink* as the streak grew (3600s at streak 0, 900s at streak 24), + # the opposite of what the setting named "maximum" promises. + if streak >= _IDLE_LONG_STREAK: + return min(int(base * _IDLE_LONG_FACTOR), ceiling) + if streak >= _IDLE_SHORT_STREAK: + return min(int(base * _IDLE_SHORT_FACTOR), ceiling) + return min(base, ceiling) + + def _note_live_fetch(self, found_live: bool) -> None: + """Record whether a look for live games found any.""" + if found_live: + if getattr(self, "_empty_live_streak", 0): + self.logger.info( + "Live games found after %d empty check(s); back to the " + "live update interval", self._empty_live_streak) + self._empty_live_streak = 0 + else: + self._empty_live_streak = getattr(self, "_empty_live_streak", 0) + 1 + def update(self): """Update live game data and handle game switching.""" if not self.is_enabled: @@ -3056,21 +3128,25 @@ def update(self): # Only use no_data_interval if we've recently checked and confirmed there are no live games. # This ensures we check for live games frequently even if the list is temporarily empty. # Only use no_data_interval if we have no live games AND we've checked recently (within last 5 minutes) - time_since_last_update = current_time - self.last_update - has_recently_checked = self.last_update > 0 and time_since_last_update < 300 - + # Whether the last look found anything, tracked explicitly rather than + # inferred from how long ago it was. The old form asked "did we check + # within the last 300s?" and only then used no_data_interval -- but + # once 300s had elapsed the answer became no, the interval dropped + # back to live_update_interval, and it fetched. no_data_interval could + # therefore never delay anything past 300s whatever it was set to. + # Measured on a live rig: an out-of-season NHL polled every ~5.5 + # minutes around the clock, returning nothing every time. if _live_games_attr: - # We have live games, use the configured update interval interval = _update_interval_attr - elif has_recently_checked: - # We've checked recently and found no live games, use longer interval - interval = _no_data_interval_attr else: - # First check or haven't checked in a while, use update interval to check for live games - interval = _update_interval_attr + interval = self._idle_live_interval() # Original line from traceback (line 455), now with variables defined: if current_time - self.last_update >= interval: + # What the previous look found, recorded before this one + # replaces it. The streak is what drives the back-off, and + # any live game resets it. + self._note_live_fetch(bool(_live_games_attr)) self.last_update = current_time # Fetch rankings if enabled diff --git a/plugins/afl-scoreboard/test_idle_league_backoff.py b/plugins/afl-scoreboard/test_idle_league_backoff.py new file mode 100644 index 00000000..5bfa154c --- /dev/null +++ b/plugins/afl-scoreboard/test_idle_league_backoff.py @@ -0,0 +1,308 @@ +#!/usr/bin/env python3 +""" +Tests that a league with nothing on stops polling on a live cadence. + +Regression under test: the live gate decided its interval like this -- + + has_recently_checked = self.last_update > 0 and time_since_last_update < 300 + if live_games: interval = live_update_interval + elif has_recently_checked: interval = no_data_interval + else: interval = live_update_interval + +Once 300s had elapsed, has_recently_checked became False, the interval fell +back to live_update_interval, and it fetched. no_data_interval could therefore +never delay anything past 300s whatever it was set to -- the setting was +inert. Measured on a live rig in mid-August: NHLLiveManager fetched 0 games 22 +times in 2 hours, every ~5.5 minutes, around the clock, for a league whose +season had not started. Roughly 264 wasted requests a day, per league. + +The interval now comes from an explicit streak of empty looks, which escalates +and is capped, and which any live game resets. + +Second regression under test: the two interval settings were read from +``self.mode_config`` -- the per-league ``{sport_key}_scoreboard`` block -- while +the schema declares them (and the web UI writes them) at the config *root*. The +saved value was therefore never seen and every user silently kept the default. +An earlier version of this test could not catch that, because it set +``no_data_interval`` on a stand-in object by hand and so never ran the lookup. +It now builds a real ``SportsLive`` and asserts on what its ``__init__`` +resolved. + +Only ``SportsCore.__init__`` is stubbed -- it pulls in logo downloading, fonts +and an ESPN data source, none of which this test needs -- so the lookup lines +themselves run for real. The stub's fidelity is asserted against the real +``SportsCore`` source below, so it cannot drift out of sync unnoticed. + +Run: /bin/python plugins/afl-scoreboard/test_idle_league_backoff.py +""" + +# A test harness: it reaches into protected members on purpose and builds a +# concrete subclass at runtime, neither of which pylint can see as intentional. +# pylint: disable=protected-access,abstract-class-instantiated,unused-argument +import ast +import sys +from pathlib import Path + +plugin_dir = Path(__file__).resolve().parent +sys.path.insert(0, str(plugin_dir)) +for candidate in (Path("/home/rackpi/projects/LEDMatrix"), + plugin_dir.parents[2] / "LEDMatrix"): + if (candidate / "src" / "plugin_system" / "base_plugin.py").exists(): + sys.path.insert(0, str(candidate)) + break + +import sports # noqa: E402 + +failures = [] + + +def check(label, ok): + print((" PASS " if ok else " FAIL ") + label) + if not ok: + failures.append(label) + + +class _Logger: + def info(self, *a, **k): + pass + + +SPORT_KEY = "test" + + +def _core_init(): + core_src = (plugin_dir / "sports.py").read_text(encoding="utf-8") + core = next(c for c in ast.walk(ast.parse(core_src)) + if isinstance(c, ast.ClassDef) and c.name == "SportsCore") + return core_src, next(m for m in core.body + if isinstance(m, ast.FunctionDef) and m.name == "__init__") + + +def _derive_mode_key(): + """Build the per-league key exactly as this plugin's SportsCore does. + + Not hardcoded: most plugins use f"{sport_key}_scoreboard" but ufc-scoreboard + uses the bare sport_key. Hardcoding one of them made the stub test fiction + on the other -- which the fidelity check below caught. Reading the real + expression out of the source keeps every plugin honest. + """ + _, init = _core_init() + for node in ast.walk(init): + if (isinstance(node, ast.Assign) + and any(getattr(t, "attr", None) == "mode_config" + for t in node.targets) + and isinstance(node.value, ast.Call) and node.value.args): + arg = node.value.args[0] + return _resolve_key(arg), ast.unparse(arg) + raise AssertionError("SportsCore no longer assigns self.mode_config") + + +def _resolve_key(node): + """Evaluate the key expression by hand -- deliberately not with eval(). + + Only the two shapes that actually occur are supported; anything else is a + hard error rather than a guess, so a future rewrite of that line surfaces + here instead of silently producing a key nothing is stored under. + """ + if isinstance(node, ast.Constant): + return node.value + if isinstance(node, ast.Name) and node.id == "sport_key": + return SPORT_KEY + if isinstance(node, ast.JoinedStr): + return "".join(_resolve_key(part) for part in node.values) + if isinstance(node, ast.FormattedValue): + return _resolve_key(node.value) + raise AssertionError( + "unsupported mode_config key expression: %s" % ast.unparse(node)) + + +MODE_KEY, _MODE_KEY_EXPR = _derive_mode_key() + + +def _assert_stub_is_faithful(): + """The stub below stands in for SportsCore.__init__; prove it still matches. + + It has to reproduce exactly the two assignments the lookup under test + depends on. If SportsCore ever changes where config or mode_config come + from, this fails loudly rather than letting the stub quietly test fiction. + """ + _, init = _core_init() + assigns = {} + for node in ast.walk(init): + if isinstance(node, ast.Assign): + for target in node.targets: + if getattr(target, "attr", None) in ("config", "mode_config"): + assigns[target.attr] = node.value + + # The stub is only faithful if SportsCore still (a) keeps the handed-in + # config verbatim as self.config -- if it ever stored a sub-dict, "root" + # would mean something different -- and (b) derives mode_config as a + # lookup *within* that same config. The key itself is read from source + # above, so it needs no assertion; these two shapes do. + raw = assigns.get("config") + check("SportsCore still keeps the handed-in config verbatim as self.config", + isinstance(raw, ast.Name) and raw.id == "config") + + mode = assigns.get("mode_config") + check("mode_config is still a lookup inside that same config", + isinstance(mode, ast.Call) + and isinstance(mode.func, ast.Attribute) + and mode.func.attr == "get" + and isinstance(mode.func.value, ast.Name) + and mode.func.value.id == "config") + + +class _StubCore: + """Exactly the two SportsCore assignments the lookup under test reads.""" + # pylint: disable=too-few-public-methods + + def __init__(self, config, display_manager, cache_manager, logger, sport_key): + self.logger = logger + self.config = config + self.mode_config = config.get(MODE_KEY, {}) or {} + + +# SportsLive may carry abstract methods from SportsCore; fill them in so it can +# be constructed. They are never called here. +_Concrete = type("_ConcreteLive", (sports.SportsLive,), + {name: (lambda self, *a, **k: None) + for name in getattr(sports.SportsLive, + "__abstractmethods__", ())}) + + +def _live(root=None, league=None): + """A real SportsLive built from a real config dict. + + root/league place the settings at the config root and in the per-league + block respectively, which is the whole point: the test can now tell those + two locations apart. + """ + config = dict(root or {}) + config[MODE_KEY] = dict(league or {}) + real_init = sports.SportsCore.__init__ + sports.SportsCore.__init__ = _StubCore.__init__ + try: + return _Concrete(config, object(), object(), _Logger(), SPORT_KEY) + finally: + sports.SportsCore.__init__ = real_init + + +def _Live(base=300, ceiling=900): + """The old signature, now backed by a real config round-trip.""" + return _live(root={"no_data_interval_seconds": base, + "live_idle_max_interval_seconds": ceiling}) + + +def main(): + print("the settings are read from where they are actually written") + _assert_stub_is_faithful() + + at_root = _live(root={"no_data_interval_seconds": 600, + "live_idle_max_interval_seconds": 1200}) + check("a root value is honoured (the schema declares them at the root)", + at_root.no_data_interval == 600) + check("...including the ceiling", at_root.live_idle_max_interval == 1200) + + defaults = _live() + check("absent anywhere falls back to the default", + defaults.no_data_interval == 300) + + legacy = _live(league={"no_data_interval_seconds": 600}) + check("a hand-placed per-league value still works", + legacy.no_data_interval == 600) + + both = _live(root={"no_data_interval_seconds": 600}, + league={"no_data_interval_seconds": 45}) + check("the root wins over a stale per-league value", + both.no_data_interval == 600) + + junk = _live(root={"no_data_interval_seconds": "soon", + "live_idle_max_interval_seconds": float("inf")}) + check("garbage at the root falls back rather than raising", + junk.no_data_interval == 300) + check("an infinite ceiling falls back too", + junk.live_idle_max_interval == sports._DEFAULT_LIVE_IDLE_MAX_SECONDS) + + print("\nthe wait grows the longer nothing is found") + live = _Live() + check("first look uses the base interval", + live._idle_live_interval() == 300) + + for _ in range(sports._IDLE_SHORT_STREAK): + live._note_live_fetch(False) + check("after a short streak it is longer (%ds)" % live._idle_live_interval(), + live._idle_live_interval() > 300) + + short_wait = live._idle_live_interval() + for _ in range(sports._IDLE_LONG_STREAK): + live._note_live_fetch(False) + long_wait = live._idle_live_interval() + # Against short_wait, not against itself: comparing the value to a second + # call of the same method with the same state can never fail, so the + # monotonicity it is meant to protect went untested. + check("after a long streak it is longer still (%ds vs %ds)" + % (long_wait, short_wait), long_wait > short_wait) + check("and never exceeds the ceiling", long_wait <= 900) + + for _ in range(500): + live._note_live_fetch(False) + check("a very long streak stays at the ceiling, not beyond", + live._idle_live_interval() == 900) + + print("\na live game resets it immediately") + live._note_live_fetch(True) + check("the streak is cleared", live._empty_live_streak == 0) + check("and the base interval is back", + live._idle_live_interval() == 300) + + print("\nthe saving is real, and bounded") + idle = _Live() + for _ in range(500): + idle._note_live_fetch(False) + per_day = 86400 / idle._idle_live_interval() + check("an out-of-season league polls far less (%d/day vs 288)" % per_day, + per_day < 288 / 2) + check("...but still often enough to notice a season starting (<= 1h)", + idle._idle_live_interval() <= 3600) + + print("\nthe ceiling is configurable") + tight = _Live(ceiling=300) + for _ in range(500): + tight._note_live_fetch(False) + check("a lower ceiling is honoured", tight._idle_live_interval() == 300) + loose = _Live(ceiling=3600) + for _ in range(500): + loose._note_live_fetch(False) + check("a higher ceiling is honoured", loose._idle_live_interval() == 1800) + + print("\nintervals from config are clamped, never trusted raw") + check("a sane value is used", sports._clamp_seconds(120, 300) == 120) + check("absent falls back", sports._clamp_seconds(None, 300) == 300) + check("nonsense falls back", sports._clamp_seconds("soon", 300) == 300) + check("zero is clamped up", sports._clamp_seconds(0, 300) >= 5) + check("a week is clamped down", sports._clamp_seconds(604800, 300) <= 86400) + + print("\nthe old recency-based logic is gone") + src = (plugin_dir / "sports.py").read_text(encoding="utf-8") + check("has_recently_checked no longer decides the interval", + "has_recently_checked" not in src) + + tree = ast.parse(src) + live_cls = next(c for c in ast.walk(tree) + if isinstance(c, ast.ClassDef) and c.name == "SportsLive") + upd = next(m for m in live_cls.body + if isinstance(m, ast.FunctionDef) and m.name == "update") + idle_calls = [n for n in ast.walk(upd) if isinstance(n, ast.Call) + and getattr(n.func, "attr", None) == "_idle_live_interval"] + note_calls = [n for n in ast.walk(upd) if isinstance(n, ast.Call) + and getattr(n.func, "attr", None) == "_note_live_fetch"] + check("update() takes its idle interval from the back-off", len(idle_calls) == 1) + check("update() records each look's outcome", len(note_calls) == 1) + + print("\n%s" % ("FAILED: %d" % len(failures) if failures + else "All checks passed")) + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/baseball-scoreboard/config_schema.json b/plugins/baseball-scoreboard/config_schema.json index 603a7e86..cf2bea98 100644 --- a/plugins/baseball-scoreboard/config_schema.json +++ b/plugins/baseball-scoreboard/config_schema.json @@ -2539,6 +2539,24 @@ "title": "Schedule lookahead (days)", "description": "How far ahead to fetch games for the Upcoming screen. Raise it for a league that plays weekly: a fixture just beyond this horizon is not fetched at all, so it never reaches the board even though the date is known.", "x-advanced": true + }, + "no_data_interval_seconds": { + "type": "integer", + "default": 300, + "minimum": 5, + "maximum": 86400, + "title": "Idle live-check interval (seconds)", + "description": "How long to wait between checks for live games when there are none. Backs off further the longer nothing is found, up to the cap below.", + "x-advanced": true + }, + "live_idle_max_interval_seconds": { + "type": "integer", + "default": 900, + "minimum": 5, + "maximum": 86400, + "title": "Maximum idle live-check interval (seconds)", + "description": "Ceiling for the back-off above. Raise it to poll an out-of-season league even less; lower it to notice the first game after a quiet spell sooner.", + "x-advanced": true } }, "additionalProperties": false, diff --git a/plugins/baseball-scoreboard/manager.py b/plugins/baseball-scoreboard/manager.py index 61b03d09..53658809 100644 --- a/plugins/baseball-scoreboard/manager.py +++ b/plugins/baseball-scoreboard/manager.py @@ -53,6 +53,15 @@ from baseball_timezone import resolve_timezone_name from baseball_favorite_check import FavoriteTeamCheck + +_ROOT_CONFIG_KEYS = ( + "schedule_lookback_days", + "schedule_lookahead_days", + "no_data_interval_seconds", + "live_idle_max_interval_seconds", +) + + # Which ESPN endpoint backs each league, for the favorite-team diagnostic. FAVORITE_CHECK_LEAGUES = { 'mlb': ('MLB', 'baseball/mlb'), @@ -765,13 +774,15 @@ def _adapt_config_for_manager(self, league: str) -> Dict[str, Any]: self.logger.debug(f"Using timezone: {timezone_str} for {league} managers") - # The schedule-window settings live at the plugin config root, and - # SportsCore reads them from the root of the config it is handed. This - # adapter builds its output key by key, so anything not named here is - # dropped -- which silently pinned every user to the defaults. - for _window_key in ("schedule_lookback_days", "schedule_lookahead_days"): - if _window_key in self.config: - manager_config[_window_key] = self.config[_window_key] + # Plugin-root settings that SportsCore reads from the root of the config + # it is handed. This adapter builds its output key by key, so anything + # not named here is dropped -- which is how the schedule window silently + # pinned every user to the defaults, and would have done the same to the + # idle-poll settings. Generalised to a list so the next one added to + # SportsCore only has to be named once. + for _root_key in _ROOT_CONFIG_KEYS: + if _root_key in self.config: + manager_config[_root_key] = self.config[_root_key] return manager_config def _sub_component_config(self) -> Dict[str, Any]: diff --git a/plugins/baseball-scoreboard/manifest.json b/plugins/baseball-scoreboard/manifest.json index cd73625c..55dfac4d 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.26.1", + "version": "1.27.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,13 @@ "branch": "main", "plugin_path": "plugins/baseball-scoreboard", "versions": [ + { + "version": "1.27.0", + "released": "2026-08-19", + "ledmatrix_min_version": "2.0.0", + "notes": "Make the schedule fetch window configurable. How far back and forward games are fetched was hard-coded at 14 days back and 7 forward. Days is the wrong unit for a league that plays weekly -- a whole matchweek can fall just past a short horizon and never reach the board -- so both are now advanced settings, bounded 1-60 days, with a stray or non-numeric value falling back to the default rather than raising. Defaults keep today's behaviour.", + "changes": "Back off the live-game poll while a league has nothing on, and let both the idle interval and its ceiling be configured. Saves API calls out of season and between games without delaying an in-progress game, which still resets the interval on the first fetch that finds one." + }, { "version": "1.26.1", "released": "2026-08-18", diff --git a/plugins/baseball-scoreboard/sports.py b/plugins/baseball-scoreboard/sports.py index d638aa77..3ab8b86f 100644 --- a/plugins/baseball-scoreboard/sports.py +++ b/plugins/baseball-scoreboard/sports.py @@ -111,6 +111,28 @@ def _clamp_window(value: Any, fallback: int) -> int: return max(_MIN_WINDOW_DAYS, min(_MAX_WINDOW_DAYS, days)) +# Backing off the live poll while a league has nothing on. Gentle at first -- +# a gap between games in a live season should cost little -- then firmer, so a +# league months out of season stops polling on a live cadence altogether. +_IDLE_SHORT_STREAK = 6 +_IDLE_SHORT_FACTOR = 2 +_IDLE_LONG_STREAK = 24 +_IDLE_LONG_FACTOR = 6 +_DEFAULT_LIVE_IDLE_MAX_SECONDS = 900 + + +def _clamp_seconds(value: Any, fallback: int, low: int = 5, + high: int = 86400) -> int: + """An interval in seconds, or the fallback when the value is unusable.""" + try: + seconds = int(value) + except (TypeError, ValueError, OverflowError): + # OverflowError: json parses bare Infinity by default and int(inf) + # raises -- the same gap _clamp_window above already covers. + return fallback + return max(low, min(high, seconds)) + + class SportsCore(ABC): def __init__( self, @@ -2350,7 +2372,20 @@ def __init__( ): super().__init__(config, display_manager, cache_manager, logger, sport_key) self.update_interval = self.mode_config.get("live_update_interval", 15) - self.no_data_interval = 300 + # Read from the config root, where the schema declares them and the web + # UI writes them -- not from mode_config, which is the per-league block + # ({sport}_scoreboard) and never carries these keys. Looking them up + # there meant the saved value was invisible and every user silently kept + # the default. mode_config is still consulted as a fallback so a + # hand-placed per-league value keeps working. + self.no_data_interval = _clamp_seconds( + self.config.get("no_data_interval_seconds", + self.mode_config.get("no_data_interval_seconds")), 300) + self.live_idle_max_interval = _clamp_seconds( + self.config.get("live_idle_max_interval_seconds", + self.mode_config.get("live_idle_max_interval_seconds")), + _DEFAULT_LIVE_IDLE_MAX_SECONDS) + self._empty_live_streak = 0 # Log the configured interval for debugging self.logger.info( f"SportsLive initialized: live_update_interval={self.update_interval}s, " @@ -2517,6 +2552,43 @@ def _detect_stale_games(self, games: List[Dict]) -> None: if game_id in self.game_update_timestamps: del self.game_update_timestamps[game_id] + def _idle_live_interval(self) -> int: + """How long to wait before looking for live games again, when there are none. + + Escalates the longer nothing turns up, and any live game resets it, so + an in-season gap between games costs at most one escalated wait while + an out-of-season league stops polling on a live cadence entirely. + + Capped rather than unbounded: the cost of backing off is how late the + first game after a quiet spell is noticed, and past the cap the saving + stops being worth that. + """ + streak = getattr(self, "_empty_live_streak", 0) + base = self.no_data_interval + ceiling = getattr(self, "live_idle_max_interval", + _DEFAULT_LIVE_IDLE_MAX_SECONDS) + # The ceiling bounds the un-escalated interval too. The two settings are + # independent integers with no cross-validation, so base > ceiling is a + # reachable config -- and returning base unclamped there made the wait + # *shrink* as the streak grew (3600s at streak 0, 900s at streak 24), + # the opposite of what the setting named "maximum" promises. + if streak >= _IDLE_LONG_STREAK: + return min(int(base * _IDLE_LONG_FACTOR), ceiling) + if streak >= _IDLE_SHORT_STREAK: + return min(int(base * _IDLE_SHORT_FACTOR), ceiling) + return min(base, ceiling) + + def _note_live_fetch(self, found_live: bool) -> None: + """Record whether a look for live games found any.""" + if found_live: + if getattr(self, "_empty_live_streak", 0): + self.logger.info( + "Live games found after %d empty check(s); back to the " + "live update interval", self._empty_live_streak) + self._empty_live_streak = 0 + else: + self._empty_live_streak = getattr(self, "_empty_live_streak", 0) + 1 + def update(self): """Update live game data and handle game switching.""" if not self.is_enabled: @@ -2544,21 +2616,25 @@ def update(self): # Only use no_data_interval if we've recently checked and confirmed there are no live games. # This ensures we check for live games frequently even if the list is temporarily empty. # Only use no_data_interval if we have no live games AND we've checked recently (within last 5 minutes) - time_since_last_update = current_time - self.last_update - has_recently_checked = self.last_update > 0 and time_since_last_update < 300 - + # Whether the last look found anything, tracked explicitly rather than + # inferred from how long ago it was. The old form asked "did we check + # within the last 300s?" and only then used no_data_interval -- but + # once 300s had elapsed the answer became no, the interval dropped + # back to live_update_interval, and it fetched. no_data_interval could + # therefore never delay anything past 300s whatever it was set to. + # Measured on a live rig: an out-of-season NHL polled every ~5.5 + # minutes around the clock, returning nothing every time. if _live_games_attr or _test_mode_attr: - # We have live games or are in test mode, use the configured update interval interval = _update_interval_attr - elif has_recently_checked: - # We've checked recently and found no live games, use longer interval - interval = _no_data_interval_attr else: - # First check or haven't checked in a while, use update interval to check for live games - interval = _update_interval_attr + interval = self._idle_live_interval() # Original line from traceback (line 455), now with variables defined: if current_time - self.last_update >= interval: + # What the previous look found, recorded before this one + # replaces it. The streak is what drives the back-off, and + # any live game resets it. + self._note_live_fetch(bool(_live_games_attr)) self.last_update = current_time # Test mode: advance the simulated game instead of fetching real API diff --git a/plugins/baseball-scoreboard/test_idle_league_backoff.py b/plugins/baseball-scoreboard/test_idle_league_backoff.py new file mode 100644 index 00000000..fe2e8ee9 --- /dev/null +++ b/plugins/baseball-scoreboard/test_idle_league_backoff.py @@ -0,0 +1,308 @@ +#!/usr/bin/env python3 +""" +Tests that a league with nothing on stops polling on a live cadence. + +Regression under test: the live gate decided its interval like this -- + + has_recently_checked = self.last_update > 0 and time_since_last_update < 300 + if live_games: interval = live_update_interval + elif has_recently_checked: interval = no_data_interval + else: interval = live_update_interval + +Once 300s had elapsed, has_recently_checked became False, the interval fell +back to live_update_interval, and it fetched. no_data_interval could therefore +never delay anything past 300s whatever it was set to -- the setting was +inert. Measured on a live rig in mid-August: NHLLiveManager fetched 0 games 22 +times in 2 hours, every ~5.5 minutes, around the clock, for a league whose +season had not started. Roughly 264 wasted requests a day, per league. + +The interval now comes from an explicit streak of empty looks, which escalates +and is capped, and which any live game resets. + +Second regression under test: the two interval settings were read from +``self.mode_config`` -- the per-league ``{sport_key}_scoreboard`` block -- while +the schema declares them (and the web UI writes them) at the config *root*. The +saved value was therefore never seen and every user silently kept the default. +An earlier version of this test could not catch that, because it set +``no_data_interval`` on a stand-in object by hand and so never ran the lookup. +It now builds a real ``SportsLive`` and asserts on what its ``__init__`` +resolved. + +Only ``SportsCore.__init__`` is stubbed -- it pulls in logo downloading, fonts +and an ESPN data source, none of which this test needs -- so the lookup lines +themselves run for real. The stub's fidelity is asserted against the real +``SportsCore`` source below, so it cannot drift out of sync unnoticed. + +Run: /bin/python plugins/baseball-scoreboard/test_idle_league_backoff.py +""" + +# A test harness: it reaches into protected members on purpose and builds a +# concrete subclass at runtime, neither of which pylint can see as intentional. +# pylint: disable=protected-access,abstract-class-instantiated,unused-argument +import ast +import sys +from pathlib import Path + +plugin_dir = Path(__file__).resolve().parent +sys.path.insert(0, str(plugin_dir)) +for candidate in (Path("/home/rackpi/projects/LEDMatrix"), + plugin_dir.parents[2] / "LEDMatrix"): + if (candidate / "src" / "plugin_system" / "base_plugin.py").exists(): + sys.path.insert(0, str(candidate)) + break + +import sports # noqa: E402 + +failures = [] + + +def check(label, ok): + print((" PASS " if ok else " FAIL ") + label) + if not ok: + failures.append(label) + + +class _Logger: + def info(self, *a, **k): + pass + + +SPORT_KEY = "test" + + +def _core_init(): + core_src = (plugin_dir / "sports.py").read_text(encoding="utf-8") + core = next(c for c in ast.walk(ast.parse(core_src)) + if isinstance(c, ast.ClassDef) and c.name == "SportsCore") + return core_src, next(m for m in core.body + if isinstance(m, ast.FunctionDef) and m.name == "__init__") + + +def _derive_mode_key(): + """Build the per-league key exactly as this plugin's SportsCore does. + + Not hardcoded: most plugins use f"{sport_key}_scoreboard" but ufc-scoreboard + uses the bare sport_key. Hardcoding one of them made the stub test fiction + on the other -- which the fidelity check below caught. Reading the real + expression out of the source keeps every plugin honest. + """ + _, init = _core_init() + for node in ast.walk(init): + if (isinstance(node, ast.Assign) + and any(getattr(t, "attr", None) == "mode_config" + for t in node.targets) + and isinstance(node.value, ast.Call) and node.value.args): + arg = node.value.args[0] + return _resolve_key(arg), ast.unparse(arg) + raise AssertionError("SportsCore no longer assigns self.mode_config") + + +def _resolve_key(node): + """Evaluate the key expression by hand -- deliberately not with eval(). + + Only the two shapes that actually occur are supported; anything else is a + hard error rather than a guess, so a future rewrite of that line surfaces + here instead of silently producing a key nothing is stored under. + """ + if isinstance(node, ast.Constant): + return node.value + if isinstance(node, ast.Name) and node.id == "sport_key": + return SPORT_KEY + if isinstance(node, ast.JoinedStr): + return "".join(_resolve_key(part) for part in node.values) + if isinstance(node, ast.FormattedValue): + return _resolve_key(node.value) + raise AssertionError( + "unsupported mode_config key expression: %s" % ast.unparse(node)) + + +MODE_KEY, _MODE_KEY_EXPR = _derive_mode_key() + + +def _assert_stub_is_faithful(): + """The stub below stands in for SportsCore.__init__; prove it still matches. + + It has to reproduce exactly the two assignments the lookup under test + depends on. If SportsCore ever changes where config or mode_config come + from, this fails loudly rather than letting the stub quietly test fiction. + """ + _, init = _core_init() + assigns = {} + for node in ast.walk(init): + if isinstance(node, ast.Assign): + for target in node.targets: + if getattr(target, "attr", None) in ("config", "mode_config"): + assigns[target.attr] = node.value + + # The stub is only faithful if SportsCore still (a) keeps the handed-in + # config verbatim as self.config -- if it ever stored a sub-dict, "root" + # would mean something different -- and (b) derives mode_config as a + # lookup *within* that same config. The key itself is read from source + # above, so it needs no assertion; these two shapes do. + raw = assigns.get("config") + check("SportsCore still keeps the handed-in config verbatim as self.config", + isinstance(raw, ast.Name) and raw.id == "config") + + mode = assigns.get("mode_config") + check("mode_config is still a lookup inside that same config", + isinstance(mode, ast.Call) + and isinstance(mode.func, ast.Attribute) + and mode.func.attr == "get" + and isinstance(mode.func.value, ast.Name) + and mode.func.value.id == "config") + + +class _StubCore: + """Exactly the two SportsCore assignments the lookup under test reads.""" + # pylint: disable=too-few-public-methods + + def __init__(self, config, display_manager, cache_manager, logger, sport_key): + self.logger = logger + self.config = config + self.mode_config = config.get(MODE_KEY, {}) or {} + + +# SportsLive may carry abstract methods from SportsCore; fill them in so it can +# be constructed. They are never called here. +_Concrete = type("_ConcreteLive", (sports.SportsLive,), + {name: (lambda self, *a, **k: None) + for name in getattr(sports.SportsLive, + "__abstractmethods__", ())}) + + +def _live(root=None, league=None): + """A real SportsLive built from a real config dict. + + root/league place the settings at the config root and in the per-league + block respectively, which is the whole point: the test can now tell those + two locations apart. + """ + config = dict(root or {}) + config[MODE_KEY] = dict(league or {}) + real_init = sports.SportsCore.__init__ + sports.SportsCore.__init__ = _StubCore.__init__ + try: + return _Concrete(config, object(), object(), _Logger(), SPORT_KEY) + finally: + sports.SportsCore.__init__ = real_init + + +def _Live(base=300, ceiling=900): + """The old signature, now backed by a real config round-trip.""" + return _live(root={"no_data_interval_seconds": base, + "live_idle_max_interval_seconds": ceiling}) + + +def main(): + print("the settings are read from where they are actually written") + _assert_stub_is_faithful() + + at_root = _live(root={"no_data_interval_seconds": 600, + "live_idle_max_interval_seconds": 1200}) + check("a root value is honoured (the schema declares them at the root)", + at_root.no_data_interval == 600) + check("...including the ceiling", at_root.live_idle_max_interval == 1200) + + defaults = _live() + check("absent anywhere falls back to the default", + defaults.no_data_interval == 300) + + legacy = _live(league={"no_data_interval_seconds": 600}) + check("a hand-placed per-league value still works", + legacy.no_data_interval == 600) + + both = _live(root={"no_data_interval_seconds": 600}, + league={"no_data_interval_seconds": 45}) + check("the root wins over a stale per-league value", + both.no_data_interval == 600) + + junk = _live(root={"no_data_interval_seconds": "soon", + "live_idle_max_interval_seconds": float("inf")}) + check("garbage at the root falls back rather than raising", + junk.no_data_interval == 300) + check("an infinite ceiling falls back too", + junk.live_idle_max_interval == sports._DEFAULT_LIVE_IDLE_MAX_SECONDS) + + print("\nthe wait grows the longer nothing is found") + live = _Live() + check("first look uses the base interval", + live._idle_live_interval() == 300) + + for _ in range(sports._IDLE_SHORT_STREAK): + live._note_live_fetch(False) + check("after a short streak it is longer (%ds)" % live._idle_live_interval(), + live._idle_live_interval() > 300) + + short_wait = live._idle_live_interval() + for _ in range(sports._IDLE_LONG_STREAK): + live._note_live_fetch(False) + long_wait = live._idle_live_interval() + # Against short_wait, not against itself: comparing the value to a second + # call of the same method with the same state can never fail, so the + # monotonicity it is meant to protect went untested. + check("after a long streak it is longer still (%ds vs %ds)" + % (long_wait, short_wait), long_wait > short_wait) + check("and never exceeds the ceiling", long_wait <= 900) + + for _ in range(500): + live._note_live_fetch(False) + check("a very long streak stays at the ceiling, not beyond", + live._idle_live_interval() == 900) + + print("\na live game resets it immediately") + live._note_live_fetch(True) + check("the streak is cleared", live._empty_live_streak == 0) + check("and the base interval is back", + live._idle_live_interval() == 300) + + print("\nthe saving is real, and bounded") + idle = _Live() + for _ in range(500): + idle._note_live_fetch(False) + per_day = 86400 / idle._idle_live_interval() + check("an out-of-season league polls far less (%d/day vs 288)" % per_day, + per_day < 288 / 2) + check("...but still often enough to notice a season starting (<= 1h)", + idle._idle_live_interval() <= 3600) + + print("\nthe ceiling is configurable") + tight = _Live(ceiling=300) + for _ in range(500): + tight._note_live_fetch(False) + check("a lower ceiling is honoured", tight._idle_live_interval() == 300) + loose = _Live(ceiling=3600) + for _ in range(500): + loose._note_live_fetch(False) + check("a higher ceiling is honoured", loose._idle_live_interval() == 1800) + + print("\nintervals from config are clamped, never trusted raw") + check("a sane value is used", sports._clamp_seconds(120, 300) == 120) + check("absent falls back", sports._clamp_seconds(None, 300) == 300) + check("nonsense falls back", sports._clamp_seconds("soon", 300) == 300) + check("zero is clamped up", sports._clamp_seconds(0, 300) >= 5) + check("a week is clamped down", sports._clamp_seconds(604800, 300) <= 86400) + + print("\nthe old recency-based logic is gone") + src = (plugin_dir / "sports.py").read_text(encoding="utf-8") + check("has_recently_checked no longer decides the interval", + "has_recently_checked" not in src) + + tree = ast.parse(src) + live_cls = next(c for c in ast.walk(tree) + if isinstance(c, ast.ClassDef) and c.name == "SportsLive") + upd = next(m for m in live_cls.body + if isinstance(m, ast.FunctionDef) and m.name == "update") + idle_calls = [n for n in ast.walk(upd) if isinstance(n, ast.Call) + and getattr(n.func, "attr", None) == "_idle_live_interval"] + note_calls = [n for n in ast.walk(upd) if isinstance(n, ast.Call) + and getattr(n.func, "attr", None) == "_note_live_fetch"] + check("update() takes its idle interval from the back-off", len(idle_calls) == 1) + check("update() records each look's outcome", len(note_calls) == 1) + + print("\n%s" % ("FAILED: %d" % len(failures) if failures + else "All checks passed")) + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/basketball-scoreboard/config_schema.json b/plugins/basketball-scoreboard/config_schema.json index bf4461b6..993b9595 100644 --- a/plugins/basketball-scoreboard/config_schema.json +++ b/plugins/basketball-scoreboard/config_schema.json @@ -2414,6 +2414,24 @@ "title": "Schedule lookahead (days)", "description": "How far ahead to fetch games for the Upcoming screen. Raise it for a league that plays weekly: a fixture just beyond this horizon is not fetched at all, so it never reaches the board even though the date is known.", "x-advanced": true + }, + "no_data_interval_seconds": { + "type": "integer", + "default": 300, + "minimum": 5, + "maximum": 86400, + "title": "Idle live-check interval (seconds)", + "description": "How long to wait between checks for live games when there are none. Backs off further the longer nothing is found, up to the cap below.", + "x-advanced": true + }, + "live_idle_max_interval_seconds": { + "type": "integer", + "default": 900, + "minimum": 5, + "maximum": 86400, + "title": "Maximum idle live-check interval (seconds)", + "description": "Ceiling for the back-off above. Raise it to poll an out-of-season league even less; lower it to notice the first game after a quiet spell sooner.", + "x-advanced": true } }, "additionalProperties": false, diff --git a/plugins/basketball-scoreboard/manager.py b/plugins/basketball-scoreboard/manager.py index fb18bf1f..7895d14f 100644 --- a/plugins/basketball-scoreboard/manager.py +++ b/plugins/basketball-scoreboard/manager.py @@ -58,6 +58,15 @@ from basketball_timezone import resolve_timezone_name from basketball_favorite_check import FavoriteTeamCheck + +_ROOT_CONFIG_KEYS = ( + "schedule_lookback_days", + "schedule_lookahead_days", + "no_data_interval_seconds", + "live_idle_max_interval_seconds", +) + + # Which ESPN endpoint backs each league, for the favorite-team diagnostic. FAVORITE_CHECK_LEAGUES = { 'nba': ('NBA', 'basketball/nba'), @@ -845,13 +854,15 @@ def _adapt_config_for_manager(self, league: str) -> Dict[str, Any]: self.logger.debug(f"Using timezone: {timezone_str} for {league} managers") - # The schedule-window settings live at the plugin config root, and - # SportsCore reads them from the root of the config it is handed. This - # adapter builds its output key by key, so anything not named here is - # dropped -- which silently pinned every user to the defaults. - for _window_key in ("schedule_lookback_days", "schedule_lookahead_days"): - if _window_key in self.config: - manager_config[_window_key] = self.config[_window_key] + # Plugin-root settings that SportsCore reads from the root of the config + # it is handed. This adapter builds its output key by key, so anything + # not named here is dropped -- which is how the schedule window silently + # pinned every user to the defaults, and would have done the same to the + # idle-poll settings. Generalised to a list so the next one added to + # SportsCore only has to be named once. + for _root_key in _ROOT_CONFIG_KEYS: + if _root_key in self.config: + manager_config[_root_key] = self.config[_root_key] return manager_config def _get_available_modes(self) -> list: diff --git a/plugins/basketball-scoreboard/manifest.json b/plugins/basketball-scoreboard/manifest.json index ce1a17a2..68f775b4 100644 --- a/plugins/basketball-scoreboard/manifest.json +++ b/plugins/basketball-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "basketball-scoreboard", "name": "Basketball Scoreboard", - "version": "1.14.1", + "version": "1.15.0", "description": "Live, recent, and upcoming basketball games across NBA, NCAA Men's, NCAA Women's, and WNBA with real-time scores, schedules, and March Madness tournament support", "author": "ChuckBuilds", "category": "sports", @@ -18,6 +18,13 @@ "branch": "main", "plugin_path": "plugins/basketball-scoreboard", "versions": [ + { + "version": "1.15.0", + "released": "2026-08-19", + "ledmatrix_min_version": "2.0.0", + "notes": "Make the schedule fetch window configurable. How far back and forward games are fetched was hard-coded at 14 days back and 7 forward. Days is the wrong unit for a league that plays weekly -- a whole matchweek can fall just past a short horizon and never reach the board -- so both are now advanced settings, bounded 1-60 days, with a stray or non-numeric value falling back to the default rather than raising. Defaults keep today's behaviour.", + "changes": "Back off the live-game poll while a league has nothing on, and let both the idle interval and its ceiling be configured. Saves API calls out of season and between games without delaying an in-progress game, which still resets the interval on the first fetch that finds one." + }, { "version": "1.14.1", "released": "2026-08-18", diff --git a/plugins/basketball-scoreboard/sports.py b/plugins/basketball-scoreboard/sports.py index 71545a6f..d96059d0 100644 --- a/plugins/basketball-scoreboard/sports.py +++ b/plugins/basketball-scoreboard/sports.py @@ -102,6 +102,28 @@ def _clamp_window(value: Any, fallback: int) -> int: return max(_MIN_WINDOW_DAYS, min(_MAX_WINDOW_DAYS, days)) +# Backing off the live poll while a league has nothing on. Gentle at first -- +# a gap between games in a live season should cost little -- then firmer, so a +# league months out of season stops polling on a live cadence altogether. +_IDLE_SHORT_STREAK = 6 +_IDLE_SHORT_FACTOR = 2 +_IDLE_LONG_STREAK = 24 +_IDLE_LONG_FACTOR = 6 +_DEFAULT_LIVE_IDLE_MAX_SECONDS = 900 + + +def _clamp_seconds(value: Any, fallback: int, low: int = 5, + high: int = 86400) -> int: + """An interval in seconds, or the fallback when the value is unusable.""" + try: + seconds = int(value) + except (TypeError, ValueError, OverflowError): + # OverflowError: json parses bare Infinity by default and int(inf) + # raises -- the same gap _clamp_window above already covers. + return fallback + return max(low, min(high, seconds)) + + class SportsCore(ABC): TOURNAMENT_ROUND_ORDER: ClassVar[Dict[str, int]] = {"NCG": 0, "F4": 1, "E8": 2, "S16": 3, "R32": 4, "R64": 5, "": 6} @@ -2584,7 +2606,20 @@ def __init__( ): super().__init__(config, display_manager, cache_manager, logger, sport_key) self.update_interval = self.mode_config.get("live_update_interval", 15) - self.no_data_interval = 300 + # Read from the config root, where the schema declares them and the web + # UI writes them -- not from mode_config, which is the per-league block + # ({sport}_scoreboard) and never carries these keys. Looking them up + # there meant the saved value was invisible and every user silently kept + # the default. mode_config is still consulted as a fallback so a + # hand-placed per-league value keeps working. + self.no_data_interval = _clamp_seconds( + self.config.get("no_data_interval_seconds", + self.mode_config.get("no_data_interval_seconds")), 300) + self.live_idle_max_interval = _clamp_seconds( + self.config.get("live_idle_max_interval_seconds", + self.mode_config.get("live_idle_max_interval_seconds")), + _DEFAULT_LIVE_IDLE_MAX_SECONDS) + self._empty_live_streak = 0 # Log the configured interval for debugging self.logger.info( f"SportsLive initialized: live_update_interval={self.update_interval}s, " @@ -2771,6 +2806,43 @@ def _detect_stale_games(self, games: List[Dict]) -> None: if game_id in self.game_update_timestamps: del self.game_update_timestamps[game_id] + def _idle_live_interval(self) -> int: + """How long to wait before looking for live games again, when there are none. + + Escalates the longer nothing turns up, and any live game resets it, so + an in-season gap between games costs at most one escalated wait while + an out-of-season league stops polling on a live cadence entirely. + + Capped rather than unbounded: the cost of backing off is how late the + first game after a quiet spell is noticed, and past the cap the saving + stops being worth that. + """ + streak = getattr(self, "_empty_live_streak", 0) + base = self.no_data_interval + ceiling = getattr(self, "live_idle_max_interval", + _DEFAULT_LIVE_IDLE_MAX_SECONDS) + # The ceiling bounds the un-escalated interval too. The two settings are + # independent integers with no cross-validation, so base > ceiling is a + # reachable config -- and returning base unclamped there made the wait + # *shrink* as the streak grew (3600s at streak 0, 900s at streak 24), + # the opposite of what the setting named "maximum" promises. + if streak >= _IDLE_LONG_STREAK: + return min(int(base * _IDLE_LONG_FACTOR), ceiling) + if streak >= _IDLE_SHORT_STREAK: + return min(int(base * _IDLE_SHORT_FACTOR), ceiling) + return min(base, ceiling) + + def _note_live_fetch(self, found_live: bool) -> None: + """Record whether a look for live games found any.""" + if found_live: + if getattr(self, "_empty_live_streak", 0): + self.logger.info( + "Live games found after %d empty check(s); back to the " + "live update interval", self._empty_live_streak) + self._empty_live_streak = 0 + else: + self._empty_live_streak = getattr(self, "_empty_live_streak", 0) + 1 + def update(self): """Update live game data and handle game switching.""" if not self.is_enabled: @@ -2795,21 +2867,25 @@ def update(self): # Only use no_data_interval if we've recently checked and confirmed there are no live games. # This ensures we check for live games frequently even if the list is temporarily empty. # Only use no_data_interval if we have no live games AND we've checked recently (within last 5 minutes) - time_since_last_update = current_time - self.last_update - has_recently_checked = self.last_update > 0 and time_since_last_update < 300 - + # Whether the last look found anything, tracked explicitly rather than + # inferred from how long ago it was. The old form asked "did we check + # within the last 300s?" and only then used no_data_interval -- but + # once 300s had elapsed the answer became no, the interval dropped + # back to live_update_interval, and it fetched. no_data_interval could + # therefore never delay anything past 300s whatever it was set to. + # Measured on a live rig: an out-of-season NHL polled every ~5.5 + # minutes around the clock, returning nothing every time. if _live_games_attr: - # We have live games, use the configured update interval interval = _update_interval_attr - elif has_recently_checked: - # We've checked recently and found no live games, use longer interval - interval = _no_data_interval_attr else: - # First check or haven't checked in a while, use update interval to check for live games - interval = _update_interval_attr + interval = self._idle_live_interval() # Original line from traceback (line 455), now with variables defined: if current_time - self.last_update >= interval: + # What the previous look found, recorded before this one + # replaces it. The streak is what drives the back-off, and + # any live game resets it. + self._note_live_fetch(bool(_live_games_attr)) self.last_update = current_time # Fetch rankings if enabled diff --git a/plugins/basketball-scoreboard/test_idle_league_backoff.py b/plugins/basketball-scoreboard/test_idle_league_backoff.py new file mode 100644 index 00000000..57e9a0ce --- /dev/null +++ b/plugins/basketball-scoreboard/test_idle_league_backoff.py @@ -0,0 +1,308 @@ +#!/usr/bin/env python3 +""" +Tests that a league with nothing on stops polling on a live cadence. + +Regression under test: the live gate decided its interval like this -- + + has_recently_checked = self.last_update > 0 and time_since_last_update < 300 + if live_games: interval = live_update_interval + elif has_recently_checked: interval = no_data_interval + else: interval = live_update_interval + +Once 300s had elapsed, has_recently_checked became False, the interval fell +back to live_update_interval, and it fetched. no_data_interval could therefore +never delay anything past 300s whatever it was set to -- the setting was +inert. Measured on a live rig in mid-August: NHLLiveManager fetched 0 games 22 +times in 2 hours, every ~5.5 minutes, around the clock, for a league whose +season had not started. Roughly 264 wasted requests a day, per league. + +The interval now comes from an explicit streak of empty looks, which escalates +and is capped, and which any live game resets. + +Second regression under test: the two interval settings were read from +``self.mode_config`` -- the per-league ``{sport_key}_scoreboard`` block -- while +the schema declares them (and the web UI writes them) at the config *root*. The +saved value was therefore never seen and every user silently kept the default. +An earlier version of this test could not catch that, because it set +``no_data_interval`` on a stand-in object by hand and so never ran the lookup. +It now builds a real ``SportsLive`` and asserts on what its ``__init__`` +resolved. + +Only ``SportsCore.__init__`` is stubbed -- it pulls in logo downloading, fonts +and an ESPN data source, none of which this test needs -- so the lookup lines +themselves run for real. The stub's fidelity is asserted against the real +``SportsCore`` source below, so it cannot drift out of sync unnoticed. + +Run: /bin/python plugins/basketball-scoreboard/test_idle_league_backoff.py +""" + +# A test harness: it reaches into protected members on purpose and builds a +# concrete subclass at runtime, neither of which pylint can see as intentional. +# pylint: disable=protected-access,abstract-class-instantiated,unused-argument +import ast +import sys +from pathlib import Path + +plugin_dir = Path(__file__).resolve().parent +sys.path.insert(0, str(plugin_dir)) +for candidate in (Path("/home/rackpi/projects/LEDMatrix"), + plugin_dir.parents[2] / "LEDMatrix"): + if (candidate / "src" / "plugin_system" / "base_plugin.py").exists(): + sys.path.insert(0, str(candidate)) + break + +import sports # noqa: E402 + +failures = [] + + +def check(label, ok): + print((" PASS " if ok else " FAIL ") + label) + if not ok: + failures.append(label) + + +class _Logger: + def info(self, *a, **k): + pass + + +SPORT_KEY = "test" + + +def _core_init(): + core_src = (plugin_dir / "sports.py").read_text(encoding="utf-8") + core = next(c for c in ast.walk(ast.parse(core_src)) + if isinstance(c, ast.ClassDef) and c.name == "SportsCore") + return core_src, next(m for m in core.body + if isinstance(m, ast.FunctionDef) and m.name == "__init__") + + +def _derive_mode_key(): + """Build the per-league key exactly as this plugin's SportsCore does. + + Not hardcoded: most plugins use f"{sport_key}_scoreboard" but ufc-scoreboard + uses the bare sport_key. Hardcoding one of them made the stub test fiction + on the other -- which the fidelity check below caught. Reading the real + expression out of the source keeps every plugin honest. + """ + _, init = _core_init() + for node in ast.walk(init): + if (isinstance(node, ast.Assign) + and any(getattr(t, "attr", None) == "mode_config" + for t in node.targets) + and isinstance(node.value, ast.Call) and node.value.args): + arg = node.value.args[0] + return _resolve_key(arg), ast.unparse(arg) + raise AssertionError("SportsCore no longer assigns self.mode_config") + + +def _resolve_key(node): + """Evaluate the key expression by hand -- deliberately not with eval(). + + Only the two shapes that actually occur are supported; anything else is a + hard error rather than a guess, so a future rewrite of that line surfaces + here instead of silently producing a key nothing is stored under. + """ + if isinstance(node, ast.Constant): + return node.value + if isinstance(node, ast.Name) and node.id == "sport_key": + return SPORT_KEY + if isinstance(node, ast.JoinedStr): + return "".join(_resolve_key(part) for part in node.values) + if isinstance(node, ast.FormattedValue): + return _resolve_key(node.value) + raise AssertionError( + "unsupported mode_config key expression: %s" % ast.unparse(node)) + + +MODE_KEY, _MODE_KEY_EXPR = _derive_mode_key() + + +def _assert_stub_is_faithful(): + """The stub below stands in for SportsCore.__init__; prove it still matches. + + It has to reproduce exactly the two assignments the lookup under test + depends on. If SportsCore ever changes where config or mode_config come + from, this fails loudly rather than letting the stub quietly test fiction. + """ + _, init = _core_init() + assigns = {} + for node in ast.walk(init): + if isinstance(node, ast.Assign): + for target in node.targets: + if getattr(target, "attr", None) in ("config", "mode_config"): + assigns[target.attr] = node.value + + # The stub is only faithful if SportsCore still (a) keeps the handed-in + # config verbatim as self.config -- if it ever stored a sub-dict, "root" + # would mean something different -- and (b) derives mode_config as a + # lookup *within* that same config. The key itself is read from source + # above, so it needs no assertion; these two shapes do. + raw = assigns.get("config") + check("SportsCore still keeps the handed-in config verbatim as self.config", + isinstance(raw, ast.Name) and raw.id == "config") + + mode = assigns.get("mode_config") + check("mode_config is still a lookup inside that same config", + isinstance(mode, ast.Call) + and isinstance(mode.func, ast.Attribute) + and mode.func.attr == "get" + and isinstance(mode.func.value, ast.Name) + and mode.func.value.id == "config") + + +class _StubCore: + """Exactly the two SportsCore assignments the lookup under test reads.""" + # pylint: disable=too-few-public-methods + + def __init__(self, config, display_manager, cache_manager, logger, sport_key): + self.logger = logger + self.config = config + self.mode_config = config.get(MODE_KEY, {}) or {} + + +# SportsLive may carry abstract methods from SportsCore; fill them in so it can +# be constructed. They are never called here. +_Concrete = type("_ConcreteLive", (sports.SportsLive,), + {name: (lambda self, *a, **k: None) + for name in getattr(sports.SportsLive, + "__abstractmethods__", ())}) + + +def _live(root=None, league=None): + """A real SportsLive built from a real config dict. + + root/league place the settings at the config root and in the per-league + block respectively, which is the whole point: the test can now tell those + two locations apart. + """ + config = dict(root or {}) + config[MODE_KEY] = dict(league or {}) + real_init = sports.SportsCore.__init__ + sports.SportsCore.__init__ = _StubCore.__init__ + try: + return _Concrete(config, object(), object(), _Logger(), SPORT_KEY) + finally: + sports.SportsCore.__init__ = real_init + + +def _Live(base=300, ceiling=900): + """The old signature, now backed by a real config round-trip.""" + return _live(root={"no_data_interval_seconds": base, + "live_idle_max_interval_seconds": ceiling}) + + +def main(): + print("the settings are read from where they are actually written") + _assert_stub_is_faithful() + + at_root = _live(root={"no_data_interval_seconds": 600, + "live_idle_max_interval_seconds": 1200}) + check("a root value is honoured (the schema declares them at the root)", + at_root.no_data_interval == 600) + check("...including the ceiling", at_root.live_idle_max_interval == 1200) + + defaults = _live() + check("absent anywhere falls back to the default", + defaults.no_data_interval == 300) + + legacy = _live(league={"no_data_interval_seconds": 600}) + check("a hand-placed per-league value still works", + legacy.no_data_interval == 600) + + both = _live(root={"no_data_interval_seconds": 600}, + league={"no_data_interval_seconds": 45}) + check("the root wins over a stale per-league value", + both.no_data_interval == 600) + + junk = _live(root={"no_data_interval_seconds": "soon", + "live_idle_max_interval_seconds": float("inf")}) + check("garbage at the root falls back rather than raising", + junk.no_data_interval == 300) + check("an infinite ceiling falls back too", + junk.live_idle_max_interval == sports._DEFAULT_LIVE_IDLE_MAX_SECONDS) + + print("\nthe wait grows the longer nothing is found") + live = _Live() + check("first look uses the base interval", + live._idle_live_interval() == 300) + + for _ in range(sports._IDLE_SHORT_STREAK): + live._note_live_fetch(False) + check("after a short streak it is longer (%ds)" % live._idle_live_interval(), + live._idle_live_interval() > 300) + + short_wait = live._idle_live_interval() + for _ in range(sports._IDLE_LONG_STREAK): + live._note_live_fetch(False) + long_wait = live._idle_live_interval() + # Against short_wait, not against itself: comparing the value to a second + # call of the same method with the same state can never fail, so the + # monotonicity it is meant to protect went untested. + check("after a long streak it is longer still (%ds vs %ds)" + % (long_wait, short_wait), long_wait > short_wait) + check("and never exceeds the ceiling", long_wait <= 900) + + for _ in range(500): + live._note_live_fetch(False) + check("a very long streak stays at the ceiling, not beyond", + live._idle_live_interval() == 900) + + print("\na live game resets it immediately") + live._note_live_fetch(True) + check("the streak is cleared", live._empty_live_streak == 0) + check("and the base interval is back", + live._idle_live_interval() == 300) + + print("\nthe saving is real, and bounded") + idle = _Live() + for _ in range(500): + idle._note_live_fetch(False) + per_day = 86400 / idle._idle_live_interval() + check("an out-of-season league polls far less (%d/day vs 288)" % per_day, + per_day < 288 / 2) + check("...but still often enough to notice a season starting (<= 1h)", + idle._idle_live_interval() <= 3600) + + print("\nthe ceiling is configurable") + tight = _Live(ceiling=300) + for _ in range(500): + tight._note_live_fetch(False) + check("a lower ceiling is honoured", tight._idle_live_interval() == 300) + loose = _Live(ceiling=3600) + for _ in range(500): + loose._note_live_fetch(False) + check("a higher ceiling is honoured", loose._idle_live_interval() == 1800) + + print("\nintervals from config are clamped, never trusted raw") + check("a sane value is used", sports._clamp_seconds(120, 300) == 120) + check("absent falls back", sports._clamp_seconds(None, 300) == 300) + check("nonsense falls back", sports._clamp_seconds("soon", 300) == 300) + check("zero is clamped up", sports._clamp_seconds(0, 300) >= 5) + check("a week is clamped down", sports._clamp_seconds(604800, 300) <= 86400) + + print("\nthe old recency-based logic is gone") + src = (plugin_dir / "sports.py").read_text(encoding="utf-8") + check("has_recently_checked no longer decides the interval", + "has_recently_checked" not in src) + + tree = ast.parse(src) + live_cls = next(c for c in ast.walk(tree) + if isinstance(c, ast.ClassDef) and c.name == "SportsLive") + upd = next(m for m in live_cls.body + if isinstance(m, ast.FunctionDef) and m.name == "update") + idle_calls = [n for n in ast.walk(upd) if isinstance(n, ast.Call) + and getattr(n.func, "attr", None) == "_idle_live_interval"] + note_calls = [n for n in ast.walk(upd) if isinstance(n, ast.Call) + and getattr(n.func, "attr", None) == "_note_live_fetch"] + check("update() takes its idle interval from the back-off", len(idle_calls) == 1) + check("update() records each look's outcome", len(note_calls) == 1) + + print("\n%s" % ("FAILED: %d" % len(failures) if failures + else "All checks passed")) + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/football-scoreboard/config_schema.json b/plugins/football-scoreboard/config_schema.json index 7c90270d..de6c7b37 100644 --- a/plugins/football-scoreboard/config_schema.json +++ b/plugins/football-scoreboard/config_schema.json @@ -1609,6 +1609,24 @@ "title": "Schedule lookahead (days)", "description": "How far ahead to fetch games for the Upcoming screen. Raise it for a league that plays weekly: a fixture just beyond this horizon is not fetched at all, so it never reaches the board even though the date is known.", "x-advanced": true + }, + "no_data_interval_seconds": { + "type": "integer", + "default": 300, + "minimum": 5, + "maximum": 86400, + "title": "Idle live-check interval (seconds)", + "description": "How long to wait between checks for live games when there are none. Backs off further the longer nothing is found, up to the cap below.", + "x-advanced": true + }, + "live_idle_max_interval_seconds": { + "type": "integer", + "default": 900, + "minimum": 5, + "maximum": 86400, + "title": "Maximum idle live-check interval (seconds)", + "description": "Ceiling for the back-off above. Raise it to poll an out-of-season league even less; lower it to notice the first game after a quiet spell sooner.", + "x-advanced": true } }, "additionalProperties": false, diff --git a/plugins/football-scoreboard/manager.py b/plugins/football-scoreboard/manager.py index 48dd5972..8aaefe42 100644 --- a/plugins/football-scoreboard/manager.py +++ b/plugins/football-scoreboard/manager.py @@ -61,6 +61,15 @@ from football_timezone import resolve_timezone_name from football_favorite_check import FavoriteTeamCheck + +_ROOT_CONFIG_KEYS = ( + "schedule_lookback_days", + "schedule_lookahead_days", + "no_data_interval_seconds", + "live_idle_max_interval_seconds", +) + + # Which ESPN endpoint backs each league, for the favorite-team diagnostic. FAVORITE_CHECK_LEAGUES = { 'nfl': ('NFL', 'football/nfl'), @@ -722,13 +731,15 @@ def _adapt_config_for_manager(self, league: str) -> Dict[str, Any]: self.logger.debug(f"Using timezone: {timezone_str} for {league} managers") - # The schedule-window settings live at the plugin config root, and - # SportsCore reads them from the root of the config it is handed. This - # adapter builds its output key by key, so anything not named here is - # dropped -- which silently pinned every user to the defaults. - for _window_key in ("schedule_lookback_days", "schedule_lookahead_days"): - if _window_key in self.config: - manager_config[_window_key] = self.config[_window_key] + # Plugin-root settings that SportsCore reads from the root of the config + # it is handed. This adapter builds its output key by key, so anything + # not named here is dropped -- which is how the schedule window silently + # pinned every user to the defaults, and would have done the same to the + # idle-poll settings. Generalised to a list so the next one added to + # SportsCore only has to be named once. + for _root_key in _ROOT_CONFIG_KEYS: + if _root_key in self.config: + manager_config[_root_key] = self.config[_root_key] return manager_config def _parse_display_mode_settings(self) -> Dict[str, Dict[str, str]]: diff --git a/plugins/football-scoreboard/manifest.json b/plugins/football-scoreboard/manifest.json index 7d43e7ce..b4c681cd 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.16.1", + "version": "2.17.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,13 @@ "ncaa_fb_live" ], "versions": [ + { + "version": "2.17.0", + "released": "2026-08-19", + "ledmatrix_min_version": "2.0.0", + "notes": "Make the schedule fetch window configurable. How far back and forward games are fetched was hard-coded at 14 days back and 7 forward. Days is the wrong unit for a league that plays weekly -- a whole matchweek can fall just past a short horizon and never reach the board -- so both are now advanced settings, bounded 1-60 days, with a stray or non-numeric value falling back to the default rather than raising. Defaults keep today's behaviour.", + "changes": "Back off the live-game poll while a league has nothing on, and let both the idle interval and its ceiling be configured. Saves API calls out of season and between games without delaying an in-progress game, which still resets the interval on the first fetch that finds one." + }, { "version": "2.16.1", "released": "2026-08-18", diff --git a/plugins/football-scoreboard/sports.py b/plugins/football-scoreboard/sports.py index e9f0d645..34135c78 100644 --- a/plugins/football-scoreboard/sports.py +++ b/plugins/football-scoreboard/sports.py @@ -104,6 +104,28 @@ def _clamp_window(value: Any, fallback: int) -> int: return max(_MIN_WINDOW_DAYS, min(_MAX_WINDOW_DAYS, days)) +# Backing off the live poll while a league has nothing on. Gentle at first -- +# a gap between games in a live season should cost little -- then firmer, so a +# league months out of season stops polling on a live cadence altogether. +_IDLE_SHORT_STREAK = 6 +_IDLE_SHORT_FACTOR = 2 +_IDLE_LONG_STREAK = 24 +_IDLE_LONG_FACTOR = 6 +_DEFAULT_LIVE_IDLE_MAX_SECONDS = 900 + + +def _clamp_seconds(value: Any, fallback: int, low: int = 5, + high: int = 86400) -> int: + """An interval in seconds, or the fallback when the value is unusable.""" + try: + seconds = int(value) + except (TypeError, ValueError, OverflowError): + # OverflowError: json parses bare Infinity by default and int(inf) + # raises -- the same gap _clamp_window above already covers. + return fallback + return max(low, min(high, seconds)) + + class SportsCore(ABC): def __init__( self, @@ -2322,7 +2344,20 @@ def __init__( ): super().__init__(config, display_manager, cache_manager, logger, sport_key) self.update_interval = self.mode_config.get("live_update_interval", 15) - self.no_data_interval = 300 + # Read from the config root, where the schema declares them and the web + # UI writes them -- not from mode_config, which is the per-league block + # ({sport}_scoreboard) and never carries these keys. Looking them up + # there meant the saved value was invisible and every user silently kept + # the default. mode_config is still consulted as a fallback so a + # hand-placed per-league value keeps working. + self.no_data_interval = _clamp_seconds( + self.config.get("no_data_interval_seconds", + self.mode_config.get("no_data_interval_seconds")), 300) + self.live_idle_max_interval = _clamp_seconds( + self.config.get("live_idle_max_interval_seconds", + self.mode_config.get("live_idle_max_interval_seconds")), + _DEFAULT_LIVE_IDLE_MAX_SECONDS) + self._empty_live_streak = 0 # Log the configured interval for debugging self.logger.info( f"SportsLive initialized: live_update_interval={self.update_interval}s, " @@ -2866,6 +2901,43 @@ def _detect_stale_games(self, games: List[Dict]) -> None: if game_id in self.game_update_timestamps: del self.game_update_timestamps[game_id] + def _idle_live_interval(self) -> int: + """How long to wait before looking for live games again, when there are none. + + Escalates the longer nothing turns up, and any live game resets it, so + an in-season gap between games costs at most one escalated wait while + an out-of-season league stops polling on a live cadence entirely. + + Capped rather than unbounded: the cost of backing off is how late the + first game after a quiet spell is noticed, and past the cap the saving + stops being worth that. + """ + streak = getattr(self, "_empty_live_streak", 0) + base = self.no_data_interval + ceiling = getattr(self, "live_idle_max_interval", + _DEFAULT_LIVE_IDLE_MAX_SECONDS) + # The ceiling bounds the un-escalated interval too. The two settings are + # independent integers with no cross-validation, so base > ceiling is a + # reachable config -- and returning base unclamped there made the wait + # *shrink* as the streak grew (3600s at streak 0, 900s at streak 24), + # the opposite of what the setting named "maximum" promises. + if streak >= _IDLE_LONG_STREAK: + return min(int(base * _IDLE_LONG_FACTOR), ceiling) + if streak >= _IDLE_SHORT_STREAK: + return min(int(base * _IDLE_SHORT_FACTOR), ceiling) + return min(base, ceiling) + + def _note_live_fetch(self, found_live: bool) -> None: + """Record whether a look for live games found any.""" + if found_live: + if getattr(self, "_empty_live_streak", 0): + self.logger.info( + "Live games found after %d empty check(s); back to the " + "live update interval", self._empty_live_streak) + self._empty_live_streak = 0 + else: + self._empty_live_streak = getattr(self, "_empty_live_streak", 0) + 1 + def update(self): """Update live game data and handle game switching.""" if not self.is_enabled: @@ -2893,21 +2965,25 @@ def update(self): # Only use no_data_interval if we've recently checked and confirmed there are no live games. # This ensures we check for live games frequently even if the list is temporarily empty. # Only use no_data_interval if we have no live games AND we've checked recently (within last 5 minutes) - time_since_last_update = current_time - self.last_update - has_recently_checked = self.last_update > 0 and time_since_last_update < 300 - + # Whether the last look found anything, tracked explicitly rather than + # inferred from how long ago it was. The old form asked "did we check + # within the last 300s?" and only then used no_data_interval -- but + # once 300s had elapsed the answer became no, the interval dropped + # back to live_update_interval, and it fetched. no_data_interval could + # therefore never delay anything past 300s whatever it was set to. + # Measured on a live rig: an out-of-season NHL polled every ~5.5 + # minutes around the clock, returning nothing every time. if _live_games_attr or _test_mode_attr: - # We have live games or are in test mode, use the configured update interval interval = _update_interval_attr - elif has_recently_checked: - # We've checked recently and found no live games, use longer interval - interval = _no_data_interval_attr else: - # First check or haven't checked in a while, use update interval to check for live games - interval = _update_interval_attr + interval = self._idle_live_interval() # Original line from traceback (line 455), now with variables defined: if current_time - self.last_update >= interval: + # What the previous look found, recorded before this one + # replaces it. The streak is what drives the back-off, and + # any live game resets it. + self._note_live_fetch(bool(_live_games_attr)) self.last_update = current_time # Test mode: advance the simulated game instead of fetching real API diff --git a/plugins/football-scoreboard/test_idle_league_backoff.py b/plugins/football-scoreboard/test_idle_league_backoff.py new file mode 100644 index 00000000..e50a512a --- /dev/null +++ b/plugins/football-scoreboard/test_idle_league_backoff.py @@ -0,0 +1,308 @@ +#!/usr/bin/env python3 +""" +Tests that a league with nothing on stops polling on a live cadence. + +Regression under test: the live gate decided its interval like this -- + + has_recently_checked = self.last_update > 0 and time_since_last_update < 300 + if live_games: interval = live_update_interval + elif has_recently_checked: interval = no_data_interval + else: interval = live_update_interval + +Once 300s had elapsed, has_recently_checked became False, the interval fell +back to live_update_interval, and it fetched. no_data_interval could therefore +never delay anything past 300s whatever it was set to -- the setting was +inert. Measured on a live rig in mid-August: NHLLiveManager fetched 0 games 22 +times in 2 hours, every ~5.5 minutes, around the clock, for a league whose +season had not started. Roughly 264 wasted requests a day, per league. + +The interval now comes from an explicit streak of empty looks, which escalates +and is capped, and which any live game resets. + +Second regression under test: the two interval settings were read from +``self.mode_config`` -- the per-league ``{sport_key}_scoreboard`` block -- while +the schema declares them (and the web UI writes them) at the config *root*. The +saved value was therefore never seen and every user silently kept the default. +An earlier version of this test could not catch that, because it set +``no_data_interval`` on a stand-in object by hand and so never ran the lookup. +It now builds a real ``SportsLive`` and asserts on what its ``__init__`` +resolved. + +Only ``SportsCore.__init__`` is stubbed -- it pulls in logo downloading, fonts +and an ESPN data source, none of which this test needs -- so the lookup lines +themselves run for real. The stub's fidelity is asserted against the real +``SportsCore`` source below, so it cannot drift out of sync unnoticed. + +Run: /bin/python plugins/football-scoreboard/test_idle_league_backoff.py +""" + +# A test harness: it reaches into protected members on purpose and builds a +# concrete subclass at runtime, neither of which pylint can see as intentional. +# pylint: disable=protected-access,abstract-class-instantiated,unused-argument +import ast +import sys +from pathlib import Path + +plugin_dir = Path(__file__).resolve().parent +sys.path.insert(0, str(plugin_dir)) +for candidate in (Path("/home/rackpi/projects/LEDMatrix"), + plugin_dir.parents[2] / "LEDMatrix"): + if (candidate / "src" / "plugin_system" / "base_plugin.py").exists(): + sys.path.insert(0, str(candidate)) + break + +import sports # noqa: E402 + +failures = [] + + +def check(label, ok): + print((" PASS " if ok else " FAIL ") + label) + if not ok: + failures.append(label) + + +class _Logger: + def info(self, *a, **k): + pass + + +SPORT_KEY = "test" + + +def _core_init(): + core_src = (plugin_dir / "sports.py").read_text(encoding="utf-8") + core = next(c for c in ast.walk(ast.parse(core_src)) + if isinstance(c, ast.ClassDef) and c.name == "SportsCore") + return core_src, next(m for m in core.body + if isinstance(m, ast.FunctionDef) and m.name == "__init__") + + +def _derive_mode_key(): + """Build the per-league key exactly as this plugin's SportsCore does. + + Not hardcoded: most plugins use f"{sport_key}_scoreboard" but ufc-scoreboard + uses the bare sport_key. Hardcoding one of them made the stub test fiction + on the other -- which the fidelity check below caught. Reading the real + expression out of the source keeps every plugin honest. + """ + _, init = _core_init() + for node in ast.walk(init): + if (isinstance(node, ast.Assign) + and any(getattr(t, "attr", None) == "mode_config" + for t in node.targets) + and isinstance(node.value, ast.Call) and node.value.args): + arg = node.value.args[0] + return _resolve_key(arg), ast.unparse(arg) + raise AssertionError("SportsCore no longer assigns self.mode_config") + + +def _resolve_key(node): + """Evaluate the key expression by hand -- deliberately not with eval(). + + Only the two shapes that actually occur are supported; anything else is a + hard error rather than a guess, so a future rewrite of that line surfaces + here instead of silently producing a key nothing is stored under. + """ + if isinstance(node, ast.Constant): + return node.value + if isinstance(node, ast.Name) and node.id == "sport_key": + return SPORT_KEY + if isinstance(node, ast.JoinedStr): + return "".join(_resolve_key(part) for part in node.values) + if isinstance(node, ast.FormattedValue): + return _resolve_key(node.value) + raise AssertionError( + "unsupported mode_config key expression: %s" % ast.unparse(node)) + + +MODE_KEY, _MODE_KEY_EXPR = _derive_mode_key() + + +def _assert_stub_is_faithful(): + """The stub below stands in for SportsCore.__init__; prove it still matches. + + It has to reproduce exactly the two assignments the lookup under test + depends on. If SportsCore ever changes where config or mode_config come + from, this fails loudly rather than letting the stub quietly test fiction. + """ + _, init = _core_init() + assigns = {} + for node in ast.walk(init): + if isinstance(node, ast.Assign): + for target in node.targets: + if getattr(target, "attr", None) in ("config", "mode_config"): + assigns[target.attr] = node.value + + # The stub is only faithful if SportsCore still (a) keeps the handed-in + # config verbatim as self.config -- if it ever stored a sub-dict, "root" + # would mean something different -- and (b) derives mode_config as a + # lookup *within* that same config. The key itself is read from source + # above, so it needs no assertion; these two shapes do. + raw = assigns.get("config") + check("SportsCore still keeps the handed-in config verbatim as self.config", + isinstance(raw, ast.Name) and raw.id == "config") + + mode = assigns.get("mode_config") + check("mode_config is still a lookup inside that same config", + isinstance(mode, ast.Call) + and isinstance(mode.func, ast.Attribute) + and mode.func.attr == "get" + and isinstance(mode.func.value, ast.Name) + and mode.func.value.id == "config") + + +class _StubCore: + """Exactly the two SportsCore assignments the lookup under test reads.""" + # pylint: disable=too-few-public-methods + + def __init__(self, config, display_manager, cache_manager, logger, sport_key): + self.logger = logger + self.config = config + self.mode_config = config.get(MODE_KEY, {}) or {} + + +# SportsLive may carry abstract methods from SportsCore; fill them in so it can +# be constructed. They are never called here. +_Concrete = type("_ConcreteLive", (sports.SportsLive,), + {name: (lambda self, *a, **k: None) + for name in getattr(sports.SportsLive, + "__abstractmethods__", ())}) + + +def _live(root=None, league=None): + """A real SportsLive built from a real config dict. + + root/league place the settings at the config root and in the per-league + block respectively, which is the whole point: the test can now tell those + two locations apart. + """ + config = dict(root or {}) + config[MODE_KEY] = dict(league or {}) + real_init = sports.SportsCore.__init__ + sports.SportsCore.__init__ = _StubCore.__init__ + try: + return _Concrete(config, object(), object(), _Logger(), SPORT_KEY) + finally: + sports.SportsCore.__init__ = real_init + + +def _Live(base=300, ceiling=900): + """The old signature, now backed by a real config round-trip.""" + return _live(root={"no_data_interval_seconds": base, + "live_idle_max_interval_seconds": ceiling}) + + +def main(): + print("the settings are read from where they are actually written") + _assert_stub_is_faithful() + + at_root = _live(root={"no_data_interval_seconds": 600, + "live_idle_max_interval_seconds": 1200}) + check("a root value is honoured (the schema declares them at the root)", + at_root.no_data_interval == 600) + check("...including the ceiling", at_root.live_idle_max_interval == 1200) + + defaults = _live() + check("absent anywhere falls back to the default", + defaults.no_data_interval == 300) + + legacy = _live(league={"no_data_interval_seconds": 600}) + check("a hand-placed per-league value still works", + legacy.no_data_interval == 600) + + both = _live(root={"no_data_interval_seconds": 600}, + league={"no_data_interval_seconds": 45}) + check("the root wins over a stale per-league value", + both.no_data_interval == 600) + + junk = _live(root={"no_data_interval_seconds": "soon", + "live_idle_max_interval_seconds": float("inf")}) + check("garbage at the root falls back rather than raising", + junk.no_data_interval == 300) + check("an infinite ceiling falls back too", + junk.live_idle_max_interval == sports._DEFAULT_LIVE_IDLE_MAX_SECONDS) + + print("\nthe wait grows the longer nothing is found") + live = _Live() + check("first look uses the base interval", + live._idle_live_interval() == 300) + + for _ in range(sports._IDLE_SHORT_STREAK): + live._note_live_fetch(False) + check("after a short streak it is longer (%ds)" % live._idle_live_interval(), + live._idle_live_interval() > 300) + + short_wait = live._idle_live_interval() + for _ in range(sports._IDLE_LONG_STREAK): + live._note_live_fetch(False) + long_wait = live._idle_live_interval() + # Against short_wait, not against itself: comparing the value to a second + # call of the same method with the same state can never fail, so the + # monotonicity it is meant to protect went untested. + check("after a long streak it is longer still (%ds vs %ds)" + % (long_wait, short_wait), long_wait > short_wait) + check("and never exceeds the ceiling", long_wait <= 900) + + for _ in range(500): + live._note_live_fetch(False) + check("a very long streak stays at the ceiling, not beyond", + live._idle_live_interval() == 900) + + print("\na live game resets it immediately") + live._note_live_fetch(True) + check("the streak is cleared", live._empty_live_streak == 0) + check("and the base interval is back", + live._idle_live_interval() == 300) + + print("\nthe saving is real, and bounded") + idle = _Live() + for _ in range(500): + idle._note_live_fetch(False) + per_day = 86400 / idle._idle_live_interval() + check("an out-of-season league polls far less (%d/day vs 288)" % per_day, + per_day < 288 / 2) + check("...but still often enough to notice a season starting (<= 1h)", + idle._idle_live_interval() <= 3600) + + print("\nthe ceiling is configurable") + tight = _Live(ceiling=300) + for _ in range(500): + tight._note_live_fetch(False) + check("a lower ceiling is honoured", tight._idle_live_interval() == 300) + loose = _Live(ceiling=3600) + for _ in range(500): + loose._note_live_fetch(False) + check("a higher ceiling is honoured", loose._idle_live_interval() == 1800) + + print("\nintervals from config are clamped, never trusted raw") + check("a sane value is used", sports._clamp_seconds(120, 300) == 120) + check("absent falls back", sports._clamp_seconds(None, 300) == 300) + check("nonsense falls back", sports._clamp_seconds("soon", 300) == 300) + check("zero is clamped up", sports._clamp_seconds(0, 300) >= 5) + check("a week is clamped down", sports._clamp_seconds(604800, 300) <= 86400) + + print("\nthe old recency-based logic is gone") + src = (plugin_dir / "sports.py").read_text(encoding="utf-8") + check("has_recently_checked no longer decides the interval", + "has_recently_checked" not in src) + + tree = ast.parse(src) + live_cls = next(c for c in ast.walk(tree) + if isinstance(c, ast.ClassDef) and c.name == "SportsLive") + upd = next(m for m in live_cls.body + if isinstance(m, ast.FunctionDef) and m.name == "update") + idle_calls = [n for n in ast.walk(upd) if isinstance(n, ast.Call) + and getattr(n.func, "attr", None) == "_idle_live_interval"] + note_calls = [n for n in ast.walk(upd) if isinstance(n, ast.Call) + and getattr(n.func, "attr", None) == "_note_live_fetch"] + check("update() takes its idle interval from the back-off", len(idle_calls) == 1) + check("update() records each look's outcome", len(note_calls) == 1) + + print("\n%s" % ("FAILED: %d" % len(failures) if failures + else "All checks passed")) + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/hockey-scoreboard/config_schema.json b/plugins/hockey-scoreboard/config_schema.json index fbf96cad..6706fc8e 100644 --- a/plugins/hockey-scoreboard/config_schema.json +++ b/plugins/hockey-scoreboard/config_schema.json @@ -2067,6 +2067,24 @@ "title": "Schedule lookahead (days)", "description": "How far ahead to fetch games for the Upcoming screen. Raise it for a league that plays weekly: a fixture just beyond this horizon is not fetched at all, so it never reaches the board even though the date is known.", "x-advanced": true + }, + "no_data_interval_seconds": { + "type": "integer", + "default": 300, + "minimum": 5, + "maximum": 86400, + "title": "Idle live-check interval (seconds)", + "description": "How long to wait between checks for live games when there are none. Backs off further the longer nothing is found, up to the cap below.", + "x-advanced": true + }, + "live_idle_max_interval_seconds": { + "type": "integer", + "default": 900, + "minimum": 5, + "maximum": 86400, + "title": "Maximum idle live-check interval (seconds)", + "description": "Ceiling for the back-off above. Raise it to poll an out-of-season league even less; lower it to notice the first game after a quiet spell sooner.", + "x-advanced": true } }, "required": [ diff --git a/plugins/hockey-scoreboard/manager.py b/plugins/hockey-scoreboard/manager.py index 3c19564d..81ba0c10 100644 --- a/plugins/hockey-scoreboard/manager.py +++ b/plugins/hockey-scoreboard/manager.py @@ -45,6 +45,15 @@ from hockey_timezone import resolve_timezone_name from hockey_favorite_check import FavoriteTeamCheck + +_ROOT_CONFIG_KEYS = ( + "schedule_lookback_days", + "schedule_lookahead_days", + "no_data_interval_seconds", + "live_idle_max_interval_seconds", +) + + # Which ESPN endpoint backs each league, for the favorite-team diagnostic. FAVORITE_CHECK_LEAGUES = { 'nhl': ('NHL', 'hockey/nhl'), @@ -862,13 +871,15 @@ def resolve_non_favorite_live_duration() -> int: } ) - # The schedule-window settings live at the plugin config root, and - # SportsCore reads them from the root of the config it is handed. This - # adapter builds its output key by key, so anything not named here is - # dropped -- which silently pinned every user to the defaults. - for _window_key in ("schedule_lookback_days", "schedule_lookahead_days"): - if _window_key in self.config: - manager_config[_window_key] = self.config[_window_key] + # Plugin-root settings that SportsCore reads from the root of the config + # it is handed. This adapter builds its output key by key, so anything + # not named here is dropped -- which is how the schedule window silently + # pinned every user to the defaults, and would have done the same to the + # idle-poll settings. Generalised to a list so the next one added to + # SportsCore only has to be named once. + for _root_key in _ROOT_CONFIG_KEYS: + if _root_key in self.config: + manager_config[_root_key] = self.config[_root_key] return manager_config def _get_available_modes(self) -> list: diff --git a/plugins/hockey-scoreboard/manifest.json b/plugins/hockey-scoreboard/manifest.json index 108792d1..0497a5c3 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.11.1", + "version": "1.12.0", "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,13 @@ } ], "versions": [ + { + "version": "1.12.0", + "released": "2026-08-19", + "ledmatrix_min_version": "2.0.0", + "notes": "Make the schedule fetch window configurable. How far back and forward games are fetched was hard-coded at 14 days back and 7 forward. Days is the wrong unit for a league that plays weekly -- a whole matchweek can fall just past a short horizon and never reach the board -- so both are now advanced settings, bounded 1-60 days, with a stray or non-numeric value falling back to the default rather than raising. Defaults keep today's behaviour.", + "changes": "Back off the live-game poll while a league has nothing on, and let both the idle interval and its ceiling be configured. Saves API calls out of season and between games without delaying an in-progress game, which still resets the interval on the first fetch that finds one." + }, { "version": "1.11.1", "released": "2026-08-18", diff --git a/plugins/hockey-scoreboard/sports.py b/plugins/hockey-scoreboard/sports.py index fcec90d0..dd4c8457 100644 --- a/plugins/hockey-scoreboard/sports.py +++ b/plugins/hockey-scoreboard/sports.py @@ -98,6 +98,28 @@ def _clamp_window(value: Any, fallback: int) -> int: return max(_MIN_WINDOW_DAYS, min(_MAX_WINDOW_DAYS, days)) +# Backing off the live poll while a league has nothing on. Gentle at first -- +# a gap between games in a live season should cost little -- then firmer, so a +# league months out of season stops polling on a live cadence altogether. +_IDLE_SHORT_STREAK = 6 +_IDLE_SHORT_FACTOR = 2 +_IDLE_LONG_STREAK = 24 +_IDLE_LONG_FACTOR = 6 +_DEFAULT_LIVE_IDLE_MAX_SECONDS = 900 + + +def _clamp_seconds(value: Any, fallback: int, low: int = 5, + high: int = 86400) -> int: + """An interval in seconds, or the fallback when the value is unusable.""" + try: + seconds = int(value) + except (TypeError, ValueError, OverflowError): + # OverflowError: json parses bare Infinity by default and int(inf) + # raises -- the same gap _clamp_window above already covers. + return fallback + return max(low, min(high, seconds)) + + class SportsCore(ABC): def __init__( self, @@ -2159,7 +2181,20 @@ def __init__( ): super().__init__(config, display_manager, cache_manager, logger, sport_key) self.update_interval = self.mode_config.get("live_update_interval", 15) - self.no_data_interval = 300 + # Read from the config root, where the schema declares them and the web + # UI writes them -- not from mode_config, which is the per-league block + # ({sport}_scoreboard) and never carries these keys. Looking them up + # there meant the saved value was invisible and every user silently kept + # the default. mode_config is still consulted as a fallback so a + # hand-placed per-league value keeps working. + self.no_data_interval = _clamp_seconds( + self.config.get("no_data_interval_seconds", + self.mode_config.get("no_data_interval_seconds")), 300) + self.live_idle_max_interval = _clamp_seconds( + self.config.get("live_idle_max_interval_seconds", + self.mode_config.get("live_idle_max_interval_seconds")), + _DEFAULT_LIVE_IDLE_MAX_SECONDS) + self._empty_live_streak = 0 # Log the configured interval for debugging try: mode_config_keys = list(self.mode_config.keys()) if isinstance(self.mode_config, dict) else "N/A" @@ -2345,6 +2380,43 @@ def _detect_stale_games(self, games: List[Dict]) -> None: if game_id in self.game_update_timestamps: del self.game_update_timestamps[game_id] + def _idle_live_interval(self) -> int: + """How long to wait before looking for live games again, when there are none. + + Escalates the longer nothing turns up, and any live game resets it, so + an in-season gap between games costs at most one escalated wait while + an out-of-season league stops polling on a live cadence entirely. + + Capped rather than unbounded: the cost of backing off is how late the + first game after a quiet spell is noticed, and past the cap the saving + stops being worth that. + """ + streak = getattr(self, "_empty_live_streak", 0) + base = self.no_data_interval + ceiling = getattr(self, "live_idle_max_interval", + _DEFAULT_LIVE_IDLE_MAX_SECONDS) + # The ceiling bounds the un-escalated interval too. The two settings are + # independent integers with no cross-validation, so base > ceiling is a + # reachable config -- and returning base unclamped there made the wait + # *shrink* as the streak grew (3600s at streak 0, 900s at streak 24), + # the opposite of what the setting named "maximum" promises. + if streak >= _IDLE_LONG_STREAK: + return min(int(base * _IDLE_LONG_FACTOR), ceiling) + if streak >= _IDLE_SHORT_STREAK: + return min(int(base * _IDLE_SHORT_FACTOR), ceiling) + return min(base, ceiling) + + def _note_live_fetch(self, found_live: bool) -> None: + """Record whether a look for live games found any.""" + if found_live: + if getattr(self, "_empty_live_streak", 0): + self.logger.info( + "Live games found after %d empty check(s); back to the " + "live update interval", self._empty_live_streak) + self._empty_live_streak = 0 + else: + self._empty_live_streak = getattr(self, "_empty_live_streak", 0) + 1 + def update(self): """Update live game data and handle game switching.""" if not self.is_enabled: @@ -2369,31 +2441,36 @@ def update(self): # Only use no_data_interval if we've recently checked and confirmed there are no live games. # This ensures we check for live games frequently even if the list is temporarily empty. # Only use no_data_interval if we have no live games AND we've checked recently (within last 5 minutes) - time_since_last_update = current_time - self.last_update - has_recently_checked = self.last_update > 0 and time_since_last_update < 300 - + # Whether the last look found anything, tracked explicitly rather than + # inferred from how long ago it was. The old form asked "did we check + # within the last 300s?" and only then used no_data_interval -- but + # once 300s had elapsed the answer became no, the interval dropped + # back to live_update_interval, and it fetched. no_data_interval could + # therefore never delay anything past 300s whatever it was set to. + # Measured on a live rig: an out-of-season NHL polled every ~5.5 + # minutes around the clock, returning nothing every time. if _live_games_attr: - # We have live games, use the configured update interval interval = _update_interval_attr - elif has_recently_checked: - # We've checked recently and found no live games, use longer interval - interval = _no_data_interval_attr else: - # First check or haven't checked in a while, use update interval to check for live games - interval = _update_interval_attr + interval = self._idle_live_interval() # Debug logging for interval selection (log every 5 minutes or when interval changes) if current_time - self.last_log_time >= 300: # Log every 5 minutes self.logger.info( f"Update check: live_games={len(_live_games_attr) if _live_games_attr else 0}, " f"update_interval={_update_interval_attr}, no_data_interval={_no_data_interval_attr}, " - f"selected_interval={interval}, time_since_last_update={time_since_last_update:.1f}s, " - f"has_recently_checked={has_recently_checked}" + f"selected_interval={interval}, " + f"time_since_last_update={current_time - self.last_update:.1f}s, " + f"empty_live_streak={getattr(self, '_empty_live_streak', 0)}" ) self.last_log_time = current_time # Original line from traceback (line 455), now with variables defined: if current_time - self.last_update >= interval: + # What the previous look found, recorded before this one + # replaces it. The streak is what drives the back-off, and + # any live game resets it. + self._note_live_fetch(bool(_live_games_attr)) self.last_update = current_time # Fetch rankings if enabled diff --git a/plugins/hockey-scoreboard/test_idle_league_backoff.py b/plugins/hockey-scoreboard/test_idle_league_backoff.py new file mode 100644 index 00000000..ffe3bc2f --- /dev/null +++ b/plugins/hockey-scoreboard/test_idle_league_backoff.py @@ -0,0 +1,308 @@ +#!/usr/bin/env python3 +""" +Tests that a league with nothing on stops polling on a live cadence. + +Regression under test: the live gate decided its interval like this -- + + has_recently_checked = self.last_update > 0 and time_since_last_update < 300 + if live_games: interval = live_update_interval + elif has_recently_checked: interval = no_data_interval + else: interval = live_update_interval + +Once 300s had elapsed, has_recently_checked became False, the interval fell +back to live_update_interval, and it fetched. no_data_interval could therefore +never delay anything past 300s whatever it was set to -- the setting was +inert. Measured on a live rig in mid-August: NHLLiveManager fetched 0 games 22 +times in 2 hours, every ~5.5 minutes, around the clock, for a league whose +season had not started. Roughly 264 wasted requests a day, per league. + +The interval now comes from an explicit streak of empty looks, which escalates +and is capped, and which any live game resets. + +Second regression under test: the two interval settings were read from +``self.mode_config`` -- the per-league ``{sport_key}_scoreboard`` block -- while +the schema declares them (and the web UI writes them) at the config *root*. The +saved value was therefore never seen and every user silently kept the default. +An earlier version of this test could not catch that, because it set +``no_data_interval`` on a stand-in object by hand and so never ran the lookup. +It now builds a real ``SportsLive`` and asserts on what its ``__init__`` +resolved. + +Only ``SportsCore.__init__`` is stubbed -- it pulls in logo downloading, fonts +and an ESPN data source, none of which this test needs -- so the lookup lines +themselves run for real. The stub's fidelity is asserted against the real +``SportsCore`` source below, so it cannot drift out of sync unnoticed. + +Run: /bin/python plugins/hockey-scoreboard/test_idle_league_backoff.py +""" + +# A test harness: it reaches into protected members on purpose and builds a +# concrete subclass at runtime, neither of which pylint can see as intentional. +# pylint: disable=protected-access,abstract-class-instantiated,unused-argument +import ast +import sys +from pathlib import Path + +plugin_dir = Path(__file__).resolve().parent +sys.path.insert(0, str(plugin_dir)) +for candidate in (Path("/home/rackpi/projects/LEDMatrix"), + plugin_dir.parents[2] / "LEDMatrix"): + if (candidate / "src" / "plugin_system" / "base_plugin.py").exists(): + sys.path.insert(0, str(candidate)) + break + +import sports # noqa: E402 + +failures = [] + + +def check(label, ok): + print((" PASS " if ok else " FAIL ") + label) + if not ok: + failures.append(label) + + +class _Logger: + def info(self, *a, **k): + pass + + +SPORT_KEY = "test" + + +def _core_init(): + core_src = (plugin_dir / "sports.py").read_text(encoding="utf-8") + core = next(c for c in ast.walk(ast.parse(core_src)) + if isinstance(c, ast.ClassDef) and c.name == "SportsCore") + return core_src, next(m for m in core.body + if isinstance(m, ast.FunctionDef) and m.name == "__init__") + + +def _derive_mode_key(): + """Build the per-league key exactly as this plugin's SportsCore does. + + Not hardcoded: most plugins use f"{sport_key}_scoreboard" but ufc-scoreboard + uses the bare sport_key. Hardcoding one of them made the stub test fiction + on the other -- which the fidelity check below caught. Reading the real + expression out of the source keeps every plugin honest. + """ + _, init = _core_init() + for node in ast.walk(init): + if (isinstance(node, ast.Assign) + and any(getattr(t, "attr", None) == "mode_config" + for t in node.targets) + and isinstance(node.value, ast.Call) and node.value.args): + arg = node.value.args[0] + return _resolve_key(arg), ast.unparse(arg) + raise AssertionError("SportsCore no longer assigns self.mode_config") + + +def _resolve_key(node): + """Evaluate the key expression by hand -- deliberately not with eval(). + + Only the two shapes that actually occur are supported; anything else is a + hard error rather than a guess, so a future rewrite of that line surfaces + here instead of silently producing a key nothing is stored under. + """ + if isinstance(node, ast.Constant): + return node.value + if isinstance(node, ast.Name) and node.id == "sport_key": + return SPORT_KEY + if isinstance(node, ast.JoinedStr): + return "".join(_resolve_key(part) for part in node.values) + if isinstance(node, ast.FormattedValue): + return _resolve_key(node.value) + raise AssertionError( + "unsupported mode_config key expression: %s" % ast.unparse(node)) + + +MODE_KEY, _MODE_KEY_EXPR = _derive_mode_key() + + +def _assert_stub_is_faithful(): + """The stub below stands in for SportsCore.__init__; prove it still matches. + + It has to reproduce exactly the two assignments the lookup under test + depends on. If SportsCore ever changes where config or mode_config come + from, this fails loudly rather than letting the stub quietly test fiction. + """ + _, init = _core_init() + assigns = {} + for node in ast.walk(init): + if isinstance(node, ast.Assign): + for target in node.targets: + if getattr(target, "attr", None) in ("config", "mode_config"): + assigns[target.attr] = node.value + + # The stub is only faithful if SportsCore still (a) keeps the handed-in + # config verbatim as self.config -- if it ever stored a sub-dict, "root" + # would mean something different -- and (b) derives mode_config as a + # lookup *within* that same config. The key itself is read from source + # above, so it needs no assertion; these two shapes do. + raw = assigns.get("config") + check("SportsCore still keeps the handed-in config verbatim as self.config", + isinstance(raw, ast.Name) and raw.id == "config") + + mode = assigns.get("mode_config") + check("mode_config is still a lookup inside that same config", + isinstance(mode, ast.Call) + and isinstance(mode.func, ast.Attribute) + and mode.func.attr == "get" + and isinstance(mode.func.value, ast.Name) + and mode.func.value.id == "config") + + +class _StubCore: + """Exactly the two SportsCore assignments the lookup under test reads.""" + # pylint: disable=too-few-public-methods + + def __init__(self, config, display_manager, cache_manager, logger, sport_key): + self.logger = logger + self.config = config + self.mode_config = config.get(MODE_KEY, {}) or {} + + +# SportsLive may carry abstract methods from SportsCore; fill them in so it can +# be constructed. They are never called here. +_Concrete = type("_ConcreteLive", (sports.SportsLive,), + {name: (lambda self, *a, **k: None) + for name in getattr(sports.SportsLive, + "__abstractmethods__", ())}) + + +def _live(root=None, league=None): + """A real SportsLive built from a real config dict. + + root/league place the settings at the config root and in the per-league + block respectively, which is the whole point: the test can now tell those + two locations apart. + """ + config = dict(root or {}) + config[MODE_KEY] = dict(league or {}) + real_init = sports.SportsCore.__init__ + sports.SportsCore.__init__ = _StubCore.__init__ + try: + return _Concrete(config, object(), object(), _Logger(), SPORT_KEY) + finally: + sports.SportsCore.__init__ = real_init + + +def _Live(base=300, ceiling=900): + """The old signature, now backed by a real config round-trip.""" + return _live(root={"no_data_interval_seconds": base, + "live_idle_max_interval_seconds": ceiling}) + + +def main(): + print("the settings are read from where they are actually written") + _assert_stub_is_faithful() + + at_root = _live(root={"no_data_interval_seconds": 600, + "live_idle_max_interval_seconds": 1200}) + check("a root value is honoured (the schema declares them at the root)", + at_root.no_data_interval == 600) + check("...including the ceiling", at_root.live_idle_max_interval == 1200) + + defaults = _live() + check("absent anywhere falls back to the default", + defaults.no_data_interval == 300) + + legacy = _live(league={"no_data_interval_seconds": 600}) + check("a hand-placed per-league value still works", + legacy.no_data_interval == 600) + + both = _live(root={"no_data_interval_seconds": 600}, + league={"no_data_interval_seconds": 45}) + check("the root wins over a stale per-league value", + both.no_data_interval == 600) + + junk = _live(root={"no_data_interval_seconds": "soon", + "live_idle_max_interval_seconds": float("inf")}) + check("garbage at the root falls back rather than raising", + junk.no_data_interval == 300) + check("an infinite ceiling falls back too", + junk.live_idle_max_interval == sports._DEFAULT_LIVE_IDLE_MAX_SECONDS) + + print("\nthe wait grows the longer nothing is found") + live = _Live() + check("first look uses the base interval", + live._idle_live_interval() == 300) + + for _ in range(sports._IDLE_SHORT_STREAK): + live._note_live_fetch(False) + check("after a short streak it is longer (%ds)" % live._idle_live_interval(), + live._idle_live_interval() > 300) + + short_wait = live._idle_live_interval() + for _ in range(sports._IDLE_LONG_STREAK): + live._note_live_fetch(False) + long_wait = live._idle_live_interval() + # Against short_wait, not against itself: comparing the value to a second + # call of the same method with the same state can never fail, so the + # monotonicity it is meant to protect went untested. + check("after a long streak it is longer still (%ds vs %ds)" + % (long_wait, short_wait), long_wait > short_wait) + check("and never exceeds the ceiling", long_wait <= 900) + + for _ in range(500): + live._note_live_fetch(False) + check("a very long streak stays at the ceiling, not beyond", + live._idle_live_interval() == 900) + + print("\na live game resets it immediately") + live._note_live_fetch(True) + check("the streak is cleared", live._empty_live_streak == 0) + check("and the base interval is back", + live._idle_live_interval() == 300) + + print("\nthe saving is real, and bounded") + idle = _Live() + for _ in range(500): + idle._note_live_fetch(False) + per_day = 86400 / idle._idle_live_interval() + check("an out-of-season league polls far less (%d/day vs 288)" % per_day, + per_day < 288 / 2) + check("...but still often enough to notice a season starting (<= 1h)", + idle._idle_live_interval() <= 3600) + + print("\nthe ceiling is configurable") + tight = _Live(ceiling=300) + for _ in range(500): + tight._note_live_fetch(False) + check("a lower ceiling is honoured", tight._idle_live_interval() == 300) + loose = _Live(ceiling=3600) + for _ in range(500): + loose._note_live_fetch(False) + check("a higher ceiling is honoured", loose._idle_live_interval() == 1800) + + print("\nintervals from config are clamped, never trusted raw") + check("a sane value is used", sports._clamp_seconds(120, 300) == 120) + check("absent falls back", sports._clamp_seconds(None, 300) == 300) + check("nonsense falls back", sports._clamp_seconds("soon", 300) == 300) + check("zero is clamped up", sports._clamp_seconds(0, 300) >= 5) + check("a week is clamped down", sports._clamp_seconds(604800, 300) <= 86400) + + print("\nthe old recency-based logic is gone") + src = (plugin_dir / "sports.py").read_text(encoding="utf-8") + check("has_recently_checked no longer decides the interval", + "has_recently_checked" not in src) + + tree = ast.parse(src) + live_cls = next(c for c in ast.walk(tree) + if isinstance(c, ast.ClassDef) and c.name == "SportsLive") + upd = next(m for m in live_cls.body + if isinstance(m, ast.FunctionDef) and m.name == "update") + idle_calls = [n for n in ast.walk(upd) if isinstance(n, ast.Call) + and getattr(n.func, "attr", None) == "_idle_live_interval"] + note_calls = [n for n in ast.walk(upd) if isinstance(n, ast.Call) + and getattr(n.func, "attr", None) == "_note_live_fetch"] + check("update() takes its idle interval from the back-off", len(idle_calls) == 1) + check("update() records each look's outcome", len(note_calls) == 1) + + print("\n%s" % ("FAILED: %d" % len(failures) if failures + else "All checks passed")) + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/lacrosse-scoreboard/config_schema.json b/plugins/lacrosse-scoreboard/config_schema.json index 8818e91a..8884fa38 100644 --- a/plugins/lacrosse-scoreboard/config_schema.json +++ b/plugins/lacrosse-scoreboard/config_schema.json @@ -1610,6 +1610,24 @@ "title": "Schedule lookahead (days)", "description": "How far ahead to fetch games for the Upcoming screen. Raise it for a league that plays weekly: a fixture just beyond this horizon is not fetched at all, so it never reaches the board even though the date is known.", "x-advanced": true + }, + "no_data_interval_seconds": { + "type": "integer", + "default": 300, + "minimum": 5, + "maximum": 86400, + "title": "Idle live-check interval (seconds)", + "description": "How long to wait between checks for live games when there are none. Backs off further the longer nothing is found, up to the cap below.", + "x-advanced": true + }, + "live_idle_max_interval_seconds": { + "type": "integer", + "default": 900, + "minimum": 5, + "maximum": 86400, + "title": "Maximum idle live-check interval (seconds)", + "description": "Ceiling for the back-off above. Raise it to poll an out-of-season league even less; lower it to notice the first game after a quiet spell sooner.", + "x-advanced": true } }, "required": [ diff --git a/plugins/lacrosse-scoreboard/manager.py b/plugins/lacrosse-scoreboard/manager.py index d6996eff..471b286a 100644 --- a/plugins/lacrosse-scoreboard/manager.py +++ b/plugins/lacrosse-scoreboard/manager.py @@ -56,6 +56,15 @@ from lacrosse_timezone import resolve_timezone_name from lacrosse_favorite_check import FavoriteTeamCheck + +_ROOT_CONFIG_KEYS = ( + "schedule_lookback_days", + "schedule_lookahead_days", + "no_data_interval_seconds", + "live_idle_max_interval_seconds", +) + + # Which ESPN endpoint backs each league, for the favorite-team diagnostic. FAVORITE_CHECK_LEAGUES = { 'ncaa_mens': ("NCAA Men's Lacrosse", 'lacrosse/mens-college-lacrosse'), @@ -834,13 +843,15 @@ def resolve_non_favorite_live_duration() -> int: } ) - # The schedule-window settings live at the plugin config root, and - # SportsCore reads them from the root of the config it is handed. This - # adapter builds its output key by key, so anything not named here is - # dropped -- which silently pinned every user to the defaults. - for _window_key in ("schedule_lookback_days", "schedule_lookahead_days"): - if _window_key in self.config: - manager_config[_window_key] = self.config[_window_key] + # Plugin-root settings that SportsCore reads from the root of the config + # it is handed. This adapter builds its output key by key, so anything + # not named here is dropped -- which is how the schedule window silently + # pinned every user to the defaults, and would have done the same to the + # idle-poll settings. Generalised to a list so the next one added to + # SportsCore only has to be named once. + for _root_key in _ROOT_CONFIG_KEYS: + if _root_key in self.config: + manager_config[_root_key] = self.config[_root_key] return manager_config def _get_available_modes(self) -> list: diff --git a/plugins/lacrosse-scoreboard/manifest.json b/plugins/lacrosse-scoreboard/manifest.json index f95aa141..1e9a5d8b 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.11.1", + "version": "1.12.0", "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,13 @@ } ], "versions": [ + { + "version": "1.12.0", + "released": "2026-08-19", + "ledmatrix_min_version": "2.0.0", + "notes": "Make the schedule fetch window configurable. How far back and forward games are fetched was hard-coded at 14 days back and 7 forward. Days is the wrong unit for a league that plays weekly -- a whole matchweek can fall just past a short horizon and never reach the board -- so both are now advanced settings, bounded 1-60 days, with a stray or non-numeric value falling back to the default rather than raising. Defaults keep today's behaviour.", + "changes": "Back off the live-game poll while a league has nothing on, and let both the idle interval and its ceiling be configured. Saves API calls out of season and between games without delaying an in-progress game, which still resets the interval on the first fetch that finds one." + }, { "version": "1.11.1", "released": "2026-08-18", diff --git a/plugins/lacrosse-scoreboard/sports.py b/plugins/lacrosse-scoreboard/sports.py index b424b2b9..a1a6d422 100644 --- a/plugins/lacrosse-scoreboard/sports.py +++ b/plugins/lacrosse-scoreboard/sports.py @@ -98,6 +98,28 @@ def _clamp_window(value: Any, fallback: int) -> int: return max(_MIN_WINDOW_DAYS, min(_MAX_WINDOW_DAYS, days)) +# Backing off the live poll while a league has nothing on. Gentle at first -- +# a gap between games in a live season should cost little -- then firmer, so a +# league months out of season stops polling on a live cadence altogether. +_IDLE_SHORT_STREAK = 6 +_IDLE_SHORT_FACTOR = 2 +_IDLE_LONG_STREAK = 24 +_IDLE_LONG_FACTOR = 6 +_DEFAULT_LIVE_IDLE_MAX_SECONDS = 900 + + +def _clamp_seconds(value: Any, fallback: int, low: int = 5, + high: int = 86400) -> int: + """An interval in seconds, or the fallback when the value is unusable.""" + try: + seconds = int(value) + except (TypeError, ValueError, OverflowError): + # OverflowError: json parses bare Infinity by default and int(inf) + # raises -- the same gap _clamp_window above already covers. + return fallback + return max(low, min(high, seconds)) + + class SportsCore(ABC): def __init__( self, @@ -2182,7 +2204,20 @@ def __init__( super().__init__(config, display_manager, cache_manager, logger, sport_key) self.update_interval = self.mode_config.get("live_update_interval", 15) self._rotation_schedule: List[str] = [] - self.no_data_interval = 300 + # Read from the config root, where the schema declares them and the web + # UI writes them -- not from mode_config, which is the per-league block + # ({sport}_scoreboard) and never carries these keys. Looking them up + # there meant the saved value was invisible and every user silently kept + # the default. mode_config is still consulted as a fallback so a + # hand-placed per-league value keeps working. + self.no_data_interval = _clamp_seconds( + self.config.get("no_data_interval_seconds", + self.mode_config.get("no_data_interval_seconds")), 300) + self.live_idle_max_interval = _clamp_seconds( + self.config.get("live_idle_max_interval_seconds", + self.mode_config.get("live_idle_max_interval_seconds")), + _DEFAULT_LIVE_IDLE_MAX_SECONDS) + self._empty_live_streak = 0 # Log the configured interval for debugging try: mode_config_keys = list(self.mode_config.keys()) if isinstance(self.mode_config, dict) else "N/A" @@ -2312,6 +2347,43 @@ def _detect_stale_games(self, games: List[Dict]) -> None: if game_id in self.game_update_timestamps: del self.game_update_timestamps[game_id] + def _idle_live_interval(self) -> int: + """How long to wait before looking for live games again, when there are none. + + Escalates the longer nothing turns up, and any live game resets it, so + an in-season gap between games costs at most one escalated wait while + an out-of-season league stops polling on a live cadence entirely. + + Capped rather than unbounded: the cost of backing off is how late the + first game after a quiet spell is noticed, and past the cap the saving + stops being worth that. + """ + streak = getattr(self, "_empty_live_streak", 0) + base = self.no_data_interval + ceiling = getattr(self, "live_idle_max_interval", + _DEFAULT_LIVE_IDLE_MAX_SECONDS) + # The ceiling bounds the un-escalated interval too. The two settings are + # independent integers with no cross-validation, so base > ceiling is a + # reachable config -- and returning base unclamped there made the wait + # *shrink* as the streak grew (3600s at streak 0, 900s at streak 24), + # the opposite of what the setting named "maximum" promises. + if streak >= _IDLE_LONG_STREAK: + return min(int(base * _IDLE_LONG_FACTOR), ceiling) + if streak >= _IDLE_SHORT_STREAK: + return min(int(base * _IDLE_SHORT_FACTOR), ceiling) + return min(base, ceiling) + + def _note_live_fetch(self, found_live: bool) -> None: + """Record whether a look for live games found any.""" + if found_live: + if getattr(self, "_empty_live_streak", 0): + self.logger.info( + "Live games found after %d empty check(s); back to the " + "live update interval", self._empty_live_streak) + self._empty_live_streak = 0 + else: + self._empty_live_streak = getattr(self, "_empty_live_streak", 0) + 1 + def update(self): """Update live game data and handle game switching.""" if not self.is_enabled: @@ -2336,31 +2408,36 @@ def update(self): # Only use no_data_interval if we've recently checked and confirmed there are no live games. # This ensures we check for live games frequently even if the list is temporarily empty. # Only use no_data_interval if we have no live games AND we've checked recently (within last 5 minutes) - time_since_last_update = current_time - self.last_update - has_recently_checked = self.last_update > 0 and time_since_last_update < 300 - + # Whether the last look found anything, tracked explicitly rather than + # inferred from how long ago it was. The old form asked "did we check + # within the last 300s?" and only then used no_data_interval -- but + # once 300s had elapsed the answer became no, the interval dropped + # back to live_update_interval, and it fetched. no_data_interval could + # therefore never delay anything past 300s whatever it was set to. + # Measured on a live rig: an out-of-season NHL polled every ~5.5 + # minutes around the clock, returning nothing every time. if _live_games_attr: - # We have live games, use the configured update interval interval = _update_interval_attr - elif has_recently_checked: - # We've checked recently and found no live games, use longer interval - interval = _no_data_interval_attr else: - # First check or haven't checked in a while, use update interval to check for live games - interval = _update_interval_attr + interval = self._idle_live_interval() # Debug logging for interval selection (log every 5 minutes or when interval changes) if current_time - self.last_log_time >= 300: # Log every 5 minutes self.logger.info( f"Update check: live_games={len(_live_games_attr) if _live_games_attr else 0}, " f"update_interval={_update_interval_attr}, no_data_interval={_no_data_interval_attr}, " - f"selected_interval={interval}, time_since_last_update={time_since_last_update:.1f}s, " - f"has_recently_checked={has_recently_checked}" + f"selected_interval={interval}, " + f"time_since_last_update={current_time - self.last_update:.1f}s, " + f"empty_live_streak={getattr(self, '_empty_live_streak', 0)}" ) self.last_log_time = current_time # Original line from traceback (line 455), now with variables defined: if current_time - self.last_update >= interval: + # What the previous look found, recorded before this one + # replaces it. The streak is what drives the back-off, and + # any live game resets it. + self._note_live_fetch(bool(_live_games_attr)) self.last_update = current_time # Fetch rankings if enabled diff --git a/plugins/lacrosse-scoreboard/test_idle_league_backoff.py b/plugins/lacrosse-scoreboard/test_idle_league_backoff.py new file mode 100644 index 00000000..2a1be762 --- /dev/null +++ b/plugins/lacrosse-scoreboard/test_idle_league_backoff.py @@ -0,0 +1,308 @@ +#!/usr/bin/env python3 +""" +Tests that a league with nothing on stops polling on a live cadence. + +Regression under test: the live gate decided its interval like this -- + + has_recently_checked = self.last_update > 0 and time_since_last_update < 300 + if live_games: interval = live_update_interval + elif has_recently_checked: interval = no_data_interval + else: interval = live_update_interval + +Once 300s had elapsed, has_recently_checked became False, the interval fell +back to live_update_interval, and it fetched. no_data_interval could therefore +never delay anything past 300s whatever it was set to -- the setting was +inert. Measured on a live rig in mid-August: NHLLiveManager fetched 0 games 22 +times in 2 hours, every ~5.5 minutes, around the clock, for a league whose +season had not started. Roughly 264 wasted requests a day, per league. + +The interval now comes from an explicit streak of empty looks, which escalates +and is capped, and which any live game resets. + +Second regression under test: the two interval settings were read from +``self.mode_config`` -- the per-league ``{sport_key}_scoreboard`` block -- while +the schema declares them (and the web UI writes them) at the config *root*. The +saved value was therefore never seen and every user silently kept the default. +An earlier version of this test could not catch that, because it set +``no_data_interval`` on a stand-in object by hand and so never ran the lookup. +It now builds a real ``SportsLive`` and asserts on what its ``__init__`` +resolved. + +Only ``SportsCore.__init__`` is stubbed -- it pulls in logo downloading, fonts +and an ESPN data source, none of which this test needs -- so the lookup lines +themselves run for real. The stub's fidelity is asserted against the real +``SportsCore`` source below, so it cannot drift out of sync unnoticed. + +Run: /bin/python plugins/lacrosse-scoreboard/test_idle_league_backoff.py +""" + +# A test harness: it reaches into protected members on purpose and builds a +# concrete subclass at runtime, neither of which pylint can see as intentional. +# pylint: disable=protected-access,abstract-class-instantiated,unused-argument +import ast +import sys +from pathlib import Path + +plugin_dir = Path(__file__).resolve().parent +sys.path.insert(0, str(plugin_dir)) +for candidate in (Path("/home/rackpi/projects/LEDMatrix"), + plugin_dir.parents[2] / "LEDMatrix"): + if (candidate / "src" / "plugin_system" / "base_plugin.py").exists(): + sys.path.insert(0, str(candidate)) + break + +import sports # noqa: E402 + +failures = [] + + +def check(label, ok): + print((" PASS " if ok else " FAIL ") + label) + if not ok: + failures.append(label) + + +class _Logger: + def info(self, *a, **k): + pass + + +SPORT_KEY = "test" + + +def _core_init(): + core_src = (plugin_dir / "sports.py").read_text(encoding="utf-8") + core = next(c for c in ast.walk(ast.parse(core_src)) + if isinstance(c, ast.ClassDef) and c.name == "SportsCore") + return core_src, next(m for m in core.body + if isinstance(m, ast.FunctionDef) and m.name == "__init__") + + +def _derive_mode_key(): + """Build the per-league key exactly as this plugin's SportsCore does. + + Not hardcoded: most plugins use f"{sport_key}_scoreboard" but ufc-scoreboard + uses the bare sport_key. Hardcoding one of them made the stub test fiction + on the other -- which the fidelity check below caught. Reading the real + expression out of the source keeps every plugin honest. + """ + _, init = _core_init() + for node in ast.walk(init): + if (isinstance(node, ast.Assign) + and any(getattr(t, "attr", None) == "mode_config" + for t in node.targets) + and isinstance(node.value, ast.Call) and node.value.args): + arg = node.value.args[0] + return _resolve_key(arg), ast.unparse(arg) + raise AssertionError("SportsCore no longer assigns self.mode_config") + + +def _resolve_key(node): + """Evaluate the key expression by hand -- deliberately not with eval(). + + Only the two shapes that actually occur are supported; anything else is a + hard error rather than a guess, so a future rewrite of that line surfaces + here instead of silently producing a key nothing is stored under. + """ + if isinstance(node, ast.Constant): + return node.value + if isinstance(node, ast.Name) and node.id == "sport_key": + return SPORT_KEY + if isinstance(node, ast.JoinedStr): + return "".join(_resolve_key(part) for part in node.values) + if isinstance(node, ast.FormattedValue): + return _resolve_key(node.value) + raise AssertionError( + "unsupported mode_config key expression: %s" % ast.unparse(node)) + + +MODE_KEY, _MODE_KEY_EXPR = _derive_mode_key() + + +def _assert_stub_is_faithful(): + """The stub below stands in for SportsCore.__init__; prove it still matches. + + It has to reproduce exactly the two assignments the lookup under test + depends on. If SportsCore ever changes where config or mode_config come + from, this fails loudly rather than letting the stub quietly test fiction. + """ + _, init = _core_init() + assigns = {} + for node in ast.walk(init): + if isinstance(node, ast.Assign): + for target in node.targets: + if getattr(target, "attr", None) in ("config", "mode_config"): + assigns[target.attr] = node.value + + # The stub is only faithful if SportsCore still (a) keeps the handed-in + # config verbatim as self.config -- if it ever stored a sub-dict, "root" + # would mean something different -- and (b) derives mode_config as a + # lookup *within* that same config. The key itself is read from source + # above, so it needs no assertion; these two shapes do. + raw = assigns.get("config") + check("SportsCore still keeps the handed-in config verbatim as self.config", + isinstance(raw, ast.Name) and raw.id == "config") + + mode = assigns.get("mode_config") + check("mode_config is still a lookup inside that same config", + isinstance(mode, ast.Call) + and isinstance(mode.func, ast.Attribute) + and mode.func.attr == "get" + and isinstance(mode.func.value, ast.Name) + and mode.func.value.id == "config") + + +class _StubCore: + """Exactly the two SportsCore assignments the lookup under test reads.""" + # pylint: disable=too-few-public-methods + + def __init__(self, config, display_manager, cache_manager, logger, sport_key): + self.logger = logger + self.config = config + self.mode_config = config.get(MODE_KEY, {}) or {} + + +# SportsLive may carry abstract methods from SportsCore; fill them in so it can +# be constructed. They are never called here. +_Concrete = type("_ConcreteLive", (sports.SportsLive,), + {name: (lambda self, *a, **k: None) + for name in getattr(sports.SportsLive, + "__abstractmethods__", ())}) + + +def _live(root=None, league=None): + """A real SportsLive built from a real config dict. + + root/league place the settings at the config root and in the per-league + block respectively, which is the whole point: the test can now tell those + two locations apart. + """ + config = dict(root or {}) + config[MODE_KEY] = dict(league or {}) + real_init = sports.SportsCore.__init__ + sports.SportsCore.__init__ = _StubCore.__init__ + try: + return _Concrete(config, object(), object(), _Logger(), SPORT_KEY) + finally: + sports.SportsCore.__init__ = real_init + + +def _Live(base=300, ceiling=900): + """The old signature, now backed by a real config round-trip.""" + return _live(root={"no_data_interval_seconds": base, + "live_idle_max_interval_seconds": ceiling}) + + +def main(): + print("the settings are read from where they are actually written") + _assert_stub_is_faithful() + + at_root = _live(root={"no_data_interval_seconds": 600, + "live_idle_max_interval_seconds": 1200}) + check("a root value is honoured (the schema declares them at the root)", + at_root.no_data_interval == 600) + check("...including the ceiling", at_root.live_idle_max_interval == 1200) + + defaults = _live() + check("absent anywhere falls back to the default", + defaults.no_data_interval == 300) + + legacy = _live(league={"no_data_interval_seconds": 600}) + check("a hand-placed per-league value still works", + legacy.no_data_interval == 600) + + both = _live(root={"no_data_interval_seconds": 600}, + league={"no_data_interval_seconds": 45}) + check("the root wins over a stale per-league value", + both.no_data_interval == 600) + + junk = _live(root={"no_data_interval_seconds": "soon", + "live_idle_max_interval_seconds": float("inf")}) + check("garbage at the root falls back rather than raising", + junk.no_data_interval == 300) + check("an infinite ceiling falls back too", + junk.live_idle_max_interval == sports._DEFAULT_LIVE_IDLE_MAX_SECONDS) + + print("\nthe wait grows the longer nothing is found") + live = _Live() + check("first look uses the base interval", + live._idle_live_interval() == 300) + + for _ in range(sports._IDLE_SHORT_STREAK): + live._note_live_fetch(False) + check("after a short streak it is longer (%ds)" % live._idle_live_interval(), + live._idle_live_interval() > 300) + + short_wait = live._idle_live_interval() + for _ in range(sports._IDLE_LONG_STREAK): + live._note_live_fetch(False) + long_wait = live._idle_live_interval() + # Against short_wait, not against itself: comparing the value to a second + # call of the same method with the same state can never fail, so the + # monotonicity it is meant to protect went untested. + check("after a long streak it is longer still (%ds vs %ds)" + % (long_wait, short_wait), long_wait > short_wait) + check("and never exceeds the ceiling", long_wait <= 900) + + for _ in range(500): + live._note_live_fetch(False) + check("a very long streak stays at the ceiling, not beyond", + live._idle_live_interval() == 900) + + print("\na live game resets it immediately") + live._note_live_fetch(True) + check("the streak is cleared", live._empty_live_streak == 0) + check("and the base interval is back", + live._idle_live_interval() == 300) + + print("\nthe saving is real, and bounded") + idle = _Live() + for _ in range(500): + idle._note_live_fetch(False) + per_day = 86400 / idle._idle_live_interval() + check("an out-of-season league polls far less (%d/day vs 288)" % per_day, + per_day < 288 / 2) + check("...but still often enough to notice a season starting (<= 1h)", + idle._idle_live_interval() <= 3600) + + print("\nthe ceiling is configurable") + tight = _Live(ceiling=300) + for _ in range(500): + tight._note_live_fetch(False) + check("a lower ceiling is honoured", tight._idle_live_interval() == 300) + loose = _Live(ceiling=3600) + for _ in range(500): + loose._note_live_fetch(False) + check("a higher ceiling is honoured", loose._idle_live_interval() == 1800) + + print("\nintervals from config are clamped, never trusted raw") + check("a sane value is used", sports._clamp_seconds(120, 300) == 120) + check("absent falls back", sports._clamp_seconds(None, 300) == 300) + check("nonsense falls back", sports._clamp_seconds("soon", 300) == 300) + check("zero is clamped up", sports._clamp_seconds(0, 300) >= 5) + check("a week is clamped down", sports._clamp_seconds(604800, 300) <= 86400) + + print("\nthe old recency-based logic is gone") + src = (plugin_dir / "sports.py").read_text(encoding="utf-8") + check("has_recently_checked no longer decides the interval", + "has_recently_checked" not in src) + + tree = ast.parse(src) + live_cls = next(c for c in ast.walk(tree) + if isinstance(c, ast.ClassDef) and c.name == "SportsLive") + upd = next(m for m in live_cls.body + if isinstance(m, ast.FunctionDef) and m.name == "update") + idle_calls = [n for n in ast.walk(upd) if isinstance(n, ast.Call) + and getattr(n.func, "attr", None) == "_idle_live_interval"] + note_calls = [n for n in ast.walk(upd) if isinstance(n, ast.Call) + and getattr(n.func, "attr", None) == "_note_live_fetch"] + check("update() takes its idle interval from the back-off", len(idle_calls) == 1) + check("update() records each look's outcome", len(note_calls) == 1) + + print("\n%s" % ("FAILED: %d" % len(failures) if failures + else "All checks passed")) + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/nrl-scoreboard/config_schema.json b/plugins/nrl-scoreboard/config_schema.json index 286a5fa8..f67bcd81 100644 --- a/plugins/nrl-scoreboard/config_schema.json +++ b/plugins/nrl-scoreboard/config_schema.json @@ -1240,6 +1240,24 @@ "title": "Schedule lookahead (days)", "description": "How far ahead to fetch games for the Upcoming screen. Raise it for a league that plays weekly: a fixture just beyond this horizon is not fetched at all, so it never reaches the board even though the date is known.", "x-advanced": true + }, + "no_data_interval_seconds": { + "type": "integer", + "default": 300, + "minimum": 5, + "maximum": 86400, + "title": "Idle live-check interval (seconds)", + "description": "How long to wait between checks for live games when there are none. Backs off further the longer nothing is found, up to the cap below.", + "x-advanced": true + }, + "live_idle_max_interval_seconds": { + "type": "integer", + "default": 900, + "minimum": 5, + "maximum": 86400, + "title": "Maximum idle live-check interval (seconds)", + "description": "Ceiling for the back-off above. Raise it to poll an out-of-season league even less; lower it to notice the first game after a quiet spell sooner.", + "x-advanced": true } }, "additionalProperties": false, diff --git a/plugins/nrl-scoreboard/manager.py b/plugins/nrl-scoreboard/manager.py index b4b47b32..4564fcd0 100644 --- a/plugins/nrl-scoreboard/manager.py +++ b/plugins/nrl-scoreboard/manager.py @@ -56,6 +56,15 @@ from nrl_timezone import resolve_timezone_name from nrl_favorite_check import FavoriteTeamCheck + +_ROOT_CONFIG_KEYS = ( + "schedule_lookback_days", + "schedule_lookahead_days", + "no_data_interval_seconds", + "live_idle_max_interval_seconds", +) + + # Which ESPN endpoint backs the league, for the favorite-team diagnostic. FAVORITE_CHECK_KEY = 'nrl' FAVORITE_CHECK_LEAGUES = {FAVORITE_CHECK_KEY: ('NRL', 'rugby-league/3')} @@ -301,13 +310,15 @@ def _adapt_config_for_manager(self) -> Dict[str, Any]: "customization": cfg.get("customization", {}), }) - # The schedule-window settings live at the plugin config root, and - # SportsCore reads them from the root of the config it is handed. This - # adapter builds its output key by key, so anything not named here is - # dropped -- which silently pinned every user to the defaults. - for _window_key in ("schedule_lookback_days", "schedule_lookahead_days"): - if _window_key in self.config: - manager_config[_window_key] = self.config[_window_key] + # Plugin-root settings that SportsCore reads from the root of the config + # it is handed. This adapter builds its output key by key, so anything + # not named here is dropped -- which is how the schedule window silently + # pinned every user to the defaults, and would have done the same to the + # idle-poll settings. Generalised to a list so the next one added to + # SportsCore only has to be named once. + for _root_key in _ROOT_CONFIG_KEYS: + if _root_key in self.config: + manager_config[_root_key] = self.config[_root_key] return manager_config def _parse_display_mode_settings(self) -> Dict[str, str]: diff --git a/plugins/nrl-scoreboard/manifest.json b/plugins/nrl-scoreboard/manifest.json index 9041b556..566d751d 100644 --- a/plugins/nrl-scoreboard/manifest.json +++ b/plugins/nrl-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "nrl-scoreboard", "name": "NRL Scoreboard", - "version": "1.8.1", + "version": "1.9.0", "author": "ChuckBuilds", "description": "Live, recent, and upcoming NRL (National Rugby League) games with real-time scores and game status.", "category": "sports", @@ -18,6 +18,13 @@ "nrl_upcoming" ], "versions": [ + { + "version": "1.9.0", + "released": "2026-08-19", + "ledmatrix_min_version": "2.0.0", + "notes": "Make the schedule fetch window configurable. How far back and forward games are fetched was hard-coded at 14 days back and 7 forward. Days is the wrong unit for a league that plays weekly -- a whole matchweek can fall just past a short horizon and never reach the board -- so both are now advanced settings, bounded 1-60 days, with a stray or non-numeric value falling back to the default rather than raising. Defaults keep today's behaviour.", + "changes": "Back off the live-game poll while a league has nothing on, and let both the idle interval and its ceiling be configured. Saves API calls out of season and between games without delaying an in-progress game, which still resets the interval on the first fetch that finds one." + }, { "version": "1.8.1", "released": "2026-08-18", diff --git a/plugins/nrl-scoreboard/sports.py b/plugins/nrl-scoreboard/sports.py index b717f027..b62d7c39 100644 --- a/plugins/nrl-scoreboard/sports.py +++ b/plugins/nrl-scoreboard/sports.py @@ -101,6 +101,28 @@ def _clamp_window(value: Any, fallback: int) -> int: return max(_MIN_WINDOW_DAYS, min(_MAX_WINDOW_DAYS, days)) +# Backing off the live poll while a league has nothing on. Gentle at first -- +# a gap between games in a live season should cost little -- then firmer, so a +# league months out of season stops polling on a live cadence altogether. +_IDLE_SHORT_STREAK = 6 +_IDLE_SHORT_FACTOR = 2 +_IDLE_LONG_STREAK = 24 +_IDLE_LONG_FACTOR = 6 +_DEFAULT_LIVE_IDLE_MAX_SECONDS = 900 + + +def _clamp_seconds(value: Any, fallback: int, low: int = 5, + high: int = 86400) -> int: + """An interval in seconds, or the fallback when the value is unusable.""" + try: + seconds = int(value) + except (TypeError, ValueError, OverflowError): + # OverflowError: json parses bare Infinity by default and int(inf) + # raises -- the same gap _clamp_window above already covers. + return fallback + return max(low, min(high, seconds)) + + class SportsCore(ABC): def __init__( self, @@ -2506,7 +2528,20 @@ def __init__( ): super().__init__(config, display_manager, cache_manager, logger, sport_key) self.update_interval = self.mode_config.get("live_update_interval", 15) - self.no_data_interval = 300 + # Read from the config root, where the schema declares them and the web + # UI writes them -- not from mode_config, which is the per-league block + # ({sport}_scoreboard) and never carries these keys. Looking them up + # there meant the saved value was invisible and every user silently kept + # the default. mode_config is still consulted as a fallback so a + # hand-placed per-league value keeps working. + self.no_data_interval = _clamp_seconds( + self.config.get("no_data_interval_seconds", + self.mode_config.get("no_data_interval_seconds")), 300) + self.live_idle_max_interval = _clamp_seconds( + self.config.get("live_idle_max_interval_seconds", + self.mode_config.get("live_idle_max_interval_seconds")), + _DEFAULT_LIVE_IDLE_MAX_SECONDS) + self._empty_live_streak = 0 # Log the configured interval for debugging self.logger.info( f"SportsLive initialized: live_update_interval={self.update_interval}s, " @@ -3044,6 +3079,43 @@ def _detect_stale_games(self, games: List[Dict]) -> None: if game_id in self.game_update_timestamps: del self.game_update_timestamps[game_id] + def _idle_live_interval(self) -> int: + """How long to wait before looking for live games again, when there are none. + + Escalates the longer nothing turns up, and any live game resets it, so + an in-season gap between games costs at most one escalated wait while + an out-of-season league stops polling on a live cadence entirely. + + Capped rather than unbounded: the cost of backing off is how late the + first game after a quiet spell is noticed, and past the cap the saving + stops being worth that. + """ + streak = getattr(self, "_empty_live_streak", 0) + base = self.no_data_interval + ceiling = getattr(self, "live_idle_max_interval", + _DEFAULT_LIVE_IDLE_MAX_SECONDS) + # The ceiling bounds the un-escalated interval too. The two settings are + # independent integers with no cross-validation, so base > ceiling is a + # reachable config -- and returning base unclamped there made the wait + # *shrink* as the streak grew (3600s at streak 0, 900s at streak 24), + # the opposite of what the setting named "maximum" promises. + if streak >= _IDLE_LONG_STREAK: + return min(int(base * _IDLE_LONG_FACTOR), ceiling) + if streak >= _IDLE_SHORT_STREAK: + return min(int(base * _IDLE_SHORT_FACTOR), ceiling) + return min(base, ceiling) + + def _note_live_fetch(self, found_live: bool) -> None: + """Record whether a look for live games found any.""" + if found_live: + if getattr(self, "_empty_live_streak", 0): + self.logger.info( + "Live games found after %d empty check(s); back to the " + "live update interval", self._empty_live_streak) + self._empty_live_streak = 0 + else: + self._empty_live_streak = getattr(self, "_empty_live_streak", 0) + 1 + def update(self): """Update live game data and handle game switching.""" if not self.is_enabled: @@ -3068,21 +3140,25 @@ def update(self): # Only use no_data_interval if we've recently checked and confirmed there are no live games. # This ensures we check for live games frequently even if the list is temporarily empty. # Only use no_data_interval if we have no live games AND we've checked recently (within last 5 minutes) - time_since_last_update = current_time - self.last_update - has_recently_checked = self.last_update > 0 and time_since_last_update < 300 - + # Whether the last look found anything, tracked explicitly rather than + # inferred from how long ago it was. The old form asked "did we check + # within the last 300s?" and only then used no_data_interval -- but + # once 300s had elapsed the answer became no, the interval dropped + # back to live_update_interval, and it fetched. no_data_interval could + # therefore never delay anything past 300s whatever it was set to. + # Measured on a live rig: an out-of-season NHL polled every ~5.5 + # minutes around the clock, returning nothing every time. if _live_games_attr: - # We have live games, use the configured update interval interval = _update_interval_attr - elif has_recently_checked: - # We've checked recently and found no live games, use longer interval - interval = _no_data_interval_attr else: - # First check or haven't checked in a while, use update interval to check for live games - interval = _update_interval_attr + interval = self._idle_live_interval() # Original line from traceback (line 455), now with variables defined: if current_time - self.last_update >= interval: + # What the previous look found, recorded before this one + # replaces it. The streak is what drives the back-off, and + # any live game resets it. + self._note_live_fetch(bool(_live_games_attr)) self.last_update = current_time # Fetch rankings if enabled diff --git a/plugins/nrl-scoreboard/test_idle_league_backoff.py b/plugins/nrl-scoreboard/test_idle_league_backoff.py new file mode 100644 index 00000000..d881bb92 --- /dev/null +++ b/plugins/nrl-scoreboard/test_idle_league_backoff.py @@ -0,0 +1,308 @@ +#!/usr/bin/env python3 +""" +Tests that a league with nothing on stops polling on a live cadence. + +Regression under test: the live gate decided its interval like this -- + + has_recently_checked = self.last_update > 0 and time_since_last_update < 300 + if live_games: interval = live_update_interval + elif has_recently_checked: interval = no_data_interval + else: interval = live_update_interval + +Once 300s had elapsed, has_recently_checked became False, the interval fell +back to live_update_interval, and it fetched. no_data_interval could therefore +never delay anything past 300s whatever it was set to -- the setting was +inert. Measured on a live rig in mid-August: NHLLiveManager fetched 0 games 22 +times in 2 hours, every ~5.5 minutes, around the clock, for a league whose +season had not started. Roughly 264 wasted requests a day, per league. + +The interval now comes from an explicit streak of empty looks, which escalates +and is capped, and which any live game resets. + +Second regression under test: the two interval settings were read from +``self.mode_config`` -- the per-league ``{sport_key}_scoreboard`` block -- while +the schema declares them (and the web UI writes them) at the config *root*. The +saved value was therefore never seen and every user silently kept the default. +An earlier version of this test could not catch that, because it set +``no_data_interval`` on a stand-in object by hand and so never ran the lookup. +It now builds a real ``SportsLive`` and asserts on what its ``__init__`` +resolved. + +Only ``SportsCore.__init__`` is stubbed -- it pulls in logo downloading, fonts +and an ESPN data source, none of which this test needs -- so the lookup lines +themselves run for real. The stub's fidelity is asserted against the real +``SportsCore`` source below, so it cannot drift out of sync unnoticed. + +Run: /bin/python plugins/nrl-scoreboard/test_idle_league_backoff.py +""" + +# A test harness: it reaches into protected members on purpose and builds a +# concrete subclass at runtime, neither of which pylint can see as intentional. +# pylint: disable=protected-access,abstract-class-instantiated,unused-argument +import ast +import sys +from pathlib import Path + +plugin_dir = Path(__file__).resolve().parent +sys.path.insert(0, str(plugin_dir)) +for candidate in (Path("/home/rackpi/projects/LEDMatrix"), + plugin_dir.parents[2] / "LEDMatrix"): + if (candidate / "src" / "plugin_system" / "base_plugin.py").exists(): + sys.path.insert(0, str(candidate)) + break + +import sports # noqa: E402 + +failures = [] + + +def check(label, ok): + print((" PASS " if ok else " FAIL ") + label) + if not ok: + failures.append(label) + + +class _Logger: + def info(self, *a, **k): + pass + + +SPORT_KEY = "test" + + +def _core_init(): + core_src = (plugin_dir / "sports.py").read_text(encoding="utf-8") + core = next(c for c in ast.walk(ast.parse(core_src)) + if isinstance(c, ast.ClassDef) and c.name == "SportsCore") + return core_src, next(m for m in core.body + if isinstance(m, ast.FunctionDef) and m.name == "__init__") + + +def _derive_mode_key(): + """Build the per-league key exactly as this plugin's SportsCore does. + + Not hardcoded: most plugins use f"{sport_key}_scoreboard" but ufc-scoreboard + uses the bare sport_key. Hardcoding one of them made the stub test fiction + on the other -- which the fidelity check below caught. Reading the real + expression out of the source keeps every plugin honest. + """ + _, init = _core_init() + for node in ast.walk(init): + if (isinstance(node, ast.Assign) + and any(getattr(t, "attr", None) == "mode_config" + for t in node.targets) + and isinstance(node.value, ast.Call) and node.value.args): + arg = node.value.args[0] + return _resolve_key(arg), ast.unparse(arg) + raise AssertionError("SportsCore no longer assigns self.mode_config") + + +def _resolve_key(node): + """Evaluate the key expression by hand -- deliberately not with eval(). + + Only the two shapes that actually occur are supported; anything else is a + hard error rather than a guess, so a future rewrite of that line surfaces + here instead of silently producing a key nothing is stored under. + """ + if isinstance(node, ast.Constant): + return node.value + if isinstance(node, ast.Name) and node.id == "sport_key": + return SPORT_KEY + if isinstance(node, ast.JoinedStr): + return "".join(_resolve_key(part) for part in node.values) + if isinstance(node, ast.FormattedValue): + return _resolve_key(node.value) + raise AssertionError( + "unsupported mode_config key expression: %s" % ast.unparse(node)) + + +MODE_KEY, _MODE_KEY_EXPR = _derive_mode_key() + + +def _assert_stub_is_faithful(): + """The stub below stands in for SportsCore.__init__; prove it still matches. + + It has to reproduce exactly the two assignments the lookup under test + depends on. If SportsCore ever changes where config or mode_config come + from, this fails loudly rather than letting the stub quietly test fiction. + """ + _, init = _core_init() + assigns = {} + for node in ast.walk(init): + if isinstance(node, ast.Assign): + for target in node.targets: + if getattr(target, "attr", None) in ("config", "mode_config"): + assigns[target.attr] = node.value + + # The stub is only faithful if SportsCore still (a) keeps the handed-in + # config verbatim as self.config -- if it ever stored a sub-dict, "root" + # would mean something different -- and (b) derives mode_config as a + # lookup *within* that same config. The key itself is read from source + # above, so it needs no assertion; these two shapes do. + raw = assigns.get("config") + check("SportsCore still keeps the handed-in config verbatim as self.config", + isinstance(raw, ast.Name) and raw.id == "config") + + mode = assigns.get("mode_config") + check("mode_config is still a lookup inside that same config", + isinstance(mode, ast.Call) + and isinstance(mode.func, ast.Attribute) + and mode.func.attr == "get" + and isinstance(mode.func.value, ast.Name) + and mode.func.value.id == "config") + + +class _StubCore: + """Exactly the two SportsCore assignments the lookup under test reads.""" + # pylint: disable=too-few-public-methods + + def __init__(self, config, display_manager, cache_manager, logger, sport_key): + self.logger = logger + self.config = config + self.mode_config = config.get(MODE_KEY, {}) or {} + + +# SportsLive may carry abstract methods from SportsCore; fill them in so it can +# be constructed. They are never called here. +_Concrete = type("_ConcreteLive", (sports.SportsLive,), + {name: (lambda self, *a, **k: None) + for name in getattr(sports.SportsLive, + "__abstractmethods__", ())}) + + +def _live(root=None, league=None): + """A real SportsLive built from a real config dict. + + root/league place the settings at the config root and in the per-league + block respectively, which is the whole point: the test can now tell those + two locations apart. + """ + config = dict(root or {}) + config[MODE_KEY] = dict(league or {}) + real_init = sports.SportsCore.__init__ + sports.SportsCore.__init__ = _StubCore.__init__ + try: + return _Concrete(config, object(), object(), _Logger(), SPORT_KEY) + finally: + sports.SportsCore.__init__ = real_init + + +def _Live(base=300, ceiling=900): + """The old signature, now backed by a real config round-trip.""" + return _live(root={"no_data_interval_seconds": base, + "live_idle_max_interval_seconds": ceiling}) + + +def main(): + print("the settings are read from where they are actually written") + _assert_stub_is_faithful() + + at_root = _live(root={"no_data_interval_seconds": 600, + "live_idle_max_interval_seconds": 1200}) + check("a root value is honoured (the schema declares them at the root)", + at_root.no_data_interval == 600) + check("...including the ceiling", at_root.live_idle_max_interval == 1200) + + defaults = _live() + check("absent anywhere falls back to the default", + defaults.no_data_interval == 300) + + legacy = _live(league={"no_data_interval_seconds": 600}) + check("a hand-placed per-league value still works", + legacy.no_data_interval == 600) + + both = _live(root={"no_data_interval_seconds": 600}, + league={"no_data_interval_seconds": 45}) + check("the root wins over a stale per-league value", + both.no_data_interval == 600) + + junk = _live(root={"no_data_interval_seconds": "soon", + "live_idle_max_interval_seconds": float("inf")}) + check("garbage at the root falls back rather than raising", + junk.no_data_interval == 300) + check("an infinite ceiling falls back too", + junk.live_idle_max_interval == sports._DEFAULT_LIVE_IDLE_MAX_SECONDS) + + print("\nthe wait grows the longer nothing is found") + live = _Live() + check("first look uses the base interval", + live._idle_live_interval() == 300) + + for _ in range(sports._IDLE_SHORT_STREAK): + live._note_live_fetch(False) + check("after a short streak it is longer (%ds)" % live._idle_live_interval(), + live._idle_live_interval() > 300) + + short_wait = live._idle_live_interval() + for _ in range(sports._IDLE_LONG_STREAK): + live._note_live_fetch(False) + long_wait = live._idle_live_interval() + # Against short_wait, not against itself: comparing the value to a second + # call of the same method with the same state can never fail, so the + # monotonicity it is meant to protect went untested. + check("after a long streak it is longer still (%ds vs %ds)" + % (long_wait, short_wait), long_wait > short_wait) + check("and never exceeds the ceiling", long_wait <= 900) + + for _ in range(500): + live._note_live_fetch(False) + check("a very long streak stays at the ceiling, not beyond", + live._idle_live_interval() == 900) + + print("\na live game resets it immediately") + live._note_live_fetch(True) + check("the streak is cleared", live._empty_live_streak == 0) + check("and the base interval is back", + live._idle_live_interval() == 300) + + print("\nthe saving is real, and bounded") + idle = _Live() + for _ in range(500): + idle._note_live_fetch(False) + per_day = 86400 / idle._idle_live_interval() + check("an out-of-season league polls far less (%d/day vs 288)" % per_day, + per_day < 288 / 2) + check("...but still often enough to notice a season starting (<= 1h)", + idle._idle_live_interval() <= 3600) + + print("\nthe ceiling is configurable") + tight = _Live(ceiling=300) + for _ in range(500): + tight._note_live_fetch(False) + check("a lower ceiling is honoured", tight._idle_live_interval() == 300) + loose = _Live(ceiling=3600) + for _ in range(500): + loose._note_live_fetch(False) + check("a higher ceiling is honoured", loose._idle_live_interval() == 1800) + + print("\nintervals from config are clamped, never trusted raw") + check("a sane value is used", sports._clamp_seconds(120, 300) == 120) + check("absent falls back", sports._clamp_seconds(None, 300) == 300) + check("nonsense falls back", sports._clamp_seconds("soon", 300) == 300) + check("zero is clamped up", sports._clamp_seconds(0, 300) >= 5) + check("a week is clamped down", sports._clamp_seconds(604800, 300) <= 86400) + + print("\nthe old recency-based logic is gone") + src = (plugin_dir / "sports.py").read_text(encoding="utf-8") + check("has_recently_checked no longer decides the interval", + "has_recently_checked" not in src) + + tree = ast.parse(src) + live_cls = next(c for c in ast.walk(tree) + if isinstance(c, ast.ClassDef) and c.name == "SportsLive") + upd = next(m for m in live_cls.body + if isinstance(m, ast.FunctionDef) and m.name == "update") + idle_calls = [n for n in ast.walk(upd) if isinstance(n, ast.Call) + and getattr(n.func, "attr", None) == "_idle_live_interval"] + note_calls = [n for n in ast.walk(upd) if isinstance(n, ast.Call) + and getattr(n.func, "attr", None) == "_note_live_fetch"] + check("update() takes its idle interval from the back-off", len(idle_calls) == 1) + check("update() records each look's outcome", len(note_calls) == 1) + + print("\n%s" % ("FAILED: %d" % len(failures) if failures + else "All checks passed")) + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/soccer-scoreboard/config_schema.json b/plugins/soccer-scoreboard/config_schema.json index 73a1b76a..907194ea 100644 --- a/plugins/soccer-scoreboard/config_schema.json +++ b/plugins/soccer-scoreboard/config_schema.json @@ -5359,6 +5359,24 @@ "title": "Schedule lookahead (days)", "description": "How far ahead to fetch games for the Upcoming screen. Raise it for a league that plays weekly: a fixture just beyond this horizon is not fetched at all, so it never reaches the board even though the date is known.", "x-advanced": true + }, + "no_data_interval_seconds": { + "type": "integer", + "default": 300, + "minimum": 5, + "maximum": 86400, + "title": "Idle live-check interval (seconds)", + "description": "How long to wait between checks for live games when there are none. Backs off further the longer nothing is found, up to the cap below.", + "x-advanced": true + }, + "live_idle_max_interval_seconds": { + "type": "integer", + "default": 900, + "minimum": 5, + "maximum": 86400, + "title": "Maximum idle live-check interval (seconds)", + "description": "Ceiling for the back-off above. Raise it to poll an out-of-season league even less; lower it to notice the first game after a quiet spell sooner.", + "x-advanced": true } }, "additionalProperties": false, diff --git a/plugins/soccer-scoreboard/manager.py b/plugins/soccer-scoreboard/manager.py index 1e1d58a4..9aaed1d8 100644 --- a/plugins/soccer-scoreboard/manager.py +++ b/plugins/soccer-scoreboard/manager.py @@ -78,6 +78,15 @@ from soccer_timezone import resolve_timezone_name + +_ROOT_CONFIG_KEYS = ( + "schedule_lookback_days", + "schedule_lookahead_days", + "no_data_interval_seconds", + "live_idle_max_interval_seconds", +) + + logger = logging.getLogger(__name__) # Predefined league keys and display names (priority 1-8) @@ -465,13 +474,15 @@ def _adapt_config_for_manager(self, league_key: str) -> Dict[str, Any]: self.logger.debug(f"Using timezone: {timezone_str} for {league_key} managers") - # The schedule-window settings live at the plugin config root, and - # SportsCore reads them from the root of the config it is handed. This - # adapter builds its output key by key, so anything not named here is - # dropped -- which silently pinned every user to the defaults. - for _window_key in ("schedule_lookback_days", "schedule_lookahead_days"): - if _window_key in self.config: - manager_config[_window_key] = self.config[_window_key] + # Plugin-root settings that SportsCore reads from the root of the config + # it is handed. This adapter builds its output key by key, so anything + # not named here is dropped -- which is how the schedule window silently + # pinned every user to the defaults, and would have done the same to the + # idle-poll settings. Generalised to a list so the next one added to + # SportsCore only has to be named once. + for _root_key in _ROOT_CONFIG_KEYS: + if _root_key in self.config: + manager_config[_root_key] = self.config[_root_key] return manager_config def _build_custom_league_map(self) -> None: @@ -769,6 +780,14 @@ def _adapt_config_for_custom_league(self, custom_league: Dict[str, Any]) -> Dict "customization": customization_config, }) + # Custom leagues go through their own whitelist adapter, so they need + # the same root-key forwarding the predefined leagues get above -- + # otherwise a user's schedule-window and idle-poll settings apply to + # every built-in league but silently not to their custom ones. + for _root_key in _ROOT_CONFIG_KEYS: + if _root_key in self.config: + manager_config[_root_key] = self.config[_root_key] + return manager_config def _initialize_league_registry(self) -> None: diff --git a/plugins/soccer-scoreboard/manifest.json b/plugins/soccer-scoreboard/manifest.json index 826ad334..6e512f25 100644 --- a/plugins/soccer-scoreboard/manifest.json +++ b/plugins/soccer-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "soccer-scoreboard", "name": "Soccer Scoreboard", - "version": "2.11.1", + "version": "2.12.0", "author": "ChuckBuilds", "description": "Live, recent, and upcoming soccer games across multiple leagues including Premier League, La Liga, Bundesliga, Serie A, Ligue 1, MLS, Liga Portugal, Champions League, Europa League, and FIFA World Cup", "category": "sports", @@ -26,6 +26,13 @@ "soccer_upcoming" ], "versions": [ + { + "version": "2.12.0", + "released": "2026-08-19", + "ledmatrix_min_version": "2.0.0", + "notes": "Make the schedule fetch window configurable. How far back and forward games are fetched was hard-coded at 14 days back and 7 forward. Days is the wrong unit for a league that plays weekly -- a whole matchweek can fall just past a short horizon and never reach the board -- so both are now advanced settings, bounded 1-60 days, with a stray or non-numeric value falling back to the default rather than raising. Defaults keep today's behaviour, except this plugin's lookahead which moves to 14 so a fixture eight days out is fetched at all.", + "changes": "Back off the live-game poll while a league has nothing on, and let both the idle interval and its ceiling be configured. Saves API calls out of season and between games without delaying an in-progress game, which still resets the interval on the first fetch that finds one." + }, { "version": "2.11.1", "released": "2026-08-18", diff --git a/plugins/soccer-scoreboard/sports.py b/plugins/soccer-scoreboard/sports.py index cb509f4a..73e4c4b6 100644 --- a/plugins/soccer-scoreboard/sports.py +++ b/plugins/soccer-scoreboard/sports.py @@ -108,6 +108,28 @@ def _clamp_window(value: Any, fallback: int) -> int: return max(_MIN_WINDOW_DAYS, min(_MAX_WINDOW_DAYS, days)) +# Backing off the live poll while a league has nothing on. Gentle at first -- +# a gap between games in a live season should cost little -- then firmer, so a +# league months out of season stops polling on a live cadence altogether. +_IDLE_SHORT_STREAK = 6 +_IDLE_SHORT_FACTOR = 2 +_IDLE_LONG_STREAK = 24 +_IDLE_LONG_FACTOR = 6 +_DEFAULT_LIVE_IDLE_MAX_SECONDS = 900 + + +def _clamp_seconds(value: Any, fallback: int, low: int = 5, + high: int = 86400) -> int: + """An interval in seconds, or the fallback when the value is unusable.""" + try: + seconds = int(value) + except (TypeError, ValueError, OverflowError): + # OverflowError: json parses bare Infinity by default and int(inf) + # raises -- the same gap _clamp_window above already covers. + return fallback + return max(low, min(high, seconds)) + + class SportsCore(ABC): def __init__( self, @@ -2515,7 +2537,20 @@ def __init__( ): super().__init__(config, display_manager, cache_manager, logger, sport_key) self.update_interval = self.mode_config.get("live_update_interval", 15) - self.no_data_interval = 300 + # Read from the config root, where the schema declares them and the web + # UI writes them -- not from mode_config, which is the per-league block + # ({sport}_scoreboard) and never carries these keys. Looking them up + # there meant the saved value was invisible and every user silently kept + # the default. mode_config is still consulted as a fallback so a + # hand-placed per-league value keeps working. + self.no_data_interval = _clamp_seconds( + self.config.get("no_data_interval_seconds", + self.mode_config.get("no_data_interval_seconds")), 300) + self.live_idle_max_interval = _clamp_seconds( + self.config.get("live_idle_max_interval_seconds", + self.mode_config.get("live_idle_max_interval_seconds")), + _DEFAULT_LIVE_IDLE_MAX_SECONDS) + self._empty_live_streak = 0 # Log the configured interval for debugging self.logger.info( f"SportsLive initialized: live_update_interval={self.update_interval}s, " @@ -3053,6 +3088,43 @@ def _detect_stale_games(self, games: List[Dict]) -> None: if game_id in self.game_update_timestamps: del self.game_update_timestamps[game_id] + def _idle_live_interval(self) -> int: + """How long to wait before looking for live games again, when there are none. + + Escalates the longer nothing turns up, and any live game resets it, so + an in-season gap between games costs at most one escalated wait while + an out-of-season league stops polling on a live cadence entirely. + + Capped rather than unbounded: the cost of backing off is how late the + first game after a quiet spell is noticed, and past the cap the saving + stops being worth that. + """ + streak = getattr(self, "_empty_live_streak", 0) + base = self.no_data_interval + ceiling = getattr(self, "live_idle_max_interval", + _DEFAULT_LIVE_IDLE_MAX_SECONDS) + # The ceiling bounds the un-escalated interval too. The two settings are + # independent integers with no cross-validation, so base > ceiling is a + # reachable config -- and returning base unclamped there made the wait + # *shrink* as the streak grew (3600s at streak 0, 900s at streak 24), + # the opposite of what the setting named "maximum" promises. + if streak >= _IDLE_LONG_STREAK: + return min(int(base * _IDLE_LONG_FACTOR), ceiling) + if streak >= _IDLE_SHORT_STREAK: + return min(int(base * _IDLE_SHORT_FACTOR), ceiling) + return min(base, ceiling) + + def _note_live_fetch(self, found_live: bool) -> None: + """Record whether a look for live games found any.""" + if found_live: + if getattr(self, "_empty_live_streak", 0): + self.logger.info( + "Live games found after %d empty check(s); back to the " + "live update interval", self._empty_live_streak) + self._empty_live_streak = 0 + else: + self._empty_live_streak = getattr(self, "_empty_live_streak", 0) + 1 + def update(self): """Update live game data and handle game switching.""" if not self.is_enabled: @@ -3077,21 +3149,25 @@ def update(self): # Only use no_data_interval if we've recently checked and confirmed there are no live games. # This ensures we check for live games frequently even if the list is temporarily empty. # Only use no_data_interval if we have no live games AND we've checked recently (within last 5 minutes) - time_since_last_update = current_time - self.last_update - has_recently_checked = self.last_update > 0 and time_since_last_update < 300 - + # Whether the last look found anything, tracked explicitly rather than + # inferred from how long ago it was. The old form asked "did we check + # within the last 300s?" and only then used no_data_interval -- but + # once 300s had elapsed the answer became no, the interval dropped + # back to live_update_interval, and it fetched. no_data_interval could + # therefore never delay anything past 300s whatever it was set to. + # Measured on a live rig: an out-of-season NHL polled every ~5.5 + # minutes around the clock, returning nothing every time. if _live_games_attr: - # We have live games, use the configured update interval interval = _update_interval_attr - elif has_recently_checked: - # We've checked recently and found no live games, use longer interval - interval = _no_data_interval_attr else: - # First check or haven't checked in a while, use update interval to check for live games - interval = _update_interval_attr + interval = self._idle_live_interval() # Original line from traceback (line 455), now with variables defined: if current_time - self.last_update >= interval: + # What the previous look found, recorded before this one + # replaces it. The streak is what drives the back-off, and + # any live game resets it. + self._note_live_fetch(bool(_live_games_attr)) self.last_update = current_time # Fetch rankings if enabled diff --git a/plugins/soccer-scoreboard/test_idle_league_backoff.py b/plugins/soccer-scoreboard/test_idle_league_backoff.py new file mode 100644 index 00000000..8efa0724 --- /dev/null +++ b/plugins/soccer-scoreboard/test_idle_league_backoff.py @@ -0,0 +1,308 @@ +#!/usr/bin/env python3 +""" +Tests that a league with nothing on stops polling on a live cadence. + +Regression under test: the live gate decided its interval like this -- + + has_recently_checked = self.last_update > 0 and time_since_last_update < 300 + if live_games: interval = live_update_interval + elif has_recently_checked: interval = no_data_interval + else: interval = live_update_interval + +Once 300s had elapsed, has_recently_checked became False, the interval fell +back to live_update_interval, and it fetched. no_data_interval could therefore +never delay anything past 300s whatever it was set to -- the setting was +inert. Measured on a live rig in mid-August: NHLLiveManager fetched 0 games 22 +times in 2 hours, every ~5.5 minutes, around the clock, for a league whose +season had not started. Roughly 264 wasted requests a day, per league. + +The interval now comes from an explicit streak of empty looks, which escalates +and is capped, and which any live game resets. + +Second regression under test: the two interval settings were read from +``self.mode_config`` -- the per-league ``{sport_key}_scoreboard`` block -- while +the schema declares them (and the web UI writes them) at the config *root*. The +saved value was therefore never seen and every user silently kept the default. +An earlier version of this test could not catch that, because it set +``no_data_interval`` on a stand-in object by hand and so never ran the lookup. +It now builds a real ``SportsLive`` and asserts on what its ``__init__`` +resolved. + +Only ``SportsCore.__init__`` is stubbed -- it pulls in logo downloading, fonts +and an ESPN data source, none of which this test needs -- so the lookup lines +themselves run for real. The stub's fidelity is asserted against the real +``SportsCore`` source below, so it cannot drift out of sync unnoticed. + +Run: /bin/python plugins/soccer-scoreboard/test_idle_league_backoff.py +""" + +# A test harness: it reaches into protected members on purpose and builds a +# concrete subclass at runtime, neither of which pylint can see as intentional. +# pylint: disable=protected-access,abstract-class-instantiated,unused-argument +import ast +import sys +from pathlib import Path + +plugin_dir = Path(__file__).resolve().parent +sys.path.insert(0, str(plugin_dir)) +for candidate in (Path("/home/rackpi/projects/LEDMatrix"), + plugin_dir.parents[2] / "LEDMatrix"): + if (candidate / "src" / "plugin_system" / "base_plugin.py").exists(): + sys.path.insert(0, str(candidate)) + break + +import sports # noqa: E402 + +failures = [] + + +def check(label, ok): + print((" PASS " if ok else " FAIL ") + label) + if not ok: + failures.append(label) + + +class _Logger: + def info(self, *a, **k): + pass + + +SPORT_KEY = "test" + + +def _core_init(): + core_src = (plugin_dir / "sports.py").read_text(encoding="utf-8") + core = next(c for c in ast.walk(ast.parse(core_src)) + if isinstance(c, ast.ClassDef) and c.name == "SportsCore") + return core_src, next(m for m in core.body + if isinstance(m, ast.FunctionDef) and m.name == "__init__") + + +def _derive_mode_key(): + """Build the per-league key exactly as this plugin's SportsCore does. + + Not hardcoded: most plugins use f"{sport_key}_scoreboard" but ufc-scoreboard + uses the bare sport_key. Hardcoding one of them made the stub test fiction + on the other -- which the fidelity check below caught. Reading the real + expression out of the source keeps every plugin honest. + """ + _, init = _core_init() + for node in ast.walk(init): + if (isinstance(node, ast.Assign) + and any(getattr(t, "attr", None) == "mode_config" + for t in node.targets) + and isinstance(node.value, ast.Call) and node.value.args): + arg = node.value.args[0] + return _resolve_key(arg), ast.unparse(arg) + raise AssertionError("SportsCore no longer assigns self.mode_config") + + +def _resolve_key(node): + """Evaluate the key expression by hand -- deliberately not with eval(). + + Only the two shapes that actually occur are supported; anything else is a + hard error rather than a guess, so a future rewrite of that line surfaces + here instead of silently producing a key nothing is stored under. + """ + if isinstance(node, ast.Constant): + return node.value + if isinstance(node, ast.Name) and node.id == "sport_key": + return SPORT_KEY + if isinstance(node, ast.JoinedStr): + return "".join(_resolve_key(part) for part in node.values) + if isinstance(node, ast.FormattedValue): + return _resolve_key(node.value) + raise AssertionError( + "unsupported mode_config key expression: %s" % ast.unparse(node)) + + +MODE_KEY, _MODE_KEY_EXPR = _derive_mode_key() + + +def _assert_stub_is_faithful(): + """The stub below stands in for SportsCore.__init__; prove it still matches. + + It has to reproduce exactly the two assignments the lookup under test + depends on. If SportsCore ever changes where config or mode_config come + from, this fails loudly rather than letting the stub quietly test fiction. + """ + _, init = _core_init() + assigns = {} + for node in ast.walk(init): + if isinstance(node, ast.Assign): + for target in node.targets: + if getattr(target, "attr", None) in ("config", "mode_config"): + assigns[target.attr] = node.value + + # The stub is only faithful if SportsCore still (a) keeps the handed-in + # config verbatim as self.config -- if it ever stored a sub-dict, "root" + # would mean something different -- and (b) derives mode_config as a + # lookup *within* that same config. The key itself is read from source + # above, so it needs no assertion; these two shapes do. + raw = assigns.get("config") + check("SportsCore still keeps the handed-in config verbatim as self.config", + isinstance(raw, ast.Name) and raw.id == "config") + + mode = assigns.get("mode_config") + check("mode_config is still a lookup inside that same config", + isinstance(mode, ast.Call) + and isinstance(mode.func, ast.Attribute) + and mode.func.attr == "get" + and isinstance(mode.func.value, ast.Name) + and mode.func.value.id == "config") + + +class _StubCore: + """Exactly the two SportsCore assignments the lookup under test reads.""" + # pylint: disable=too-few-public-methods + + def __init__(self, config, display_manager, cache_manager, logger, sport_key): + self.logger = logger + self.config = config + self.mode_config = config.get(MODE_KEY, {}) or {} + + +# SportsLive may carry abstract methods from SportsCore; fill them in so it can +# be constructed. They are never called here. +_Concrete = type("_ConcreteLive", (sports.SportsLive,), + {name: (lambda self, *a, **k: None) + for name in getattr(sports.SportsLive, + "__abstractmethods__", ())}) + + +def _live(root=None, league=None): + """A real SportsLive built from a real config dict. + + root/league place the settings at the config root and in the per-league + block respectively, which is the whole point: the test can now tell those + two locations apart. + """ + config = dict(root or {}) + config[MODE_KEY] = dict(league or {}) + real_init = sports.SportsCore.__init__ + sports.SportsCore.__init__ = _StubCore.__init__ + try: + return _Concrete(config, object(), object(), _Logger(), SPORT_KEY) + finally: + sports.SportsCore.__init__ = real_init + + +def _Live(base=300, ceiling=900): + """The old signature, now backed by a real config round-trip.""" + return _live(root={"no_data_interval_seconds": base, + "live_idle_max_interval_seconds": ceiling}) + + +def main(): + print("the settings are read from where they are actually written") + _assert_stub_is_faithful() + + at_root = _live(root={"no_data_interval_seconds": 600, + "live_idle_max_interval_seconds": 1200}) + check("a root value is honoured (the schema declares them at the root)", + at_root.no_data_interval == 600) + check("...including the ceiling", at_root.live_idle_max_interval == 1200) + + defaults = _live() + check("absent anywhere falls back to the default", + defaults.no_data_interval == 300) + + legacy = _live(league={"no_data_interval_seconds": 600}) + check("a hand-placed per-league value still works", + legacy.no_data_interval == 600) + + both = _live(root={"no_data_interval_seconds": 600}, + league={"no_data_interval_seconds": 45}) + check("the root wins over a stale per-league value", + both.no_data_interval == 600) + + junk = _live(root={"no_data_interval_seconds": "soon", + "live_idle_max_interval_seconds": float("inf")}) + check("garbage at the root falls back rather than raising", + junk.no_data_interval == 300) + check("an infinite ceiling falls back too", + junk.live_idle_max_interval == sports._DEFAULT_LIVE_IDLE_MAX_SECONDS) + + print("\nthe wait grows the longer nothing is found") + live = _Live() + check("first look uses the base interval", + live._idle_live_interval() == 300) + + for _ in range(sports._IDLE_SHORT_STREAK): + live._note_live_fetch(False) + check("after a short streak it is longer (%ds)" % live._idle_live_interval(), + live._idle_live_interval() > 300) + + short_wait = live._idle_live_interval() + for _ in range(sports._IDLE_LONG_STREAK): + live._note_live_fetch(False) + long_wait = live._idle_live_interval() + # Against short_wait, not against itself: comparing the value to a second + # call of the same method with the same state can never fail, so the + # monotonicity it is meant to protect went untested. + check("after a long streak it is longer still (%ds vs %ds)" + % (long_wait, short_wait), long_wait > short_wait) + check("and never exceeds the ceiling", long_wait <= 900) + + for _ in range(500): + live._note_live_fetch(False) + check("a very long streak stays at the ceiling, not beyond", + live._idle_live_interval() == 900) + + print("\na live game resets it immediately") + live._note_live_fetch(True) + check("the streak is cleared", live._empty_live_streak == 0) + check("and the base interval is back", + live._idle_live_interval() == 300) + + print("\nthe saving is real, and bounded") + idle = _Live() + for _ in range(500): + idle._note_live_fetch(False) + per_day = 86400 / idle._idle_live_interval() + check("an out-of-season league polls far less (%d/day vs 288)" % per_day, + per_day < 288 / 2) + check("...but still often enough to notice a season starting (<= 1h)", + idle._idle_live_interval() <= 3600) + + print("\nthe ceiling is configurable") + tight = _Live(ceiling=300) + for _ in range(500): + tight._note_live_fetch(False) + check("a lower ceiling is honoured", tight._idle_live_interval() == 300) + loose = _Live(ceiling=3600) + for _ in range(500): + loose._note_live_fetch(False) + check("a higher ceiling is honoured", loose._idle_live_interval() == 1800) + + print("\nintervals from config are clamped, never trusted raw") + check("a sane value is used", sports._clamp_seconds(120, 300) == 120) + check("absent falls back", sports._clamp_seconds(None, 300) == 300) + check("nonsense falls back", sports._clamp_seconds("soon", 300) == 300) + check("zero is clamped up", sports._clamp_seconds(0, 300) >= 5) + check("a week is clamped down", sports._clamp_seconds(604800, 300) <= 86400) + + print("\nthe old recency-based logic is gone") + src = (plugin_dir / "sports.py").read_text(encoding="utf-8") + check("has_recently_checked no longer decides the interval", + "has_recently_checked" not in src) + + tree = ast.parse(src) + live_cls = next(c for c in ast.walk(tree) + if isinstance(c, ast.ClassDef) and c.name == "SportsLive") + upd = next(m for m in live_cls.body + if isinstance(m, ast.FunctionDef) and m.name == "update") + idle_calls = [n for n in ast.walk(upd) if isinstance(n, ast.Call) + and getattr(n.func, "attr", None) == "_idle_live_interval"] + note_calls = [n for n in ast.walk(upd) if isinstance(n, ast.Call) + and getattr(n.func, "attr", None) == "_note_live_fetch"] + check("update() takes its idle interval from the back-off", len(idle_calls) == 1) + check("update() records each look's outcome", len(note_calls) == 1) + + print("\n%s" % ("FAILED: %d" % len(failures) if failures + else "All checks passed")) + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/ufc-scoreboard/config_schema.json b/plugins/ufc-scoreboard/config_schema.json index 622d489e..30c15ded 100644 --- a/plugins/ufc-scoreboard/config_schema.json +++ b/plugins/ufc-scoreboard/config_schema.json @@ -671,6 +671,24 @@ "title": "Schedule lookahead (days)", "description": "How far ahead to fetch games for the Upcoming screen. Raise it for a league that plays weekly: a fixture just beyond this horizon is not fetched at all, so it never reaches the board even though the date is known.", "x-advanced": true + }, + "no_data_interval_seconds": { + "type": "integer", + "default": 300, + "minimum": 5, + "maximum": 86400, + "title": "Idle live-check interval (seconds)", + "description": "How long to wait between checks for live games when there are none. Backs off further the longer nothing is found, up to the cap below.", + "x-advanced": true + }, + "live_idle_max_interval_seconds": { + "type": "integer", + "default": 900, + "minimum": 5, + "maximum": 86400, + "title": "Maximum idle live-check interval (seconds)", + "description": "Ceiling for the back-off above. Raise it to poll an out-of-season league even less; lower it to notice the first game after a quiet spell sooner.", + "x-advanced": true } }, "additionalProperties": false, diff --git a/plugins/ufc-scoreboard/manager.py b/plugins/ufc-scoreboard/manager.py index 36f94c99..1a871ba6 100644 --- a/plugins/ufc-scoreboard/manager.py +++ b/plugins/ufc-scoreboard/manager.py @@ -38,6 +38,15 @@ from ufc_timezone import resolve_timezone_name + +_ROOT_CONFIG_KEYS = ( + "schedule_lookback_days", + "schedule_lookahead_days", + "no_data_interval_seconds", + "live_idle_max_interval_seconds", +) + + logger = logging.getLogger(__name__) @@ -343,13 +352,15 @@ def _adapt_config_for_manager(self, league: str) -> Dict[str, Any]: ) self.logger.debug(f"Using timezone: {timezone_str} for {league} managers") - # The schedule-window settings live at the plugin config root, and - # SportsCore reads them from the root of the config it is handed. This - # adapter builds its output key by key, so anything not named here is - # dropped -- which silently pinned every user to the defaults. - for _window_key in ("schedule_lookback_days", "schedule_lookahead_days"): - if _window_key in self.config: - manager_config[_window_key] = self.config[_window_key] + # Plugin-root settings that SportsCore reads from the root of the config + # it is handed. This adapter builds its output key by key, so anything + # not named here is dropped -- which is how the schedule window silently + # pinned every user to the defaults, and would have done the same to the + # idle-poll settings. Generalised to a list so the next one added to + # SportsCore only has to be named once. + for _root_key in _ROOT_CONFIG_KEYS: + if _root_key in self.config: + manager_config[_root_key] = self.config[_root_key] return manager_config def _parse_display_mode_settings(self) -> Dict[str, Dict[str, str]]: diff --git a/plugins/ufc-scoreboard/manifest.json b/plugins/ufc-scoreboard/manifest.json index 394e3282..7db841f3 100644 --- a/plugins/ufc-scoreboard/manifest.json +++ b/plugins/ufc-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "ufc-scoreboard", "name": "UFC Scoreboard", - "version": "1.5.1", + "version": "1.6.0", "author": "LegoGuy1000", "contributors": [ { @@ -32,6 +32,13 @@ "default_duration": 15, "config_schema": "config_schema.json", "versions": [ + { + "version": "1.6.0", + "released": "2026-08-19", + "ledmatrix_min_version": "2.0.0", + "notes": "Make the schedule fetch window configurable. How far back and forward games are fetched was hard-coded at 14 days back and 7 forward. Days is the wrong unit for a league that plays weekly -- a whole matchweek can fall just past a short horizon and never reach the board -- so both are now advanced settings, bounded 1-60 days, with a stray or non-numeric value falling back to the default rather than raising. Defaults keep today's behaviour.", + "changes": "Back off the live-game poll while a league has nothing on, and let both the idle interval and its ceiling be configured. Saves API calls out of season and between games without delaying an in-progress game, which still resets the interval on the first fetch that finds one." + }, { "version": "1.5.1", "released": "2026-08-18", diff --git a/plugins/ufc-scoreboard/sports.py b/plugins/ufc-scoreboard/sports.py index 7ab634f4..af55fee3 100644 --- a/plugins/ufc-scoreboard/sports.py +++ b/plugins/ufc-scoreboard/sports.py @@ -97,6 +97,28 @@ def _clamp_window(value: Any, fallback: int) -> int: return max(_MIN_WINDOW_DAYS, min(_MAX_WINDOW_DAYS, days)) +# Backing off the live poll while a league has nothing on. Gentle at first -- +# a gap between games in a live season should cost little -- then firmer, so a +# league months out of season stops polling on a live cadence altogether. +_IDLE_SHORT_STREAK = 6 +_IDLE_SHORT_FACTOR = 2 +_IDLE_LONG_STREAK = 24 +_IDLE_LONG_FACTOR = 6 +_DEFAULT_LIVE_IDLE_MAX_SECONDS = 900 + + +def _clamp_seconds(value: Any, fallback: int, low: int = 5, + high: int = 86400) -> int: + """An interval in seconds, or the fallback when the value is unusable.""" + try: + seconds = int(value) + except (TypeError, ValueError, OverflowError): + # OverflowError: json parses bare Infinity by default and int(inf) + # raises -- the same gap _clamp_window above already covers. + return fallback + return max(low, min(high, seconds)) + + class SportsCore(ABC): def __init__( self, @@ -2137,7 +2159,20 @@ def __init__( ): super().__init__(config, display_manager, cache_manager, logger, sport_key) self.update_interval = self.mode_config.get("live_update_interval", 15) - self.no_data_interval = 300 + # Read from the config root, where the schema declares them and the web + # UI writes them -- not from mode_config, which is the per-league block + # ({sport}_scoreboard) and never carries these keys. Looking them up + # there meant the saved value was invisible and every user silently kept + # the default. mode_config is still consulted as a fallback so a + # hand-placed per-league value keeps working. + self.no_data_interval = _clamp_seconds( + self.config.get("no_data_interval_seconds", + self.mode_config.get("no_data_interval_seconds")), 300) + self.live_idle_max_interval = _clamp_seconds( + self.config.get("live_idle_max_interval_seconds", + self.mode_config.get("live_idle_max_interval_seconds")), + _DEFAULT_LIVE_IDLE_MAX_SECONDS) + self._empty_live_streak = 0 # Log the configured interval for debugging self.logger.info( f"SportsLive initialized: live_update_interval={self.update_interval}s, " @@ -2238,6 +2273,43 @@ def _detect_stale_games(self, games: List[Dict]) -> None: if game_id in self.game_update_timestamps: del self.game_update_timestamps[game_id] + def _idle_live_interval(self) -> int: + """How long to wait before looking for live games again, when there are none. + + Escalates the longer nothing turns up, and any live game resets it, so + an in-season gap between games costs at most one escalated wait while + an out-of-season league stops polling on a live cadence entirely. + + Capped rather than unbounded: the cost of backing off is how late the + first game after a quiet spell is noticed, and past the cap the saving + stops being worth that. + """ + streak = getattr(self, "_empty_live_streak", 0) + base = self.no_data_interval + ceiling = getattr(self, "live_idle_max_interval", + _DEFAULT_LIVE_IDLE_MAX_SECONDS) + # The ceiling bounds the un-escalated interval too. The two settings are + # independent integers with no cross-validation, so base > ceiling is a + # reachable config -- and returning base unclamped there made the wait + # *shrink* as the streak grew (3600s at streak 0, 900s at streak 24), + # the opposite of what the setting named "maximum" promises. + if streak >= _IDLE_LONG_STREAK: + return min(int(base * _IDLE_LONG_FACTOR), ceiling) + if streak >= _IDLE_SHORT_STREAK: + return min(int(base * _IDLE_SHORT_FACTOR), ceiling) + return min(base, ceiling) + + def _note_live_fetch(self, found_live: bool) -> None: + """Record whether a look for live games found any.""" + if found_live: + if getattr(self, "_empty_live_streak", 0): + self.logger.info( + "Live games found after %d empty check(s); back to the " + "live update interval", self._empty_live_streak) + self._empty_live_streak = 0 + else: + self._empty_live_streak = getattr(self, "_empty_live_streak", 0) + 1 + def update(self): """Update live game data and handle game switching.""" if not self.is_enabled: @@ -2265,21 +2337,25 @@ def update(self): # Only use no_data_interval if we've recently checked and confirmed there are no live games. # This ensures we check for live games frequently even if the list is temporarily empty. # Only use no_data_interval if we have no live games AND we've checked recently (within last 5 minutes) - time_since_last_update = current_time - self.last_update - has_recently_checked = self.last_update > 0 and time_since_last_update < 300 - + # Whether the last look found anything, tracked explicitly rather than + # inferred from how long ago it was. The old form asked "did we check + # within the last 300s?" and only then used no_data_interval -- but + # once 300s had elapsed the answer became no, the interval dropped + # back to live_update_interval, and it fetched. no_data_interval could + # therefore never delay anything past 300s whatever it was set to. + # Measured on a live rig: an out-of-season NHL polled every ~5.5 + # minutes around the clock, returning nothing every time. if _live_games_attr or _test_mode_attr: - # We have live games or are in test mode, use the configured update interval interval = _update_interval_attr - elif has_recently_checked: - # We've checked recently and found no live games, use longer interval - interval = _no_data_interval_attr else: - # First check or haven't checked in a while, use update interval to check for live games - interval = _update_interval_attr + interval = self._idle_live_interval() # Original line from traceback (line 455), now with variables defined: if current_time - self.last_update >= interval: + # What the previous look found, recorded before this one + # replaces it. The streak is what drives the back-off, and + # any live game resets it. + self._note_live_fetch(bool(_live_games_attr)) self.last_update = current_time # Fetch rankings if enabled diff --git a/plugins/ufc-scoreboard/test_idle_league_backoff.py b/plugins/ufc-scoreboard/test_idle_league_backoff.py new file mode 100644 index 00000000..64780b56 --- /dev/null +++ b/plugins/ufc-scoreboard/test_idle_league_backoff.py @@ -0,0 +1,308 @@ +#!/usr/bin/env python3 +""" +Tests that a league with nothing on stops polling on a live cadence. + +Regression under test: the live gate decided its interval like this -- + + has_recently_checked = self.last_update > 0 and time_since_last_update < 300 + if live_games: interval = live_update_interval + elif has_recently_checked: interval = no_data_interval + else: interval = live_update_interval + +Once 300s had elapsed, has_recently_checked became False, the interval fell +back to live_update_interval, and it fetched. no_data_interval could therefore +never delay anything past 300s whatever it was set to -- the setting was +inert. Measured on a live rig in mid-August: NHLLiveManager fetched 0 games 22 +times in 2 hours, every ~5.5 minutes, around the clock, for a league whose +season had not started. Roughly 264 wasted requests a day, per league. + +The interval now comes from an explicit streak of empty looks, which escalates +and is capped, and which any live game resets. + +Second regression under test: the two interval settings were read from +``self.mode_config`` -- the per-league ``{sport_key}_scoreboard`` block -- while +the schema declares them (and the web UI writes them) at the config *root*. The +saved value was therefore never seen and every user silently kept the default. +An earlier version of this test could not catch that, because it set +``no_data_interval`` on a stand-in object by hand and so never ran the lookup. +It now builds a real ``SportsLive`` and asserts on what its ``__init__`` +resolved. + +Only ``SportsCore.__init__`` is stubbed -- it pulls in logo downloading, fonts +and an ESPN data source, none of which this test needs -- so the lookup lines +themselves run for real. The stub's fidelity is asserted against the real +``SportsCore`` source below, so it cannot drift out of sync unnoticed. + +Run: /bin/python plugins/ufc-scoreboard/test_idle_league_backoff.py +""" + +# A test harness: it reaches into protected members on purpose and builds a +# concrete subclass at runtime, neither of which pylint can see as intentional. +# pylint: disable=protected-access,abstract-class-instantiated,unused-argument +import ast +import sys +from pathlib import Path + +plugin_dir = Path(__file__).resolve().parent +sys.path.insert(0, str(plugin_dir)) +for candidate in (Path("/home/rackpi/projects/LEDMatrix"), + plugin_dir.parents[2] / "LEDMatrix"): + if (candidate / "src" / "plugin_system" / "base_plugin.py").exists(): + sys.path.insert(0, str(candidate)) + break + +import sports # noqa: E402 + +failures = [] + + +def check(label, ok): + print((" PASS " if ok else " FAIL ") + label) + if not ok: + failures.append(label) + + +class _Logger: + def info(self, *a, **k): + pass + + +SPORT_KEY = "test" + + +def _core_init(): + core_src = (plugin_dir / "sports.py").read_text(encoding="utf-8") + core = next(c for c in ast.walk(ast.parse(core_src)) + if isinstance(c, ast.ClassDef) and c.name == "SportsCore") + return core_src, next(m for m in core.body + if isinstance(m, ast.FunctionDef) and m.name == "__init__") + + +def _derive_mode_key(): + """Build the per-league key exactly as this plugin's SportsCore does. + + Not hardcoded: most plugins use f"{sport_key}_scoreboard" but ufc-scoreboard + uses the bare sport_key. Hardcoding one of them made the stub test fiction + on the other -- which the fidelity check below caught. Reading the real + expression out of the source keeps every plugin honest. + """ + _, init = _core_init() + for node in ast.walk(init): + if (isinstance(node, ast.Assign) + and any(getattr(t, "attr", None) == "mode_config" + for t in node.targets) + and isinstance(node.value, ast.Call) and node.value.args): + arg = node.value.args[0] + return _resolve_key(arg), ast.unparse(arg) + raise AssertionError("SportsCore no longer assigns self.mode_config") + + +def _resolve_key(node): + """Evaluate the key expression by hand -- deliberately not with eval(). + + Only the two shapes that actually occur are supported; anything else is a + hard error rather than a guess, so a future rewrite of that line surfaces + here instead of silently producing a key nothing is stored under. + """ + if isinstance(node, ast.Constant): + return node.value + if isinstance(node, ast.Name) and node.id == "sport_key": + return SPORT_KEY + if isinstance(node, ast.JoinedStr): + return "".join(_resolve_key(part) for part in node.values) + if isinstance(node, ast.FormattedValue): + return _resolve_key(node.value) + raise AssertionError( + "unsupported mode_config key expression: %s" % ast.unparse(node)) + + +MODE_KEY, _MODE_KEY_EXPR = _derive_mode_key() + + +def _assert_stub_is_faithful(): + """The stub below stands in for SportsCore.__init__; prove it still matches. + + It has to reproduce exactly the two assignments the lookup under test + depends on. If SportsCore ever changes where config or mode_config come + from, this fails loudly rather than letting the stub quietly test fiction. + """ + _, init = _core_init() + assigns = {} + for node in ast.walk(init): + if isinstance(node, ast.Assign): + for target in node.targets: + if getattr(target, "attr", None) in ("config", "mode_config"): + assigns[target.attr] = node.value + + # The stub is only faithful if SportsCore still (a) keeps the handed-in + # config verbatim as self.config -- if it ever stored a sub-dict, "root" + # would mean something different -- and (b) derives mode_config as a + # lookup *within* that same config. The key itself is read from source + # above, so it needs no assertion; these two shapes do. + raw = assigns.get("config") + check("SportsCore still keeps the handed-in config verbatim as self.config", + isinstance(raw, ast.Name) and raw.id == "config") + + mode = assigns.get("mode_config") + check("mode_config is still a lookup inside that same config", + isinstance(mode, ast.Call) + and isinstance(mode.func, ast.Attribute) + and mode.func.attr == "get" + and isinstance(mode.func.value, ast.Name) + and mode.func.value.id == "config") + + +class _StubCore: + """Exactly the two SportsCore assignments the lookup under test reads.""" + # pylint: disable=too-few-public-methods + + def __init__(self, config, display_manager, cache_manager, logger, sport_key): + self.logger = logger + self.config = config + self.mode_config = config.get(MODE_KEY, {}) or {} + + +# SportsLive may carry abstract methods from SportsCore; fill them in so it can +# be constructed. They are never called here. +_Concrete = type("_ConcreteLive", (sports.SportsLive,), + {name: (lambda self, *a, **k: None) + for name in getattr(sports.SportsLive, + "__abstractmethods__", ())}) + + +def _live(root=None, league=None): + """A real SportsLive built from a real config dict. + + root/league place the settings at the config root and in the per-league + block respectively, which is the whole point: the test can now tell those + two locations apart. + """ + config = dict(root or {}) + config[MODE_KEY] = dict(league or {}) + real_init = sports.SportsCore.__init__ + sports.SportsCore.__init__ = _StubCore.__init__ + try: + return _Concrete(config, object(), object(), _Logger(), SPORT_KEY) + finally: + sports.SportsCore.__init__ = real_init + + +def _Live(base=300, ceiling=900): + """The old signature, now backed by a real config round-trip.""" + return _live(root={"no_data_interval_seconds": base, + "live_idle_max_interval_seconds": ceiling}) + + +def main(): + print("the settings are read from where they are actually written") + _assert_stub_is_faithful() + + at_root = _live(root={"no_data_interval_seconds": 600, + "live_idle_max_interval_seconds": 1200}) + check("a root value is honoured (the schema declares them at the root)", + at_root.no_data_interval == 600) + check("...including the ceiling", at_root.live_idle_max_interval == 1200) + + defaults = _live() + check("absent anywhere falls back to the default", + defaults.no_data_interval == 300) + + legacy = _live(league={"no_data_interval_seconds": 600}) + check("a hand-placed per-league value still works", + legacy.no_data_interval == 600) + + both = _live(root={"no_data_interval_seconds": 600}, + league={"no_data_interval_seconds": 45}) + check("the root wins over a stale per-league value", + both.no_data_interval == 600) + + junk = _live(root={"no_data_interval_seconds": "soon", + "live_idle_max_interval_seconds": float("inf")}) + check("garbage at the root falls back rather than raising", + junk.no_data_interval == 300) + check("an infinite ceiling falls back too", + junk.live_idle_max_interval == sports._DEFAULT_LIVE_IDLE_MAX_SECONDS) + + print("\nthe wait grows the longer nothing is found") + live = _Live() + check("first look uses the base interval", + live._idle_live_interval() == 300) + + for _ in range(sports._IDLE_SHORT_STREAK): + live._note_live_fetch(False) + check("after a short streak it is longer (%ds)" % live._idle_live_interval(), + live._idle_live_interval() > 300) + + short_wait = live._idle_live_interval() + for _ in range(sports._IDLE_LONG_STREAK): + live._note_live_fetch(False) + long_wait = live._idle_live_interval() + # Against short_wait, not against itself: comparing the value to a second + # call of the same method with the same state can never fail, so the + # monotonicity it is meant to protect went untested. + check("after a long streak it is longer still (%ds vs %ds)" + % (long_wait, short_wait), long_wait > short_wait) + check("and never exceeds the ceiling", long_wait <= 900) + + for _ in range(500): + live._note_live_fetch(False) + check("a very long streak stays at the ceiling, not beyond", + live._idle_live_interval() == 900) + + print("\na live game resets it immediately") + live._note_live_fetch(True) + check("the streak is cleared", live._empty_live_streak == 0) + check("and the base interval is back", + live._idle_live_interval() == 300) + + print("\nthe saving is real, and bounded") + idle = _Live() + for _ in range(500): + idle._note_live_fetch(False) + per_day = 86400 / idle._idle_live_interval() + check("an out-of-season league polls far less (%d/day vs 288)" % per_day, + per_day < 288 / 2) + check("...but still often enough to notice a season starting (<= 1h)", + idle._idle_live_interval() <= 3600) + + print("\nthe ceiling is configurable") + tight = _Live(ceiling=300) + for _ in range(500): + tight._note_live_fetch(False) + check("a lower ceiling is honoured", tight._idle_live_interval() == 300) + loose = _Live(ceiling=3600) + for _ in range(500): + loose._note_live_fetch(False) + check("a higher ceiling is honoured", loose._idle_live_interval() == 1800) + + print("\nintervals from config are clamped, never trusted raw") + check("a sane value is used", sports._clamp_seconds(120, 300) == 120) + check("absent falls back", sports._clamp_seconds(None, 300) == 300) + check("nonsense falls back", sports._clamp_seconds("soon", 300) == 300) + check("zero is clamped up", sports._clamp_seconds(0, 300) >= 5) + check("a week is clamped down", sports._clamp_seconds(604800, 300) <= 86400) + + print("\nthe old recency-based logic is gone") + src = (plugin_dir / "sports.py").read_text(encoding="utf-8") + check("has_recently_checked no longer decides the interval", + "has_recently_checked" not in src) + + tree = ast.parse(src) + live_cls = next(c for c in ast.walk(tree) + if isinstance(c, ast.ClassDef) and c.name == "SportsLive") + upd = next(m for m in live_cls.body + if isinstance(m, ast.FunctionDef) and m.name == "update") + idle_calls = [n for n in ast.walk(upd) if isinstance(n, ast.Call) + and getattr(n.func, "attr", None) == "_idle_live_interval"] + note_calls = [n for n in ast.walk(upd) if isinstance(n, ast.Call) + and getattr(n.func, "attr", None) == "_note_live_fetch"] + check("update() takes its idle interval from the back-off", len(idle_calls) == 1) + check("update() records each look's outcome", len(note_calls) == 1) + + print("\n%s" % ("FAILED: %d" % len(failures) if failures + else "All checks passed")) + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/test_schedule_window_plumbing.py b/scripts/test_schedule_window_plumbing.py index f4543bd0..92cd93e2 100644 --- a/scripts/test_schedule_window_plumbing.py +++ b/scripts/test_schedule_window_plumbing.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -A schedule-window setting the user saves has to survive the config adapter. +A plugin-root setting the user saves has to survive the config adapter. Every scoreboard plugin hands its managers a config built by _adapt_config_for_manager, and each of those adapters constructs its output key @@ -38,7 +38,13 @@ if not _CORE: print("SKIP: no LEDMatrix core checkout found (set LEDMATRIX_CORE)") sys.exit(2) -WINDOW_KEYS = ("schedule_lookback_days", "schedule_lookahead_days") +# Every plugin-root setting SportsCore reads. They share one failure mode: the +# adapter builds its output key by key, so a key it does not name is dropped and +# the user silently keeps the default. +WINDOW_KEYS = ( + "schedule_lookback_days", "schedule_lookahead_days", + "no_data_interval_seconds", "live_idle_max_interval_seconds", +) # (plugin directory, a league key that plugin's adapter understands) PLUGINS = [ @@ -106,6 +112,8 @@ def __getattr__(self, name): "enabled": True, "schedule_lookback_days": 30, "schedule_lookahead_days": 21, + "no_data_interval_seconds": 1800, + "live_idle_max_interval_seconds": 7200, league: {"enabled": True, "display_modes": {"live": True}}, } adapter = cls._adapt_config_for_manager @@ -139,9 +147,37 @@ def main(): continue for key in WINDOW_KEYS: # SportsCore reads these off the root of the config it is handed. + # That "root is where they are read" half is asserted directly, per + # plugin, by test_idle_league_backoff.py -- this file only proves + # the adapters get them there. check(f"{name}: {key} reaches the config root", where(config, key) == "root") + print("\nsoccer's custom leagues go through a second adapter:") + soccer = REPO / "plugins" / "soccer-scoreboard" + if soccer.is_dir(): + try: + cls = load_plugin_class(soccer) + probe = object.__new__(type("Probe", (cls,), + {"__getattr__": lambda self, n: 0})) + probe.logger = logging.getLogger("plumbing-test") + probe.config = { + "schedule_lookback_days": 30, + "schedule_lookahead_days": 21, + "no_data_interval_seconds": 1800, + "live_idle_max_interval_seconds": 7200, + } + custom = cls._adapt_config_for_custom_league( + probe, {"league_code": "epl2", "enabled": True, + "display_modes": {"live": True}}) + except (ImportError, AttributeError, TypeError, KeyError, ValueError) as exc: + check("soccer: custom-league adapter ran", False) + print(f" {type(exc).__name__}: {exc}") + else: + for key in WINDOW_KEYS: + check(f"soccer custom league: {key} reaches the config root", + where(custom, key) == "root") + print(f"\n{'FAILED: ' + str(len(FAILURES)) + ' check(s)' if FAILURES else 'All checks passed.'}") return 1 if FAILURES else 0