Control socket stage 2: the render thread wakes for queued commands (static screens ~1 ms, Vegas within one frame), brightness.set, and plugin.reload after a store update, with mailbox/restart fallbacks. Rig checks listed in the PR body are still to run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
15 KiB
Restructuring DisplayController.run()
run() in 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 ofifblocks. 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_modeoris_display_activeunderneath it.run()notices with five separatecurrent_display_mode != active_modechecks: 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:
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.- With no modes: dwell 1 s, next pass.
- 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.
- Scheduled off: blank, dwell up to 60 s.
_blank_while_scheduled_off - Follower: render one frame from the leader.
_run_follower_frame - 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. - 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_takeoverin the frame loops and the dwell sleep (at most once a second, and not while a live mode is showing). - 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. - 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. The frame loops, the Vegas branch and every early exit are still
inline in run().
Target design
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
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
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 (this PR)
test/_run_loop_harness.pybuilds a realDisplayControllerthrough__init__on in-memory fakes (plugins, cache, config service, plugin manager, sync manager, display manager). It swaps the module'stimeanddatetimefor one fake clock and runs the realrun()until a horizon. The first frame of each screen still goes through the realPluginExecutorand the per-plugin locks.test/test_run_loop_golden.pyhas 15 scenarios, each compared withtest/fixtures/run_loop_golden/<scenario>.json:- plain rotation (display_durations override, a high-FPS scroller, a
plugin whose
display()takes nodisplay_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
- schedule off and dim, with an on-demand override during downtime
- WiFi notice; sync follower
- Vegas, with and without
live_in_ticker
- plain rotation (display_durations override, a high-FPS scroller, a
plugin whose
- Each trace row is
[start, mode, duration, exit_reason, frames, force_clear]. The exit reason is the event that decided what came next. - All 16 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 "Whatrun()does today"). Breaking any one of them fails at least one golden trace.
Stage 2: Arbiter, starting with Follower and Wifi
- Add
ScreenPlanand anArbiterwith the ScheduledOff gate, Follower and Wifi. Every other case returns aLEGACYplan, which means "carry on with the existing code" (steps 7-9). run()callsdecide()after the bookkeeping in step 3 and dispatches onplan.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, sodecide()stays pure.- 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 "Whatrun()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.
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.
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 withLEDMATRIX_REGEN_GOLDEN=1in its own commit, and the commit message explains each changed row. A new scenario's golden is generated against main'srun()first, then checked against the branch. - Mutation check. Break each moved or new piece once, for example take
maxof the caps instead ofmin, 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 --previewfor 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.