mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
* fix(display): tear down Vegas mode on controller cleanup DisplayController.cleanup() never called VegasModeCoordinator.cleanup(), so the Vegas teardown (stop, pipeline/stream reset, adapter cache drop) was unreachable. Call it before the display manager is cleaned up, and skip it when Vegas was never created. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(vegas): default max_cycle_duration to the documented 240s The template, the web UI help, CONFIG_REFERENCE and the controller all say 240, but the code defaulted to 600 in two places, so a config without the key ran Vegas iterations 2.5x longer than documented. from_config now falls back to the dataclass field defaults instead of repeating each one, so the two copies can no longer drift, and the controller's follower scroll-speed default reads VegasModeConfig's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(display): let run.py -d show display_manager's DEBUG output display_manager pinned its logger to INFO at import, overriding the root level, so debug mode never showed its DEBUG lines. Use get_logger() from src.logging_config like the rest of the core and leave the level to the logging setup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(display): run each startup validation check once StartupValidator.validate_all() ran twice at boot, before and after the plugin manager was created, so every config, cache, display and systemd-unit warning was logged twice. The second pass now runs only the plugin checks. Drop the commented-out raise_on_errors line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(vegas): one INFO line per plugin-list refresh StreamManager logged "=" * 60 banners and a line per plugin (INCLUDED, SKIPPED, FETCHING CONTENT, SEGMENT CREATED) at INFO on every refresh and fetch, i.e. at each cycle start and every 30s. Log one INFO summary of the rotation per refresh and move the per-plugin detail, the weighting breakdown and "no content this cycle" to DEBUG (the adapter still warns when every content path fails). Also drop the check/cross marks from the controller's log messages. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(vegas): drop the per-iteration static-mode plugin scan run_iteration() rebuilt _static_mode_plugins on every iteration, asking every plugin for its display mode and logging the set at INFO, but nothing ever read it: static pauses are triggered by _check_static_plugin_trigger() from the next segment. Delete it, the coordinator's get_ordered_plugins() that only it used, and the write-only _static_pause_plugin / _static_pause_start. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(vegas): remove the staging buffer that was never filled StreamManager and RenderPipeline carried a double-buffer design that nothing used: _staging_buffer was only ever cleared or swapped, so swap_buffers() never did anything and should_recompose()'s staging_count > 0 branch was dead, and _active_scroll_image, _staging_scroll_image, _is_rendering, _last_frame_time and _frame_interval were written but never read. Delete the machinery and rewrite the docstrings around what actually carries updates: _pending_updates, consumed by process_updates() in swap mode and invalidate_pending_updates() in continuous mode. should_recompose() no longer builds a buffer-status dict every frame. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(display): tidy the display controller without changing behaviour - Import VegasModeCoordinator locally instead of through module globals (there is no circular import to avoid). - Drop hasattr() checks on attributes PluginManager.__init__ always sets (plugin_executor, plugin_last_update, get_plugin_lock, run_scheduled_updates*, stop_update_worker) and the dead "older manager" fallbacks; keep the health_tracker None checks, now via _health_tracker(). - Extract _display_once() for the per-frame display call both render loops copied, _advance_on_demand() for the two on-demand rotations, _reset_on_demand_fields() for the error and clear paths, and _timezone() / _in_window() for the two schedule checks. - Remove always-true conditions and the unreachable non-plugin else branch in run(), and read _was_display_active / _last_published_mode / vegas_coordinator directly now that __init__ declares them. - Declare the follower render state in __init__, name its tuning constants, add _follower_sign(), and share the 90/s sync send interval with the render pipeline (SYNC_SEND_INTERVAL). - Delete history narration and the "Opt #N" labels, fix the comment that called _scroll_speed constant (hot reload updates it), and drop a startup timing log that measured nothing. - render_pipeline / plugin_adapter: read display_manager.width/height as the properties they are, drop an empty TYPE_CHECKING block, an aliased threading import and a duplicated `if result and self.sync_manager:`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(display): trim dead code from display_manager - Add _new_canvas() for the image/draw/fontmode="1" setup that was copied six times. - Call resolve_double_sided() and compose_pixel_mapper_config() directly instead of through a module alias and a passthrough method, and replace the comment that said the passthrough read class attributes. - Delete the unused _initialized flag and _ORIENTATION_ROTATE_DEGREES alias (no core or monorepo reader; tests stop resetting the flag), the test pattern's unreachable no-matrix branch (it only runs once the matrix exists), `del old_image # help GC` (a no-op on a local), a duplicated early return in process_deferred_updates, and stale comments. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(vegas): remove unread fields and test-only helpers, fix docstrings - ContentSegment: drop total_width, fetched_at, is_stale, image_count and is_static, none of which is read. - StreamManager: drop _current_index (never advanced) and the test-only get_all_content_for_composition() and has_pending_updates(); VegasModeConfig: drop the test-only is_plugin_included(). - geometry.find_blank_cut() has had no production caller since the crop moved to item boundaries; delete it and its tests. - PluginAdapter: the _finalize docstring described separator_width between every image, and _crop_to_budget's said cuts snap to the nearest blank column; both now describe what the code does. - Coordinator: the static-pause interrupt log no longer blames follower mode for every interrupt, and set_update_callback names the callback the controller actually wires. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(scroll): correct ScrollHelper comments and drop dead branches - Four comments said the strip always starts with display_width of blank; it does only when lead_gap is None (Vegas passes its own). - Delete the "Width calculation mismatch" warning: the image is created at the calculated width, so the two can never differ. - Remove the two scroll_delay <= 0 fallbacks (which disagreed with each other): set_scroll_delay clamps it to at least 0.001 and nothing in core or the plugin monorepo assigns it directly. - Trim the scipy history from the blend docstring. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(run): drop a redundant comment Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(display): log set_scrolling_state only when it changes Vegas and scrolling plugins set the scrolling state every frame, so once display_manager's DEBUG output became visible in debug mode it printed "Scrolling state set to: True" about 120 times a second. Log only when the value differs from the previous one; the state, activity timestamp and frame hold still update on every call. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(changelog): display-vegas Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
592 lines
24 KiB
Python
592 lines
24 KiB
Python
"""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._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
|