镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 11 additions & 18 deletions docs/SCROLL_PERFORMANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions src/display_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
63 changes: 63 additions & 0 deletions test/test_handover_scroll_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading