refactor(display): run() stage 2 - an Arbiter decides the scheduled-off blank, follower and WiFi notice, no behaviour change (#733)

run() stage 2: a pure Arbiter.decide() (src/display_arbiter.py) chooses scheduled-off, follower and WiFi notices; everything else takes the existing path. Golden traces byte-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-10-02 22:30:52 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 084697346b
commit a5ec645d25
8 changed files with 582 additions and 40 deletions
+185
View File
@@ -0,0 +1,185 @@
"""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()``.
The full 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.
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.
"""
from dataclasses import dataclass
from enum import Enum
from typing import Optional
__all__ = [
"Arbiter",
"ArbiterInputs",
"ArbiterState",
"SCHEDULED_OFF_DWELL",
"ScreenPlan",
"Source",
"WIFI_NOTICE_DWELL",
"WifiNotice",
"wifi_notice_preempts",
]
# How long one scheduled-off pass blanks the panel. The dwell ends early when
# on-demand starts or the schedule turns the panel back on.
SCHEDULED_OFF_DWELL = 60.0
# How long one WiFi-notice pass holds the notice before the next pass looks
# again; the notice stays up, pass after pass, until it expires.
WIFI_NOTICE_DWELL = 0.5
class Source(Enum):
"""Who gets the panel this pass."""
SCHEDULED_OFF = "scheduled-off"
FOLLOWER = "follower"
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.
LEGACY = "legacy"
@dataclass(frozen=True)
class WifiNotice:
"""A WiFi status message waiting to be drawn.
``expires_at`` is wall-clock time (``time.time()``), as written by the
WiFi manager.
"""
message: str
expires_at: float
@dataclass(frozen=True)
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.
"""
@dataclass(frozen=True)
class ArbiterInputs:
"""One pass's snapshot, gathered by run() before it calls decide().
Attributes:
schedule_on: The display schedule has the panel on, not counting an
on-demand override of a scheduled-off window.
on_demand_active: An on-demand session is running.
follower_active: A sync leader is driving this panel.
wifi_notice: The pending WiFi notice, or None. run() reads it only
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.
"""
schedule_on: bool
on_demand_active: bool
follower_active: bool
wifi_notice: Optional[WifiNotice] = None
@dataclass(frozen=True)
class ScreenPlan:
"""The Arbiter's answer for one pass.
Attributes:
source: The Source that gets the panel.
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.
notice: The WiFi notice to draw, for a WIFI plan.
"""
source: Source
max_duration: Optional[float] = None
notice: Optional[WifiNotice] = None
SCHEDULED_OFF_PLAN = ScreenPlan(Source.SCHEDULED_OFF, max_duration=SCHEDULED_OFF_DWELL)
FOLLOWER_PLAN = ScreenPlan(Source.FOLLOWER)
LEGACY_PLAN = ScreenPlan(Source.LEGACY)
class Arbiter:
"""Decides which Source gets the panel. Stateless; see the module docstring."""
@staticmethod
def decide(state: ArbiterState, inputs: ArbiterInputs, now: float) -> ScreenPlan:
"""The plan for this pass, from the Sources in priority order.
Args:
state: What the Arbiter remembers between passes (nothing yet).
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).
Returns:
The winning Source's plan, or LEGACY_PLAN when the winner is one
run() still decides itself.
"""
del state, now # not read by the stage-2 Sources; see the docstring
# ScheduledOff is a gate, not a Source: a scheduled-off panel stays
# blank even for a follower, and only an on-demand session overrides
# it (#714 -- one ending in off hours blanks at the next pass).
if not inputs.schedule_on and not inputs.on_demand_active:
return SCHEDULED_OFF_PLAN
# 1. Follower: a sync leader drives this panel, ahead of on-demand.
if inputs.follower_active:
return FOLLOWER_PLAN
# 2. OnDemand: decided by run() until stage 3. It outranks the notice.
if inputs.on_demand_active:
return LEGACY_PLAN
# 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
def wifi_notice_preempts(notice: Optional[WifiNotice], on_demand_active: bool,
now: float) -> bool:
"""Whether a WiFi notice should end the current screen early.
The Wifi Source's mid-screen rule, polled between frames, during dwells
and when a Vegas iteration yields. On-demand outranks the notice, as in
:meth:`Arbiter.decide`. Unlike the top-of-pass check it also compares
``now`` with the expiry, because the 1 Hz read throttle can hand back a
notice that has expired since it was read.
"""
if on_demand_active or notice is None:
return False
return now < notice.expires_at