From cd5a4e2251b89c5b6f0aa4bc811bd82d0efd9e7f Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:58:39 -0400 Subject: [PATCH] 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 * 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 --------- Co-authored-by: Claude Opus 5.5 --- src/display_controller.py | 164 ++++++-- test/test_display_pending_changes.py | 592 +++++++++++++++++++++++++++ 2 files changed, 732 insertions(+), 24 deletions(-) create mode 100644 test/test_display_pending_changes.py diff --git a/src/display_controller.py b/src/display_controller.py index dea182fa..1124f01b 100644 --- a/src/display_controller.py +++ b/src/display_controller.py @@ -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 diff --git a/test/test_display_pending_changes.py b/test/test_display_pending_changes.py new file mode 100644 index 00000000..8a4e4684 --- /dev/null +++ b/test/test_display_pending_changes.py @@ -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