fix(display): a WiFi notice preempts the current screen and Vegas yields to it (#712)

A WiFi notice preempts the current screen within about a second (frame loops, post-loop check, make-up dwell), and an interrupted Vegas iteration that yielded for a notice ends the pass so the notice shows next. Goldens: wifi_notice, vegas. First of three run-loop fixes (#712, #713, #714), pre-tested together.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-10-01 15:51:00 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 4be53d048b
commit 6c6394a1a7
8 changed files with 216 additions and 34 deletions
+8
View File
@@ -93,6 +93,14 @@ policies are unchanged.
the plugin leaves rotation until the cooldown ends, the same as a raising the plugin leaves rotation until the cooldown ends, the same as a raising
`update()`. The display still moves straight on to the next mode. A hung `update()`. The display still moves straight on to the next mode. A hung
`display()` is still recorded once, as a hang. `display()` is still recorded once, as a hang.
- A WiFi notice (such as "Connected to HomeNet" or "AP mode on") now shows
within about a second of being posted. It was only checked between
screens, so a 5 s notice posted during a 20 s screen expired before that
screen ended and never appeared. The screen it interrupts comes back in
full once the notice ends. When Vegas stops scrolling for a notice, the
notice is what shows next, and Vegas resumes after it; before, a rotation
screen showed instead and the notice expired behind it. An active
on-demand session still holds the panel until it ends.
## 3.8.0 ## 3.8.0
+12 -14
View File
@@ -41,12 +41,16 @@ Each pass, in order:
brightness target. brightness target.
4. **Scheduled off:** blank, dwell up to 60 s. `_blank_while_scheduled_off` 4. **Scheduled off:** blank, dwell up to 60 s. `_blank_while_scheduled_off`
5. **Follower:** render one frame from the leader. `_run_follower_frame` 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` 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 7. **Live priority** (unless on-demand, or Vegas keeps live content in the
ticker): switch to the next live mode, or resume the rotation. ticker): switch to the next live mode, or resume the rotation.
8. **Vegas** (unless on-demand, or live content preempts it): run one 8. **Vegas** (unless on-demand, or live content preempts it): run one
iteration of up to `max_cycle_duration`. A completed iteration ends the iteration of up to `max_cycle_duration`. A completed iteration ends the
pass. An interrupted one falls through to step 9 in the same pass. 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 9. **One screen:** pick the mode (`_resolve_active_mode`), the plugin
(`_plugin_for_mode`), draw the first frame through the executor (`_plugin_for_mode`), draw the first frame through the executor
(`_dispatch_first_frame`). On no content, rotate at once (`_dispatch_first_frame`). On no content, rotate at once
@@ -192,8 +196,8 @@ that the harness patches in today.
WiFi message, schedule state) are collected first, so `decide()` stays WiFi message, schedule state) are collected first, so `decide()` stays
pure. pure.
3. Unit-test `decide()` with tables. The golden traces must not change. 3. Unit-test `decide()` with tables. The golden traces must not change.
This includes the missed WiFi notice described below: fixing it is a The Wifi Source must keep the mid-screen preemption described in step 6
separate PR. of "What `run()` does today".
Follower and Wifi go first because each is one self-contained branch that 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. ends the pass. They prove the plumbing without touching the frame loops.
@@ -254,20 +258,14 @@ These are recorded as they are today. Each one should be fixed in its own
PR, which updates the affected trace and explains why. None of them is PR, which updates the affected trace and explains why. None of them is
changed by the restructure. changed by the restructure.
1. **A WiFi notice is only checked between screens.** A 5 s notice posted 1. **Vegas yields to live content, then shows a rotation screen first.**
during a 20 s screen expires before the screen ends and is never shown
(`wifi_notice`, t=25).
2. **Vegas yields to a WiFi notice, then shows a rotation screen instead of
the notice.** An interrupted iteration falls through to step 9 in the
same pass, and the notice has expired by the next pass (`vegas`, t=200).
3. **Vegas yields to live content, then shows a rotation screen first.**
The live game appears one screen later (`vegas`, t=70-90). The live game appears one screen later (`vegas`, t=70-90).
4. **Live priority only takes over between screens.** A game that goes 2. **Live priority only takes over between screens.** A game that goes
live mid-screen waits for that screen to end (`live_priority`: live at live mid-screen waits for that screen to end (`live_priority`: live at
t=50, shown at t=60). t=50, shown at t=60).
5. **An on-demand session that expires during scheduled-off keeps the panel 3. **An on-demand session that expires during scheduled-off keeps the panel
on** until the next minute boundary, because the schedule check runs at on** until the next minute boundary, because the schedule check runs at
most once a minute (`schedule`, t=190-210). most once a minute (`schedule`, t=190-210).
6. **A schedule window's end minute is inclusive**, and whether the panel 4. **A schedule window's end minute is inclusive**, and whether the panel
turns off at the start of that minute or the end depends on when in the turns off at the start of that minute or the end depends on when in the
minute the first check runs. minute the first check runs.
+45 -8
View File
@@ -1144,9 +1144,10 @@ class DisplayController:
Also services pending changes (see _service_pending_changes), and Also services pending changes (see _service_pending_changes), and
returns early when one of them changes what the panel should show -- returns early when one of them changes what the panel should show --
an on-demand start or stop, or the display schedule turning the panel an on-demand start or stop, the display schedule turning the panel
on or off -- so the caller can act on it instead of finishing a dwell on or off, or a WiFi notice arriving -- so the caller can act on it
that could be a minute long (sixty seconds while scheduled off). instead of finishing a dwell that could be a minute long (sixty
seconds while scheduled off).
""" """
if duration <= 0: if duration <= 0:
return return
@@ -1156,6 +1157,9 @@ class DisplayController:
mode = self.current_display_mode mode = self.current_display_mode
display_active = self.is_display_active display_active = self.is_display_active
on_demand = self.on_demand_active on_demand = self.on_demand_active
# Edge-triggered: the notice's own dwell starts with it pending and
# must not cut itself short; any other dwell ends when one arrives.
wifi_pending = self._wifi_notice_pending()
while True: while True:
remaining = end_time - time.time() remaining = end_time - time.time()
@@ -1171,7 +1175,9 @@ class DisplayController:
self._service_pending_changes() self._service_pending_changes()
if (self.current_display_mode != mode if (self.current_display_mode != mode
or self.is_display_active != display_active or self.is_display_active != display_active
or self.on_demand_active != on_demand): or self.on_demand_active != on_demand
or (not wifi_pending and self.is_display_active
and self._wifi_notice_pending())):
break break
def _note_empty_pass(self) -> None: def _note_empty_pass(self) -> None:
@@ -2427,6 +2433,22 @@ class DisplayController:
self._sleep_with_plugin_updates(0.5) self._sleep_with_plugin_updates(0.5)
return True return True
def _wifi_notice_pending(self) -> bool:
"""True when a WiFi notice is waiting that _show_wifi_notice would draw.
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.
"""
if self.on_demand_active:
return False
status = self._check_wifi_status_message()
# The 1 s throttle can hand back a result that has expired since.
return bool(status) and time.time() < status['expires_at']
def _resolve_active_mode(self): def _resolve_active_mode(self):
"""The mode this pass shows: the on-demand session's current mode """The mode this pass shows: the on-demand session's current mode
while one is active, else the rotation's. while one is active, else the rotation's.
@@ -2990,6 +3012,11 @@ class DisplayController:
# Scheduled off mid-iteration: blank the # Scheduled off mid-iteration: blank the
# panel now rather than render a screen. # panel now rather than render a screen.
continue continue
if self._wifi_notice_pending():
# It yielded for a WiFi notice: the next
# pass shows it, not a rotation screen
# that would outlast a short notice.
continue
except Exception: except Exception:
logger.exception("Vegas mode error") logger.exception("Vegas mode error")
# Fall through to normal rotation on error # Fall through to normal rotation on error
@@ -3160,7 +3187,8 @@ class DisplayController:
time.sleep(_remaining if _remaining > 0 else 0.001) time.sleep(_remaining if _remaining > 0 else 0.001)
if (self.current_display_mode != active_mode if (self.current_display_mode != active_mode
or not self.is_display_active): or not self.is_display_active
or self._wifi_notice_pending()):
logger.debug("Mode changed during high-FPS loop, breaking early") logger.debug("Mode changed during high-FPS loop, breaking early")
break break
@@ -3223,7 +3251,8 @@ class DisplayController:
self._service_pending_changes() self._service_pending_changes()
if (self.current_display_mode != active_mode if (self.current_display_mode != active_mode
or not self.is_display_active): or not self.is_display_active
or self._wifi_notice_pending()):
logger.info("Mode changed during display loop from %s to %s, breaking early", active_mode, self.current_display_mode) logger.info("Mode changed during display loop from %s to %s, breaking early", active_mode, self.current_display_mode)
break break
@@ -3246,9 +3275,12 @@ class DisplayController:
# _activate_on_demand already sets force_change=True and clears the # _activate_on_demand already sets force_change=True and clears the
# display, so the next loop iteration renders the new mode immediately. # display, so the next loop iteration renders the new mode immediately.
# Likewise if the schedule turned the display off # Likewise if the schedule turned the display off
# mid-screen: the next iteration blanks it. # mid-screen (the next iteration blanks it), or a WiFi
# notice arrived (the next iteration shows it, then this
# mode resumes rather than rotating past it).
if (self.current_display_mode != active_mode if (self.current_display_mode != active_mode
or not self.is_display_active): or not self.is_display_active
or (not loop_completed and self._wifi_notice_pending())):
continue continue
# Ensure we honour minimum duration when not dynamic and loop ended early # Ensure we honour minimum duration when not dynamic and loop ended early
@@ -3261,6 +3293,11 @@ class DisplayController:
remaining_sleep = max(0.0, max_duration - elapsed) remaining_sleep = max(0.0, max_duration - elapsed)
if remaining_sleep > 0: if remaining_sleep > 0:
self._sleep_with_plugin_updates(remaining_sleep) self._sleep_with_plugin_updates(remaining_sleep)
# Cut short by a WiFi notice: show it, then
# resume this mode rather than rotating past it.
if (self._wifi_notice_pending()
and time.time() - start_time < max_duration):
continue
if dynamic_enabled: if dynamic_enabled:
elapsed_total = time.time() - start_time elapsed_total = time.time() - start_time
+8 -1
View File
@@ -766,7 +766,14 @@ def reduce_trace(events, horizon: float) -> Dict[str, Any]:
rows = [] rows = []
for i, screen in enumerate(screens): for i, screen in enumerate(screens):
end = screens[i + 1]["t"] if i + 1 < len(screens) else horizon end = screens[i + 1]["t"] if i + 1 < len(screens) else horizon
exit_reason = screen["exit"] or ("horizon" if i + 1 == len(screens) else "duration") nxt = screens[i + 1] if i + 1 < len(screens) else None
# A WiFi notice logs no event at the moment it takes the panel (the
# file is written earlier), so a screen followed by one is labelled
# "wifi". Its duration column shows whether it was cut short.
exit_reason = screen["exit"] or (
"horizon" if nxt is None
else "wifi" if nxt["mode"] == "<wifi>" and screen["mode"] != "<wifi>"
else "duration")
rows.append([screen["t"], screen["mode"], round(end - screen["t"], 3), rows.append([screen["t"], screen["mode"], round(end - screen["t"], 3),
exit_reason, screen["frames"], screen["clear"]]) exit_reason, screen["frames"], screen["clear"]])
return {"screens": rows, "events": notable} return {"screens": rows, "events": notable}
+3 -3
View File
@@ -10,9 +10,9 @@
[150.263, "clock", 20.0, "duration", 20, true], [150.263, "clock", 20.0, "duration", 20, true],
[170.263, "clock", 5.0, "on-demand-expired", 5, true], [170.263, "clock", 5.0, "on-demand-expired", 5, true],
[175.263, "<vegas>", 24.959, "vegas-interrupt", 3120, null], [175.263, "<vegas>", 24.959, "vegas-interrupt", 3120, null],
[200.222, "clock", 20.0, "duration", 20, true], [200.222, "<wifi>", 3.0, "duration", 6, null],
[220.222, "<vegas>", 30.008, "duration", 3751, null], [203.222, "<vegas>", 30.008, "duration", 3751, null],
[250.23, "<vegas>", 9.77, "horizon", 1222, null] [233.23, "<vegas>", 26.77, "horizon", 3347, null]
], ],
"events": [ "events": [
[70.255, "vegas-live"], [70.255, "vegas-live"],
+8 -6
View File
@@ -1,13 +1,15 @@
{ {
"screens": [ "screens": [
[0.0, "clock", 20.0, "duration", 20, false], [0.0, "clock", 20.0, "duration", 20, false],
[20.0, "weather", 20.0, "duration", 20, true], [20.0, "weather", 5.0, "wifi", 6, true],
[40.0, "clock", 20.0, "on-demand-start", 20, true], [25.0, "<wifi>", 5.0, "duration", 10, null],
[60.0, "clock", 20.0, "on-demand-expired", 20, true], [30.0, "weather", 20.0, "duration", 20, true],
[50.0, "clock", 20.0, "on-demand-start", 20, true],
[70.0, "clock", 10.0, "on-demand-expired", 10, true],
[80.0, "<wifi>", 15.0, "duration", 30, null], [80.0, "<wifi>", 15.0, "duration", 30, null],
[95.0, "weather", 20.0, "duration", 20, true], [95.0, "clock", 20.0, "duration", 20, true],
[115.0, "clock", 20.0, "duration", 20, true], [115.0, "weather", 20.0, "duration", 20, true],
[135.0, "weather", 15.0, "horizon", 15, true] [135.0, "clock", 15.0, "horizon", 15, true]
], ],
"events": [ "events": [
[25.0, "wifi-file", "Connected to HomeNet"], [25.0, "wifi-file", "Connected to HomeNet"],
+2 -2
View File
@@ -148,8 +148,8 @@ def scenario_schedule(h: RunLoopHarness):
def scenario_wifi_notice(h: RunLoopHarness): def scenario_wifi_notice(h: RunLoopHarness):
h.add_plugin(FakePlugin("clock", ["clock"], duration=20)) h.add_plugin(FakePlugin("clock", ["clock"], duration=20))
h.add_plugin(FakePlugin("weather", ["weather"], duration=20)) h.add_plugin(FakePlugin("weather", ["weather"], duration=20))
# Posted mid-screen and expired before the screen ends: never shown, # Posted mid-screen: it preempts the screen at its next frame, stays up
# because the notice is only checked between screens. # until it expires, and the interrupted mode then comes back in full.
h.wifi_message(25, "Connected to HomeNet", duration=5) h.wifi_message(25, "Connected to HomeNet", duration=5)
# While on-demand is active the notice waits. # While on-demand is active the notice waits.
h.on_demand_request(60, "w1", plugin_id="clock", duration=20) h.on_demand_request(60, "w1", plugin_id="clock", duration=20)
+130
View File
@@ -0,0 +1,130 @@
"""A WiFi notice preempts the current screen promptly and stays up.
It used to be checked only between screens, so a notice shorter than the
screen it was posted during expired unseen, and Vegas yielding for one went
on to a rotation screen instead of the notice. Runs the real run() loop on
the fake clock of test/_run_loop_harness.py.
"""
import os
os.environ.setdefault("EMULATOR", "true")
from test._run_loop_harness import FakePlugin, RunLoopHarness # noqa: E402
def _rows(trace):
return [tuple(row[:4]) for row in trace["screens"]]
def _wifi_rows(trace):
return [row for row in trace["screens"] if row[1] == "<wifi>"]
def test_notice_preempts_a_static_screen_and_the_mode_resumes(tmp_path):
h = RunLoopHarness(tmp_path, horizon=70)
h.add_plugin(FakePlugin("clock", ["clock"], duration=20))
h.add_plugin(FakePlugin("weather", ["weather"], duration=20))
h.wifi_message(25.3, "Connected to HomeNet", duration=5)
trace = h.run()
(wifi,) = _wifi_rows(trace)
# Within about a second of being posted (the 1 Hz loop's next frame),
# and up until it expires at 30.3.
assert 25.3 <= wifi[0] <= 26.3
assert wifi[0] + wifi[2] >= 30.3
rows = _rows(trace)
i = rows.index(tuple(wifi[:4]))
assert rows[i - 1][1] == "weather" and rows[i - 1][3] == "wifi"
# The interrupted screen comes back in full; the rotation is not skipped.
assert rows[i + 1][1:3] == ("weather", 20.0)
assert rows[i + 2][1] == "clock"
def test_notice_preempts_a_high_fps_screen(tmp_path):
h = RunLoopHarness(tmp_path, horizon=60)
h.add_plugin(FakePlugin("ticker", ["ticker"], duration=30, enable_scrolling=True))
h.add_plugin(FakePlugin("clock", ["clock"], duration=20))
h.wifi_message(10.0, "AP mode on", duration=4)
trace = h.run()
(wifi,) = _wifi_rows(trace)
assert 10.0 <= wifi[0] <= 11.0
assert wifi[0] + wifi[2] >= 14.0
assert trace["screens"][0][1:4] == ["ticker", wifi[0], "wifi"]
def test_notice_cuts_the_make_up_dwell_short(tmp_path):
# flaky returns False after its first frame, so the rest of its screen
# is a make-up dwell rather than a frame loop.
h = RunLoopHarness(tmp_path, horizon=40)
h.add_plugin(FakePlugin("flaky", ["flaky"], duration=20, first_frame_only=True))
h.add_plugin(FakePlugin("clock", ["clock"], duration=20))
h.wifi_message(8.0, "Connected to HomeNet", duration=5)
trace = h.run()
(wifi,) = _wifi_rows(trace)
assert 8.0 <= wifi[0] <= 9.0
assert wifi[0] + wifi[2] >= 13.0
rows = _rows(trace)
i = rows.index(tuple(wifi[:4]))
# flaky was cut short, so it comes back rather than being rotated past.
assert rows[i + 1][1] == "flaky"
def test_a_screen_that_ran_its_full_time_is_not_repeated(tmp_path):
# Posted just before clock's 20 s are up: clock ends on time, the notice
# shows, and the rotation moves on to weather rather than clock again.
h = RunLoopHarness(tmp_path, horizon=50)
h.add_plugin(FakePlugin("clock", ["clock"], duration=20))
h.add_plugin(FakePlugin("weather", ["weather"], duration=20))
h.wifi_message(19.5, "Connected to HomeNet", duration=5)
trace = h.run()
rows = _rows(trace)
assert rows[0] == (0.0, "clock", 20.0, "wifi")
assert rows[1][1] == "<wifi>"
assert rows[2][1] == "weather"
def test_on_demand_still_outranks_the_notice(tmp_path):
h = RunLoopHarness(tmp_path, horizon=80)
h.add_plugin(FakePlugin("clock", ["clock"], duration=20))
h.add_plugin(FakePlugin("weather", ["weather"], duration=20))
h.on_demand_request(5, "o1", plugin_id="weather", duration=30)
h.wifi_message(15, "AP mode on", duration=30)
trace = h.run()
(wifi,) = _wifi_rows(trace)
# Held back until the on-demand session expires at about t=35.
assert wifi[0] >= 35.0
on_demand = [r for r in trace["screens"] if r[1] == "weather" and r[0] < 35]
assert on_demand and all(r[3] != "wifi" for r in on_demand[:-1])
def test_vegas_yields_to_the_notice_then_resumes(tmp_path):
h = RunLoopHarness(tmp_path, horizon=80)
h.add_plugin(FakePlugin("clock", ["clock"], duration=20))
h.enable_vegas(cycle=30)
h.wifi_message(40, "Connected to HomeNet", duration=3)
trace = h.run()
rows = _rows(trace)
i = next(n for n, row in enumerate(rows) if row[3] == "vegas-interrupt")
assert rows[i][1] == "<vegas>" and 40.0 <= rows[i][0] + rows[i][2] <= 41.0
# The notice is what shows next, for its whole 3 s, and Vegas follows.
assert rows[i + 1][1] == "<wifi>"
assert rows[i + 1][0] + rows[i + 1][2] >= 43.0
assert rows[i + 2][1] == "<vegas>"
assert "clock" not in [row[1] for row in rows[i:]]
def test_pending_check_ignores_a_cached_notice_that_has_expired(tmp_path):
h = RunLoopHarness(tmp_path, horizon=10)
dc = h.controller
dc._check_wifi_status_message = lambda: {"message": "x", "expires_at": 0.0}
assert not dc._wifi_notice_pending()
dc._check_wifi_status_message = lambda: {"message": "x", "expires_at": float("inf")}
assert dc._wifi_notice_pending()
dc.on_demand_active = True
assert not dc._wifi_notice_pending()