mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
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:
@@ -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
|
||||
+64
-30
@@ -35,6 +35,9 @@ from concurrent.futures import ThreadPoolExecutor, as_completed # pylint: disab
|
||||
import pytz
|
||||
|
||||
from src import display_watchdog
|
||||
from src.display_arbiter import (
|
||||
Arbiter, ArbiterInputs, ArbiterState, Source, WifiNotice, wifi_notice_preempts,
|
||||
)
|
||||
from src.display_manager import DisplayManager
|
||||
from src.config_manager import ConfigManager
|
||||
from src.config_service import ConfigService
|
||||
@@ -2754,11 +2757,12 @@ class DisplayController:
|
||||
# into an Arbiter / ScreenRunner / Sources (docs/RUN_LOOP_REDESIGN.md).
|
||||
# test/test_run_loop_golden.py pins down what the loop does with them.
|
||||
|
||||
def _blank_while_scheduled_off(self) -> None:
|
||||
def _blank_while_scheduled_off(self, dwell: float) -> None:
|
||||
"""One pass while the schedule has the panel off: blank it and dwell.
|
||||
|
||||
The dwell returns early when on-demand starts or the schedule turns
|
||||
the panel back on (see _sleep_with_plugin_updates).
|
||||
The Arbiter's SCHEDULED_OFF plan; ``dwell`` is its max_duration. The
|
||||
dwell returns early when on-demand starts or the schedule turns the
|
||||
panel back on (see _sleep_with_plugin_updates).
|
||||
"""
|
||||
# Clear display when schedule makes it inactive to ensure blank screen
|
||||
# (not showing initialization screen)
|
||||
@@ -2771,7 +2775,7 @@ class DisplayController:
|
||||
|
||||
logger.info(f"Display not active (is_display_active={self.is_display_active}), sleeping...")
|
||||
self._publish_current_mode_state()
|
||||
self._sleep_with_plugin_updates(60)
|
||||
self._sleep_with_plugin_updates(dwell)
|
||||
|
||||
def _run_follower_frame(self) -> None:
|
||||
"""One frame while a sync leader drives this panel (follower mode).
|
||||
@@ -2850,46 +2854,69 @@ class DisplayController:
|
||||
if remaining > 0:
|
||||
time.sleep(remaining)
|
||||
|
||||
def _show_wifi_notice(self) -> bool:
|
||||
"""Show a pending WiFi status message for one pass.
|
||||
def _arbiter_inputs(self) -> ArbiterInputs:
|
||||
"""This pass's snapshot for Arbiter.decide, taken after _evaluate_schedule.
|
||||
|
||||
_evaluate_schedule forces is_display_active on while an on-demand
|
||||
session overrides a scheduled-off window, and flags that with
|
||||
on_demand_schedule_override, so the schedule's own answer is "on and
|
||||
not overridden". The WiFi notice is read only when it could win --
|
||||
the panel is on and neither a follower nor on-demand outranks it --
|
||||
because _check_wifi_status_message has side effects (its 1 Hz
|
||||
throttle, deleting an expired or corrupt file) that such a pass
|
||||
never had.
|
||||
"""
|
||||
schedule_on = self.is_display_active and not self.on_demand_schedule_override
|
||||
on_demand = self.on_demand_active
|
||||
follower = self.sync_manager.is_follower_active()
|
||||
notice = None
|
||||
if self.is_display_active and not follower and not on_demand:
|
||||
notice = self._read_wifi_notice()
|
||||
return ArbiterInputs(schedule_on=schedule_on, on_demand_active=on_demand,
|
||||
follower_active=follower, wifi_notice=notice)
|
||||
|
||||
def _read_wifi_notice(self) -> Optional[WifiNotice]:
|
||||
"""The pending WiFi notice (see _check_wifi_status_message), or None."""
|
||||
status = self._check_wifi_status_message()
|
||||
if not status:
|
||||
return None
|
||||
return WifiNotice(message=status['message'],
|
||||
expires_at=float(status['expires_at']))
|
||||
|
||||
def _show_wifi_notice(self, notice: WifiNotice, dwell: float) -> bool:
|
||||
"""Draw the Arbiter's WIFI plan and hold it for ``dwell`` seconds.
|
||||
|
||||
Returns True when the message was drawn, and the pass ends there
|
||||
(no rotation). On-demand outranks it, so nothing is checked while
|
||||
on-demand is active; a message that fails to draw is treated as no
|
||||
message.
|
||||
(no rotation). A message that fails to draw is treated as no
|
||||
message: the pass carries on as a LEGACY plan.
|
||||
"""
|
||||
if self.on_demand_active:
|
||||
return False
|
||||
wifi_status_data = self._check_wifi_status_message()
|
||||
if not wifi_status_data:
|
||||
return False
|
||||
self._end_scroll_before_core_screen()
|
||||
if not self._display_wifi_status_message(wifi_status_data):
|
||||
if not self._display_wifi_status_message(
|
||||
{'message': notice.message, 'expires_at': notice.expires_at}):
|
||||
# Display failed, clear the status and continue normally
|
||||
return False
|
||||
# The plugin that resumes afterwards must redraw
|
||||
# the whole panel, not paint over the message.
|
||||
self.force_change = True
|
||||
self._sleep_with_plugin_updates(0.5)
|
||||
self._sleep_with_plugin_updates(dwell)
|
||||
return True
|
||||
|
||||
def _wifi_notice_pending(self) -> bool:
|
||||
"""True when a WiFi notice is waiting that _show_wifi_notice would draw.
|
||||
"""True when a WiFi notice should end the current screen early.
|
||||
|
||||
Polled from the frame loops, the dwell sleep and after a Vegas
|
||||
iteration yields, so a notice preempts whatever is on the panel
|
||||
within about a second instead of waiting for the screen to end --
|
||||
by which time a short notice has usually expired unseen. Cheap at
|
||||
frame rate: _check_wifi_status_message stats the file at most once
|
||||
a second. On-demand outranks the notice, as in _show_wifi_notice.
|
||||
a second. The rule is display_arbiter.wifi_notice_preempts; the
|
||||
file is not read at all while on-demand, which outranks the notice,
|
||||
is active.
|
||||
"""
|
||||
if self.on_demand_active:
|
||||
return False
|
||||
status = self._check_wifi_status_message()
|
||||
if not status:
|
||||
return False
|
||||
# The 1 s throttle can hand back a result that has expired since.
|
||||
return time.time() < float(status['expires_at'])
|
||||
return wifi_notice_preempts(self._read_wifi_notice(), self.on_demand_active,
|
||||
time.time())
|
||||
|
||||
def _resolve_active_mode(self):
|
||||
"""The mode this pass shows: the on-demand session's current mode
|
||||
@@ -3499,8 +3526,13 @@ class DisplayController:
|
||||
# is active). No repaint: this screen's first frame pushes it.
|
||||
self._apply_brightness_target()
|
||||
|
||||
if not self.is_display_active:
|
||||
self._blank_while_scheduled_off()
|
||||
# Who gets the panel this pass (src/display_arbiter.py). The
|
||||
# Arbiter decides the scheduled-off gate, Follower and Wifi;
|
||||
# a LEGACY plan carries on to the code below.
|
||||
plan = Arbiter.decide(ArbiterState(), self._arbiter_inputs(), time.time())
|
||||
|
||||
if plan.source is Source.SCHEDULED_OFF:
|
||||
self._blank_while_scheduled_off(plan.max_duration)
|
||||
continue
|
||||
|
||||
self._publish_current_mode_state_if_changed()
|
||||
@@ -3513,7 +3545,7 @@ class DisplayController:
|
||||
# Multi-display sync: follower mode — render frames received from leader.
|
||||
# Plugin update() threads still run (via _tick_plugin_updates above) so
|
||||
# data is fresh when we return to standalone if the leader goes offline.
|
||||
if self.sync_manager.is_follower_active():
|
||||
if plan.source is Source.FOLLOWER:
|
||||
self._run_follower_frame()
|
||||
continue
|
||||
|
||||
@@ -3521,10 +3553,12 @@ class DisplayController:
|
||||
# This also cleans up expired updates to prevent memory leaks
|
||||
self.display_manager.process_deferred_updates()
|
||||
|
||||
# Check for WiFi status message (interrupts normal rotation, but respects on-demand)
|
||||
# Priority: on-demand > wifi-status > live-priority > normal rotation
|
||||
# Past this point no WiFi message is showing this pass.
|
||||
if self._show_wifi_notice():
|
||||
# WiFi status message: interrupts the rotation, but on-demand
|
||||
# outranks it (the Arbiter's order). Past this point no WiFi
|
||||
# message is showing this pass: one that failed to draw
|
||||
# carries on as a LEGACY plan.
|
||||
if (plan.source is Source.WIFI and plan.notice is not None
|
||||
and self._show_wifi_notice(plan.notice, plan.max_duration)):
|
||||
continue # Skip to next iteration, don't rotate
|
||||
|
||||
# Check for live priority content and switch to it immediately.
|
||||
|
||||
Reference in New Issue
Block a user