refactor(display): run loop stage 3 -- ScreenRunner, PREEMPTED, OnDemand/Live/Rotation Sources (#762)

* refactor(display): run each screen through a ScreenRunner (run loop stage 3)

The two frame loops, the make-up dwell and the dynamic-duration exit move
out of DisplayController.run() into src/screen_runner.py. ScreenRunner
paces with an injected FrameClock (production: this module's time, looked
up per call so the golden harness's fake clock still drives it) and
returns one Outcome whose ExitReason is DURATION, CYCLE_COMPLETE, EMPTY,
ERROR, DISPLAY_FALSE, RELOAD or PREEMPTED.

PREEMPTED replaces the re-checks that used to follow each frame loop and
the make-up dwell (current_display_mode != active_mode, the schedule, a
pending WiFi notice): the runner asks its host at named service points
(FRAME, AFTER_LOOP, after_dwell, FINAL), and on PREEMPTED run() goes to
the next pass without advancing the rotation, as each `continue` did.
RELOAD is the one early end that still advances, as a reload always did.
Each service point reads the WiFi notice file exactly when the loop did
(NoticeRead), because the read is throttled and deletes expired files.

The host answers still use the old checks; the following commits move
them to the Arbiter. _screen_preempted is gone (folded into the FRAME
check); _wait_frame_interval returns the preempting plan instead of a
bool. The frame pacing (8 ms deadline, 1 ms minimum yield, 1 Hz wait with
socket wake) is the same code, moved.

Golden traces unchanged. A capture of every harness run (all 67, with
every sleep, display() call, wifi read, live scan, publish and dwell
logged) is identical to origin/main except for throttled WiFi reads that
returned the cached answer (no side effect) after a notice preempted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(display): on-demand is an Arbiter Source (run loop stage 3)

Arbiter.decide() now answers for an active on-demand session itself
(Source.ON_DEMAND) instead of returning LEGACY:

- ArbiterState gains the session: its mode list, index, expiry and pin,
  plus current_mode, snapshotted from the controller's fields by
  _arbiter_state(). The controller's attributes stay the record that the
  web UI, the control socket and the cache read.
- The OnDemand plan is the session's current mode (an index past the end
  of a shortened list starts again at 0), with what is left of a timed
  session at `now` as max_duration and the expiry as deadline. A session
  with no modes left is a plan with no mode; the controller ends it and
  shows the rotation's mode, as _resolve_active_mode did.
- on_demand_bound() is _clamp_to_on_demand made pure. It is still applied
  after the first frame, with the clock read there.
- ArbiterState.next_on_demand() is the step _advance_on_demand takes.

run() asks decide() for the screen at the point it used to call
_resolve_active_mode (after any Vegas iteration, so a session that
started mid-iteration still shows next), and _take_plan() applies it.

Golden traces and the 67-run capture identical to origin/main. Adds
TestOnDemand and the bound table to test_display_arbiter.py; the stage-2
table's on-demand rows now name ON_DEMAND instead of LEGACY.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(display): live priority is an Arbiter Source (run loop stage 3)

The live-priority step of run() (step 7) and the live checks around the
Vegas iteration become the Arbiter's Live Source:

- ArbiterInputs gains live_modes (the scan, None where run() made none),
  vegas_enabled, vegas_live_in_ticker and vegas_yielded. ArbiterState
  gains the rotation and its index, the live resume point and the
  "takeover not shown yet" flag.
- Live picks the next live mode round-robin (live_pick, now also what
  _check_live_priority returns), not advancing past a mid-screen takeover
  that has not shown. It outranks Vegas unless the ticker keeps live
  content, in which case it has no say at all, as before. With nothing
  live, a plan below it carries ends_live and the interrupted rotation
  resumes.
- ArbiterState.claim_live/release_live are _apply_live_priority's
  bookkeeping made pure; _apply_live_priority applies them.

run() reads the inputs below the WiFi notice where it always did
(_arbiter_inputs_below_wifi: the Vegas check, then the scan), asks
decide() once more, and _take_plan applies the claim or the resume. The
Vegas iteration moves to _run_vegas_iteration, which re-decides with
vegas_yielded after a yield, so a game that stopped the ticker or an
on-demand session that started mid-iteration still shows next. LEGACY
now means Vegas or the rotation.

One redundant call is gone: a Vegas pass scanned the live plugins twice
at the same instant (step 7, then step 8's "is anything live?"); it scans
once. Golden traces unchanged. The 67-run capture is identical to
origin/main once that duplicate scan and _apply_live_priority(None) calls
that changed nothing are left out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(display): the rotation is an Arbiter Source; LEGACY means Vegas (run loop stage 3)

decide() now names the screen for every pass: the Rotation Source
(Source.ROTATION) answers with the rotation's current mode, after the
resume when live priority just ended. LEGACY is left meaning only Vegas,
whose iteration is still run()'s own code until stage 4. Once this pass's
iteration has yielded (vegas_yielded), Vegas passes and the screen it fell
through to is decided like any other.

The rotation's mode is state.current_mode rather than
rotation[rotation_index]: they agree except where something moved the
panel off the list and the rotation carries on from there (a live mode no
entry names, or None after a session ended with nothing to resume to),
and run() always showed current_display_mode.

ArbiterState.after(outcome) is _advance_after_screen's step: an on-demand
session moves to its next mode; otherwise the rotation advances unless the
mode just shown is still live. The Outcome carries the two facts only the
controller can see at the end of the screen (on_demand_active, the live
hold from _still_live). Ending a session with no modes left stays in the
controller, because it is not pure.

Golden traces unchanged; the 67-run capture is identical to origin/main
with the same two exclusions as the previous commit. Adds the Vegas /
Rotation table and TestAfter; the stage-2 rows that said LEGACY for "live,
Vegas or rotation" now say ROTATION.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(display): one decide() call at each of the runner's service points (run loop stage 3)

The three mid-screen checks the frame loops made one after another --
_check_live_takeover, then _screen_preempted with _wifi_notice_pending in
it -- become one call: Arbiter.decide(state, inputs, now, running=plan).
It returns `running` itself while the screen holds, else the plan that
ends it, from these rules in the order the loops checked them:

1. Live: a game went live while a non-live screen runs. 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 is still claimed
   when a WiFi notice is pending too; the next pass shows the notice,
   then the game, as before.
2. The panel's mode moved under the screen (on-demand started, ended or
   changed; the rotation was rebuilt).
3. The schedule turned the panel off.
4. A WiFi notice arrived (on-demand outranks it; compared with expiry).
5. A plugin reload is waiting (between frames only).

Each rule is gated by plan.preemptible_by: every screen may be preempted
by the gate, OnDemand, Wifi, Live, Rotation and a reload, except that a
live screen leaves Live out. A follower and Vegas never preempt
mid-screen. The pure helper live_takeover() is the Live rule, shared with
the dwell sleep's _check_live_takeover.

The controller only gathers and applies. _screen_service applies pending
changes and makes the live scan when one is due (_scan_for_takeover: the
same throttle and gates as before); _screen_check reads the WiFi notice
exactly where the loop did (the read is throttled and deletes an expired
file, so an extra read would move both), calls decide() once, and claims
a live takeover. _screen_preempted is gone; _check_live_takeover and
_wifi_notice_pending remain for the dwell sleep and the Vegas yield path,
built on the same rules.

Golden traces unchanged. The 67-run capture is identical to origin/main
(with the earlier two exclusions) except for one event: in the 125 Hz loop
the live scan still runs before the frame's sleep, but the claim is now
made by the service point after it, so the "live" state change is logged
8 ms later (test_live_game_cuts_a_scrolling_screen_short: 8.064 -> 8.072).
The screen still ends at the same frame (8.072) and every frame, sleep
and pass is unchanged.

Adds the mid-screen table (24 rows), live_takeover's table, and
test/test_screen_runner.py (the runner on a scripted host, plus the
controller's service point: which reads it makes at which checkpoint).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs, tests: run loop stage 3 -- doc, changelog, mutation survivors

docs/RUN_LOOP_REDESIGN.md describes run() as it now is (two decide()
calls per pass, the runner and its service points, the state snapshot and
its transitions), records what stage 3 shipped and how it was checked,
and adds one open "may be wrong" behaviour the mutation run surfaced: a
Vegas iteration stopped for a sync follower falls through to a full
rotation screen before the follower gets the panel (pinned by
test_vegas_yielding_to_a_follower_shows_a_rotation_screen_first; passes on
origin/main too). docs/IPC_CONTROL_SOCKET.md no longer names
_screen_preempted. CHANGELOG entry under Unreleased.

A mutation run broke 46 moved or new pieces once each (OnDemand, Live,
Vegas/Rotation, after(), each mid-screen rule, the runner's pacing, exits
and service points, the controller's gathering and claims). Three
survived and get a test here:

- the after-loop service point not reading the WiFi notice: the
  completed-loop checkpoint gets its own name, and a run-loop test has a
  notice pending when a later frame comes back empty;
- the Vegas yield path not marking vegas_yielded: the follower test above;
- _take_plan not writing back a reset on-demand index: a controller test
  with an index past a shortened list.

All 46 now fail at least one test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-10-04 22:15:30 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 294bd522bd
commit 26cae3e5d6
11 changed files with 2495 additions and 642 deletions
+33
View File
@@ -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
+3 -1
View File
@@ -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
+205 -81
View File
@@ -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.
+1
View File
@@ -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
+404 -39
View File
@@ -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,
+495 -503
View File
File diff suppressed because it is too large Load Diff
+484
View File
@@ -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)
+399 -12
View File
@@ -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
+1 -1
View File
@@ -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
+15 -5
View File
@@ -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
+455
View File
@@ -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] == "<wifi>"
# ... and the mode it cut short comes back, not the next one.
after = next(row for row in rows[1:] if row[1] != "<wifi>")
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] == "<vegas>" 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] == "<follower>"