diff --git a/CHANGELOG.md b/CHANGELOG.md index 5319b1dcf..329c86429 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -108,6 +108,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 148d8f795..56112e920 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 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. +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. + +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 6ae459a3e..39a96ac8d 100644 --- a/src/display_controller.py +++ b/src/display_controller.py @@ -2400,6 +2400,7 @@ def _blank_while_scheduled_off(self) -> None: """ # 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() @@ -2500,6 +2501,7 @@ def _show_wifi_notice(self) -> bool: 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 @@ -2606,6 +2608,22 @@ def _start_screen_handover(self, plugin, active_mode: str) -> bool: 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 cc031a367..aa060afcd 100644 --- a/test/test_handover_scroll_state.py +++ b/test/test_handover_scroll_state.py @@ -639,3 +639,66 @@ def needs_high_fps(self, value): # 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