mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
fix(display): apply on-demand, brightness and schedule changes mid-screen (#618)
* fix(display): apply on-demand, brightness and schedule changes mid-screen The main loop read the on-demand mailbox, the on/off schedule and the brightness target once per pass -- once per screen. A dwell can be a minute and a Vegas iteration runs for max_cycle_duration (240s), so on a Pi an on-demand request posted at 10:54:27 was activated at 10:57:24, and two brightness saves 12s apart inside one 30s screen never reached the panel. During Vegas nothing read the mailbox at all: _check_vegas_interrupt only checked on_demand_active, which only the main-loop read sets. _service_pending_changes does the main loop's on-demand poll, expiry, schedule and brightness steps, throttled to PENDING_CHANGES_INTERVAL (the existing 0.25s mailbox floor), on the display thread. It runs from the Vegas interrupt checker, the high-FPS and once-a-second render loops (replacing their direct on-demand poll) and _sleep_with_plugin_updates; between passes it costs one monotonic compare. A brightness change re-pushes the current frame, since the panel only shows it from the next push. Callers act on what it leaves behind: Vegas yields on an on-demand start or the display being scheduled off (and the main loop then blanks instead of rendering a screen), the render loops break on a schedule-off as they already did on a mode change, and the dwell sleep returns early on an on-demand start/stop or a schedule flip -- so the 60s scheduled-off sleep now wakes for an on-demand request. The main loop no longer rotates after a dwell that ended that way, which advanced a new on-demand session past the mode that was asked for. A brightness set_brightness() refuses is not retried until the target changes, so the 4Hz pass doesn't log the same failure (fallback mode) four times a second. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(display): a screen scheduled off midway stops rendering Covers the schedule-off break added to the high-FPS and once-a-second render loops: with the display scheduled off halfway through a 120s screen, neither loop renders for more than one redraw plus one service interval past the boundary. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+140
-24
@@ -207,6 +207,12 @@ class DisplayController:
|
||||
# _poll_on_demand_requests. None means "never polled", so the first
|
||||
# call always goes through.
|
||||
self._last_on_demand_poll: Optional[float] = None
|
||||
# Monotonic stamp of the last _service_pending_changes pass; same
|
||||
# "None means never" convention as _last_on_demand_poll.
|
||||
self._last_pending_service: Optional[float] = None
|
||||
# A brightness set_brightness() refused, so the periodic service pass
|
||||
# doesn't retry (and log) the same failure several times a second.
|
||||
self._failed_brightness_target: Optional[int] = None
|
||||
self.on_demand_active = False
|
||||
self.on_demand_mode: Optional[str] = None
|
||||
self.on_demand_modes: List[str] = [] # All modes for the on-demand plugin
|
||||
@@ -581,10 +587,22 @@ class DisplayController:
|
||||
Returns:
|
||||
True if Vegas should yield control, False to continue
|
||||
"""
|
||||
# A Vegas iteration runs for up to max_cycle_duration (240s by
|
||||
# default) without returning to the main loop, and this is the only
|
||||
# code of ours it calls while it does. Without servicing here, an
|
||||
# on-demand request was never even read until the iteration ended --
|
||||
# on_demand_active below is only set by that read -- and a saved
|
||||
# brightness or a schedule boundary waited just as long.
|
||||
self._service_pending_changes()
|
||||
|
||||
# Check for pending on-demand request
|
||||
if self.on_demand_active:
|
||||
return True
|
||||
|
||||
# Scheduled off mid-iteration: hand back so the main loop blanks it.
|
||||
if not self.is_display_active:
|
||||
return True
|
||||
|
||||
# Check for wifi status that needs display
|
||||
if self._check_wifi_status_message():
|
||||
return True
|
||||
@@ -1004,12 +1022,22 @@ class DisplayController:
|
||||
self.sync_manager.send_frame(follower_frame)
|
||||
|
||||
def _sleep_with_plugin_updates(self, duration: float, tick_interval: float = 1.0):
|
||||
"""Sleep while continuing to service plugin update schedules."""
|
||||
"""Sleep while continuing to service plugin update schedules.
|
||||
|
||||
Also services pending changes (see _service_pending_changes), and
|
||||
returns early when one of them changes what the panel should show --
|
||||
an on-demand start or stop, or the display schedule turning the panel
|
||||
on or off -- so the caller can act on it instead of finishing a dwell
|
||||
that could be a minute long (sixty seconds while scheduled off).
|
||||
"""
|
||||
if duration <= 0:
|
||||
return
|
||||
|
||||
end_time = time.time() + duration
|
||||
tick_interval = max(0.001, tick_interval)
|
||||
tick_interval = max(0.001, min(tick_interval, self.PENDING_CHANGES_INTERVAL))
|
||||
mode = self.current_display_mode
|
||||
display_active = self.is_display_active
|
||||
on_demand = self.on_demand_active
|
||||
|
||||
while True:
|
||||
remaining = end_time - time.time()
|
||||
@@ -1019,6 +1047,11 @@ class DisplayController:
|
||||
sleep_time = min(tick_interval, remaining)
|
||||
time.sleep(sleep_time)
|
||||
self._tick_plugin_updates()
|
||||
self._service_pending_changes()
|
||||
if (self.current_display_mode != mode
|
||||
or self.is_display_active != display_active
|
||||
or self.on_demand_active != on_demand):
|
||||
break
|
||||
|
||||
def _get_display_duration(self, mode_key):
|
||||
"""Seconds to show a mode: the Rotation & Durations page's value for it
|
||||
@@ -1215,6 +1248,83 @@ class DisplayController:
|
||||
#: perceptible, and it cuts the read rate by 30x.
|
||||
ON_DEMAND_POLL_INTERVAL = 0.25
|
||||
|
||||
#: Shortest gap between _service_pending_changes passes. The same floor as
|
||||
#: the mailbox poll, since that read is the only real cost in the pass:
|
||||
#: the schedule checks are gated to once per clock minute and the rest is
|
||||
#: attribute compares. Callers run at frame rate, so between passes the
|
||||
#: whole cost is one monotonic-clock compare.
|
||||
PENDING_CHANGES_INTERVAL = ON_DEMAND_POLL_INTERVAL
|
||||
|
||||
def _service_pending_changes(self) -> None:
|
||||
"""Apply changes made elsewhere while the display thread is busy.
|
||||
|
||||
The main loop applies on-demand requests, the display on/off schedule
|
||||
and brightness (a saved display.hardware.brightness, or a dim-schedule
|
||||
transition) once per pass -- that is, once per screen. A screen can
|
||||
dwell a minute and a Vegas iteration four, so those waited just as
|
||||
long: an on-demand request sat unread for up to 240s, and a brightness
|
||||
saved and reverted within one screen never reached the panel at all.
|
||||
|
||||
So the places the display thread spends long stretches -- the dwell
|
||||
sleep, the per-screen render loops and Vegas's interrupt check -- call
|
||||
this. It is throttled to PENDING_CHANGES_INTERVAL, runs on the display
|
||||
thread (set_brightness must not be called from the config watcher),
|
||||
and does what the main loop does, in the same order. Callers decide
|
||||
what to do about the result from the state it leaves behind
|
||||
(on_demand_active, current_display_mode, is_display_active).
|
||||
"""
|
||||
now = time.monotonic()
|
||||
last = self._last_pending_service
|
||||
if last is not None and now - last < self.PENDING_CHANGES_INTERVAL:
|
||||
return
|
||||
self._last_pending_service = now
|
||||
|
||||
try:
|
||||
self._poll_on_demand_requests()
|
||||
self._check_on_demand_expiration()
|
||||
self._evaluate_schedule()
|
||||
self._apply_brightness_target(repaint=True)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
# Called from inside Vegas and the render loops; a failure here
|
||||
# must not take the display loop down with it.
|
||||
logger.exception("Error servicing pending display changes")
|
||||
|
||||
def _evaluate_schedule(self) -> None:
|
||||
"""Re-check the on/off schedule, letting an on-demand session override it."""
|
||||
self._check_schedule()
|
||||
if self.on_demand_active and not self.is_display_active:
|
||||
if not self.on_demand_schedule_override:
|
||||
logger.info("On-demand override keeping display active during scheduled downtime")
|
||||
self.on_demand_schedule_override = True
|
||||
self.is_display_active = True
|
||||
elif not self.on_demand_active and self.on_demand_schedule_override:
|
||||
self.on_demand_schedule_override = False
|
||||
|
||||
def _apply_brightness_target(self, repaint: bool = False) -> None:
|
||||
"""Push the brightness the config and dim schedule call for, if it changed.
|
||||
|
||||
Args:
|
||||
repaint: Re-push the current frame afterwards. The panel only shows
|
||||
a new brightness from the next frame pushed; the main loop is
|
||||
about to render one, but a dwell or a static screen may not
|
||||
push again for a minute.
|
||||
"""
|
||||
if not self.is_display_active:
|
||||
return
|
||||
target = self._check_dim_schedule()
|
||||
if target == self.current_brightness:
|
||||
self._failed_brightness_target = None
|
||||
return
|
||||
if target == self._failed_brightness_target:
|
||||
return
|
||||
if self.display_manager.set_brightness(target):
|
||||
self.current_brightness = target
|
||||
self._failed_brightness_target = None
|
||||
if repaint:
|
||||
self.display_manager.update_display()
|
||||
else:
|
||||
self._failed_brightness_target = target
|
||||
|
||||
def _select_startup_plugins(self, discovered_plugins: List[str],
|
||||
on_demand_config: Optional[Dict[str, Any]]) -> List[str]:
|
||||
"""Which plugins to load at startup, restoring on-demand state if any.
|
||||
@@ -1845,21 +1955,11 @@ class DisplayController:
|
||||
self._log_memory_stats_if_due()
|
||||
|
||||
# Check the schedule
|
||||
self._check_schedule()
|
||||
if self.on_demand_active and not self.is_display_active:
|
||||
if not self.on_demand_schedule_override:
|
||||
logger.info("On-demand override keeping display active during scheduled downtime")
|
||||
self.on_demand_schedule_override = True
|
||||
self.is_display_active = True
|
||||
elif not self.on_demand_active and self.on_demand_schedule_override:
|
||||
self.on_demand_schedule_override = False
|
||||
self._evaluate_schedule()
|
||||
|
||||
# Check dim schedule and apply brightness (only when display is active)
|
||||
if self.is_display_active:
|
||||
target_brightness = self._check_dim_schedule()
|
||||
if target_brightness != self.current_brightness:
|
||||
if self.display_manager.set_brightness(target_brightness):
|
||||
self.current_brightness = target_brightness
|
||||
# Check dim schedule and apply brightness (only when display
|
||||
# is active). No repaint: this screen's first frame pushes it.
|
||||
self._apply_brightness_target()
|
||||
|
||||
if not self.is_display_active:
|
||||
# Clear display when schedule makes it inactive to ensure blank screen
|
||||
@@ -2010,6 +2110,10 @@ class DisplayController:
|
||||
else:
|
||||
# Vegas was interrupted (live priority), fall through to normal handling
|
||||
logger.debug("Vegas mode interrupted, falling back to normal rotation")
|
||||
if not self.is_display_active:
|
||||
# Scheduled off mid-iteration: blank the
|
||||
# panel now rather than render a screen.
|
||||
continue
|
||||
except Exception:
|
||||
logger.exception("Vegas mode error")
|
||||
# Fall through to normal rotation on error
|
||||
@@ -2473,8 +2577,8 @@ class DisplayController:
|
||||
self._send_follower_frame(manager_to_display)
|
||||
|
||||
self._tick_plugin_updates()
|
||||
self._poll_on_demand_requests()
|
||||
self._check_on_demand_expiration()
|
||||
# Throttled: one clock compare between passes.
|
||||
self._service_pending_changes()
|
||||
|
||||
# Pace to the frame deadline rather than sleeping a flat
|
||||
# interval on top of the work. display() has already
|
||||
@@ -2492,7 +2596,8 @@ class DisplayController:
|
||||
# update threads and the web UI are not starved of the GIL.
|
||||
time.sleep(_remaining if _remaining > 0 else 0.001)
|
||||
|
||||
if self.current_display_mode != active_mode:
|
||||
if (self.current_display_mode != active_mode
|
||||
or not self.is_display_active):
|
||||
logger.debug("Mode changed during high-FPS loop, breaking early")
|
||||
break
|
||||
|
||||
@@ -2570,9 +2675,9 @@ class DisplayController:
|
||||
# Multi-display sync: send follower frame after each render
|
||||
self._send_follower_frame(manager_to_display)
|
||||
|
||||
self._poll_on_demand_requests()
|
||||
self._check_on_demand_expiration()
|
||||
if self.current_display_mode != active_mode:
|
||||
self._service_pending_changes()
|
||||
if (self.current_display_mode != active_mode
|
||||
or not self.is_display_active):
|
||||
logger.info("Mode changed during display loop from %s to %s, breaking early", active_mode, self.current_display_mode)
|
||||
break
|
||||
|
||||
@@ -2599,7 +2704,10 @@ class DisplayController:
|
||||
# Removing it again will silently reintroduce both issues. _activate_on_demand
|
||||
# already sets force_change=True and clears the display, so the next loop
|
||||
# iteration renders the new mode immediately.
|
||||
if self.current_display_mode != active_mode:
|
||||
# Likewise if the schedule turned the display off
|
||||
# mid-screen: the next iteration blanks it.
|
||||
if (self.current_display_mode != active_mode
|
||||
or not self.is_display_active):
|
||||
continue
|
||||
|
||||
# Ensure we honour minimum duration when not dynamic and loop ended early
|
||||
@@ -2647,7 +2755,15 @@ class DisplayController:
|
||||
else:
|
||||
# For non-plugin modes, use the original behavior
|
||||
self._sleep_with_plugin_updates(max_duration)
|
||||
|
||||
|
||||
# The dwell sleeps above return early when a pending change
|
||||
# (on-demand started or stopped, display scheduled off) has
|
||||
# already decided what comes next; rotating now would skip it
|
||||
# -- an on-demand start would advance past the requested mode.
|
||||
if (self.current_display_mode != active_mode
|
||||
or not self.is_display_active):
|
||||
continue
|
||||
|
||||
# Move to next mode
|
||||
if self.on_demand_active:
|
||||
# Guard against empty on_demand_modes to prevent ZeroDivisionError
|
||||
|
||||
Reference in New Issue
Block a user