diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ef4773d..d9cd2ddc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -147,6 +147,15 @@ policies are unchanged. ### Fixes +- The schedule-off blank and the WiFi notice no longer start with a + scroller's leftovers. Both are drawn by the display controller rather than + dispatched to a plugin, so #716's handover never reached them: drawn while + the last scroll's state was still set, the blank went out with the + ticker's lagging rows on a scan-compensated panel and stayed up for its + 60 s dwell, and the notice's redraws (which #712 now shows over a running + scroller or Vegas) were counted as 0.5-1 s freezes and logged as a + `Render stall ... mid-scroll`. The controller now ends the scroll state + before drawing either. - A plugin whose `display()` raises now opens its circuit breaker. The first frame of each screen goes through the plugin executor, which caught the exception and returned False. The display read that as "no content" and diff --git a/docs/SCROLL_PERFORMANCE.md b/docs/SCROLL_PERFORMANCE.md index 148d8f79..56112e92 100644 --- a/docs/SCROLL_PERFORMANCE.md +++ b/docs/SCROLL_PERFORMANCE.md @@ -554,24 +554,17 @@ its first `display()`, so the frames that call presents go out as drawn, in one swap each, instead of with the lagging half taken from the scroller's last frame. -Other screens that follow a scroll still are, while the scroll state lasts (it -expires 2 s after the scroller's last frame). Their first frame takes its -lagging half from the scroller's last frame: for one refresh after a held -scroll, and after a scroll at one frame per refresh until the next frame -replaces it. They are: +The controller's own screens -- the blank shown when the schedule turns the +panel off, and the WiFi status message -- end the scroll state before they are +drawn, so they go out as drawn and are timed as static frames, not as freezes +of the old scroll. A scroller that resumes after a WiFi notice sets the state +again on its next frame. -- the blank shown when the schedule turns the panel off. It is redrawn once a - minute while the panel is off, so half of the scroller's last frame can stay - lit for up to 60 s; -- the WiFi status message, until the next pass half a second later; -- a screen that runs the high-FPS loop without scrolling (an older - `static-image`, which is forced into it), until its next frame. - -The frame stats still time those two as frames of the old scroll: a WiFi -notice that preempts a scroller records up to three 0.5-1 s freezes, and the -schedule-off blank a `Render stall ... mid-scroll`. Ending the scroll state -before the schedule-off blank and the WiFi message is a follow-up, the -schedule-off blank first. +One screen that follows a scroll is still composed while the scroll state lasts +(it expires 2 s after the scroller's last frame): a screen that runs the +high-FPS loop without scrolling (an older `static-image`, which is forced into +it). Its first frame takes its lagging half from the scroller's last frame, for +one refresh after a held scroll and otherwise until its next frame. Checked on hdpi (4×128×64 on one chain, rotated 180, 2026-09-24) before it was written: `scan_mode: 1` (interlaced) made the step vanish but turned moving diff --git a/src/display_controller.py b/src/display_controller.py index ea42ce52..e1173a0f 100644 --- a/src/display_controller.py +++ b/src/display_controller.py @@ -2592,6 +2592,7 @@ class DisplayController: """ # Clear display when schedule makes it inactive to ensure blank screen # (not showing initialization screen) + self._end_scroll_before_core_screen() try: self.display_manager.clear() self.display_manager.update_display() @@ -2692,6 +2693,7 @@ class DisplayController: wifi_status_data = self._check_wifi_status_message() if not wifi_status_data: return False + self._end_scroll_before_core_screen() if not self._display_wifi_status_message(wifi_status_data): # Display failed, clear the status and continue normally return False @@ -2798,6 +2800,22 @@ class DisplayController: end_scroll() return static_screen + def _end_scroll_before_core_screen(self) -> None: + """End any scroll before the controller draws a screen of its own. + + The schedule-off blank and the WiFi notice are drawn by the + controller, not by a plugin, so they never pass through + _dispatch_first_frame and its handover. Drawn while the last scroll's state is still set, the blank + went out with the scroller's lagging rows (on a scan-compensated + panel) for its 60 s dwell, and the notice's redraws were timed as + freezes of the old scroll. Ending the state first sends them out as + drawn, at hold 1, as static frames. A scroller that resumes sets the + state again on its next frame. + """ + set_scrolling_state = getattr(self.display_manager, 'set_scrolling_state', None) + if set_scrolling_state is not None: + set_scrolling_state(False) + def _note_screen_handover(self) -> None: """Tag the frame the first dispatch is about to present. diff --git a/test/test_handover_scroll_state.py b/test/test_handover_scroll_state.py index cc031a36..aa060afc 100644 --- a/test/test_handover_scroll_state.py +++ b/test/test_handover_scroll_state.py @@ -639,3 +639,66 @@ class TestRunLoop: # Undecidable, so the broken screen's turn left the scroll state alone. _, turn = _turn(c.events, "broken") assert ("scrolling", False) not in turn + + +def _core_screen_controller(dm): + """Just enough of a DisplayController to draw its own screens on ``dm``.""" + from src.display_controller import DisplayController + c = MagicMock() + c.display_manager = dm + c.on_demand_active = False + c._end_scroll_before_core_screen = types.MethodType( + DisplayController._end_scroll_before_core_screen, c) + return c + + +def _record_scrolling(dm, monkeypatch): + """The ``scrolling`` flag each presented frame is timed with.""" + flags = [] + real = dm.frame_timing.record + + def record(*args, **kwargs): + flags.append(args[3]) + return real(*args, **kwargs) + monkeypatch.setattr(dm.frame_timing, "record", record) + return flags + + +class TestTheControllersOwnScreens: + """The schedule-off blank and the WiFi notice are drawn by the controller, + not dispatched to a plugin, so they end the scroll themselves.""" + + def test_the_schedule_off_blank_shows_none_of_the_ticker(self, dm, monkeypatch): + from src.display_controller import DisplayController + dm._scan_lag_bands = [(24, 48, 1)] + dm.set_scrolling_state(True, 1) + _push(dm, (255, 0, 0)) # the ticker's last frame + flags = _record_scrolling(dm, monkeypatch) + c = _core_screen_controller(dm) + DisplayController._blank_while_scheduled_off(c) + assert dm._presented[-1].getpixel((10, 30)) == (0, 0, 0) + assert flags == [False] # a static frame, not a freeze + assert not dm.is_currently_scrolling() + c._sleep_with_plugin_updates.assert_called_once_with(60) + + def test_the_wifi_notice_shows_none_of_the_ticker(self, dm, monkeypatch): + from src.display_controller import DisplayController + dm._scan_lag_bands = [(24, 48, 1)] + dm.set_scrolling_state(True, 1) + _push(dm, (255, 0, 0)) + flags = _record_scrolling(dm, monkeypatch) + c = _core_screen_controller(dm) + c._check_wifi_status_message.return_value = {"message": "x", "expires_at": 1e12} + c._display_wifi_status_message.side_effect = lambda _s: _push(dm, (0, 0, 255)) and True + assert DisplayController._show_wifi_notice(c) is True + assert dm._presented[-1].getpixel((10, 30)) == (0, 0, 255) + assert flags == [False] + + def test_no_notice_leaves_the_scroll_alone(self, dm): + from src.display_controller import DisplayController + dm.set_scrolling_state(True, 2) + c = _core_screen_controller(dm) + c._check_wifi_status_message.return_value = None + assert DisplayController._show_wifi_notice(c) is False + assert dm.is_currently_scrolling() + assert dm._frame_hold == 2