mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +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
|
||||
|
||||
@@ -0,0 +1,592 @@
|
||||
"""Changes made from the web UI must reach the panel while a screen is showing.
|
||||
|
||||
The main loop applies on-demand requests, the display schedule and brightness
|
||||
once per pass -- once per screen. Screens are long: a dwell can be a minute and
|
||||
a Vegas iteration runs for vegas_scroll.max_cycle_duration (240s here). On a
|
||||
real Pi on 2026-09-23:
|
||||
|
||||
* an on-demand request posted at 10:54:27 was activated at 10:57:24, when the
|
||||
Vegas iteration it arrived during finally ended -- nothing read the mailbox
|
||||
in between, because _check_vegas_interrupt only looked at a flag that the
|
||||
main-loop read sets;
|
||||
* two brightness saves 12s apart inside one 30s screen never showed at all.
|
||||
|
||||
_service_pending_changes is the fix: a throttled pass the dwell sleep, the
|
||||
render loops and the Vegas interrupt check all call. These tests drive those
|
||||
long stretches on a fake clock and check a change lands within one throttle
|
||||
interval -- and that the throttle holds, since the callers run at frame rate.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import types
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.vegas_mode.config import VegasModeConfig
|
||||
from src.vegas_mode.coordinator import VegasModeCoordinator
|
||||
|
||||
VEGAS_ITERATION_SECONDS = 240
|
||||
REQUEST_KEY = 'display_on_demand_request'
|
||||
|
||||
|
||||
class FakeClock:
|
||||
"""A clock that only moves when the code under test sleeps.
|
||||
|
||||
Installed as the ``time`` module of display_controller and the Vegas
|
||||
coordinator only -- patching the real module would also speed up every
|
||||
background thread in the process.
|
||||
"""
|
||||
|
||||
def __init__(self, start=10_000.0):
|
||||
self.t = start
|
||||
self._events = []
|
||||
|
||||
def now(self):
|
||||
return self.t
|
||||
|
||||
def sleep(self, seconds):
|
||||
# Never zero, so a loop that sleeps "the rest of the frame" still
|
||||
# advances when its fake frame took no time.
|
||||
self.t += max(seconds, 0.0005)
|
||||
while self._events and self._events[0][0] <= self.t:
|
||||
_, fn = self._events.pop(0)
|
||||
fn()
|
||||
|
||||
def after(self, seconds, fn):
|
||||
"""Run fn once the clock is `seconds` past now."""
|
||||
self._events.append((self.t + seconds, fn))
|
||||
self._events.sort(key=lambda e: e[0])
|
||||
|
||||
def module(self):
|
||||
return types.SimpleNamespace(time=self.now, monotonic=self.now,
|
||||
perf_counter=self.now, sleep=self.sleep)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clock(monkeypatch):
|
||||
c = FakeClock()
|
||||
fake_time = c.module()
|
||||
monkeypatch.setattr('src.display_controller.time', fake_time)
|
||||
monkeypatch.setattr('src.vegas_mode.coordinator.time', fake_time)
|
||||
return c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def controller(test_display_controller, clock):
|
||||
"""A controller at rest: no schedule, full brightness, empty mailbox."""
|
||||
c = test_display_controller
|
||||
c._refresh_config_cache({'display': {'hardware': {'brightness': 90}}})
|
||||
c.current_brightness = 90
|
||||
c.is_display_active = True
|
||||
c._check_wifi_status_message = MagicMock(return_value=None)
|
||||
|
||||
c.mailbox = {} # what the web process has written
|
||||
|
||||
def cache_get(key, *args, **kwargs):
|
||||
if key == REQUEST_KEY:
|
||||
return c.mailbox.get('request')
|
||||
return None
|
||||
|
||||
def cache_delete(key):
|
||||
if key == REQUEST_KEY:
|
||||
c.mailbox.pop('request', None)
|
||||
|
||||
c.cache_manager.get = MagicMock(side_effect=cache_get)
|
||||
c.cache_manager.delete = MagicMock(side_effect=cache_delete)
|
||||
c.cache_manager.set = MagicMock()
|
||||
c.display_manager.set_brightness = MagicMock(return_value=True)
|
||||
c.display_manager.update_display = MagicMock()
|
||||
|
||||
# Two loaded plugins: the rotation is on one, on-demand can ask for either.
|
||||
for mode in ('clock', 'weather'):
|
||||
c.plugin_display_modes[mode] = [mode]
|
||||
c.plugin_modes[mode] = MagicMock(spec=[])
|
||||
c.mode_to_plugin_id[mode] = mode
|
||||
c.available_modes = ['clock', 'weather']
|
||||
c.current_mode_index = 0
|
||||
c.current_display_mode = 'clock'
|
||||
return c
|
||||
|
||||
|
||||
def post_request(controller, request_id='r1', mode='clock'):
|
||||
controller.mailbox['request'] = {
|
||||
'request_id': request_id, 'action': 'start',
|
||||
'plugin_id': mode, 'mode': mode,
|
||||
}
|
||||
|
||||
|
||||
def save_brightness(controller, brightness):
|
||||
"""What the config watcher thread does when the web UI saves."""
|
||||
controller._controller_config_change(
|
||||
controller.config,
|
||||
{'display': {'hardware': {'brightness': brightness}}})
|
||||
|
||||
|
||||
def mailbox_reads(controller):
|
||||
return sum(1 for call in controller.cache_manager.get.call_args_list
|
||||
if call.args and call.args[0] == REQUEST_KEY)
|
||||
|
||||
|
||||
def vegas_coordinator(controller):
|
||||
"""A coordinator that runs real run_iteration() over a stub renderer.
|
||||
|
||||
Wired to the controller's interrupt checker the way _initialize_vegas_mode
|
||||
wires it (every 10 frames).
|
||||
"""
|
||||
coord = VegasModeCoordinator.__new__(VegasModeCoordinator)
|
||||
coord.vegas_config = VegasModeConfig.from_config({'display': {'vegas_scroll': {
|
||||
'enabled': True, 'max_cycle_duration': VEGAS_ITERATION_SECONDS}}})
|
||||
assert coord.vegas_config.continuous_scroll
|
||||
coord.render_pipeline = MagicMock()
|
||||
coord.stream_manager = MagicMock()
|
||||
coord.display_manager = controller.display_manager
|
||||
coord.stats = {'cycles_completed': 0, 'interruptions': 0}
|
||||
coord._state_lock = threading.Lock()
|
||||
coord._is_active = True
|
||||
coord._is_paused = False
|
||||
coord._should_stop = False
|
||||
coord._fps_last_health_log = 0.0
|
||||
coord._fps_was_degraded = False
|
||||
coord._live_priority_check = None
|
||||
coord._live_priority_active = False
|
||||
coord._update_callback = None
|
||||
coord._update_tick_running = False
|
||||
coord.sync_manager = None
|
||||
coord._update_static_mode_plugins = lambda: None
|
||||
coord._check_static_plugin_trigger = lambda: None
|
||||
coord.frames = 0
|
||||
|
||||
def run_frame():
|
||||
coord.frames += 1
|
||||
return True
|
||||
|
||||
coord.run_frame = run_frame
|
||||
coord.set_interrupt_checker(controller._check_vegas_interrupt, check_interval=10)
|
||||
return coord
|
||||
|
||||
|
||||
def frame_budget(coord):
|
||||
"""Longest a change can wait for the next interrupt check."""
|
||||
return coord._interrupt_check_interval * coord.vegas_config.get_frame_interval()
|
||||
|
||||
|
||||
class TestOnDemandDuringVegas:
|
||||
def test_a_request_mid_iteration_ends_the_iteration_promptly(self, controller, clock):
|
||||
coord = vegas_coordinator(controller)
|
||||
start = clock.t
|
||||
posted = {}
|
||||
|
||||
def post():
|
||||
posted['at'] = clock.t
|
||||
post_request(controller, mode='weather')
|
||||
|
||||
clock.after(30, post)
|
||||
completed = coord.run_iteration()
|
||||
latency = clock.t - posted['at']
|
||||
|
||||
assert completed is False, "Vegas ran the whole iteration past the request"
|
||||
assert controller.on_demand_active
|
||||
assert controller.current_display_mode == 'weather'
|
||||
assert latency <= controller.PENDING_CHANGES_INTERVAL + frame_budget(coord) + 0.01, (
|
||||
f"request took {latency:.2f}s to interrupt Vegas")
|
||||
assert clock.t - start < VEGAS_ITERATION_SECONDS
|
||||
|
||||
def test_the_interrupt_checker_answers_within_one_interval(self, controller, clock):
|
||||
controller._service_pending_changes() # the main loop's last pass
|
||||
post_request(controller)
|
||||
waited = 0.0
|
||||
step = 0.01
|
||||
while not controller._check_vegas_interrupt():
|
||||
clock.t += step
|
||||
waited += step
|
||||
assert waited <= controller.PENDING_CHANGES_INTERVAL + step, (
|
||||
"interrupt checker never saw the request")
|
||||
assert controller.on_demand_active
|
||||
|
||||
def test_an_empty_mailbox_lets_the_iteration_run_out(self, controller, clock):
|
||||
coord = vegas_coordinator(controller)
|
||||
assert coord.run_iteration() is True
|
||||
assert not controller.on_demand_active
|
||||
# And the read is throttled: at most one per interval, not per check.
|
||||
max_reads = VEGAS_ITERATION_SECONDS / controller.PENDING_CHANGES_INTERVAL + 1
|
||||
assert 0 < mailbox_reads(controller) <= max_reads
|
||||
checks = coord.frames // coord._interrupt_check_interval
|
||||
assert mailbox_reads(controller) < checks / 2
|
||||
|
||||
|
||||
class TestBrightnessIsAppliedMidScreen:
|
||||
def _applied_at(self, controller, clock, brightness):
|
||||
"""Clock time set_brightness(brightness) was first called, or None."""
|
||||
return controller.applied.get(brightness)
|
||||
|
||||
def _record_brightness(self, controller, clock):
|
||||
controller.applied = {}
|
||||
|
||||
def set_brightness(value):
|
||||
controller.applied.setdefault(value, clock.t)
|
||||
return True
|
||||
|
||||
controller.display_manager.set_brightness = MagicMock(side_effect=set_brightness)
|
||||
|
||||
def test_during_a_long_dwell(self, controller, clock):
|
||||
"""The observed failure: two saves 12s apart inside one screen."""
|
||||
self._record_brightness(controller, clock)
|
||||
saves = {}
|
||||
|
||||
def save(value):
|
||||
saves[value] = clock.t
|
||||
save_brightness(controller, value)
|
||||
|
||||
clock.after(5, lambda: save(40))
|
||||
clock.after(17, lambda: save(90))
|
||||
controller._sleep_with_plugin_updates(30)
|
||||
|
||||
interval = controller.PENDING_CHANGES_INTERVAL
|
||||
assert 40 in controller.applied, "brightness 40 never reached the panel"
|
||||
assert controller.applied[40] - saves[40] <= interval + 0.01
|
||||
assert controller.applied[90] - saves[90] <= interval + 0.01
|
||||
assert controller.current_brightness == 90
|
||||
|
||||
def test_the_frame_is_repushed_so_the_change_shows(self, controller, clock):
|
||||
clock.after(1, lambda: save_brightness(controller, 40))
|
||||
controller._sleep_with_plugin_updates(5)
|
||||
calls = controller.display_manager.method_calls
|
||||
names = [c[0] for c in calls]
|
||||
assert 'set_brightness' in names
|
||||
assert 'update_display' in names[names.index('set_brightness'):], (
|
||||
"nothing pushed a frame after the brightness change")
|
||||
|
||||
def test_during_a_vegas_iteration(self, controller, clock):
|
||||
self._record_brightness(controller, clock)
|
||||
coord = vegas_coordinator(controller)
|
||||
saved = {}
|
||||
|
||||
def save():
|
||||
saved['at'] = clock.t
|
||||
save_brightness(controller, 25)
|
||||
|
||||
clock.after(60, save)
|
||||
assert coord.run_iteration() is True # brightness does not end the scroll
|
||||
assert 25 in controller.applied, "brightness waited for the iteration to end"
|
||||
assert controller.applied[25] - saved['at'] <= (
|
||||
controller.PENDING_CHANGES_INTERVAL + frame_budget(coord) + 0.01)
|
||||
|
||||
def test_a_dim_schedule_transition_mid_dwell(self, controller, clock):
|
||||
self._record_brightness(controller, clock)
|
||||
controller._refresh_config_cache({
|
||||
'display': {'hardware': {'brightness': 90}},
|
||||
'dim_schedule': {'enabled': True, 'start_time': '20:00',
|
||||
'end_time': '07:00', 'dim_brightness': 20},
|
||||
})
|
||||
wall_start = datetime(2026, 9, 21, 19, 59, 30)
|
||||
t0 = clock.t
|
||||
with patch('src.display_controller.datetime') as mock_dt:
|
||||
mock_dt.strptime = datetime.strptime
|
||||
mock_dt.now.side_effect = lambda tz=None: wall_start + timedelta(seconds=clock.t - t0)
|
||||
controller._sleep_with_plugin_updates(60)
|
||||
|
||||
assert 20 in controller.applied, "the dim period started mid-screen and was missed"
|
||||
dimmed_at = t0 + 30 # 20:00:00
|
||||
assert controller.applied[20] - dimmed_at <= controller.PENDING_CHANGES_INTERVAL + 0.01
|
||||
|
||||
|
||||
class TestThrottle:
|
||||
def test_no_reads_or_brightness_calls_between_passes(self, controller, clock):
|
||||
controller._service_pending_changes()
|
||||
reads = mailbox_reads(controller)
|
||||
save_brightness(controller, 40)
|
||||
post_request(controller)
|
||||
for _ in range(500): # a few seconds of frames, all inside one interval
|
||||
controller._service_pending_changes()
|
||||
clock.t += controller.PENDING_CHANGES_INTERVAL / 1000
|
||||
assert mailbox_reads(controller) == reads
|
||||
controller.display_manager.set_brightness.assert_not_called()
|
||||
assert not controller.on_demand_active
|
||||
|
||||
clock.t += controller.PENDING_CHANGES_INTERVAL
|
||||
controller._service_pending_changes()
|
||||
# The poll, plus the consume step's re-read of the request it acted on.
|
||||
assert mailbox_reads(controller) > reads
|
||||
controller.display_manager.set_brightness.assert_called_once_with(40)
|
||||
assert controller.on_demand_active
|
||||
|
||||
def test_between_passes_it_does_no_work_at_all(self, controller, clock):
|
||||
"""The high-FPS and Vegas loops call this every frame."""
|
||||
controller._service_pending_changes()
|
||||
controller._poll_on_demand_requests = MagicMock()
|
||||
controller._check_dim_schedule = MagicMock(return_value=90)
|
||||
controller._check_schedule = MagicMock()
|
||||
for _ in range(100):
|
||||
controller._service_pending_changes()
|
||||
controller._check_vegas_interrupt()
|
||||
controller._poll_on_demand_requests.assert_not_called()
|
||||
controller._check_dim_schedule.assert_not_called()
|
||||
controller._check_schedule.assert_not_called()
|
||||
|
||||
def test_an_unchanged_brightness_is_not_re_sent(self, controller, clock):
|
||||
for _ in range(20):
|
||||
controller._service_pending_changes()
|
||||
clock.t += controller.PENDING_CHANGES_INTERVAL
|
||||
controller.display_manager.set_brightness.assert_not_called()
|
||||
|
||||
def test_a_refused_brightness_is_not_retried_every_pass(self, controller, clock):
|
||||
controller.display_manager.set_brightness = MagicMock(return_value=False)
|
||||
save_brightness(controller, 40)
|
||||
for _ in range(20):
|
||||
controller._service_pending_changes()
|
||||
clock.t += controller.PENDING_CHANGES_INTERVAL
|
||||
controller.display_manager.set_brightness.assert_called_once_with(40)
|
||||
assert controller.current_brightness == 90
|
||||
# A different target is still tried.
|
||||
save_brightness(controller, 50)
|
||||
controller._service_pending_changes()
|
||||
controller.display_manager.set_brightness.assert_called_with(50)
|
||||
|
||||
def test_a_refused_brightness_is_tried_again_after_going_back(self, controller, clock):
|
||||
controller.display_manager.set_brightness = MagicMock(return_value=False)
|
||||
for value in (40, 90, 40):
|
||||
save_brightness(controller, value)
|
||||
controller._service_pending_changes()
|
||||
clock.t += controller.PENDING_CHANGES_INTERVAL
|
||||
assert controller.display_manager.set_brightness.call_count == 2
|
||||
|
||||
|
||||
class TestScheduleAndDwells:
|
||||
def _schedule_until(self, controller, end_time):
|
||||
controller._refresh_config_cache({
|
||||
'display': {'hardware': {'brightness': 90}},
|
||||
'schedule': {'enabled': True, 'start_time': '07:00', 'end_time': end_time},
|
||||
})
|
||||
|
||||
def test_vegas_hands_back_when_the_display_is_scheduled_off(self, controller, clock):
|
||||
self._schedule_until(controller, '22:59')
|
||||
coord = vegas_coordinator(controller)
|
||||
wall_start = datetime(2026, 9, 21, 22, 59, 0)
|
||||
t0 = clock.t
|
||||
with patch('src.display_controller.datetime') as mock_dt:
|
||||
mock_dt.strptime = datetime.strptime
|
||||
mock_dt.now.side_effect = lambda tz=None: wall_start + timedelta(seconds=clock.t - t0)
|
||||
completed = coord.run_iteration()
|
||||
assert completed is False
|
||||
assert not controller.is_display_active
|
||||
off_at = t0 + 60 # 23:00:00
|
||||
assert clock.t - off_at <= controller.PENDING_CHANGES_INTERVAL + frame_budget(coord) + 0.01
|
||||
|
||||
def test_on_demand_wakes_a_scheduled_off_display_within_an_interval(self, controller, clock):
|
||||
"""The display-off branch sleeps 60s at a time between checks."""
|
||||
controller.is_display_active = False
|
||||
controller._evaluate_schedule = MagicMock() # hold the schedule "off"
|
||||
posted = {}
|
||||
|
||||
def post():
|
||||
posted['at'] = clock.t
|
||||
post_request(controller)
|
||||
|
||||
clock.after(3, post)
|
||||
start = clock.t
|
||||
controller._sleep_with_plugin_updates(60)
|
||||
assert controller.on_demand_active
|
||||
assert clock.t - start < 60, "the off-schedule sleep ran its full minute"
|
||||
assert clock.t - posted['at'] <= controller.PENDING_CHANGES_INTERVAL + 0.01
|
||||
|
||||
def test_the_dwell_returns_early_on_an_on_demand_stop(self, controller, clock):
|
||||
post_request(controller)
|
||||
controller._service_pending_changes()
|
||||
assert controller.on_demand_active
|
||||
clock.t += controller.PENDING_CHANGES_INTERVAL
|
||||
controller.mailbox['request'] = {'request_id': 'r2', 'action': 'stop'}
|
||||
start = clock.t
|
||||
controller._sleep_with_plugin_updates(30)
|
||||
assert not controller.on_demand_active
|
||||
assert clock.t - start <= controller.PENDING_CHANGES_INTERVAL + 0.01
|
||||
|
||||
def test_a_quiet_dwell_runs_its_full_length(self, controller, clock):
|
||||
start = clock.t
|
||||
controller._sleep_with_plugin_updates(30)
|
||||
assert clock.t - start >= 30
|
||||
|
||||
|
||||
class _Plugin:
|
||||
"""A static (non-scrolling) plugin mode that records what was shown."""
|
||||
|
||||
needs_high_fps = False
|
||||
|
||||
def __init__(self, mode, plugin_id, shown, results=()):
|
||||
self.mode = mode
|
||||
self.plugin_id = plugin_id
|
||||
self._shown = shown
|
||||
self._results = iter(results)
|
||||
|
||||
def display(self, force_clear=False):
|
||||
self._shown.append(self.mode)
|
||||
if len(self._shown) > 6:
|
||||
raise KeyboardInterrupt # ends run(); it catches this and cleans up
|
||||
return next(self._results, True)
|
||||
|
||||
|
||||
class TestRunLoopDoesNotSkipTheRequestedMode:
|
||||
def test_on_demand_during_the_minimum_duration_sleep(self, controller, clock):
|
||||
"""A dwell sleep that ends early on an on-demand start must not rotate.
|
||||
|
||||
The first screen shows once, then reports no content, so run() sleeps
|
||||
out the rest of its minimum duration. The request lands during that
|
||||
sleep. Falling through to "move to next mode" advanced the new
|
||||
on-demand rotation past the mode that was asked for.
|
||||
"""
|
||||
c = controller
|
||||
shown = []
|
||||
c.plugin_modes.clear()
|
||||
c.mode_to_plugin_id.clear()
|
||||
c.plugin_display_modes.clear()
|
||||
modes = {'clock': ('clock', [True, False]), 'news': ('news', []),
|
||||
'weather': ('weather', []), 'weather_hourly': ('weather', [])}
|
||||
for mode, (pid, results) in modes.items():
|
||||
c.plugin_modes[mode] = _Plugin(mode, pid, shown, results)
|
||||
c.mode_to_plugin_id[mode] = pid
|
||||
c.plugin_display_modes.setdefault(pid, []).append(mode)
|
||||
c.available_modes = list(modes)
|
||||
c.current_mode_index = 0
|
||||
c._cleanup_expired_wifi_status = MagicMock()
|
||||
c.config.setdefault('display', {})['display_durations'] = {'clock': 30}
|
||||
c.plugin_manager.plugin_executor.execute_display.side_effect = (
|
||||
lambda target, plugin_id, force_clear=False, display_mode=None, **kw:
|
||||
target.display(force_clear=force_clear))
|
||||
clock.after(10, lambda: post_request(c, mode='weather'))
|
||||
|
||||
c.run()
|
||||
|
||||
assert c.on_demand_modes[:2] == ['weather', 'weather_hourly']
|
||||
assert shown[:2] == ['clock', 'clock']
|
||||
assert shown[2] == 'weather', f"on-demand opened on {shown[2]}, not the requested mode"
|
||||
|
||||
|
||||
class _Stop(KeyboardInterrupt):
|
||||
"""Ends run(): it catches KeyboardInterrupt and cleans up."""
|
||||
|
||||
|
||||
class TestRunLoopBlanksWhenVegasHandsBack:
|
||||
def test_scheduled_off_mid_iteration_blanks_instead_of_rendering(self, controller, clock):
|
||||
c = controller
|
||||
shown = []
|
||||
c.plugin_modes['clock'] = _Plugin('clock', 'clock', shown)
|
||||
c.plugin_modes['weather'] = _Plugin('weather', 'weather', shown)
|
||||
c._cleanup_expired_wifi_status = MagicMock()
|
||||
c._refresh_config_cache({
|
||||
'display': {'hardware': {'brightness': 90}},
|
||||
'schedule': {'enabled': True, 'start_time': '07:00', 'end_time': '22:59'},
|
||||
})
|
||||
c.vegas_coordinator = vegas_coordinator(c)
|
||||
c.vegas_coordinator._pending_config_update = False
|
||||
|
||||
def stop():
|
||||
raise _Stop()
|
||||
|
||||
wall_start = datetime(2026, 9, 21, 22, 59, 0)
|
||||
t0 = clock.t
|
||||
clock.after(90, stop)
|
||||
with patch('src.display_controller.datetime') as mock_dt:
|
||||
mock_dt.strptime = datetime.strptime
|
||||
mock_dt.now.side_effect = lambda tz=None: wall_start + timedelta(seconds=clock.t - t0)
|
||||
c.run()
|
||||
|
||||
assert c.vegas_coordinator.frames > 0 # Vegas was running first
|
||||
assert not c.is_display_active
|
||||
assert shown == [], f"rendered {shown} after the display was scheduled off"
|
||||
c.display_manager.clear.assert_called()
|
||||
|
||||
@pytest.mark.parametrize('needs_high_fps', [True, False])
|
||||
def test_a_screen_scheduled_off_midway_stops_rendering(self, controller, clock,
|
||||
needs_high_fps):
|
||||
c = controller
|
||||
shown_at = []
|
||||
plugin = _Plugin('ticker', 'ticker', [])
|
||||
plugin.needs_high_fps = needs_high_fps
|
||||
plugin.display = lambda force_clear=False: shown_at.append(clock.t) or True
|
||||
c.plugin_modes.clear()
|
||||
c.mode_to_plugin_id.clear()
|
||||
c.plugin_display_modes.clear()
|
||||
c.plugin_modes['ticker'] = plugin
|
||||
c.mode_to_plugin_id['ticker'] = 'ticker'
|
||||
c.plugin_display_modes['ticker'] = ['ticker']
|
||||
c.available_modes = ['ticker']
|
||||
c.current_mode_index = 0
|
||||
c._cleanup_expired_wifi_status = MagicMock()
|
||||
c._refresh_config_cache({
|
||||
'display': {'hardware': {'brightness': 90},
|
||||
'display_durations': {'ticker': 120}},
|
||||
'schedule': {'enabled': True, 'start_time': '07:00', 'end_time': '22:59'},
|
||||
})
|
||||
c.plugin_manager.plugin_executor.execute_display.side_effect = (
|
||||
lambda target, plugin_id, force_clear=False, display_mode=None, **kw:
|
||||
target.display(force_clear=force_clear))
|
||||
|
||||
def stop():
|
||||
raise _Stop()
|
||||
|
||||
wall_start = datetime(2026, 9, 21, 22, 59, 0)
|
||||
t0 = clock.t
|
||||
clock.after(150, stop)
|
||||
with patch('src.display_controller.datetime') as mock_dt:
|
||||
mock_dt.strptime = datetime.strptime
|
||||
mock_dt.now.side_effect = lambda tz=None: wall_start + timedelta(seconds=clock.t - t0)
|
||||
c.run()
|
||||
|
||||
off_at = t0 + 60 # 23:00:00, halfway through a 120s screen
|
||||
assert shown_at and shown_at[0] < off_at
|
||||
assert shown_at[-1] - off_at <= 1.0 + c.PENDING_CHANGES_INTERVAL, (
|
||||
f"kept rendering {shown_at[-1] - off_at:.1f}s after the display was scheduled off")
|
||||
assert not c.is_display_active
|
||||
|
||||
|
||||
class TestRunLoopAppliesBrightnessMidScreen:
|
||||
"""The render loops of run() itself, not just the helpers they call."""
|
||||
|
||||
def _run_screen(self, c, clock, needs_high_fps):
|
||||
shown = []
|
||||
plugin = _Plugin('ticker', 'ticker', shown)
|
||||
plugin.needs_high_fps = needs_high_fps
|
||||
plugin.display = lambda force_clear=False: shown.append('ticker') or True
|
||||
c.plugin_modes.clear()
|
||||
c.mode_to_plugin_id.clear()
|
||||
c.plugin_display_modes.clear()
|
||||
c.plugin_modes['ticker'] = plugin
|
||||
c.mode_to_plugin_id['ticker'] = 'ticker'
|
||||
c.plugin_display_modes['ticker'] = ['ticker']
|
||||
c.available_modes = ['ticker']
|
||||
c.current_mode_index = 0
|
||||
c._cleanup_expired_wifi_status = MagicMock()
|
||||
c.config.setdefault('display', {})['display_durations'] = {'ticker': 60}
|
||||
c.plugin_manager.plugin_executor.execute_display.side_effect = (
|
||||
lambda target, plugin_id, force_clear=False, display_mode=None, **kw:
|
||||
target.display(force_clear=force_clear))
|
||||
applied = {}
|
||||
|
||||
def set_brightness(value):
|
||||
applied.setdefault(value, clock.t)
|
||||
return True
|
||||
|
||||
c.display_manager.set_brightness = MagicMock(side_effect=set_brightness)
|
||||
start = clock.t
|
||||
clock.after(5, lambda: save_brightness(c, 30))
|
||||
|
||||
def stop():
|
||||
raise _Stop()
|
||||
|
||||
clock.after(20, stop)
|
||||
c.run()
|
||||
return start, applied, shown
|
||||
|
||||
def test_a_scrolling_screen(self, controller, clock):
|
||||
start, applied, shown = self._run_screen(controller, clock, needs_high_fps=True)
|
||||
assert len(shown) > 100 # really was the high-FPS loop
|
||||
assert 30 in applied, "brightness waited for the scrolling screen to end"
|
||||
assert applied[30] - (start + 5) <= controller.PENDING_CHANGES_INTERVAL + 0.02
|
||||
|
||||
def test_a_static_screen(self, controller, clock):
|
||||
start, applied, shown = self._run_screen(controller, clock, needs_high_fps=False)
|
||||
assert len(shown) < 30 # really was the once-a-second loop
|
||||
assert 30 in applied, "brightness waited for the static screen to end"
|
||||
# This loop redraws once a second, and services changes after each redraw.
|
||||
assert applied[30] - (start + 5) <= 1.0 + controller.PENDING_CHANGES_INTERVAL
|
||||
Reference in New Issue
Block a user