diff --git a/CHANGELOG.md b/CHANGELOG.md index a97d2f13..223cc50c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,39 @@ accepts both, but the store flags the old spelling as deprecated ## Unreleased +### Display loop stage 3: a ScreenRunner, and the Arbiter decides every screen + +Internal; no behaviour change. Stage 3 of `docs/RUN_LOOP_REDESIGN.md`. + +- Each screen runs in `ScreenRunner` (`src/screen_runner.py`): the first + frame, the 125 Hz or 1 Hz frame loop, the make-up dwell and the + dynamic-duration exit, moved out of `DisplayController.run()` with their + pacing unchanged. It paces with an injected clock and returns an + `Outcome` whose `ExitReason` is `DURATION`, `CYCLE_COMPLETE`, `EMPTY`, + `ERROR`, `DISPLAY_FALSE`, `RELOAD` or `PREEMPTED`. `PREEMPTED` replaces + the five "did the mode change under this screen?" re-checks. +- `Arbiter.decide()` now answers for on-demand, live priority and the + rotation too (Sources `ON_DEMAND`, `LIVE`, `ROTATION`); `LEGACY` means + only Vegas, whose iteration moves to stage 4. The on-demand session, the + rotation's position and the live resume point are snapshotted into + `ArbiterState`, whose pure transitions (`next_on_demand`, `claim_live`, + `release_live`, `after`) replace the bookkeeping in `_resolve_active_mode`, + `_apply_live_priority` and `_advance_after_screen`. +- Between frames, the runner's service points make one + `decide(..., running=plan)` call instead of `_check_live_takeover`, + `_screen_preempted` and `_wifi_notice_pending` one after another. The + WiFi notice file is still read exactly where it was (the read is + throttled and deletes an expired file). +- A Vegas pass scans the live-priority plugins once instead of twice at the + same instant. +- The golden traces are byte-identical, and a capture of all 67 harness + runs in the suite (every sleep, frame, read and scan) matches `main` + apart from the duplicate scan above and one moment: in the 125 Hz loop a + live takeover's state change is made after the frame's 8 ms sleep rather + than before it, ending the screen at the same frame as before. +- New module: `src/screen_runner.py`. Core-internal: plugins have no reason + to import it, so it sets no `ledmatrix_min_version` floor. + ### A scrolling screen held by its plugin's update() is reported - While a plugin's `update()` runs it holds the plugin's lock, and that diff --git a/docs/IPC_CONTROL_SOCKET.md b/docs/IPC_CONTROL_SOCKET.md index 2ec19e1f..ffb6988d 100644 --- a/docs/IPC_CONTROL_SOCKET.md +++ b/docs/IPC_CONTROL_SOCKET.md @@ -367,7 +367,9 @@ place it reads the mailbox: Vegas iteration is on the stack (`_apply_pending_plugin_reloads`). Until then the current screen ends early, as it does for a WiFi notice: the frame loops, the dwell and Vegas's interrupt check all treat a pending - reload as a reason to stop (`_screen_preempted`). + reload as a reason to stop (the frame loops through the Arbiter's + mid-screen check, `Source.RELOAD`; the dwell through + `_plugin_reload_pending`). - Only the quick half of the reload runs on the render thread (`_start_plugin_reload`): the plugin's modes leave the rotation, its config subscription is dropped, and `PluginManager.detach_plugin` takes diff --git a/docs/RUN_LOOP_REDESIGN.md b/docs/RUN_LOOP_REDESIGN.md index 0b58cd53..88e1d804 100644 --- a/docs/RUN_LOOP_REDESIGN.md +++ b/docs/RUN_LOOP_REDESIGN.md @@ -36,122 +36,189 @@ Each pass, in order: 1. `loop_pass()` (watchdog). Apply a pending plugin enable/disable, then any plugin reloads the control socket asked for (`_apply_pending_plugin_reloads`; a pending reload ends the screen - before it, like a WiFi notice, through `_screen_preempted`). The static - screen's frame sleep and the dwell wait on the socket's queue instead of - sleeping (`_wait_frame_interval`, `_sleep_with_plugin_updates`); without - a socket, as in the golden traces, they are the plain sleeps. + before it, like a WiFi notice, as `Source.RELOAD` at the runner's + service points). The static screen's frame sleep and the dwell wait on + the socket's queue instead of sleeping (`_wait_frame_interval`, + `_sleep_with_plugin_updates`); without a socket, as in the golden + traces, they are the plain sleeps. 2. With no modes: dwell 1 s, next pass. 3. Poll on-demand requests and expiry, release plugins loaded only for on-demand, tick plugin updates, drop an expired WiFi notice, evaluate the schedule (an on-demand session overrides scheduled-off), apply the brightness target. Then gather the Arbiter's inputs - (`_arbiter_inputs`) and call `Arbiter.decide()`, which picks one of - steps 4-6 or returns `LEGACY` for steps 7-9 (stage 2). + (`_arbiter_inputs`) and call `Arbiter.decide()`. 4. **Scheduled off:** blank, dwell up to 60 s. `_blank_while_scheduled_off` 5. **Follower:** render one frame from the leader. `_run_follower_frame` 6. **WiFi notice** (unless on-demand): draw it, dwell 0.5 s. `_show_wifi_notice`. - It is also polled mid-screen (`_wifi_notice_pending`): the frame loops, - the dwell sleep and an interrupted Vegas iteration end within about a - second when one arrives, and a screen cut short resumes after it. -7. **Live priority** (unless on-demand, or Vegas keeps live content in the - ticker): switch to the next live mode, or resume the rotation. A game - that goes live during a screen is caught sooner, by - `_check_live_takeover` in the frame loops and the dwell sleep (at most - once a second, and not while a live mode is showing). -8. **Vegas** (unless on-demand, or live content preempts it): run one - iteration of up to `max_cycle_duration`. A completed iteration ends the - pass, and so does one that yielded for a WiFi notice or the schedule. - Any other interrupted one falls through to step 9 in the same pass. -9. **One screen:** pick the mode (`_resolve_active_mode`), the plugin - (`_plugin_for_mode`), draw the first frame through the executor - (`_dispatch_first_frame`). On no content, rotate at once - (`_note_empty_pass`, `_skip_failed_plugin_modes`). Otherwise work out the - bounds (`_track_dynamic_cycle`, `_resolve_durations`, - `_clamp_to_on_demand`) and the frame rate (`_needs_high_fps`), run the - 125 Hz or 1 Hz frame loop, make up the minimum duration, then pick the - next mode (`_advance_after_screen`). + It also ends a running screen within about a second (the runner's + service points), and a screen cut short resumes after it. +7. **The Sources below the notice:** read whether Vegas is on and make the + live-priority scan (`_arbiter_inputs_below_wifi`, where run() always + read them), and call `decide()` again. It answers OnDemand (the + session's current mode), Live (the next live mode, round-robin; a game + that goes live during a screen takes over at the next service point, at + most once a second), Vegas (`LEGACY`) or Rotation. `_take_plan` applies + the answer: a live claim or the resume when live priority ends, the + on-demand index. +8. **Vegas** (`_run_vegas_iteration`): one iteration of up to + `max_cycle_duration`. A completed iteration ends the pass, and so does + one that yielded for the schedule, a reload or a WiFi notice. Any other + interrupted one asks `decide()` once more (`vegas_yielded`): a game that + stopped the ticker, or an on-demand session that started, shows next. +9. **One screen:** pick the plugin (`_plugin_for_mode`) and hand the plan + to the `ScreenRunner` (`src/screen_runner.py`). It draws the first frame + through the executor (`_dispatch_first_frame`), has the controller fill + in the plugin's durations, dynamic flag and frame policy + (`_complete_plan`), runs the 125 Hz or 1 Hz frame loop with a service + point after each frame, makes up the minimum duration, and returns an + `Outcome`. On `PREEMPTED` the pass ends without advancing. On no + content, rotate at once (`_note_empty_pass`, `_skip_failed_plugin_modes`). + Otherwise `ArbiterState.after()` picks the next mode + (`_advance_after_screen`). -The helpers named above were extracted in stage 1 without changing -behaviour. Since stage 2 the choice between steps 4, 5, 6 and the rest is -made by `Arbiter.decide()` in `src/display_arbiter.py`. The frame loops, the -Vegas branch and every early exit are still inline in `run()`. - -## Target design +## Design ```python def run(self): while True: - inputs = self._drain_inputs() # requests, schedule, config, sync - plan = self.arbiter.decide(self.state, inputs, clock.now()) - outcome = self.runner.run(plan) # ExitReason + elapsed - self.state = self.state.after(plan, outcome) # rotation, on-demand index, live resume + inputs = self._arbiter_inputs() # schedule, follower, notice + plan = Arbiter.decide(self._arbiter_state(), inputs, now) + ... # off / follower / notice + plan = self._take_plan(Arbiter.decide(state, self._arbiter_inputs_below_wifi(inputs), now)) + if plan.source is Source.LEGACY: # Vegas, until stage 4 + plan = self._run_vegas_iteration(...) + outcome = runner.run(plan, plugin) # ExitReason + elapsed + if outcome.exit_reason is not ExitReason.PREEMPTED: + self._advance_after_screen(plan, outcome) # ArbiterState.after ``` +The controller's attributes (`current_display_mode`, `current_mode_index`, +`on_demand_*`, `_live_resume_index`) stay the record that the web UI, the +control socket and the on-demand cache read. `_arbiter_state()` snapshots +them into a frozen `ArbiterState`; the transitions are pure methods on it, +and the controller writes their result back (`_adopt_state`). + ### Sources Each kind of content is a Source. A Source looks at the state and the inputs and either offers a screen or passes. The Arbiter asks them in this order: -| Order | Source | Offers a screen when | Today | +| Order | Source | Offers a screen when | Code | |---|---|---|---| -| gate | ScheduledOff | the schedule is off and no on-demand session overrides it | step 4 | -| 1 | Follower | a sync leader is driving this panel | step 5 | -| 2 | OnDemand | a session is active (its mode list, index, expiry and pin) | `_resolve_active_mode` | -| 3 | Wifi | a status message is pending and on-demand is not active | step 6 | -| 4 | Live | a live-priority plugin has live content (round-robin across several) | step 7 | -| 5 | Vegas | Vegas is enabled and nothing above wants the panel | step 8 | -| 6 | Rotation | always: `available_modes[current_mode_index]` | step 9 | +| gate | ScheduledOff | the schedule is off and no on-demand session overrides it | `decide` | +| 1 | Follower | a sync leader is driving this panel | `decide` | +| 2 | OnDemand | a session is active (its mode list, index, expiry and pin) | `_on_demand_plan` | +| 3 | Wifi | a status message is pending and on-demand is not active | `decide` | +| 4 | Live | a live-priority plugin has live content (round-robin across several) | `live_pick` | +| 5 | Vegas | Vegas is enabled and nothing above wants the panel | `LEGACY`, run by `_run_vegas_iteration` | +| 6 | Rotation | always: the rotation's current mode | `rotation_plan` | ScheduledOff is a gate in front of the Sources because that is how it works today: a scheduled-off panel stays blank even for a follower, and only an on-demand session overrides it. +The Rotation answers `state.current_mode`, not +`available_modes[current_mode_index]`: the two agree except where something +moved the panel off the list and the rotation carries on from there (a live +mode no rotation entry names, or None after a session ended with no enabled +mode to resume to), and `run()` always showed `current_display_mode`. + ### Arbiter ```python -Arbiter.decide(state, inputs, now) -> ScreenPlan +Arbiter.decide(state, inputs, now, running=None) -> ScreenPlan ``` `decide` is a pure function: it does no I/O, takes no locks and does not sleep. It can be tested with plain tables of (state, inputs, now) mapped to -an expected plan. It returns a `ScreenPlan`: +an expected plan. + +- `ArbiterState`: the current mode; the rotation and its index; the + on-demand session's modes, index, expiry and pin; the live resume point; + whether a mid-screen takeover has not shown yet. Transitions: + `next_on_demand`, `showing`, `claim_live`, `release_live`, `after`. +- `ArbiterInputs`: whether the schedule has the panel on, an on-demand + session, a follower, the WiFi notice, the live modes (None where no scan + was made), whether Vegas is on and keeps live content in its ticker, + whether this pass's Vegas iteration has yielded, and (mid-screen) whether + a plugin reload is waiting. +- `ScreenPlan`: | Field | Meaning | |---|---| | `source` | which Source won | -| `mode`, `plugin` | what to draw (None for a blank or follower plan) | -| `min_duration`, `max_duration` | from `_resolve_durations` and `_clamp_to_on_demand` | +| `mode`, `plugin` | what to draw (None for a blank or follower plan); the plugin id once resolved | +| `min_duration`, `max_duration` | from `_resolve_durations` and the on-demand bound (`on_demand_bound`), filled in after the first frame; an on-demand plan's `max_duration` is what is left of the session at `now` | | `dynamic` | run until the plugin's cycle completes, between min and max | -| `frame_policy` | today `_needs_high_fps` (125 Hz or 1 Hz); see stage 5 | +| `frame_policy` | `HIGH_FPS` or `STATIC`, today `_needs_high_fps`; see stage 5 | | `preemptible_by` | the Sources allowed to interrupt this plan mid-screen | +| `notice`, `deadline`, `ends_live` | the WiFi notice; the on-demand expiry for the bound; "live priority just ended, resume the rotation first" | + +`decide` cannot ask a plugin anything, so the fields a plugin answers are +filled in by the controller after the first frame, where they were always +read (`_complete_plan`). + +With `running`, `decide` answers the mid-screen question instead: `running` +itself while the screen holds, else the plan that ends it +(`_hold_or_preempt`), in the order the frame loops always checked: + +1. Live: a game went live while a non-live screen runs. It is the one + preemption that changes the state (the rotation moves to the live mode + and remembers where it was), and it is claimed even when a WiFi notice + is also pending; the next pass shows the notice, then the game. +2. The panel's mode moved under the screen (on-demand started, ended or + changed mode; the rotation was rebuilt). +3. The schedule turned the panel off. +4. A WiFi notice (unless on-demand outranks it), compared with its expiry. +5. A plugin reload is waiting (between frames only). + +Every screen is preemptible by the gate, OnDemand, Wifi, Live, Rotation and +a reload (`SCREEN_PREEMPTERS`), except that a live screen leaves Live out +(`LIVE_PREEMPTERS`): live games take turns between screens. A follower and +Vegas are looked at only between screens. ### ScreenRunner ```python -ScreenRunner(clock: FrameClock).run(plan) -> Outcome(exit_reason, elapsed) +ScreenRunner(clock: FrameClock, host: ScreenHost).run(plan, plugin) -> Outcome ``` The ScreenRunner draws the first frame (`_dispatch_first_frame`), runs the frame loop that the plan's frame policy selects, services pending changes between frames, and returns one `ExitReason`: -| ExitReason | Today's equivalent (golden-trace exit) | +| ExitReason | Golden-trace exit | |---|---| | `DURATION` | target duration reached (`duration`) | | `CYCLE_COMPLETE` | dynamic plugin finished after its minimum (`cycle-complete`) | -| `EMPTY` | first frame returned False (`empty`; `raised` when display() raised inside the executor) | +| `EMPTY` | first frame returned False, or no plugin (`empty`; `raised` when display() raised inside the executor; `no-plugin`, `breaker`) | | `ERROR` | the dispatch itself raised (`error`) | | `DISPLAY_FALSE` | a later frame returned False (`display-false`) | -| `PREEMPTED` | another Source took the panel (`on-demand-*`, `schedule-off`, `vegas-interrupt`, ...) | +| `PREEMPTED` | another Source took the panel (`on-demand-*`, `schedule-off`, `live`, `wifi`, ...) | +| `RELOAD` | a plugin reload is waiting: the screen ends early but counts as shown, and the rotation advances | `PREEMPTED` replaces the five `current_display_mode != active_mode` checks. -The runner asks the Arbiter, at the throttled service points it already has, -whether a Source in `plan.preemptible_by` now wants the panel. +The runner asks its host at named service points (`Checkpoint`): `FRAME` +after each frame (and when a socket command wakes the 1 Hz wait), +`AFTER_LOOP` / `AFTER_COMPLETED_LOOP` when the frame loop ends, +`after_dwell` after the make-up dwell, and `FINAL` before the rotation +advances. Each is one `decide(..., running=plan)` call +(`DisplayController._screen_check`). The checkpoint says whether a pending +reload counts there and when the WiFi notice file is read (`NoticeRead`): +the read is throttled to once a second and deletes an expired file, so it +happens exactly where the loop always read it. -`FrameClock` provides `now()` and `sleep()`. In production it is -`time.monotonic`/`time.sleep`. In the golden traces it is the fake clock -that the harness patches in today. +In the 125 Hz loop the live-priority scan is made before the frame's sleep +(`_screen_service`), at the moments it always was, and weighed by the +service point after the sleep, where the loop always decided to end the +screen. + +`FrameClock` provides `time()`, `perf_counter()` and `sleep()`, the shape of +the `time` module. In production it is `_ModuleClock`, which looks up +`src.display_controller.time` on each call, so the golden traces' fake clock +drives the runner as it drove the inline loops. The runner's log lines use +the controller's logger, so they keep their source in the journal. ## Stages @@ -250,37 +317,84 @@ What shipped: Source, the dwells, the expiry comparison, the snapshot's reads, each dispatch in `run()`); every one failed a test. -### Stage 3: ScreenRunner and `PREEMPTED` +### Stage 3: ScreenRunner and `PREEMPTED` (done; awaiting the ledpi soak) -Move the two frame loops, the make-up dwell and the dynamic-duration exit -into `ScreenRunner.run(plan)` with an injected `FrameClock`. Replace the -five re-checks with `PREEMPTED`. Add the OnDemand, Live and Rotation Sources -so `LEGACY` is left meaning only Vegas. - -Concretely, from where stage 2 left off: +The plan, from where stage 2 left off: 1. `ArbiterState` gains the rotation index, the on-demand mode list, index, - expiry and pin, and the live resume point (today `current_mode_index`, - `on_demand_*` and the live-priority stash). `ArbiterInputs` gains the - live modes (`_collect_live_modes`) and whether Vegas is enabled and keeps - live content in the ticker. -2. OnDemand returns its current mode with `_clamp_to_on_demand`'s bound, - reading `now` for the expiry. Live returns the next live mode - (round-robin). Rotation returns `available_modes[current_mode_index]`. - `ScreenPlan` gains `mode`, `plugin`, `min_duration`, `max_duration`, - `dynamic`, `frame_policy` and `preemptible_by`. -3. `ScreenRunner.run(plan)` returns an `ExitReason`; `state.after(plan, - outcome)` replaces `_advance_after_screen` and the live-resume - bookkeeping. Each mid-screen check asks `decide()` whether a Source in - `plan.preemptible_by` now wins, so `_screen_preempted`, - `_check_live_takeover` and `_wifi_notice_pending` become one call. + expiry and pin, and the live resume point. `ArbiterInputs` gains the + live modes and whether Vegas is enabled and keeps live content in the + ticker. +2. OnDemand returns its current mode with the session's bound, reading + `now` for the expiry. Live returns the next live mode (round-robin). + Rotation returns the rotation's mode. `ScreenPlan` gains `mode`, + `plugin`, `min_duration`, `max_duration`, `dynamic`, `frame_policy` and + `preemptible_by`. +3. `ScreenRunner.run(plan)` returns an `ExitReason`; `state.after(outcome)` + replaces `_advance_after_screen`'s step and the live-resume bookkeeping. + Each mid-screen check asks `decide()` whether a Source in + `plan.preemptible_by` now wins. 4. The control socket (`_drain_control_commands`, `_wait_for_control`) and state publishing stay where they are; the runner calls them at its service points. +What shipped, one commit each: the runner; then the OnDemand, Live and +Rotation Sources; then one `decide()` call at the service points. + +- `src/screen_runner.py` (on the mypy ratchet): `ScreenRunner`, + `FrameClock`, `ExitReason`, `Outcome`, `Checkpoint`, `NoticeRead`, + `Screen` and the `ScreenHost` protocol, which `DisplayController` + implements through `_ScreenHost` (one-line forwards to its own methods). + The two frame loops, the make-up dwell and the dynamic-duration exit + moved in unchanged, pacing included. +- `src/display_arbiter.py`: `Source` gains `ON_DEMAND`, `LIVE`, `ROTATION` + and `RELOAD`; `LEGACY` means only Vegas. `FramePolicy`. `ArbiterState` + and `ArbiterInputs` as listed under "Arbiter". The pure helpers + `on_demand_bound` (`_clamp_to_on_demand`), `live_pick` + (`_check_live_priority`'s pick), `live_takeover` (the mid-screen claim) + and `rotation_plan`. +- A pass asks `decide()` twice: once with the inputs every pass reads, and + once, only when nothing above the notice took the panel, with the Vegas + check and the live scan, read where run() always read them (the scan + asks every live-priority plugin, and the Vegas check applies queued + Vegas config, so reading them earlier, on a follower or notice pass, + would be a change). A Vegas iteration that yields asks a third time. +- `_resolve_active_mode`, `_clamp_to_on_demand` and `_screen_preempted` are + gone. `_apply_live_priority`, `_check_live_priority`, + `_check_live_takeover` and `_wifi_notice_pending` remain (Vegas, the + dwell sleep and the tests call them), built on the same pure rules. +- `_sleep_with_plugin_updates` keeps its own break rules. It also serves + the blank, the notice and the idle wait, which are not screens, and its + rules are edge-triggered (an on-demand session starting on the mode + already showing ends a dwell but not a frame loop); folding them into + `decide()` would change behaviour. + +Behaviour, checked three ways: + +- Golden traces: unchanged, no regeneration. +- Every harness run in the suite (67: the goldens plus the live-takeover, + WiFi+live, socket-wake, plugin-reload, schedule and tick tests) was + captured with every sleep, `display()` call, WiFi read, live scan, + publish, dwell and scroll-state call logged, and diffed against + `origin/main`. Identical, except: + - a Vegas pass used to scan the live plugins twice at the same instant + (step 7, then step 8's "is anything live?"); it scans once; + - `_apply_live_priority(None)` calls that changed nothing are not made; + - throttled WiFi reads that returned the cached answer (no side effect) + after a notice had already ended the screen are not made; + - in the 125 Hz loop the live scan still runs before the frame's sleep, + but the claim is made by the service point after it, so the "live" + state change happens 8 ms later. The screen ends at the same frame as + before. +- Tables: `test/test_display_arbiter.py` (OnDemand, Live, Vegas/Rotation, + `after`, the 24-row mid-screen table, `live_takeover`) and + `test/test_screen_runner.py` (the runner on a scripted host and fake + clock; the controller's service point and the reads it makes). A + mutation run broke each moved or new piece once; see the PR. + This stage touches frame pacing (the 8 ms deadline sleep, the 1 ms yield), -so it needs a frame soak on ledpi, A/B against main. Coordinate with -whoever owns scroll performance (`docs/SCROLL_PERFORMANCE.md`). +so it needs a frame soak on ledpi, A/B against main, before it merges. +Coordinate with whoever owns scroll performance (`docs/SCROLL_PERFORMANCE.md`). ### Stage 4: Vegas as a Source @@ -345,3 +459,13 @@ PR that updates the affected trace and explains why. All six are fixed: A new one found later goes the same way: record it here with the trace that shows it, then fix it in its own PR, not inside a restructure stage. + +Open: + +- Vegas stops for a sync follower (its interrupt check includes + `is_follower_active`), but the yield path never looks at a follower, so a + full rotation screen (20 s in the test) runs before the next pass hands + the panel to the leader. Found by stage 3's mutation run; + `test_screen_runner.py::TestThroughRun::test_vegas_yielding_to_a_follower_shows_a_rotation_screen_first` + pins it. Stage 4, which drops the interrupt callback, is the natural + place to fix it. diff --git a/mypy-clean.txt b/mypy-clean.txt index b4f3f4f3..63f6f5bc 100644 --- a/mypy-clean.txt +++ b/mypy-clean.txt @@ -88,6 +88,7 @@ src/plugin_system/testing/vegas.py src/plugin_system/vegas_elements.py src/redaction.py src/scan_order.py +src/screen_runner.py src/startup_validator.py src/vegas_mode/__init__.py src/vegas_mode/config.py diff --git a/src/display_arbiter.py b/src/display_arbiter.py index 9d45933e..879d6017 100644 --- a/src/display_arbiter.py +++ b/src/display_arbiter.py @@ -1,41 +1,56 @@ """What the panel shows next: the Arbiter of docs/RUN_LOOP_REDESIGN.md. ``Arbiter.decide(state, inputs, now)`` takes a snapshot that -``DisplayController.run()`` gathers once per pass and returns a -:class:`ScreenPlan` naming the Source that gets the panel. It is a pure -function: no I/O, no clock reads (``now`` is passed in), no locks, and it -changes nothing it is given. That is what lets a plain table of cases test -the priority order, which used to exist only as the order of ``if`` blocks -in ``run()``. +``DisplayController.run()`` gathers and returns a :class:`ScreenPlan` naming +the Source that gets the panel. It is a pure function: no I/O, no clock +reads (``now`` is passed in), no locks, and it changes nothing it is given. +That is what lets a plain table of cases test the priority order, which +used to exist only as the order of ``if`` blocks in ``run()``. -The full order is +The order is ScheduledOff (a gate), Follower, OnDemand, Wifi, Live, Vegas, Rotation -Stage 2 decides the gate, Follower and Wifi. Every other case returns a -``LEGACY`` plan, meaning "carry on with run()'s existing code" (live -priority, Vegas, then one rotation screen). OnDemand is in the order already -because it outranks the WiFi notice: an active session is a ``LEGACY`` plan -even when a notice is pending. +Every Source but Vegas is decided here (stage 3). Vegas is the ``LEGACY`` +plan: the Arbiter picks it, but its iteration is still run()'s own code +until stage 4. -The Wifi Source's mid-screen rule, :func:`wifi_notice_preempts`, lives here -too, so both of its answers -- at the top of a pass and between frames -- -come from one module. +A pass asks twice: once with the inputs every pass reads (the gate, +Follower, OnDemand, Wifi), and once more, only when nothing above the +notice took the panel, with the inputs the Sources below it need (whether +Vegas is on, the live-priority scan), read where run() always read them. + +``decide(..., running=plan)`` is the other question, asked by the +ScreenRunner (src/screen_runner.py) at its service points: does a Source +in ``plan.preemptible_by`` now take the panel from the screen that is +running? The mid-screen rules are :func:`_hold_or_preempt`. + +The state transitions (the next on-demand mode, a live claim and its +release, the rotation's step after a screen) are pure methods of +:class:`ArbiterState`; the controller applies what they return. """ -from dataclasses import dataclass +from dataclasses import dataclass, replace from enum import Enum -from typing import Optional +from typing import FrozenSet, Optional, Protocol, Tuple __all__ = [ "Arbiter", "ArbiterInputs", "ArbiterState", + "FramePolicy", + "LIVE_PREEMPTERS", "SCHEDULED_OFF_DWELL", + "SCREEN_PREEMPTERS", + "ScreenEnd", "ScreenPlan", "Source", "WIFI_NOTICE_DWELL", "WifiNotice", + "live_pick", + "live_takeover", + "on_demand_bound", + "rotation_plan", "wifi_notice_preempts", ] @@ -53,11 +68,41 @@ class Source(Enum): SCHEDULED_OFF = "scheduled-off" FOLLOWER = "follower" + ON_DEMAND = "on-demand" WIFI = "wifi" - # Not decided by the Arbiter yet: on-demand, live priority, Vegas and the - # rotation are still chosen by run()'s own code. Stage 3 adds the - # OnDemand, Live and Rotation Sources; stage 4 adds Vegas. + LIVE = "live" + # Vegas: the Arbiter picks it, but its iteration is still run()'s own + # code (and its interrupt callback a second copy of this order) until + # stage 4 makes it a Source driven frame by frame. LEGACY = "legacy" + ROTATION = "rotation" + # Not a screen: a plugin reload waits at the top of the loop. It ends a + # screen between frames (the screen counts as shown and the rotation + # moves on), and the next pass reloads before it draws. + RELOAD = "reload" + + +class FramePolicy(Enum): + """How often a screen draws: today's two frame loops (see + DisplayController._needs_high_fps). Stage 5 lets plugins declare it.""" + + #: The 125 Hz loop, paced to an 8 ms deadline: scrolling plugins. + HIGH_FPS = "high-fps" + #: The 1 Hz loop. + STATIC = "static" + + +class ScreenEnd(Protocol): + """What ArbiterState.after needs to know about how a screen ended + (screen_runner.Outcome, filled in by the controller).""" + + @property + def on_demand_active(self) -> bool: + """An on-demand session was running when the screen ended.""" + + @property + def still_live(self) -> bool: + """The mode's plugin still had live content: hold the rotation.""" @dataclass(frozen=True) @@ -76,11 +121,122 @@ class WifiNotice: class ArbiterState: """What the Arbiter remembers between passes. - Nothing yet: the stage-2 Sources decide from the inputs alone. The - on-demand index, the rotation index and the live resume point move here - with their Sources in stage 3. + A snapshot of the controller's own fields, taken when decide() is + called (DisplayController._arbiter_state); the transitions below return + the next state, and the controller writes it back. + + Attributes: + current_mode: The mode on the panel or about to be + (``current_display_mode``). + on_demand_modes: The on-demand session's modes, in the order it + shows them (a pinned mode already moved to the front when the + session started, by _apply_on_demand_pin). + on_demand_index: Which of them is showing. + on_demand_expires_at: When the session ends (wall clock), or None + for a session with no duration. + on_demand_pinned: The session was started pinned. Carried for the + snapshot; the pin itself is already in ``on_demand_modes``. + rotation: The rotation's modes (``available_modes``). + rotation_index: Where the rotation is (``current_mode_index``). + live_resume_index: Where the rotation was when live priority took + the panel, so it resumes there once nothing is live; None while + live priority holds nothing. + live_takeover_unshown: A mid-screen takeover chose current_mode and + it has not been shown yet, so the next pass must not advance the + live round-robin past it. """ + current_mode: Optional[str] = None + on_demand_modes: Tuple[str, ...] = () + on_demand_index: int = 0 + on_demand_expires_at: Optional[float] = None + on_demand_pinned: bool = False + rotation: Tuple[str, ...] = () + rotation_index: int = 0 + live_resume_index: Optional[int] = None + live_takeover_unshown: bool = False + + def next_on_demand(self) -> "ArbiterState": + """The session's next mode, wrapping round. Needs a mode list.""" + index = (self.on_demand_index + 1) % len(self.on_demand_modes) + return replace(self, on_demand_index=index, + current_mode=self.on_demand_modes[index]) + + def claim_live(self, mode: str) -> "ArbiterState": + """Live priority takes the panel for ``mode``. + + The rotation's position is saved only on the first claim, not on + each re-check while the hold continues, so it resumes where live + priority interrupted it instead of after the live mode (which would + skip every mode between the two). + """ + if self.current_mode == mode: + return self + resume = self.rotation_index if self.live_resume_index is None else self.live_resume_index + index = self.rotation.index(mode) if mode in self.rotation else self.rotation_index + return replace(self, current_mode=mode, rotation_index=index, + live_resume_index=resume) + + def after(self, outcome: "ScreenEnd") -> "ArbiterState": + """The state once a screen has run its course: the next mode. + + An on-demand session moves to its next mode. Otherwise the rotation + advances -- unless the mode just shown is a live-priority mode that + is still live, which holds the panel. A session with no modes left + is ended by the controller before it asks (that is not pure: it + resumes the rotation and clears the cache). + """ + if outcome.on_demand_active: + return self.next_on_demand() if self.on_demand_modes else self + if outcome.still_live or not self.rotation: + return self + index = (self.rotation_index + 1) % len(self.rotation) + return replace(self, rotation_index=index, current_mode=self.rotation[index]) + + def release_live(self) -> "ArbiterState": + """Nothing is live any more: the rotation resumes where it was.""" + if self.live_resume_index is None or not self.rotation: + return self + index = self.live_resume_index % len(self.rotation) + return replace(self, current_mode=self.rotation[index], rotation_index=index, + live_resume_index=None) + + def showing(self, plan: "ScreenPlan") -> "ArbiterState": + """The state once ``plan`` is on the panel. + + An on-demand plan puts the session's index on the mode it shows (an + index past the end of a shortened list starts it again at 0). + """ + state = replace(self, current_mode=plan.mode) + if plan.source is Source.ON_DEMAND and self.on_demand_modes: + state = replace(state, on_demand_index=_on_demand_index(self)) + return state + + +def _on_demand_index(state: ArbiterState) -> int: + """The session's index, or 0 once it is past the end of its list.""" + index = state.on_demand_index + return index if index < len(state.on_demand_modes) else 0 + + +def on_demand_bound(min_duration: float, max_duration: float, + deadline: Optional[float], + now: float) -> Optional[Tuple[float, float]]: + """Shorten a screen's (min, max) seconds to what is left of a timed + on-demand session ending at ``deadline``. None when nothing is left. + + The OnDemand Source's bound, applied after the screen's first frame, + where it always was (``now`` is read then). + """ + if deadline is None: + return min_duration, max_duration + remaining = max(0.0, deadline - now) + min_duration = min(min_duration, remaining) + max_duration = min(max_duration, remaining) + if max_duration <= 0: + return None + return min_duration, max_duration + @dataclass(frozen=True) class ArbiterInputs: @@ -95,57 +251,122 @@ class ArbiterInputs: when it could win (the panel is on, and neither a follower nor on-demand outranks it), because reading it has side effects: a 1 Hz throttle and deleting an expired file. + live_modes: The modes with live content, from a live-priority scan, + in registration order; None when no scan was made (on-demand, + Vegas keeping live content in its ticker, a throttled + mid-screen check). A scan asks every live-priority plugin, so it + is made only where run() always made it. + vegas_enabled: Vegas mode is on (and no on-demand session holds it + off). + vegas_live_in_ticker: Vegas keeps live content in its ticker + instead of yielding the panel to it. + vegas_yielded: This pass's Vegas iteration has run and yielded, so + the Vegas Source passes and the screen it fell through to is + decided. + reload_pending: Mid-screen only: a plugin reload is waiting for the + top of the loop, at a service point where that ends the screen. """ schedule_on: bool on_demand_active: bool follower_active: bool wifi_notice: Optional[WifiNotice] = None + live_modes: Optional[Tuple[str, ...]] = None + vegas_enabled: bool = False + vegas_live_in_ticker: bool = False + vegas_yielded: bool = False + reload_pending: bool = False @dataclass(frozen=True) class ScreenPlan: """The Arbiter's answer for one pass. + decide() is pure, so it cannot ask a plugin anything: the fields a + plugin answers (its durations, whether it runs a dynamic cycle, how + often it draws) are filled in by the controller after the screen's first + frame, when they have always been read (DisplayController.complete_plan). + Attributes: source: The Source that gets the panel. + mode: The display mode to draw (None for a blank, follower or notice). + plugin: The id of the plugin drawing ``mode``, once resolved. + min_duration: Seconds the screen runs at least (dynamic duration). max_duration: How long the plan holds the panel, in seconds, at most (its dwell ends early when what the panel should show changes). - None when the Source paces itself: a follower frame, or LEGACY. + None when the Source paces itself (a follower frame, Vegas), and + for a rotation or live plan until its first frame. + dynamic: Run until the plugin's cycle completes, between min and max. + frame_policy: Which frame loop the screen runs. + preemptible_by: The Sources that may end the screen mid-way. notice: The WiFi notice to draw, for a WIFI plan. + deadline: For an on-demand plan, when the session ends (wall + clock): after the first frame the screen's durations are cut to + what is left (:func:`on_demand_bound`). + ends_live: Nothing is live any more and live priority had + interrupted the rotation: taking this plan resumes the rotation + where it was (ArbiterState.release_live) before it shows. """ source: Source + mode: Optional[str] = None + plugin: Optional[str] = None + min_duration: Optional[float] = None max_duration: Optional[float] = None + dynamic: bool = False + frame_policy: Optional[FramePolicy] = None + preemptible_by: FrozenSet[Source] = frozenset() notice: Optional[WifiNotice] = None + deadline: Optional[float] = None + ends_live: bool = False SCHEDULED_OFF_PLAN = ScreenPlan(Source.SCHEDULED_OFF, max_duration=SCHEDULED_OFF_DWELL) FOLLOWER_PLAN = ScreenPlan(Source.FOLLOWER) -LEGACY_PLAN = ScreenPlan(Source.LEGACY) +RELOAD_PLAN = ScreenPlan(Source.RELOAD) + +#: What may end a screen mid-way: the schedule, an on-demand session +#: starting or ending, a WiFi notice, a live game, the rotation moving +#: under the screen, and a plugin reload. Not a follower or Vegas: those +#: are only looked at between screens. +SCREEN_PREEMPTERS: FrozenSet[Source] = frozenset( + {Source.SCHEDULED_OFF, Source.ON_DEMAND, Source.WIFI, Source.LIVE, Source.ROTATION, + Source.RELOAD}) + +#: A live screen is not preempted by Live: live games take turns between +#: screens, never mid-screen. +LIVE_PREEMPTERS: FrozenSet[Source] = SCREEN_PREEMPTERS - {Source.LIVE} class Arbiter: """Decides which Source gets the panel. Stateless; see the module docstring.""" @staticmethod - def decide(state: ArbiterState, inputs: ArbiterInputs, now: float) -> ScreenPlan: + def decide(state: ArbiterState, inputs: ArbiterInputs, now: float, + running: Optional[ScreenPlan] = None) -> ScreenPlan: """The plan for this pass, from the Sources in priority order. + With ``running``, the question is the ScreenRunner's at one of its + service points instead: does a Source in ``running.preemptible_by`` + now take the panel from that screen? The answer is ``running`` + itself (the same object) while it holds, else the plan that ends it. + See :func:`_hold_or_preempt` for the rules. + Args: - state: What the Arbiter remembers between passes (nothing yet). + state: What the Arbiter remembers between passes. inputs: This pass's snapshot. - now: Wall-clock time of the snapshot. No stage-2 Source reads it: - the top-of-pass WiFi check takes the notice as read, and only - the mid-screen check (:func:`wifi_notice_preempts`) compares - it with the expiry. It is in the signature for the Sources - stage 3 adds (on-demand expiry, durations). + now: Wall-clock time of the snapshot. The OnDemand Source reads + it for what is left of a timed session, and the mid-screen + WiFi rule (:func:`wifi_notice_preempts`) to compare with the + notice's expiry. The top-of-pass WiFi check does not: it + takes the notice as read. + running: The screen on the panel, for a mid-screen check. Returns: - The winning Source's plan, or LEGACY_PLAN when the winner is one - run() still decides itself. + The winning Source's plan (LEGACY for Vegas), or ``running``. """ - del state, now # not read by the stage-2 Sources; see the docstring + if running is not None: + return _hold_or_preempt(state, inputs, now, running) # ScheduledOff is a gate, not a Source: a scheduled-off panel stays # blank even for a follower, and only an on-demand session overrides @@ -157,17 +378,161 @@ class Arbiter: if inputs.follower_active: return FOLLOWER_PLAN - # 2. OnDemand: decided by run() until stage 3. It outranks the notice. + # 2. OnDemand: the session's current mode. It outranks the notice. if inputs.on_demand_active: - return LEGACY_PLAN + return _on_demand_plan(state, now) # 3. Wifi: a pending notice, held for one short dwell per pass. if inputs.wifi_notice is not None: return ScreenPlan(Source.WIFI, max_duration=WIFI_NOTICE_DWELL, notice=inputs.wifi_notice) - # 4-6. Live, Vegas, Rotation: still run()'s own code. - return LEGACY_PLAN + # 4. Live: the next live game, round-robin across several. With + # nothing live, a rotation that live priority interrupted resumes. + ends_live = False + if _live_applies(inputs): + pick = live_pick(inputs.live_modes, state.current_mode, + advance=not state.live_takeover_unshown) + if pick is not None: + return ScreenPlan(Source.LIVE, mode=pick, preemptible_by=LIVE_PREEMPTERS) + ends_live = state.live_resume_index is not None and bool(state.rotation) + + # 5. Vegas: one iteration of the ticker, run by run()'s own code + # until stage 4. Passes once this pass's iteration has yielded. + if inputs.vegas_enabled and not inputs.vegas_yielded: + return ScreenPlan(Source.LEGACY, ends_live=ends_live) + + # 6. Rotation: the rotation's current mode (after the resume, when + # live priority just ended). + return rotation_plan(state.release_live() if ends_live else state, + ends_live=ends_live) + + +def _hold_or_preempt(state: ArbiterState, inputs: ArbiterInputs, now: float, + running: ScreenPlan) -> ScreenPlan: + """The mid-screen rules: ``running``, or the plan that ends it. + + What the frame loops used to check one by one (_check_live_takeover, + then _screen_preempted with _wifi_notice_pending in it, before stage 3), + in their order: + + 1. Live: a game went live while a non-live screen runs (the inputs + carry a scan only when one was due, at most once a second). Checked + first because it is the one preemption that changes the state -- the + rotation moves to the live mode and remembers where it was -- and it + still happens when a WiFi notice is also pending: the next pass then + shows the notice, and the game after it. + 2. The panel's mode moved under the screen: an on-demand session + started, ended or changed mode, or the rotation was rebuilt (a + plugin enabled, disabled or reloaded). + 3. The schedule turned the panel off. + 4. A WiFi notice arrived (unless on-demand outranks it), compared with + its expiry because the read throttle can hand back a stale one. + 5. A plugin reload is waiting at the top of the loop. + + A follower and Vegas are never mid-screen preemptions; they are looked + at between screens. + """ + by = running.preemptible_by + if Source.LIVE in by: + takeover = live_takeover(state, inputs) + if takeover is not None: + return ScreenPlan(Source.LIVE, mode=takeover, preemptible_by=LIVE_PREEMPTERS) + if state.current_mode != running.mode: + source = Source.ON_DEMAND if inputs.on_demand_active else Source.ROTATION + if source in by: + return ScreenPlan(source, mode=state.current_mode, preemptible_by=SCREEN_PREEMPTERS) + if (Source.SCHEDULED_OFF in by and not inputs.schedule_on + and not inputs.on_demand_active): + return SCHEDULED_OFF_PLAN + notice = inputs.wifi_notice + if (Source.WIFI in by and notice is not None + and wifi_notice_preempts(notice, inputs.on_demand_active, now)): + return ScreenPlan(Source.WIFI, max_duration=WIFI_NOTICE_DWELL, notice=notice) + if Source.RELOAD in by and inputs.reload_pending: + return RELOAD_PLAN + return running + + +def live_takeover(state: ArbiterState, inputs: ArbiterInputs) -> Optional[str]: + """The live mode that takes the panel mid-screen, or None. + + The first live mode, when a scan found one and the panel is not on a + live mode already. Never while on-demand holds the panel, while it is + scheduled off, or while Vegas keeps live content in its ticker. + """ + if not _live_applies(inputs) or inputs.on_demand_active or not inputs.schedule_on: + return None + live = inputs.live_modes + if not live or state.current_mode in live: + return None + return live[0] + + +def _on_demand_plan(state: ArbiterState, now: float) -> ScreenPlan: + """The OnDemand Source: the session's current mode. + + ``max_duration`` is what is left of a timed session at ``now`` (None + without a duration); ``deadline`` carries the expiry so the bound can be + applied again after the first frame. A session with no modes left (its + plugin was unloaded under it) gets a plan with no mode: the controller + ends the session and shows the rotation's mode instead. + """ + modes = state.on_demand_modes + if not modes: + return ScreenPlan(Source.ON_DEMAND) + expires_at = state.on_demand_expires_at + remaining = None if expires_at is None else max(0.0, expires_at - now) + return ScreenPlan(Source.ON_DEMAND, mode=modes[_on_demand_index(state)], + max_duration=remaining, deadline=expires_at, + preemptible_by=SCREEN_PREEMPTERS) + + +def rotation_plan(state: ArbiterState, ends_live: bool = False) -> ScreenPlan: + """The Rotation Source: the mode the rotation is on. + + That is ``state.current_mode``, which is ``rotation[rotation_index]`` + except where something moved the panel off the list and the rotation + carries on from there: a live mode no rotation entry names, or None + when a session ended with no enabled mode to resume to. + """ + return ScreenPlan(Source.ROTATION, mode=state.current_mode, ends_live=ends_live, + preemptible_by=SCREEN_PREEMPTERS) + + +def _live_applies(inputs: ArbiterInputs) -> bool: + """Whether the Live Source has a say: a scan was made, and Vegas is not + keeping live content in its ticker (where the live plugin takes extra + turns in the marquee instead of the panel).""" + if inputs.live_modes is None: + return False + return not (inputs.vegas_enabled and inputs.vegas_live_in_ticker) + + +def live_pick(live_modes: Optional[Tuple[str, ...]], current_mode: Optional[str], + advance: bool) -> Optional[str]: + """The live mode to show, or None when nothing is live. + + When several plugins are live at once this round-robins between them, so + the panel alternates each dwell instead of pinning to the first one + registered. The mode on the panel is the cursor, so this stays right as + games start and end. + + Args: + live_modes: The live modes, in registration order. + current_mode: The mode on the panel. + advance: True for the rotation's pick (the live mode after the one + showing). False for a peek (the one showing if it is still live, + else the first), which Vegas uses to ask whether anything is. + """ + if not live_modes: + return None + if current_mode in live_modes: + if advance: + index = live_modes.index(current_mode) + return live_modes[(index + 1) % len(live_modes)] + return current_mode + return live_modes[0] def wifi_notice_preempts(notice: Optional[WifiNotice], on_demand_active: bool, diff --git a/src/display_controller.py b/src/display_controller.py index 11604bca..39b96115 100644 --- a/src/display_controller.py +++ b/src/display_controller.py @@ -30,6 +30,7 @@ import threading import types from collections import deque from contextlib import contextmanager +from dataclasses import replace from typing import Dict, Any, FrozenSet, List, Optional, Callable, Set, Tuple from datetime import datetime from concurrent.futures import ThreadPoolExecutor, as_completed # pylint: disable=no-name-in-module @@ -37,7 +38,12 @@ import pytz from src import display_watchdog from src.display_arbiter import ( - Arbiter, ArbiterInputs, ArbiterState, Source, WifiNotice, wifi_notice_preempts, + Arbiter, ArbiterInputs, ArbiterState, FramePolicy, + ScreenPlan, Source, WifiNotice, live_pick, live_takeover, on_demand_bound, rotation_plan, + wifi_notice_preempts, +) +from src.screen_runner import ( + FRAME, Checkpoint, ExitReason, FirstFrame, NoticeRead, Outcome, Screen, ScreenRunner, ) from src.display_manager import DisplayManager from src.config_manager import ConfigManager @@ -170,6 +176,77 @@ _FOLLOWER_NEAR_GAIN = 0.05 _FOLLOWER_FRAME_INTERVAL = 1.0 / 60 _FOLLOWER_DEADLINE_SLIP = 0.1 +class _ModuleClock: + """The FrameClock a ScreenRunner paces with: this module's ``time``. + + Looked up on every call, so a test that patches + ``src.display_controller.time`` (the golden traces' fake clock) drives + the runner too. + """ + + @staticmethod + def time() -> float: + return time.time() + + @staticmethod + def perf_counter() -> float: + return time.perf_counter() + + @staticmethod + def sleep(seconds: float) -> None: + time.sleep(seconds) + + +_MODULE_CLOCK = _ModuleClock() + + +class _ScreenHost: + """The DisplayController as a ScreenRunner's ScreenHost. + + One-line forwards to the controller's own methods, which keeps generic + names (draw, tick, check) off the controller, and means a test that + replaces one of those methods on the instance is still the one called. + """ + + def __init__(self, controller: "DisplayController"): + self._c = controller + + def first_frame(self, plan: ScreenPlan, plugin: Any) -> FirstFrame: + return FirstFrame(*self._c._dispatch_first_frame(plugin, plan.mode)) + + def complete_plan(self, plan: ScreenPlan, plugin: Any) -> Optional[ScreenPlan]: + return self._c._complete_plan(plan, plugin) + + def draw(self, screen: Screen) -> Any: + # Only the high-FPS loop times a run of frames held by the plugin's + # update() (see _display_once's report_hold). + return self._c._display_once( + screen.plugin, screen.mode, screen.accepts_display_mode, + report_hold=screen.plan.frame_policy is FramePolicy.HIGH_FPS) + + def after_frame(self, screen: Screen) -> None: + self._c._send_follower_frame(screen.plugin) + + def tick(self) -> None: + self._c._tick_plugin_updates_if_due() + + def service(self, screen: Screen) -> Optional[Tuple[str, ...]]: + return self._c._screen_service(screen) + + def wait_frame(self, interval: float, screen: Screen) -> Optional[ScreenPlan]: + return self._c._wait_frame_interval(interval, screen) + + def check(self, screen: Screen, checkpoint: Checkpoint, + live_scan: Optional[Tuple[str, ...]] = None) -> Optional[ScreenPlan]: + return self._c._screen_check(screen, checkpoint, live_scan) + + def dwell(self, seconds: float) -> None: + self._c._sleep_with_plugin_updates(seconds) + + def cycle_complete(self, screen: Screen) -> bool: + return self._c._plugin_cycle_complete(screen.plugin) + + class DisplayController: """ Top-level controller that owns the LED display run loop. @@ -1758,10 +1835,15 @@ class DisplayController: def _advance_on_demand(self) -> None: """Move an active on-demand session to its next mode and publish it. - The caller checks that on_demand_modes is non-empty. + The caller checks that on_demand_modes is non-empty. The step itself + is ArbiterState.next_on_demand. """ - self.on_demand_mode_index = (self.on_demand_mode_index + 1) % len(self.on_demand_modes) - next_mode = self.on_demand_modes[self.on_demand_mode_index] + self._show_on_demand_step(self._arbiter_state().next_on_demand()) + + def _show_on_demand_step(self, nxt: ArbiterState) -> None: + """Apply an on-demand session's step to its next mode and publish it.""" + self.on_demand_mode_index = nxt.on_demand_index + next_mode = nxt.current_mode logger.info("Rotating to next on-demand mode: %s (index %d/%d)", next_mode, self.on_demand_mode_index, len(self.on_demand_modes)) self.current_display_mode = next_mode @@ -2041,35 +2123,30 @@ class DisplayController: server = self._control_server return bool(server is not None and server.has_pending) - def _screen_preempted(self, active_mode: Optional[str]) -> bool: - """What ends a screen mid-way: the checks the frame loops make.""" - return (self.current_display_mode != active_mode - or not self.is_display_active - or self._wifi_notice_pending() - or self._plugin_reload_pending) - - def _wait_frame_interval(self, interval: float, active_mode: Optional[str]) -> bool: + def _wait_frame_interval(self, interval: float, screen: Screen) -> Optional[ScreenPlan]: """The static screen's sleep between frames, woken by socket commands. Each command that arrives is applied at once (_service_pending_changes - skips its floor while one is queued). True when that ended the - screen; otherwise the wait carries on to the end of the interval, so - the frame cadence is unchanged by a command that does not change the - screen (a brightness, say). + skips its floor while one is queued). Returns the plan that takes the + panel when that ended the screen (see _screen_check); otherwise the + wait carries on to the end of the interval, so the frame cadence is + unchanged by a command that does not change the screen (a + brightness, say), and returns None. """ if self._control_server is None: time.sleep(interval) - return False + return None deadline = time.monotonic() + interval while True: remaining = deadline - time.monotonic() if remaining <= 0: - return False + return None if not self._wait_for_control(remaining): - return False + return None self._service_pending_changes() - if self._screen_preempted(active_mode): - return True + by = self._screen_check(screen, FRAME) + if by is not None: + return by def _apply_control_brightness(self, command: QueuedCommand) -> None: """``brightness.set``: the new normal brightness, on the panel now. @@ -2882,27 +2959,23 @@ class DisplayController: rotation resumes from there instead of continuing after the live plugin's mode (which would skip every mode between the two). The save happens only on the initial switch, not on each re-check while the - live hold continues. + live hold continues. The steps are ArbiterState.claim_live and + release_live; this applies them. """ + state = self._arbiter_state() if live_priority_mode: - if self.current_display_mode != live_priority_mode: + nxt = state.claim_live(live_priority_mode) + if nxt is not state: logger.info("Live content detected - switching immediately to %s", live_priority_mode) - if self._live_resume_index is None: - self._live_resume_index = self.current_mode_index - self.current_display_mode = live_priority_mode + self._adopt_state(nxt) self.force_change = True - # Update mode index to match the new mode - try: - self.current_mode_index = self.available_modes.index(live_priority_mode) - except ValueError: - pass - elif self._live_resume_index is not None and self.available_modes: - # Live priority ended — resume rotation where it was interrupted. - self.current_mode_index = self._live_resume_index % len(self.available_modes) - self.current_display_mode = self.available_modes[self.current_mode_index] - self.force_change = True - logger.info("Live priority ended - resuming rotation at %s", self.current_display_mode) - self._live_resume_index = None + else: + nxt = state.release_live() + if nxt is not state: + # Live priority ended — resume rotation where it was interrupted. + self._adopt_state(nxt) + self.force_change = True + logger.info("Live priority ended - resuming rotation at %s", self.current_display_mode) def _collect_live_modes(self): """Return every currently live-priority mode, in registration order. @@ -2970,16 +3043,10 @@ class DisplayController: currently shown, so each dwell advances to the next live game. The currently-displayed mode is the cursor, so this stays correct as games start and end (no separate index to keep in sync). + + The pick is display_arbiter.live_pick, which the Live Source uses. """ - live_modes = self._collect_live_modes() - if not live_modes: - return None - if self.current_display_mode in live_modes: - if advance: - idx = live_modes.index(self.current_display_mode) - return live_modes[(idx + 1) % len(live_modes)] - return self.current_display_mode - return live_modes[0] + return live_pick(tuple(self._collect_live_modes()), self.current_display_mode, advance) #: Shortest gap between live-priority scans made mid-screen. A scan asks #: every live-priority plugin has_live_content(), which the scoreboards @@ -2996,42 +3063,81 @@ class DisplayController: #: shown yet, so the next pass must not advance the live round-robin past it. _live_takeover_unshown: bool = False - def _check_live_takeover(self) -> None: - """Hand the panel to a live game that started while a screen runs. + def _scan_for_takeover(self) -> Optional[Tuple[str, ...]]: + """The live-priority scan a mid-screen check makes, when one is due. - Called from the frame loops and the dwell sleep. Live priority used - to be checked only between screens, so a game that went live during - a 30 s screen waited for it to end. This switches current_display_mode - to the live mode, which ends the screen the way any other mode change - does. Throttled to LIVE_TAKEOVER_INTERVAL since the last scan of any - kind. Nothing happens while an on-demand session is active, while - the panel is scheduled off, while Vegas keeps live content in its - ticker, or when the screen showing is already a live mode. + Live priority used to be checked only between screens, so a game + that went live during a 30 s screen waited for it to end. The frame + loops and the dwell sleep now scan for one, throttled to + LIVE_TAKEOVER_INTERVAL since the last scan of any kind. None (no + scan) while an on-demand session is active, while the panel is + scheduled off, while Vegas keeps live content in its ticker, or when + the screen showing is already a live mode -- a live screen is not + rescanned at all: live priority put it there, and live games take + turns between screens, not mid-screen. - A live screen is not rescanned at all: live priority put it there, - and live games take turns between screens, not mid-screen. + Whether what it found takes the panel is the Arbiter's call + (display_arbiter.live_takeover). """ if self.current_display_mode in self._last_live_modes: - return + return None last = self._last_live_scan if last is not None and time.monotonic() - last < self.LIVE_TAKEOVER_INTERVAL: - return + return None if self.on_demand_active or not self.is_display_active: - return + return None try: coordinator = getattr(self, 'vegas_coordinator', None) if (coordinator is not None and coordinator.is_enabled and self._vegas_keeps_live_in_ticker()): - return - live_modes = self._collect_live_modes() - if not live_modes or self.current_display_mode in live_modes: - return - self._apply_live_priority(live_modes[0]) - self._live_takeover_unshown = True + return None + return tuple(self._collect_live_modes()) except Exception: # pylint: disable=broad-except # Called from inside the frame loops; a failure here must not # take the display loop down with it. logger.exception("Error checking for a live-priority takeover") + return None + + def _claim_live_takeover(self, mode: str) -> None: + """A live game takes the panel mid-screen: switch current_display_mode + to it (which ends the screen) and keep the next pass from advancing + the live round-robin past it before it has shown.""" + try: + self._apply_live_priority(mode) + self._live_takeover_unshown = True + except Exception: # pylint: disable=broad-except + logger.exception("Error checking for a live-priority takeover") + + def _mid_screen_inputs(self, live_scan: Optional[Tuple[str, ...]] = None, + reload: bool = False) -> ArbiterInputs: + """The Arbiter's inputs between frames and in a dwell, without the + WiFi notice (read separately, only when it could decide anything). + + A follower is not looked at mid-screen, and the Vegas flags are left + out: the scan is None whenever the ticker keeps live content. + """ + return ArbiterInputs( + schedule_on=self.is_display_active and not self.on_demand_schedule_override, + on_demand_active=self.on_demand_active, + follower_active=False, + live_modes=live_scan, + reload_pending=reload and self._plugin_reload_pending, + ) + + def _check_live_takeover(self) -> None: + """Hand the panel to a live game that started while the dwell sleeps. + + The dwell's half of what the frame loops do at their service points + (_screen_check): scan when one is due (_scan_for_takeover), and if + the Arbiter says a live mode takes over, claim it. + """ + live_scan = self._scan_for_takeover() + if live_scan is None: + return + mode = live_takeover(ArbiterState(current_mode=self.current_display_mode), + self._mid_screen_inputs(live_scan)) + if mode is not None: + self._claim_live_takeover(mode) # -- Pieces of run() -------------------------------------------------- # Extracted from run() unchanged, as the first step of restructuring it @@ -3172,7 +3278,7 @@ class DisplayController: Returns True when the message was drawn, and the pass ends there (no rotation). A message that fails to draw is treated as no - message: the pass carries on as a LEGACY plan. + message: the pass carries on to the Sources below the notice. """ self._end_scroll_before_core_screen() if not self._display_wifi_status_message( @@ -3188,44 +3294,144 @@ class DisplayController: def _wifi_notice_pending(self) -> bool: """True when a WiFi notice should end the current screen early. - Polled from the frame loops, the dwell sleep and after a Vegas - iteration yields, so a notice preempts whatever is on the panel - within about a second instead of waiting for the screen to end -- - by which time a short notice has usually expired unseen. Cheap at - frame rate: _check_wifi_status_message stats the file at most once - a second. The rule is display_arbiter.wifi_notice_preempts; the - file is not read at all while on-demand, which outranks the notice, - is active. + Polled from the dwell sleep and after a Vegas iteration yields (the + frame loops ask the same rule through _screen_check), so a notice + preempts whatever is on the panel within about a second instead of + waiting for the screen to end -- by which time a short notice has + usually expired unseen. Cheap at frame rate: + _check_wifi_status_message stats the file at most once a second. + The rule is display_arbiter.wifi_notice_preempts; the file is not + read at all while on-demand, which outranks the notice, is active. """ if self.on_demand_active: return False return wifi_notice_preempts(self._read_wifi_notice(), self.on_demand_active, time.time()) - def _resolve_active_mode(self): - """The mode this pass shows: the on-demand session's current mode - while one is active, else the rotation's. + def _arbiter_state(self) -> ArbiterState: + """What the Arbiter remembers, read from the controller's fields. - Moves current_display_mode onto the on-demand mode (forcing a clear) - when they differ, and ends an on-demand session that has no modes. - None when the rotation has no current mode. + The controller's attributes stay the record (the web UI, the control + socket and the on-demand cache read them); this is the frozen copy + decide() and the state transitions work on. """ - if not self.on_demand_active: - return self.current_display_mode - # Guard against empty on_demand_modes - if not self.on_demand_modes: - logger.warning("On-demand active but no modes available, clearing on-demand mode") - self._clear_on_demand(reason='no-modes-available') - return self.current_display_mode - # Rotate through on-demand plugin modes - if self.on_demand_mode_index >= len(self.on_demand_modes): - # Reset to first mode if index is out of bounds - self.on_demand_mode_index = 0 - active_mode = self.on_demand_modes[self.on_demand_mode_index] - if self.current_display_mode != active_mode: - self.current_display_mode = active_mode - self.force_change = True - return active_mode + return ArbiterState( + current_mode=self.current_display_mode, + on_demand_modes=tuple(self.on_demand_modes or ()), + on_demand_index=self.on_demand_mode_index, + on_demand_expires_at=self.on_demand_expires_at, + on_demand_pinned=bool(self.on_demand_pinned), + rotation=tuple(self.available_modes), + rotation_index=self.current_mode_index, + live_resume_index=self._live_resume_index, + live_takeover_unshown=self._live_takeover_unshown, + ) + + def _adopt_state(self, state: ArbiterState) -> None: + """Write a state transition's result back to the controller's fields. + + Only the fields a transition moves: the rotation's mode list and the + on-demand session's list and expiry are not the Arbiter's to change. + """ + self.current_display_mode = state.current_mode + self.current_mode_index = state.rotation_index + self.on_demand_mode_index = state.on_demand_index + self._live_resume_index = state.live_resume_index + self._live_takeover_unshown = state.live_takeover_unshown + + def _arbiter_inputs_below_wifi(self, inputs: ArbiterInputs) -> ArbiterInputs: + """``inputs`` with what the Sources below the WiFi notice read. + + Whether Vegas is on (_is_vegas_mode_active, which also applies a + pending Vegas start and queued Vegas config), whether it keeps live + content in its ticker, and the live-priority scan -- made unless an + on-demand session holds the panel or the ticker keeps live content, + exactly the passes that scanned before. No notice is pending here: + one that drew ended the pass, and one that failed to draw is + treated as none. + """ + vegas = self._is_vegas_mode_active() + keeps = self._vegas_keeps_live_in_ticker() + live = None + if not self.on_demand_active and not (vegas and keeps): + live = tuple(self._collect_live_modes()) + return replace(inputs, wifi_notice=None, live_modes=live, + vegas_enabled=vegas, vegas_live_in_ticker=keeps) + + def _run_vegas_iteration(self, inputs: ArbiterInputs) -> Optional[ScreenPlan]: + """One Vegas iteration (the LEGACY plan), and what follows it. + + None when the pass ends here: the iteration ran its course, or it + yielded for the schedule, a plugin reload or a WiFi notice (the next + pass shows the notice, not a rotation screen that would outlast a + short one). Otherwise the plan for the screen it fell through to, + decided with ``vegas_yielded``: a live game that stopped the ticker + shows now (step 7 ran before the game went live, so without this a + rotation screen showed first and the game a screen later), an + on-demand session that started mid-iteration shows, else the + rotation's mode. The yield path checked the schedule itself and + never looked at a follower, so the gate and Follower answers are + carried over from the top of the pass. + """ + try: + # Run Vegas mode iteration + if self.vegas_coordinator.run_iteration(): + # Vegas completed an iteration, continue to next loop + return None + # Vegas was interrupted, 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. + return None + if self._plugin_reload_pending: + # Reload first (top of the loop), then the ticker carries on. + return None + if self._wifi_notice_pending(): + # Checked before live content: WiFi outranks live, and a + # later pass switches to the game. + return None + keeps = self._vegas_keeps_live_in_ticker() + live = None + if not self.on_demand_active and not keeps: + live = tuple(self._collect_live_modes()) + yielded = replace(inputs, on_demand_active=self.on_demand_active, + live_modes=live, vegas_live_in_ticker=keeps, + vegas_yielded=True) + except Exception: # pylint: disable=broad-except + logger.exception("Vegas mode error") + # Fall through to normal rotation on error + yielded = replace(inputs, on_demand_active=self.on_demand_active, + live_modes=None, vegas_yielded=True) + return self._take_plan(Arbiter.decide(self._arbiter_state(), yielded, time.time())) + + def _take_plan(self, plan: ScreenPlan) -> ScreenPlan: + """Put ``plan`` in the controller's state before its screen runs. + + A live plan claims the panel for its mode (_apply_live_priority); a + plan that ``ends_live`` resumes the rotation where live priority + interrupted it. An on-demand plan moves current_display_mode onto the + session's mode (forcing a clear) when they differ; one with no mode + ends a session that has no modes left, and the rotation's mode shows + instead. Returns the plan the screen runs. + """ + if plan.source is Source.LIVE: + self._apply_live_priority(plan.mode) + elif plan.ends_live: + self._apply_live_priority(None) + # The takeover's mode is chosen and about to show (or the pass has + # moved past it): the round-robin may advance from here on. + self._live_takeover_unshown = False + if plan.source is Source.ON_DEMAND: + if plan.mode is None: + logger.warning("On-demand active but no modes available, clearing on-demand mode") + self._clear_on_demand(reason='no-modes-available') + return rotation_plan(self._arbiter_state()) + self.on_demand_mode_index = self._arbiter_state().showing(plan).on_demand_index + if self.current_display_mode != plan.mode: + self.current_display_mode = plan.mode + self.force_change = True + return plan def _plugin_for_mode(self, mode: Optional[str]): """The plugin to draw ``mode``, or None to skip it this pass. @@ -3634,20 +3840,6 @@ class DisplayController: max_duration = 15.0 return min_duration, max_duration - def _clamp_to_on_demand(self, min_duration: float, - max_duration: float) -> Optional[Tuple[float, float]]: - """Shorten a screen's (min, max) to what is left of a timed on-demand - session. None when the session has no time left (nothing to show). - """ - if self.on_demand_active: - remaining = self._get_on_demand_remaining() - if remaining is not None: - min_duration = min(min_duration, remaining) - max_duration = min(max_duration, remaining) - if max_duration <= 0: - return None - return min_duration, max_duration - def _needs_high_fps(self, plugin, active_mode: str, log: bool = True) -> bool: """Whether a screen runs the high-FPS (8 ms) loop or the 1 s one. @@ -3691,43 +3883,151 @@ class DisplayController: ) return needs_high_fps - def _advance_after_screen(self, active_mode: Optional[str]) -> None: + def _advance_after_screen(self, plan: ScreenPlan, outcome: Outcome) -> None: """Pick the next mode once a screen has run its course. - An on-demand session moves to its next mode (one with no modes left - is ended, and the rotation advances instead). Otherwise the rotation - advances -- unless the mode just shown is a live-priority mode that - is still live, which holds the panel. + ArbiterState.after makes the step; this gathers what it needs and + applies it. An on-demand session moves to its next mode (one with no + modes left is ended first, and the rotation advances instead). + Otherwise the rotation advances -- unless the mode just shown is a + live-priority mode that is still live, which holds the panel. """ - if self.on_demand_active: + if self.on_demand_active and not self.on_demand_modes: # Guard against empty on_demand_modes to prevent ZeroDivisionError - if not self.on_demand_modes: - logger.warning("On-demand active but no modes available, clearing on-demand mode") - self._clear_on_demand(reason='no-modes-available') - # Fall through to normal rotation - else: - self._advance_on_demand() - return - - # Check for live priority - don't rotate if current plugin has live content - should_rotate = True - if active_mode in self.plugin_modes: - plugin_instance = self.plugin_modes[active_mode] - if hasattr(plugin_instance, 'has_live_priority') and hasattr(plugin_instance, 'has_live_content'): - try: - if plugin_instance.has_live_priority() and plugin_instance.has_live_content(): - logger.info("Live priority active for %s - staying on current mode", active_mode) - should_rotate = False - except Exception as e: - logger.warning("Error checking live priority for %s: %s", active_mode, e) - - if should_rotate and self.available_modes: - self.current_mode_index = (self.current_mode_index + 1) % len(self.available_modes) - self.current_display_mode = self.available_modes[self.current_mode_index] + logger.warning("On-demand active but no modes available, clearing on-demand mode") + self._clear_on_demand(reason='no-modes-available') + # Fall through to normal rotation + on_demand = self.on_demand_active + # The live hold is only asked about outside a session. + still_live = not on_demand and self._still_live(plan.mode) + state = self._arbiter_state() + nxt = state.after(replace(outcome, on_demand_active=on_demand, still_live=still_live)) + if on_demand: + self._show_on_demand_step(nxt) + elif nxt is not state: + self._adopt_state(nxt) self.force_change = True - logger.info("Switching to mode: %s", self.current_display_mode) + def _still_live(self, mode: Optional[str]) -> bool: + """The live hold: ``mode``'s plugin has live priority and live content, + so the rotation stays on it.""" + if mode not in self.plugin_modes: + return False + plugin_instance = self.plugin_modes[mode] + if not (hasattr(plugin_instance, 'has_live_priority') + and hasattr(plugin_instance, 'has_live_content')): + return False + try: + if plugin_instance.has_live_priority() and plugin_instance.has_live_content(): + logger.info("Live priority active for %s - staying on current mode", mode) + return True + except Exception as e: # pylint: disable=broad-except + logger.warning("Error checking live priority for %s: %s", mode, e) + return False + + # -- One screen (ScreenHost for src/screen_runner.py) ------------------- + + def _complete_plan(self, plan: ScreenPlan, plugin) -> Optional[ScreenPlan]: + """``plan`` with what its plugin answers, read after the first frame. + + The durations (_resolve_durations, then the on-demand session's + bound), whether the plugin runs a dynamic cycle and which frame loop + it needs (_needs_high_fps, read again here, after the first dispatch, + as it always was: a plugin may settle it in that display() call). + None when an on-demand session has no time left for the screen; its + expiry is applied before returning. + """ + self._empty_pass_streak = 0 + active_mode = plan.mode + # Get base duration for current mode + base_duration = self._get_display_duration(active_mode) + dynamic_enabled = self._plugin_supports_dynamic(plugin) + + # Log dynamic duration status + if dynamic_enabled: + logger.debug( + "Dynamic duration enabled for mode %s (plugin: %s)", + active_mode, + getattr(plugin, "plugin_id", "unknown"), + ) + + self._track_dynamic_cycle(plugin, active_mode, dynamic_enabled) + min_duration, max_duration = self._resolve_durations( + plugin, active_mode, base_duration, dynamic_enabled) + + # The OnDemand Source's bound: what is left of a timed session. + bounds = on_demand_bound(min_duration, max_duration, plan.deadline, time.time()) + if bounds is None: + self._check_on_demand_expiration() + return None + min_duration, max_duration = bounds + + needs_high_fps = self._needs_high_fps(plugin, active_mode) + return replace( + plan, + plugin=getattr(plugin, 'plugin_id', None), + min_duration=min_duration, + max_duration=max_duration, + dynamic=dynamic_enabled, + frame_policy=FramePolicy.HIGH_FPS if needs_high_fps else FramePolicy.STATIC, + ) + + def _screen_service(self, screen: Screen) -> Optional[Tuple[str, ...]]: + """Between frames: apply pending changes, then the live scan if due. + + The scan's answer is weighed at the service point that follows + (_screen_check); in the 125 Hz loop that is after the frame's sleep, + where the loop has always checked whether the screen should end. + """ + # Throttled: one clock compare between passes. + self._service_pending_changes() + return self._scan_for_takeover() + + def _screen_check(self, screen: Screen, checkpoint: Checkpoint, + live_scan: Optional[Tuple[str, ...]] = None) -> Optional[ScreenPlan]: + """The ScreenRunner's service point: one Arbiter.decide() call. + + Returns the plan that ends ``screen`` (a live takeover is claimed + first), or None while it holds. What it reads follows + ``checkpoint``: a pending plugin reload counts only between frames, + and the WiFi notice file is read exactly where the loop always read + it (NoticeRead) -- the read is throttled to once a second and deletes + an expired file, so an extra or a missing read would move both. + """ + running = screen.plan + # The mid-screen rules read only the mode on the panel; the full + # snapshot would copy the rotation list on every 125 Hz frame. + state = ArbiterState(current_mode=self.current_display_mode) + inputs = self._mid_screen_inputs(live_scan, reload=checkpoint.reload) + if self._screen_reads_notice(checkpoint, state, inputs, running): + notice = self._read_wifi_notice() + if checkpoint.notice_counts: + inputs = replace(inputs, wifi_notice=notice) + by = Arbiter.decide(state, inputs, time.time(), running=running) + if by is running: + return None + if by.source is Source.LIVE and by.mode is not None: + self._claim_live_takeover(by.mode) + return by + + def _screen_reads_notice(self, checkpoint: Checkpoint, state: ArbiterState, + inputs: ArbiterInputs, running: ScreenPlan) -> bool: + """Whether this service point reads the WiFi notice file. + + Never during an on-demand session, which outranks the notice. Where + the loop read it only if nothing cheaper had already ended the + screen (IF_UNDECIDED), not once the mode has moved, the schedule has + the panel off, or a live game is taking over. + """ + if checkpoint.notice is NoticeRead.NEVER or self.on_demand_active: + return False + if checkpoint.notice is NoticeRead.ALWAYS: + return True + return (self.is_display_active + and state.current_mode == running.mode + and live_takeover(state, inputs) is None) + def run(self): """Run the display controller, switching between displays.""" if not self.available_modes: @@ -3748,7 +4048,8 @@ class DisplayController: self.current_display_mode = self.available_modes[self.current_mode_index] if self.available_modes else 'none' logger.info(f"Initial mode set to: {self.current_display_mode} (index: {self.current_mode_index}, total modes: {len(self.available_modes)})") self._publish_current_mode_state() - + runner = ScreenRunner(_MODULE_CLOCK, _ScreenHost(self), logger) + while True: # Arms the watchdog after the first frame -- or after the # first full pass, when there is nothing to draw -- and pings @@ -3812,10 +4113,12 @@ class DisplayController: # is active). No repaint: this screen's first frame pushes it. self._apply_brightness_target() - # Who gets the panel this pass (src/display_arbiter.py). The - # Arbiter decides the scheduled-off gate, Follower and Wifi; - # a LEGACY plan carries on to the code below. - plan = Arbiter.decide(ArbiterState(), self._arbiter_inputs(), time.time()) + # Who gets the panel this pass (src/display_arbiter.py): the + # scheduled-off gate, Follower, OnDemand and Wifi are decided + # here; the Sources below the notice once their inputs are + # read, further down. + inputs = self._arbiter_inputs() + plan = Arbiter.decide(self._arbiter_state(), inputs, time.time()) if plan.source is Source.SCHEDULED_OFF: self._blank_while_scheduled_off(plan.max_duration) @@ -3842,75 +4145,31 @@ class DisplayController: # WiFi status message: interrupts the rotation, but on-demand # outranks it (the Arbiter's order). Past this point no WiFi # message is showing this pass: one that failed to draw - # carries on as a LEGACY plan. + # carries on as if there were none. if (plan.source is Source.WIFI and plan.notice is not None and self._show_wifi_notice(plan.notice, plan.max_duration)): continue # Skip to next iteration, don't rotate - # Check for live priority content and switch to it immediately. - # advance=True so multiple simultaneously-live games take turns - # (round-robin) instead of pinning to the first plugin. - # Skipped when the ticker is keeping live content: switching - # the rotation underneath Vegas would move current_mode_index - # and stash a resume point for a takeover that never happens. - # After a mid-screen takeover (_check_live_takeover) the live - # mode is already chosen but not shown yet: don't advance past it. - if (not self.on_demand_active - and not (self._is_vegas_mode_active() - and self._vegas_keeps_live_in_ticker())): - live_priority_mode = self._check_live_priority( - advance=not self._live_takeover_unshown) - self._apply_live_priority(live_priority_mode) - self._live_takeover_unshown = False + # The Sources below the notice: OnDemand, Live, Vegas and the + # rotation. Their inputs (whether Vegas is on, the live scan) + # are read only now, where run() always read them: a scan + # asks every live-priority plugin, and the Vegas check + # applies queued Vegas config. + below = self._arbiter_inputs_below_wifi(inputs) + plan = self._take_plan(Arbiter.decide(self._arbiter_state(), below, time.time())) - # Vegas scroll mode - continuous ticker across all plugins - # Priority: on-demand > wifi-status > live-priority > vegas > normal rotation - if self._is_vegas_mode_active(): - # Live content normally preempts the ticker entirely. With - # vegas_scroll.live_in_ticker the marquee keeps running and - # the live plugin takes extra turns inside it instead -- - # see StreamManager._apply_priority_weights. - live_mode = (None if self._vegas_keeps_live_in_ticker() - else self._check_live_priority()) - if not live_mode: - try: - # Run Vegas mode iteration - if self.vegas_coordinator.run_iteration(): - # Vegas completed an iteration, continue to next loop - continue - 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 - if self._plugin_reload_pending: - # Reload first (top of the loop), then - # the ticker carries on. - continue - if self._wifi_notice_pending(): - # It yielded for a WiFi notice: the next - # pass shows it, not a rotation screen - # that would outlast a short notice. - # Checked before live content: WiFi - # outranks live, and step 7 of a later - # pass switches to the game. - continue - # Live content stopped the ticker: switch to - # the game now. Step 7 ran before the game - # went live, so without this a rotation screen - # showed first and the game a screen later. - if (not self.on_demand_active - and not self._vegas_keeps_live_in_ticker()): - live_mode = self._check_live_priority(advance=True) - if live_mode: - self._apply_live_priority(live_mode) - except Exception: - logger.exception("Vegas mode error") - # Fall through to normal rotation on error + # Vegas scroll mode - continuous ticker across all plugins. + # Live content outranks it (a LIVE plan above) unless + # vegas_scroll.live_in_ticker keeps the marquee running and + # gives the live plugin extra turns inside it instead -- see + # StreamManager._apply_priority_weights. + if plan.source is Source.LEGACY: + after_vegas = self._run_vegas_iteration(below) + if after_vegas is None: + continue + plan = after_vegas - active_mode = self._resolve_active_mode() + active_mode = plan.mode if self._active_dynamic_mode and self._active_dynamic_mode != active_mode: self._active_dynamic_mode = None @@ -3924,19 +4183,23 @@ class DisplayController: # Handle plugin-based display modes manager_to_display = self._plugin_for_mode(active_mode) - - # Display the current mode. if not manager_to_display: logger.warning(f"No plugin manager found for mode {active_mode} - skipping display and rotating to next mode") - display_result = False - display_failed_due_to_exception = False - else: - (display_result, display_failed_due_to_exception, - _accepts_display_mode) = self._dispatch_first_frame( - manager_to_display, active_mode) + manager_to_display = None + + # One screen: the first frame, the frame loop, the make-up + # dwell (src/screen_runner.py). + outcome = runner.run(plan, manager_to_display) + + if outcome.exit_reason is ExitReason.PREEMPTED: + # Something else has the panel now (an on-demand start or + # stop, the schedule, a WiFi notice, a live game): the + # next pass shows it, and the rotation does not advance + # past the screen it cut short. + continue # If display() returned False, skip to next mode immediately - if not display_result: + if outcome.exit_reason in (ExitReason.EMPTY, ExitReason.ERROR): was_on_demand = self.on_demand_active self._note_empty_pass() # The pause returns early when an on-demand request, its @@ -3954,293 +4217,22 @@ class DisplayController: continue self._advance_on_demand() continue - else: - # Routine (no live game right now): DEBUG, see "Processing mode" above. - logger.debug("No content to display for %s, skipping to next mode", active_mode) - # Don't clear display when immediately moving to next mode - this causes black flashes - # The next mode will render immediately with force_clear=True, which is sufficient + # Routine (no live game right now): DEBUG, see "Processing mode" above. + logger.debug("No content to display for %s, skipping to next mode", active_mode) + # Don't clear display when immediately moving to next mode - this causes black flashes + # The next mode will render immediately with force_clear=True, which is sufficient - # Only skip all modes for this plugin if there was an exception (broken plugin) - # If it's just "no content", we should still try other modes (recent, upcoming) - if (display_failed_due_to_exception - and self._skip_failed_plugin_modes(active_mode)): - # Already set next mode, skip to next iteration - continue - # If no exception (just no content), fall through to normal rotation logic - # This allows trying other modes (recent, upcoming) from the same plugin - else: - self._empty_pass_streak = 0 - # Get base duration for current mode - base_duration = self._get_display_duration(active_mode) - dynamic_enabled = self._plugin_supports_dynamic(manager_to_display) - - # Log dynamic duration status - if dynamic_enabled: - logger.debug( - "Dynamic duration enabled for mode %s (plugin: %s)", - active_mode, - getattr(manager_to_display, "plugin_id", "unknown"), - ) - - self._track_dynamic_cycle(manager_to_display, active_mode, dynamic_enabled) - min_duration, max_duration = self._resolve_durations( - manager_to_display, active_mode, base_duration, dynamic_enabled) - - bounds = self._clamp_to_on_demand(min_duration, max_duration) - if bounds is None: - self._check_on_demand_expiration() + # Only skip all modes for this plugin if there was an exception (broken plugin) + # If it's just "no content", we should still try other modes (recent, upcoming) + if (outcome.exit_reason is ExitReason.ERROR + and self._skip_failed_plugin_modes(active_mode)): + # Already set next mode, skip to next iteration continue - min_duration, max_duration = bounds - - # High-FPS decision; see _needs_high_fps for the order. - # Read again here, after the first dispatch, as it always - # was: a plugin may settle it in that display() call. - needs_high_fps = self._needs_high_fps(manager_to_display, active_mode) - - target_duration = max_duration - start_time = time.time() - - def _should_exit_dynamic(elapsed_time: float) -> bool: - if not dynamic_enabled: - return False - # Add small grace period (0.5s) after min_duration to prevent - # premature exits due to timing issues - grace_period = 0.5 - if elapsed_time < min_duration + grace_period: - logger.debug( - "_should_exit_dynamic: elapsed %.2fs < min_duration %.2fs + grace %.2fs, returning False", - elapsed_time, - min_duration, - grace_period, - ) - return False - cycle_complete = self._plugin_cycle_complete(manager_to_display) - logger.debug( - "_should_exit_dynamic: elapsed %.2fs >= min %.2fs, cycle_complete=%s, returning %s", - elapsed_time, - min_duration + grace_period, - cycle_complete, - cycle_complete, - ) - if cycle_complete: - logger.debug( - "Cycle complete detected for %s after %.2fs (min: %.2fs, grace: %.2fs)", - active_mode, - elapsed_time, - min_duration, - grace_period, - ) - return cycle_complete - - loop_completed = False - - if needs_high_fps: - # Ultra-smooth FPS for scrolling plugins (8ms = 125 FPS) - display_interval = 0.008 - logger.debug( - "Entering high-FPS loop for %s with display_interval=%.3fs (%.1f FPS)", - active_mode, - display_interval, - 1.0 / display_interval - ) - - while True: - _frame_start = time.perf_counter() - try: - result = self._display_once( - manager_to_display, active_mode, _accepts_display_mode, - report_hold=True) - if isinstance(result, bool) and not result: - logger.debug("Display returned False, breaking early") - break - except Exception: # pylint: disable=broad-except - logger.exception("Error during display update") - - # Multi-display sync: send follower frame after each render - self._send_follower_frame(manager_to_display) - - self._tick_plugin_updates_if_due() - # Throttled: one clock compare between passes. - self._service_pending_changes() - self._check_live_takeover() - - # Pace to the frame deadline rather than sleeping a flat - # interval on top of the work. display() has already - # blocked on the panel's vsync by this point, so an - # unconditional sleep is added to a wait that already - # happened. Measured on a 2x128x64 chain at - # limit_refresh_rate_hz=100: ~4ms of render plus a flat - # 8ms put each iteration at ~12ms against a 10ms refresh - # grid, so every swap missed a refresh and the loop - # settled at 50fps where display_interval asks for 125 -- - # and with zero headroom, ~14% of frames slipped a - # further refresh, which is what reads as scroll stutter. - _remaining = display_interval - (time.perf_counter() - _frame_start) - # Yield even when the frame overran its budget, so plugin - # update threads and the web UI are not starved of the GIL. - time.sleep(_remaining if _remaining > 0 else 0.001) - - if self._screen_preempted(active_mode): - logger.debug("Mode changed during high-FPS loop, breaking early") - break - - elapsed = time.time() - start_time - if elapsed >= target_duration: - logger.debug( - "Reached high-FPS target duration %.2fs for mode %s", - target_duration, - active_mode, - ) - loop_completed = True - break - if _should_exit_dynamic(elapsed): - logger.debug( - "Dynamic duration cycle complete for %s after %.2fs", - active_mode, - elapsed, - ) - loop_completed = True - break - else: - # Normal FPS for other plugins (1 second) - display_interval = 1.0 - logger.debug( - "Entering normal FPS loop for %s with display_interval=%.3fs", - active_mode, - display_interval - ) - - while True: - # Wakes for a control socket command and applies - # it at once, instead of up to a second later. - if self._wait_frame_interval(display_interval, active_mode): - logger.info("Mode changed during display loop from %s to %s, " - "breaking early", active_mode, - self.current_display_mode) - break - self._tick_plugin_updates_if_due() - - elapsed = time.time() - start_time - if elapsed >= target_duration: - logger.debug( - "Reached standard target duration %.2fs for mode %s", - target_duration, - active_mode, - ) - loop_completed = True - break - - try: - result = self._display_once( - manager_to_display, active_mode, _accepts_display_mode) - if isinstance(result, bool) and not result: - # For dynamic duration plugins, don't exit on False - keep looping - # until cycle is complete or max duration is reached - if not dynamic_enabled: - logger.info("Display returned False for %s (no dynamic duration), breaking early", active_mode) - break - else: - logger.debug("Display returned False for %s (dynamic duration enabled), continuing loop", active_mode) - except Exception: # pylint: disable=broad-except - logger.exception("Error during display update") - - # Multi-display sync: send follower frame after each render - self._send_follower_frame(manager_to_display) - - self._service_pending_changes() - self._check_live_takeover() - if self._screen_preempted(active_mode): - logger.info("Mode changed during display loop from %s to %s, breaking early", active_mode, self.current_display_mode) - break - - if _should_exit_dynamic(elapsed): - logger.info( - "Dynamic duration cycle complete for %s after %.2fs", - active_mode, - elapsed, - ) - loop_completed = True - break - - # LOAD-BEARING: if current_display_mode changed mid-loop (on-demand - # activation, live priority, etc.), restart the main loop now instead - # of falling into the "honour minimum duration" sleep below. That sleep - # can run for up to the *previous* mode's full display_duration (default - # 30s) and doesn't poll on-demand requests or re-check the mode, so a - # freshly-requested mode switch would sit invisible for up to 30s — or - # get clobbered by a queued stop request — before ever rendering. - # _activate_on_demand already sets force_change=True and clears the - # display, so the next loop iteration renders the new mode immediately. - # Likewise if the schedule turned the display off - # mid-screen (the next iteration blanks it), or a WiFi - # notice arrived (the next iteration shows it, then this - # mode resumes rather than rotating past it). - if (self.current_display_mode != active_mode - or not self.is_display_active - or (not loop_completed and self._wifi_notice_pending())): - continue - # A screen cut short for a plugin reload is over: the - # make-up dwell below returns at once, the rotation - # advances, and the next pass reloads before it draws. - - # Ensure we honour minimum duration when not dynamic and loop ended early - if ( - not dynamic_enabled - and not loop_completed - and not needs_high_fps - ): - elapsed = time.time() - start_time - remaining_sleep = max(0.0, max_duration - elapsed) - if remaining_sleep > 0: - self._sleep_with_plugin_updates(remaining_sleep) - # Cut short by a WiFi notice: show it, then - # resume this mode rather than rotating past it. - if (self._wifi_notice_pending() - and time.time() - start_time < max_duration): - continue - - if dynamic_enabled: - elapsed_total = time.time() - start_time - cycle_done = self._plugin_cycle_complete(manager_to_display) - - # Log cycle completion status and metrics - if cycle_done: - logger.info( - "Dynamic duration cycle completed for %s after %.2fs (target: %.2fs, min: %.2fs, max: %.2fs)", - active_mode, - elapsed_total, - target_duration, - min_duration, - max_duration, - ) - elif elapsed_total >= max_duration: - logger.info( - "Dynamic duration cap reached before cycle completion for %s (%.2fs/%ds, min: %.2fs)", - active_mode, - elapsed_total, - int(max_duration), - min_duration, - ) - else: - logger.debug( - "Dynamic duration cycle in progress for %s: %.2fs elapsed (target: %.2fs, min: %.2fs, max: %.2fs)", - active_mode, - elapsed_total, - target_duration, - min_duration, - 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 + # If no exception (just no content), fall through to normal rotation logic + # This allows trying other modes (recent, upcoming) from the same plugin # Move to next mode - self._advance_after_screen(active_mode) + self._advance_after_screen(plan, outcome) except KeyboardInterrupt: logger.info("Received interrupt signal, shutting down...") diff --git a/src/screen_runner.py b/src/screen_runner.py new file mode 100644 index 00000000..33fb7cc6 --- /dev/null +++ b/src/screen_runner.py @@ -0,0 +1,484 @@ +"""Runs one screen: the ScreenRunner of docs/RUN_LOOP_REDESIGN.md. + +``ScreenRunner.run(plan, plugin)`` draws a screen's first frame, runs the +frame loop its plan's ``frame_policy`` picks (125 Hz or 1 Hz), makes up the +minimum duration when the loop ended early, and returns one +:class:`Outcome` saying why the screen ended. Everything that touches the +plugin, the panel or the controller's state goes through a +:class:`ScreenHost` (the DisplayController); everything that reads or waits +on the clock goes through an injected :class:`FrameClock`. The runner itself +holds no state between screens. + +What can end a screen early is decided at the runner's service points: after +each frame, after the frame loop, and after the make-up dwell. At each one +the host gathers a snapshot and asks the Arbiter, once, whether a Source in +``plan.preemptible_by`` now wants the panel (:meth:`ScreenHost.check`). A yes +is ``ExitReason.PREEMPTED``: the next pass of the loop decides what shows, +and the rotation does not advance past the screen that was cut short. + +The frame pacing is the loop that used to be inline in +``DisplayController.run()``, unchanged: the 125 Hz loop paces to an 8 ms +deadline from the start of each frame (sleeping at least 1 ms, so a frame +that overran still yields the GIL), and the 1 Hz loop sleeps a flat second +between frames, woken early by a control socket command. +""" + +import logging +from dataclasses import dataclass +from enum import Enum +from typing import Any, NamedTuple, Optional, Protocol, Tuple + +from src.display_arbiter import FramePolicy, ScreenPlan, Source + +__all__ = [ + "AFTER_COMPLETED_LOOP", + "AFTER_LOOP", + "Checkpoint", + "DYNAMIC_GRACE", + "ExitReason", + "FINAL", + "FRAME", + "FirstFrame", + "FrameClock", + "HIGH_FPS_INTERVAL", + "NoticeRead", + "Outcome", + "STATIC_INTERVAL", + "Screen", + "ScreenHost", + "ScreenRunner", + "after_dwell", +] + +#: Seconds between frames in the high-FPS loop (125 Hz), for scrolling plugins. +HIGH_FPS_INTERVAL = 0.008 + +#: Seconds between frames in the static loop (1 Hz). +STATIC_INTERVAL = 1.0 + +#: A dynamic-duration screen ends on cycle completion only this long after its +#: minimum, so timing jitter around the minimum can't end it early. +DYNAMIC_GRACE = 0.5 + + +class ExitReason(Enum): + """Why a screen ended. The value is the golden traces' exit column where + one exists (test/test_run_loop_golden.py).""" + + #: The screen ran its target duration. + DURATION = "duration" + #: A dynamic-duration plugin finished its cycle after its minimum. + CYCLE_COMPLETE = "cycle-complete" + #: The first frame had nothing to show (display() returned False or + #: raised inside the executor), or no plugin draws the mode. + EMPTY = "empty" + #: The first frame's dispatch itself raised. + ERROR = "error" + #: A later frame returned False (a dynamic-duration screen on the 1 Hz + #: loop keeps going instead). + DISPLAY_FALSE = "display-false" + #: Another Source took the panel, or an on-demand session ran out before + #: the screen began. The rotation does not advance. + PREEMPTED = "preempted" + #: A plugin reload is waiting for the top of the loop. The screen is cut + #: short but counts as shown: the rotation advances, and the next pass + #: reloads before it draws. + RELOAD = "reload" + + +@dataclass(frozen=True) +class Outcome: + """How a screen ended. + + Attributes: + exit_reason: Why it ended. + elapsed: Seconds from the end of the first frame to the end. + preempted_by: For PREEMPTED, the plan that took the panel when the + Arbiter named one (None when the session simply ran out). + on_demand_active: Filled in by the controller when the screen is + over: an on-demand session was running at that moment. + still_live: Filled in by the controller: the mode's plugin still had + live content at that moment, which holds the rotation on it. + """ + + exit_reason: ExitReason + elapsed: float = 0.0 + preempted_by: Optional[ScreenPlan] = None + on_demand_active: bool = False + still_live: bool = False + + +class FrameClock(Protocol): + """The clocks the runner reads and the sleep it paces with. + + The shape of the ``time`` module, so production passes it (through an + indirection that lets tests patch the module) and the golden traces pass + their fake clock. + """ + + def time(self) -> float: + """Wall-clock seconds: what screen durations are measured in.""" + + def perf_counter(self) -> float: + """A monotonic high-resolution clock: what the 8 ms pacing reads.""" + + def sleep(self, seconds: float) -> None: + """Block for ``seconds``.""" + + +class NoticeRead(Enum): + """When a service point reads the WiFi notice file. + + The read is throttled to once a second and deletes an expired file, so + *when* it happens is behaviour: each service point reads it exactly + when the loop always did. + """ + + #: Not at all. + NEVER = "never" + #: Only if nothing cheaper has already ended the screen: no on-demand + #: session, the panel on, the mode unchanged and no live takeover. + IF_UNDECIDED = "if-undecided" + #: Whenever no on-demand session is running. + ALWAYS = "always" + + +@dataclass(frozen=True) +class Checkpoint: + """What one kind of service point considers. + + Attributes: + name: For logs and tests. + notice: When the WiFi notice is read (see NoticeRead). + notice_counts: Whether a pending notice ends the screen here. After + the make-up dwell it does only while the screen had time left. + reload: Whether a pending plugin reload ends the screen here. Only + between frames: once the frame loop is over the screen is too. + """ + + name: str + notice: NoticeRead + reload: bool + notice_counts: bool = True + + +#: Between frames: the frame loops' check, and the socket wake in the 1 Hz wait. +FRAME = Checkpoint("frame", NoticeRead.IF_UNDECIDED, reload=True) +#: After a frame loop that ended early (display() returned False, a reload). +AFTER_LOOP = Checkpoint("after-loop", NoticeRead.IF_UNDECIDED, reload=False) +#: After a frame loop that ran its course: only a mode change or the schedule. +AFTER_COMPLETED_LOOP = Checkpoint("after-completed-loop", NoticeRead.NEVER, reload=False) +#: The last look before the rotation advances. +FINAL = Checkpoint("final", NoticeRead.NEVER, reload=False) + + +def after_dwell(time_left: bool) -> Checkpoint: + """After the make-up dwell: a notice that cut it short ends the screen, + so the mode resumes after the notice instead of rotating past it.""" + return Checkpoint("after-dwell", NoticeRead.ALWAYS, reload=False, + notice_counts=time_left) + + +class FirstFrame(NamedTuple): + """What the first frame's dispatch returned (see _dispatch_first_frame).""" + + shown: bool + raised: bool + accepts_display_mode: bool + + +@dataclass +class Screen: + """One running screen: its completed plan, the plugin drawing it and + when it started. Mutable only in that the runner owns it.""" + + plan: ScreenPlan + plugin: Any + accepts_display_mode: bool + start: float + + @property + def mode(self) -> Optional[str]: + return self.plan.mode + + +class ScreenHost(Protocol): + """The controller's side of a screen. See DisplayController.""" + + def first_frame(self, plan: ScreenPlan, plugin: Any) -> FirstFrame: + """Draw the first frame through the plugin executor.""" + + def complete_plan(self, plan: ScreenPlan, plugin: Any) -> Optional[ScreenPlan]: + """The plan with the plugin's durations, dynamic flag and frame + policy, read after the first frame. None when an on-demand session + has no time left for it.""" + + def draw(self, screen: Screen) -> Any: + """One later frame: what display() returned.""" + + def after_frame(self, screen: Screen) -> None: + """After a frame that did not end the screen (the follower frame).""" + + def tick(self) -> None: + """Plugin updates that have come due (throttled).""" + + def service(self, screen: Screen) -> Optional[Tuple[str, ...]]: + """Apply pending changes (on-demand requests, schedule, brightness, + finished reloads). Returns the live modes when a live-priority scan + was due, else None.""" + + def wait_frame(self, interval: float, screen: Screen) -> Optional[ScreenPlan]: + """The 1 Hz loop's sleep between frames. The plan that takes the + panel when a control socket command ended the screen, else None.""" + + def check(self, screen: Screen, checkpoint: Checkpoint, + live_scan: Optional[Tuple[str, ...]] = None) -> Optional[ScreenPlan]: + """The service point: the plan that now takes the panel from this + screen, or None while it holds. One Arbiter.decide() call.""" + + def dwell(self, seconds: float) -> None: + """Sleep up to ``seconds``, servicing changes; returns early on one.""" + + def cycle_complete(self, screen: Screen) -> bool: + """The plugin's dynamic-duration cycle is complete.""" + + +class ScreenRunner: + """Runs one screen at a time for a ScreenHost. See the module docstring.""" + + def __init__(self, clock: FrameClock, host: ScreenHost, + log: Optional[logging.Logger] = None): + self.clock = clock + self.host = host + # The controller passes its own logger, so these lines keep the + # source they always had in the journal. + self.log = log or logging.getLogger(__name__) + + # -- the screen ------------------------------------------------------ + + def run(self, plan: ScreenPlan, plugin: Any) -> Outcome: + """Run ``plan``'s screen, drawn by ``plugin`` (None: nothing draws it).""" + if plugin is None: + return Outcome(ExitReason.EMPTY) + first = self.host.first_frame(plan, plugin) + if not first.shown: + return Outcome(ExitReason.ERROR if first.raised else ExitReason.EMPTY) + completed = self.host.complete_plan(plan, plugin) + if completed is None: + return Outcome(ExitReason.PREEMPTED) + screen = Screen(completed, plugin, first.accepts_display_mode, + start=self.clock.time()) + + if completed.frame_policy is FramePolicy.HIGH_FPS: + reason, by = self._high_fps_loop(screen) + else: + reason, by = self._static_loop(screen) + if reason is ExitReason.PREEMPTED: + # The service point that ended the loop has decided; looking + # again now, at the same instant, gives the same answer. + return self._outcome(screen, reason, by) + + loop_completed = reason in (ExitReason.DURATION, ExitReason.CYCLE_COMPLETE) + # LOAD-BEARING: a change the frame loop did not end on (a dwell + # inside it, a later frame returning False) must not fall into the + # make-up dwell below. It can run for the rest of the screen's + # duration, and a freshly requested on-demand mode would sit + # invisible for that long -- or be clobbered by a queued stop. + by = self.host.check(screen, AFTER_COMPLETED_LOOP if loop_completed else AFTER_LOOP) + if by is not None: + return self._outcome(screen, ExitReason.PREEMPTED, by) + + # Honour the minimum duration when a static, non-dynamic screen's + # loop ended early. A screen cut short for a plugin reload is over: + # the dwell returns at once and the rotation advances. + if (not completed.dynamic and not loop_completed + and completed.frame_policy is not FramePolicy.HIGH_FPS): + elapsed = self.clock.time() - screen.start + remaining = max(0.0, self._max(screen) - elapsed) + if remaining > 0: + self.host.dwell(remaining) + time_left = self.clock.time() - screen.start < self._max(screen) + by = self.host.check(screen, after_dwell(time_left)) + if by is not None: + return self._outcome(screen, ExitReason.PREEMPTED, by) + + if completed.dynamic: + self._log_dynamic_end(screen) + + # The dwells above return early when a pending change (on-demand + # started or stopped, the panel scheduled off) has already decided + # what comes next; rotating now would skip it. + by = self.host.check(screen, FINAL) + if by is not None: + return self._outcome(screen, ExitReason.PREEMPTED, by) + return self._outcome(screen, reason, None) + + def _outcome(self, screen: Screen, reason: ExitReason, + by: Optional[ScreenPlan]) -> Outcome: + return Outcome(reason, self.clock.time() - screen.start, preempted_by=by) + + @staticmethod + def _max(screen: Screen) -> float: + return float(screen.plan.max_duration or 0.0) + + @staticmethod + def _min(screen: Screen) -> float: + return float(screen.plan.min_duration or 0.0) + + @staticmethod + def _ended_by(by: ScreenPlan) -> ExitReason: + return ExitReason.RELOAD if by.source is Source.RELOAD else ExitReason.PREEMPTED + + # -- the frame loops --------------------------------------------------- + + def _high_fps_loop(self, screen: Screen) -> Tuple[ExitReason, Optional[ScreenPlan]]: + """Ultra-smooth frames for scrolling plugins (8 ms = 125 FPS).""" + clock, host, log = self.clock, self.host, self.log + interval = HIGH_FPS_INTERVAL + log.debug("Entering high-FPS loop for %s with display_interval=%.3fs (%.1f FPS)", + screen.mode, interval, 1.0 / interval) + target = self._max(screen) + while True: + frame_start = clock.perf_counter() + try: + result = host.draw(screen) + if isinstance(result, bool) and not result: + log.debug("Display returned False, breaking early") + return ExitReason.DISPLAY_FALSE, None + except Exception: # pylint: disable=broad-except + log.exception("Error during display update") + + # Multi-display sync: send follower frame after each render + host.after_frame(screen) + host.tick() + # Throttled: one clock compare between passes. A live-priority + # scan, when one is due, happens here, before the sleep, as it + # always has; the Arbiter weighs it after the sleep. + live_scan = host.service(screen) + + # Pace to the frame deadline rather than sleeping a flat + # interval on top of the work. display() has already blocked on + # the panel's vsync by this point, so an unconditional sleep is + # added to a wait that already happened. Measured on a 2x128x64 + # chain at limit_refresh_rate_hz=100: ~4ms of render plus a flat + # 8ms put each iteration at ~12ms against a 10ms refresh grid, so + # every swap missed a refresh and the loop settled at 50fps where + # display_interval asks for 125 -- and with zero headroom, ~14% of + # frames slipped a further refresh, which is what reads as scroll + # stutter. + remaining = interval - (clock.perf_counter() - frame_start) + # Yield even when the frame overran its budget, so plugin update + # threads and the web UI are not starved of the GIL. + clock.sleep(remaining if remaining > 0 else 0.001) + + by = host.check(screen, FRAME, live_scan) + if by is not None: + log.debug("Mode changed during high-FPS loop, breaking early") + return self._ended_by(by), by + + elapsed = clock.time() - screen.start + if elapsed >= target: + log.debug("Reached high-FPS target duration %.2fs for mode %s", + target, screen.mode) + return ExitReason.DURATION, None + if self._should_exit_dynamic(screen, elapsed): + log.debug("Dynamic duration cycle complete for %s after %.2fs", + screen.mode, elapsed) + return ExitReason.CYCLE_COMPLETE, None + + def _static_loop(self, screen: Screen) -> Tuple[ExitReason, Optional[ScreenPlan]]: + """One frame a second for everything else.""" + clock, host, log = self.clock, self.host, self.log + interval = STATIC_INTERVAL + log.debug("Entering normal FPS loop for %s with display_interval=%.3fs", + screen.mode, interval) + target = self._max(screen) + dynamic = screen.plan.dynamic + while True: + # Wakes for a control socket command and applies it at once, + # instead of up to a second later. + by = host.wait_frame(interval, screen) + if by is not None: + log.info("Mode changed during display loop from %s to %s (%s), " + "breaking early", screen.mode, by.mode, by.source.value) + return self._ended_by(by), by + host.tick() + + elapsed = clock.time() - screen.start + if elapsed >= target: + log.debug("Reached standard target duration %.2fs for mode %s", + target, screen.mode) + return ExitReason.DURATION, None + + try: + result = host.draw(screen) + if isinstance(result, bool) and not result: + # A dynamic-duration screen doesn't end on False: it + # keeps looping until its cycle completes or its maximum. + if not dynamic: + log.info("Display returned False for %s (no dynamic duration), " + "breaking early", screen.mode) + return ExitReason.DISPLAY_FALSE, None + log.debug("Display returned False for %s (dynamic duration enabled), " + "continuing loop", screen.mode) + except Exception: # pylint: disable=broad-except + log.exception("Error during display update") + + # Multi-display sync: send follower frame after each render + host.after_frame(screen) + + live_scan = host.service(screen) + by = host.check(screen, FRAME, live_scan) + if by is not None: + log.info("Mode changed during display loop from %s to %s (%s), " + "breaking early", screen.mode, by.mode, by.source.value) + return self._ended_by(by), by + + if self._should_exit_dynamic(screen, elapsed): + log.info("Dynamic duration cycle complete for %s after %.2fs", + screen.mode, elapsed) + return ExitReason.CYCLE_COMPLETE, None + + # -- dynamic duration -------------------------------------------------- + + def _should_exit_dynamic(self, screen: Screen, elapsed: float) -> bool: + if not screen.plan.dynamic: + return False + minimum = self._min(screen) + # A small grace period after min_duration prevents premature exits + # due to timing issues. + if elapsed < minimum + DYNAMIC_GRACE: + self.log.debug( + "_should_exit_dynamic: elapsed %.2fs < min_duration %.2fs + grace %.2fs, " + "returning False", elapsed, minimum, DYNAMIC_GRACE) + return False + cycle_complete = self.host.cycle_complete(screen) + self.log.debug( + "_should_exit_dynamic: elapsed %.2fs >= min %.2fs, cycle_complete=%s, returning %s", + elapsed, minimum + DYNAMIC_GRACE, cycle_complete, cycle_complete) + if cycle_complete: + self.log.debug("Cycle complete detected for %s after %.2fs (min: %.2fs, grace: %.2fs)", + screen.mode, elapsed, minimum, DYNAMIC_GRACE) + return cycle_complete + + def _log_dynamic_end(self, screen: Screen) -> None: + """How a dynamic-duration screen ended, for the log. Asks the plugin + once more whether its cycle is complete, as the loop always did.""" + elapsed_total = self.clock.time() - screen.start + cycle_done = self.host.cycle_complete(screen) + minimum, maximum = self._min(screen), self._max(screen) + if cycle_done: + self.log.info( + "Dynamic duration cycle completed for %s after %.2fs " + "(target: %.2fs, min: %.2fs, max: %.2fs)", + screen.mode, elapsed_total, maximum, minimum, maximum) + elif elapsed_total >= maximum: + self.log.info( + "Dynamic duration cap reached before cycle completion for %s " + "(%.2fs/%ds, min: %.2fs)", + screen.mode, elapsed_total, int(maximum), minimum) + else: + self.log.debug( + "Dynamic duration cycle in progress for %s: %.2fs elapsed " + "(target: %.2fs, min: %.2fs, max: %.2fs)", + screen.mode, elapsed_total, maximum, minimum, maximum) diff --git a/test/test_display_arbiter.py b/test/test_display_arbiter.py index 7ef7b6e8..b6e603b5 100644 --- a/test/test_display_arbiter.py +++ b/test/test_display_arbiter.py @@ -9,6 +9,7 @@ that overrides the schedule and ends (#714). """ import itertools +from dataclasses import replace import os from unittest.mock import MagicMock, patch @@ -19,6 +20,7 @@ os.environ.setdefault("EMULATOR", "true") from src import display_arbiter # noqa: E402 from src.display_arbiter import ( # noqa: E402 SCHEDULED_OFF_DWELL, + SCREEN_PREEMPTERS, WIFI_NOTICE_DWELL, Arbiter, ArbiterInputs, @@ -26,6 +28,9 @@ from src.display_arbiter import ( # noqa: E402 ScreenPlan, Source, WifiNotice, + live_pick, + live_takeover, + on_demand_bound, wifi_notice_preempts, ) @@ -34,7 +39,9 @@ NOTICE = WifiNotice(message="Connected to HomeNet", expires_at=1_000.0) OFF = Source.SCHEDULED_OFF FOLLOW = Source.FOLLOWER WIFI = Source.WIFI +ONDEM = Source.ON_DEMAND LEGACY = Source.LEGACY +ROTATION = Source.ROTATION # (schedule_on, on_demand_active, follower_active, notice) -> Source. # Every combination of the stage-2 inputs: 2 x 2 x 2 x 2 = 16 rows. @@ -46,17 +53,17 @@ DECIDE_TABLE = [ (False, False, True, None, OFF), (False, False, True, NOTICE, OFF), # Scheduled off, but on-demand overrides the gate. - (False, True, False, None, LEGACY), # on-demand: run() decides - (False, True, False, NOTICE, LEGACY), # on-demand outranks WiFi + (False, True, False, None, ONDEM), # on-demand overrides the gate + (False, True, False, NOTICE, ONDEM), # on-demand outranks WiFi (False, True, True, None, FOLLOW), # follower outranks on-demand (False, True, True, NOTICE, FOLLOW), # Scheduled on. - (True, False, False, None, LEGACY), # live / Vegas / rotation + (True, False, False, None, ROTATION), # live / Vegas / rotation (True, False, False, NOTICE, WIFI), (True, False, True, None, FOLLOW), (True, False, True, NOTICE, FOLLOW), # follower outranks WiFi - (True, True, False, None, LEGACY), - (True, True, False, NOTICE, LEGACY), # on-demand outranks WiFi + (True, True, False, None, ONDEM), + (True, True, False, NOTICE, ONDEM), # on-demand outranks WiFi (True, True, True, None, FOLLOW), (True, True, True, NOTICE, FOLLOW), ] @@ -95,8 +102,15 @@ class TestDecide: elif expected is WIFI: assert plan == ScreenPlan(WIFI, max_duration=WIFI_NOTICE_DWELL, notice=NOTICE) + elif expected is ONDEM: + # An empty state: a session with no modes, which the + # controller ends (see TestOnDemand for real sessions). + assert plan == ScreenPlan(ONDEM) + elif expected is ROTATION: + # No live scan and Vegas off: the rotation's (empty) mode. + assert plan == ScreenPlan(ROTATION, preemptible_by=SCREEN_PREEMPTERS) else: - # A follower paces itself; LEGACY is run()'s existing code. + # A follower paces itself. assert plan == ScreenPlan(expected) def test_dwells_are_todays(self): @@ -224,16 +238,389 @@ class TestControllerSnapshot: assert (plan.source is OFF) is (not dc.is_display_active), time_str return plan.source - assert step("22:59:30") is LEGACY + assert step("22:59:30") is ROTATION assert step("23:00:00") is OFF # window ends dc.on_demand_active = True - assert step("23:00:10") is LEGACY # on-demand overrides + assert step("23:00:10") is ONDEM # on-demand overrides assert dc.on_demand_schedule_override is True - assert step("23:01:00") is LEGACY # next minute, still on + assert step("23:01:00") is ONDEM # next minute, still on dc._reset_on_demand_fields() # session ends assert step("23:01:20") is OFF # same minute: blanks dc.on_demand_active = True - assert step("06:59:00") is LEGACY - assert step("07:00:00") is LEGACY # schedule back on mid-session + assert step("06:59:00") is ONDEM + assert step("07:00:00") is ONDEM # schedule back on mid-session dc._reset_on_demand_fields() - assert step("07:00:30") is LEGACY + assert step("07:00:30") is ROTATION + + +# -- OnDemand (stage 3) --------------------------------------------------- + +ON = ArbiterInputs(schedule_on=True, on_demand_active=True, follower_active=False) + + +def _session(modes=("a", "b", "c"), index=0, expires_at=None, current=None): + return ArbiterState(current_mode=current, on_demand_modes=tuple(modes), + on_demand_index=index, on_demand_expires_at=expires_at) + + +class TestOnDemand: + + # (modes, index, expires_at, now) -> (mode, max_duration) + TABLE = [ + (("a", "b", "c"), 0, None, 100.0, "a", None), # untimed + (("a", "b", "c"), 2, None, 100.0, "c", None), + (("a", "b", "c"), 3, None, 100.0, "a", None), # past the end: 0 + (("a", "b", "c"), 9, None, 100.0, "a", None), + (("a",), 0, 130.0, 100.0, "a", 30.0), # 30 s left + (("a",), 0, 130.0, 130.0, "a", 0.0), # none left + (("a",), 0, 130.0, 200.0, "a", 0.0), # never negative + ] + + @pytest.mark.parametrize("modes,index,expires_at,now,mode,max_duration", TABLE) + def test_current_mode_and_time_left(self, modes, index, expires_at, now, mode, + max_duration): + plan = Arbiter.decide(_session(modes, index, expires_at), ON, now) + assert plan.source is ONDEM + assert plan.mode == mode + assert plan.max_duration == max_duration + assert plan.deadline == expires_at + + def test_no_modes_left_is_a_plan_with_no_mode(self): + plan = Arbiter.decide(_session(modes=()), ON, 0.0) + assert plan == ScreenPlan(ONDEM) + + def test_preemptible_by_the_schedule_a_reload_and_its_own_changes(self): + plan = Arbiter.decide(_session(), ON, 0.0) + assert {Source.SCHEDULED_OFF, ONDEM, Source.RELOAD} <= plan.preemptible_by + assert Source.FOLLOWER not in plan.preemptible_by + + @pytest.mark.parametrize("index,expected_index,expected_mode", [ + (0, 1, "b"), (1, 2, "c"), (2, 0, "a")]) + def test_next_on_demand_wraps(self, index, expected_index, expected_mode): + nxt = _session(index=index).next_on_demand() + assert (nxt.on_demand_index, nxt.current_mode) == (expected_index, expected_mode) + + def test_showing_a_plan_resets_an_index_past_the_end(self): + state = _session(index=5, current="x") + plan = Arbiter.decide(state, ON, 0.0) + shown = state.showing(plan) + assert (shown.on_demand_index, shown.current_mode) == (0, "a") + assert state.on_demand_index == 5 # not mutated + + +# (min, max, deadline, now) -> bounds. The bound applied after the first frame. +BOUND_TABLE = [ + (10.0, 20.0, None, 0.0, (10.0, 20.0)), # untimed: unchanged + (10.0, 20.0, 100.0, 50.0, (10.0, 20.0)), # plenty left + (10.0, 20.0, 100.0, 85.0, (10.0, 15.0)), # max cut to what is left + (10.0, 20.0, 100.0, 95.0, (5.0, 5.0)), # both cut + (10.0, 20.0, 100.0, 100.0, None), # nothing left + (10.0, 20.0, 100.0, 150.0, None), +] + + +@pytest.mark.parametrize("min_d,max_d,deadline,now,expected", BOUND_TABLE) +def test_on_demand_bound(min_d, max_d, deadline, now, expected): + assert on_demand_bound(min_d, max_d, deadline, now) == expected + + +# -- Live (stage 3) ------------------------------------------------------- + +LIVE = Source.LIVE + +# (live_modes, current_mode, advance) -> pick. _check_live_priority's rule. +PICK_TABLE = [ + ((), "clock", True, None), + (None, "clock", True, None), + (("nfl",), "clock", True, "nfl"), # not on a live mode: first + (("nfl", "nhl"), "clock", True, "nfl"), + (("nfl", "nhl"), "clock", False, "nfl"), + (("nfl", "nhl"), "nfl", True, "nhl"), # round-robin + (("nfl", "nhl"), "nhl", True, "nfl"), # wraps + (("nfl", "nhl"), "nhl", False, "nhl"), # a peek stays put + (("nfl",), "nfl", True, "nfl"), # one game: itself +] + + +@pytest.mark.parametrize("live,current,advance,expected", PICK_TABLE) +def test_live_pick(live, current, advance, expected): + assert live_pick(live, current, advance) == expected + + +def _below(live=None, vegas=False, keeps=False, yielded=False, on_demand=False): + """Inputs for the Sources below the notice (no notice, no follower).""" + return ArbiterInputs(schedule_on=True, on_demand_active=on_demand, + follower_active=False, live_modes=live, vegas_enabled=vegas, + vegas_live_in_ticker=keeps, vegas_yielded=yielded) + + +ROT = ("clock", "weather", "nfl_live", "nhl_live") + + +def _rot(current="clock", index=0, resume=None, unshown=False): + return ArbiterState(current_mode=current, rotation=ROT, rotation_index=index, + live_resume_index=resume, live_takeover_unshown=unshown) + + +class TestLive: + + # (state, inputs) -> (source, mode, ends_live) + TABLE = [ + # Nothing live, nothing to resume. + (_rot(), _below(live=()), "below", None, False), + # Not scanned (on-demand, or the ticker keeps live content). + (_rot(), _below(live=None), "below", None, False), + # A game is live: it takes the panel. + (_rot(), _below(live=("nfl_live",)), LIVE, "nfl_live", False), + # Two: round-robin from the one showing. + (_rot("nfl_live", 2), _below(live=("nfl_live", "nhl_live")), LIVE, "nhl_live", False), + # ... unless a mid-screen takeover chose it and it has not shown yet. + (_rot("nfl_live", 2, resume=0, unshown=True), + _below(live=("nfl_live", "nhl_live")), LIVE, "nfl_live", False), + # Vegas keeps live content in its ticker: Live has no say at all, + # not even the resume. + (_rot(), _below(live=("nfl_live",), vegas=True, keeps=True), "below", None, False), + (_rot("nfl_live", 2, resume=1), + _below(live=(), vegas=True, keeps=True), "below", None, False), + # Vegas that yields to live content: Live outranks it. + (_rot(), _below(live=("nfl_live",), vegas=True), LIVE, "nfl_live", False), + # The game ended: the interrupted rotation resumes. + (_rot("nfl_live", 2, resume=1), _below(live=()), "below", None, True), + (_rot("nfl_live", 2, resume=1), _below(live=(), vegas=True), "below", None, True), + # On-demand outranks Live. + (ArbiterState(on_demand_modes=("x",)), _below(live=("nfl_live",), on_demand=True), + ONDEM, "x", False), + ] + + @pytest.mark.parametrize("state,inputs,source,mode,ends_live", TABLE) + def test_decide(self, state, inputs, source, mode, ends_live): + plan = Arbiter.decide(state, inputs, 0.0) + if source == "below": + assert plan.source not in (LIVE, ONDEM, OFF, FOLLOW, WIFI) + else: + assert plan.source is source + assert plan.mode == mode + assert plan.ends_live is ends_live + + def test_a_live_plan_has_no_durations_until_its_first_frame(self): + plan = Arbiter.decide(_rot(), _below(live=("nfl_live",)), 0.0) + assert (plan.min_duration, plan.max_duration, plan.frame_policy) == (None, None, None) + + +class TestLiveTransitions: + + def test_claim_saves_where_the_rotation_was(self): + nxt = _rot("weather", 1).claim_live("nfl_live") + assert (nxt.current_mode, nxt.rotation_index, nxt.live_resume_index) == ("nfl_live", 2, 1) + + def test_a_second_claim_keeps_the_first_resume_point(self): + nxt = _rot("nfl_live", 2, resume=1).claim_live("nhl_live") + assert (nxt.current_mode, nxt.rotation_index, nxt.live_resume_index) == ("nhl_live", 3, 1) + + def test_claiming_the_mode_showing_changes_nothing(self): + state = _rot("nfl_live", 2, resume=1) + assert state.claim_live("nfl_live") is state + + def test_a_live_mode_outside_the_rotation_keeps_the_index(self): + nxt = _rot("weather", 1).claim_live("mlb_live") + assert (nxt.current_mode, nxt.rotation_index, nxt.live_resume_index) == ("mlb_live", 1, 1) + + def test_release_resumes_and_forgets(self): + nxt = _rot("nhl_live", 3, resume=1).release_live() + assert (nxt.current_mode, nxt.rotation_index, nxt.live_resume_index) == ("weather", 1, None) + + def test_release_wraps_a_resume_point_past_a_shortened_rotation(self): + nxt = _rot("nhl_live", 3, resume=6).release_live() + assert (nxt.current_mode, nxt.rotation_index) == ("nfl_live", 2) # 6 % 4 + + def test_release_with_nothing_to_resume_changes_nothing(self): + state = _rot("clock", 0) + assert state.release_live() is state + empty = ArbiterState(current_mode="x", live_resume_index=2) + assert empty.release_live() is empty # no rotation to resume into + + +# -- Vegas and Rotation (stage 3) ----------------------------------------- + +class TestVegasAndRotation: + + # (state, inputs) -> (source, mode, ends_live). LEGACY now means Vegas only. + TABLE = [ + (_rot("weather", 1), _below(live=()), ROTATION, "weather", False), + (_rot("weather", 1), _below(live=None), ROTATION, "weather", False), + (_rot("weather", 1), _below(live=(), vegas=True), LEGACY, None, False), + (_rot("weather", 1), _below(live=None, vegas=True, keeps=True), LEGACY, None, False), + # The iteration yielded: the screen it fell through to. + (_rot("weather", 1), _below(live=(), vegas=True, yielded=True), + ROTATION, "weather", False), + (_rot("weather", 1), _below(live=("nfl_live",), vegas=True, yielded=True), + LIVE, "nfl_live", False), + # Live priority just ended: the rotation resumes where it was cut. + (_rot("nhl_live", 3, resume=1), _below(live=()), ROTATION, "weather", True), + # ... and Vegas carries the resume through to its own pass. + (_rot("nhl_live", 3, resume=1), _below(live=(), vegas=True), LEGACY, None, True), + # A rotation that something moved off its list carries on from there. + (ArbiterState(current_mode=None, rotation=ROT), _below(live=()), ROTATION, None, False), + ] + + @pytest.mark.parametrize("state,inputs,source,mode,ends_live", TABLE) + def test_decide(self, state, inputs, source, mode, ends_live): + plan = Arbiter.decide(state, inputs, 0.0) + assert (plan.source, plan.mode, plan.ends_live) == (source, mode, ends_live) + + def test_a_rotation_plan_may_be_preempted_by_everything_a_screen_watches(self): + plan = Arbiter.decide(_rot(), _below(live=()), 0.0) + assert plan.preemptible_by == SCREEN_PREEMPTERS + assert {OFF, ONDEM, WIFI, LIVE, ROTATION, Source.RELOAD} == SCREEN_PREEMPTERS + + +class _End: + def __init__(self, on_demand_active=False, still_live=False): + self.on_demand_active = on_demand_active + self.still_live = still_live + + +class TestAfter: + """ArbiterState.after: _advance_after_screen's step.""" + + def test_the_rotation_advances(self): + nxt = _rot("weather", 1).after(_End()) + assert (nxt.current_mode, nxt.rotation_index) == ("nfl_live", 2) + + def test_it_wraps(self): + nxt = _rot("nhl_live", 3).after(_End()) + assert (nxt.current_mode, nxt.rotation_index) == ("clock", 0) + + def test_a_live_mode_still_live_holds(self): + state = _rot("nfl_live", 2) + assert state.after(_End(still_live=True)) is state + + def test_an_on_demand_session_moves_to_its_next_mode(self): + state = replace(_session(index=1, current="b"), rotation=ROT, rotation_index=1) + nxt = state.after(_End(on_demand_active=True)) + assert (nxt.current_mode, nxt.on_demand_index, nxt.rotation_index) == ("c", 2, 1) + + def test_a_session_with_no_modes_is_left_to_the_controller(self): + state = ArbiterState(current_mode="x", rotation=ROT) + assert state.after(_End(on_demand_active=True)) is state + + def test_no_rotation_no_step(self): + state = ArbiterState(current_mode="x") + assert state.after(_End()) is state + + +# -- Mid-screen: decide(..., running=plan) (stage 3) ---------------------- +# +# What the ScreenRunner asks at each service point. These rows are what +# _check_live_takeover, _screen_preempted and _wifi_notice_pending answered +# between frames before stage 3, written out. + +CLOCK = Arbiter.decide(ArbiterState(current_mode="clock"), _below(live=()), 0.0) +NFL = Arbiter.decide(ArbiterState(current_mode="clock"), _below(live=("nfl_live",)), 0.0) +OD = Arbiter.decide(_session(modes=("x", "y")), ON, 0.0) +FRESH = WifiNotice(message="AP mode", expires_at=1_000.0) + +HELD = "held" + + +def _mid(on_demand=False, schedule_on=True, live=None, notice=None, reload=False): + return ArbiterInputs(schedule_on=schedule_on, on_demand_active=on_demand, + follower_active=False, live_modes=live, wifi_notice=notice, + reload_pending=reload) + + +# (running, current_mode, inputs, now) -> HELD or (source, mode) +MID_TABLE = [ + # Nothing changed. + (CLOCK, "clock", _mid(), 500.0, HELD), + (CLOCK, "clock", _mid(live=()), 500.0, HELD), + # A game went live: it takes the panel (the first live mode). + (CLOCK, "clock", _mid(live=("nfl_live", "nhl_live")), 500.0, (LIVE, "nfl_live")), + # ... even with a notice pending: the claim is made now, and the next + # pass shows the notice first (top-of-pass order), then the game. + (CLOCK, "clock", _mid(live=("nfl_live",), notice=FRESH), 500.0, (LIVE, "nfl_live")), + # ... but not over the schedule or an on-demand session. + (CLOCK, "clock", _mid(live=("nfl_live",), schedule_on=False), 500.0, (OFF, None)), + (CLOCK, "x", _mid(live=("nfl_live",), on_demand=True), 500.0, (ONDEM, "x")), + # A screen already on a live mode is not taken over by another. + (CLOCK, "nfl_live", _mid(live=("nfl_live", "nhl_live")), 500.0, (ROTATION, "nfl_live")), + (NFL, "nfl_live", _mid(live=("nhl_live",)), 500.0, HELD), + # The mode moved under the screen: on-demand started, or ended, or the + # rotation was rebuilt. + (CLOCK, "x", _mid(on_demand=True), 500.0, (ONDEM, "x")), + (OD, "weather", _mid(), 500.0, (ROTATION, "weather")), + (CLOCK, "weather", _mid(), 500.0, (ROTATION, "weather")), + # An on-demand session that ends on the same mode keeps the screen. + (OD, "x", _mid(), 500.0, HELD), + # The schedule: off ends it; an on-demand override holds. + (CLOCK, "clock", _mid(schedule_on=False), 500.0, (OFF, None)), + (OD, "x", _mid(on_demand=True, schedule_on=False), 500.0, HELD), + # A WiFi notice, compared with its expiry; on-demand outranks it. + (CLOCK, "clock", _mid(notice=FRESH), 999.9, (WIFI, None)), + (CLOCK, "clock", _mid(notice=FRESH), 1_000.0, HELD), + (NFL, "nfl_live", _mid(notice=FRESH), 500.0, (WIFI, None)), + (OD, "x", _mid(on_demand=True, notice=FRESH), 500.0, HELD), + # A plugin reload waits at the top of the loop. + (CLOCK, "clock", _mid(reload=True), 500.0, (Source.RELOAD, None)), + (OD, "x", _mid(on_demand=True, reload=True), 500.0, (Source.RELOAD, None)), + # The order between them: a moved mode before the schedule, the + # schedule before a notice, a notice before a reload. + (CLOCK, "weather", _mid(schedule_on=False), 500.0, (ROTATION, "weather")), + (CLOCK, "clock", _mid(schedule_on=False, notice=FRESH), 500.0, (OFF, None)), + (CLOCK, "clock", _mid(notice=FRESH, reload=True), 500.0, (WIFI, None)), +] + + +class TestMidScreen: + + @pytest.mark.parametrize("running,current,inputs,now,expected", MID_TABLE) + def test_decide(self, running, current, inputs, now, expected): + state = ArbiterState(current_mode=current) + plan = Arbiter.decide(state, inputs, now, running=running) + if expected == HELD: + assert plan is running + else: + assert plan is not running + assert (plan.source, plan.mode) == expected + + def test_the_running_plans(self): + assert (CLOCK.source, CLOCK.mode) == (ROTATION, "clock") + assert (NFL.source, NFL.mode) == (LIVE, "nfl_live") + assert LIVE not in NFL.preemptible_by + assert (OD.source, OD.mode) == (ONDEM, "x") + + def test_a_follower_and_vegas_never_preempt(self): + for plan in (CLOCK, NFL, OD): + assert FOLLOW not in plan.preemptible_by + assert LEGACY not in plan.preemptible_by + + def test_nothing_in_preemptible_by_means_nothing_preempts(self): + bare = replace(CLOCK, preemptible_by=frozenset()) + inputs = _mid(live=("nfl_live",), schedule_on=False, notice=FRESH, reload=True) + assert Arbiter.decide(ArbiterState(current_mode="weather"), inputs, 0.0, + running=bare) is bare + + def test_mid_screen_decide_reads_no_clock(self): + boom = MagicMock(side_effect=AssertionError("decide read the clock")) + with patch("time.time", boom), patch("time.monotonic", boom): + for running, current, inputs, now, _ in MID_TABLE: + Arbiter.decide(ArbiterState(current_mode=current), inputs, now, + running=running) + + +# (current, inputs) -> live_takeover. The mode a mid-screen check claims. +TAKEOVER_TABLE = [ + ("clock", _mid(live=("nfl_live",)), "nfl_live"), + ("clock", _mid(live=()), None), + ("clock", _mid(live=None), None), # no scan was due + ("nfl_live", _mid(live=("nfl_live",)), None), + ("clock", _mid(live=("nfl_live",), on_demand=True), None), + ("clock", _mid(live=("nfl_live",), schedule_on=False), None), + ("clock", replace(_mid(live=("nfl_live",)), vegas_enabled=True, + vegas_live_in_ticker=True), None), +] + + +@pytest.mark.parametrize("current,inputs,expected", TAKEOVER_TABLE) +def test_live_takeover(current, inputs, expected): + assert live_takeover(ArbiterState(current_mode=current), inputs) == expected diff --git a/test/test_handover_scroll_state.py b/test/test_handover_scroll_state.py index dfcbf6df..b8bca90a 100644 --- a/test/test_handover_scroll_state.py +++ b/test/test_handover_scroll_state.py @@ -709,6 +709,6 @@ class TestTheControllersOwnScreens: c._check_wifi_status_message.return_value = None inputs = DisplayController._arbiter_inputs(c) assert inputs.wifi_notice is None - assert Arbiter.decide(ArbiterState(), inputs, 0.0).source is Source.LEGACY + assert Arbiter.decide(ArbiterState(), inputs, 0.0).source is Source.ROTATION assert dm.is_currently_scrolling() assert dm._frame_hold == 2 diff --git a/test/test_ipc_display_stage2.py b/test/test_ipc_display_stage2.py index 97a59416..2d745665 100644 --- a/test/test_ipc_display_stage2.py +++ b/test/test_ipc_display_stage2.py @@ -41,6 +41,15 @@ def _reload(plugin_id): return _command(Command.PLUGIN_RELOAD, PluginReloadArgs(plugin_id)) +def _screen(mode): + """A rotation screen of ``mode``, as the ScreenRunner hands it to the + 1 Hz loop's frame wait.""" + from src.display_arbiter import ArbiterState, rotation_plan + from src.screen_runner import Screen + plan = rotation_plan(ArbiterState(current_mode=mode)) + return Screen(plan, plugin=None, accepts_display_mode=False, start=0.0) + + @pytest.fixture def dc(test_display_controller): c = test_display_controller @@ -253,7 +262,8 @@ class TestRealTimeWake: dc.on_demand_active = False dc._activate_on_demand = MagicMock( side_effect=lambda request: setattr(dc, 'current_display_mode', 'weather')) - dc._wifi_notice_pending = MagicMock(return_value=False) + dc._wifi_notice_pending = MagicMock(return_value=False) # the dwell's check + dc._read_wifi_notice = MagicMock(return_value=None) # the frame wait's dc._tick_plugin_updates = MagicMock() dc._check_live_takeover = MagicMock() dc.cache_manager.get = MagicMock(return_value=None) @@ -282,10 +292,10 @@ class TestRealTimeWake: dc.current_display_mode = 'clock' stamps = [] t = self._post_later(server, self._start_line(f's{i}'), 0.05, stamps) - ended = dc._wait_frame_interval(1.0, 'clock') + ended = dc._wait_frame_interval(1.0, _screen('clock')) woke = time.monotonic() t.join() - assert ended is True + assert ended is not None latencies.append(woke - stamps[0]) latencies.sort() print(f"static-screen wake latency: median {latencies[5] * 1000:.2f} ms, " @@ -315,7 +325,7 @@ class TestRealTimeWake: 'args': {'brightness': 33}}).encode() started = time.monotonic() t = self._post_later(server, line, 0.1, stamps) - assert dc._wait_frame_interval(0.5, 'clock') is False + assert dc._wait_frame_interval(0.5, _screen('clock')) is None assert time.monotonic() - started >= 0.49 t.join() dc.display_manager.set_brightness.assert_called_once_with(33) @@ -323,7 +333,7 @@ class TestRealTimeWake: def test_without_a_socket_it_is_a_plain_sleep(self, dc): dc._control_server = None started = time.monotonic() - assert dc._wait_frame_interval(0.2, 'clock') is False + assert dc._wait_frame_interval(0.2, _screen('clock')) is None assert time.monotonic() - started >= 0.19 diff --git a/test/test_screen_runner.py b/test/test_screen_runner.py new file mode 100644 index 00000000..3c5da032 --- /dev/null +++ b/test/test_screen_runner.py @@ -0,0 +1,455 @@ +"""ScreenRunner (src/screen_runner.py) on a scripted host and a fake clock. + +The golden traces (test_run_loop_golden.py) run it inside the real +DisplayController; these pin down its own contract: which ExitReason each +way of ending gives, the order it calls its host in, how the 125 Hz loop +paces, and which service point asks what. +""" + +from typing import Any, List, Optional, Tuple + +import pytest + +from src.display_arbiter import ( + RELOAD_PLAN, SCREEN_PREEMPTERS, FramePolicy, ScreenPlan, Source, +) +from src.screen_runner import ( + AFTER_COMPLETED_LOOP, AFTER_LOOP, DYNAMIC_GRACE, FINAL, FRAME, HIGH_FPS_INTERVAL, + Checkpoint, ExitReason, FirstFrame, NoticeRead, Screen, ScreenRunner, +) + +WIFI_PLAN = ScreenPlan(Source.WIFI) + + +class Clock: + """time/perf_counter read ``now``; sleep advances it.""" + + def __init__(self): + self.now = 0.0 + self.sleeps: List[float] = [] + + def time(self) -> float: + return self.now + + def perf_counter(self) -> float: + return self.now + + def sleep(self, seconds: float) -> None: + self.sleeps.append(round(seconds, 6)) + self.now += seconds + + +class Host: + """A ScreenHost whose answers are scripted; records every call.""" + + def __init__(self, clock: Clock, *, shown=True, raised=False, minimum=10.0, + maximum=10.0, dynamic=False, policy=FramePolicy.STATIC, + completed=True, draws=None, checks=None, work=0.0, + cycle_complete_at=None, dwell_to=None): + self.clock = clock + self.calls: List[Tuple[Any, ...]] = [] + self.shown, self.raised = shown, raised + self.minimum, self.maximum = minimum, maximum + self.dynamic, self.policy, self.completed = dynamic, policy, completed + #: display() results for the frames after the first, in order. + self.draws = list(draws or []) + #: (checkpoint name, frame number) -> plan, or a callable(screen, cp). + self.checks = checks or {} + self.work = work + self.cycle_complete_at = cycle_complete_at + self.dwell_to = dwell_to + self.frames = 0 + + def first_frame(self, plan, plugin): + self.calls.append(("first", plan.mode)) + return FirstFrame(self.shown, self.raised, False) + + def complete_plan(self, plan, plugin): + self.calls.append(("complete",)) + if not self.completed: + return None + return ScreenPlan(plan.source, mode=plan.mode, min_duration=self.minimum, + max_duration=self.maximum, dynamic=self.dynamic, + frame_policy=self.policy, preemptible_by=plan.preemptible_by) + + def draw(self, screen): + self.frames += 1 + self.calls.append(("draw", self.frames)) + self.clock.now += self.work + return self.draws.pop(0) if self.draws else True + + def after_frame(self, screen): + self.calls.append(("after_frame",)) + + def tick(self): + self.calls.append(("tick",)) + + def service(self, screen): + self.calls.append(("service",)) + return ("scan", self.frames) + + def wait_frame(self, interval, screen): + self.calls.append(("wait", interval)) + self.clock.sleep(interval) + return self._scripted("wait", screen) + + def check(self, screen, checkpoint, live_scan=None): + self.calls.append(("check", checkpoint.name, live_scan)) + return self._scripted(checkpoint.name, screen, checkpoint) + + def _scripted(self, name, screen, checkpoint=None): + answer = self.checks.get((name, self.frames)) + if callable(answer): + return answer(screen, checkpoint) + return answer + + def dwell(self, seconds): + self.calls.append(("dwell", round(seconds, 6))) + self.clock.now = self.dwell_to if self.dwell_to is not None else self.clock.now + seconds + + def cycle_complete(self, screen): + self.calls.append(("cycle?",)) + return self.cycle_complete_at is not None and self.clock.now >= self.cycle_complete_at + + +PLAN = ScreenPlan(Source.ROTATION, mode="clock", preemptible_by=SCREEN_PREEMPTERS) + + +def _run(**kwargs) -> Tuple[Any, Host, Clock]: + clock = Clock() + host = Host(clock, **kwargs) + outcome = ScreenRunner(clock, host).run(PLAN, plugin=object()) + return outcome, host, clock + + +def _names(host, *kinds): + return [c for c in host.calls if c[0] in kinds] + + +class TestFirstFrame: + + def test_no_plugin_is_empty_without_a_dispatch(self): + clock = Clock() + host = Host(clock) + outcome = ScreenRunner(clock, host).run(PLAN, plugin=None) + assert outcome.exit_reason is ExitReason.EMPTY + assert host.calls == [] + + @pytest.mark.parametrize("raised,reason", [(False, ExitReason.EMPTY), + (True, ExitReason.ERROR)]) + def test_nothing_shown(self, raised, reason): + outcome, host, _ = _run(shown=False, raised=raised) + assert outcome.exit_reason is reason + assert host.calls == [("first", "clock")] + + def test_an_on_demand_session_with_no_time_left(self): + outcome, host, _ = _run(completed=False) + assert outcome.exit_reason is ExitReason.PREEMPTED + assert outcome.preempted_by is None + assert host.calls == [("first", "clock"), ("complete",)] + + +class TestStaticLoop: + + def test_runs_its_duration_one_frame_a_second(self): + outcome, host, clock = _run(maximum=5.0) + assert outcome.exit_reason is ExitReason.DURATION + assert outcome.elapsed == 5.0 + # Frames at 1..4 s; the wait that reaches 5 s ends it before a fifth. + assert host.frames == 4 + assert clock.sleeps == [1.0] * 5 + # Each frame: wait, tick, draw, follower frame, service, the check. + assert host.calls[2:9] == [("wait", 1.0), ("tick",), ("draw", 1), ("after_frame",), + ("service",), ("check", "frame", ("scan", 1)), ("wait", 1.0)] + # A completed loop: the after-loop look reads no notice, then FINAL. + assert host.calls[-2:] == [("check", "after-completed-loop", None), ("check", "final", None)] + + def test_preempted_between_frames(self): + by = ScreenPlan(Source.ON_DEMAND, mode="x") + outcome, host, _ = _run(maximum=30.0, checks={("frame", 3): by}) + assert outcome.exit_reason is ExitReason.PREEMPTED + assert outcome.preempted_by is by + assert outcome.elapsed == 3.0 + # Decided at the service point: no second look, no dwell. + assert host.calls[-1] == ("check", "frame", ("scan", 3)) + + def test_preempted_by_a_socket_command_in_the_frame_wait(self): + outcome, host, _ = _run(maximum=30.0, checks={("wait", 2): WIFI_PLAN}) + assert outcome.exit_reason is ExitReason.PREEMPTED + assert host.calls[-1] == ("wait", 1.0) + + def test_display_false_makes_up_the_minimum(self): + outcome, host, _ = _run(maximum=12.0, draws=[True, False]) + assert outcome.exit_reason is ExitReason.DISPLAY_FALSE + # The loop ended early: the after-loop look may read a notice, then + # the dwell makes up the rest of the 12 s. + assert _names(host, "check", "dwell")[-4:] == [ + ("check", "after-loop", None), ("dwell", 10.0), + ("check", "after-dwell", None), ("check", "final", None)] + assert outcome.elapsed == 12.0 + + def test_a_notice_that_cuts_the_dwell_short_ends_the_screen(self): + def notice(screen, checkpoint): + assert checkpoint.notice is NoticeRead.ALWAYS + return WIFI_PLAN if checkpoint.notice_counts else None + outcome, _, _ = _run(maximum=12.0, draws=[False], dwell_to=6.0, + checks={("after-dwell", 1): notice}) + assert outcome.exit_reason is ExitReason.PREEMPTED + + def test_a_notice_after_a_full_dwell_does_not(self): + def notice(screen, checkpoint): + return WIFI_PLAN if checkpoint.notice_counts else None + outcome, _, _ = _run(maximum=12.0, draws=[False], + checks={("after-dwell", 1): notice}) + assert outcome.exit_reason is ExitReason.DISPLAY_FALSE + + def test_a_reload_ends_the_loop_but_the_screen_counts(self): + outcome, host, _ = _run(maximum=30.0, checks={("frame", 2): RELOAD_PLAN}) + assert outcome.exit_reason is ExitReason.RELOAD + # Not decided at the service point: the after-loop look, the dwell + # (which returns at once while a reload waits) and FINAL follow. + assert ("check", "after-loop", None) in host.calls + assert ("check", "final", None) in host.calls + + def test_a_change_after_the_loop_preempts(self): + by = ScreenPlan(Source.ROTATION, mode="weather") + outcome, _, _ = _run(maximum=3.0, checks={("final", 2): by}) + assert outcome.exit_reason is ExitReason.PREEMPTED + assert outcome.preempted_by is by + + def test_a_dynamic_screen_keeps_going_on_false(self): + outcome, host, _ = _run(minimum=3.0, maximum=8.0, dynamic=True, + draws=[False, False, False]) + assert outcome.exit_reason is ExitReason.DURATION + assert host.frames == 7 + + +class TestHighFpsLoop: + + def test_paces_to_the_deadline(self): + outcome, host, clock = _run(maximum=0.05, policy=FramePolicy.HIGH_FPS, work=0.003) + assert outcome.exit_reason is ExitReason.DURATION + # 3 ms of drawing leaves 5 ms of an 8 ms frame to sleep. + assert set(clock.sleeps) == {round(HIGH_FPS_INTERVAL - 0.003, 6)} + + def test_an_overrun_frame_still_yields(self): + _, _, clock = _run(maximum=0.05, policy=FramePolicy.HIGH_FPS, work=0.02) + assert set(clock.sleeps) == {0.001} + + def test_service_before_the_sleep_check_after(self): + _, host, clock = _run(maximum=0.016, policy=FramePolicy.HIGH_FPS) + first = host.calls[2:7] + assert first == [("draw", 1), ("after_frame",), ("tick",), ("service",), + ("check", "frame", ("scan", 1))] + assert clock.sleeps[0] == HIGH_FPS_INTERVAL + + def test_preempted_after_the_frames_sleep(self): + outcome, _, clock = _run(maximum=30.0, policy=FramePolicy.HIGH_FPS, + checks={("frame", 3): WIFI_PLAN}) + assert outcome.exit_reason is ExitReason.PREEMPTED + assert clock.now == pytest.approx(3 * HIGH_FPS_INTERVAL) + + def test_display_false_has_no_make_up_dwell(self): + outcome, host, _ = _run(maximum=30.0, policy=FramePolicy.HIGH_FPS, + draws=[True, False]) + assert outcome.exit_reason is ExitReason.DISPLAY_FALSE + assert not _names(host, "dwell") + + +class TestDynamicDuration: + + def test_cycle_complete_after_the_minimum_and_grace(self): + outcome, host, _ = _run(minimum=3.0, maximum=20.0, dynamic=True, + cycle_complete_at=1.0) + assert outcome.exit_reason is ExitReason.CYCLE_COMPLETE + # Not asked before minimum + grace (3.5 s): the 1 Hz loop's first + # frame at or past it is the one at 4 s. + assert outcome.elapsed == 4.0 + assert DYNAMIC_GRACE == 0.5 + + def test_capped_at_the_maximum(self): + outcome, host, _ = _run(minimum=3.0, maximum=6.0, dynamic=True) + assert outcome.exit_reason is ExitReason.DURATION + # The end-of-screen log asks the plugin once more, before FINAL. + assert host.calls[-3:] == [("check", "after-completed-loop", None), ("cycle?",), + ("check", "final", None)] + + def test_high_fps_cycle_complete(self): + outcome, _, _ = _run(minimum=0.02, maximum=1.0, dynamic=True, + policy=FramePolicy.HIGH_FPS, cycle_complete_at=0.0) + assert outcome.exit_reason is ExitReason.CYCLE_COMPLETE + assert outcome.elapsed >= 0.02 + DYNAMIC_GRACE + + +class TestCheckpoints: + + @pytest.mark.parametrize("checkpoint,notice,reload", [ + (FRAME, NoticeRead.IF_UNDECIDED, True), + (AFTER_LOOP, NoticeRead.IF_UNDECIDED, False), + (AFTER_COMPLETED_LOOP, NoticeRead.NEVER, False), + (FINAL, NoticeRead.NEVER, False), + ]) + def test_what_each_service_point_considers(self, checkpoint, notice, reload): + """A reload counts only between frames; the notice file is read + where the loop read it before stage 3.""" + assert isinstance(checkpoint, Checkpoint) + assert (checkpoint.notice, checkpoint.reload) == (notice, reload) + + def test_the_screen_carries_the_completed_plan(self): + seen: List[Optional[Screen]] = [] + + def grab(screen, checkpoint): + seen.append(screen) + _run(maximum=2.0, checks={("frame", 1): grab}) + assert seen[0].plan.frame_policy is FramePolicy.STATIC + assert seen[0].mode == "clock" + + +class TestControllerServicePoint: + """DisplayController._screen_check: the reads it makes and what it claims, + on a controller built by the run-loop harness.""" + + @pytest.fixture + def dc(self, tmp_path): + import os + os.environ.setdefault("EMULATOR", "true") + from test._run_loop_harness import FakePlugin, RunLoopHarness + h = RunLoopHarness(tmp_path, horizon=10) + h.add_plugin(FakePlugin("clock", ["clock"], duration=20)) + h.add_plugin(FakePlugin("sports", ["sports_live"], duration=20, + live=(0, 100), live_priority=True)) + dc = h.controller + dc.current_display_mode = "clock" + dc.current_mode_index = 0 + dc.reads = [] + + def read(): + dc.reads.append(1) + return {"message": "AP mode", "expires_at": 1e12} + dc._check_wifi_status_message = read + return dc + + @staticmethod + def _screen(mode="clock"): + from src.display_arbiter import ArbiterState, rotation_plan + return Screen(rotation_plan(ArbiterState(current_mode=mode)), plugin=None, + accepts_display_mode=False, start=0.0) + + def test_a_pending_notice_is_read_and_ends_the_screen(self, dc): + by = dc._screen_check(self._screen(), FRAME) + assert by.source is Source.WIFI and len(dc.reads) == 1 + + def test_not_read_once_the_mode_has_moved(self, dc): + dc.current_display_mode = "sports_live" + by = dc._screen_check(self._screen(), FRAME) + assert by.source is Source.ROTATION and dc.reads == [] + + def test_not_read_while_scheduled_off(self, dc): + dc.is_display_active = False + by = dc._screen_check(self._screen(), FRAME) + assert by.source is Source.SCHEDULED_OFF and dc.reads == [] + + def test_not_read_during_on_demand(self, dc): + dc.on_demand_active = True + dc.on_demand_schedule_override = True + assert dc._screen_check(self._screen(), FRAME) is None + assert dc.reads == [] + + def test_a_live_takeover_is_claimed_before_the_notice(self, dc): + by = dc._screen_check(self._screen(), FRAME, live_scan=("sports_live",)) + assert by.source is Source.LIVE and dc.reads == [] + assert dc.current_display_mode == "sports_live" + assert dc._live_takeover_unshown is True and dc._live_resume_index == 0 + + def test_after_a_completed_loop_the_notice_is_not_read(self, dc): + assert dc._screen_check(self._screen(), AFTER_COMPLETED_LOOP) is None + assert dc.reads == [] + + def test_after_a_full_dwell_it_is_read_but_does_not_count(self, dc): + from src.screen_runner import after_dwell + assert dc._screen_check(self._screen(), after_dwell(False)) is None + assert len(dc.reads) == 1 + + def test_a_reload_counts_only_between_frames(self, dc): + dc._check_wifi_status_message = lambda: None + dc._pending_plugin_reloads = ("pending",) + assert dc._screen_check(self._screen(), FRAME) is RELOAD_PLAN + assert dc._screen_check(self._screen(), AFTER_LOOP) is None + + def test_an_on_demand_index_past_a_shortened_list_starts_again(self, dc): + """_take_plan writes the shown index back, so the session's next + step goes on from the mode actually shown.""" + from src.display_arbiter import Arbiter, ArbiterInputs + dc.on_demand_active = True + dc.on_demand_modes = ["clock", "sports_live"] + dc.on_demand_mode_index = 5 # the list shrank under it + inputs = ArbiterInputs(schedule_on=True, on_demand_active=True, + follower_active=False) + plan = dc._take_plan(Arbiter.decide(dc._arbiter_state(), inputs, 0.0)) + assert plan.mode == "clock" and dc.on_demand_mode_index == 0 + dc._advance_on_demand() + assert dc.current_display_mode == "sports_live" + + +@pytest.mark.parametrize("policy,report_hold", [(FramePolicy.HIGH_FPS, True), + (FramePolicy.STATIC, False)]) +def test_only_the_high_fps_loop_reports_a_held_frame(policy, report_hold): + """#758's report_hold: the 125 Hz loop times frames held by update(); + the 1 Hz loop's frames are a second apart and must not.""" + from unittest.mock import MagicMock + from src.display_controller import _ScreenHost + controller = MagicMock() + plan = ScreenPlan(Source.ROTATION, mode="ticker", frame_policy=policy) + plugin = object() + _ScreenHost(controller).draw(Screen(plan, plugin, True, 0.0)) + controller._display_once.assert_called_once_with(plugin, "ticker", True, + report_hold=report_hold) + + +def _rows(tmp_path, horizon, build): + import os + os.environ.setdefault("EMULATOR", "true") + from test._run_loop_harness import RunLoopHarness + h = RunLoopHarness(tmp_path, horizon=horizon) + build(h) + return h.run()["screens"] + + +class TestThroughRun: + """Service points that only the full loop reaches, on the harness.""" + + def test_a_notice_pending_when_a_later_frame_is_empty_ends_the_screen(self, tmp_path): + """The after-loop look reads the notice when the loop ended early: a + 1 Hz screen whose second frame has nothing to show must not sit in + its make-up dwell (which only notices a notice that arrives during + it) while the notice expires.""" + from test._run_loop_harness import FakePlugin + + def build(h): + h.add_plugin(FakePlugin("flaky", ["flaky"], duration=12, first_frame_only=True)) + h.add_plugin(FakePlugin("clock", ["clock"], duration=12)) + h.wifi_message(0.5, "Connected to HomeNet", duration=5) + rows = _rows(tmp_path, 30, build) + assert rows[0][1] == "flaky" and rows[0][2] == 1.0 + assert rows[1][1] == "" + # ... and the mode it cut short comes back, not the next one. + after = next(row for row in rows[1:] if row[1] != "") + assert after[1] == "flaky" + + def test_vegas_yielding_to_a_follower_shows_a_rotation_screen_first(self, tmp_path): + """Pins today's behaviour (docs/RUN_LOOP_REDESIGN.md, "may be + wrong"): the interrupt check stops the ticker for a follower, but + the yield path never looks at one, so a rotation screen runs its + full duration before the next pass hands the panel to the leader.""" + from test._run_loop_harness import FakePlugin + + def build(h): + h.add_plugin(FakePlugin("clock", ["clock"], duration=20)) + h.add_plugin(FakePlugin("weather", ["weather"], duration=20)) + h.enable_vegas(cycle=30) + h.sync.follower_windows = [(10, 60)] + rows = _rows(tmp_path, 50, build) + assert rows[0][1] == "" and rows[0][3] == "vegas-interrupt" + assert 10.0 <= rows[0][0] + rows[0][2] <= 10.2 + assert rows[1][1] == "clock" and rows[1][2] == 20.0 + assert rows[2][1] == ""