mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 22:35:08 +00:00
fix(display): end the scroll state at scroller-to-static handovers (#716)
A static plugin screen that follows a scroller no longer starts with the ticker's lagging rows on scan-compensated panels, and the 1 Hz loop's second frame is no longer recorded as a ~1 s mid-scroll freeze / Render stall. The display controller calls DisplayManager.end_scroll_for_static_screen() before a static screen's first display() (clears the scan history; _scan_segments passes its frames through in one swap) and set_scrolling_state(False) after it; the scroller's hold stays until then, so late-frame counts are unchanged. A screen's first frame is tagged 'handover': gaps of 250 ms or more before it go to the additive handover_freezes (frame_soak prints 'Handover gaps'), not freezes. The display thread is named display-<plugin id>. The WiFi notice and the schedule-off blank are not covered yet (docs list them as a follow-up). ledpi A B B A soak (20 min each, --preview): main 0.118% / 0.113% late with 6 / 3 freezes; with this and #717 0.107% / 0.104% late, 0 freezes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+100
-13
@@ -42,6 +42,7 @@ from src.cache_manager import CacheManager
|
||||
from src.font_manager import FontManager
|
||||
from src.logging_config import get_logger
|
||||
from src.exceptions import PluginError
|
||||
from src.common.frame_timing import HANDOVER_OP
|
||||
from src.common.sync_manager import DisplaySyncManager, SyncRole
|
||||
from src.ipc.server import ControlServer, start_control_server
|
||||
from src.vegas_mode.render_pipeline import SYNC_SEND_INTERVAL
|
||||
@@ -2571,6 +2572,70 @@ class DisplayController:
|
||||
logger.debug(f"Found plugin manager for mode {mode}: {type(plugin_instance).__name__}")
|
||||
return plugin_instance
|
||||
|
||||
def _start_screen_handover(self, plugin, active_mode: str) -> bool:
|
||||
"""Before a screen's first dispatch: if the screen is static, keep
|
||||
the last scroll's leftovers off its first frame.
|
||||
|
||||
Nothing else ends a scroll when the rotation moves on: the state
|
||||
expires 2 s after the scroller's last frame. Left to that, a static
|
||||
screen's first frame -- up for a whole second -- went out, on a panel
|
||||
with scan-order compensation, with rows taken from the scroller's
|
||||
last frame (after a held scroll, for its first refresh); and its
|
||||
second frame, 1 s later, was still "mid-scroll", so the frame-timing
|
||||
soak counted a 1-2 s freeze and the stall watchdog logged a "Render
|
||||
stall" at every scroller-to-static handover. See
|
||||
DisplayManager.end_scroll_for_static_screen.
|
||||
|
||||
Returns whether the screen is static, for _finish_screen_handover.
|
||||
False when that cannot be told, which leaves the scroll state as it
|
||||
was before this existed.
|
||||
"""
|
||||
try:
|
||||
static_screen = not self._needs_high_fps(plugin, active_mode, log=False)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
# A plugin property raising. The FPS check after the dispatch is
|
||||
# where that is reported; here it only means "leave it alone".
|
||||
logger.debug("Could not tell whether %s is static before its first frame",
|
||||
active_mode, exc_info=True)
|
||||
return False
|
||||
if static_screen:
|
||||
end_scroll = getattr(self.display_manager, 'end_scroll_for_static_screen', None)
|
||||
if end_scroll is not None:
|
||||
end_scroll()
|
||||
return static_screen
|
||||
|
||||
def _note_screen_handover(self) -> None:
|
||||
"""Tag the frame the first dispatch is about to present.
|
||||
|
||||
The gap from the last screen's final frame to it is the next screen
|
||||
drawing, not a scroll freezing: frame_timing counts it apart from
|
||||
the freezes, and the stall watchdog labels it a handover gap.
|
||||
"""
|
||||
recorder = getattr(self.display_manager, 'frame_timing', None)
|
||||
note = getattr(recorder, 'note_op', None)
|
||||
if note is not None:
|
||||
note(HANDOVER_OP)
|
||||
|
||||
def _finish_screen_handover(self, static_screen: bool) -> None:
|
||||
"""After a screen's first dispatch, whatever it returned.
|
||||
|
||||
Drops the handover tag if no frame took it (a screen with nothing to
|
||||
show), so it cannot land on an unrelated frame later. For a static
|
||||
screen, also ends the previous scroll now, whether or not it showed
|
||||
anything: its first frame has gone out, and with the state left set
|
||||
its next one -- a second later in the 1 Hz loop -- would be timed as
|
||||
a frame of the old scroll.
|
||||
"""
|
||||
dm = self.display_manager
|
||||
recorder = getattr(dm, 'frame_timing', None)
|
||||
drop = getattr(recorder, 'drop_op', None)
|
||||
if drop is not None:
|
||||
drop(HANDOVER_OP)
|
||||
if static_screen:
|
||||
set_scrolling_state = getattr(dm, 'set_scrolling_state', None)
|
||||
if set_scrolling_state is not None:
|
||||
set_scrolling_state(False)
|
||||
|
||||
def _dispatch_first_frame(self, plugin, active_mode: str) -> Tuple[bool, bool, bool]:
|
||||
"""Draw the first frame of a screen through the PluginExecutor.
|
||||
|
||||
@@ -2592,6 +2657,10 @@ class DisplayController:
|
||||
display_failed_due_to_exception = False
|
||||
_accepts_display_mode = False
|
||||
plugin_id = getattr(plugin, 'plugin_id', active_mode)
|
||||
# Decided before the first frame rather than at run()'s FPS check
|
||||
# after it, by when that frame has gone out with the last scroll's
|
||||
# rows. See _start_screen_handover.
|
||||
static_screen = self._start_screen_handover(plugin, active_mode)
|
||||
try:
|
||||
logger.debug(f"Calling display() for {active_mode} with force_clear={self.force_change}")
|
||||
if plugin_id not in self._plugin_accepts_display_mode:
|
||||
@@ -2606,6 +2675,10 @@ class DisplayController:
|
||||
display_hung = False
|
||||
# Set when display() raised inside the executor.
|
||||
display_error: Optional[Exception] = None
|
||||
if can_display:
|
||||
# Only when display() will run: a busy plugin
|
||||
# presents nothing for the tag to land on.
|
||||
self._note_screen_handover()
|
||||
|
||||
if display_lock is None:
|
||||
# Only when plugin loading failed part-way.
|
||||
@@ -2729,6 +2802,10 @@ class DisplayController:
|
||||
self.force_change = True
|
||||
display_result = False
|
||||
display_failed_due_to_exception = True
|
||||
# Whatever the dispatch did -- drew, had nothing to show, raised
|
||||
# inside the executor or out here -- and after the health record,
|
||||
# before the 1 Hz loop or the next mode.
|
||||
self._finish_screen_handover(static_screen)
|
||||
return display_result, display_failed_due_to_exception, _accepts_display_mode
|
||||
|
||||
def _skip_failed_plugin_modes(self, active_mode: str) -> bool:
|
||||
@@ -2878,7 +2955,7 @@ class DisplayController:
|
||||
return None
|
||||
return min_duration, max_duration
|
||||
|
||||
def _needs_high_fps(self, plugin, active_mode: str) -> bool:
|
||||
def _needs_high_fps(self, plugin, active_mode: str, log: bool = True) -> bool:
|
||||
"""Whether a screen runs the high-FPS (8 ms) loop or the 1 s one.
|
||||
|
||||
In precedence order:
|
||||
@@ -2889,29 +2966,36 @@ class DisplayController:
|
||||
the attribute keep the historical forced high-FPS
|
||||
(GIF support).
|
||||
3. Otherwise scrolling plugins get high FPS.
|
||||
|
||||
``log=False`` for the look taken before a screen's first dispatch
|
||||
(see _start_screen_handover): the FPS check after it logs the
|
||||
decision, and once per screen is enough.
|
||||
"""
|
||||
plugin_id = getattr(plugin, 'plugin_id', None)
|
||||
declared = getattr(plugin, 'needs_high_fps', None)
|
||||
if declared is not None:
|
||||
needs_high_fps = bool(declared)
|
||||
logger.debug(
|
||||
"[DisplayController] FPS check for %s (plugin=%s) - "
|
||||
"plugin declares needs_high_fps=%s",
|
||||
active_mode, plugin_id, needs_high_fps)
|
||||
if log:
|
||||
logger.debug(
|
||||
"[DisplayController] FPS check for %s (plugin=%s) - "
|
||||
"plugin declares needs_high_fps=%s",
|
||||
active_mode, plugin_id, needs_high_fps)
|
||||
elif plugin_id == 'static-image':
|
||||
needs_high_fps = True
|
||||
logger.debug("FPS check - static-image plugin: forcing high-FPS mode for GIF support")
|
||||
if log:
|
||||
logger.debug("FPS check - static-image plugin: forcing high-FPS mode for GIF support")
|
||||
else:
|
||||
has_enable_scrolling = hasattr(plugin, 'enable_scrolling')
|
||||
enable_scrolling_value = getattr(plugin, 'enable_scrolling', False)
|
||||
needs_high_fps = has_enable_scrolling and enable_scrolling_value
|
||||
logger.info(
|
||||
"FPS check for %s - has_enable_scrolling: %s, enable_scrolling_value: %s, needs_high_fps: %s",
|
||||
active_mode,
|
||||
has_enable_scrolling,
|
||||
enable_scrolling_value,
|
||||
needs_high_fps,
|
||||
)
|
||||
if log:
|
||||
logger.info(
|
||||
"FPS check for %s - has_enable_scrolling: %s, enable_scrolling_value: %s, needs_high_fps: %s",
|
||||
active_mode,
|
||||
has_enable_scrolling,
|
||||
enable_scrolling_value,
|
||||
needs_high_fps,
|
||||
)
|
||||
return needs_high_fps
|
||||
|
||||
def _advance_after_screen(self, active_mode: Optional[str]) -> None:
|
||||
@@ -3194,6 +3278,9 @@ class DisplayController:
|
||||
continue
|
||||
min_duration, max_duration = bounds
|
||||
|
||||
# High-FPS decision; see _needs_high_fps for the order.
|
||||
# Read again here, after the first dispatch, as it always
|
||||
# was: a plugin may settle it in that display() call.
|
||||
needs_high_fps = self._needs_high_fps(manager_to_display, active_mode)
|
||||
|
||||
target_duration = max_duration
|
||||
|
||||
Reference in New Issue
Block a user