mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
A request naming a *_live mode (football-scoreboard / ncaa_fb_live with 15 college games on) answered 200 and showed nfl_recent. The session's mode list kept live modes only when has_live_content() said so, which is the live-priority question and is answered for favourite teams only. A mode the request names now leads the session; display() decides whether it has anything to draw, and an empty one moves on to the plugin's next mode as any empty on-demand mode does. The name is saved in display_on_demand_config (named_mode) so a restart resumes on it. A bare plugin-id request still skips quiet live modes, as before. A restart during a session whose plugin then failed to load (clock-simple failed config validation after a crash on ledpi) left the session active with no modes and its cached request in place. It now ends at startup with status error / restore-failed, and the cached request is dropped; likewise when the plugin system fails to start. Golden traces: two new scenarios (on_demand_named_live, on_demand_restore_failed); every existing trace is unchanged. The harness's restore_on_demand takes named_mode and logs a failed restore. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
348 lines
18 KiB
Markdown
348 lines
18 KiB
Markdown
# Restructuring `DisplayController.run()`
|
|
|
|
`run()` in [`src/display_controller.py`](../src/display_controller.py) decides
|
|
what the panel shows and runs it. This document is the plan for turning it
|
|
from one long loop into three parts with clear jobs: an **Arbiter** that
|
|
decides, a **ScreenRunner** that runs one screen, and **Sources** that each
|
|
know about one kind of content. It covers the target design, the stages that
|
|
get there, and how each stage is checked.
|
|
|
|
The goal is to change how the control flow is organised, not to move code
|
|
into more files. Each stage ships as its own PR, and none of them changes
|
|
what the panel shows unless that PR says so and updates the golden traces
|
|
on purpose.
|
|
|
|
## Why
|
|
|
|
- **The priority order is written in branch order, twice.** It is
|
|
Follower, on-demand, WiFi notice, live priority, Vegas, rotation. In
|
|
`run()` that order exists only as the order of `if` blocks. Vegas
|
|
repeats part of it in its interrupt callback (`_check_vegas_interrupt`).
|
|
- **Preemption is found by re-checking.** A screen ends early when
|
|
something else changed `current_display_mode` or `is_display_active`
|
|
underneath it. `run()` notices with five separate
|
|
`current_display_mode != active_mode` checks: after an empty pass, in each
|
|
of the two frame loops, after the frame loops, and before rotating.
|
|
- **Most recent fixes were ordering bugs** between these branches (#618,
|
|
#644, #649, #652): a lost mode switch, rotating past an on-demand request,
|
|
spinning when every mode is empty.
|
|
- **It could not be tested** without threads, real sleeps and stopping the
|
|
loop by raising from a patched method.
|
|
|
|
## What `run()` does today
|
|
|
|
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.
|
|
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).
|
|
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`).
|
|
|
|
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
|
|
|
|
```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
|
|
```
|
|
|
|
### 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 |
|
|
|---|---|---|---|
|
|
| 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 |
|
|
|
|
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.
|
|
|
|
### Arbiter
|
|
|
|
```python
|
|
Arbiter.decide(state, inputs, now) -> 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`:
|
|
|
|
| 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` |
|
|
| `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 |
|
|
| `preemptible_by` | the Sources allowed to interrupt this plan mid-screen |
|
|
|
|
### ScreenRunner
|
|
|
|
```python
|
|
ScreenRunner(clock: FrameClock).run(plan) -> Outcome(exit_reason, elapsed)
|
|
```
|
|
|
|
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) |
|
|
|---|---|
|
|
| `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) |
|
|
| `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` 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.
|
|
|
|
`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.
|
|
|
|
## Stages
|
|
|
|
| Stage | Change | Behaviour change | Verified by |
|
|
|---|---|---|---|
|
|
| 1 | Golden traces; extract helpers from `run()` | none | traces generated on main pass unchanged; mutation check |
|
|
| 2 | Arbiter with Follower and Wifi Sources | none | traces unchanged; Arbiter unit tables; ledpi smoke |
|
|
| 3 | ScreenRunner, FrameClock, ExitReason, `PREEMPTED`; OnDemand, Live, Rotation Sources | none | traces unchanged; ledpi frame soak A/B |
|
|
| 4 | Vegas as a Source driven by `run_frame()` | none intended | traces against the real coordinator; ledpi Vegas soak A/B |
|
|
| 5 | Plugins declare `frame_policy` | DEBUG instead of INFO for the FPS line | traces; soak on a static-heavy rotation |
|
|
|
|
### Stage 1 (#704)
|
|
|
|
- `test/_run_loop_harness.py` builds a real `DisplayController` through
|
|
`__init__` on in-memory fakes (plugins, cache, config service, plugin
|
|
manager, sync manager, display manager). It swaps the module's `time` and
|
|
`datetime` for one fake clock and runs the real `run()` until a horizon.
|
|
The first frame of each screen still goes through the real
|
|
`PluginExecutor` and the per-plugin locks.
|
|
- `test/test_run_loop_golden.py` has 15 scenarios, each compared with
|
|
`test/fixtures/run_loop_golden/<scenario>.json`:
|
|
- plain rotation (display_durations override, a high-FPS scroller, a
|
|
plugin whose `display()` takes no `display_mode`)
|
|
- empty modes and a mode with no plugin; an all-empty rotation (the 1 s
|
|
pause)
|
|
- plugin errors and the circuit breaker
|
|
- dynamic duration (cycle complete, plugin cap, global cap)
|
|
- live priority taking over and handing back; live round-robin
|
|
- on-demand start/stop/expiry; pinned on-demand; a session resumed after
|
|
a restart, and one that cannot resume (its plugin did not load); a
|
|
request naming a live mode the plugin's live check would drop
|
|
- schedule off and dim, with an on-demand override during downtime
|
|
- WiFi notice; sync follower
|
|
- Vegas, with and without `live_in_ticker`
|
|
- Each trace row is `[start, mode, duration, exit_reason, frames,
|
|
force_clear]`. The exit reason is the event that decided what came next.
|
|
- All 18 tests run in under a second. The goldens were generated from
|
|
main's `run()` before any code moved.
|
|
- Vegas uses `FakeVegas`, which implements only the contract the controller
|
|
depends on: `run_iteration()` returns True after its duration and False
|
|
when the interrupt or live check asks it to yield, checking at the real
|
|
coordinator's cadence. Running the real coordinator on the fake clock
|
|
belongs to stage 4.
|
|
- Twelve helpers were extracted from `run()` (listed under "What `run()`
|
|
does today"). Breaking any one of them fails at least one golden trace.
|
|
|
|
### Stage 2: Arbiter, starting with Follower and Wifi (done)
|
|
|
|
1. Add `ScreenPlan` and an `Arbiter` with the ScheduledOff gate, Follower
|
|
and Wifi. Every other case returns a `LEGACY` plan, which means "carry on
|
|
with the existing code" (steps 7-9).
|
|
2. `run()` calls `decide()` after the bookkeeping in step 3 and dispatches
|
|
on `plan.source`: blank, `_run_follower_frame()`, the WiFi notice, or the
|
|
existing path. Inputs that Sources read (follower active, the pending
|
|
WiFi message, schedule state) are collected first, so `decide()` stays
|
|
pure.
|
|
3. Unit-test `decide()` with tables. The golden traces must not change.
|
|
The Wifi Source must keep the mid-screen preemption described in step 6
|
|
of "What `run()` does today".
|
|
|
|
Follower and Wifi go first because each is one self-contained branch that
|
|
ends the pass. They prove the plumbing without touching the frame loops.
|
|
|
|
What shipped:
|
|
|
|
- `src/display_arbiter.py` (on the mypy ratchet) holds `Source`
|
|
(`SCHEDULED_OFF`, `FOLLOWER`, `WIFI`, `LEGACY`), `ArbiterInputs`,
|
|
`ArbiterState`, `WifiNotice`, `ScreenPlan` and `Arbiter.decide`.
|
|
`ScreenPlan` has only the fields stage 2 uses: `source`, `max_duration`
|
|
(60 s for the blank, 0.5 s for the notice, the constants `run()` used to
|
|
hard-code) and `notice`. `mode`, `plugin`, the other durations,
|
|
`frame_policy` and `preemptible_by` arrive with the Sources that need them.
|
|
- `ArbiterState` is empty: no stage-2 Source remembers anything between
|
|
passes. `now` is passed but not read, because the top-of-pass WiFi check
|
|
never compared the expiry and must not start (the table pins this).
|
|
- `ArbiterInputs` holds `schedule_on`, `on_demand_active`,
|
|
`follower_active` and `wifi_notice`. `_arbiter_inputs` derives
|
|
`schedule_on` as `is_display_active and not on_demand_schedule_override`,
|
|
so the gate (blank when the schedule is off and no on-demand session
|
|
overrides it) blanks exactly when `is_display_active` is False, as before,
|
|
including #714's on-demand ending in off hours. It reads the WiFi notice
|
|
only when the notice could win, because `_check_wifi_status_message` has
|
|
side effects (its 1 Hz throttle, deleting an expired file) that those
|
|
passes never had.
|
|
- The mid-screen rule is `wifi_notice_preempts(notice, on_demand, now)`,
|
|
which `_wifi_notice_pending` calls; it does compare the expiry.
|
|
- `run()` still calls `_publish_current_mode_state_if_changed`,
|
|
`_apply_pending_vegas_init` and `process_deferred_updates` at the same
|
|
points relative to the branches, so the order of side effects in a pass
|
|
is unchanged.
|
|
- `test/test_display_arbiter.py`: the 16-row table (every combination of
|
|
the four inputs, written out), the mid-screen table, purity checks (no
|
|
clock reads, nothing mutated, no I/O imports), and the controller's
|
|
snapshot through an on-demand session that overrides the schedule and
|
|
ends. A mutation run broke 23 pieces once each (the gate, the order, each
|
|
Source, the dwells, the expiry comparison, the snapshot's reads, each
|
|
dispatch in `run()`); every one failed a test.
|
|
|
|
### Stage 3: ScreenRunner and `PREEMPTED`
|
|
|
|
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:
|
|
|
|
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.
|
|
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.
|
|
|
|
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`).
|
|
|
|
### Stage 4: Vegas as a Source
|
|
|
|
The controller calls `coordinator.run_frame()` once per frame from the
|
|
ScreenRunner instead of handing over to `run_iteration()` for up to
|
|
`max_cycle_duration`. The interrupt callback and the second copy of the
|
|
priority order go away, because preemption becomes `PREEMPTED`. The
|
|
`vegas-plugin-tick` thread that is spawned every 4 s becomes the
|
|
controller's normal update tick. Extend the harness to drive the real
|
|
coordinator on the fake clock, which means patching its `time` and running
|
|
its prefetch inline. Verify with a Vegas soak on ledpi, A/B.
|
|
|
|
### Stage 5: `frame_policy`
|
|
|
|
Plugins declare `frame_policy` (STATIC, PERIODIC(hz), ANIMATED(fps),
|
|
SCROLL). `_needs_high_fps` becomes the mapping for legacy plugins
|
|
(`needs_high_fps`, the `static-image` special case, `enable_scrolling`),
|
|
and its per-screen INFO line drops to DEBUG. It is already read twice per
|
|
screen: once quietly before the first frame, so `_dispatch_first_frame` can
|
|
end the previous scroll for a screen that runs the 1 Hz loop
|
|
(`_start_screen_handover`), and once after it to pick the loop. A declared
|
|
policy answers both.
|
|
|
|
## How each stage is verified
|
|
|
|
- **Golden traces.** Run `python -m pytest test/test_run_loop_golden.py`;
|
|
it takes about a second. A refactoring stage must leave every trace
|
|
unchanged. A deliberate behaviour change regenerates them with
|
|
`LEDMATRIX_REGEN_GOLDEN=1` in its own commit, and the commit message
|
|
explains each changed row. A new scenario's golden is generated against
|
|
main's `run()` first, then checked against the branch.
|
|
- **Mutation check.** Break each moved or new piece once, for example take
|
|
`max` of the caps instead of `min`, or skip the live hold. At least one
|
|
trace must fail each time. Stage 1 did this for all twelve helpers.
|
|
- **Full suite.** Diff the FAILED/ERROR ids against a baseline run of main
|
|
in a separate worktree. The Windows host has a stable set of
|
|
pre-existing failures, so never compare against zero.
|
|
- **ledpi soak** (stages 2-5). With the service running the branch:
|
|
`python3 scripts/frame_soak.py --preview` for 10 minutes on a scrolling
|
|
rotation, and on Vegas for stages 3-4. Alternate which build goes first.
|
|
Compare late-frame rate and freezes with main. Also check by hand that
|
|
on-demand start, stop and expiry, a live game taking over and handing
|
|
back, and the schedule turning the panel off and on all behave as before.
|
|
|
|
## Behaviour the traces pin down that may be wrong
|
|
|
|
Stage 1 recorded six behaviours as they were, each to be fixed in its own
|
|
PR that updates the affected trace and explains why. All six are fixed:
|
|
|
|
- A WiFi notice was only checked between screens, and Vegas yielded to one
|
|
and then showed a rotation screen instead. Notices now preempt within
|
|
about a second, and Vegas yields straight to them (#712; `wifi_notice`,
|
|
`vegas`).
|
|
- A live game only took over between screens, and Vegas yielded to one and
|
|
then showed a rotation screen first. Games now take over within about a
|
|
second, and Vegas yields straight to them (#713; `live_priority`,
|
|
`vegas`).
|
|
- An on-demand session that ended during scheduled-off kept the panel on
|
|
until the next minute, and a schedule window's end minute counted as on
|
|
only sometimes. Windows are now half-open `[start, end)`, and the panel
|
|
blanks as soon as on-demand ends in off hours (#714; `schedule`).
|
|
|
|
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.
|