From f18d90befc7b39446a953b06a0d7a154e107b676 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:43:56 -0400 Subject: [PATCH 1/4] refactor(sports): put the scoreboards on the shared scroll resolver Eight sports scoreboards -- afl, baseball, basketball, football, hockey, lacrosse, nrl, soccer -- scrolled through this module's own pacing while the other eleven scrolling plugins went through src/common/scroll_config. Two implementations of the same job, and this one was on the losing side of every difference. It never called set_scrolling_state. Two consequences, both of which this release's work was about: - The frame hold is applied through that call, so a speed the crisp ladder could render in whole pixels still presented a new frame every refresh. - Core only runs deferred updates while nothing is scrolling. Believing nothing was, it ran blocking work in the middle of these scrolls. The default is non-crisp today: scroll_speed 50.0 with scroll_delay 0.01 is 50 px/s, which on a 100Hz panel is half a pixel per refresh. That cannot render as motion -- it alternates 0px and 1px steps and judders at a 50Hz beat, on every scoreboard, out of the box. Resolved through the ladder it stays 50 px/s and holds each frame for two refreshes: same speed, whole-pixel motion. The stepping disagreement that used to justify a separate module is gone. scroll_config avoided frame-based mode because it stepped on a wall clock at 1/scroll_delay with scroll_delay set to the frame period, so the decision sat on its own threshold and flipped on sub-millisecond jitter. That branch now accumulates elapsed time, identical arithmetic to the time-based one, so the two differ only in the units the speed arrives in. What is NOT shared, and must not be: the two modules read identically-named keys with different meanings. Here scroll_speed is px/SECOND and scroll_delay only converts to px/frame; in scroll_config scroll_speed is px per STEP, so px/s is speed/delay. Passing this module's settings dict to the resolver turns 50 px/s into 5000, clamped to 500 -- a tenfold speed-up everywhere. So _get_scroll_settings keeps sole ownership of reading sports config, including the league merging, and hands the resolver a plain px/s. A test pins that specific number, because it is the mistake the refactor invites. MIN/MAX_PIXELS_PER_FRAME are gone; the resolver bounds speed and the helper clamps FPS. _resolve_target_fps stays, re-purposed: under the old model that key was the rate frames were presented at, so it is the faithful translation into the refresh the ladder is computed against, used when no hardware refresh is configured. Speed changes for panels that are not 100Hz: 50 px/s becomes 60 at 60Hz (+20%) and 48 at 120Hz (-4%). At 100Hz it is unchanged. Co-Authored-By: Claude Opus 5 --- src/common/sports_scroll.py | 124 +++++++++++++++++--------- test/test_sports_scroll.py | 171 +++++++++++++++++++++++++----------- 2 files changed, 206 insertions(+), 89 deletions(-) diff --git a/src/common/sports_scroll.py b/src/common/sports_scroll.py index f9a32e37..0278a1d3 100644 --- a/src/common/sports_scroll.py +++ b/src/common/sports_scroll.py @@ -43,6 +43,7 @@ class HockeyScrollDisplayManager(SportsScrollDisplayManager): from PIL import Image +from src.common import scroll_config from src.common.scroll_helper import ScrollHelper logger = logging.getLogger(__name__) @@ -58,13 +59,9 @@ class HockeyScrollDisplayManager(SportsScrollDisplayManager): "dynamic_duration": True, } -#: Bounds on the px/second -> px/frame conversion, applied before the helper -#: sees the value. FPS is *not* clamped here — ScrollHelper.set_target_fps -#: already does that, and a second copy of the range would drift from it. -MIN_PIXELS_PER_FRAME = 0.1 -MAX_PIXELS_PER_FRAME = 5.0 - #: Pacing to assume when scroll_delay is 0, i.e. the plugin has not set one. +#: Only used to interpret this module's own px/frame config shape; the speed +#: bounds and the px/s -> px/frame conversion belong to scroll_config now. ASSUMED_FPS_WHEN_UNPACED = 100.0 @@ -236,51 +233,75 @@ def _configure_scroll_helper(self) -> None: """Apply config to the scroll helper. Safe to call again after a change.""" settings = self._get_scroll_settings() - scroll_speed = self._coerce_float(settings.get("scroll_speed"), 50.0) - scroll_delay = self._coerce_float(settings.get("scroll_delay"), 0.01) dynamic_duration = bool(settings.get("dynamic_duration", True)) - - self.scroll_helper.set_scroll_delay(scroll_delay) self.scroll_helper.set_dynamic_duration_settings( enabled=dynamic_duration, min_duration=settings.get("min_duration", 30), max_duration=settings.get("max_duration", 600), buffer=0.2, # ensure the strip clears the panel completely ) - # Frame-based scrolling: motion advances per rendered frame rather than - # per wall-clock second, which is what makes the pacing stable. - self.scroll_helper.set_frame_based_scrolling(True) - # Config states speed in px/second; frame-based mode wants px/frame. - if scroll_delay > 0: - pixels_per_frame = scroll_speed * scroll_delay - else: - pixels_per_frame = scroll_speed / ASSUMED_FPS_WHEN_UNPACED - pixels_per_frame = max( - MIN_PIXELS_PER_FRAME, min(MAX_PIXELS_PER_FRAME, pixels_per_frame) - ) - self.scroll_helper.set_scroll_speed(pixels_per_frame) - - effective_pps = ( - pixels_per_frame / scroll_delay - if scroll_delay > 0 - else pixels_per_frame * ASSUMED_FPS_WHEN_UNPACED + # Speed goes through scroll_config, which every other scrolling plugin + # already uses. What must NOT happen is handing it this module's + # settings dict: the two read the same key names with different + # meanings, and the collision is a factor of 1/scroll_delay. + # + # sports_scroll: scroll_speed is px/SECOND; scroll_delay is only the + # frame period used to reach px/frame. + # scroll_config: scroll_speed is px per STEP, so px/s = speed/delay. + # + # Passing {"scroll_speed": 50.0, "scroll_delay": 0.01} straight through + # resolves to 5000 px/s (clamped to 500) instead of 50. So this module + # keeps ownership of reading its own config -- _get_scroll_settings + # merges the league overrides -- and hands the resolver a plain px/s. + pixels_per_second = self._resolve_pixels_per_second(settings) + + resolved = scroll_config.configure( + self.scroll_helper, + plugin_config=None, + global_config=self.global_config, + default_pixels_per_second=pixels_per_second, + display_manager=self.display_manager, + plugin_logger=self.logger, + refresh_hz=self._resolve_refresh_hz(), ) + self._scroll_settings = resolved self.logger.info( - f"ScrollHelper configured: {pixels_per_frame:.2f} px/frame, " - f"delay={scroll_delay}s (effective {effective_pps:.1f} px/s from " - f"{scroll_speed} px/s config), dynamic_duration={dynamic_duration}" + "ScrollHelper configured: %s (requested %.1f px/s), " + "dynamic_duration=%s", + resolved.describe(), + pixels_per_second, dynamic_duration, ) - # The reason this module exists upstream: the bundled copies hardcode - # ~100 FPS via scroll_delay and never consult the global target. - # No hasattr guard here, unlike the plugin copies: they probe because - # they may run against an older core, whereas this module ships in the - # same release as the ScrollHelper it calls. The helper clamps. - target_fps = self._resolve_target_fps() - if target_fps: - self.scroll_helper.set_target_fps(target_fps) - self.logger.info(f"Target FPS set to {target_fps}") + def _resolve_pixels_per_second(self, settings: Dict[str, Any]) -> float: + """This module's config shape, expressed as plain pixels per second. + + ``scroll_speed`` is already px/s here. ``scroll_delay`` only matters + when a caller supplied px/frame instead, which the 0 case covers. + """ + scroll_speed = self._coerce_float(settings.get("scroll_speed"), 50.0) + scroll_delay = self._coerce_float(settings.get("scroll_delay"), 0.01) + if scroll_delay <= 0: + return scroll_speed * ASSUMED_FPS_WHEN_UNPACED + return scroll_speed + + def _resolve_refresh_hz(self) -> Optional[float]: + """The panel refresh the crisp ladder should be computed against. + + Prefers the configured hardware refresh. Falls back to the global + ``target_fps``/``scroll_target_fps`` this module has always honoured: + under the old model that key *was* the rate frames were presented at, + so it is the faithful translation for anyone who set it. Returning + None lets scroll_config apply its own default. + """ + hardware = scroll_config.refresh_hz_from_config(self.global_config) + if hardware and hardware != scroll_config.DEFAULT_REFRESH_HZ: + return hardware + return self._resolve_target_fps() or hardware or None + + def _scroll_frame_hold(self) -> int: + """Refreshes to hold each frame for, from the resolved settings.""" + return getattr(getattr(self, "_scroll_settings", None), "frame_hold", 1) # ------------------------------------------------------------------ # Frame pumping @@ -305,6 +326,16 @@ def display_scroll_frame(self) -> bool: if not visible: return False + # Tell the core the panel is scrolling, and for how many + # refreshes to hold each frame. Without this the frame hold is + # never applied -- so a speed the ladder made crisp still presents + # a new frame every refresh and judders -- and, because deferred + # updates only run while nothing is scrolling, core would run + # blocking work in the middle of this scroll. + if hasattr(self.display_manager, "set_scrolling_state"): + self.display_manager.set_scrolling_state( + True, frame_hold=self._scroll_frame_hold()) + self.display_manager.image = visible self.display_manager.update_display() self._frame_count += 1 @@ -331,7 +362,19 @@ def _log_scroll_progress(self) -> None: def is_scroll_complete(self) -> bool: """True when the strip has scrolled fully past the panel.""" - return self.scroll_helper.is_scroll_complete() + complete = self.scroll_helper.is_scroll_complete() + if complete: + self._release_scrolling_state() + return complete + + def _release_scrolling_state(self) -> None: + """Tell the core this display is no longer scrolling. + + The scrolling flag and the frame hold are global to the display + manager, so leaving them set holds every other plugin's frames too. + """ + if hasattr(self.display_manager, "set_scrolling_state"): + self.display_manager.set_scrolling_state(False) def reset_scroll(self) -> None: """Return the strip to its starting position, keeping the content.""" @@ -349,6 +392,7 @@ def clear(self) -> None: self._vegas_content_items = [] self._is_scrolling = False self._scroll_start_time = None + self._release_scrolling_state() self.logger.debug("Scroll display cleared") # ------------------------------------------------------------------ diff --git a/test/test_sports_scroll.py b/test/test_sports_scroll.py index 9f6f72e8..893dfa34 100644 --- a/test/test_sports_scroll.py +++ b/test/test_sports_scroll.py @@ -21,8 +21,6 @@ from src.common.sports_scroll import ( # noqa: E402 DEFAULT_SCROLL_SETTINGS, - MAX_PIXELS_PER_FRAME, - MIN_PIXELS_PER_FRAME, SportsScrollDisplay, SportsScrollDisplayManager, ) @@ -187,29 +185,48 @@ def test_a_null_league_block_is_tolerated(self, build): # --------------------------------------------------------------------------- class TestConfigureScrollHelper: - def test_speed_is_converted_to_pixels_per_frame(self, build): + """Pacing now comes from scroll_config, the same resolver every other + scrolling plugin uses. What this class guards is the translation into it, + because the two modules read identically-named keys differently.""" + + def test_config_speed_is_pixels_per_second_not_pixels_per_step(self, build): + """The collision that makes a naive hand-off wrong by 1/scroll_delay. + + sports config states scroll_speed in px/SECOND and uses scroll_delay + only to reach px/frame. scroll_config states it in px per STEP, so it + computes px/s as speed/delay. Handing this module's settings dict + straight to the resolver turns 50 px/s into 5000 px/s, which its own + bounds then clamp to 500 -- a tenfold speed-up on every scoreboard. + """ display = build({"nhl": {"scroll_settings": { - "scroll_speed": 100.0, "scroll_delay": 0.02}}}) - # 100 px/s * 0.02 s/frame = 2 px/frame - display.scroll_helper.set_scroll_speed.assert_called_with(2.0) - - def test_conversion_is_clamped_low(self, build): - display = build({"nhl": {"scroll_settings": { - "scroll_speed": 0.001, "scroll_delay": 0.001}}}) - display.scroll_helper.set_scroll_speed.assert_called_with(MIN_PIXELS_PER_FRAME) + "scroll_speed": 50.0, "scroll_delay": 0.01}}}) + applied = display.scroll_helper.set_scroll_speed.call_args[0][0] + assert applied == pytest.approx(50.0, abs=1.0), ( + f"applied {applied} px/s; 500 means the settings dict was passed " + "through to the resolver instead of a plain px/s") + + def test_speed_is_snapped_to_whole_pixel_motion(self, build): + """50 px/s on a 100Hz panel is half a pixel per refresh, which cannot + render as motion -- it alternates 0 and 1 px steps and judders. The + ladder keeps the speed and holds each frame for two refreshes.""" + display = build() + assert display._scroll_settings.frame_hold == 2 + assert display._scroll_settings.pixels_per_second == pytest.approx(50.0) - def test_conversion_is_clamped_high(self, build): - display = build({"nhl": {"scroll_settings": { - "scroll_speed": 5000.0, "scroll_delay": 0.5}}}) - display.scroll_helper.set_scroll_speed.assert_called_with(MAX_PIXELS_PER_FRAME) + def test_the_resolved_hold_is_what_gets_published(self, build): + display = build() + assert display._scroll_frame_hold() == 2 - def test_zero_delay_assumes_a_pacing_instead_of_dividing_by_zero(self, build): - display = build({"nhl": {"scroll_settings": { - "scroll_speed": 100.0, "scroll_delay": 0}}}) - display.scroll_helper.set_scroll_speed.assert_called_with(1.0) + def test_hold_defaults_to_one_before_anything_is_resolved(self, display_manager): + bare = SportsScrollDisplay.__new__(SportsScrollDisplay) + assert SportsScrollDisplay._scroll_frame_hold(bare) == 1 - def test_frame_based_scrolling_is_enabled(self, build): - build().scroll_helper.set_frame_based_scrolling.assert_called_once_with(True) + def test_stepping_is_time_based(self, build): + """Frame-based mode gated motion on a wall clock at 1/scroll_delay + steps, with scroll_delay set to the frame period -- putting the + comparison exactly on its own threshold, so it flipped on sub- + millisecond jitter.""" + build().scroll_helper.set_frame_based_scrolling.assert_called_once_with(False) def test_dynamic_duration_settings_are_applied(self, build): display = build({"nhl": {"scroll_settings": { @@ -219,32 +236,38 @@ def test_dynamic_duration_settings_are_applied(self, build): assert kwargs["min_duration"] == 5 assert kwargs["max_duration"] == 50 - def test_target_fps_reaches_the_helper(self, build): - """The whole point of upstreaming: the bundled copies hardcode ~100 FPS - via scroll_delay and never consult the global target.""" - display = build(global_config={"target_fps": 120}) - display.scroll_helper.set_target_fps.assert_called_once_with(120.0) + def test_zero_delay_still_means_pixels_per_frame(self, build): + """The one case where scroll_speed is not already px/s.""" + display = build({"nhl": {"scroll_settings": { + "scroll_speed": 1.0, "scroll_delay": 0}}}) + applied = display.scroll_helper.set_scroll_speed.call_args[0][0] + assert applied == pytest.approx(100.0, abs=1.0) + + def test_global_target_fps_sets_the_refresh_the_ladder_uses(self, build): + """Under the old model this key was the rate frames were presented at, + so it is the faithful translation of it into a panel refresh.""" + display = build(global_config={"target_fps": 60}) + assert display._resolve_refresh_hz() == 60.0 def test_legacy_key_is_honored(self, build): display = build(global_config={"scroll_target_fps": 90}) - display.scroll_helper.set_target_fps.assert_called_once_with(90.0) + assert display._resolve_refresh_hz() == 90.0 def test_modern_key_wins_over_legacy(self, build): display = build(global_config={"target_fps": 120, "scroll_target_fps": 90}) - display.scroll_helper.set_target_fps.assert_called_once_with(120.0) + assert display._resolve_refresh_hz() == 120.0 - def test_absent_target_fps_leaves_config_pacing_alone(self, build): - build().scroll_helper.set_target_fps.assert_not_called() + def test_configured_hardware_refresh_wins_over_the_fps_target(self, build): + display = build(global_config={ + "target_fps": 60, + "display": {"hardware": {"limit_refresh_rate_hz": 120}}}) + assert display._resolve_refresh_hz() == 120.0 @pytest.mark.parametrize("bad", ["fast", None, {}, [], "", 0]) def test_unusable_target_fps_degrades_instead_of_raising(self, build, bad): """A malformed global config must cost the FPS target, not the display.""" display = build(global_config={"target_fps": bad}) - display.scroll_helper.set_target_fps.assert_not_called() - - def test_string_digits_are_accepted(self, build): - display = build(global_config={"target_fps": "120"}) - display.scroll_helper.set_target_fps.assert_called_once_with(120.0) + assert display._scroll_settings.pixels_per_second > 0 @pytest.mark.parametrize("bad", [None, "fast", {}, []]) def test_unusable_scroll_speed_degrades_instead_of_crashing(self, build, bad): @@ -252,24 +275,63 @@ def test_unusable_scroll_speed_degrades_instead_of_crashing(self, build, bad): present with null reaches the arithmetic and raises inside __init__, taking the whole display down before it renders anything.""" display = build({"nhl": {"scroll_settings": {"scroll_speed": bad}}}) - # 50.0 px/s * 0.01 s/frame == 0.5 px/frame, i.e. the default speed. - display.scroll_helper.set_scroll_speed.assert_called_with(0.5) + applied = display.scroll_helper.set_scroll_speed.call_args[0][0] + assert applied == pytest.approx(50.0, abs=1.0) @pytest.mark.parametrize("bad", [None, "slow", {}]) def test_unusable_scroll_delay_degrades_instead_of_crashing(self, build, bad): display = build({"nhl": {"scroll_settings": {"scroll_delay": bad}}}) - display.scroll_helper.set_scroll_delay.assert_called_with(0.01) + applied = display.scroll_helper.set_scroll_speed.call_args[0][0] + assert applied == pytest.approx(50.0, abs=1.0) def test_numeric_strings_are_accepted(self, build): display = build({"nhl": {"scroll_settings": { "scroll_speed": "100", "scroll_delay": "0.02"}}}) - display.scroll_helper.set_scroll_speed.assert_called_with(2.0) + applied = display.scroll_helper.set_scroll_speed.call_args[0][0] + assert applied == pytest.approx(100.0, abs=1.0) + + +class TestScrollingStateIsPublished: + """The core has to be told, or the frame hold is never applied and + deferred work runs in the middle of the scroll.""" + + def _drawable(self, display): + display.scroll_helper.cached_image = Image.new("RGB", (400, 32)) + display.scroll_helper.get_visible_portion.return_value = Image.new( + "RGB", (128, 32)) + + def test_a_drawn_frame_declares_the_hold(self, build, display_manager): + display = build() + self._drawable(display) + assert display.display_scroll_frame() is True + display_manager.set_scrolling_state.assert_called_with( + True, frame_hold=display._scroll_frame_hold()) + + def test_completion_releases_the_state(self, build, display_manager): + display = build() + display.scroll_helper.is_scroll_complete.return_value = True + assert display.is_scroll_complete() is True + display_manager.set_scrolling_state.assert_called_with(False) + + def test_an_incomplete_scroll_does_not_release(self, build, display_manager): + display = build() + display.scroll_helper.is_scroll_complete.return_value = False + display.is_scroll_complete() + assert (False,) not in [c.args for c in + display_manager.set_scrolling_state.call_args_list] + + def test_clearing_releases_the_state(self, build, display_manager): + display = build() + display.clear() + display_manager.set_scrolling_state.assert_called_with(False) + + def test_an_older_core_without_the_call_is_tolerated(self, build): + """Plugins ship independently of the core they run against.""" + display = build() + del display.display_manager.set_scrolling_state + self._drawable(display) + assert display.display_scroll_frame() is True - def test_fps_clamping_is_left_to_the_helper(self, build): - """Deliberately not clamped here — a second copy of the range would - drift from ScrollHelper.set_target_fps.""" - display = build(global_config={"target_fps": 5000}) - display.scroll_helper.set_target_fps.assert_called_once_with(5000.0) # --------------------------------------------------------------------------- @@ -429,7 +491,11 @@ def test_global_config_is_threaded_to_children(self, manager): never honoring target_fps.""" child = manager.get_scroll_display("live") assert child.global_config == {"target_fps": 120} - child.scroll_helper.set_target_fps.assert_called_once_with(120.0) + # The target is now the refresh the crisp ladder is computed against, + # so what proves the hand-off is that the child reasons about 120Hz -- + # not the presentation rate, which the chosen hold divides down. + assert child._resolve_refresh_hz() == 120.0 + child.scroll_helper.set_target_fps.assert_called_once() def test_prepare_sets_the_active_type(self, manager): assert manager.prepare_and_display([{"id": "g1"}], "live", ["nhl"]) is True @@ -551,10 +617,17 @@ def real(self, display_manager, monkeypatch): ) def test_configuration_lands_on_the_real_helper(self, real): - helper = real.get_scroll_display("live").scroll_helper - assert helper.target_fps == 120.0 - assert helper.frame_based_scrolling is True - assert helper.scroll_speed == pytest.approx(0.5) # 500 px/s * 0.001 s + display = real.get_scroll_display("live") + helper = display.scroll_helper + # 500 px/s on a 120Hz panel snaps to a whole number of pixels per + # refresh, and the helper is driven in px/s in time-based mode. + assert helper.frame_based_scrolling is False + assert helper.scroll_speed == pytest.approx( + display._scroll_settings.pixels_per_second) + assert helper.scroll_speed == pytest.approx(500.0, abs=25.0) + # target_fps is the presentation rate the chosen hold produces. + assert helper.target_fps == pytest.approx( + 120.0 / display._scroll_settings.frame_hold, abs=1.0) def test_a_strip_is_built_and_scrolls_to_completion(self, real): import time From 650076ef0547da1e979ce250e53f6f8bb245063c Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:13:18 -0400 Subject: [PATCH 2/4] fix(display): drop the frame hold when a scroll times out, not just when it says so set_scrolling_state(False) clears the hold. The other way a scroll ends is is_currently_scrolling() deciding, after scroll_inactivity_threshold of silence, that it is over -- which is what happens when the rotation moves on mid-scroll or a plugin is torn down. That path cleared the flag and kept the hold, so every later plugin, scrolling or static, was presented at refresh/N by whoever scrolled last, until something called the explicit stop. The method's own docstring already states the rule this breaks: the hold "must not outlive the scroll that asked for it". The timeout was the exception it did not cover. Pre-existing, but reachable by three plugins before and eleven after the sports scoreboards moved onto the shared resolver, so it belongs with that change. The test ages the activity timestamp past the threshold rather than sleeping. Also adds scripts/sports_scroll_check.py. The sports scroll path is per-league opt-in, so a rig showing static game cards never constructs a SportsScrollDisplay and none of its pacing can be observed from a normal run -- which is exactly what happened when this change was first put on hardware: 26 minutes, zero sports scroll lines. The script drives the path directly with synthetic games and asserts the three things the resolver is meant to buy: the speed lands on whole pixels, the hold is published, and it is released after. It never starts or stops the display service, matching scroll_speeds.py, so a crash here cannot leave the panel dark. Co-Authored-By: Claude Opus 5 --- scripts/sports_scroll_check.py | 153 ++++++++++++++++++++++++++++ src/display_manager.py | 8 ++ test/test_display_dirty_tracking.py | 23 +++++ 3 files changed, 184 insertions(+) create mode 100644 scripts/sports_scroll_check.py diff --git a/scripts/sports_scroll_check.py b/scripts/sports_scroll_check.py new file mode 100644 index 00000000..800b0c78 --- /dev/null +++ b/scripts/sports_scroll_check.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +"""Drive a sports scoreboard scroll on the panel and report what it did. + +The eight sports scoreboards scroll through ``src/common/sports_scroll.py``, +and that path is per-league opt-in: a rig showing static game cards never +constructs a SportsScrollDisplay at all, so nothing about its pacing can be +observed from a normal run. This drives it directly, with synthetic games, so +the pacing can be measured without changing anyone's configuration. + +What it checks is what the shared resolver is supposed to buy: + +* the requested speed lands on a whole number of pixels per refresh +* the frame hold that makes that true is published to the display manager +* frames actually arrive at the interval the hold implies + + sudo systemctl stop ledmatrix + sudo python3 scripts/sports_scroll_check.py --seconds 20 + sudo systemctl start ledmatrix + +Like scripts/scroll_speeds.py, this never starts or stops the display service +itself -- that is left to the caller, so a crash here cannot leave the panel +dark. +""" +from __future__ import annotations + +import argparse +import json +import statistics +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from PIL import Image # noqa: E402 + +from src.common.sports_scroll import SportsScrollDisplay # noqa: E402 +from src.display_manager import DisplayManager # noqa: E402 + + +class _Check(SportsScrollDisplay): + """A scoreboard whose cards are plain blocks -- pacing is what matters.""" + + SCROLL_LEAGUE_KEYS = ("nfl",) + + def prepare_scroll_content(self, games, game_type, leagues, rankings_cache=None): + width = self.display_height * 2 + cards = [] + for i, _ in enumerate(games): + card = Image.new("RGB", (width, self.display_height), (0, 0, 0)) + shade = 40 + (i * 37) % 180 + for x in range(2, width - 2): + for y in range(2, self.display_height - 2): + card.putpixel((x, y), (shade, 90, 220 - shade // 2)) + cards.append(card) + self._current_games = list(games) + self._current_game_type = game_type + self._current_leagues = list(leagues) + self.scroll_helper.create_scrolling_image(content_items=cards, item_gap=24) + return bool(cards) + + +class _HoldSpy: + """Records what the scroll publishes, without changing what it does.""" + + def __init__(self, display_manager): + self.dm = display_manager + self.calls = [] + self._real = display_manager.set_scrolling_state + + def __enter__(self): + def spy(is_scrolling, frame_hold=1): + self.calls.append((is_scrolling, frame_hold)) + return self._real(is_scrolling, frame_hold=frame_hold) + self.dm.set_scrolling_state = spy + return self + + def __exit__(self, *exc): + self.dm.set_scrolling_state = self._real + return False + + +def main(): + ap = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--seconds", type=float, default=20.0) + ap.add_argument("--speed", type=float, default=None, + help="px/s to request; default is the module's own") + ap.add_argument("--games", type=int, default=6) + ap.add_argument("--fallback", action="store_true", + help="run without the panel. Driving the real matrix needs " + "root; this checks everything except the vsync pacing " + "-- what speed resolves to, that the hold is published, " + "and that it is released afterwards.") + args = ap.parse_args() + + root = Path(__file__).resolve().parent.parent + config = json.loads((root / "config" / "config.json").read_text(encoding="utf-8")) + + display_manager = DisplayManager(config, force_fallback=args.fallback) + settings = {} if args.speed is None else { + "nfl": {"scroll_settings": {"scroll_speed": args.speed}}} + + display = _Check(display_manager, settings, global_config=config) + resolved = display._scroll_settings + print("resolved: %s" % resolved.describe()) + print("frame hold: %d refresh(es) per frame" % resolved.frame_hold) + if resolved.warning: + print("warning: %s" % resolved.warning) + + display.prepare_scroll_content( + [{"id": "g%d" % i} for i in range(args.games)], "live", ["nfl"]) + + gaps, drawn = [], 0 + last = None + with _HoldSpy(display_manager) as spy: + started = time.perf_counter() + while time.perf_counter() - started < args.seconds: + if not display.display_scroll_frame(): + break + now = time.perf_counter() + if last is not None: + gaps.append((now - last) * 1000.0) + last = now + drawn += 1 + display.clear() + + if not gaps: + sys.exit("no frames were drawn -- the scroll never started") + + gaps.sort() + expected = 1000.0 * resolved.frame_hold / (resolved.crisp.refresh_hz + if resolved.crisp else 100.0) + print("\n%d frames in %.1fs -> %.1f fps" % ( + drawn, args.seconds, drawn / args.seconds)) + print("frame gap median %.2fms p95 %.2fms max %.2fms (hold implies %.2fms)" + % (statistics.median(gaps), gaps[int(len(gaps) * 0.95)], gaps[-1], expected)) + + holds = {h for on, h in spy.calls if on} + print("published while scrolling: frame_hold=%s" % (sorted(holds) or "NOTHING")) + print("released on clear: %s" % any(not on for on, _ in spy.calls)) + print("display manager hold now: %d (1 means released)" + % getattr(display_manager, "_frame_hold", -1)) + + if not holds: + sys.exit("FAIL: the scroll never told the core it was scrolling") + if holds != {resolved.frame_hold}: + sys.exit("FAIL: published %s but resolved %d" % (holds, resolved.frame_hold)) + print("\nOK: the resolved hold reached the panel and was released after") + + +if __name__ == "__main__": + main() diff --git a/src/display_manager.py b/src/display_manager.py index 9649cd1f..5d042489 100644 --- a/src/display_manager.py +++ b/src/display_manager.py @@ -1436,6 +1436,14 @@ def is_currently_scrolling(self) -> bool: # If we've been inactive for the threshold period, consider it not scrolling if current_time - self._scrolling_state['last_scroll_activity'] > self._scrolling_state['scroll_inactivity_threshold']: self._scrolling_state['is_scrolling'] = False + # Drop the hold with the state, exactly as set_scrolling_state(False) + # does. This path is the one a scroll takes when it ends without + # saying so -- the rotation moves on mid-scroll, or the plugin is + # torn down -- and leaving the hold set there means every later + # plugin, scrolling or static, is presented at refresh/N until + # somebody calls set_scrolling_state(False). The hold must not + # outlive the scroll that asked for it, however that scroll ends. + self._frame_hold = 1 return False return True diff --git a/test/test_display_dirty_tracking.py b/test/test_display_dirty_tracking.py index cf33d12b..eb2a97c3 100644 --- a/test/test_display_dirty_tracking.py +++ b/test/test_display_dirty_tracking.py @@ -293,6 +293,29 @@ def test_scrolling_state_carries_the_hold(self, dm): finally: dm.set_scrolling_state(False) + def test_a_scroll_that_times_out_does_not_strand_a_hold(self, dm): + """The hold must go when the state does, however the scroll ended. + + set_scrolling_state(False) is the polite exit. The other one is + is_currently_scrolling() deciding, after scroll_inactivity_threshold + of silence, that the scroll is over -- which is what happens when the + rotation moves on mid-scroll or a plugin is torn down. That path used + to clear the flag and keep the hold, so every later plugin, scrolling + or static, was presented at refresh/N by whoever scrolled last. + """ + dm.set_scrolling_state(True, frame_hold=5) + # Age the scroll past the inactivity threshold rather than sleeping. + dm._scrolling_state['last_scroll_activity'] -= ( + dm._scrolling_state['scroll_inactivity_threshold'] + 1.0) + + assert dm.is_currently_scrolling() is False + dm.draw.rectangle([0, 0, 5, 5], fill=(10, 10, 200)) + with _SwapSpy(dm.matrix) as spy: + dm.update_display() + assert spy.last_frame_hold == 1, ( + "a timed-out scroll left its frame hold behind; the next plugin " + "is being presented at a fraction of the refresh rate") + def test_another_plugin_stopping_does_not_strand_a_hold(self, dm): dm.set_scrolling_state(True, frame_hold=3) dm.set_scrolling_state(False) # some other plugin finishes From e094386f94cd1d139503fa369d25052fbf1d2e2c Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Tue, 8 Sep 2026 18:38:45 -0400 Subject: [PATCH 3/4] fix(scripts): refuse to grab the panel while the display service has it The module docstring already said to stop ledmatrix first. Nothing enforced it, and running the script against a live service is not a harmless mistake: rpi-rgb-led-matrix configures GPIO directions and the hardware PWM inside RGBMatrix(), and when the root check fails it calls exit() from C with no cleanup. The service keeps rendering and swapping onto pins that have been reconfigured underneath it, so the panel goes black while every diagnostic says the display is healthy -- fresh framebuffer, every pixel lit, "RGB Matrix initialized successfully", nothing in the log. A restart fixes it, once you work out that is what happened. Found the hard way: this is what took the panel down on the test rig, not the change the script was written to verify. --fallback skips the check, since it never opens the matrix. --force is there for anyone who means it. Co-Authored-By: Claude Opus 5 --- scripts/sports_scroll_check.py | 42 ++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/scripts/sports_scroll_check.py b/scripts/sports_scroll_check.py index 800b0c78..cc14d9fb 100644 --- a/scripts/sports_scroll_check.py +++ b/scripts/sports_scroll_check.py @@ -26,6 +26,7 @@ import argparse import json import statistics +import subprocess import sys import time from pathlib import Path @@ -80,6 +81,41 @@ def __exit__(self, *exc): return False +MESSAGE = """ledmatrix is running and owns the panel's GPIO. + +Stop it first, or this run can leave the display dark: + + sudo systemctl stop ledmatrix + sudo python3 scripts/sports_scroll_check.py + sudo systemctl start ledmatrix + +Use --fallback to check the pacing logic without the panel, or --force if +you really mean it.""" + + +def _refuse_if_the_service_is_running(force): + """Refuse to touch the panel while ledmatrix has it. + + rpi-rgb-led-matrix configures GPIO directions and the hardware PWM inside + RGBMatrix(), and on the root check it calls exit() from C -- no cleanup. + Do that while the service is driving those same pins and the panel goes + dark while the service carries on rendering happily: fresh framebuffer, + every pixel lit, "RGB Matrix initialized successfully", nothing in the log. + A restart brings it back, but only once you work out that is what happened. + + The module docstring says to stop the service first. This makes it true. + """ + if force: + return + try: + active = subprocess.run(["systemctl", "is-active", "ledmatrix"], + capture_output=True, text=True).stdout.strip() + except OSError: + return # not a systemd box; nothing to protect + if active == "active": + sys.exit(MESSAGE) + + def main(): ap = argparse.ArgumentParser( description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) @@ -87,6 +123,9 @@ def main(): ap.add_argument("--speed", type=float, default=None, help="px/s to request; default is the module's own") ap.add_argument("--games", type=int, default=6) + ap.add_argument("--force", action="store_true", + help="run even though the display service is up. It owns " + "the GPIO; expect a dark panel until you restart it.") ap.add_argument("--fallback", action="store_true", help="run without the panel. Driving the real matrix needs " "root; this checks everything except the vsync pacing " @@ -94,6 +133,9 @@ def main(): "and that it is released afterwards.") args = ap.parse_args() + if not args.fallback: + _refuse_if_the_service_is_running(args.force) + root = Path(__file__).resolve().parent.parent config = json.loads((root / "config" / "config.json").read_text(encoding="utf-8")) From 5a0be31a589405e6a04a2368ce7c7ddedee584ba Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Tue, 8 Sep 2026 20:06:02 -0400 Subject: [PATCH 4/4] chore(scripts): annotate the subprocess call the way this repo already does Codacy fails a PR on one new issue, and bandit B404 fires on any subprocess import. scripts/run_plugin_tests.py carries the same suppression with the same justification -- list-form argv, no shell -- so this follows it rather than inventing a second convention. Co-Authored-By: Claude Opus 5 --- scripts/sports_scroll_check.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/scripts/sports_scroll_check.py b/scripts/sports_scroll_check.py index cc14d9fb..b5d37e54 100644 --- a/scripts/sports_scroll_check.py +++ b/scripts/sports_scroll_check.py @@ -26,7 +26,7 @@ import argparse import json import statistics -import subprocess +import subprocess # nosec B404 - list-form argv only, no shell # nosemgrep import sys import time from pathlib import Path @@ -108,8 +108,9 @@ def _refuse_if_the_service_is_running(force): if force: return try: - active = subprocess.run(["systemctl", "is-active", "ledmatrix"], - capture_output=True, text=True).stdout.strip() + active = subprocess.run( # nosec B603 B607 - hardcoded systemctl args # nosemgrep + ["systemctl", "is-active", "ledmatrix"], + capture_output=True, text=True).stdout.strip() except OSError: return # not a systemd box; nothing to protect if active == "active":