Compare commits

..
Author SHA1 Message Date
Claude 6b6c4d2daf fix(install): stop libblockdev matching the desktop-environment check
dpkg -l | grep -E '^ii.*kde' searched the description column too, so
unrelated packages (libblockdev-*) set DESKTOP_DETECTED on Raspberry Pi OS
Lite. Match on the installed package name only, anchored.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VZNWFWprcFYGfuf1JAyrBJ
2026-10-06 01:39:19 +00:00
ChuckandClaude Opus 5.5 e4f5e49ff7 test(sports): treat an adopted sports_helpers copy as parity, not missing (#777)
* test(sports): treat an adopted sports_helpers copy as parity, not missing

The scoreboards deleted their copies of the sports_helpers bodies and
constants when they adopted SportsHelpersMixin (ledmatrix-plugins
#563/#564), so the 19 parity tests in test/test_sports_helpers.py failed
whenever LEDMATRIX_PLUGINS pointed at a plugins checkout. A copy that is
gone now counts as adopted when the plugin imports
src.common.sports_helpers, as the stage 3/4 and game-over parity tests
already do; a copy that remains must still match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(sports): _adopted checks for a real import via the AST, not a text match

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:42:27 -04:00
ChuckandClaude Opus 5.5 e40bc47d28 chore: prepare the 3.8.2 release (#776)
Bumps src.__version__ to 3.8.2 and turns Unreleased into ## 3.8.2: the
display's malloc arena cap and between-screen malloc_trim (#774), and
src.common.sports_favorites (#775, sports family 6), which the scoreboards
adopt by flooring on 3.8.2. src/common/README.md and
docs/SPORTS_UNIFICATION.md say 3.8.2 for it; the SPORTS_UNIFICATION module
table also said "next release" for the four stage 4 modules, which shipped
in 3.8.0.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:50:05 -04:00
ChuckandClaude Opus 5.5 3bdb5bff3b feat(common): sports_favorites -- the reconciled favourite matching (sports family 6) (#775)
* feat(common): sports_favorites -- the reconciled favourite matching (sports family 6)

New hardware-free module src/common/sports_favorites.py, copied from
ledmatrix-plugins claude/family6-reconcile once the nine scoreboards made
_is_favorite_game (seven bodies), _select_games_for_display (two) and
_select_recent_games_for_display (three) one body each. One mixin per class
that carries the methods, so adopting one gives no manager a method it did
not have:

- SportsFavoritesMixin (SportsCore): _is_favorite_game and _favorite_code.
- SportsUpcomingFavoritesMixin: _select_games_for_display.
- SportsRecentFavoritesMixin: _select_recent_games_for_display.

Each side of a game is named by the 3.5.0 _favorite_key seam
(SportsHelpersMixin; the abbreviation by default, nrl overrides it with the
ESPN team id and None for a missing id) and compared with favorite_teams
stripped and upper-cased. The selection methods give each favourite up to the
per-team limit, count a game between two favourites for both, treat only
games with an id as possible duplicates and log their summary at INFO.

- test/test_sports_favorites.py: the plugins' pinned cases for an abbreviation
  host and an id-keyed (nrl-style) host -- case, spaces, ids, the NEW
  collision, the "None" favourite, missing keys; selection order, limits,
  duplicates and the id-less fix, the INFO summary; host contract, one carrier
  per method, and SportsGameRulesMixin reaching the shared body.
- test/test_sports_favorites_parity.py: with LEDMATRIX_PLUGINS, compares each
  body with every plugin copy (drift-report normalisation plus decorators),
  checks no other plugin class carries a copy, and that only nrl overrides
  _favorite_key.
- mypy ratchet, src/common/README.md, CHANGELOG (Unreleased, New modules).
- sports_helpers docstrings: _favorite_key now has a caller and an override.
- docs/SPORTS_UNIFICATION.md: family 6 status and decisions, and the seam
  table. SportsCoreSharedMixin._round_robin_favorites still groups by raw
  abbreviation or _team_in: it is not one of the plugin bodies, so it waits
  for a later family.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(sports): family 6 also routes the Upcoming favourites-only filter and three live boosts

ledmatrix-plugins claude/family6-reconcile now sends the Upcoming update()'s
favourites-only pre-filter and the basketball, hockey and lacrosse live
favourite boost through _is_favorite_game, so a lower-case favourite works on
a favourites-only Upcoming board. The module is unchanged (update() is not
promoted); the parity test still passes against the branch. Updates the
pinned row and cell counts and what is left for later families.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: cite ledmatrix-plugins #635 for the family 6 reconcile

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:33:05 -04:00
ChuckandClaude Opus 5.5 e745ae8060 feat(display): cap malloc arenas in-process and malloc_trim between screens (#774)
* feat(display): cap malloc arenas in-process and malloc_trim between screens

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: malloc_tuning with ctypes mocked; add to the mypy ratchet

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(changelog): malloc arena cap and malloc_trim between screens

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(changelog): spacing

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:47:29 -04:00
ChuckandClaude Opus 5.5 a669d781f5 chore: prepare the 3.8.1 release (#756)
* chore: prepare the 3.8.1 release

Bumps src.__version__ to 3.8.1 and moves the Unreleased CHANGELOG entries
under a 3.8.1 heading, with an empty Unreleased above it.

The reason for the release is smooth scrolling at held-frame speeds. On
3.8.0 the default 50 px/s snapped to a stepped 48 px/s on a 120 Hz panel,
and any scroll slower than one pixel per refresh showed a half-pixel step
across the middle of the panel. Both are fixed on main (#710, #711) but
were in no release, so every stable-channel device still had them.

- #710's CHANGELOG entry had been filed under 3.8.0 although it merged
  after the v3.8.0 tag; it moves to 3.8.1.
- #711 had no CHANGELOG entry; it gets one.

scripts/check_release_version.py v3.8.1 passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: sports_game_over and draw_text_outlined ship in 3.8.1

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:11:17 -04:00
ChuckandClaude Opus 5.5 c20c0beac2 feat(web): the Display tab is an ES-module page, with a page-visibility service (stage 4) (#771)
* feat(web): the Display tab is an ES-module page, with a page-visibility service (stage 4)

- core/visibility.js: each page gets ctx.visibility (whileVisible, every,
  isVisible). Work registered there runs only while the page's tab is
  active and the browser tab visible, and ends when the page is swapped
  out. It reads the active tab from window.LEDVisibility, so it agrees
  with the classic partials. The registry gained a mountContext option for
  per-mount services.
- pages/display.js replaces display.html's two inline scripts. The 5 s
  sync status poll runs through ctx.visibility.every; the status and
  scroll-speed hint requests go through ctx.api with ctx.signal, as does
  the Vegas order widget's plugin-list request. The Advanced toggle is a
  delegated data-action; window.updateSyncUI is a deprecated alias.
- New DOM suites test_visibility_service.js and test_display_page.js;
  test_display_partial_ids.js imports the module.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(web): no computed keys in the Display page's readout and destroy

Codacy's object-injection rule flagged v[id] and ctx.state[name].

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:53:43 -04:00
ChuckandClaude Opus 5.5 6fb2dc3595 fix(sports): honour every pending kickoff in the idle back-off, not just the first (#772)
_note_scheduled_start_candidate kept one kickoff. While it was inside its
15-minute grace every later kickoff was refused, and by the time the grace
ended the later one had passed and was refused again as already past. So of
two favourites kicking off within 15 minutes of each other, the second lost
its own grace: if the first game was not live by then (a rain delay, a
postponement, ESPN slow to flip it) and ESPN had not flipped the second
either, the back-off went straight back to its ceiling and the second game
was noticed up to that late.

Later kickoffs now wait in a short queue (_later_scheduled_starts, the
earliest 8). When the current kickoff's grace ends, the earliest queued one
still inside its own grace takes over -- including one that has already
passed. A kickoff still holds the live cadence for at most its own grace, so
a postponed game costs the same quarter of an hour as before, and
_next_scheduled_start_ts keeps its meaning for anything that reads or sets
it. The promotion is a module function, so the mixin's method set is
unchanged.

Table tests replay the idle loop on a fake clock over kickoff schedules;
mutation-checked (the old code fails 10 of the new tests).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:52:22 -04:00
ChuckandClaude Opus 5.5 b638b91169 feat(common): sports_game_over -- the reconciled game-over check (sports family 5) (#770)
* feat(common): sports_game_over -- the reconciled game-over check (sports family 5)

New hardware-free module src/common/sports_game_over.py with
SportsGameOverMixin._is_game_really_over, the scoreboards' SportsLive check
that drops a game ESPN still lists as live, copied from ledmatrix-plugins
claude/family5-reconcile once the nine copies (five bodies) became one.

Over on a final period text; from period FINAL_PERIOD on, also on a 0:00
clock string unless the score is level (a tie at the end of regulation goes
to overtime; a game that ends tied ends on its final status). FINAL_PERIOD
is the one per-sport seam, a class attribute defaulting to None (the clock
never ends a game); the scoreboards declare 3 (hockey), 4 (basketball,
football, lacrosse) or None (afl, nrl, soccer, baseball, ufc).

- test/test_sports_game_over.py: the plugins' pinned matrix folded to the
  three FINAL_PERIOD values, edge shapes, the tie guard, ufc's recorded
  ESPN MMA states, an override deferring through super() (baseball), the
  base order with SportsLiveSharedMixin._detect_stale_games, host contract.
- test/test_sports_game_over_parity.py: with LEDMATRIX_PLUGINS, compares the
  body with every plugin copy (drift-report normalisation plus decorators)
  and each plugin's FINAL_PERIOD with the owner's decision.
- mypy ratchet, src/common/README.md, CHANGELOG (Unreleased, New modules).
- docs/SPORTS_UNIFICATION.md: family 5 status and decisions; the seam
  tables now match the code (FINAL_PERIOD defaults to None; the
  CLOCK_COUNTS_DOWN seam never existed and is gone from the doc).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: cite ledmatrix-plugins #625 for the family 5 reconcile

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:36:12 -04:00
ChuckandClaude Opus 5.5 bb475a79ea fix(plugins): keep a plugin's tokens and local files across store updates (#755)
* fix(plugins): keep a plugin's tokens and local files across store updates

A monorepo plugin update replaces the plugin directory with the fresh
download and deletes the old copy, taking with it everything the plugin
wrote beside itself. On 2026-10-04 updating calendar 1.2.9 -> 1.2.12 deleted
token.pickle and credentials.json, and the calendar stopped until they were
restored from a backup.

Before the set-aside copy is discarded (store update, reinstall over an
existing copy, install_from_url replace), carry over files the plugin's
.gitignore excludes plus known secret/state files (*.pickle, token.json,
credentials.json, config_secrets.json, .pkce_code_verifier). Files the new
release ships win; byte code and .git are not carried; if a copy fails the
old copy is kept.

The git-pull path no longer sweeps untracked tokens into its auto-stash,
which is never popped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(plugins): find the new copy via _existing_install, as install_plugin does

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(on-demand): find the write under test by key, not by position

TestARestoreWithNothingToResume took the last cache_manager.set call to be
the on-demand state, but the controller's font-usage publisher thread writes
font_usage_snapshot to the same mock, and on a slow runner it lands last.
Failing on main since #748 (Python 3.11 job). Same fix for the named-mode
restart test, which had the same race.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(starlark): fake only the editor launch, not every Popen in the request

TestPixletEditorHostDefaultsButDoesNotOverride patched subprocess.Popen for
the whole request. When the captive-portal before_request hook's 30s AP-mode
cache had expired, its `systemctl is-active hostapd` check went through
subprocess.run, got the fake process, and raised TypeError (run() uses the
process as a context manager): a 500 instead of 200. Seen on the Python 3.13
job; reproduced locally by forcing the cache to expire. Other calls now reach
the real Popen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:06:56 -04:00
ChuckandClaude Opus 5.5 3f920f2899 fix(fonts): unloading a plugin forgets its manifest fonts (#757)
* fix(fonts): unloading a plugin forgets its manifest fonts

PluginManager.unload_plugin() and the failed-load cleanup only called
FontManager.forget_manager_fonts(), which drops usage data. The plugin's
manifest registrations stayed: plugin_fonts / plugin_font_catalogs, its
plugin_id::family entries in font_catalog, and cached font objects for them
-- so its fonts kept resolving after unload and a family a reinstalled
manifest dropped stayed registered. The deprecated unregister_plugin_fonts
did this cleanup but nothing called it; it was removed in #708.

Add FontManager.forget_plugin_fonts(plugin_id) and call it from both
paths alongside forget_manager_fonts (each guarded on its own, so a font
manager stub with only one still works). FontManager takes no locks, so
like forget_manager_fonts it uses atomic pops over snapshots. A reload
(unload + load) registers the manifest fonts again and they resolve.

Raised by CodeRabbit on #709.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(fonts): call the font manager's forget methods without a None-able local

Pylint E1102 (Codacy) read getattr(..., None) as possibly not callable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:26:26 -04:00
ChuckandClaude Opus 5.5 0577c807eb feat(plugins): request_on_demand() / end_on_demand() -- plugins ask for the screen in-process (#768)
* feat(plugins): request_on_demand() / end_on_demand() -- plugins ask for the screen in-process

Four plugins (birdnet-go, mqtt-notifications, on-air, pomodoro-timer) take
the screen by writing the display_on_demand_request mailbox, which the
display reads once a second while the control socket is up and which stage 5
removes. This is the in-process way in that stage needed.

- BasePlugin.request_on_demand(mode=None, duration=None, pinned=False) and
  end_on_demand(), safe from any thread, go through PluginManager to
  DisplayController.submit_plugin_on_demand, which only queues (at most 32)
  and wakes the render thread through ControlServer.wake(). The render
  thread applies them in _drain_control_commands, after socket commands,
  through _handle_on_demand_request, so they land within a frame; without a
  socket, on the next pending-changes pass.
- A plugin's stop ends only its own session; a mailbox stop still ends any.
- Both answer the request id, or None with no display in the process (web
  interface, check_plugin.py), a full queue, or a mock manager -- a plugin's
  cue to write the mailbox, which the display still reads.
- docs/PLUGIN_API_REFERENCE.md documents the hasattr pattern for plugins
  that must keep working on older cores; IPC_CONTROL_SOCKET.md and the
  CHANGELOG are updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(display): wire the on-demand handler only on a manager that has it

Tests and the golden traces stand in simpler plugin managers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:39:02 -04:00
ChuckandClaude Opus 5.5 5a7893b11a feat(web): Schedule and General become ES-module pages (stage 3) (#767)
* feat(web): Schedule and General become ES-module pages (stage 3)

Schedule and General follow stage 2 (#727): no inline scripts or inline
handlers in either partial. Their code moves to static/v3/js/pages/schedule.js
and pages/general.js, started per swap-in by the page registry.

- Schedule: both pickers are drawn from the saved config carried as JSON in
  data-* attributes. The forms' hx-on save handlers become one
  htmx:afterRequest listener on the page; the forms are marked
  data-reports-result, which app.js now treats like an hx-on after-request
  handler, so a save still shows one notification.
- General: the timezone picker reads data-timezone. The Security section's
  forms and buttons are delegated data-actions; requests go through
  core/api.js, so the login redirect is quiet, and a change made just
  before a swap is still reported.
- handleScheduleResponse, handleDimScheduleResponse and webLogin stay as
  deprecated aliases through window.LEDMatrix.
- New DOM suites test_schedule_page.js and test_general_page.js; the web
  login unit suite imports the module; test_es_modules.py pins the pages,
  the aliases, and the schedule config's round trip through its attribute.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(web): no unused catch bindings or computed writes in the stage 3 modules

Codacy flagged two unused catch variables and dynamic-key writes in
pages/schedule.js and boot.js. The schedule config is read with
getAttribute, and the default days and the webLogin alias object are built
with Object.fromEntries. No behaviour change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 23:18:02 -04:00
ChuckandClaude Opus 5.5 3866aa4519 feat(ipc): the control socket carries every web command; mailboxes are a fallback (stage 4) (#765)
- client: ControlError.sent says whether the display had the request;
  should_fall_back() allows a mailbox write only when it did not, or when
  the display is too old to know the command (upgrade case)
- on-demand start/stop: a display that had the request and failed it is
  answered 503 (400 for invalid_args), no mailbox copy
- errors.clear: new socket command, answered on the connection thread by
  a handler the display registers; applied and republished before the
  answer; plugin_error_clear_request only on fallback
- display: on-demand mailbox looked at once a second while the socket is
  up (0.25 s without), read only when its file changed (one stat via
  CacheManager.file_signature / MailboxWatch); socket commands no longer
  touch the mailbox; a processed duplicate is consumed; writers logged once
- error publisher: mailbox read only when changed; snapshot carries
  applied_clear_cutoff so an older mailbox request is not shown pending
- docs and CHANGELOG (mailboxes kept for one release)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 22:44:17 -04:00
ChuckandClaude Opus 5.5 26cae3e5d6 refactor(display): run loop stage 3 -- ScreenRunner, PREEMPTED, OnDemand/Live/Rotation Sources (#762)
* refactor(display): run each screen through a ScreenRunner (run loop stage 3)

The two frame loops, the make-up dwell and the dynamic-duration exit move
out of DisplayController.run() into src/screen_runner.py. ScreenRunner
paces with an injected FrameClock (production: this module's time, looked
up per call so the golden harness's fake clock still drives it) and
returns one Outcome whose ExitReason is DURATION, CYCLE_COMPLETE, EMPTY,
ERROR, DISPLAY_FALSE, RELOAD or PREEMPTED.

PREEMPTED replaces the re-checks that used to follow each frame loop and
the make-up dwell (current_display_mode != active_mode, the schedule, a
pending WiFi notice): the runner asks its host at named service points
(FRAME, AFTER_LOOP, after_dwell, FINAL), and on PREEMPTED run() goes to
the next pass without advancing the rotation, as each `continue` did.
RELOAD is the one early end that still advances, as a reload always did.
Each service point reads the WiFi notice file exactly when the loop did
(NoticeRead), because the read is throttled and deletes expired files.

The host answers still use the old checks; the following commits move
them to the Arbiter. _screen_preempted is gone (folded into the FRAME
check); _wait_frame_interval returns the preempting plan instead of a
bool. The frame pacing (8 ms deadline, 1 ms minimum yield, 1 Hz wait with
socket wake) is the same code, moved.

Golden traces unchanged. A capture of every harness run (all 67, with
every sleep, display() call, wifi read, live scan, publish and dwell
logged) is identical to origin/main except for throttled WiFi reads that
returned the cached answer (no side effect) after a notice preempted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(display): on-demand is an Arbiter Source (run loop stage 3)

Arbiter.decide() now answers for an active on-demand session itself
(Source.ON_DEMAND) instead of returning LEGACY:

- ArbiterState gains the session: its mode list, index, expiry and pin,
  plus current_mode, snapshotted from the controller's fields by
  _arbiter_state(). The controller's attributes stay the record that the
  web UI, the control socket and the cache read.
- The OnDemand plan is the session's current mode (an index past the end
  of a shortened list starts again at 0), with what is left of a timed
  session at `now` as max_duration and the expiry as deadline. A session
  with no modes left is a plan with no mode; the controller ends it and
  shows the rotation's mode, as _resolve_active_mode did.
- on_demand_bound() is _clamp_to_on_demand made pure. It is still applied
  after the first frame, with the clock read there.
- ArbiterState.next_on_demand() is the step _advance_on_demand takes.

run() asks decide() for the screen at the point it used to call
_resolve_active_mode (after any Vegas iteration, so a session that
started mid-iteration still shows next), and _take_plan() applies it.

Golden traces and the 67-run capture identical to origin/main. Adds
TestOnDemand and the bound table to test_display_arbiter.py; the stage-2
table's on-demand rows now name ON_DEMAND instead of LEGACY.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(display): live priority is an Arbiter Source (run loop stage 3)

The live-priority step of run() (step 7) and the live checks around the
Vegas iteration become the Arbiter's Live Source:

- ArbiterInputs gains live_modes (the scan, None where run() made none),
  vegas_enabled, vegas_live_in_ticker and vegas_yielded. ArbiterState
  gains the rotation and its index, the live resume point and the
  "takeover not shown yet" flag.
- Live picks the next live mode round-robin (live_pick, now also what
  _check_live_priority returns), not advancing past a mid-screen takeover
  that has not shown. It outranks Vegas unless the ticker keeps live
  content, in which case it has no say at all, as before. With nothing
  live, a plan below it carries ends_live and the interrupted rotation
  resumes.
- ArbiterState.claim_live/release_live are _apply_live_priority's
  bookkeeping made pure; _apply_live_priority applies them.

run() reads the inputs below the WiFi notice where it always did
(_arbiter_inputs_below_wifi: the Vegas check, then the scan), asks
decide() once more, and _take_plan applies the claim or the resume. The
Vegas iteration moves to _run_vegas_iteration, which re-decides with
vegas_yielded after a yield, so a game that stopped the ticker or an
on-demand session that started mid-iteration still shows next. LEGACY
now means Vegas or the rotation.

One redundant call is gone: a Vegas pass scanned the live plugins twice
at the same instant (step 7, then step 8's "is anything live?"); it scans
once. Golden traces unchanged. The 67-run capture is identical to
origin/main once that duplicate scan and _apply_live_priority(None) calls
that changed nothing are left out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(display): the rotation is an Arbiter Source; LEGACY means Vegas (run loop stage 3)

decide() now names the screen for every pass: the Rotation Source
(Source.ROTATION) answers with the rotation's current mode, after the
resume when live priority just ended. LEGACY is left meaning only Vegas,
whose iteration is still run()'s own code until stage 4. Once this pass's
iteration has yielded (vegas_yielded), Vegas passes and the screen it fell
through to is decided like any other.

The rotation's mode is state.current_mode rather than
rotation[rotation_index]: they agree except where something moved the
panel off the list and the rotation carries on from there (a live mode no
entry names, or None after a session ended with nothing to resume to),
and run() always showed current_display_mode.

ArbiterState.after(outcome) is _advance_after_screen's step: an on-demand
session moves to its next mode; otherwise the rotation advances unless the
mode just shown is still live. The Outcome carries the two facts only the
controller can see at the end of the screen (on_demand_active, the live
hold from _still_live). Ending a session with no modes left stays in the
controller, because it is not pure.

Golden traces unchanged; the 67-run capture is identical to origin/main
with the same two exclusions as the previous commit. Adds the Vegas /
Rotation table and TestAfter; the stage-2 rows that said LEGACY for "live,
Vegas or rotation" now say ROTATION.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(display): one decide() call at each of the runner's service points (run loop stage 3)

The three mid-screen checks the frame loops made one after another --
_check_live_takeover, then _screen_preempted with _wifi_notice_pending in
it -- become one call: Arbiter.decide(state, inputs, now, running=plan).
It returns `running` itself while the screen holds, else the plan that
ends it, from these rules in the order the loops checked them:

1. Live: a game went live while a non-live screen runs. First because it
   is the one preemption that changes the state (the rotation moves to
   the live mode and remembers where it was), and it is still claimed
   when a WiFi notice is pending too; the next pass shows the notice,
   then the game, as before.
2. The panel's mode moved under the screen (on-demand started, ended or
   changed; the rotation was rebuilt).
3. The schedule turned the panel off.
4. A WiFi notice arrived (on-demand outranks it; compared with expiry).
5. A plugin reload is waiting (between frames only).

Each rule is gated by plan.preemptible_by: every screen may be preempted
by the gate, OnDemand, Wifi, Live, Rotation and a reload, except that a
live screen leaves Live out. A follower and Vegas never preempt
mid-screen. The pure helper live_takeover() is the Live rule, shared with
the dwell sleep's _check_live_takeover.

The controller only gathers and applies. _screen_service applies pending
changes and makes the live scan when one is due (_scan_for_takeover: the
same throttle and gates as before); _screen_check reads the WiFi notice
exactly where the loop did (the read is throttled and deletes an expired
file, so an extra read would move both), calls decide() once, and claims
a live takeover. _screen_preempted is gone; _check_live_takeover and
_wifi_notice_pending remain for the dwell sleep and the Vegas yield path,
built on the same rules.

Golden traces unchanged. The 67-run capture is identical to origin/main
(with the earlier two exclusions) except for one event: in the 125 Hz loop
the live scan still runs before the frame's sleep, but the claim is now
made by the service point after it, so the "live" state change is logged
8 ms later (test_live_game_cuts_a_scrolling_screen_short: 8.064 -> 8.072).
The screen still ends at the same frame (8.072) and every frame, sleep
and pass is unchanged.

Adds the mid-screen table (24 rows), live_takeover's table, and
test/test_screen_runner.py (the runner on a scripted host, plus the
controller's service point: which reads it makes at which checkpoint).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs, tests: run loop stage 3 -- doc, changelog, mutation survivors

docs/RUN_LOOP_REDESIGN.md describes run() as it now is (two decide()
calls per pass, the runner and its service points, the state snapshot and
its transitions), records what stage 3 shipped and how it was checked,
and adds one open "may be wrong" behaviour the mutation run surfaced: a
Vegas iteration stopped for a sync follower falls through to a full
rotation screen before the follower gets the panel (pinned by
test_vegas_yielding_to_a_follower_shows_a_rotation_screen_first; passes on
origin/main too). docs/IPC_CONTROL_SOCKET.md no longer names
_screen_preempted. CHANGELOG entry under Unreleased.

A mutation run broke 46 moved or new pieces once each (OnDemand, Live,
Vegas/Rotation, after(), each mid-screen rule, the runner's pacing, exits
and service points, the controller's gathering and claims). Three
survived and get a test here:

- the after-loop service point not reading the WiFi notice: the
  completed-loop checkpoint gets its own name, and a run-loop test has a
  notice pending when a later frame comes back empty;
- the Vegas yield path not marking vegas_yielded: the follower test above;
- _take_plan not writing back a reset on-demand index: a controller test
  with an index past a shortened list.

All 46 now fail at least one test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 22:15:30 -04:00
ChuckandClaude Opus 5.5 294bd522bd fix(web): the installed-plugins list never waits on the registry download (#766)
GET /plugins/installed called get_registry_info() per plugin. Despite the
"no network call" comment, a cold or expired cache made that download
plugins.json (10 s timeout, three attempts), and with no cached copy each
plugin's lookup repeated it -- offline, every load waited out the timeouts.

The route now reads the registry copy already in memory, however old, via
get_cached_registry_info(). A missing or expired copy starts a single
background refresh (backing off after an offline failure), so a later load
gets update and verified badges. Store, install and update paths still fetch.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 20:56:00 -04:00
ChuckandClaude Opus 5.5 80048fe78e test(starlark): keep the route tests off the network, out of the repo, and order-independent (#764)
Three hygiene problems in test_starlark_pixlet_routes.py:

- GET /plugins/installed calls plugin_store_manager.get_registry_info() per
  plugin, which calls fetch_registry(); on a cold cache that downloads
  plugins.json from raw.githubusercontent.com (10s timeout plus retries).
  The three listing tests in TestInstalledAppsAppearWithTheOtherPlugins
  therefore went to the network and stalled without a connection. The
  client fixture now stubs fetch_registry on the app's store manager.

- _STARLARK_APPS_DIR, _STARLARK_MANIFEST_FILE and _STARLARK_MANIFEST_LOCK_FILE
  are three separate module constants. Four fixtures redirected only the
  first two, so _starlark_manifest_lock() still took its lock at the repo's
  starlark-apps/manifest.json.lock (Linux only; Windows has no fcntl). The
  path is gitignored, so `git status` never showed it. One autouse fixture
  now redirects all three to tmp_path for every test in the file, and the
  class fixtures build on it.

- The TestTheStoreUsesTheTokenTheUserConfigured unit tests patch
  plugin_store_manager / config_manager on the api_v3 blueprint. Those
  attributes only exist once web_interface/app.py has been imported, which
  happens as a side effect of the first test that uses the client fixture.
  Run before any such test, the four non-client tests raised AttributeError.
  They now patch with create=True.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 19:46:26 -04:00
ChuckandClaude Opus 5.5 378478124c test(on-demand): find the config write by key, not by position (#763)
The font-usage publisher thread writes its own cache key at its own pace;
the last set() call is not always the on-demand config. Same race #751
fixed for the restore-failed state test.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 19:27:51 -04:00
ChuckandClaude Opus 5.5 d447cdd965 test(starlark): stop the pixlet-editor route tests leaking a fake Popen into the captive-portal check (#761)
TestPixletEditorHostDefaultsButDoesNotOverride patched Popen on the shared
subprocess module. The app's before_request captive-portal hook runs
subprocess.run (`with Popen(...) as p`) for `systemctl is-active hostapd`
whenever its 30s AP-mode cache is cold, so on a Linux host with systemctl -
the CI runner - the hook got the FakeProcess and the request 500'd with
"'FakeProcess' object does not support the context manager protocol".
Whether it happened depended on how long ago the previous request ran,
hence the intermittency; Windows has no systemctl and never reached it.

- Patch the starlark route module's own `subprocess` binding instead of
  the global Popen.
- Pin is_ap_mode_active to False in this file's client fixture, so no
  request here shells out to systemctl/nmcli at all.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 19:14:21 -04:00
ChuckandClaude Opus 5.5 ee7c3389f9 feat(fetch): count bytes on the wire as well as decoded (#760)
fetch-stats now reports wire_bytes (urllib3's raw socket byte count) beside the decoded bytes in every counter set. ESPN gzips its scoreboards, so the decoded count overstated real traffic ~10-14x: ledpi measured football at 33.2 MB/h decoded vs 3.15 MB/h on the wire.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 18:56:32 -04:00
ChuckandClaude Opus 5.5 caec9f5bf5 feat(display): report a scrolling screen held by its plugin's update() (#758)
While a plugin's update() runs it holds the plugin's lock and its frames are skipped -- on a scroller, a frozen strip -- with nothing logged. The high-FPS loop now times each run of skipped frames (report_hold=True); one of 250 ms or more logs 'Display of X held N ms by its update()' (rate-limited per plugin) and is recorded as a 'display hold' busy skip, which never touches the circuit breaker. The 1 Hz loop is left out: one skipped frame there measures the loop interval on a screen that did not visibly freeze (seen on ledpi as ~1000 ms reports on clock-simple and switch-mode football).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 17:17:10 -04:00
ChuckandClaude Opus 5.5 a74b5a2f0f fix(espn): fetch a window's edge months whole, cap chunk requests per process (#751)
- fetch_espn_date_chunks() asks for a window's partial edge month whole when the window covers ESPN_MONTH_COVER_MIN_DAYS (7) or more of its days, trimmed to the window by US Eastern start date. New espn_request_chunks().
- Chunk requests share one process-wide cap of ESPN_CHUNK_WORKERS (6) in flight.
- A new process starts as if a range had just been rejected, so it no longer spends a doomed 400 per window at start.
- Also: _eastern_zone() without try/except/pass (Codacy), and test_on_demand_live_and_restore reads the last on-demand state write rather than the last cache write (the font-usage publisher raced it; main CI had failed on it since #748).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:46:15 -04:00
ChuckandClaude Opus 5.5 57d7df6705 fix(web): schedule saves, restart_required, health and current-status agree with the rig (#750)
- POST /api/v3/config/schedule and /config/dim-schedule accept a disabled per-day schedule with every day off, and keep an off day's times.
- POST /api/v3/config/main answers restart_required only when the save changed a setting the running display does not apply live.
- GET /api/v3/health reports degraded with checks.display_loop.status stopped when the display service is stopped.
- GET /api/v3/display/current-status answers unknown (null fields) after the display stops instead of the cached last state. New web_interface.display_state.display_gone().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:07:19 -04:00
ChuckandClaude Opus 5.5 e09e251553 fix(display): a frame the preview throttle skipped still reaches the snapshot (#752)
A screen that draws its card once and holds it no longer leaves the web preview black: DisplayManager remembers a changed frame the snapshot throttle skipped, and the render loop writes it (write_owed_snapshot(), called from _display_once) once the interval has passed. A failed owed write stays owed and is retried.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:41:47 -04:00
ChuckandClaude Opus 5.5 7026eeb156 fix(on-demand): show a named live mode; end a session that cannot resume (#748)
On-demand: a mode requested by name is shown first (even a quiet live mode); a session that can't resume after a restart, or whose plugin system failed to start, ends with status restore-failed instead of staying dead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:25:12 -04:00
110 changed files with 11976 additions and 2250 deletions
+410 -31
View File
@@ -19,6 +19,347 @@ accepts both, but the store flags the old spelling as deprecated
## Unreleased
### Tooling
- `test/test_sports_helpers.py`'s parity tests pass again with
`LEDMATRIX_PLUGINS` set. The scoreboards deleted their copies of the
`sports_helpers` bodies and constants when they adopted `SportsHelpersMixin`
(ledmatrix-plugins #563/#564), and the 19 tests still expected them. A copy
that is gone now counts as adopted when the plugin imports
`src.common.sports_helpers`, as the stage 3/4 and game-over parity tests
already do; a copy that remains must still match.
## 3.8.2
The display hands freed memory back to the OS (#774), and sports consolidation
family 6: `src.common.sports_favorites`, which the scoreboards adopt by
flooring on 3.8.2 (#775).
### The display hands freed memory back to the OS
The display process's resident memory climbed in steps for hours while the
data it held stayed flat: glibc keeps what Python frees in per-thread malloc
arenas and returns little of it. `src/malloc_tuning.py` (new, standard library
only, a no-op off Linux/glibc) does two things in-process, so it reaches
devices without re-running the installer:
- **Arena cap at start-up.** `run.py` calls `mallopt(M_ARENA_MAX, 2)` before any
thread exists, the same cap as the unit's `Environment=MALLOC_ARENA_MAX=2`.
Units installed before that line never got it (systemd runs the copy in
`/etc/systemd/system`); a `MALLOC_ARENA_MAX` in the environment still wins.
- **`malloc_trim(0)` between screens**, at most every 5 minutes, from the top of
the render loop where no frame is being drawn. Measured on a Pi 4: 2-11 ms
per call.
On ledpi (Pi 4, 192x48, Vegas on, nine plugins, a unit without
`MALLOC_ARENA_MAX`), alternated main / branch / branch / main arms of 2.5 h:
two hours in, resident memory was 551 MB on main (the second main arm was
already at 651 MB after 1 h 44 min) against 412 and 386 MB with this change,
and the 20-minute frame soaks came out at 0.147-0.165% late against main's
0.151-0.188%.
### New modules
- `src/common/sports_favorites.py` -- sports consolidation family 6, once the
plugins made `_is_favorite_game` (seven bodies), `_select_games_for_display`
(two) and `_select_recent_games_for_display` (three) one each:
`SportsFavoritesMixin` (`SportsCore`: `_is_favorite_game`, `_favorite_code`),
`SportsUpcomingFavoritesMixin` and `SportsRecentFavoritesMixin` (the
favourites-only picks). Each side of a game is named by the 3.5.0
`_favorite_key` seam and compared with `favorite_teams` stripped and
upper-cased; nrl overrides the key with the ESPN team id. Only a game with an
id can be a duplicate. A plugin may inherit the mixins once it floors on
3.8.2, and deletes its copies then. (#775)
## 3.8.1
Smooth scrolling at the slower speeds, and the fixes and performance work
since 3.8.0. Highlights: the default 50 px/s and every other held-frame speed
now scroll cleanly (below), Raspberry Pi OS Bookworm is supported alongside
Trixie, updates refresh the systemd units, the display control socket gains
stages 2 and 3, the shared fetch service lands (stages 1 and 2), and a run of
web UI and Plugin Manager fixes. One new module is for plugins:
`src.common.sports_game_over` (sports family 5), which the scoreboards adopt
by flooring on 3.8.1; the other new modules are core-internal and set no
`ledmatrix_min_version` floor.
### Scroll speed
These two entries were the reason for this release: on 3.8.0 a slow scroll
either stepped or showed a half-pixel tear across the middle of the panel,
so only speeds of one pixel per refresh looked right.
- The Vegas Scroll Speed slider now says what the panel will do with the speed
it is on, and offers the nearest smooth ones to click. Only speeds that advance
a whole number of pixels per refresh look smooth, and which those are depends
on the panel (`GET /api/v3/config/scroll-speed-advice`, built on
`scroll_config.speed_advice()`; it uses the refresh the display measured, not
the `limit_refresh_rate_hz` cap). The slider steps by 1 px/s instead of 5.
- The default 50 px/s no longer snaps to a stepped 48 px/s (2 px every 5
refreshes, 24 fps) on a 120 Hz panel: `solve_crisp()` now prefers 60 or 40 px/s,
which move one pixel at a time. 100 Hz panels are unaffected. (#710)
- A held-frame scroll (one pixel every two or more refreshes, such as 50 or
60 px/s on a 100-120 Hz panel) no longer shows a half-pixel step across the
middle of the panel. Scan-order compensation ran only at one frame per
refresh; a held frame is now presented as a sequence of swaps
(`scan_order.refresh_plan()`), so the half of the panel that scans later
steps one refresh after the rest. It is skipped when a blit takes more than
half a refresh, since the second blit has to land before the next vsync.
(#711)
### Web UI: the Display tab is an ES-module page, with a page-visibility service (stage 4)
- New `static/v3/js/core/visibility.js`: each page module gets
`ctx.visibility` with `whileVisible(start, stop)`, `every(ms, fn)` and
`isVisible()`. Work registered there runs only while the page's tab is the
active tab and the browser tab is visible, and ends when the page is
swapped out, with no teardown code in the page. It reads the active tab
from `window.LEDVisibility`, so it agrees with the classic partials that
still use that directly. The page registry gained a `mountContext` option
for services bound to one mounted page.
- The Display tab's inline scripts are now `static/v3/js/pages/display.js`.
The partial has no inline script, `onclick` or `onchange` any more. The
multi-display sync status poll (every 5 s) runs through
`ctx.visibility.every`; the status and scroll-speed hint requests go
through `core/api.js` with the page's abort signal, and so does the Vegas
order widget's plugin-list request.
- Behaviour differences: with sync on, opening the tab asks for the status
once instead of twice, and a Display tab loaded while not on screen waits
until it is. A login redirect during a poll no longer flashes "Sync status
unavailable". The `window.syncStatusInterval` timer id is gone (nothing
read it). A pending scroll-hint request or widget retry is dropped when
the partial is swapped out.
- `window.updateSyncUI` keeps working as a deprecated alias through
`window.LEDMatrix` (one console warning).
- New suites `test/js/dom/test_visibility_service.js` and
`dom/test_display_page.js`; `unit/test_display_partial_ids.js` imports the
module instead of slicing the template.
### Plugins ask for the screen in-process: `request_on_demand()` / `end_on_demand()`
The in-process way in that stage 5 of the control socket needed
(`docs/IPC_CONTROL_SOCKET.md`, "Plugins in the display process").
- **`BasePlugin.request_on_demand(mode=None, duration=None, pinned=False)`**
shows the plugin now, and **`BasePlugin.end_on_demand()`** gives the
screen back. Both are safe from any thread (an MQTT callback, a timer
thread): `PluginManager.request_on_demand()` / `end_on_demand()` hand the
request to `DisplayController.submit_plugin_on_demand()`, which only
queues it (at most 32) and wakes the render thread through the control
socket's flag (`ControlServer.wake()`). The render thread applies it with
the socket's commands, through the same handler as a web on-demand
request, so it lands within a frame rather than on the mailbox's
once-a-second look. Both return the request id, or `None` when no display
runs in the process (the web interface, `scripts/check_plugin.py`) or the
queue is full.
- **A plugin's stop ends only its own session.** A mailbox stop still ends
any session, whoever started it.
- **Older cores.** Plugins detect the methods with `hasattr` and write the
`display_on_demand_request` mailbox when they are missing or answer
`None`; the pattern is in `docs/PLUGIN_API_REFERENCE.md` ("On-demand
display"). The display still reads the mailbox for plugins that write it.
### Web UI: Schedule and General are ES-module pages (stage 3)
- The Schedule and General tabs follow stage 2 (#727): their inline
`<script>` blocks are now `static/v3/js/pages/schedule.js` and
`pages/general.js`, started once per swap-in by the page registry and
stopped on swap-out. Neither partial has an inline script, `onclick`,
`onsubmit` or `oninput` any more.
- Schedule: both pickers are drawn from the saved config the partial
carries as JSON in `data-schedule-config` / `data-dim-schedule-config`.
The forms' `hx-on` save handlers became one `htmx:afterRequest` listener
on the page; the forms are marked `data-reports-result`, which `app.js`
now honours like an `hx-on` after-request handler, so a save still shows
one notification.
- General: the timezone picker reads the saved zone from `data-timezone`.
The Security section's forms and buttons carry `data-action` and use one
delegated submit and one delegated click listener, so a token row added
after a create needs no listener of its own. Requests go through
`core/api.js`: the optional login's "sign in again" answer no longer
flashes an error while the page navigates to the login form. A login
change made just before a swap is still reported.
- Old globals keep working as deprecated aliases through `window.LEDMatrix`
(one console warning each): `handleScheduleResponse`,
`handleDimScheduleResponse`, and `webLogin` (its five methods).
- New DOM suites `test/js/dom/test_{schedule,general}_page.js`;
`unit/test_general_web_login_token.js` imports the module instead of
slicing the template, and `unit/test_restart_banner.js` covers
`data-reports-result`.
### The control socket carries every web command; the mailboxes are a fallback
Stage 4 of the web → display control socket (`docs/IPC_CONTROL_SOCKET.md`).
- **Mailbox only when the socket cannot carry it.** The on-demand routes
(`POST /api/v3/display/on-demand/start` and `/stop`) write the
`display_on_demand_request` mailbox only when the display never had the
request: no socket (a stopped display, one older than the socket), a
refused or timed-out connect, or a display too old to know the command.
A display that had it and refused or did not answer (a full queue, bad
arguments, silence after the send) is answered `503` (`400` for bad
arguments) with `socket_error`, and no mailbox copy is written: the
display may have applied it, or would refuse the copy too. A stop with
`stop_service` still stops the service. `src.ipc.client.should_fall_back()`
holds the rule; `ControlError.sent` says whether the display had the
request.
- **`errors.clear`.** `POST /api/v3/errors/clear` goes over the socket: the
display clears its error records and republishes its error snapshot
before it answers, so the response says `applied: true` with the
display's own `cleared_count`. The `plugin_error_clear_request` mailbox
is written only on the same fallback rule (a display from before this
release answers `unknown_command`, and gets the mailbox). A display that
had it and failed answers `503`. The error snapshot gains
`applied_clear_cutoff`, so an older mailbox request is not shown as
pending once a wider clear has been applied.
- **The display looks at the mailboxes less, and more cheaply.** While the
control socket is up, the on-demand mailbox is looked at once a second
instead of every 0.25 s (`MAILBOX_POLL_INTERVAL_WITH_SOCKET`), and both
mailboxes are read only when their file changed since the last look:
otherwise a look is one `stat()` (`CacheManager.file_signature`,
`MailboxWatch`). A socket command no longer reads or deletes the mailbox
file. A duplicate already processed is taken out of the mailbox, rather
than re-read for an hour. Without a socket (Windows,
`LEDMATRIX_CONTROL_SOCKET=off`) the mailbox is read every 0.25 s as before.
- **Kept for one release.** The display still reads both mailboxes, so an
older web interface (or a web user not yet in the socket's group) keeps
working during an upgrade, and still writes `display_current_state`,
`display_on_demand_state` and `plugin_runtime_snapshot` for the readers'
fallback. A request that comes through the on-demand mailbox while the
socket is up is logged once per writer: plugins that write
`display_on_demand_request` themselves (birdnet-go, mqtt-notifications,
on-air, pomodoro-timer) now get the screen within a second rather than a
quarter second, and need an in-process way in before the mailbox goes.
### Display loop stage 3: a ScreenRunner, and the Arbiter decides every screen
Internal; no behaviour change. Stage 3 of `docs/RUN_LOOP_REDESIGN.md`.
- Each screen runs in `ScreenRunner` (`src/screen_runner.py`): the first
frame, the 125 Hz or 1 Hz frame loop, the make-up dwell and the
dynamic-duration exit, moved out of `DisplayController.run()` with their
pacing unchanged. It paces with an injected clock and returns an
`Outcome` whose `ExitReason` is `DURATION`, `CYCLE_COMPLETE`, `EMPTY`,
`ERROR`, `DISPLAY_FALSE`, `RELOAD` or `PREEMPTED`. `PREEMPTED` replaces
the five "did the mode change under this screen?" re-checks.
- `Arbiter.decide()` now answers for on-demand, live priority and the
rotation too (Sources `ON_DEMAND`, `LIVE`, `ROTATION`); `LEGACY` means
only Vegas, whose iteration moves to stage 4. The on-demand session, the
rotation's position and the live resume point are snapshotted into
`ArbiterState`, whose pure transitions (`next_on_demand`, `claim_live`,
`release_live`, `after`) replace the bookkeeping in `_resolve_active_mode`,
`_apply_live_priority` and `_advance_after_screen`.
- Between frames, the runner's service points make one
`decide(..., running=plan)` call instead of `_check_live_takeover`,
`_screen_preempted` and `_wifi_notice_pending` one after another. The
WiFi notice file is still read exactly where it was (the read is
throttled and deletes an expired file).
- A Vegas pass scans the live-priority plugins once instead of twice at the
same instant.
- The golden traces are byte-identical, and a capture of all 67 harness
runs in the suite (every sleep, frame, read and scan) matches `main`
apart from the duplicate scan above and one moment: in the 125 Hz loop a
live takeover's state change is made after the frame's 8 ms sleep rather
than before it, ending the screen at the same frame as before.
- New module: `src/screen_runner.py`. Core-internal: plugins have no reason
to import it, so it sets no `ledmatrix_min_version` floor.
### A scrolling screen held by its plugin's update() is reported
- While a plugin's `update()` runs it holds the plugin's lock, and that
plugin's frames are skipped: on a scroller, a frozen strip, with nothing
logged (and a freeze of 5 s or more is a gap, not a freeze, to the frame
stats). The high-FPS loop now times each run of skipped frames; one of
250 ms or more logs `Display of <plugin> held N ms by its update()`
(rate-limited per plugin) when it ends, and is recorded on the plugin's
health as a `display hold` busy skip, which never counts toward the
circuit breaker. The 1 Hz loop is left out: its frames are a second apart,
so one skipped frame there measures nothing and freezes nothing visible.
### Fixed
- Unloading a plugin now forgets the fonts its manifest registered, not only
the fonts it reported using. Its `plugin_id::family` entries kept resolving
and their cached font objects stayed alive until a restart, and a family a
reinstalled plugin's manifest dropped stayed registered. The new
`FontManager.forget_plugin_fonts(plugin_id)` does the cleanup;
`PluginManager.unload_plugin()` and a failed load call it alongside
`forget_manager_fonts()`, and a reload registers the manifest's fonts again.
- The web preview and `/api/v3/display/current` no longer stay black for a
whole screen that draws its card once and then holds it. The snapshot is
written from `update_display()` at most once per write interval, so a frame
pushed inside that interval was skipped and left for the next
`update_display()` -- which such a screen never makes. Soccer's
recent/upcoming cards skip redundant redraws, and the first one after an
on-demand start lands a few milliseconds after the start's clear wrote a
black frame: on ledpi the preview showed 0 lit pixels for the whole 15 s
while the panel showed the card. `DisplayManager` now remembers a skipped
changed frame, and the render loop writes it (`write_owed_snapshot()`)
once the interval has passed. The cadence is unchanged, and nothing extra
runs when no frame is owed.
- The installed-plugins list (`GET /api/v3/plugins/installed`) no longer
waits on GitHub. Its comment said the registry lookup made no network call,
but on a cold or expired cache `get_registry_info()` downloads plugins.json
(10 s timeout, three attempts), and with nothing cached to fall back on
every plugin's lookup repeated that: offline, 5 plugins took 11 s with DNS
failing and 2 plugins 65 s with the route black-holed, on every load. The
list now reads the registry copy already in memory, however old
(`get_cached_registry_info()`); with none yet it returns without update or
verified badges and starts one background refresh
(`refresh_registry_in_background()`, backing off for a minute after an
offline failure), so a later load has them. The store, install and update
paths still fetch as before.
- A sports live manager's idle back-off now honours every pending kickoff,
not just the first. `_note_scheduled_start_candidate()` kept one kickoff
and, while it was inside its 15-minute grace, refused every later one; by
the time the grace ended the later one had passed and was refused again.
So of two favourites kicking off within 15 minutes of each other, the
second lost its own grace: if the first game was not live by then (a rain
delay, a postponement, ESPN slow to flip it) and ESPN had not flipped the
second either, the back-off went back to its ceiling and the second game
was noticed up to the ceiling (15 minutes by default) late. Later
kickoffs now wait in a short queue (`_later_scheduled_starts`, the
earliest 8) and each takes over with a grace of its own when the one
before it expires. A kickoff still
holds the live cadence for at most its own grace, so a postponed game
costs the same quarter of an hour as before.
### ESPN date-range fetches: fewer requests, fewer at once
A soccer board (8 leagues, ESPN rejecting `dates=` ranges) logged ~90
`NameResolutionError` lines and an `update() timed out` at every start on a
Pi: each league's fortnight-either-side window was 29 day requests, fetched
by several managers at once, ~40 in flight. Measured against live ESPN with
soccer-scoreboard 2.39.2, alternating runs: **~450 requests per start, peak
~45 in flight, ~75 DNS lookups -> 46 requests, peak 13, ~30 lookups**.
- `fetch_espn_date_chunks()` asks for a window's partial edge month whole
when the window covers `ESPN_MONTH_COVER_MIN_DAYS` (7) or more of its days,
and trims the answer to the window's days by each event's US Eastern start
date -- the day ESPN's `dates=YYYYMMDD` means (417 of 417 live soccer
events matched). A 29-day window spanning two months is 2 requests instead
of 29. Short windows (a live poll's 1-2 days) stay day by day. A trimmed
month that comes back at the 500-event cap re-asks only the window's days.
An event with no readable date is kept. New: `espn_request_chunks()`.
- Chunk requests share one process-wide cap of `ESPN_CHUNK_WORKERS` (6) in
flight, across every window being fetched, instead of six per window.
- A new process starts as if a range had just been rejected, so it no longer
spends one doomed 400 per window at every start (eleven at once from a
soccer board); the range is still retried `RANGE_RETRY_SECONDS` in.
### Fetch stats: bytes on the wire, not just decoded
`GET /api/v3/plugins/fetch-stats` reported only `bytes`, the decoded body
size, and that read as the download volume. ESPN gzips every scoreboard, so
it overstated what crossed the network about 14x: a college football
Saturday's scoreboard is 865 KB decoded and 63 KB on the wire, and ledpi's
"643 MB in 6 hours" of football was ~47 MB of actual traffic. Every counter
set (totals, per plugin, per host) now has `wire_bytes` too, read from
urllib3's count of the raw bytes it took off the socket. A response with no
urllib3 response behind it is counted at its decoded size. `bytes` keeps its
meaning.
### Cheap per-frame and per-fetch savings
- `BaseOddsManager.get_odds()` no longer pretty-prints every odds response
@@ -457,6 +798,16 @@ policies are unchanged.
- `src/display_arbiter.py` -- the display loop's Arbiter (see Tooling).
Core-internal: plugins have no reason to import it, so it sets no
`ledmatrix_min_version` floor.
- `src/common/sports_game_over.py` -- `SportsGameOverMixin`, sports
consolidation family 5: `_is_game_really_over`, the scoreboards'
`SportsLive` check that drops a game ESPN still lists as live, once the
plugins made their five bodies one. Over on a final period text, or on a
0:00 clock from period `FINAL_PERIOD` on unless the score is level (a tie
at the end of regulation goes to overtime). `FINAL_PERIOD` is the per-sport
class attribute, `None` by default (the clock never ends a game); the
scoreboards declare 3 (hockey), 4 (basketball, football, lacrosse) or
`None`. List the mixin before `SportsLiveSharedMixin`. A plugin may import
it once it floors on 3.8.1, and deletes its copy then. (#770)
### Tooling
@@ -505,6 +856,54 @@ policies are unchanged.
stored `ttl` was stretched the same way. A memory hit is now also checked against
the record's own timestamp, and a stale one falls through to disk, which
returns a newer write if there is one.
- An on-demand request that names a `*_live` mode now shows that mode. On
ledpi, `{"plugin_id": "football-scoreboard", "mode": "ncaa_fb_live"}` with
15 college games on answered 200 and showed `nfl_recent`. The session's
mode list kept a live mode only when the plugin's `has_live_content()`
said so. That method answers the live-priority question, and the sports
plugins answer it for favourite teams only. A mode the request names
(not one resolved from a bare plugin id) now leads the session, with the
plugin's other modes after it. If it has nothing to draw, the session
moves on to the next of those modes, like any empty on-demand mode. The
name is saved with the session (`named_mode` in
`display_on_demand_config`), so a restart resumes on it.
- A restart during an on-demand session whose plugin then fails to load no
longer leaves a session with no modes. On ledpi, `clock-simple` failed
config validation after a crash. The display logged `No valid display
modes found for on-demand plugin 'clock-simple' after restoration` and
kept reporting the session as active until its first pass ended it as
`idle`. The cached request stayed behind for the next restart. The session
now ends at startup with status `error` and error `restore-failed`, which
`/display/on-demand/status` reports, and the cached request is dropped. The
same applies when the plugin system itself fails to start.
- `POST /api/v3/config/schedule` and `/config/dim-schedule` accept a
disabled per-day schedule with every day off. That is the shape
`config.template.json` ships, so posting back what GET returned on a fresh
install answered 400 "At least one day must be enabled". An enabled per-day
schedule still needs a day on. A day that is off now keeps the times it
was posted with (the schedule picker sends them). Before, saving dropped
them, so turning the day back on showed the defaults.
- `POST /api/v3/config/main` answers `restart_required: true` only when the
save changed a setting the running display does not apply by itself.
Brightness (`brightness.set` and the config watcher), the per-mode
durations and plugin sections are applied live. A brightness-only save,
such as the MQTT bridge's slider, or a save that changed nothing, no longer
shows the restart banner. Hardware, rotation order, timezone and every
other setting still ask for the restart.
- `GET /api/v3/health` reports `degraded` when the display service is
stopped. Before, only the sub-checks changed, and the overall status stayed
`healthy` for as long as the last preview frame was under 60 s old.
`checks.display_loop.status` is now `stopped` when three things agree:
systemd says the service is not active, the control socket does not
answer, and there is no live heartbeat. Where the platform has no socket
(Windows) or it is switched off, nothing changes.
- `GET /api/v3/display/current-status` no longer reports the stopped
display's last state (`is_display_active: true`) from the cache for up to
120 s. When the control socket does not answer and the render loop's
heartbeat is absent, stale, or from a process that is gone (#726's rules),
the answer is unknown, with every field `null`. A display that still beats
without a socket, Windows and a socket switched off read the cache as
before. New `web_interface.display_state.display_gone()`.
- The garbage-collection timer (`GcMonitor`, above) no longer prints
`Exception ignored while calling GC callback ... 'NoneType' object has no
attribute 'perf_counter'` when the display service or a test run exits.
@@ -885,25 +1284,6 @@ policies are unchanged.
a runtime publisher that stops still goes `stale`, and a subscription that
goes quiet still falls back to the cache. The cache path's 120 s rule is
unchanged.
- A plugin that pauses the Vegas scroll gets its pause when its display
duration is not a plain number. Several plugins (clock-simple, calendar,
countdown) return `display_duration` as it is in config.json, so a value
saved as `"20"` or `null` (the raw config editor, a hand edit) reached the
pause as a string or None; comparing it with the clock raised, and the
plugin flashed up and the scroll went straight on, at every one of its
turns. `inf` held the pause until something interrupted it, and 0, a
negative number or NaN ended it at once. The pause now reads the duration
as the rotation does (`finite_seconds()` in `base_plugin`): a numeric
string counts, anything else that is not a finite number (or a
`get_display_duration()` that raises) pauses for 30 s, and a number at or
below zero for 15 s, with one warning per plugin.
- Reinstalling Weather, Music, Stocks or Leaderboard from the Plugin Store
while it is enabled asks for a display restart, as reinstalling any other
enabled plugin does. `POST /api/v3/plugins/install` looked for the
plugin's `enabled` flag under the store id (`weather`), but its config
section is under the id its manifest declares (`ledmatrix-weather`), so
`restart_required` was always false and the display kept running the
copy it had loaded. The check now uses the installed id.
### Scrolling
@@ -1081,18 +1461,6 @@ guard the import, since the loader's version check is advisory).
processes, or turns the socket off with `off`. A non-root dev run uses a
private per-user path under the temp directory.
### Scroll speed
- The Vegas Scroll Speed slider now says what the panel will do with the speed
it is on, and offers the nearest smooth ones to click. Only speeds that advance
a whole number of pixels per refresh look smooth, and which those are depends
on the panel (`GET /api/v3/config/scroll-speed-advice`, built on
`scroll_config.speed_advice()`; it uses the refresh the display measured, not
the `limit_refresh_rate_hz` cap). The slider steps by 1 px/s instead of 5.
- The default 50 px/s no longer snaps to a stepped 48 px/s (2 px every 5
refreshes, 24 fps) on a 120 Hz panel: `solve_crisp()` now prefers 60 or 40 px/s,
which move one pixel at a time. 100 Hz panels are unaffected.
### Update channels
- Devices no longer pick up every merge to `main`. A new setting,
@@ -1350,6 +1718,17 @@ read any of them:
### Fixes
- Updating a plugin from the store no longer deletes the files it wrote
beside itself. A monorepo update replaces the plugin directory with the
fresh download and deletes the old copy, so calendar's Google OAuth files
(`token.pickle`, `credentials.json`) were lost on every update and the
calendar stopped until they were restored by hand. Before the old copy is
removed, the update now copies over anything the plugin's `.gitignore`
excludes plus known secret/state files (`*.pickle`, `token.json`,
`credentials.json`, `config_secrets.json`, `.pkce_code_verifier`); files the
new release ships are never overwritten, and byte code is not carried. A
plugin updated with `git pull` no longer sweeps an untracked token into the
auto-stash, which is never popped (`src/plugin_system/plugin_local_files.py`).
- Quieter routine logging. Every rotation logged each mode twice
("Switching to mode", then "Processing mode"), and a mode with nothing to
show added "display() returned False" and "No content to display". Those
+11 -3
View File
@@ -649,7 +649,9 @@ When nothing is running on demand, `data.state` is
> on-demand machinery is internal — drive it through the REST endpoints
> above (or the web UI buttons). The API handlers
> (`start_on_demand_display()` / `stop_on_demand_display()` in
> `web_interface/blueprints/api_v3/display.py`) write a request into the cache
> `web_interface/blueprints/api_v3/display.py`) send the request over the
> display's control socket ([IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md)).
> Only when the socket cannot carry it do they write it into the cache
> manager under the `display_on_demand_request` key, which
> `DisplayController._poll_on_demand_requests()`
> (`src/display_controller.py`) picks up. A separate
@@ -747,8 +749,14 @@ keys helps troubleshoot stuck states.
"timestamp": 1234567890.123
}
```
**Purpose:** Communication from web interface to display controller
**When Set:** API endpoint receives request
**Purpose:** Communication from web interface to display controller, as the
fallback when the control socket cannot carry the request (deprecated; it
will be removed in a later release)
**When Set:** API endpoint receives a request and the display's control
socket is unavailable (display stopped, or older than the socket or the
command); some plugins also write it directly
**Read:** once a second while the display serves the control socket (0.25 s
without it), and only when the file changed since the last look
**Auto-Cleared:** After processing or 1 hour TTL
**2. display_on_demand_config** (No TTL)
+12 -7
View File
@@ -42,11 +42,11 @@ each other. They share three things:
| State | Where | Written by | Read by |
|---|---|---|---|
| On-demand command | control socket `/run/ledmatrix/control.sock` ([IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md)) | web: `start_on_demand_display()` / `stop_on_demand_display()` in [`api_v3/display.py`](../web_interface/blueprints/api_v3/display.py), via [`src/ipc/client.py`](../src/ipc/client.py) | display: [`src/ipc/server.py`](../src/ipc/server.py) acks; the render thread applies it in `_poll_on_demand_requests()` |
| On-demand request (fallback) | cache `display_on_demand_request` | web, when the socket fails; four plugins write it directly | display: `_poll_on_demand_requests()` |
| On-demand request (fallback) | cache `display_on_demand_request` | web, only when the socket could not carry the request (`should_fall_back`); four plugins write it directly | display: `_poll_on_demand_requests()`, a `stat()` every 1 s while the socket is up (0.25 s without), read only when the file changed |
| On-demand state | cache `display_on_demand_state` | display: `_publish_on_demand_state()` | web: `/api/v3/display/on-demand/status` |
| Current screen | cache `display_current_state` | display | web: `/api/v3/display/current-status` |
| Plugin errors | cache `plugin_error_snapshot` | display: `ErrorSnapshotPublisher` ([`src/error_aggregator.py`](../src/error_aggregator.py)) | web: `read_error_report()` for `/api/v3/errors/*` |
| Error clear | cache `plugin_error_clear_request` | web | display |
| Error clear | control socket `errors.clear`; cache `plugin_error_clear_request` as the fallback | web: `POST /api/v3/errors/clear` | display: applied before the socket answers; the mailbox on the error publisher's 5 s tick, read only when the file changed |
| Font usage | cache `font_usage_snapshot` | display: `FontUsagePublisher` ([`src/font_usage.py`](../src/font_usage.py)) | web: Fonts tab |
| Fetch statistics (requests per plugin and host) | cache `fetch_stats_snapshot` | display: `FetchStatsPublisher` ([`src/common/fetch_service.py`](../src/common/fetch_service.py)), at most once a minute on change | web: `read_fetch_stats()` for `/api/v3/plugins/fetch-stats` |
| Plugin health | cache `plugin_health:<id>` | display (web writes on reset) | web: `/api/v3/plugins/health` |
@@ -58,11 +58,16 @@ each other. They share three things:
The on-demand start route starts `ledmatrix.service` when it is not running
(`start_service`, on by default) but never restarts a running one. The routes
send the command over the display's control socket and get an ack; when that
fails (a stopped display, one older than the socket) they write the mailbox
instead, which the display reads every `ON_DEMAND_POLL_INTERVAL` (0.25s), from
its dwell sleep, its render loops and Vegas's interrupt check as well as the
main loop. Both ways end in the same handler, `_handle_on_demand_request()`.
send the command over the display's control socket and get an ack; only when
the socket could not carry it (a stopped display, one older than the socket
or the command) do they write the mailbox instead. A display that had the
request and refused it is answered with the error, not posted a mailbox
copy. The display looks at the mailbox every
`MAILBOX_POLL_INTERVAL_WITH_SOCKET` (1 s) while it serves the socket, and
every `ON_DEMAND_POLL_INTERVAL` (0.25 s) without one, from its dwell sleep,
its render loops and Vegas's interrupt check as well as the main loop; a
look is one `stat()` unless the file changed. Both ways end in the same
handler, `_handle_on_demand_request()`.
The socket's handlers only queue; see [IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md)
for the protocol, the permission model and the plan to retire the mailboxes.
+3 -1
View File
@@ -203,6 +203,7 @@ Current methods:
| `measure_text(text, font)` | `(width, height, baseline)` |
| `get_font_height(font)` | Line height |
| `register_plugin_fonts(plugin_id, font_manifest)` | Register a plugin's fonts (core calls it at load) |
| `forget_plugin_fonts(plugin_id)` | Drop a plugin's manifest fonts and their cached objects (core calls it when a plugin unloads) |
| `clear_cache()` | Drop cached fonts and metrics |
| `font_catalog` (attribute) | Family name → file path |
@@ -218,5 +219,6 @@ Removed in 3.8.0, after logging a deprecation warning on first call since
| `get_performance_stats()` | — |
| `set_override()`, `remove_override()`, `get_overrides()` | a font field in your plugin's config schema |
| `get_manager_fonts()`, `get_detected_fonts()` | — |
| `get_plugin_fonts()`, `unregister_plugin_fonts()` | — |
| `get_plugin_fonts()` | — |
| `unregister_plugin_fonts()` | `forget_plugin_fonts()` (core calls it on unload) |
| `add_font()`, `remove_font()`, `validate_font()` | the web UI's Fonts tab |
+133 -24
View File
@@ -7,14 +7,18 @@ status. Stage 2 makes those commands land within a frame on every kind of
screen, and adds `brightness.set` and `plugin.reload`. Stage 3 adds a state
stream (`state.get`, `state.subscribe`), so the web interface reads what the
display is doing from the socket instead of from cache files the display
wrote to the SD card. The file mailbox and the cache keys stay as a fallback
for one release.
wrote to the SD card. Stage 4 makes the socket the only way a command goes
while it works: the web interface writes a mailbox only when the socket
cannot carry the request, `errors.clear` replaces the last command that
always went through a mailbox, and the display looks at the mailboxes once
a second, with a `stat()`. The file mailboxes and the cache keys stay as a
fallback for one release.
| | |
|---|---|
| Socket | `/run/ledmatrix/control.sock` (tmpfs) |
| Served by | the display process ([`src/ipc/server.py`](../src/ipc/server.py)), started by `DisplayController.run()` |
| Used by | the web interface ([`src/ipc/client.py`](../src/ipc/client.py)): `POST /api/v3/display/on-demand/start` and `/stop`, `POST /api/v3/plugins/update` (reload), `POST /api/v3/config/main` (brightness); and through [`web_interface/display_state.py`](../web_interface/display_state.py) (the state stream), `GET /api/v3/display/current-status`, `/display/on-demand/status`, `/plugins/installed` (`runtime`), `/plugins/state` and the reconciliations, `/health` (`display_loop`) |
| Used by | the web interface ([`src/ipc/client.py`](../src/ipc/client.py)): `POST /api/v3/display/on-demand/start` and `/stop`, `POST /api/v3/plugins/update` (reload), `POST /api/v3/config/main` (brightness), `POST /api/v3/errors/clear`; and through [`web_interface/display_state.py`](../web_interface/display_state.py) (the state stream), `GET /api/v3/display/current-status`, `/display/on-demand/status`, `/plugins/installed` (`runtime`), `/plugins/state` and the reconciliations, `/health` (`display_loop`) |
| Contract | [`src/ipc/contract.py`](../src/ipc/contract.py): messages, versions, framing and the socket path; both sides import it |
| Override | `LEDMATRIX_CONTROL_SOCKET=/some/path.sock` for both processes, or `=off` to disable it |
@@ -85,6 +89,17 @@ one. Clients branch on `error.code`, never on the message text.
| `plugin.reload` | `{plugin_id}` | `{plugin_id, reloaded: true, version, modes}` | queued, awaited (10 s) |
| `state.get` | `{since?, epoch?}` | a state snapshot (see "The state stream") | answered directly |
| `state.subscribe` | — | a state snapshot, then pushed `state` / `tick` events | answered directly, then a stream |
| `errors.clear` | `{cutoff: number}` (epoch seconds, finite, ≥ 0) | `{request_id, cutoff, cleared}` | answered directly, once applied |
`errors.clear` (stage 4) forgets the plugin errors the display recorded at
or before `cutoff` and rewrites its error snapshot (`plugin_error_snapshot`)
before it answers, so the web interface's next read already has it. The
request `id` is the clear's id, which the snapshot reports as
`applied_clear_id`. It is answered on the connection thread by a handler the
display registers (`ControlServer(handlers=...)`, the contract's
`DIRECT_COMMANDS`): the error aggregator and its publisher have their own
locks, and nothing the render thread owns is touched. A display that has no
handler answers `unknown_command`, as an older display does.
`duration` is a number of seconds, or a numeric string. `0`, `null` or `""`
mean "until stopped". `pinned` must be a real boolean: the REST route has
@@ -367,7 +382,9 @@ place it reads the mailbox:
Vegas iteration is on the stack (`_apply_pending_plugin_reloads`). Until
then the current screen ends early, as it does for a WiFi notice: the
frame loops, the dwell and Vegas's interrupt check all treat a pending
reload as a reason to stop (`_screen_preempted`).
reload as a reason to stop (the frame loops through the Arbiter's
mid-screen check, `Source.RELOAD`; the dwell through
`_plugin_reload_pending`).
- Only the quick half of the reload runs on the render thread
(`_start_plugin_reload`): the plugin's modes leave the rotation, its
config subscription is dropped, and `PluginManager.detach_plugin` takes
@@ -390,7 +407,7 @@ place it reads the mailbox:
unloads it mid-load; a disable saved meanwhile is applied once the
reload is done. A second reload of the same plugin runs after the first.
The 0.25 s floor on the mailbox read does not apply to the queue, because
The floor on the mailbox read (0.25 s, 1 s since stage 4) does not apply to the queue, because
draining it costs no disk read. A queued command also lets
`_service_pending_changes()` skip its own floor.
@@ -418,7 +435,7 @@ Now the queue wakes the render thread:
So a command lands within a millisecond or so on a static screen and in a
dwell, and within one frame in Vegas and on a scrolling screen. The mailbox
keeps its old delays. Commands still run only on the render thread: the
is slower on purpose (see "The mailboxes now"). Commands still run only on the render thread: the
connection threads only queue them and set the event. The one exception is
the slow half of `plugin.reload` (tearing down and loading the plugin),
which runs on its own thread. Every change to the display's state still
@@ -436,11 +453,74 @@ bookkeeping. A client's send to the render thread waking took 0.72 ms median
Without a socket (Windows, `LEDMATRIX_CONTROL_SOCKET=off`) the waits are the
plain sleeps they were.
**Exactly once.** A command and a mailbox write for the same request share
one `request_id`. If the client times out after the display queued the
command and then also writes the mailbox, the display processes the request
once. The existing `on_demand_request_id` and processed-id checks drop the
second copy.
**Exactly once.** Since stage 4 the web interface writes the mailbox only
when the display never had the request (see "When the web interface falls
back"), so a request goes one way or the other, never both. A command and a
mailbox write for the same request still share one `request_id`, and the
`on_demand_request_id` and processed-id checks still drop a second copy: an
older web interface (before stage 4) wrote the mailbox after a reply timed
out, too. The display takes such a copy out of the mailbox when it drops it.
## When the web interface falls back (stage 4)
The client tells a request the display never had from one it had and then
failed. `ControlError.sent` is True once the whole request was written to a
connected display; a refusal the display sends before reading anything
(`forbidden`, too many connections) carries no request id, and leaves it
False. `src.ipc.client.should_fall_back()` is the one rule every route uses:
| What happened | Example reasons | Mailbox? | The route answers |
|---|---|---|---|
| The display never had it | `no_socket`, `refused`, `disabled`, `unsupported`, a connect or send that timed out, `forbidden` / `busy` at the door, `invalid_request` (refused by the client itself) | yes | success, `transport: "mailbox"`, `socket_error` |
| A display too old to know it (the upgrade case) | `unknown_command`, `unsupported_version` | yes | as above |
| The display had it and failed | `busy` (queue full), `invalid_args`, `internal`, a timeout or hang-up after the send, `bad_response` | no | `503` (`400` for `invalid_args`), `socket_error` |
A display that had the request may have applied it (a reply that timed out),
or would refuse the mailbox copy as well (bad arguments), or is stuck and
would not read the mailbox either (a full queue). Writing the copy anyway
only turned that into a "success". An on-demand stop with `stop_service`
still stops the service, which ends on-demand whatever happened.
Brightness and plugin reload never had a mailbox: without the socket, the
config watcher applies the saved brightness and a reload becomes the
restart banner, as before.
### The mailboxes now
| Mailbox | Written by | Read by the display | While the socket is up |
|---|---|---|---|
| `display_on_demand_request` | the web interface, only on fallback; plugins that predate `BasePlugin.request_on_demand()`, or run on a core without it | the render thread, `_poll_on_demand_requests()` | looked at every 1 s (`MAILBOX_POLL_INTERVAL_WITH_SOCKET`), 0.25 s without a socket |
| `plugin_error_clear_request` | the web interface, only on fallback | the error publisher's thread, every 5 s tick | unchanged rate |
A look is one `stat()` of the mailbox file (`CacheManager.file_signature`):
`(inode, mtime, size)`, and every write renames a new file into place, so a
new write always looks different. `MailboxWatch` reads the file only when
that changed since the last look, so a mailbox that holds nothing new, or
nothing at all, costs no open and no parse. A socket command never reads or
deletes the on-demand mailbox. A start already processed is taken out of
the mailbox instead of being re-read until it expires.
A request that comes through the on-demand mailbox while the socket is up
is logged once per writer (`came through the file mailbox although the
control socket is up`), which names the plugins that still write it.
### Plugins in the display process
A plugin asks for the screen with `BasePlugin.request_on_demand()` and gives
it back with `end_on_demand()` (see "On-demand display" in
[PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md)). Neither goes through
the socket or a file: `PluginManager` hands the mailbox-shaped request,
marked `source: 'plugin'`, to `DisplayController.submit_plugin_on_demand`,
which queues it in memory (at most `PLUGIN_ON_DEMAND_QUEUE_SIZE`, 32) from
whatever thread the plugin called on, and wakes the render thread through
the socket's queue flag (`ControlServer.wake()`). The render thread applies
it in `_drain_control_commands`, after the socket's commands, through the
same `_handle_on_demand_request`, so it lands within a frame like a socket
command. Without a socket it lands on the next pending-changes pass (typically
within 0.25 s). A plugin's stop ends only a session that plugin owns. The four
plugins that wrote the mailbox (birdnet-go, mqtt-notifications, on-air,
pomodoro-timer) use it where the core has it and write the mailbox
otherwise.
## Robustness
@@ -457,9 +537,10 @@ block the render loop or crash it:
and the connection is closed, because the next message boundary cannot be
found. A client that disconnects mid-message is dropped silently. No
exception from a handler leaves the connection thread.
- **Full queue.** When the queue is full, the client gets `busy` and falls
back to the mailbox. A full queue means the render thread is stuck, and the
systemd watchdog deals with that.
- **Full queue.** When the queue is full, the client gets `busy`, and the web
interface answers `503` rather than write the mailbox, which the stuck
render thread would not read either. A full queue means the render thread
is stuck, and the systemd watchdog deals with that.
- **Awaited commands.** The wait for an awaited command's outcome happens on
its connection thread and is bounded (`AWAIT_SECONDS`), so a stuck render
thread costs that client `pending` and one connection slot for at most
@@ -576,15 +657,32 @@ device never touches the live display.
an uninstall that keeps its config, still answer `restart_required`.
They can now use a load/unload command and report the result the same
way the update route does.
4. **Retire the mailboxes.** After a release in which every device has had the
socket, the web interface stops writing `display_on_demand_request`, and
the display stops polling it, logging the plugins that still write it so
they can move to an in-process `request_display()`. The other cache keys
used as messages (`plugin_error_clear_request` and the remaining
`display_*` keys) move to the socket or to tmpfs. The display also stops
writing `display_current_state`, `display_on_demand_state` and
`plugin_runtime_snapshot` once the web interface no longer falls back to
them.
4. **The mailboxes become a fallback (done).**
- The web interface writes a mailbox only when the socket could not carry
the request (`should_fall_back`); a display that had it and failed is
answered as that (see "When the web interface falls back").
- `errors.clear` replaces `plugin_error_clear_request` as the way a clear
reaches the display.
- The display looks at the on-demand mailbox once a second while the
socket is up, reads either mailbox only when its file changed, and logs
who still writes the on-demand one (see "The mailboxes now").
- Not changed, deliberately: config saves (the schedule, the dim
schedule, plugin settings) still reach the display through
`config.json` and its watcher, which is the setting itself rather than
a message; see `config.reload` under stage 2. The preview viewer marker
(`/tmp/led_matrix_preview_viewer`) is a presence signal the display
already stats at most once a second. Plugin health and metrics resets
write the persisted record the display publishes and do not reach the
running display (their routes say so); they are not mailboxes.
5. **Remove the mailboxes (next release).** Once every device has run a
display with stage 4, the web interface stops writing both mailboxes and
the display stops reading them. The four plugins that wrote
`display_on_demand_request` now have an in-process way to ask for the
screen (`BasePlugin.request_on_demand()` / `end_on_demand()`, see
"Plugins in the display process"); they keep the mailbox write only as
their fallback on older cores. The display also stops writing `display_current_state`,
`display_on_demand_state` and `plugin_runtime_snapshot` once the web
interface no longer falls back to them.
## Checking it on a device
@@ -599,7 +697,18 @@ curl -s -X POST localhost:5000/api/v3/display/on-demand/start \
If the response says `"transport": "mailbox"`, `socket_error` gives the
reason. `no_socket` means the display is stopped or predates the socket.
`refused` usually means the web user is not in the socket's group, which
takes effect when the web service restarts after the user is added.
takes effect when the web service restarts after the user is added. A `503`
with `"transport": "socket"` means the display had the request and did not
take it (`busy`, `timeout`, ...): nothing was written to the mailbox.
An error clear:
```bash
curl -s -X POST localhost:5000/api/v3/errors/clear \
-H 'Content-Type: application/json' -d '{"all":true}'
# ... "applied": true, "transport": "socket"
sudo journalctl -u ledmatrix | grep -E "Cleared .* plugin error|file mailbox"
```
Brightness and a plugin reload:
+80
View File
@@ -488,6 +488,78 @@ working for the plugin itself. `get_vegas_segment_width()` read the
`vegas_panel_count` config value, which has never affected Vegas — a card's
width comes from `get_vegas_content()` and `vegas_width_pct`.
### On-demand display
A plugin that reacts to something outside the rotation (an MQTT message, a
timer, a detection) can take the screen for it, and give it back. Both
methods are safe from any thread, including an MQTT callback: they only
queue the request, and the display applies it on its render thread within a
frame or so, exactly like an on-demand start or stop from the web interface.
#### `request_on_demand(mode=None, duration=None, pinned=False) -> Optional[str]`
Show this plugin now.
- `mode`: one of the plugin's display modes; `None` for its first.
- `duration`: seconds before the rotation resumes; `None` (or `0`) for no
limit, until `end_on_demand()` or the user stops it.
- `pinned`: stay on `mode` instead of cycling through the plugin's other
modes.
Returns the request id once the display has queued it, or `None` when
there is no display in this process to ask (the web interface's plugin
manager, `scripts/check_plugin.py`) or its queue is full. A bad argument
(a `mode` that is not a string, a `duration` that is not a number) raises
`ValueError`.
#### `end_on_demand() -> Optional[str]`
Give the screen back. Ends only a session this plugin owns: a session the
user started for another plugin, or one that already ended, is left alone.
Returns the request id once queued, or `None` as above.
#### Older cores: feature detection
These methods are new after core 3.8.0 (see `CHANGELOG.md`). Before them,
plugins wrote the `display_on_demand_request` cache key (the "mailbox")
themselves. The display reads it only once a second while the control
socket is up, and it will be removed in a future release (see
[IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md), stage 5). A plugin that
must keep working on older cores checks for the method, and writes the
mailbox only when the method is missing or answers `None`:
```python
import time, uuid
def _show_alert(self):
if hasattr(self, "request_on_demand") and self.request_on_demand(
mode="my_alert", duration=15):
return
# Older core, or no display in this process: the mailbox, as before.
self.cache_manager.set("display_on_demand_request", {
"request_id": str(uuid.uuid4()), "action": "start",
"plugin_id": self.plugin_id, "mode": "my_alert",
"duration": 15, "pinned": False, "timestamp": time.time(),
})
def _release(self):
if hasattr(self, "end_on_demand") and self.end_on_demand():
return
self.cache_manager.set("display_on_demand_request", {
"request_id": str(uuid.uuid4()), "action": "stop",
"plugin_id": self.plugin_id, "timestamp": time.time(),
})
```
Keep `ledmatrix_min_version` where it is: the fallback is what keeps the
plugin working on older cores. A mailbox stop ends any on-demand session,
whoever started it; `end_on_demand()` ends only the plugin's own.
Both methods answer a request id only when the plugin manager returned a
string, so a test that gives the plugin a `MagicMock()` plugin manager gets
`None` and exercises the mailbox path. To test the new path, set
`plugin_manager.request_on_demand.return_value = "some-id"`.
> The full source for `BasePlugin` lives in
> `src/plugin_system/base_plugin.py`. If a method here disagrees with the
> source, the source wins — please open an issue or PR to fix the doc.
@@ -966,6 +1038,14 @@ if info:
self.logger.info(f"Plugin: {info['name']}, Version: {info.get('version')}")
```
#### `request_on_demand(plugin_id, mode=None, duration=None, pinned=False)` / `end_on_demand(plugin_id)`
What `BasePlugin.request_on_demand()` and `end_on_demand()` call, with the
plugin's own id. Call those instead; see
[On-demand display](#on-demand-display). The display controller routes them
to itself with `set_on_demand_handler()`; a plugin manager without a
display behind it answers `None`.
#### `get_all_plugin_info() -> List[Dict[str, Any]]`
Get information for all plugins.
+62 -28
View File
@@ -159,14 +159,16 @@ there an unchecked checkbox — which the browser omits — is saved as
}
```
`restart_required` is always true here: display hardware, rotation,
durations and general settings take effect when the display restarts, and
the web UI shows its restart banner on the flag. (Plugin sections saved
through this route reach the running plugin live, like
`POST /plugins/config`.)
`restart_required` is true when the save changed a setting that takes
effect when the display restarts: display hardware, rotation order,
timezone, general settings and the rest. The web UI shows its restart banner
on the flag. It is false when the save changed only what the running display
applies by itself, or nothing: `brightness`, the per-mode durations
(`duration__<mode>`, `display.display_durations`) and plugin sections, which
reach the running plugin live, like `POST /plugins/config`.
A saved `brightness` is the exception: it reaches the panel without a
restart. The route also sends it to the running display over the control
A saved `brightness` reaches the panel without a restart. The route also
sends it to the running display over the control
socket (`brightness.set`), which puts it on the panel at once, and the
response adds `"brightness_transport": "socket"`. Otherwise it is
`"config"`, with `brightness_socket_error` giving the reason, and the
@@ -246,7 +248,10 @@ Replace the schedule configuration.
```
A day whose `<day>_enabled` key is absent counts as enabled, with default
times `07:00`-`23:00`. At least one day must be enabled.
times `07:00`-`23:00`. An enabled schedule needs at least one day enabled; a
disabled one (`"enabled": false`) may have every day off, as
`config.template.json` ships it. A day that is off keeps the times sent for
it, when they are valid `HH:MM`.
**Response**:
```json
@@ -343,7 +348,11 @@ control socket ([IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md)), and `cache`
when it came from the `display_current_state` cache key (no socket: the
display is stopped or older, or this is Windows). A display whose render
loop has not refreshed its state for 120 seconds is reported with every
field `null`, either way.
field `null`, either way. So is a stopped display: when the socket does not
answer and the render loop's heartbeat
(`/run/ledmatrix/display-heartbeat.json`) is absent, stale or from a process
that is gone, the cache's last entry is not used. A display still beating
without a socket, Windows, or a socket switched off reads the cache.
### List Display Modes
@@ -455,7 +464,7 @@ Request a specific plugin to display on-demand.
- `mode` (string, optional): Display mode name (plugin_id inferred if not provided)
- `duration` (number, optional): Duration in seconds (0 = until stopped)
- `pinned` (boolean, optional): Pin display (pause rotation)
- `start_service` (boolean, optional): Start the display service if it is not running (default: true). A running service is never restarted: it picks the request up within about a quarter of a second. When false and the service is stopped, the route returns 400.
- `start_service` (boolean, optional): Start the display service if it is not running (default: true). A running service is never restarted: it picks the request up within a frame over its control socket (within about a second through the mailbox fallback). When false and the service is stopped, the route returns 400.
**Response**:
```json
@@ -480,10 +489,19 @@ display's control socket acknowledged it (it is queued for the render thread,
which wakes for it and applies it within a frame; see
[IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md)), `"mailbox"` means it was
written to the cache mailbox the display polls, as before the socket existed.
With `"mailbox"`, `socket_error` gives the reason the socket was not used
(`no_socket` when the display is stopped or predates the socket, `timeout`,
`refused`, `busy`, ...). Either way the request is applied the same way;
`request_id` is the same id in both.
The mailbox is used only when the socket could not carry the request. With
`"mailbox"`, `socket_error` gives the reason (`no_socket` when the display is
stopped or predates the socket, `refused`, a connect `timeout`,
`unknown_command` from a display too old for the command, ...). Either way
the request is applied the same way; `request_id` is the same id in both.
When the display had the request and did not take it -- a full queue
(`busy`), bad arguments (`invalid_args`), no answer after the request was
sent (`timeout`, `closed`) -- the route answers `503` (`400` for
`invalid_args`) with `status: "error"` and `data: {request_id, transport:
"socket", socket_error}`, and writes nothing to the mailbox. The stop route
does the same, except that with `stop_service: true` it still stops the
service and answers success.
### Stop On-Demand Display
@@ -2239,13 +2257,11 @@ error with `"all": true` (`max_age_hours` is then ignored).
}
```
The clear is asynchronous. The web interface records a request
(`plugin_error_clear_request` in the shared cache), and the display service
applies it within about 5 seconds, rebuilding its counts from the errors it
keeps and republishing. Reads hide the cleared errors from the moment the
request is recorded. Until the display service applies an age-based clear,
`recent_errors` and `active_patterns` are already filtered but the counts
are the old ones, and `clear_pending` is `true`.
The clear goes to the display service over its control socket
(`errors.clear`, see [IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md)), which
applies it, rebuilding its counts from the errors it keeps, and republishes
before it answers: `applied` is `true`, `transport` is `"socket"`, and
`cleared_count` is the display's own count.
```json
{
@@ -2253,17 +2269,30 @@ are the old ones, and `clear_pending` is `true`.
"data": {
"cleared_count": 13,
"clear_requested": true,
"applied": true,
"transport": "socket",
"request_id": "5f0c1e...",
"cutoff": "2026-09-23T09:59:02.310000"
},
"message": "Clear of all errors requested; the display service applies it within about 5 seconds"
"message": "Cleared all errors"
}
```
`cleared_count` is how many of the reported errors the clear hides. It is
When the socket cannot carry it (the display is stopped, or older than
`errors.clear`) the clear is asynchronous, as before: the web interface
records a request (`plugin_error_clear_request` in the shared cache),
`applied` is `false` and `transport` is `"mailbox"`, and the display service
applies it within about 5 seconds. Reads hide the cleared errors from the
moment the request is recorded. Until the display service applies an
age-based clear, `recent_errors` and `active_patterns` are already filtered
but the counts are the old ones, and `clear_pending` is `true`. Then
`cleared_count` is how many of the reported errors the clear hides, and
`null` when that cannot be known before the display service applies it (an
age-based clear over more errors than the report lists). A request that
could not be written to the shared cache answers `500`.
age-based clear over more errors than the report lists).
A request that could not be written to the shared cache answers `500`. A
display that had the request and failed it (`internal`, a timeout after the
request was sent) answers `503`, with `context.socket_error`.
---
@@ -2281,7 +2310,11 @@ display snapshot. `data.status` is `healthy` or `degraded`, with
(with `heartbeat_age_seconds`), `stalled` (no heartbeat for 60s: the panel is
frozen even if the service is active; the status turns `degraded`), or
`not_reported` when the display writes none (not started yet, the dev server,
Windows), which does not affect the status. Its `source` is `socket` when the
Windows), which does not affect the status, or `stopped` (with `source:
"service"`) when the display service is not active, the control socket does
not answer and there is no live heartbeat; the status then turns
`degraded`. A platform with no control socket (Windows) or a socket switched
off never reports `stopped`. Its `source` is `socket` when the
age came from the display's state stream over the control socket (measured
in memory by the display) and `heartbeat_file` when it came from
`/run/ledmatrix/display-heartbeat.json`.
@@ -2344,8 +2377,9 @@ Replace the dim schedule. `dim_brightness` is 0-100 (default 30). In
`per-day` mode the days can be sent either as the `days` object that GET
returns, or as the web form's flat fields (`monday_enabled`,
`monday_start`, `monday_end`, ...). A day that is not sent counts as
enabled with default times `20:00`-`07:00`; at least one day must be
enabled.
enabled with default times `20:00`-`07:00`. As for the schedule above, an
enabled dim schedule needs at least one day enabled and a disabled one may
have every day off.
---
+208 -83
View File
@@ -36,122 +36,189 @@ 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.
before it, like a WiFi notice, as `Source.RELOAD` at the runner's
service points). 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).
(`_arbiter_inputs`) and call `Arbiter.decide()`.
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`).
It also ends a running screen within about a second (the runner's
service points), and a screen cut short resumes after it.
7. **The Sources below the notice:** read whether Vegas is on and make the
live-priority scan (`_arbiter_inputs_below_wifi`, where run() always
read them), and call `decide()` again. It answers OnDemand (the
session's current mode), Live (the next live mode, round-robin; a game
that goes live during a screen takes over at the next service point, at
most once a second), Vegas (`LEGACY`) or Rotation. `_take_plan` applies
the answer: a live claim or the resume when live priority ends, the
on-demand index.
8. **Vegas** (`_run_vegas_iteration`): one iteration of up to
`max_cycle_duration`. A completed iteration ends the pass, and so does
one that yielded for the schedule, a reload or a WiFi notice. Any other
interrupted one asks `decide()` once more (`vegas_yielded`): a game that
stopped the ticker, or an on-demand session that started, shows next.
9. **One screen:** pick the plugin (`_plugin_for_mode`) and hand the plan
to the `ScreenRunner` (`src/screen_runner.py`). It draws the first frame
through the executor (`_dispatch_first_frame`), has the controller fill
in the plugin's durations, dynamic flag and frame policy
(`_complete_plan`), runs the 125 Hz or 1 Hz frame loop with a service
point after each frame, makes up the minimum duration, and returns an
`Outcome`. On `PREEMPTED` the pass ends without advancing. On no
content, rotate at once (`_note_empty_pass`, `_skip_failed_plugin_modes`).
Otherwise `ArbiterState.after()` picks 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
## 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
inputs = self._arbiter_inputs() # schedule, follower, notice
plan = Arbiter.decide(self._arbiter_state(), inputs, now)
... # off / follower / notice
plan = self._take_plan(Arbiter.decide(state, self._arbiter_inputs_below_wifi(inputs), now))
if plan.source is Source.LEGACY: # Vegas, until stage 4
plan = self._run_vegas_iteration(...)
outcome = runner.run(plan, plugin) # ExitReason + elapsed
if outcome.exit_reason is not ExitReason.PREEMPTED:
self._advance_after_screen(plan, outcome) # ArbiterState.after
```
The controller's attributes (`current_display_mode`, `current_mode_index`,
`on_demand_*`, `_live_resume_index`) stay the record that the web UI, the
control socket and the on-demand cache read. `_arbiter_state()` snapshots
them into a frozen `ArbiterState`; the transitions are pure methods on it,
and the controller writes their result back (`_adopt_state`).
### 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 |
| Order | Source | Offers a screen when | Code |
|---|---|---|---|
| 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 |
| gate | ScheduledOff | the schedule is off and no on-demand session overrides it | `decide` |
| 1 | Follower | a sync leader is driving this panel | `decide` |
| 2 | OnDemand | a session is active (its mode list, index, expiry and pin) | `_on_demand_plan` |
| 3 | Wifi | a status message is pending and on-demand is not active | `decide` |
| 4 | Live | a live-priority plugin has live content (round-robin across several) | `live_pick` |
| 5 | Vegas | Vegas is enabled and nothing above wants the panel | `LEGACY`, run by `_run_vegas_iteration` |
| 6 | Rotation | always: the rotation's current mode | `rotation_plan` |
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.
The Rotation answers `state.current_mode`, not
`available_modes[current_mode_index]`: the two agree except where something
moved the panel off the list and the rotation carries on from there (a live
mode no rotation entry names, or None after a session ended with no enabled
mode to resume to), and `run()` always showed `current_display_mode`.
### Arbiter
```python
Arbiter.decide(state, inputs, now) -> ScreenPlan
Arbiter.decide(state, inputs, now, running=None) -> 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`:
an expected plan.
- `ArbiterState`: the current mode; the rotation and its index; the
on-demand session's modes, index, expiry and pin; the live resume point;
whether a mid-screen takeover has not shown yet. Transitions:
`next_on_demand`, `showing`, `claim_live`, `release_live`, `after`.
- `ArbiterInputs`: whether the schedule has the panel on, an on-demand
session, a follower, the WiFi notice, the live modes (None where no scan
was made), whether Vegas is on and keeps live content in its ticker,
whether this pass's Vegas iteration has yielded, and (mid-screen) whether
a plugin reload is waiting.
- `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` |
| `mode`, `plugin` | what to draw (None for a blank or follower plan); the plugin id once resolved |
| `min_duration`, `max_duration` | from `_resolve_durations` and the on-demand bound (`on_demand_bound`), filled in after the first frame; an on-demand plan's `max_duration` is what is left of the session at `now` |
| `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 |
| `frame_policy` | `HIGH_FPS` or `STATIC`, today `_needs_high_fps`; see stage 5 |
| `preemptible_by` | the Sources allowed to interrupt this plan mid-screen |
| `notice`, `deadline`, `ends_live` | the WiFi notice; the on-demand expiry for the bound; "live priority just ended, resume the rotation first" |
`decide` cannot ask a plugin anything, so the fields a plugin answers are
filled in by the controller after the first frame, where they were always
read (`_complete_plan`).
With `running`, `decide` answers the mid-screen question instead: `running`
itself while the screen holds, else the plan that ends it
(`_hold_or_preempt`), in the order the frame loops always checked:
1. Live: a game went live while a non-live screen runs. It is the one
preemption that changes the state (the rotation moves to the live mode
and remembers where it was), and it is claimed even when a WiFi notice
is also pending; the next pass shows the notice, then the game.
2. The panel's mode moved under the screen (on-demand started, ended or
changed mode; the rotation was rebuilt).
3. The schedule turned the panel off.
4. A WiFi notice (unless on-demand outranks it), compared with its expiry.
5. A plugin reload is waiting (between frames only).
Every screen is preemptible by the gate, OnDemand, Wifi, Live, Rotation and
a reload (`SCREEN_PREEMPTERS`), except that a live screen leaves Live out
(`LIVE_PREEMPTERS`): live games take turns between screens. A follower and
Vegas are looked at only between screens.
### ScreenRunner
```python
ScreenRunner(clock: FrameClock).run(plan) -> Outcome(exit_reason, elapsed)
ScreenRunner(clock: FrameClock, host: ScreenHost).run(plan, plugin) -> Outcome
```
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) |
| ExitReason | 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) |
| `EMPTY` | first frame returned False, or no plugin (`empty`; `raised` when display() raised inside the executor; `no-plugin`, `breaker`) |
| `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` | another Source took the panel (`on-demand-*`, `schedule-off`, `live`, `wifi`, ...) |
| `RELOAD` | a plugin reload is waiting: the screen ends early but counts as shown, and the rotation advances |
`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.
The runner asks its host at named service points (`Checkpoint`): `FRAME`
after each frame (and when a socket command wakes the 1 Hz wait),
`AFTER_LOOP` / `AFTER_COMPLETED_LOOP` when the frame loop ends,
`after_dwell` after the make-up dwell, and `FINAL` before the rotation
advances. Each is one `decide(..., running=plan)` call
(`DisplayController._screen_check`). The checkpoint says whether a pending
reload counts there and when the WiFi notice file is read (`NoticeRead`):
the read is throttled to once a second and deletes an expired file, so it
happens exactly where the loop always read it.
`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.
In the 125 Hz loop the live-priority scan is made before the frame's sleep
(`_screen_service`), at the moments it always was, and weighed by the
service point after the sleep, where the loop always decided to end the
screen.
`FrameClock` provides `time()`, `perf_counter()` and `sleep()`, the shape of
the `time` module. In production it is `_ModuleClock`, which looks up
`src.display_controller.time` on each call, so the golden traces' fake clock
drives the runner as it drove the inline loops. The runner's log lines use
the controller's logger, so they keep their source in the journal.
## Stages
@@ -181,13 +248,14 @@ that the harness patches in today.
- 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
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 16 tests run in under a second. The goldens were generated from
- 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
@@ -249,37 +317,84 @@ What shipped:
Source, the dwells, the expiry comparison, the snapshot's reads, each
dispatch in `run()`); every one failed a test.
### Stage 3: ScreenRunner and `PREEMPTED`
### Stage 3: ScreenRunner and `PREEMPTED` (done; awaiting the ledpi soak)
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:
The plan, 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.
expiry and pin, and the live resume point. `ArbiterInputs` gains the
live modes and whether Vegas is enabled and keeps live content in the
ticker.
2. OnDemand returns its current mode with the session's bound, reading
`now` for the expiry. Live returns the next live mode (round-robin).
Rotation returns the rotation's mode. `ScreenPlan` gains `mode`,
`plugin`, `min_duration`, `max_duration`, `dynamic`, `frame_policy` and
`preemptible_by`.
3. `ScreenRunner.run(plan)` returns an `ExitReason`; `state.after(outcome)`
replaces `_advance_after_screen`'s step and the live-resume bookkeeping.
Each mid-screen check asks `decide()` whether a Source in
`plan.preemptible_by` now wins.
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.
What shipped, one commit each: the runner; then the OnDemand, Live and
Rotation Sources; then one `decide()` call at the service points.
- `src/screen_runner.py` (on the mypy ratchet): `ScreenRunner`,
`FrameClock`, `ExitReason`, `Outcome`, `Checkpoint`, `NoticeRead`,
`Screen` and the `ScreenHost` protocol, which `DisplayController`
implements through `_ScreenHost` (one-line forwards to its own methods).
The two frame loops, the make-up dwell and the dynamic-duration exit
moved in unchanged, pacing included.
- `src/display_arbiter.py`: `Source` gains `ON_DEMAND`, `LIVE`, `ROTATION`
and `RELOAD`; `LEGACY` means only Vegas. `FramePolicy`. `ArbiterState`
and `ArbiterInputs` as listed under "Arbiter". The pure helpers
`on_demand_bound` (`_clamp_to_on_demand`), `live_pick`
(`_check_live_priority`'s pick), `live_takeover` (the mid-screen claim)
and `rotation_plan`.
- A pass asks `decide()` twice: once with the inputs every pass reads, and
once, only when nothing above the notice took the panel, with the Vegas
check and the live scan, read where run() always read them (the scan
asks every live-priority plugin, and the Vegas check applies queued
Vegas config, so reading them earlier, on a follower or notice pass,
would be a change). A Vegas iteration that yields asks a third time.
- `_resolve_active_mode`, `_clamp_to_on_demand` and `_screen_preempted` are
gone. `_apply_live_priority`, `_check_live_priority`,
`_check_live_takeover` and `_wifi_notice_pending` remain (Vegas, the
dwell sleep and the tests call them), built on the same pure rules.
- `_sleep_with_plugin_updates` keeps its own break rules. It also serves
the blank, the notice and the idle wait, which are not screens, and its
rules are edge-triggered (an on-demand session starting on the mode
already showing ends a dwell but not a frame loop); folding them into
`decide()` would change behaviour.
Behaviour, checked three ways:
- Golden traces: unchanged, no regeneration.
- Every harness run in the suite (67: the goldens plus the live-takeover,
WiFi+live, socket-wake, plugin-reload, schedule and tick tests) was
captured with every sleep, `display()` call, WiFi read, live scan,
publish, dwell and scroll-state call logged, and diffed against
`origin/main`. Identical, except:
- a Vegas pass used to scan the live plugins twice at the same instant
(step 7, then step 8's "is anything live?"); it scans once;
- `_apply_live_priority(None)` calls that changed nothing are not made;
- throttled WiFi reads that returned the cached answer (no side effect)
after a notice had already ended the screen are not made;
- in the 125 Hz loop the live scan still runs before the frame's sleep,
but the claim is made by the service point after it, so the "live"
state change happens 8 ms later. The screen ends at the same frame as
before.
- Tables: `test/test_display_arbiter.py` (OnDemand, Live, Vegas/Rotation,
`after`, the 24-row mid-screen table, `live_takeover`) and
`test/test_screen_runner.py` (the runner on a scripted host and fake
clock; the controller's service point and the reads it makes). A
mutation run broke each moved or new piece once; see the PR.
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`).
so it needs a frame soak on ledpi, A/B against main, before it merges.
Coordinate with whoever owns scroll performance (`docs/SCROLL_PERFORMANCE.md`).
### Stage 4: Vegas as a Source
@@ -344,3 +459,13 @@ PR that updates the affected trace and explains why. All six are fixed:
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.
Open:
- Vegas stops for a sync follower (its interrupt check includes
`is_follower_active`), but the yield path never looks at a follower, so a
full rotation screen (20 s in the test) runs before the next pass hands
the panel to the leader. Found by stage 3's mutation run;
`test_screen_runner.py::TestThroughRun::test_vegas_yielding_to_a_follower_shows_a_rotation_screen_first`
pins it. Stage 4, which drops the interrupt callback, is the natural
place to fix it.
+85 -30
View File
@@ -87,10 +87,12 @@ more. Shared sports code lives in `src/common`:
| `sports_celebration.py` | 3.7.0 | `SportsCelebrationMixin` — draws the score/win takeover; colour helpers |
| `sports_fetch.py` | 3.7.0 | `SportsFetchMixin` — season fetch, live lookback and live-odds decisions |
| `sports_card_wrappers.py` | 3.7.0 | `SportsCardWrappersMixin` — the game renderer's `sports_card` delegations |
| `sports_plugin_host.py` | next release | `SportsPluginHostMixin` — the plugin class's (`manager.py`) identical helpers: Vegas weight, off-thread switch refresh |
| `sports_live_scroll.py` | next release | `SportsLiveScrollMixin` — rebuild a live scroll strip mid-cycle, keeping the marquee's place |
| `sports_display_rules.py` | next release | `SportsCardOptionsMixin`, `SportsGameRulesMixin` — scorebug date options, the no-favourites filter, non-favourite live dwell |
| `sports_font_path.py` | next release | `resolve_font_path` — what the plugins' `_resolve_font_path` copies return |
| `sports_plugin_host.py` | 3.8.0 | `SportsPluginHostMixin` — the plugin class's (`manager.py`) identical helpers: Vegas weight, off-thread switch refresh |
| `sports_live_scroll.py` | 3.8.0 | `SportsLiveScrollMixin` — rebuild a live scroll strip mid-cycle, keeping the marquee's place |
| `sports_display_rules.py` | 3.8.0 | `SportsCardOptionsMixin`, `SportsGameRulesMixin` — scorebug date options, the no-favourites filter, non-favourite live dwell |
| `sports_font_path.py` | 3.8.0 | `resolve_font_path` — what the plugins' `_resolve_font_path` copies return |
| `sports_game_over.py` | 3.8.1 | `SportsGameOverMixin` — `_is_game_really_over`, with the `FINAL_PERIOD` seam (family 5) |
| `sports_favorites.py` | 3.8.2 | `SportsFavoritesMixin`, `SportsUpcomingFavoritesMixin`, `SportsRecentFavoritesMixin` — `_is_favorite_game` and the favourites-only picks, on the `_favorite_key` seam (family 6) |
Each is described in [src/common/README.md](../src/common/README.md).
@@ -102,7 +104,8 @@ modules taken from the plugin copies, each a **new module** rather than growth
on an existing one: a plugin that deletes a method copy and relies on an older
module having gained it fails at runtime with an `AttributeError`, while a
missing module fails at load, where the version checks can see it.
`sports_helpers.py` holds `_favorite_key`, the override point listed below.
`sports_helpers.py` holds `_favorite_key`, the override point listed below;
`sports_favorites.py` is what calls it.
Each promoted module has a parity test that compares its bodies against the
plugin copies when `LEDMATRIX_PLUGINS` points at a checkout
(`test_sports_helpers.py`, `test_sports_stage3_parity.py`), and
@@ -125,7 +128,7 @@ deprecation cycle.
| `_custom_scorebug_layout(game, draw)` | Per-sport overlay on the base layout | no-op |
| `score_phrase(points, team_abbr)` | Celebration wording (`"GOOOOAAALLL!"` vs `"TOUCHDOWN!"`). `points` is the score delta, which sports with variable-value scores use to name the play | `"<abbr> SCORES!"` — only consulted when `CelebrationMixin` is present |
| `win_phrase(team_abbr)` | Win-celebration wording | `"<abbr> WINS!"` — mixin only |
| `_favorite_key(game, side)` | Which view-model field identifies a team for favorites matching | `game["<side>_abbr"]` |
| `_favorite_key(game, side)` | Which view-model field identifies a team for favorites matching. `sports_favorites` compares it, and each `favorite_teams` entry, stripped and upper-cased; a `None` matches nothing | `game["<side>_abbr"]`. nrl returns the ESPN team id, `None` when it is missing |
| `_config_schema_path()` | Plugin's `config_schema.json` — returning it routes `_get_layout_offset` through the `src.element_style` resolver (and gives it the defaults to compare against) | `None`, i.e. the classic inline `customization.layout` read |
| `_font_root()` | Directory to resolve `assets/fonts` against | core install root |
@@ -134,8 +137,7 @@ constants rather than behavior:
| Attribute | Meaning | Default |
|---|---|---|
| `FINAL_PERIOD` | Period at/after which a zero clock can mean "over" | `4` (hockey overrides to `3`) |
| `CLOCK_COUNTS_DOWN` | Whether `0:00` means "expired" | `True` (soccer/afl/nrl override to `False` — their clocks count up, so `0:00` is kickoff) |
| `FINAL_PERIOD` | Period from which a 0:00 clock ends a game (`sports_game_over`) | `None`: the clock never ends a game (afl, nrl, soccer, baseball, ufc). Hockey sets `3`; basketball, football and lacrosse `4` |
| `COALESCE_SCORING_SEQUENCE` | Fold score increments arriving during an active celebration into that one celebration | `False` (football overrides to `True` — a touchdown lands as +6, then +1 for the extra point) |
### Why these are seams and not branches
@@ -146,11 +148,14 @@ so NRL matches favorites on team ID. Flattening every plugin to abbreviations
would silently select the wrong club for NRL users. The base declares the seam,
NRL fills it, and core never learns the string `"nrl"`.
`CLOCK_COUNTS_DOWN` exists for the same reason in the opposite direction: a
`FINAL_PERIOD` exists for the same reason in the opposite direction: a
soccer clock reading `0:00` means the match has not kicked off, so running the
clock-expiry branch there would evict live games.
clock-expiry rule there would evict live games. Those sports declare `None`,
and so do baseball (innings, not a clock) and ufc (a bout ends only on ESPN's
final status). One attribute covers both questions, whether the clock can end
a game and from which period, so no separate count-down flag was added.
`COALESCE_SCORING_SEQUENCE` is the third of the same kind. In football one
`COALESCE_SCORING_SEQUENCE` is another of the same kind. In football one
scoring play arrives as two score updates, so the follow-up must be folded into
the first celebration; in soccer two increments a few seconds apart are two real
goals, and folding them would swallow one. Neither default is "right" — which is
@@ -298,6 +303,49 @@ Left in the plugins, though identical:
renderers) is already core's, in `SportsHelpersMixin`; a renderer that
wants it can inherit that.
### Family 5: the game-over check (core done; adoption waits for a release)
The pilot of the method below. ledmatrix-plugins `scripts/test_game_over_check.py`
(#621) pinned 3,115 answers across the nine plugins first; the reconcile
(ledmatrix-plugins #625) made the five bodies one and
changed only the cells the owner's decisions under
[Product decisions](#product-decisions-each-family-needs) explain: ufc's
clock rule (65 cells), baseball's dormant one (53, every one a game with a
`period` baseball's games never carry), and a level score at 0:00 (five
cells in hockey, basketball, football and lacrosse). The harness renders
were pixel-identical. `src/common/sports_game_over.py` holds the body;
`test/test_sports_game_over_parity.py` compares it, and each plugin's
`FINAL_PERIOD`, with the plugin copies.
### Family 6: favourite matching (core done; adoption waits for a release)
ledmatrix-plugins `scripts/test_favourite_matching.py` (#634) pinned 204 rows
across the nine plugins first: `_is_favorite_game` on each manager role, the
two selection methods, the real `update()` with favourites-only on and off,
and the INFO summary; the reconcile extends it to 217 (a lower-case and a
padded favourite through `update()`, and the live favourite boost). The reconcile (ledmatrix-plugins
#635) made `_is_favorite_game` one body on `SportsCore`
(afl and soccer's `SportsUpcoming` copies and five `SportsLive` copies, all
redundant, are gone), added `_favorite_code` beside it, and gave nrl a
`_favorite_key` override instead of its own copies. So that a lower-case
favourite works on a favourites-only Upcoming board, the Upcoming `update()`'s
favourites-only pre-filter and the basketball, hockey and lacrosse live boost
now ask `_is_favorite_game` too (a one-line change each; `update()` itself is
family 13). Of 3,897 cells only those the decisions above explain changed:
case and spaces in eight plugins (30-40 each), the id-less duplicate fix (6-8
each), nrl's key (6) and its "None" match (6), and the INFO line in baseball,
football and ufc. The harness renders were byte-identical. `src/common/sports_favorites.py` holds the
bodies, one mixin per carrying class; `test/test_sports_favorites_parity.py`
compares them with the plugin copies and checks that only nrl overrides
`_favorite_key`.
Left for later families: the live screens' favourites-only filter
(`_classify_live_game` and its inline copies) and favourites-first sort still
compare abbreviations exactly, and
`SportsCoreSharedMixin._round_robin_favorites` groups favourites by raw
abbreviation (or by `_team_in` where a plugin has one) instead of through
`_favorite_key`. The result-colour helpers also wait (decision above).
### Why the method changes
Byte-identical promotion has nearly run dry. Measured on ledmatrix-plugins
@@ -333,8 +381,8 @@ game-over check); the report measures each method in it. The procedure:
line in each plugin.
- *A per-sport fact* (hockey ends in period 3; a soccer clock counts up).
Make it a declared class constant or override point with a default, as
`FINAL_PERIOD`, `CLOCK_COUNTS_DOWN`, `COALESCE_SCORING_SEQUENCE` and
`_favorite_key` are, and add it to the tables above. Never a sport-name
`FINAL_PERIOD`, `COALESCE_SCORING_SEQUENCE` and `_favorite_key` are,
and add it to the tables above. Never a sport-name
branch: core must not learn sport names.
- *A product difference*: anything a user can see (which games show, a
colour, a date, a badge, how long a screen stays). The owner picks the
@@ -382,8 +430,8 @@ release.
| # | Family | Methods (variants) | Why here |
|---|---|---|---|
| 4 | Identical sweep | `manager.py`: `_dispatch_switch_refresh`, `_favorite_team_is_live`, `get_vegas_priority_weight`, `_game_involves`, `_favorite_scan_targets`, `_favorite_scan_games`, `_get_total_games_for_manager` (all nine, 1); the live-scroll helpers `_preserving_scroll_position`, `_refresh_live_scroll_managers`, `_live_scroll_managers`, `_note_live_scroll_built`, `_live_scroll_needs_rebuild`, `_live_scroll_fields` (eight, 1). `sports.py`: `_card_option`, `_filtered_or_all`, `_effective_live_duration`, `_recent_date_text` (eight, 1). 58 identical families in all | Nothing to decide; brings `manager.py` into core as a `SportsPluginHostMixin`. `_resolve_font_path` (identical in nine `sports.py` and eight renderers) becomes `sports_font_path.resolve_font_path`, not `font_layout.resolve_asset_path`, which skips the cwd. Core side done; see [Stage 4](#stage-4-the-identical-sweep-core-done-adoption-waits-for-a-release) |
| 5 | Game-over check | `SportsLive._is_game_really_over` (5) | Pure logic, no pixels; its seams (`FINAL_PERIOD`, `CLOCK_COUNTS_DOWN`) were designed in B1. The pilot for the procedure |
| 6 | Favourite matching | `_is_favorite_game` (7 across three classes), `_select_games_for_display` (2: nrl), `_select_recent_games_for_display` (3) | Everything that asks "is this a favourite" goes through the 3.5.0 `_favorite_key` seam |
| 5 | Game-over check | `SportsLive._is_game_really_over` (5) | Pure logic, no pixels; one seam, `FINAL_PERIOD`. The pilot for the procedure. Reconciled to one body and promoted as `sports_game_over`; adoption waits for the release that ships it. See [Family 5](#family-5-the-game-over-check-core-done-adoption-waits-for-a-release) |
| 6 | Favourite matching | `_is_favorite_game` (7 across three classes), `_select_games_for_display` (2: nrl), `_select_recent_games_for_display` (3) | Everything that asks "is this a favourite" goes through the 3.5.0 `_favorite_key` seam. Reconciled to one body each and promoted as `sports_favorites`; adoption waits for the release that ships it. See [Family 6](#family-6-favourite-matching-core-done-adoption-waits-for-a-release) |
| 7 | Other-games rotation | `_by_importance`, `_other_games_window`, `_advance_other_games_if_due` (2 each: football), `_rotate_other_games_on_display` (2: ufc) | One outlier each; football carries two fixes the other eight lack |
| 8 | Rankings | `_fetch_team_rankings` (3), `_choose_poll` (3), `_load_division_team_ids`, `_passes_other_filters`, `_best_rank`, `_is_ranked_game` (2 each: football) | Needs 7; the rank badge and the "ranked only" filter read it |
| 9 | Live fetch and odds | `_fetch_todays_games` (5), `_fetch_odds` (3), `_attach_odds_to_rotated_games` (3) | The prerequisite for one shared ESPN poller across plugins |
@@ -416,21 +464,28 @@ family 9 prepares.
Owner calls to make before (or while) reconciling. Items marked *verify* are
suspected behaviour that needs a payload or a rig to confirm first.
- **5, game-over check.** Which rule each sport gets: the clock never ends a
game in afl, nrl and soccer (`CLOCK_COUNTS_DOWN = False`); hockey ends at
0:00 from period 3, basketball, football and lacrosse from period 4.
baseball and ufc share a copy that reads a missing clock as "0:00": dormant
in baseball (its games carry no `period`), and not triggered by ufc's round
breaks either. ESPN sends a break as `STATUS_END_OF_ROUND` with displayClock
`-`, not `0:00` (verified against recorded payloads; ledmatrix-plugins#580
pins it). Whatever rule ufc gets must not read `-` as `0:00`. Decide ufc's
rule: no clock rule (ESPN's `STATUS_FINAL` is the only end signal it needs;
this also closes a ~1 s window at the horn when the ticking clock reads
`0:00`), or its own final period.
- **6, favourite matching.** NRL keeps matching favourites by team id
(abbreviations collide: NEW, CAN), through `_favorite_key` rather than its
own copies of the selection methods. Six plugins log the recent-games
selection at INFO; baseball, football and ufc do not.
- **5, game-over check. Decided 2026-10-05, done:** one seam,
`FINAL_PERIOD`: hockey 3; basketball, football and lacrosse 4; `None` (the
clock never ends a game) for afl, nrl and soccer (clocks that count up),
baseball (its games carry no `period`, so the old rule was dormant) and
ufc (a bout ends only on ESPN's final status, which also closes the ~1 s
window at the horn when the ticking clock reads `0:00`; ESPN's round-break
displayClock `-` was never a zero clock, ledmatrix-plugins#580). Only a
non-empty clock string counts (the baseball/ufc copy read a missing clock
as `0:00`). A score level at 0:00 is not over: the game stays live through
the break before overtime, and one that really ends tied ends on its final
status. Baseball keeps its postponed/suspended override in `BaseballLive`.
- **6, favourite matching. Decided 2026-10-05, done:** each side of a game is
named by `_favorite_key` (the abbreviation; NRL overrides it with the ESPN
team id, and `None` for a missing id, which fixes a favourite typed "None"
matching every game without one) and compared with `favorite_teams`
stripped and upper-cased, so " bos" matches BOS. NRL's ambiguous "NEW"
still matches nothing and is logged; routing the result-colour helpers
(`side_is_favorite`, which tint both NEW clubs) through `_favorite_key` is
left for a later family. The recent-games selection logs at INFO in all
nine. ufc stays on the shared body, dormant: its favourites are fighters,
which its MMA managers match themselves (a follow-up). Fix ported: only a
game with an id can be a duplicate in the selection methods.
- **7, other-games rotation.** football advances the rotation window under
`_games_lock` (update() and display() both advance it; interleaved, a
window of games is skipped) and fixes a favourites-only pool that recomposed
+50 -6
View File
@@ -42,7 +42,8 @@ static/v3/js/
registry.js page lifecycle: init/destroy on htmx swaps
api.js fetch wrapper for /api/v3 (JSON envelope, login redirect)
facade.js window.LEDMatrix and deprecated aliases
(later) escape.js, notify.js, dialog.js, streams.js, visibility.js,
visibility.js ctx.visibility: a page's timers run only while it is on screen
(later) escape.js, notify.js, dialog.js, streams.js,
store.js (the one installed-plugin store), form/renderer.js
pages/ one module per tab partial
cache.js export init(root, ctx), destroy(root, ctx)
@@ -81,6 +82,20 @@ The conventions the converted pages share:
to the module's export of the same name and warns once.
- **Timers are cleared in `destroy()`**, the one thing `ctx.signal` cannot
undo by itself.
- **Polling goes through `ctx.visibility`.** A refresh that repeats
(`ctx.visibility.every(ms, fn)`) or work that should run only while the
page is on screen (`ctx.visibility.whileVisible(start, stop)`) is
registered there, never with a bare `setInterval`. It runs only while the
page's tab is the active tab and the browser tab is visible, and it ends
when the page is destroyed, with no code in `destroy()`.
- **A page reports its own htmx saves.** A form whose result a page module
shows (an `htmx:afterRequest` listener on the page root, in place of an
`hx-on` attribute naming a global) carries `data-reports-result`. `app.js`
then leaves the server's message to the page, as it does for a form with
an `hx-on` after-request handler, so a save shows one notification.
- **Server data for the module goes in `data-*` attributes**, as JSON where
it is structured (`data-schedule-config='{{ schedule_config | tojson }}'`),
not templated into a script.
`core/registry.js` handles the rest:
@@ -103,6 +118,7 @@ Each mount gets a `ctx` object:
| `ctx.state` | A per-mount object for the page's own state |
| `ctx.api` | Shared service from `boot.js` |
| `ctx.notify` | Shared service from `boot.js` |
| `ctx.visibility` | This page's handle on `core/visibility.js` (below), made per mount by `boot.js` through the registry's `mountContext` option |
A page that passes `{ signal: ctx.signal }` to `addEventListener` and
`fetch` needs no teardown code. Its listeners and in-flight requests go
@@ -111,6 +127,30 @@ example: its delete buttons use one delegated listener, rows are built with
`textContent` rather than markup strings, and a newer load supersedes an
older one.
### Page visibility
`core/visibility.js` gives each mounted page `ctx.visibility`:
| Member | What it does |
|---|---|
| `whileVisible(start, stop)` | Runs `start()` when the page comes on screen (at once, if it mounts on screen) and `stop()` when it leaves. Returns a function that ends the registration, running `stop()` first if needed |
| `every(ms, fn)` | `fn()` at once, then every `ms` while on screen. The interval is cleared while hidden and restarted, with an immediate `fn()`, when the page is back. Returns the same kind of end function |
| `isVisible()` | True while the page is on screen |
| `tab` | The tab the page belongs to: its name, or `forPage(ctx, { tab })` |
"On screen" means the page's tab is the active tab and the browser tab is
visible. Everything a page registered ends when its `ctx.signal` aborts,
after `destroy()`, so a swapped-out partial leaves no interval behind.
The answer comes from `window.LEDVisibility` (`app-shell.js`), read at call
time, so the page modules and the classic partials that still call it
(Overview, Logs, Tools) agree on the active tab, and the SSE streams keep
pausing with them. Each registration takes its own `LEDVisibility` key, so
registrations never replace each other or a classic partial's. Without
`LEDVisibility` (a page outside `base.html`), the browser tab's visibility
alone decides. Moving the tracker itself into the module (the shell table
below) changes only `core/visibility.js`.
### One facade
`window.LEDMatrix` is the only global the module code adds:
@@ -239,9 +279,9 @@ are the inline script in each partial today.
| 3 | Operation History | 293 lines, now 0 | **Done in stage 2.** Read-only list; rows drawn with `textContent`, the search debounce cleared on destroy. The "Showing x to y" counters now also reset when nothing matches |
| 4 | Config Editor (`raw_json.html`) | 212 lines, now 0 | **Done in stage 2.** Plain textareas (no CodeMirror on this page). It defined 5 globals after all (`formatJson`, `manualValidateJson`, `validateJSON`, `saveMainConfig`, `saveSecretsConfig`); nothing else used them, and they are deprecated aliases now. The live "Invalid JSON" line no longer puts the parser's message into `innerHTML` |
| 5 | Backup & Restore | 232 lines, now 0 | **Done in stage 2.** Its 5 globals (`exportBackup`, `loadBackupList`, `validateRestoreFile`, `clearRestore`, `runRestore`) are deprecated aliases; the buttons are delegated `data-action`s. Uploads go through `ctx.api.request(..., { body: formData })` (`api.js` gained a raw `body` option) |
| 6 | Schedule | 193 | 2 globals used as `hx-on` response handlers. Moves `hx-on` handlers into page listeners |
| 7 | General | 147 | `webLogin` global and the security section. The first page that touches login |
| 8 | Display | 231 | First page with `LEDVisibility` timers: those move to a `ctx.visibility` service that stops on destroy |
| 6 | Schedule | 193 lines, now 0 | **Done in stage 3.** Its 2 `hx-on` response handlers (`handleScheduleResponse`, `handleDimScheduleResponse`) are one `htmx:afterRequest` listener on the page root, and deprecated aliases. The forms are marked `data-reports-result` so `app.js` does not repeat the server's message. The saved schedules reach the module as JSON in `data-schedule-config` / `data-dim-schedule-config` instead of being templated into the script |
| 7 | General | 153 lines, now 0 | **Done in stage 3.** The Security section's three forms and two buttons are delegated `data-action`s (one submit and one click listener); `window.webLogin` is a deprecated alias of an object with its five methods. Login requests go through `ctx.api`, so the login redirect is quiet. The settings form keeps its `hx-on` call to the shared `showSaveResult`, as Rotation's does |
| 8 | Display | 292 lines (2 scripts), now 0 | **Done in stage 4.** The first page with a timer: the 5 s multi-display sync poll is `ctx.visibility.every(5000, ...)` (above), so it runs only while the tab is on screen and stops when the partial is swapped out. Its one global, `updateSyncUI` (the Role menu's `onchange`), is a deprecated alias; the Advanced section's `onclick` is a delegated `data-action="toggle-section"` that calls the shared `toggleSection`. The status poll and the scroll-speed hint go through `ctx.api` with `ctx.signal`, as does the Vegas order widget's plugin-list request. The settings form keeps its `hx-on` call to `showSaveResult` and its `onsubmit` call to `fixInvalidNumberInputs`, as Rotation's does |
| 9 | Overview | 410 (4 scripts) | First-run surface: Getting Started, update banner, live preview. Five globals |
| 10 | WiFi | 364 | `x-data="wifiSetup()"` is defined by its own script. Moves to `Alpine.data()` registered from the module. AP-mode first screen, so it needs the AP-mode test on a real device |
| 11 | Fonts | 681 | Large, but self-contained (6 globals) |
@@ -258,7 +298,7 @@ the order:
| `showNotification` | 4 versions | `core/notify.js` |
| The modal helper | `utils/dialog.js` | `core/dialog.js` |
| SSE streams | `app-shell.js` | `core/streams.js` |
| `LEDVisibility` | `app-shell.js` | `core/visibility.js` |
| `LEDVisibility` | `app-shell.js` | `core/visibility.js` (the page-facing `ctx.visibility` is there since step 8; it reads the tracker from `app-shell.js`) |
Each move leaves the old global as an alias. When the last inline script is
gone, the script re-execution in `htmx-config.js` and the "HTMX never
@@ -278,12 +318,16 @@ Unit suites need only node. They import the shipped modules directly:
| Suite | Kind | What it covers |
|---|---|---|
| `unit/test_page_registry.js` | Unit, minimal DOM shim | The lifecycle: one init per root, destroy on swap, a veto keeps the page, swaps elsewhere leave it alone, the sweep, lazy loading, a destroy while loading, error containment |
| `unit/test_page_registry.js` | Unit, minimal DOM shim | The lifecycle: one init per root, destroy on swap, a veto keeps the page, swaps elsewhere leave it alone, the sweep, lazy loading, a destroy while loading, error containment, `mountContext` fields per mount |
| `dom/test_visibility_service.js` | DOM: real `LEDVisibility` from `app-shell.js`, real registry, no server | `whileVisible` and `every` start and stop with the active tab and the browser tab's visibility; no interval runs while hidden or after a swap-out; one interval after five swaps; registrations never replace each other or a classic partial's; a destroyed page registers nothing; a throwing `start()` is contained; the no-`LEDVisibility` fallback |
| `unit/test_core_modules.js` | Unit | `api.js` (envelope, errors, abort, login redirect, path check) and `facade.js` (facade, aliases) |
| `dom/test_cache_page.js` | DOM: real partial, real API shape | No inline script; one request per swap and per Refresh after five swaps; a cancelled request draws nothing; hostile keys stay text; delete, empty, error, network and login states |
| `dom/test_durations_page.js` | DOM: real partial, real widget, real API shape | One plugin-list request per swap; Move down moves one place after five swaps; the swap cancels a request in flight; a late-loading widget is waited for, and a page swapped away while waiting starts nothing; hostile names stay text |
| `dom/test_operation_history_page.js` | DOM: real partial, real API shape | One history request per swap and per Refresh; the plugin filter filled once (from `PluginAPI`'s cache when loaded); paging, filters, debounced search, Clear (one DELETE), error/network/login states, cancel on swap; hostile ids, users and errors stay text |
| `dom/test_raw_json_page.js` | DOM: real partial, real config | One POST per Save after five swaps, to the right file; Format and Validate act once; invalid JSON never sent and its message stays text; a save survives a swap and is still reported; the old globals' entry points |
| `dom/test_schedule_page.js` | DOM: real partial, real widget | Both pickers drawn once per swap from the saved config; after five swaps each form's answer is one notification (message, fallback, refused, non-JSON, `null`), a request from outside the forms none; the brightness label; a late widget waited for, a page swapped away while waiting draws nothing; the old globals' entry points |
| `dom/test_display_page.js` | DOM: real partial, real widget, real `LEDVisibility`, real API shape | After five swaps one page, one sync interval, the Vegas order drawn once and each control acting once (brightness, resolution, the two show/hide toggles, the Advanced toggle, one debounced hint request); the sync poll only while on screen and never after a swap-out; sync states and hostile peer names as text, failure and login answers; a late widget waited for; `updateSyncUI`'s entry point |
| `dom/test_general_page.js` | DOM: real partial, real widget, real API shape | The timezone picker drawn once per swap with the saved zone; the settings form left to htmx; after five swaps each Security action makes one request (create, copy, revoke and its cancel, password and its mismatch); hostile token names stay text; refused, network and login answers; a create made before a swap is still reported and draws nothing; `webLogin`'s entry points |
| `dom/test_backup_restore_page.js` | DOM: real partial, real API shape | One request per Refresh, Delete, Export (busy button ignores a second click), Inspect and Restore after five swaps; the upload's fields and the six restore options; reads cancelled by a swap, writes not; hostile file and host names stay text; the old globals' entry points |
| `test/web_interface/test_es_modules.py` | pytest | MIME type; `no-cache` without `?v` and immutable with it; `boot.js` loads last; every import resolves inside `core/` and `pages/`; the converted pages are exactly the registered ones, each with its module, `init`, and one root in the rendered partial; a converted partial has no `<script>` and no `onclick`; every moved global is aliased in `boot.js` and exported by its module, and no template defines it any more |
| `test/test_field_model_parity.py` | pytest | The model against the macro for every available schema |
+4 -1
View File
@@ -91,7 +91,10 @@ if [ -r "$LM_OS_RELEASE_FILE" ]; then
DESKTOP_DETECTED=0
# grep without -q: -q exits at the first match, dpkg then dies of SIGPIPE,
# and pipefail turns a found desktop into "not found".
if dpkg -l | grep -E "^ii.*raspberrypi-ui-mods|^ii.*lxde|^ii.*xfce|^ii.*gnome|^ii.*kde" >/dev/null; then
# Match on the package name only, anchored: `dpkg -l` lines include the
# description, so an unanchored ".*kde" matched libblockdev-* on Lite.
if dpkg-query -W -f='${db:Status-Abbrev} ${binary:Package}\n' 2>/dev/null \
| grep -E "^ii +(raspberrypi-ui-mods|lxde|xfce|gnome|kde)" >/dev/null; then
DESKTOP_DETECTED=1
fi
if systemctl list-units --type=service --state=running 2>/dev/null | grep -qE "lightdm|gdm3|sddm|lxdm"; then
+4
View File
@@ -36,8 +36,10 @@ src/common/sports_card.py
src/common/sports_card_wrappers.py
src/common/sports_celebration.py
src/common/sports_display_rules.py
src/common/sports_favorites.py
src/common/sports_fetch.py
src/common/sports_font_path.py
src/common/sports_game_over.py
src/common/sports_live_scroll.py
src/common/sports_plugin_host.py
src/common/sports_scroll.py
@@ -58,6 +60,7 @@ src/ipc/contract.py
src/ipc/server.py
src/logging_config.py
src/logo_downloader.py
src/malloc_tuning.py
src/matrix_support.py
src/pi5_matrix_support.py
src/plugin_system/__init__.py
@@ -88,6 +91,7 @@ src/plugin_system/testing/vegas.py
src/plugin_system/vegas_elements.py
src/redaction.py
src/scan_order.py
src/screen_runner.py
src/startup_validator.py
src/vegas_mode/__init__.py
src/vegas_mode/config.py
+6
View File
@@ -14,6 +14,12 @@ project_dir = os.path.dirname(os.path.abspath(__file__))
if project_dir not in sys.path:
sys.path.insert(0, project_dir)
# Cap glibc's malloc arenas before any thread exists (arenas already made
# stay): the in-process twin of the unit's MALLOC_ARENA_MAX=2, for units
# installed before that line. A no-op off glibc. See src/malloc_tuning.py.
from src import malloc_tuning
malloc_tuning.cap_arenas()
# Under systemd the watchdog clock is already running, and start-up (plugin
# loads, initial updates) takes far longer than the render loop's limit. Widen
# it before anything slow is imported; the render loop narrows it again once
+1 -1
View File
@@ -4,5 +4,5 @@ LEDMatrix Display System
Core source package for the LED Matrix Display project.
"""
__version__ = "3.8.0"
__version__ = "3.8.2"
+57 -2
View File
@@ -28,7 +28,7 @@ import os
import time
from datetime import datetime
import pytz
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Optional, Tuple
import logging
import threading
import tempfile
@@ -72,6 +72,43 @@ def _outlived(record: Any, max_age: Optional[float], now: float) -> bool:
return False
_NOT_SEEN: Any = object()
class MailboxWatch:
"""Tells the poller of a mailbox key whether its file changed since the
last look, from one stat() (:meth:`CacheManager.file_signature`).
The display polls the mailboxes the web interface falls back to. Reading
one is an open and a JSON parse; with this a poll that finds the same file
(or none) costs a stat, and the file is read only after a new write. A
cache without ``file_signature`` (a test double) is read every time.
"""
def __init__(self, key: str):
self.key = key
self._seen: Any = _NOT_SEEN
def changed(self, cache_manager: Any) -> bool:
"""True when the poller should read the key now."""
signature = getattr(cache_manager, 'file_signature', None)
sig = signature(self.key) if callable(signature) else _NOT_SEEN
if sig is not None and not isinstance(sig, tuple):
return True # cannot tell: read it
if sig is None:
self._seen = None
return False # no file, nothing to read
if sig == self._seen:
return False
self._seen = sig
return True
def forget(self) -> None:
"""Read the key on the next poll even if its file has not changed
(the last read failed)."""
self._seen = _NOT_SEEN
class CacheManager:
"""Manages caching of API responses to reduce API calls."""
@@ -295,7 +332,25 @@ class CacheManager:
def _get_cache_path(self, key: str) -> Optional[str]:
"""Get the path for a cache file."""
return self._disk_cache_component.get_cache_path(key)
def file_signature(self, key: str) -> Optional[Tuple[int, int, int]]:
"""``(st_ino, st_mtime_ns, st_size)`` of ``key``'s file, or None when
there is no file (the key is absent, or this cache has no disk tier).
One stat(), no read: a poller of a mailbox another process writes
compares it with the last one it saw and reads the file only when it
changed. Every write replaces the file (a temp file renamed into
place), so a new write always has a new inode, however fast it came.
"""
path = self._get_cache_path(key)
if not path:
return None
try:
st = os.stat(path)
except OSError:
return None
return (st.st_ino, st.st_mtime_ns, st.st_size)
def get_cached_data(self, key: str, max_age: int = 300, memory_ttl: Optional[int] = None) -> Optional[Dict[str, Any]]:
"""Get data from cache (memory first, then disk) honoring TTLs.
+31 -3
View File
@@ -44,8 +44,10 @@ Rules for the package:
| [`sports_card_wrappers`](#sports_card_wrappers) | The game renderer's `sports_card` delegations | Yes (scoreboards) | 3.7.0 |
| [`sports_celebration`](#sports_celebration) | Draw a scoreboard's score/win celebration | Yes (scoreboards) | 3.7.0 |
| [`sports_display_rules`](#sports_display_rules) | Which games a scoreboard shows, for how long, and its scorebug date line | Yes (scoreboards) | 3.8.0 |
| [`sports_favorites`](#sports_favorites) | Which games involve a favourite team, and the favourites-only picks | Yes (scoreboards) | 3.8.2 |
| [`sports_fetch`](#sports_fetch) | Scoreboard season fetch, lookback and live-odds decisions | Yes (scoreboards) | 3.7.0 |
| [`sports_font_path`](#sports_font_path) | Find a scoreboard's bundled font whatever the cwd | Yes (scoreboards) | 3.8.0 |
| [`sports_game_over`](#sports_game_over) | Whether a game ESPN still lists as live has ended | Yes (scoreboards) | 3.8.1 |
| [`sports_game_renderer`](#sports_game_renderer) | Scoreboard scroll/Vegas card geometry | Yes (scoreboards) | 3.3.0 |
| [`sports_helpers`](#sports_helpers) | Small helpers every scoreboard `sports.py` copies | Yes (scoreboards) | 3.5.0 |
| [`sports_live_scroll`](#sports_live_scroll) | Rebuild a live scroll strip mid-cycle without moving it | Yes (scoreboards) | 3.8.0 |
@@ -109,8 +111,11 @@ and the plugin test harness all use it. Most plugins get BDF text through
[`espn_dates.py`](espn_dates.py). ESPN's site API rejects `dates=` ranges
and truncates results when `limit` is above 500. `fetch_espn_scoreboard()`
splits a range into month and day requests ESPN accepts and merges the
results; `espn_date_chunks()`, `fetch_espn_date_chunks()`,
`clamp_espn_limit()` and `merge_scoreboard_payloads()` are the pieces.
results; `espn_date_chunks()`, `espn_request_chunks()`,
`fetch_espn_date_chunks()`, `clamp_espn_limit()` and
`merge_scoreboard_payloads()` are the pieces. A window's partial edge months
are asked whole and trimmed to its days (US Eastern), and chunk requests share
one process-wide cap of `ESPN_CHUNK_WORKERS` in flight.
Every request goes through [`fetch_service`](#fetch_service), the chunks
counted against the plugin that asked. Scoreboard plugins also bundle a copy
for older cores.
@@ -276,6 +281,19 @@ list it before `SportsCoreSharedMixin`) and `SportsGameRulesMixin`
`_effective_live_duration()`, the shorter dwell for a non-favourite live
game).
### sports_favorites
[`sports_favorites.py`](sports_favorites.py). Sports family 6, one mixin per
class that carried the methods: `SportsFavoritesMixin` (`SportsCore`:
`_is_favorite_game(game)` and `_favorite_code(value)`),
`SportsUpcomingFavoritesMixin` (`_select_games_for_display`) and
`SportsRecentFavoritesMixin` (`_select_recent_games_for_display`). Each side
of a game is named by `_favorite_key` (from `SportsHelpersMixin`; NRL
overrides it with the team id) and compared with `favorite_teams` stripped and
upper-cased. The selection methods give each favourite up to the per-team
limit, count a game between two favourites for both, and treat only games
with an id as possible duplicates.
### sports_fetch
[`sports_fetch.py`](sports_fetch.py). `SportsFetchMixin`: the `SportsCore`
@@ -291,6 +309,16 @@ path as given when it exists (relative to the cwd), else
`font_layout.resolve_asset_path(path)`. What the scoreboards'
`_resolve_font_path` copies return on a core that ships it.
### sports_game_over
[`sports_game_over.py`](sports_game_over.py). `SportsGameOverMixin`:
`_is_game_really_over(game)`, the `SportsLive` check that drops a game ESPN
still lists as live (`SportsLiveSharedMixin._detect_stale_games` calls it).
Over on a final period text, or on a 0:00 clock from period `FINAL_PERIOD`
on unless the score is level. `FINAL_PERIOD` is a class attribute the host
sets per sport; the default `None` means the clock never ends a game. List
it before `SportsLiveSharedMixin`.
### sports_game_renderer
[`sports_game_renderer.py`](sports_game_renderer.py).
@@ -388,7 +416,7 @@ Created by `DisplayController`; works with any plugin.
`draw_multiline_text()`, `create_text_image()`.
`draw_text_outlined(draw, xy, text, font, fill, outline_color=(0, 0, 0),
offsets=OUTLINE_SQUARE)` (Unreleased) draws the text in `outline_color` at
offsets=OUTLINE_SQUARE)` (3.8.1) draws the text in `outline_color` at
each offset, then in `fill` on top: the same pixels as one `draw.text` per
offset, but the string is rasterized once. `OUTLINE_SQUARE` is the
eight-sided one-pixel outline the scoreboards draw, `OUTLINE_CROSS` the
+189 -37
View File
@@ -26,10 +26,30 @@ A month can hold more than 500 events (college baseball's March does), and
ESPN answers that with exactly ``limit`` events and no hint that more exist. A
month chunk that comes back full is therefore re-asked day by day.
A window's *partial* edge months are asked for whole, too, once the window
covers ``ESPN_MONTH_COVER_MIN_DAYS`` or more of their days, and the answer is
trimmed back to the window's days. A scoreboard's default fortnight either side
of today (29 days, two partial months) was 29 day requests per league; it is
now 2. Trimming needs ESPN's "game day", which is the event's start in US
Eastern time -- checked against the live API on 2026-10-03: 417 of 417 soccer
events across five leagues and three months (one of them spanning the end of
daylight saving) came back from exactly the day query their Eastern date
names. A short window (a live poll's one or two days) stays day by day, so it
never downloads a whole month to read a day of it.
Chunk requests share one process-wide budget of ``ESPN_CHUNK_WORKERS`` in
flight, however many windows are being fetched at once. Each window used to get
its own six, so a scoreboard starting eight leagues -- each with a recent and
an upcoming manager -- had ~40 requests in flight, every one beyond a session's
pool a new connection and a new DNS lookup. On a Pi whose resolver could not
keep up, that was ~90 ``NameResolutionError`` lines within a minute of every
start.
Once a range has been rejected, later ranges skip straight to chunks for
``RANGE_RETRY_SECONDS`` instead of spending a doomed request first -- live
scoreboards ask every 30 seconds. After that the range is tried again, so the
workaround retires itself if ESPN reverts.
workaround retires itself if ESPN reverts. A process starts inside that
period, as if a range had just been rejected.
ONE CACHE KEY PER SCOREBOARD
----------------------------
@@ -55,7 +75,7 @@ import re
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from datetime import date, datetime, timedelta
from datetime import date, datetime, timedelta, tzinfo
from functools import partial
from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple, cast
@@ -100,16 +120,57 @@ RANGE_RETRY_SECONDS = 6 * 60 * 60
# pool_maxsize of 10 so the shared Session never has to discard connections.
ESPN_CHUNK_WORKERS = 6
#: An edge month the window covers at least this many days of is asked for
#: whole and trimmed, instead of one request per day (see module docstring).
#: Below it the days are cheaper than the month: a whole month is two to
#: three times the bytes of the half of it a fortnight window holds.
ESPN_MONTH_COVER_MIN_DAYS = 7
# Every chunk request in the process holds one of these while it is in flight
# -- the cap is per process, not per window (see module docstring).
_chunk_slots = threading.BoundedSemaphore(ESPN_CHUNK_WORKERS)
def _eastern_zone() -> Optional[tzinfo]:
"""US Eastern, the zone ESPN's ``dates=YYYYMMDD`` means, or None when
this Python has no time zone data (no edge month is trimmed then)."""
zone: Optional[tzinfo] = None
try:
from zoneinfo import ZoneInfo
zone = ZoneInfo("America/New_York")
except Exception: # noqa: BLE001 - no zoneinfo module or no tz database
zone = None
if zone is not None:
return zone
try:
import pytz
return cast(tzinfo, pytz.timezone("America/New_York"))
except Exception: # noqa: BLE001
return None
_EASTERN = _eastern_zone()
# What _fetch_one_chunk returns for a month that came back at the cap.
_CAPPED: Any = object()
_range_lock = threading.Lock()
_ranges_rejected_until = 0.0
# A process starts out assuming ranges are still rejected, as they have been
# since 2026-09-15, and tries one again RANGE_RETRY_SECONDS in. Starting
# from "unknown" cost one doomed range request per window at every start --
# eleven 400s at once from a soccer board, each fetching before any had
# answered -- to learn what every start learns.
_ranges_rejected_until = time.monotonic() + RANGE_RETRY_SECONDS
__all__ = [
"ESPN_MAX_LIMIT",
"ESPN_CHUNK_WORKERS",
"ESPN_MONTH_COVER_MIN_DAYS",
"RANGE_RETRY_SECONDS",
"clamp_espn_limit",
"parse_espn_date_range",
"espn_date_chunks",
"espn_request_chunks",
"merge_scoreboard_payloads",
"fetch_espn_date_chunks",
"fetch_espn_scoreboard",
@@ -220,6 +281,79 @@ def espn_date_chunks(start: date, end: date) -> List[str]:
return chunks
def espn_request_chunks(
start: date,
end: date,
month_cover_min_days: Optional[int] = None,
) -> List[Tuple[str, Optional[Tuple[date, date]]]]:
"""The requests that fetch ``[start, end]``, as ``(dates, trim)`` pairs.
:func:`espn_date_chunks`, except that a partial edge month with
``month_cover_min_days`` (default ``ESPN_MONTH_COVER_MIN_DAYS``) or more
of its days in the window becomes one ``YYYYMM`` request whose ``trim``
is the first and last of those days: its events that start outside them
(US Eastern) are dropped. ``trim`` is None for every other request.
Without time zone data nothing can be trimmed, so the edge days stay day
requests.
"""
if month_cover_min_days is None:
month_cover_min_days = ESPN_MONTH_COVER_MIN_DAYS
planned: List[Tuple[str, Optional[Tuple[date, date]]]] = []
run: List[str] = []
def flush() -> None:
if (_EASTERN is not None and month_cover_min_days > 0
and len(run) >= month_cover_min_days):
planned.append((run[0][:6], (_parse_day(run[0]), _parse_day(run[-1]))))
else:
planned.extend((day, None) for day in run)
run.clear()
for chunk in espn_date_chunks(start, end):
if run and (len(chunk) != 8 or chunk[:6] != run[0][:6]):
flush()
if len(chunk) == 8:
run.append(chunk)
else:
planned.append((chunk, None))
flush()
return planned
def _parse_day(text: str) -> date:
return date(int(text[:4]), int(text[4:6]), int(text[6:8]))
def _eastern_day(stamp: Any) -> Optional[date]:
"""The US Eastern date of an ESPN event ``date`` ("2026-10-10T11:30Z"),
or None when it cannot be read."""
if not isinstance(stamp, str) or _EASTERN is None:
return None
try:
moment = datetime.fromisoformat(stamp.strip().replace("Z", "+00:00"))
except ValueError:
return None
if moment.tzinfo is None:
return None
return moment.astimezone(_EASTERN).date()
def _trim_to_days(payload: Any, first: date, last: date) -> Any:
"""Drop the events of a month payload that start outside ``[first, last]``
(US Eastern). An event whose date cannot be read is kept: its day query
might well have returned it, and a game is never dropped on a guess.
"""
if not isinstance(payload, dict) or not isinstance(payload.get("events"), list):
return payload
kept = []
for event in payload["events"]:
day = _eastern_day(event.get("date")) if isinstance(event, dict) else None
if day is None or first <= day <= last:
kept.append(event)
payload["events"] = kept
return payload
def merge_scoreboard_payloads(payloads: List[Any]) -> Dict[str, Any]:
"""Fold chunk responses into one scoreboard payload.
@@ -250,37 +384,54 @@ def merge_scoreboard_payloads(payloads: List[Any]) -> Dict[str, Any]:
def _fetch_one_chunk(
session, url: str, params: Dict[str, Any], headers, timeout, logger, chunk: str,
cache_max_age: Optional[float] = None,
) -> Optional[Dict[str, Any]]:
trims: Optional[Dict[str, Tuple[date, date]]] = None,
) -> Any:
"""GET a single ``dates=`` chunk, or None when it failed.
One bad chunk must not sink the rest of the season, so every error is
logged and swallowed here rather than raised to the gather below.
A month that comes back at the cap is truncated: it returns ``_CAPPED``,
its payload dropped here before it is ever held beside the others. A
month in ``trims`` loses its events outside the days given there.
The request holds one of the process-wide ``_chunk_slots`` while it runs.
"""
try:
response = fetch_get(
session,
url,
params=dict(params, dates=chunk, limit=ESPN_MAX_LIMIT),
headers=headers,
timeout=timeout,
**_memo_kwargs(cache_max_age),
)
response.raise_for_status()
return cast(Optional[Dict[str, Any]], response_json(response))
with _chunk_slots:
response = fetch_get(
session,
url,
params=dict(params, dates=chunk, limit=ESPN_MAX_LIMIT),
headers=headers,
timeout=timeout,
**_memo_kwargs(cache_max_age),
)
response.raise_for_status()
payload = response_json(response)
except Exception as exc: # noqa: BLE001 - see docstring
if logger:
logger.warning("ESPN chunk %s failed, skipping it: %s", chunk, exc)
return None
if len(chunk) == 6 and isinstance(payload, dict):
if len(payload.get("events") or []) >= ESPN_MAX_LIMIT:
return _CAPPED
trim = (trims or {}).get(chunk)
if trim is not None:
payload = _trim_to_days(payload, *trim)
return payload
def _fetch_chunks(
session, url: str, params: Dict[str, Any], headers, timeout, logger,
chunks: List[str], cache_max_age: Optional[float] = None,
) -> List[Optional[Dict[str, Any]]]:
trims: Optional[Dict[str, Tuple[date, date]]] = None,
) -> List[Any]:
"""Fetch every chunk, returning payloads positionally aligned with ``chunks``.
Requests go out ``ESPN_CHUNK_WORKERS`` at a time because a cold season is
over a hundred of them. The order they come back in is not significant --
over a hundred of them -- and no more than that across every window the
process is fetching, which ``_fetch_one_chunk``'s slot enforces. The order they come back in is not significant --
callers keep ``chunks`` order from the returned list -- but it does mean
the session is shared across threads, which is why this only ever issues
GETs and never touches session state.
@@ -293,7 +444,7 @@ def _fetch_chunks(
return []
fetch = partial(
_fetch_one_chunk, session, url, params, headers, timeout, logger,
cache_max_age=cache_max_age,
cache_max_age=cache_max_age, trims=trims,
)
if len(chunks) == 1:
return [fetch(chunks[0])]
@@ -340,7 +491,9 @@ def fetch_espn_date_chunks(
if span is None:
return None
chunks = espn_date_chunks(*span)
planned = espn_request_chunks(*span)
chunks = [chunk for chunk, _ in planned]
trims = {chunk: trim for chunk, trim in planned if trim is not None}
if logger:
logger.debug(
"Fetching ESPN date range %s as %d month/day chunks",
@@ -349,32 +502,31 @@ def fetch_espn_date_chunks(
results = _fetch_chunks(
session, url, params, headers, timeout, logger, chunks, cache_max_age,
trims,
)
attempted = len(chunks)
# A month that came back at the cap is truncated; its days replace it in
# place, so merged events stay in chunk order however the requests raced.
# A month that came back at the cap is truncated; its days (only the
# window's, for a trimmed edge month) replace it in place, so merged
# events stay in chunk order however the requests raced. Its payload was
# already dropped in the worker: a capped college-baseball month is ~2MB
# of parsed JSON, and holding four of them through ~120 day requests added
# ~25MB to the peak -- more than the concurrency itself. Low-memory boards
# (docs/LOW_MEMORY_BOARDS.md) have under 200MB of headroom.
slots: List[Any] = results
capped: Dict[int, List[str]] = {}
for index, chunk in enumerate(chunks):
payload = slots[index]
if payload is None or len(chunk) != 6:
if slots[index] is not _CAPPED:
continue
events = payload.get("events") if isinstance(payload, dict) else None
if len(events or []) >= ESPN_MAX_LIMIT:
if logger:
logger.info(
"ESPN month %s hit the %d-event cap; re-asking it day by day",
chunk, ESPN_MAX_LIMIT,
)
capped[index] = _days_of_month(chunk)
# Drop the truncated month now rather than after its days arrive:
# a capped college-baseball month is ~2MB of parsed JSON, and
# holding four of them through ~120 day requests added ~25MB to
# the peak -- more than the concurrency itself. Low-memory boards
# (docs/LOW_MEMORY_BOARDS.md) have under 200MB of headroom.
slots[index] = None
payload = events = None
if logger:
logger.info(
"ESPN month %s hit the %d-event cap; re-asking it day by day",
chunk, ESPN_MAX_LIMIT,
)
trim = trims.get(chunk)
capped[index] = (_days_of_month(chunk) if trim is None
else espn_date_chunks(*trim))
slots[index] = None
if capped:
days = [day for index in sorted(capped) for day in capped[index]]
+30 -1
View File
@@ -59,7 +59,10 @@ says how old with ``cache_max_age`` (``fetch_get(..., cache_max_age=ttl)``;
Identical means what the validator store keys on: URL, query, effective
headers and, for a session with cookies or auth, the session.
**Counters.** Requests, merged requests, bytes, 304s, errors, HTTP errors,
**Counters.** Requests, merged requests, bytes (``bytes`` decoded, as the
caller reads them; ``wire_bytes`` as they crossed the network, which is
what a metered connection pays for -- ESPN gzips, so the two differ ~14x),
304s, errors, HTTP errors,
adapter retries, throttled requests and seconds waited, plus requests
answered without the network: ``memo_hits`` (the response cache) and
``cache_hits`` / ``legacy_cache_hits`` (a shared ESPN scoreboard cache entry,
@@ -201,6 +204,7 @@ _COUNTER_FIELDS = (
"throttled", # requests that waited for a host budget
"overruns", # requests that went after max_wait_seconds anyway
"bytes", # decoded response body bytes received
"wire_bytes", # body bytes as they came off the socket (still compressed)
"wait_seconds", # time spent waiting for host budgets
"memo_hits", # answered from the response cache (max-age); nothing sent
"cache_hits", # scoreboard fetches answered from a shared ESPN cache entry
@@ -616,6 +620,30 @@ def _body_of(response: Any) -> Optional[bytes]:
return content if isinstance(content, bytes) else None
def _wire_bytes_of(response: Any, body: Optional[bytes]) -> int:
"""How many body bytes came off the socket for ``response``: the
compressed size when the server sent gzip, which ESPN does for every
scoreboard (63 KB on the wire for an 865 KB college football Saturday).
urllib3's ``HTTPResponse.tell()`` counts the raw bytes read before
decoding. A response without one (a test double, an adapter that is not
urllib3) or one whose body was not read is counted at its decoded size,
or as 0, so the counter never claims less than it can prove.
"""
if body is None:
return 0
raw = getattr(response, "raw", None)
tell = getattr(raw, "tell", None)
if callable(tell):
try:
read = tell()
except Exception:
read = None
if isinstance(read, int) and not isinstance(read, bool) and read > 0:
return read
return len(body)
def _retries_of(response: Any) -> int:
raw = getattr(response, "raw", None)
retries = getattr(raw, "retries", None)
@@ -1117,6 +1145,7 @@ class FetchService:
http_errors=int(status is not None and status >= 400),
retries=_retries_of(response),
bytes=len(body) if body is not None else 0,
wire_bytes=_wire_bytes_of(response, body),
throttled=int(waited > 0), overruns=int(overrun),
wait_seconds=waited)
except Exception:
+263
View File
@@ -0,0 +1,263 @@
"""Which games involve a favourite team, and which of them to show (sports family 6).
The scoreboards' favourite matching, reconciled in ledmatrix-plugins
(family 6) from seven ``_is_favorite_game`` bodies, two
``_select_games_for_display`` and three ``_select_recent_games_for_display``
into one each, and copied here under their existing names:
- ``SportsFavoritesMixin`` (``SportsCore``): ``_is_favorite_game(game)``,
asked by ``SportsCoreSharedMixin._favorites_first``, the switch-mode
favourite boost (``SportsHelpersMixin._next_switch_index``), the
non-favourite live dwell (``SportsGameRulesMixin._effective_live_duration``)
and the plugins' live rotation; and ``_favorite_code(value)``, the
normalisation both sides of every comparison go through.
- ``SportsUpcomingFavoritesMixin`` (``SportsUpcoming``):
``_select_games_for_display``, the favourites-only pick of upcoming games.
- ``SportsRecentFavoritesMixin`` (``SportsRecent``):
``_select_recent_games_for_display``, the same for finished games, most
recent first.
Each mixin carries only what its class already had, so no manager gains a
method it did not have.
THE RULE
--------
Each side of a game is named by ``_favorite_key(game, side)``, the override
point ``SportsHelpersMixin`` (``src.common.sports_helpers``) has carried since
3.5.0: the team abbreviation by default. A sport whose abbreviations are not
unique overrides it -- NRL returns the ESPN team id (and None when the id is
missing), because "NEW" is both Newcastle and New Zealand. That value and every
entry of ``favorite_teams`` are compared as ``_favorite_code`` leaves them:
stripped and upper-cased, a blank or missing value matching nothing. So
" bos" in the config matches BOS.
The selection methods give each favourite team up to the per-team limit
(``upcoming_games_to_show`` / ``recent_games_to_show``); a game between two
favourites counts for both. Only a game with an id can be a duplicate: two
games without one are two games.
A new module rather than more methods on ``sports_shared`` or
``sports_helpers``, for the reason ``sports_helpers`` gives: a missing module
fails at load, where the version checks see it; a missing method fails
mid-update.
WHAT A HOST MUST PROVIDE
------------------------
Derived by walking every ``self.<attr>`` the mixins read; the host-contract
test in ``test/test_sports_favorites.py`` fails if a read is added without
being listed here.
- ``favorite_teams`` -- the resolved favourites list (``_is_favorite_game``).
The selection methods are handed the list instead.
- ``_favorite_key`` -- ``SportsHelpersMixin`` supplies the default.
- ``_favorite_code`` -- from ``SportsFavoritesMixin``, which the Upcoming and
Recent classes inherit through their ``SportsCore``.
- ``logger`` -- the selection methods log each pick at DEBUG and a summary at
INFO.
- ``upcoming_games_to_show`` (Upcoming) and ``recent_games_to_show`` (Recent)
-- the per-team limits.
The methods read the game dict's ``id`` and ``start_time_utc`` (selection),
whatever ``_favorite_key`` reads (``home_abbr`` / ``away_abbr`` by default),
and ``home_abbr`` / ``away_abbr`` again for the DEBUG line; any may be missing.
BASE ORDER
----------
No other mixin defines these methods, so the position in the bases does not
change which body runs; a method on the plugin's own class still wins. The
mixins have no ``__init__`` and no state.
"""
import logging
from datetime import datetime, timezone
from typing import Callable, Dict, List, Optional
class SportsFavoritesMixin:
"""``SportsCore``'s favourite check. See module docstring."""
# The host contract, declared for type checking only.
favorite_teams: List[str]
_favorite_key: Callable[[Dict, str], Optional[str]]
@staticmethod
def _favorite_code(value) -> Optional[str]:
"""``value`` as favourites are compared: stripped and upper-cased.
None for a missing or blank value, which matches nothing.
"""
if value is None:
return None
return str(value).strip().upper() or None
def _is_favorite_game(self, game: Dict) -> bool:
"""Does either side of this game belong to a favourite team?
``_favorite_key`` names each side (the abbreviation; nrl overrides it
with the ESPN team id), and both it and ``favorite_teams`` are compared
as ``_favorite_code`` normalises them, so " bos" matches BOS.
"""
favorites = {self._favorite_code(team) for team in self.favorite_teams or ()}
favorites.discard(None)
return any(
self._favorite_code(self._favorite_key(game, side)) in favorites
for side in ("home", "away")
)
class SportsUpcomingFavoritesMixin:
"""``SportsUpcoming``'s favourites-only pick. See module docstring."""
# The host contract, declared for type checking only.
logger: logging.Logger
upcoming_games_to_show: int
_favorite_key: Callable[[Dict, str], Optional[str]]
_favorite_code: Callable[[object], Optional[str]]
def _select_games_for_display(
self, processed_games: List[Dict], favorite_teams: List[str]
) -> List[Dict]:
"""
Single-pass game selection with proper deduplication and counting.
When a game involves two favorite teams, it counts toward BOTH teams' limits.
This prevents unexpected game counts from the multi-pass algorithm.
Teams are matched as _is_favorite_game matches them. Only a game with
an id can be a duplicate: two games without one are two games.
"""
sorted_games = sorted(
processed_games,
key=lambda g: g.get("start_time_utc")
or datetime.max.replace(tzinfo=timezone.utc),
)
if not favorite_teams:
return sorted_games
selected_games = []
selected_ids = set()
team_counts: Dict[Optional[str], int] = {
code: 0 for code in map(self._favorite_code, favorite_teams) if code
}
for game in sorted_games:
game_id = game.get("id")
if game_id is not None and game_id in selected_ids:
continue
home = self._favorite_code(self._favorite_key(game, "home"))
away = self._favorite_code(self._favorite_key(game, "away"))
home_fav = home in team_counts
away_fav = away in team_counts
if not home_fav and not away_fav:
continue
home_needs = home_fav and team_counts[home] < self.upcoming_games_to_show
away_needs = away_fav and team_counts[away] < self.upcoming_games_to_show
if home_needs or away_needs:
selected_games.append(game)
if game_id is not None:
selected_ids.add(game_id)
if home_fav:
team_counts[home] += 1
if away_fav:
team_counts[away] += 1
self.logger.debug(
f"Selected game {game.get('away_abbr')}@{game.get('home_abbr')}: "
f"team_counts={team_counts}"
)
if all(c >= self.upcoming_games_to_show for c in team_counts.values()):
self.logger.debug("All favorite teams satisfied, stopping selection")
break
self.logger.info(
f"Selected {len(selected_games)} games for {len(favorite_teams)} "
f"favorite teams: {team_counts}"
)
return selected_games
class SportsRecentFavoritesMixin:
"""``SportsRecent``'s favourites-only pick. See module docstring."""
# The host contract, declared for type checking only.
logger: logging.Logger
recent_games_to_show: int
_favorite_key: Callable[[Dict, str], Optional[str]]
_favorite_code: Callable[[object], Optional[str]]
def _select_recent_games_for_display(
self, processed_games: List[Dict], favorite_teams: List[str]
) -> List[Dict]:
"""
Single-pass game selection for recent games with proper deduplication.
When a game involves two favorite teams, it counts toward BOTH teams' limits.
Games are sorted by most recent first.
Teams are matched as _is_favorite_game matches them. Only a game with
an id can be a duplicate: two games without one are two games.
"""
sorted_games = sorted(
processed_games,
key=lambda g: g.get("start_time_utc")
or datetime.min.replace(tzinfo=timezone.utc),
reverse=True,
)
if not favorite_teams:
return sorted_games
selected_games = []
selected_ids = set()
team_counts: Dict[Optional[str], int] = {
code: 0 for code in map(self._favorite_code, favorite_teams) if code
}
for game in sorted_games:
game_id = game.get("id")
if game_id is not None and game_id in selected_ids:
continue
home = self._favorite_code(self._favorite_key(game, "home"))
away = self._favorite_code(self._favorite_key(game, "away"))
home_fav = home in team_counts
away_fav = away in team_counts
if not home_fav and not away_fav:
continue
home_needs = home_fav and team_counts[home] < self.recent_games_to_show
away_needs = away_fav and team_counts[away] < self.recent_games_to_show
if home_needs or away_needs:
selected_games.append(game)
if game_id is not None:
selected_ids.add(game_id)
if home_fav:
team_counts[home] += 1
if away_fav:
team_counts[away] += 1
self.logger.debug(
f"Selected recent game {game.get('away_abbr')}@{game.get('home_abbr')}: "
f"team_counts={team_counts}"
)
if all(c >= self.recent_games_to_show for c in team_counts.values()):
self.logger.debug("All favorite teams satisfied, stopping selection")
break
self.logger.info(
f"Selected {len(selected_games)} recent games for {len(favorite_teams)} "
f"favorite teams: {team_counts}"
)
return selected_games
__all__ = ["SportsFavoritesMixin", "SportsUpcomingFavoritesMixin", "SportsRecentFavoritesMixin"]
+125
View File
@@ -0,0 +1,125 @@
"""Whether a game ESPN still lists as live has in fact ended (sports family 5).
``SportsGameOverMixin._is_game_really_over`` is the scoreboards'
``SportsLive._is_game_really_over``, reconciled in ledmatrix-plugins
#625 from five bodies into one and copied here under
its existing name. ``SportsLiveSharedMixin._detect_stale_games``
(``src.common.sports_shared``) calls it on every live game, and the plugins'
live-priority filters call it too, to drop a game ESPN still reports as
in progress.
A game is over when its period text says final. From period ``FINAL_PERIOD``
on, a clock reading 0:00 ends it too, unless the score is level: a tie at the
end of regulation goes to overtime (or a shootout), and a game that does end
tied says final. Only a clock *string* is read ("0:00" and ":00" are zero;
":40", "0.0" and ESPN's "-" between MMA rounds are not), and a missing or
unreadable score leaves the decision to the clock.
``FINAL_PERIOD`` is the one per-sport fact, a class attribute rather than a
sport-name branch. The scoreboards declare it on their ``SportsLive``:
- 3: hockey;
- 4: basketball, football, lacrosse;
- ``None`` (this default; the clock never ends a game): afl, nrl and soccer,
whose clocks count up; baseball, which has innings; ufc, whose bouts end
only on ESPN's final status.
A sport can still override the method and defer to it, as baseball's
``BaseballLive`` does to end postponed and suspended games first.
A new module rather than another method on ``sports_shared``, for the reason
``sports_helpers`` gives: a missing module fails at load, where the version
checks see it; a missing method fails mid-update.
WHAT A HOST MUST PROVIDE
------------------------
Derived by walking every ``self.<attr>`` the mixin reads; the host-contract
test in ``test/test_sports_game_over.py`` fails if a read is added without
being listed here.
- ``logger`` -- a ``logging.Logger``; the method logs its verdict at DEBUG.
- ``FINAL_PERIOD`` -- defaulted here to ``None``; set it on the host class.
The method reads the game dict's ``away_abbr``, ``home_abbr``,
``period_text``, ``period``, ``clock``, ``away_score`` and ``home_score``
(``_extract_game_details_common``'s keys); any of them may be missing or
null.
BASE ORDER
----------
List the mixin before ``SportsLiveSharedMixin`` --
``class SportsLive(SportsGameOverMixin, SportsLiveSharedMixin, SportsCore)`` --
so the shared mixin's ``_detect_stale_games`` finds this method through the
MRO. Neither shared mixin defines it, so the order does not change which body
runs today; it keeps the method next to its caller should one ever be added
there. A method on the plugin's own class still wins, and its ``super()``
reaches this one. The mixin has no ``__init__`` and no state.
"""
import logging
from typing import Dict, Optional
class SportsGameOverMixin:
"""The live manager's "is this game really over?" check. See module docstring."""
# The host contract, declared for type checking only.
logger: logging.Logger
#: Period from which a 0:00 clock ends a game; None: the clock never does.
FINAL_PERIOD: Optional[int] = None
def _is_game_really_over(self, game: Dict) -> bool:
"""Whether a game ESPN still lists as live has in fact ended.
It has when its period text says final. From period ``FINAL_PERIOD``
on, a clock at 0:00 ends it too, unless the score is level: a tie at
the end of regulation goes to overtime, and a game that does end tied
says final. With ``FINAL_PERIOD = None`` the clock never ends a game.
"""
game_str = f"{game.get('away_abbr')}@{game.get('home_abbr')}"
# ESPN can send the key as null, and .get()'s default only covers a
# missing key, so a None here crashed the whole live update.
raw_period_text = game.get("period_text")
period_text = raw_period_text.lower() if isinstance(raw_period_text, str) else ""
if "final" in period_text:
self.logger.debug(
f"_is_game_really_over({game_str}): "
f"returning True - 'final' in period_text='{period_text}'"
)
return True
# Same for a null or non-numeric period: treat it as period 0.
try:
period = int(game.get("period") or 0)
except (TypeError, ValueError, OverflowError):
period = 0
# Only a clock string is read: "0:00" and ":00" are zero; ":40" is not.
clock = game.get("clock")
clock_at_zero = isinstance(clock, str) and clock.replace(":", "").strip() in ("000", "00")
if self.FINAL_PERIOD is not None and period >= self.FINAL_PERIOD and clock_at_zero:
try:
tied = int(game["away_score"]) == int(game["home_score"])
except (KeyError, TypeError, ValueError, OverflowError):
tied = False # a missing or unreadable score leaves it to the clock
if not tied:
self.logger.debug(
f"_is_game_really_over({game_str}): "
f"returning True - clock at 0:00 (clock='{clock}', period={period})"
)
return True
self.logger.debug(
f"_is_game_really_over({game_str}): "
f"returning False - tied at 0:00 (period={period}), overtime next"
)
return False
self.logger.debug(
f"_is_game_really_over({game_str}): returning False"
)
return False
__all__ = ["SportsGameOverMixin"]
+10 -12
View File
@@ -34,8 +34,9 @@ first core release that ships it (see ``CHANGELOG.md``).
``_favorite_key`` is the one method not taken from the plugins: it is the
override point from the since-removed ``src/base_classes`` sports core,
carried here so later phases (shared celebrations and game selection) have a
hardware-free home for the seam. No plugin defines it today and nothing in this module calls it.
carried here as the hardware-free home for the seam. ``sports_favorites``
calls it; nrl overrides it with the ESPN team id. Nothing in this module
calls it.
WHAT A HOST MUST PROVIDE
------------------------
@@ -47,8 +48,8 @@ listed here.
- ``mode_config`` (dict) and ``logger`` -- ``_setting_int``. ``league`` is
read with ``getattr`` for the warning text only.
- ``games_list`` and ``current_game_index`` -- ``_next_switch_index``; plus
``_is_favorite_game`` (called with a game), which stays per-plugin and is
only called when
``_is_favorite_game`` (called with a game; ``sports_favorites`` has the
shared body), only called when
``favorite_rotation_boost`` is above 1. ``favorite_rotation_boost`` itself
defaults to 1 on the mixin.
- ``last_game_switch`` -- ``_reset_dwell_on_reentry``, read with ``getattr``
@@ -216,15 +217,12 @@ class SportsHelpersMixin:
rather than a branch so core never has to learn the string "nrl"::
def _favorite_key(self, game, side):
return str(game.get(f"{side}_id"))
team_id = game.get(f"{side}_id")
return None if team_id is None else str(team_id)
An override that stringifies should note that a missing id becomes the
literal ``"None"``, which would spuriously match a favorites list
containing that string. The default returns ``None`` for a missing
abbreviation, which never matches.
Carried from the since-removed ``src/base_classes`` sports core for
later phases; nothing in this module calls it yet.
It returns ``None`` for a missing id rather than ``str(None)``, which
would match a favourite typed "None". A ``None`` never matches.
``sports_favorites`` compares the value stripped and upper-cased.
"""
return game.get(f"{side}_abbr")
+55 -7
View File
@@ -57,7 +57,9 @@ Methods that stay per-plugin, because they are not identical across the eight
``_get_layout_offset``, ``_by_importance``, ``_other_games_window``,
``_upcoming_date_and_time_text``, ``_extract_game_details_common``,
``_load_division_team_ids``, ``_get_timezone``, ``_is_favorite_game``,
``_is_game_really_over``, ``_is_ranked_game``, ``_passes_other_filters``.
``_is_ranked_game``, ``_passes_other_filters``. (``_is_game_really_over``,
which ``_detect_stale_games`` below calls, was here too until the plugins
reconciled it; it is now ``src.common.sports_game_over``.)
Of the fourteen shared class constants, thirteen are identical everywhere and
live here. Only ``_SCORE_PROBE_TEXT`` varies -- afl and basketball reach three digits
@@ -120,6 +122,32 @@ _DEFAULT_LIVE_IDLE_MAX_SECONDS = 900
_KICKOFF_GRACE_SECONDS = 900
#: Fallback cadence around a kickoff when the manager has no update_interval.
_KICKOFF_POLL_FLOOR = 30
#: How many kickoffs after the current one a live manager remembers. Only the
#: earliest few can matter before the next look refreshes the list, so this
#: bounds the memory without dropping a kickoff the board would wait for.
_KICKOFF_QUEUE_MAX = 8
def _current_scheduled_start(host: Any, now: float) -> Optional[float]:
"""The kickoff a live manager is honouring now, promoting the next queued one.
``_next_scheduled_start_ts`` is the kickoff being honoured: the earliest
one ahead of us, or one that has just passed and is inside its grace.
Kickoffs behind it wait in ``_later_scheduled_starts``. When the current
one's grace runs out, the earliest queued kickoff that is not itself past
its grace takes over -- including one that has already passed, so a
second kickoff inside the first one's grace still gets a grace of its own.
"""
current: Optional[float] = getattr(host, "_next_scheduled_start_ts", None)
if current and current > now - _KICKOFF_GRACE_SECONDS:
return current
queued: Optional[List[float]] = getattr(host, "_later_scheduled_starts", None)
if queued:
alive = sorted(s for s in queued if s > now - _KICKOFF_GRACE_SECONDS)
current = alive.pop(0) if alive else None
host._later_scheduled_starts = alive
host._next_scheduled_start_ts = current
return current if current and current > now - _KICKOFF_GRACE_SECONDS else None
def _resolve_font_path(path: str) -> str:
@@ -1294,11 +1322,11 @@ class SportsLiveSharedMixin:
otherwise look like another empty check and escalate the back-off
again, right when the game is actually starting.
"""
start = getattr(self, "_next_scheduled_start_ts", None)
now = time.time()
start = _current_scheduled_start(self, now)
if not start:
return interval
live = getattr(self, "update_interval", None) or _KICKOFF_POLL_FLOOR
now = time.time()
if now < start:
return max(live, min(interval, int(start - now)))
if now - start <= _KICKOFF_GRACE_SECONDS:
@@ -1313,6 +1341,16 @@ class SportsLiveSharedMixin:
already has. Self-correcting: a stored start that has passed is
replaced by the next one offered, so a postponed game cannot pin the
cadence to a kickoff that never happens.
Every pending kickoff is honoured, not just the first. A kickoff that
arrives while an earlier one is inside its grace is queued in
``_later_scheduled_starts`` (the earliest _KICKOFF_QUEUE_MAX of them)
and takes over when that grace ends, with a grace of its own. Keeping
only the one kickoff dropped the second of two favourites starting
within the grace of each other: it was refused while the first held
the slot, and refused again once it had passed, so if ESPN had not
flipped it live by the end of the first grace the back-off went
straight back to its ceiling and the game was noticed up to that late.
"""
if not isinstance(details, dict):
return
@@ -1329,7 +1367,7 @@ class SportsLiveSharedMixin:
now = time.time()
if candidate <= now:
return
current = getattr(self, "_next_scheduled_start_ts", None)
current = _current_scheduled_start(self, now)
# A kickoff that has only just passed is *kept*, not replaced by the
# next one on the card. Replacing it immediately is what made the grace
# window in _clamp_to_scheduled_start dead code: the moment 13:00 came
@@ -1339,10 +1377,20 @@ class SportsLiveSharedMixin:
# polled at 13:00:45, found nothing live because ESPN had not flipped
# the status yet, and then went quiet for the next quarter of an hour,
# which is the behaviour this whole clamp exists to prevent.
if (current is None
or current <= now - _KICKOFF_GRACE_SECONDS
or candidate < current):
#
# Nor is it forgotten: whichever kickoff loses is queued behind the
# one honoured now, so it gets its own grace when that one's ends.
if current is None:
self._next_scheduled_start_ts = candidate
return
if candidate == current:
return
if candidate < current:
self._next_scheduled_start_ts, candidate = candidate, current
queued = getattr(self, "_later_scheduled_starts", None) or []
if candidate not in queued:
self._later_scheduled_starts = sorted(
[*queued, candidate])[:_KICKOFF_QUEUE_MAX]
#: How long a game that finished live is still reported by
#: finished_games_snapshot(): long enough for the recent-games list, which
+404 -39
View File
@@ -1,41 +1,56 @@
"""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()``.
``DisplayController.run()`` gathers 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
The 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.
Every Source but Vegas is decided here (stage 3). Vegas is the ``LEGACY``
plan: the Arbiter picks it, but its iteration is still run()'s own code
until stage 4.
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.
A pass asks twice: once with the inputs every pass reads (the gate,
Follower, OnDemand, Wifi), and once more, only when nothing above the
notice took the panel, with the inputs the Sources below it need (whether
Vegas is on, the live-priority scan), read where run() always read them.
``decide(..., running=plan)`` is the other question, asked by the
ScreenRunner (src/screen_runner.py) at its service points: does a Source
in ``plan.preemptible_by`` now take the panel from the screen that is
running? The mid-screen rules are :func:`_hold_or_preempt`.
The state transitions (the next on-demand mode, a live claim and its
release, the rotation's step after a screen) are pure methods of
:class:`ArbiterState`; the controller applies what they return.
"""
from dataclasses import dataclass
from dataclasses import dataclass, replace
from enum import Enum
from typing import Optional
from typing import FrozenSet, Optional, Protocol, Tuple
__all__ = [
"Arbiter",
"ArbiterInputs",
"ArbiterState",
"FramePolicy",
"LIVE_PREEMPTERS",
"SCHEDULED_OFF_DWELL",
"SCREEN_PREEMPTERS",
"ScreenEnd",
"ScreenPlan",
"Source",
"WIFI_NOTICE_DWELL",
"WifiNotice",
"live_pick",
"live_takeover",
"on_demand_bound",
"rotation_plan",
"wifi_notice_preempts",
]
@@ -53,11 +68,41 @@ class Source(Enum):
SCHEDULED_OFF = "scheduled-off"
FOLLOWER = "follower"
ON_DEMAND = "on-demand"
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.
LIVE = "live"
# Vegas: the Arbiter picks it, but its iteration is still run()'s own
# code (and its interrupt callback a second copy of this order) until
# stage 4 makes it a Source driven frame by frame.
LEGACY = "legacy"
ROTATION = "rotation"
# Not a screen: a plugin reload waits at the top of the loop. It ends a
# screen between frames (the screen counts as shown and the rotation
# moves on), and the next pass reloads before it draws.
RELOAD = "reload"
class FramePolicy(Enum):
"""How often a screen draws: today's two frame loops (see
DisplayController._needs_high_fps). Stage 5 lets plugins declare it."""
#: The 125 Hz loop, paced to an 8 ms deadline: scrolling plugins.
HIGH_FPS = "high-fps"
#: The 1 Hz loop.
STATIC = "static"
class ScreenEnd(Protocol):
"""What ArbiterState.after needs to know about how a screen ended
(screen_runner.Outcome, filled in by the controller)."""
@property
def on_demand_active(self) -> bool:
"""An on-demand session was running when the screen ended."""
@property
def still_live(self) -> bool:
"""The mode's plugin still had live content: hold the rotation."""
@dataclass(frozen=True)
@@ -76,11 +121,122 @@ class WifiNotice:
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.
A snapshot of the controller's own fields, taken when decide() is
called (DisplayController._arbiter_state); the transitions below return
the next state, and the controller writes it back.
Attributes:
current_mode: The mode on the panel or about to be
(``current_display_mode``).
on_demand_modes: The on-demand session's modes, in the order it
shows them (a pinned mode already moved to the front when the
session started, by _apply_on_demand_pin).
on_demand_index: Which of them is showing.
on_demand_expires_at: When the session ends (wall clock), or None
for a session with no duration.
on_demand_pinned: The session was started pinned. Carried for the
snapshot; the pin itself is already in ``on_demand_modes``.
rotation: The rotation's modes (``available_modes``).
rotation_index: Where the rotation is (``current_mode_index``).
live_resume_index: Where the rotation was when live priority took
the panel, so it resumes there once nothing is live; None while
live priority holds nothing.
live_takeover_unshown: A mid-screen takeover chose current_mode and
it has not been shown yet, so the next pass must not advance the
live round-robin past it.
"""
current_mode: Optional[str] = None
on_demand_modes: Tuple[str, ...] = ()
on_demand_index: int = 0
on_demand_expires_at: Optional[float] = None
on_demand_pinned: bool = False
rotation: Tuple[str, ...] = ()
rotation_index: int = 0
live_resume_index: Optional[int] = None
live_takeover_unshown: bool = False
def next_on_demand(self) -> "ArbiterState":
"""The session's next mode, wrapping round. Needs a mode list."""
index = (self.on_demand_index + 1) % len(self.on_demand_modes)
return replace(self, on_demand_index=index,
current_mode=self.on_demand_modes[index])
def claim_live(self, mode: str) -> "ArbiterState":
"""Live priority takes the panel for ``mode``.
The rotation's position is saved only on the first claim, not on
each re-check while the hold continues, so it resumes where live
priority interrupted it instead of after the live mode (which would
skip every mode between the two).
"""
if self.current_mode == mode:
return self
resume = self.rotation_index if self.live_resume_index is None else self.live_resume_index
index = self.rotation.index(mode) if mode in self.rotation else self.rotation_index
return replace(self, current_mode=mode, rotation_index=index,
live_resume_index=resume)
def after(self, outcome: "ScreenEnd") -> "ArbiterState":
"""The state once a screen has run its course: the next mode.
An on-demand session moves to its next mode. Otherwise the rotation
advances -- unless the mode just shown is a live-priority mode that
is still live, which holds the panel. A session with no modes left
is ended by the controller before it asks (that is not pure: it
resumes the rotation and clears the cache).
"""
if outcome.on_demand_active:
return self.next_on_demand() if self.on_demand_modes else self
if outcome.still_live or not self.rotation:
return self
index = (self.rotation_index + 1) % len(self.rotation)
return replace(self, rotation_index=index, current_mode=self.rotation[index])
def release_live(self) -> "ArbiterState":
"""Nothing is live any more: the rotation resumes where it was."""
if self.live_resume_index is None or not self.rotation:
return self
index = self.live_resume_index % len(self.rotation)
return replace(self, current_mode=self.rotation[index], rotation_index=index,
live_resume_index=None)
def showing(self, plan: "ScreenPlan") -> "ArbiterState":
"""The state once ``plan`` is on the panel.
An on-demand plan puts the session's index on the mode it shows (an
index past the end of a shortened list starts it again at 0).
"""
state = replace(self, current_mode=plan.mode)
if plan.source is Source.ON_DEMAND and self.on_demand_modes:
state = replace(state, on_demand_index=_on_demand_index(self))
return state
def _on_demand_index(state: ArbiterState) -> int:
"""The session's index, or 0 once it is past the end of its list."""
index = state.on_demand_index
return index if index < len(state.on_demand_modes) else 0
def on_demand_bound(min_duration: float, max_duration: float,
deadline: Optional[float],
now: float) -> Optional[Tuple[float, float]]:
"""Shorten a screen's (min, max) seconds to what is left of a timed
on-demand session ending at ``deadline``. None when nothing is left.
The OnDemand Source's bound, applied after the screen's first frame,
where it always was (``now`` is read then).
"""
if deadline is None:
return min_duration, max_duration
remaining = max(0.0, deadline - now)
min_duration = min(min_duration, remaining)
max_duration = min(max_duration, remaining)
if max_duration <= 0:
return None
return min_duration, max_duration
@dataclass(frozen=True)
class ArbiterInputs:
@@ -95,57 +251,122 @@ class ArbiterInputs:
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.
live_modes: The modes with live content, from a live-priority scan,
in registration order; None when no scan was made (on-demand,
Vegas keeping live content in its ticker, a throttled
mid-screen check). A scan asks every live-priority plugin, so it
is made only where run() always made it.
vegas_enabled: Vegas mode is on (and no on-demand session holds it
off).
vegas_live_in_ticker: Vegas keeps live content in its ticker
instead of yielding the panel to it.
vegas_yielded: This pass's Vegas iteration has run and yielded, so
the Vegas Source passes and the screen it fell through to is
decided.
reload_pending: Mid-screen only: a plugin reload is waiting for the
top of the loop, at a service point where that ends the screen.
"""
schedule_on: bool
on_demand_active: bool
follower_active: bool
wifi_notice: Optional[WifiNotice] = None
live_modes: Optional[Tuple[str, ...]] = None
vegas_enabled: bool = False
vegas_live_in_ticker: bool = False
vegas_yielded: bool = False
reload_pending: bool = False
@dataclass(frozen=True)
class ScreenPlan:
"""The Arbiter's answer for one pass.
decide() is pure, so it cannot ask a plugin anything: the fields a
plugin answers (its durations, whether it runs a dynamic cycle, how
often it draws) are filled in by the controller after the screen's first
frame, when they have always been read (DisplayController.complete_plan).
Attributes:
source: The Source that gets the panel.
mode: The display mode to draw (None for a blank, follower or notice).
plugin: The id of the plugin drawing ``mode``, once resolved.
min_duration: Seconds the screen runs at least (dynamic duration).
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.
None when the Source paces itself (a follower frame, Vegas), and
for a rotation or live plan until its first frame.
dynamic: Run until the plugin's cycle completes, between min and max.
frame_policy: Which frame loop the screen runs.
preemptible_by: The Sources that may end the screen mid-way.
notice: The WiFi notice to draw, for a WIFI plan.
deadline: For an on-demand plan, when the session ends (wall
clock): after the first frame the screen's durations are cut to
what is left (:func:`on_demand_bound`).
ends_live: Nothing is live any more and live priority had
interrupted the rotation: taking this plan resumes the rotation
where it was (ArbiterState.release_live) before it shows.
"""
source: Source
mode: Optional[str] = None
plugin: Optional[str] = None
min_duration: Optional[float] = None
max_duration: Optional[float] = None
dynamic: bool = False
frame_policy: Optional[FramePolicy] = None
preemptible_by: FrozenSet[Source] = frozenset()
notice: Optional[WifiNotice] = None
deadline: Optional[float] = None
ends_live: bool = False
SCHEDULED_OFF_PLAN = ScreenPlan(Source.SCHEDULED_OFF, max_duration=SCHEDULED_OFF_DWELL)
FOLLOWER_PLAN = ScreenPlan(Source.FOLLOWER)
LEGACY_PLAN = ScreenPlan(Source.LEGACY)
RELOAD_PLAN = ScreenPlan(Source.RELOAD)
#: What may end a screen mid-way: the schedule, an on-demand session
#: starting or ending, a WiFi notice, a live game, the rotation moving
#: under the screen, and a plugin reload. Not a follower or Vegas: those
#: are only looked at between screens.
SCREEN_PREEMPTERS: FrozenSet[Source] = frozenset(
{Source.SCHEDULED_OFF, Source.ON_DEMAND, Source.WIFI, Source.LIVE, Source.ROTATION,
Source.RELOAD})
#: A live screen is not preempted by Live: live games take turns between
#: screens, never mid-screen.
LIVE_PREEMPTERS: FrozenSet[Source] = SCREEN_PREEMPTERS - {Source.LIVE}
class Arbiter:
"""Decides which Source gets the panel. Stateless; see the module docstring."""
@staticmethod
def decide(state: ArbiterState, inputs: ArbiterInputs, now: float) -> ScreenPlan:
def decide(state: ArbiterState, inputs: ArbiterInputs, now: float,
running: Optional[ScreenPlan] = None) -> ScreenPlan:
"""The plan for this pass, from the Sources in priority order.
With ``running``, the question is the ScreenRunner's at one of its
service points instead: does a Source in ``running.preemptible_by``
now take the panel from that screen? The answer is ``running``
itself (the same object) while it holds, else the plan that ends it.
See :func:`_hold_or_preempt` for the rules.
Args:
state: What the Arbiter remembers between passes (nothing yet).
state: What the Arbiter remembers between passes.
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).
now: Wall-clock time of the snapshot. The OnDemand Source reads
it for what is left of a timed session, and the mid-screen
WiFi rule (:func:`wifi_notice_preempts`) to compare with the
notice's expiry. The top-of-pass WiFi check does not: it
takes the notice as read.
running: The screen on the panel, for a mid-screen check.
Returns:
The winning Source's plan, or LEGACY_PLAN when the winner is one
run() still decides itself.
The winning Source's plan (LEGACY for Vegas), or ``running``.
"""
del state, now # not read by the stage-2 Sources; see the docstring
if running is not None:
return _hold_or_preempt(state, inputs, now, running)
# ScheduledOff is a gate, not a Source: a scheduled-off panel stays
# blank even for a follower, and only an on-demand session overrides
@@ -157,17 +378,161 @@ class Arbiter:
if inputs.follower_active:
return FOLLOWER_PLAN
# 2. OnDemand: decided by run() until stage 3. It outranks the notice.
# 2. OnDemand: the session's current mode. It outranks the notice.
if inputs.on_demand_active:
return LEGACY_PLAN
return _on_demand_plan(state, now)
# 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
# 4. Live: the next live game, round-robin across several. With
# nothing live, a rotation that live priority interrupted resumes.
ends_live = False
if _live_applies(inputs):
pick = live_pick(inputs.live_modes, state.current_mode,
advance=not state.live_takeover_unshown)
if pick is not None:
return ScreenPlan(Source.LIVE, mode=pick, preemptible_by=LIVE_PREEMPTERS)
ends_live = state.live_resume_index is not None and bool(state.rotation)
# 5. Vegas: one iteration of the ticker, run by run()'s own code
# until stage 4. Passes once this pass's iteration has yielded.
if inputs.vegas_enabled and not inputs.vegas_yielded:
return ScreenPlan(Source.LEGACY, ends_live=ends_live)
# 6. Rotation: the rotation's current mode (after the resume, when
# live priority just ended).
return rotation_plan(state.release_live() if ends_live else state,
ends_live=ends_live)
def _hold_or_preempt(state: ArbiterState, inputs: ArbiterInputs, now: float,
running: ScreenPlan) -> ScreenPlan:
"""The mid-screen rules: ``running``, or the plan that ends it.
What the frame loops used to check one by one (_check_live_takeover,
then _screen_preempted with _wifi_notice_pending in it, before stage 3),
in their order:
1. Live: a game went live while a non-live screen runs (the inputs
carry a scan only when one was due, at most once a second). Checked
first because it is the one preemption that changes the state -- the
rotation moves to the live mode and remembers where it was -- and it
still happens when a WiFi notice is also pending: the next pass then
shows the notice, and the game after it.
2. The panel's mode moved under the screen: an on-demand session
started, ended or changed mode, or the rotation was rebuilt (a
plugin enabled, disabled or reloaded).
3. The schedule turned the panel off.
4. A WiFi notice arrived (unless on-demand outranks it), compared with
its expiry because the read throttle can hand back a stale one.
5. A plugin reload is waiting at the top of the loop.
A follower and Vegas are never mid-screen preemptions; they are looked
at between screens.
"""
by = running.preemptible_by
if Source.LIVE in by:
takeover = live_takeover(state, inputs)
if takeover is not None:
return ScreenPlan(Source.LIVE, mode=takeover, preemptible_by=LIVE_PREEMPTERS)
if state.current_mode != running.mode:
source = Source.ON_DEMAND if inputs.on_demand_active else Source.ROTATION
if source in by:
return ScreenPlan(source, mode=state.current_mode, preemptible_by=SCREEN_PREEMPTERS)
if (Source.SCHEDULED_OFF in by and not inputs.schedule_on
and not inputs.on_demand_active):
return SCHEDULED_OFF_PLAN
notice = inputs.wifi_notice
if (Source.WIFI in by and notice is not None
and wifi_notice_preempts(notice, inputs.on_demand_active, now)):
return ScreenPlan(Source.WIFI, max_duration=WIFI_NOTICE_DWELL, notice=notice)
if Source.RELOAD in by and inputs.reload_pending:
return RELOAD_PLAN
return running
def live_takeover(state: ArbiterState, inputs: ArbiterInputs) -> Optional[str]:
"""The live mode that takes the panel mid-screen, or None.
The first live mode, when a scan found one and the panel is not on a
live mode already. Never while on-demand holds the panel, while it is
scheduled off, or while Vegas keeps live content in its ticker.
"""
if not _live_applies(inputs) or inputs.on_demand_active or not inputs.schedule_on:
return None
live = inputs.live_modes
if not live or state.current_mode in live:
return None
return live[0]
def _on_demand_plan(state: ArbiterState, now: float) -> ScreenPlan:
"""The OnDemand Source: the session's current mode.
``max_duration`` is what is left of a timed session at ``now`` (None
without a duration); ``deadline`` carries the expiry so the bound can be
applied again after the first frame. A session with no modes left (its
plugin was unloaded under it) gets a plan with no mode: the controller
ends the session and shows the rotation's mode instead.
"""
modes = state.on_demand_modes
if not modes:
return ScreenPlan(Source.ON_DEMAND)
expires_at = state.on_demand_expires_at
remaining = None if expires_at is None else max(0.0, expires_at - now)
return ScreenPlan(Source.ON_DEMAND, mode=modes[_on_demand_index(state)],
max_duration=remaining, deadline=expires_at,
preemptible_by=SCREEN_PREEMPTERS)
def rotation_plan(state: ArbiterState, ends_live: bool = False) -> ScreenPlan:
"""The Rotation Source: the mode the rotation is on.
That is ``state.current_mode``, which is ``rotation[rotation_index]``
except where something moved the panel off the list and the rotation
carries on from there: a live mode no rotation entry names, or None
when a session ended with no enabled mode to resume to.
"""
return ScreenPlan(Source.ROTATION, mode=state.current_mode, ends_live=ends_live,
preemptible_by=SCREEN_PREEMPTERS)
def _live_applies(inputs: ArbiterInputs) -> bool:
"""Whether the Live Source has a say: a scan was made, and Vegas is not
keeping live content in its ticker (where the live plugin takes extra
turns in the marquee instead of the panel)."""
if inputs.live_modes is None:
return False
return not (inputs.vegas_enabled and inputs.vegas_live_in_ticker)
def live_pick(live_modes: Optional[Tuple[str, ...]], current_mode: Optional[str],
advance: bool) -> Optional[str]:
"""The live mode to show, or None when nothing is live.
When several plugins are live at once this round-robins between them, so
the panel alternates each dwell instead of pinning to the first one
registered. The mode on the panel is the cursor, so this stays right as
games start and end.
Args:
live_modes: The live modes, in registration order.
current_mode: The mode on the panel.
advance: True for the rotation's pick (the live mode after the one
showing). False for a peek (the one showing if it is still live,
else the first), which Vegas uses to ask whether anything is.
"""
if not live_modes:
return None
if current_mode in live_modes:
if advance:
index = live_modes.index(current_mode)
return live_modes[(index + 1) % len(live_modes)]
return current_mode
return live_modes[0]
def wifi_notice_preempts(notice: Optional[WifiNotice], on_demand_active: bool,
+843 -541
View File
File diff suppressed because it is too large Load Diff
+52 -2
View File
@@ -317,6 +317,11 @@ class DisplayManager:
# is handed to the writer; this only once it has been saved, so an
# mtime touch never vouches for a frame still waiting to be written.
self._saved_snapshot_digest: Optional[int] = None
# A changed frame reached _write_snapshot_if_due() inside the write
# interval and was skipped. Nothing writes it unless update_display()
# runs again, and a screen that draws once and holds never calls it
# again -- see write_owed_snapshot().
self._snapshot_owed = False
self._snapshot_dir_prepared = False
# Background writer used mid-scroll; see _write_snapshot_if_due.
self._snapshot_cond = threading.Condition()
@@ -1788,9 +1793,10 @@ class DisplayManager:
if frame_checksum is not None:
digest = frame_checksum
frame_changed = digest != self._last_snapshot_digest
action = snapshot_policy.decide(
now, self._last_snapshot_ts, self._last_snapshot_touch_ts,
viewer_fresh, digest != self._last_snapshot_digest)
viewer_fresh, frame_changed)
else:
# Ask as if the frame had changed before paying to find out.
# decide() is monotone in frame_changed -- a SKIP for a
@@ -1802,22 +1808,36 @@ class DisplayManager:
now, self._last_snapshot_ts, self._last_snapshot_touch_ts,
viewer_fresh, True)
if action is snapshot_policy.SnapshotAction.SKIP:
# Not hashed, so not known to be unchanged: owed until a
# later look finds it written or unchanged.
self._snapshot_owed = True
return
digest = zlib.adler32(self.image.tobytes())
if digest == self._last_snapshot_digest:
frame_changed = digest != self._last_snapshot_digest
if not frame_changed:
# Unchanged after all: the decision an unchanged frame gets.
action = snapshot_policy.decide(
now, self._last_snapshot_ts,
self._last_snapshot_touch_ts, viewer_fresh, False)
if action is snapshot_policy.SnapshotAction.SKIP:
# A changed frame inside the write interval stays owed: the
# next update_display() would write it, but a static screen
# may not make one -- write_owed_snapshot() covers that.
self._snapshot_owed = frame_changed
return
if (action is snapshot_policy.SnapshotAction.TOUCH
and self._saved_snapshot_digest == digest):
# mtime bump only: keeps the health check (snapshot age)
# green without paying for a PNG encode of an unchanged frame
# (this frame is already on disk, so nothing is owed).
self._snapshot_owed = False
os.utime(self._snapshot_path, None)
self._last_snapshot_touch_ts = now
return
# Owed until the write below succeeds: if it raises, the frame
# stays owed and write_owed_snapshot() retries it, rather than a
# held screen leaving the preview stale after one failed write.
self._snapshot_owed = True
# (A TOUCH for a frame that isn't on disk yet -- still queued, or
# its write failed -- is written instead: touching would make the
# older file on disk look current.)
@@ -1842,9 +1862,39 @@ class DisplayManager:
self._last_snapshot_ts = now
self._last_snapshot_touch_ts = now
self._last_snapshot_digest = digest
self._snapshot_owed = False
except Exception as e:
self._log_snapshot_failure(e)
def write_owed_snapshot(self) -> None:
"""Write a frame the snapshot throttle skipped, once it is due.
The preview snapshot is only ever written from update_display(), and
at most once per write interval (snapshot_policy). A frame pushed
inside that interval is skipped, and is written by the next
update_display() that comes after it -- but a screen that draws its
card once and then holds it makes no further call. Its frame was on
the panel and never in the preview: soccer's recent/upcoming cards
skip redundant redraws, and the first one after an on-demand start
(pushed a few milliseconds after the controller's clear) left
/api/v3/display/current and the web preview black for the whole
screen while the panel showed the card.
The render loop calls this after each frame. Cheap when nothing is
owed (one attribute read); otherwise the usual policy decides, so
the write still waits out the interval and an unchanged frame is
never re-encoded.
"""
if not self._snapshot_owed:
return
try:
if self._writes_suppressed():
return
with self._update_lock:
self._write_snapshot_if_due()
except Exception as e: # pylint: disable=broad-except
self._log_snapshot_failure(e)
def _log_snapshot_failure(self, error: Exception) -> None:
# Snapshot failures must never break display — but they must not
# be silent either: the snapshot's mtime is the web UI's display
+125 -31
View File
@@ -490,10 +490,13 @@ def record_error(
# user reads, and the other way round for the clear request.
#
# ERROR_SNAPSHOT_KEY written by the display service only
# ERROR_CLEAR_REQUEST_KEY written by the web interface only
# ERROR_CLEAR_REQUEST_KEY written by the web interface only, as a fallback
#
# A clear is asynchronous: the web interface records a request, and the
# display service applies it (clear_before) on its next tick and republishes.
# A clear goes over the control socket (``errors.clear``): the display applies
# it (clear_before) and republishes the snapshot before it answers. Only when
# the socket cannot carry it (no socket, or a display older than the command)
# does the web interface record a request in the mailbox, which the display
# applies on its next tick; its tick reads that file only when it changed.
# Until it has, the web interface hides whatever the snapshot shows from
# before the cutoff, so a clear takes effect for readers immediately and a
# snapshot published just before the request cannot bring old errors back.
@@ -598,13 +601,43 @@ class ErrorSnapshotPublisher:
self._published_version: Optional[int] = None
self._last_attempt: Optional[float] = None
self._applied_clear_id: Optional[str] = None
# The widest cutoff applied in this process: a clear request at or
# before it has nothing left to clear (see _pending_cutoff).
self._applied_clear_cutoff: Optional[float] = None
from src.cache_manager import MailboxWatch # the display's cache, loaded already
self._mailbox = MailboxWatch(ERROR_CLEAR_REQUEST_KEY)
self._tick_lock = threading.Lock()
self._stop = threading.Event()
self._thread: Optional[threading.Thread] = None
def _clear(self, request_id: str, cutoff: float) -> int:
"""Apply one clear and remember it. Caller holds _tick_lock."""
cleared = 0
if math.isfinite(cutoff):
cleared = self.aggregator.clear_before(datetime.fromtimestamp(cutoff))
_snapshot_logger.info("Cleared %d plugin error record(s) as requested (%s)",
cleared, request_id)
if self._applied_clear_cutoff is None or cutoff > self._applied_clear_cutoff:
self._applied_clear_cutoff = cutoff
# A malformed request is acknowledged too, so it is not retried forever.
self._applied_clear_id = request_id
return cleared
def _apply_clear_request(self) -> bool:
"""Honour a clear request we have not applied yet. True if one was."""
request = self.cache_manager.get(ERROR_CLEAR_REQUEST_KEY, max_age=None, memory_ttl=0)
"""Honour a mailbox clear request we have not applied yet. True if one was.
The mailbox is the fallback for a web interface that could not use
the control socket (``errors.clear``, :meth:`clear_now`). It is read
only when its file changed since the last tick; otherwise a tick
costs one stat().
"""
if not self._mailbox.changed(self.cache_manager):
return False
try:
request = self.cache_manager.get(ERROR_CLEAR_REQUEST_KEY, max_age=None, memory_ttl=0)
except Exception:
self._mailbox.forget()
raise
if not isinstance(request, dict):
return False
request_id = request.get("request_id")
@@ -614,14 +647,30 @@ class ErrorSnapshotPublisher:
cutoff = float(request.get("cutoff"))
except (TypeError, ValueError):
cutoff = float("nan")
if math.isfinite(cutoff):
cleared = self.aggregator.clear_before(datetime.fromtimestamp(cutoff))
_snapshot_logger.info("Cleared %d plugin error record(s) as requested (%s)",
cleared, request_id)
# A malformed request is acknowledged too, so it is not retried forever.
self._applied_clear_id = request_id
self._clear(request_id, cutoff)
return True
def clear_now(self, request_id: str, cutoff: float) -> int:
"""``errors.clear`` over the control socket: apply a clear at once and
republish the snapshot, so the web interface's next read has it.
Returns how many records were cleared. Raises when the snapshot
could not be written, so the caller is not told it worked."""
with self._tick_lock:
cleared = self._clear(request_id, float(cutoff))
self._publish(self.aggregator.version, self._clock())
return cleared
def _publish(self, version: int, now: float) -> None:
"""Write the snapshot. Caller holds _tick_lock."""
# Stamp the attempt before writing: a cache that keeps failing
# is retried at the throttled rate, not on every tick.
self._last_attempt = now
snapshot = self.aggregator.build_snapshot()
snapshot["applied_clear_id"] = self._applied_clear_id
snapshot["applied_clear_cutoff"] = self._applied_clear_cutoff
self.cache_manager.set(ERROR_SNAPSHOT_KEY, snapshot)
self._published_version = version
def tick(self) -> bool:
"""Apply a pending clear and publish if due. True if a snapshot was written."""
with self._tick_lock:
@@ -635,13 +684,7 @@ class ErrorSnapshotPublisher:
if (self._last_attempt is not None
and now - self._last_attempt < self.min_interval):
return False
# Stamp the attempt before writing: a cache that keeps failing
# is retried at the throttled rate, not on every tick.
self._last_attempt = now
snapshot = self.aggregator.build_snapshot()
snapshot["applied_clear_id"] = self._applied_clear_id
self.cache_manager.set(ERROR_SNAPSHOT_KEY, snapshot)
self._published_version = version
self._publish(version, now)
return True
except Exception as err: # never let reporting break the display
_snapshot_logger.debug("Could not publish the plugin error snapshot: %s",
@@ -693,6 +736,22 @@ def start_error_snapshot_publisher(cache_manager: Any) -> Optional[ErrorSnapshot
return None
def apply_error_clear(request_id: str, args: Any) -> Dict[str, Any]:
"""The display's handler for ``errors.clear`` on the control socket.
``args`` is the contract's ErrorsClearArgs (``cutoff``, epoch seconds).
Runs on the socket's connection thread: the aggregator and the publisher
have their own locks, and nothing here touches rendering. Returns
ErrorsClearResult once the clear is applied and the snapshot rewritten.
"""
publisher = _snapshot_publisher
if publisher is None:
raise RuntimeError("the error snapshot publisher is not running")
cutoff = float(args.cutoff)
cleared = publisher.clear_now(request_id, cutoff)
return {"request_id": request_id, "cutoff": cutoff, "cleared": cleared}
# --- Reading side (web interface) -------------------------------------------
def read_error_report(cache_manager: Any) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
@@ -731,7 +790,15 @@ def _pending_cutoff(snapshot: Optional[Dict[str, Any]],
cutoff = float(clear_request.get("cutoff"))
except (TypeError, ValueError):
return None
return cutoff if math.isfinite(cutoff) else None
if not math.isfinite(cutoff):
return None
# A wider clear has been applied since (over the control socket): this
# older request has nothing left to hide.
applied = snapshot.get("applied_clear_cutoff") if snapshot is not None else None
if (isinstance(applied, (int, float)) and not isinstance(applied, bool)
and applied >= cutoff):
return None
return cutoff
def _is_after(item: Any, field_name: str, cutoff: float) -> bool:
@@ -831,13 +898,31 @@ def _count_cleared(summary: Dict[str, Any], cutoff: float) -> Optional[int]:
return None
def request_error_clear(cache_manager: Any, cutoff: float) -> Dict[str, Any]:
#: ``send(request_id, cutoff)`` hands a clear to the display over the control
#: socket and returns its ErrorsClearResult, or None when the socket could
#: not carry it and the mailbox should be written instead. Any exception it
#: raises reaches the caller: the display had the request and failed it.
ClearSender = Callable[[str, float], Optional[Dict[str, Any]]]
def request_error_clear(cache_manager: Any, cutoff: float,
send: Optional[ClearSender] = None) -> Dict[str, Any]:
"""Ask the display service to forget errors recorded at or before ``cutoff``.
Returns ``request_id``, ``cutoff`` (ISO, local time), ``cleared_count``
(see _count_cleared) and ``clear_requested``. Raises OSError when the
request did not reach the shared cache, since a cache without a usable
directory accepts set() and keeps nothing.
Over the control socket when ``send`` is given and carries it: the
display applies the clear and republishes its snapshot before it
answers, so nothing is written here. Otherwise (no socket, or a display
older than ``errors.clear``) a request is written to the
``plugin_error_clear_request`` mailbox, which the display applies on
its next tick, and readers hide the cleared errors until then.
Returns ``request_id``, ``cutoff`` (ISO, local time), ``cleared_count``,
``clear_requested``, ``applied`` (the display has already cleared them)
and ``transport`` (``socket`` or ``mailbox``). ``cleared_count`` is the
display's own count over the socket, else an estimate from the snapshot
(see _count_cleared). Raises OSError when a mailbox request did not reach
the shared cache, since a cache without a usable directory accepts set()
and keeps nothing.
A request the display has not applied yet is only ever widened: a later,
narrower one ("older than 24 hours" after "everything") overwriting it
@@ -848,8 +933,21 @@ def request_error_clear(cache_manager: Any, cutoff: float) -> Dict[str, Any]:
if pending is not None:
cutoff = max(cutoff, pending)
before = error_summary_from_report(snapshot, clear_request)
request_id = uuid.uuid4().hex
answer = {
"clear_requested": True,
"request_id": request_id,
"cutoff": datetime.fromtimestamp(cutoff).isoformat(),
}
if send is not None:
result = send(request_id, cutoff)
if result is not None:
count = result.get("cleared")
return dict(answer, applied=True, transport="socket",
cleared_count=count if isinstance(count, int) and not isinstance(count, bool)
else _count_cleared(before, cutoff))
request = {
"request_id": uuid.uuid4().hex,
"request_id": request_id,
"cutoff": cutoff,
"requested_at": time.time(),
}
@@ -857,9 +955,5 @@ def request_error_clear(cache_manager: Any, cutoff: float) -> Dict[str, Any]:
stored = cache_manager.get(ERROR_CLEAR_REQUEST_KEY, max_age=None, memory_ttl=0)
if not isinstance(stored, dict) or stored.get("request_id") != request["request_id"]:
raise OSError("the clear request was not stored in the shared cache")
return {
"cleared_count": _count_cleared(before, cutoff),
"clear_requested": True,
"request_id": request["request_id"],
"cutoff": datetime.fromtimestamp(cutoff).isoformat(),
}
return dict(answer, applied=False, transport="mailbox",
cleared_count=_count_cleared(before, cutoff))
+33
View File
@@ -222,6 +222,39 @@ class FontManager:
logger.error(f"Error registering fonts for plugin {plugin_id}: {e}", exc_info=True)
return False
def forget_plugin_fonts(self, plugin_id: str) -> bool:
"""Drop the fonts ``plugin_id``'s manifest registered: its manifest
and catalog, its ``plugin_id::family`` entries in font_catalog, and
cached font objects for those families. Called by core when a plugin
is unloaded, so a reload registers from its current manifest and a
removed plugin's fonts stop resolving.
FontManager takes no locks; like forget_manager_fonts this relies on
single dict operations being atomic and iterates snapshots, so a
render thread calling get_font() meanwhile cannot break it. Returns
True if the plugin had registered fonts.
"""
prefix = f"{plugin_id}::"
manifest = self.plugin_fonts.pop(plugin_id, None)
catalog = self.plugin_font_catalogs.pop(plugin_id, None)
# Every namespaced entry, not just the families in the catalog: one
# whose file failed to load never made it into the catalog, and a
# caller may have added one directly.
for family in list(self.font_catalog):
if family.startswith(prefix):
self.font_catalog.pop(family, None)
# get_font() keys the cache f"{family}_{size_px}".
dropped = [key for key in list(self.font_cache) if key.startswith(prefix)]
for key in dropped:
self.font_cache.pop(key, None)
if dropped:
# Font objects someone may hold were dropped; see cache_generation.
self.cache_generation += 1
if manifest is None and catalog is None:
return False
logger.info("Forgot fonts of plugin %s", plugin_id)
return True
def _validate_font_manifest(self, font_manifest: Dict[str, Any]) -> bool:
"""Validate the structure of a plugin's font manifest."""
required_fields = ["fonts"]
+71 -10
View File
@@ -3,8 +3,13 @@
Every failure -- no socket (the display is stopped, or predates the socket),
a refused or timed-out connection, a reply that breaks the contract, or an
error the display returned -- raises :class:`ControlError` with a short
``reason``, and the caller falls back to the file mailbox. Nothing here
blocks for longer than ``timeout`` in total.
``reason``. Nothing here blocks for longer than ``timeout`` in total.
Whether the caller may then write the file mailbox instead is
:func:`should_fall_back`: only when the display never took the request (it
could not be reached, or it is too old to know the command). A display that
took the request and then failed, refused or went quiet is answered as
that, not posted a second time through the mailbox.
"""
from __future__ import annotations
@@ -22,6 +27,7 @@ from src.ipc.contract import (
SUBSCRIBE_KEEPALIVE_SECONDS,
SUPPORTED_VERSIONS,
Command,
ErrorCode,
FrameReader,
ProtocolError,
Request,
@@ -49,17 +55,49 @@ class ControlError(Exception):
``refused``, ``timeout``, ``closed``, ``bad_response``, ``invalid_request``.
When the display answered with an error, ``reason`` is that error's
:class:`~src.ipc.contract.ErrorCode` (``busy``, ``unknown_command``, ...).
``sent`` is True once the whole request was written to a connected
display, which may then have acted on it. A refusal the display sends
before it reads anything (``forbidden``, too many connections) carries
no request id and leaves ``sent`` False.
"""
def __init__(self, reason: str, message: str = ''):
def __init__(self, reason: str, message: str = '', *, sent: bool = False):
super().__init__(reason, message)
self.reason = reason
self.message = message
self.sent = sent
def __str__(self) -> str:
return f'{self.reason}: {self.message}' if self.message else self.reason
#: Answers from a display that read the request but does not speak it: one
#: older than the command (an upgrade in progress) or the protocol version.
#: It did nothing, so the mailbox is the way to reach it.
UPGRADE_REASONS = frozenset({ErrorCode.UNKNOWN_COMMAND, ErrorCode.UNSUPPORTED_VERSION})
def should_fall_back(error: BaseException) -> bool:
"""May the caller write the file mailbox after ``error``?
Yes when the display never took the request: there is no socket (the
display is stopped, predates the socket, or it is switched off), the
connection was refused or timed out, the display turned the connection
away before reading it, or it is too old to know the command
(:data:`UPGRADE_REASONS`). Also for an error that is not a
:class:`ControlError` (a bug in the client), as before.
No once the display had the request: a ``busy`` queue, ``invalid_args``,
an ``internal`` error, or a timeout or hang-up after the request was
sent. The display may have applied it, or would refuse it from the
mailbox too, so a second copy there only hides the failure.
"""
if not isinstance(error, ControlError):
return True
return not error.sent or error.reason in UPGRADE_REASONS
def request(cmd: str, args: Optional[Mapping[str, Any]] = None, *,
request_id: Optional[str] = None,
timeout: float = DEFAULT_TIMEOUT_SECONDS,
@@ -93,11 +131,14 @@ def request(cmd: str, args: Optional[Mapping[str, Any]] = None, *,
# A refusal before the request was read (forbidden, too many
# connections) carries no id.
if response.id != request_id and not (response.id is None and not response.ok):
raise ControlError('bad_response', 'the reply is for a different request')
raise ControlError('bad_response', 'the reply is for a different request', sent=True)
if not response.ok:
error = response.error
# No id: refused at the door (forbidden, too many connections),
# before the display read the request.
raise ControlError(error.code if error else 'bad_response',
error.message if error else '')
error.message if error else '',
sent=response.id is not None)
return dict(response.result or {})
@@ -142,26 +183,31 @@ def _connect(paths: Sequence[str], deadline: float) -> socket.socket:
def _exchange(sock: socket.socket, payload: bytes, deadline: float) -> Response:
"""Send ``payload`` and read the reply. A failure once the whole request
is written raises with ``sent=True``: the display may have it."""
sent = False
try:
sock.settimeout(_remaining(deadline))
sock.sendall(payload)
sent = True
reader = FrameReader(MAX_MESSAGE_BYTES)
while True:
sock.settimeout(_remaining(deadline))
data = sock.recv(4096)
if not data:
raise ControlError('closed', 'the display closed the connection')
raise ControlError('closed', 'the display closed the connection', sent=sent)
lines = reader.feed(data)
if lines:
return Response.from_dict(decode_message(lines[0]))
except socket.timeout:
raise ControlError('timeout', 'no reply in time') from None
raise ControlError('timeout', 'no reply in time', sent=sent) from None
except ProtocolError as e:
raise ControlError('bad_response', e.message) from None
except ControlError:
raise ControlError('bad_response', e.message, sent=sent) from None
except ControlError as e:
e.sent = e.sent or sent
raise
except OSError as e:
raise ControlError('closed', str(e)) from None
raise ControlError('closed', str(e), sent=sent) from None
# -- commands ---------------------------------------------------------------------------
@@ -227,6 +273,21 @@ def plugin_reload(plugin_id: str, *, timeout: Optional[float] = None,
else timeout, paths=paths)
def errors_clear(request_id: str, cutoff: float, *,
timeout: float = DEFAULT_TIMEOUT_SECONDS,
paths: Optional[Sequence[str]] = None) -> Dict[str, Any]:
"""Have the display forget the plugin errors recorded at or before
``cutoff`` (epoch seconds) and publish its error snapshot again.
Returns :class:`~src.ipc.contract.ErrorsClearResult` once it is done.
Raises :class:`ControlError`: ``unknown_command`` from a display older
than the command, which still reads the ``plugin_error_clear_request``
mailbox.
"""
return request(Command.ERRORS_CLEAR, {'cutoff': cutoff}, request_id=request_id,
timeout=timeout, paths=paths)
def ping(*, timeout: float = DEFAULT_TIMEOUT_SECONDS,
paths: Optional[Sequence[str]] = None) -> Dict[str, Any]:
return request(Command.PING, {}, timeout=timeout, paths=paths)
+44 -4
View File
@@ -149,12 +149,13 @@ class Command:
PLUGIN_RELOAD = 'plugin.reload'
STATE_GET = 'state.get'
STATE_SUBSCRIBE = 'state.subscribe'
ERRORS_CLEAR = 'errors.clear'
#: Every command version 1 defines, in the order ``hello`` reports them.
#: ``brightness.set`` and ``plugin.reload`` came in stage 2, and ``state.get``
#: and ``state.subscribe`` in stage 3, all within version 1 (see the module
#: docstring on adding commands).
#: ``brightness.set`` and ``plugin.reload`` came in stage 2, ``state.get``
#: and ``state.subscribe`` in stage 3, and ``errors.clear`` in stage 4, all
#: within version 1 (see the module docstring on adding commands).
COMMANDS: Tuple[str, ...] = (
Command.HELLO,
Command.PING,
@@ -165,8 +166,15 @@ COMMANDS: Tuple[str, ...] = (
Command.PLUGIN_RELOAD,
Command.STATE_GET,
Command.STATE_SUBSCRIBE,
Command.ERRORS_CLEAR,
)
#: Commands the connection thread answers itself, through a handler the
#: display registers (``ControlServer(handlers=...)``), because they touch
#: nothing the render thread owns. A display that registered none answers
#: ``unknown_command``, and the client falls back as from an older display.
DIRECT_COMMANDS = frozenset({Command.ERRORS_CLEAR})
#: Commands that are queued for the render thread.
QUEUED_COMMANDS = frozenset({Command.ON_DEMAND_START, Command.ON_DEMAND_STOP,
Command.BRIGHTNESS_SET, Command.PLUGIN_RELOAD})
@@ -565,8 +573,32 @@ class StateSubscribeArgs:
return cls()
@dataclass(frozen=True)
class ErrorsClearArgs:
"""``errors.clear``: forget the plugin errors recorded at or before
``cutoff`` (seconds since the epoch), as ``POST /api/v3/errors/clear``
asks. The request id is the clear's id, which the display's error
snapshot then reports as ``applied_clear_id``.
"""
cutoff: float
def to_dict(self) -> Dict[str, Any]:
return {'cutoff': self.cutoff}
@classmethod
def from_dict(cls, args: Mapping[str, Any]) -> 'ErrorsClearArgs':
value = args.get('cutoff')
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ProtocolError(ErrorCode.INVALID_ARGS, 'cutoff must be a number of seconds')
if not math.isfinite(value) or value < 0:
raise ProtocolError(ErrorCode.INVALID_ARGS,
'cutoff must be a finite, non-negative number of seconds')
return cls(cutoff=float(value))
CommandArgs = Union[HelloArgs, OnDemandStartArgs, OnDemandStopArgs, NoArgs,
BrightnessSetArgs, PluginReloadArgs, StateGetArgs, StateSubscribeArgs]
BrightnessSetArgs, PluginReloadArgs, StateGetArgs, StateSubscribeArgs,
ErrorsClearArgs]
#: The arguments of a command that goes on the render thread's queue.
QueuedArgs = Union[OnDemandStartArgs, OnDemandStopArgs, BrightnessSetArgs, PluginReloadArgs]
@@ -581,6 +613,7 @@ _ARG_TYPES: Dict[str, Any] = {
Command.PLUGIN_RELOAD: PluginReloadArgs,
Command.STATE_GET: StateGetArgs,
Command.STATE_SUBSCRIBE: StateSubscribeArgs,
Command.ERRORS_CLEAR: ErrorsClearArgs,
}
@@ -653,6 +686,13 @@ class PluginReloadResult(TypedDict):
modes: List[str]
class ErrorsClearResult(TypedDict):
"""``errors.clear``, once applied and the error snapshot republished."""
request_id: str
cutoff: float
cleared: int
class LoopState(TypedDict):
"""``loop``: is the render loop still going round?
+57 -6
View File
@@ -6,7 +6,9 @@ rendering: a command that changes the panel is validated, put on a bounded
queue and acknowledged, and the render thread drains that queue at the point
where it reads the file mailbox (``DisplayController._poll_on_demand_requests``),
handing each command to the same code. Queries (``on_demand.status``) are
answered from a snapshot callable the display provides.
answered from a snapshot callable the display provides, and the few commands
that touch nothing the render thread owns (``errors.clear``) by a handler the
display registers, on the connection thread.
The queue also wakes the render thread: :meth:`ControlServer.wait_for_command`
is what it waits on in place of a sleep, so a command lands within a frame on
@@ -62,6 +64,7 @@ from src.ipc.contract import (
AWAITED_COMMANDS,
COMMANDS,
DEFAULT_SOCKET_DIR,
DIRECT_COMMANDS,
DEFAULT_SOCKET_PATH,
MAX_MESSAGE_BYTES,
MAX_SUBSCRIBERS,
@@ -107,7 +110,8 @@ MAX_CLIENTS = 8
#: Commands waiting for the render thread. It drains them at least every
#: 0.25 s, so a full queue means the render thread is stuck, and the client
#: is told ``busy`` (and falls back to the mailbox) instead of piling up work.
#: is told ``busy`` instead of piling up work. The mailbox would not be read
#: either, so the web interface reports the failure rather than fall back.
QUEUE_SIZE = 16
#: Timeout for one recv()/send() on a connection.
@@ -552,6 +556,12 @@ def server_socket_path(environ: Optional[Mapping[str, str]] = None) -> Optional[
StatusProvider = Callable[[], Dict[str, Any]]
#: A handler for one of DIRECT_COMMANDS, ``(request_id, args) -> result``. It
#: runs on the connection thread, so it must not touch what the render thread
#: owns. It may raise ProtocolError to answer with that error's code; any
#: other exception is answered ``internal``.
DirectHandler = Callable[[str, Any], Mapping[str, Any]]
class ControlServer:
"""Serves the control socket on background threads.
@@ -569,9 +579,12 @@ class ControlServer:
await_seconds: Optional[Mapping[str, float]] = None,
state_hub: Optional[StateHub] = None,
max_subscribers: int = MAX_SUBSCRIBERS,
keepalive: float = SUBSCRIBE_KEEPALIVE_SECONDS):
keepalive: float = SUBSCRIBE_KEEPALIVE_SECONDS,
handlers: Optional[Mapping[str, DirectHandler]] = None):
self.path = path
self.state_hub = state_hub
self._handlers: Dict[str, DirectHandler] = {
cmd: fn for cmd, fn in (handlers or {}).items() if cmd in DIRECT_COMMANDS}
self._subscriber_slots = threading.BoundedSemaphore(max_subscribers)
self._keepalive = keepalive
self._await_seconds: Dict[str, float] = dict(AWAIT_SECONDS)
@@ -759,8 +772,22 @@ class ControlServer:
"""
return self._pending.wait(timeout)
def wake(self) -> None:
"""Wake the render thread as a queued command would, with nothing queued.
For work that reaches the display another way in the same process (a
plugin's on-demand request, ``DisplayController.submit_plugin_on_demand``):
the render thread returns from :meth:`wait_for_command` and drains,
and reads the caller's own queue there. Safe from any thread.
"""
self._pending.set()
def drain(self) -> List[QueuedCommand]:
"""Every queued command, oldest first. Called from the render thread."""
"""Every queued command, oldest first. Called from the render thread.
Clears the wake flag first, so anything queued (or woken for) while
this runs wakes the next wait again.
"""
commands: List[QueuedCommand] = []
self._pending.clear()
while True:
@@ -1028,6 +1055,9 @@ class ControlServer:
snap = hub.snapshot()
return Response.success(request.id, fit_snapshot(snap), v=request.v)
if request.cmd in DIRECT_COMMANDS:
return self._direct(request, args)
if request.cmd in QUEUED_COMMANDS and isinstance(args, (
OnDemandStartArgs, OnDemandStopArgs, BrightnessSetArgs, PluginReloadArgs)):
awaited = request.cmd in AWAITED_COMMANDS
@@ -1055,6 +1085,25 @@ class ControlServer:
return Response.failure(request.id, ErrorCode.INTERNAL,
f'{request.cmd} is not implemented', v=request.v)
def _direct(self, request: Request, args: Any) -> Response:
"""A command the display answers on this thread (DIRECT_COMMANDS)."""
handler = self._handlers.get(request.cmd)
if handler is None:
# Answered as an older display would, so the client falls back.
return Response.failure(request.id, ErrorCode.UNKNOWN_COMMAND,
f'{request.cmd} is not served by this display',
v=request.v)
try:
result = handler(request.id, args)
except ProtocolError as e:
return Response.failure(request.id, e.code, e.message, v=request.v)
except Exception: # pylint: disable=broad-except
logger.exception("Control socket: %s %s failed", request.cmd, request.id)
return Response.failure(request.id, ErrorCode.INTERNAL,
'the display failed to apply it', v=request.v)
logger.info("Control socket applied %s %s", request.cmd, request.id)
return Response.success(request.id, dict(result), v=request.v)
def _await_outcome(self, request: Request, outcome: CommandOutcome) -> Response:
"""Answer an awaited command once the render thread has applied it.
@@ -1079,7 +1128,9 @@ class ControlServer:
def start_control_server(status_provider: Optional[StatusProvider] = None,
cache_dir: Optional[str] = None,
environ: Optional[Mapping[str, str]] = None,
state_hub: Optional[StateHub] = None) -> Optional[ControlServer]:
state_hub: Optional[StateHub] = None,
handlers: Optional[Mapping[str, DirectHandler]] = None,
) -> Optional[ControlServer]:
"""Start the display's control socket, or return None when it can't run.
None covers Windows, ``LEDMATRIX_CONTROL_SOCKET=off`` and any failure to
@@ -1091,7 +1142,7 @@ def start_control_server(status_provider: Optional[StatusProvider] = None,
logger.debug("Control socket disabled or unsupported here; using the file mailbox only")
return None
server = ControlServer(path, status_provider, resolve_socket_group(cache_dir),
state_hub=state_hub)
state_hub=state_hub, handlers=handlers)
return server if server.start() else None
+123
View File
@@ -0,0 +1,123 @@
"""Keep glibc's malloc from holding on to memory the display has freed.
The display process allocates and frees PIL images and numpy buffers all day
from a dozen threads. glibc gives each allocating thread its own malloc arena
(up to 8 x CPU count) and returns little of what is freed inside them to the
OS, so resident memory climbs for hours while the live data stays flat. Two
in-process remedies, both standard library only (ctypes) and both no-ops off
Linux/glibc:
* :func:`cap_arenas` -- ``mallopt(M_ARENA_MAX, 2)``, the in-process twin of the
unit's ``Environment=MALLOC_ARENA_MAX=2``. Units installed before that line
existed never got it (systemd runs the copy in /etc/systemd/system), so the
process applies it itself. Call it before any other thread starts: arenas
already created stay. A ``MALLOC_ARENA_MAX`` set in the environment wins.
* :class:`MallocTrimmer` -- ``malloc_trim(0)`` at most every few minutes,
called from the render loop between screens, where no frame is being drawn.
glibc 2.8+ releases free pages from the middle of every arena, not only the
top of the main heap.
Without glibc (macOS, Windows, musl, the dev server on any of them) nothing is
loaded and every call returns False.
"""
import ctypes
import logging
import os
import sys
import time
from typing import Any, Callable, Optional
logger = logging.getLogger(__name__)
#: glibc's mallopt() parameter number for the arena cap (malloc.h).
M_ARENA_MAX = -8
#: The arena cap applied when the environment does not set one; the same value
#: as the unit's ``MALLOC_ARENA_MAX``.
DEFAULT_ARENA_MAX = 2
#: Seconds between malloc_trim() calls. A trim takes about 1-20 ms on a Pi 4,
#: so this keeps it far from frame timing while still returning memory long
#: before it piles up.
TRIM_INTERVAL_SECONDS = 300.0
_UNLOADED = object()
_libc: Any = _UNLOADED
def _load_libc() -> Optional[Any]:
"""The process's C library if it is glibc with malloc_trim, else None."""
global _libc
if _libc is _UNLOADED:
_libc = None
if sys.platform.startswith('linux'):
try:
libc = ctypes.CDLL(None)
# gnu_get_libc_version is glibc-only, so musl (which has
# mallopt but no malloc_trim) is left alone as a whole.
if all(hasattr(libc, name) for name in
('gnu_get_libc_version', 'malloc_trim', 'mallopt')):
libc.malloc_trim.argtypes = [ctypes.c_size_t]
libc.malloc_trim.restype = ctypes.c_int
libc.mallopt.argtypes = [ctypes.c_int, ctypes.c_int]
libc.mallopt.restype = ctypes.c_int
_libc = libc
except (OSError, AttributeError, TypeError):
logger.debug("glibc malloc controls unavailable", exc_info=True)
return _libc
def cap_arenas(max_arenas: int = DEFAULT_ARENA_MAX) -> bool:
"""Cap glibc's malloc arenas at ``max_arenas``. True when the cap was set.
Skipped when ``MALLOC_ARENA_MAX`` is in the environment: glibc has read it
already, and an operator who set it chose that value.
"""
if os.environ.get('MALLOC_ARENA_MAX'):
return False
libc = _load_libc()
if libc is None:
return False
try:
return bool(libc.mallopt(M_ARENA_MAX, int(max_arenas)))
except Exception: # pylint: disable=broad-except
logger.debug("mallopt(M_ARENA_MAX) failed", exc_info=True)
return False
class MallocTrimmer:
"""Calls ``malloc_trim(0)`` at most once per ``interval`` seconds.
:meth:`maybe_trim` is meant for an idle point of the render loop; it costs
one clock read when no trim is due. The first trim comes one interval
after construction, so start-up's allocations have settled.
"""
def __init__(self, interval: float = TRIM_INTERVAL_SECONDS,
clock: Callable[[], float] = time.monotonic) -> None:
self._interval = interval
self._clock = clock
self._libc = _load_libc()
self._next = clock() + interval
@property
def available(self) -> bool:
return self._libc is not None
def maybe_trim(self) -> bool:
"""Trim if one is due. True when malloc_trim ran and released memory."""
if self._libc is None:
return False
now = self._clock()
if now < self._next:
return False
self._next = now + self._interval
try:
released = bool(self._libc.malloc_trim(0))
except Exception: # pylint: disable=broad-except
logger.debug("malloc_trim failed; not trying again", exc_info=True)
self._libc = None
return False
logger.debug("malloc_trim(0) took %.1f ms, released=%s",
(self._clock() - now) * 1000.0, released)
return released
+59 -21
View File
@@ -11,7 +11,6 @@ Stability: Stable - maintains backward compatibility
from abc import ABC, abstractmethod
from enum import Enum
from typing import Dict, Any, Optional, List
import math
import os
import sys
from src.deprecation import deprecated, warn_deprecated
@@ -241,26 +240,6 @@ def resolve_vegas_participation(plugin: Any, plugin_id: Optional[str] = None) ->
return legacy_vegas_participation(plugin)
def finite_seconds(value: Any) -> Optional[float]:
"""``value`` as seconds when it is a finite number or a numeric string,
else None. A bool is not a number here, though it is an int: True would
read as a one-second screen.
How the core reads a plugin's get_display_duration() -- the rotation
(DisplayController._get_display_duration) and the Vegas static pause --
which several plugins answer straight from config.json, so a value saved
as "20" or null arrives as a string or None. A number at or below zero is
returned as it is; each caller has its own rule for that.
"""
if isinstance(value, bool):
return None
try:
seconds = float(value)
except (TypeError, ValueError, OverflowError):
return None
return seconds if math.isfinite(seconds) else None
class BasePlugin(ABC):
"""
Base class that all plugins must inherit from.
@@ -1091,6 +1070,65 @@ class BasePlugin(ABC):
if callable(notify):
notify(self.plugin_id)
def request_on_demand(self, mode: Optional[str] = None,
duration: Optional[float] = None,
pinned: bool = False) -> Optional[str]:
"""
Take the screen now: show this plugin on demand. Safe from any thread.
For a plugin that reacts to something outside the rotation -- an MQTT
message, a timer, a detection -- and wants the panel for it. The
request goes straight to the display in this process and is applied
on its render thread within a frame or so, exactly like an on-demand
start from the web interface.
Args:
mode: One of this plugin's display modes; None for its first.
duration: Seconds to show it before the rotation resumes; None
(or zero) for no limit, until end_on_demand() or the user
stops it.
pinned: Stay on ``mode`` instead of cycling through the
plugin's other modes.
Returns:
The request id once the display has queued it, or None when
there is no display in this process to ask (the web interface,
scripts/check_plugin.py) or its queue is full. A plugin that
also runs on cores without this method writes the
``display_on_demand_request`` mailbox on None, as before; see
"On-demand display" in docs/PLUGIN_API_REFERENCE.md.
Example::
if not (hasattr(self, 'request_on_demand')
and self.request_on_demand(mode='my_alert', duration=15)):
self._write_on_demand_mailbox(...) # older cores
"""
request = getattr(getattr(self, 'plugin_manager', None), 'request_on_demand', None)
if not callable(request):
return None
request_id = request(self.plugin_id, mode=mode, duration=duration, pinned=pinned)
# Only a real id counts: a test's MagicMock manager answers a mock,
# which must read as "not taken" so the plugin's fallback runs.
return request_id if isinstance(request_id, str) else None
def end_on_demand(self) -> Optional[str]:
"""
Give the screen back: end this plugin's on-demand session. Any thread.
Ends only a session this plugin owns. One the user started for
another plugin, or a session that already ended, is left alone. The
rotation resumes where it left off.
Returns:
The request id once queued, or None as request_on_demand() does.
"""
end = getattr(getattr(self, 'plugin_manager', None), 'end_on_demand', None)
if not callable(end):
return None
request_id = end(self.plugin_id)
return request_id if isinstance(request_id, str) else None
def get_vegas_participation(self) -> str:
"""
How this plugin takes part in Vegas mode: ``'scroll'``, ``'pause'`` or
+204
View File
@@ -0,0 +1,204 @@
"""
Files a plugin writes beside itself at runtime, which an update must keep.
A store update replaces a plugin's directory with a fresh download and then
deletes the old copy. Anything the plugin created there -- OAuth tokens, a
client-secrets file, a PKCE verifier, cached state -- is in no release, so the
fresh download does not contain it and deleting the old copy destroys it. On
2026-10-04 updating calendar 1.2.9 -> 1.2.12 that way deleted its
``token.pickle`` and ``credentials.json``, and the calendar stopped until they
were restored from a backup.
What counts as "the plugin's own local file" is the union of:
* :data:`KNOWN_STATE_PATTERNS` -- secret and state files plugins are known to
write, kept even when a plugin forgot to gitignore them; and
* whatever the plugin's own ``.gitignore`` (old copy or new) excludes. A file
the author ignores is by definition not part of a release.
A file the new release ships is never overwritten: tracked content wins. Byte
code (``__pycache__``, ``*.pyc``) and ``.git`` are never carried, since they
belong to the old code rather than to the user.
"""
from __future__ import annotations
import fnmatch
import os
import re
import shutil
from pathlib import Path
from typing import Iterable, List, Optional, Pattern, Tuple
__all__ = [
'KNOWN_STATE_PATTERNS',
'carry_over_local_files',
'is_known_state_file',
'local_files_to_keep',
]
# Basename globs. Kept even when the plugin's .gitignore does not list them.
KNOWN_STATE_PATTERNS: Tuple[str, ...] = (
'token.pickle',
'*.pickle',
'token.json',
'credentials.json',
'config_secrets.json',
'.pkce_code_verifier',
)
_NEVER_CARRY_DIRS = frozenset({'.git', '__pycache__'})
_NEVER_CARRY_SUFFIXES = ('.pyc', '.pyo')
def is_known_state_file(rel_path: str) -> bool:
"""True when ``rel_path``'s basename is a known secret/state file."""
name = rel_path.replace('\\', '/').rsplit('/', 1)[-1]
return any(fnmatch.fnmatchcase(name, p) for p in KNOWN_STATE_PATTERNS)
class _GitIgnore:
"""The subset of gitignore semantics plugin .gitignore files use.
Supports comments, ``!`` negation (last match wins), a trailing ``/`` for
directory-only patterns, anchoring by a leading or embedded ``/``, ``*``,
``?``, ``[...]`` and ``**``. As in git, a file under an ignored directory
is ignored regardless of later negations.
"""
def __init__(self, lines: Iterable[str]):
self._rules: List[Tuple[Pattern[str], bool, bool]] = []
for raw in lines:
line = raw.rstrip('\n').rstrip()
if not line or line.startswith('#'):
continue
negate = line.startswith('!')
if negate:
line = line[1:]
elif line.startswith('\\'):
line = line[1:]
dir_only = line.endswith('/')
line = line.rstrip('/')
if not line:
continue
anchored = '/' in line
line = line.lstrip('/')
body = self._translate(line)
regex = body if anchored else r'(?:.*/)?' + body
self._rules.append((re.compile(r'\A' + regex + r'\Z'), negate, dir_only))
@staticmethod
def _translate(pattern: str) -> str:
out, i, n = [], 0, len(pattern)
while i < n:
if pattern.startswith('**/', i):
out.append(r'(?:.*/)?')
i += 3
elif pattern.startswith('/**', i) and i + 3 == n:
out.append(r'/.*')
i += 3
elif pattern.startswith('**', i):
out.append(r'.*')
i += 2
elif pattern[i] == '*':
out.append(r'[^/]*')
i += 1
elif pattern[i] == '?':
out.append(r'[^/]')
i += 1
elif pattern[i] == '[':
end = pattern.find(']', i + 1)
if end == -1:
out.append(re.escape('['))
i += 1
else:
cls = pattern[i + 1:end]
if cls.startswith('!'):
cls = '^' + cls[1:]
out.append('[' + cls.replace('\\', '\\\\') + ']')
i = end + 1
else:
out.append(re.escape(pattern[i]))
i += 1
return ''.join(out)
def _decide(self, rel: str, is_dir: bool) -> Optional[bool]:
verdict = None
for regex, negate, dir_only in self._rules:
if dir_only and not is_dir:
continue
if regex.match(rel):
verdict = not negate
return verdict
def ignores(self, rel_path: str) -> bool:
if not self._rules:
return False
parts = rel_path.replace('\\', '/').split('/')
for depth in range(1, len(parts)):
if self._decide('/'.join(parts[:depth]), True):
return True
return bool(self._decide('/'.join(parts), False))
def _read_gitignore(plugin_dir: Path) -> List[str]:
try:
return (plugin_dir / '.gitignore').read_text(
encoding='utf-8', errors='replace').splitlines()
except OSError:
return []
def local_files_to_keep(old_dir: Path, new_dir: Path) -> List[str]:
"""Relative paths (``/``-separated) in ``old_dir`` to copy into ``new_dir``.
Regular files only; symlinks and anything the new release already ships
are skipped.
"""
old_dir, new_dir = Path(old_dir), Path(new_dir)
ignore = _GitIgnore(_read_gitignore(old_dir) + _read_gitignore(new_dir))
keep: List[str] = []
for root, dirs, files in os.walk(old_dir):
dirs[:] = sorted(d for d in dirs if d not in _NEVER_CARRY_DIRS
and not os.path.islink(os.path.join(root, d)))
rel_root = os.path.relpath(root, old_dir)
for name in sorted(files):
if name.endswith(_NEVER_CARRY_SUFFIXES):
continue
full = os.path.join(root, name)
if os.path.islink(full) or not os.path.isfile(full):
continue
rel = name if rel_root == '.' else f"{rel_root}/{name}".replace('\\', '/')
if not (is_known_state_file(rel) or ignore.ignores(rel)):
continue
if os.path.lexists(new_dir / rel):
continue
keep.append(rel)
return keep
def carry_over_local_files(
old_dir: Path, new_dir: Path
) -> Tuple[List[str], List[Tuple[str, str]]]:
"""Copy the plugin's local files from ``old_dir`` into ``new_dir``.
Copies rather than moves, so ``old_dir`` stays a complete copy until the
caller deletes it. Returns ``(copied, failed)`` where ``failed`` pairs a
relative path with the error; the caller should keep ``old_dir`` when
anything failed.
"""
copied: List[str] = []
failed: List[Tuple[str, str]] = []
try:
candidates = local_files_to_keep(old_dir, new_dir)
except OSError as e:
return copied, [('.', str(e))]
for rel in candidates:
dest = Path(new_dir) / rel
try:
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(Path(old_dir) / rel, dest)
copied.append(rel)
except OSError as e:
failed.append((rel, str(e)))
return copied, failed
+92 -11
View File
@@ -15,6 +15,7 @@ import sys
import time
import threading
import types
import uuid
from pathlib import Path
from typing import Callable, Dict, List, NamedTuple, Optional, Any, Tuple, Union
import logging
@@ -214,6 +215,9 @@ class PluginManager:
# add_update_listener(). A tuple, replaced rather than mutated, so the
# worker can iterate it without a lock.
self._update_listeners: Tuple[Callable[[str], None], ...] = ()
# Where plugins' on-demand requests go: the display controller's
# submit_plugin_on_demand. See set_on_demand_handler().
self._on_demand_handler: Optional[Callable[[Dict[str, Any]], bool]] = None
# Config changes that found the plugin's lock busy, latest per plugin,
# with the instance they were meant for. See apply_config_change().
self._deferred_config_changes: Dict[str, Tuple[Any, Dict[str, Any]]] = {}
@@ -597,11 +601,21 @@ class PluginManager:
self.plugin_loader.unregister_plugin_modules(plugin_id)
except Exception as e: # pragma: no cover - defensive
self.logger.debug("Could not drop modules of %s: %s", plugin_id, e)
try:
if self.font_manager is not None and hasattr(self.font_manager, 'forget_manager_fonts'):
self.font_manager.forget_manager_fonts(plugin_id)
except Exception as e:
self.logger.debug("Could not forget fonts of %s: %s", plugin_id, e)
self._forget_plugin_fonts(plugin_id)
def _forget_plugin_fonts(self, plugin_id: str) -> None:
"""Drop what the FontManager holds for a plugin: the fonts its
instance reported using (the Fonts tab's "Used by") and the fonts its
manifest registered. Never raises."""
if self.font_manager is None:
return
for name in ('forget_manager_fonts', 'forget_plugin_fonts'):
if not hasattr(self.font_manager, name):
continue
try:
getattr(self.font_manager, name)(plugin_id)
except Exception as e:
self.logger.debug("Could not forget fonts of %s (%s): %s", plugin_id, name, e)
#: Config keys the **core** reads out of a plugin's own config block. The
#: plugin never declares them, so a schema with
@@ -846,12 +860,9 @@ class PluginManager:
# Delegate sub-module and cached-module cleanup to the loader
self.plugin_loader.unregister_plugin_modules(plugin_id)
# Its font registrations go with it (the Fonts tab's "Used by").
try:
if self.font_manager is not None and hasattr(self.font_manager, 'forget_manager_fonts'):
self.font_manager.forget_manager_fonts(plugin_id)
except Exception as e:
self.logger.debug("Could not forget fonts of %s: %s", plugin_id, e)
# Its font registrations go with it: the fonts it reported using
# and the ones its manifest registered.
self._forget_plugin_fonts(plugin_id)
# Update state
self.state_manager.set_state(plugin_id, PluginState.UNLOADED)
@@ -1844,3 +1855,73 @@ class PluginManager:
done = sorted(self._completed_updates)
self._completed_updates.clear()
return done
# -- on-demand requests from plugins -------------------------------------
def set_on_demand_handler(
self, handler: Optional[Callable[[Dict[str, Any]], bool]]) -> None:
"""Route plugins' on-demand requests to ``handler`` (None: nowhere).
The display controller sets its ``submit_plugin_on_demand`` here
before any plugin loads. The handler takes a mailbox-shaped request
from any thread, queues it for the render thread and returns True,
or False when it could not. A plugin manager with no handler (the
web interface's, a test's, scripts/check_plugin.py's) has no screen
to give, so request_on_demand() there answers None.
"""
self._on_demand_handler = handler
def request_on_demand(self, plugin_id: str, mode: Optional[str] = None,
duration: Optional[float] = None,
pinned: bool = False) -> Optional[str]:
"""Ask the display to show ``plugin_id`` now. Safe from any thread.
BasePlugin.request_on_demand() lands here; see it for the arguments.
Returns the request id once the display has queued the request (it
is applied on the render thread within a frame or so), or None when
this process has no display to ask or its queue is full.
"""
if not isinstance(plugin_id, str) or not plugin_id:
raise ValueError('plugin_id is required')
if mode is not None and (not isinstance(mode, str) or not mode):
raise ValueError('mode must be a non-empty string or None')
if duration is not None:
if isinstance(duration, bool) or not isinstance(duration, (int, float)):
raise ValueError('duration must be a number of seconds or None')
if not math.isfinite(duration) or duration <= 0:
duration = None # the display reads these as "no limit" too
else:
duration = float(duration)
return self._submit_on_demand({
'action': 'start', 'plugin_id': plugin_id, 'mode': mode,
'duration': duration, 'pinned': bool(pinned)})
def end_on_demand(self, plugin_id: str) -> Optional[str]:
"""Give the screen back, if ``plugin_id``'s on-demand session has it.
BasePlugin.end_on_demand() lands here. A session the plugin does not
own (the user started another plugin from the web interface, say) is
left alone. Returns the request id once queued, or None as
request_on_demand() does.
"""
if not isinstance(plugin_id, str) or not plugin_id:
raise ValueError('plugin_id is required')
return self._submit_on_demand({'action': 'stop', 'plugin_id': plugin_id})
def _submit_on_demand(self, request: Dict[str, Any]) -> Optional[str]:
# __dict__.get: tests build bare managers with PluginManager.__new__.
handler = self.__dict__.get('_on_demand_handler')
if handler is None:
return None
request_id = str(uuid.uuid4())
request.update({'request_id': request_id, 'timestamp': time.time(),
'source': 'plugin'})
try:
accepted = handler(request)
except Exception as exc: # pylint: disable=broad-except
self._warn_rate_limited(
"on-demand-handler",
"The on-demand request from plugin %s failed: %r",
request.get('plugin_id'), exc)
return None
return request_id if accepted else None
+33 -4
View File
@@ -22,6 +22,7 @@ from src.plugin_system.plugin_loader import (
contained_plugin_dir, requirements_to_install,
)
from src.plugin_system.plugin_dirs import BACKUP_MARKER
from src.plugin_system.plugin_local_files import carry_over_local_files
from src.plugin_system.repo_urls import (
USER_AGENT, github_api_headers, github_owner_repo, normalize_repo_url,
)
@@ -92,7 +93,9 @@ class _InstallMixin:
raise
if installed:
self._discard_backup(plugin_id, backup_path, "install")
self._discard_backup(
plugin_id, backup_path, "install",
new_path=self._existing_install(plugin_id) or plugin_path)
return True
self._restore_backup(plugin_id, plugin_path, backup_path, "Install")
@@ -133,8 +136,33 @@ class _InstallMixin:
return f"could not set aside {plugin_path}: {e}"
return None
def _discard_backup(self, plugin_id: str, backup_path: Path, action: str) -> None:
"""Remove the set-aside copy after a successful (re)install."""
def _discard_backup(
self, plugin_id: str, backup_path: Path, action: str,
new_path: Optional[Path] = None,
) -> None:
"""Remove the set-aside copy after a successful (re)install.
With ``new_path`` (where the new copy landed), first carries the
plugin's own runtime files -- OAuth tokens, client secrets, anything
its .gitignore excludes -- from the old copy into the new one: no
release contains them, so deleting the old copy would destroy them.
See src/plugin_system/plugin_local_files.py. If any could not be
copied the old copy is kept, so nothing is lost.
"""
if new_path is not None and new_path.is_dir():
copied, failed = carry_over_local_files(backup_path, new_path)
if copied:
self.logger.info(
"Kept %d local file(s) of %s across the %s: %s",
len(copied), plugin_id, action, ", ".join(copied))
if failed:
self.logger.error(
"Could not carry %s's local files into the new copy (%s); "
"the previous copy is kept at %s -- copy them back by hand",
plugin_id,
"; ".join(f"{rel}: {err}" for rel, err in failed),
backup_path)
return
if not self._safe_remove_directory(backup_path):
self.logger.warning(
"%s of %s succeeded but the previous copy at %s could not be "
@@ -542,7 +570,8 @@ class _InstallMixin:
raise
temp_dir = None # Prevent cleanup since we moved it
if backup_path is not None:
self._discard_backup(plugin_id, backup_path, "install")
self._discard_backup(
plugin_id, backup_path, "install", new_path=final_path)
# Install dependencies
self._install_dependencies(final_path)
+5
View File
@@ -138,6 +138,11 @@ class PluginStoreManager(_RegistryMixin, _InstallMixin, _UpdateMixin):
# the registry cache expires. Only one thread fetches; others wait and
# then get the result from the warm cache (double-checked locking).
self._registry_fetch_lock = threading.Lock()
# refresh_registry_in_background: the one refresh thread, and when
# an offline one may be retried (see that method).
self._registry_refresh_lock = threading.Lock()
self._registry_refresh_thread: Optional[threading.Thread] = None
self._registry_refresh_retry_after = 0.0
# Per-plugin locks for _reinstall_with_rollback: the web UI runs
# Flask with threaded=True, so two overlapping requests for the
+57 -3
View File
@@ -7,6 +7,7 @@ methods reach shared state and helpers through ``self``.
import json
import requests
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
@@ -985,10 +986,14 @@ class _RegistryMixin:
def get_registry_info(self, plugin_id: str) -> Optional[Dict]:
"""
Get plugin information from the registry cache only (no GitHub API calls).
Get plugin information from the registry (plugins.json).
Use this for lightweight lookups where only registry fields are needed
(e.g., verified status, latest_version).
Makes no GitHub API calls, but it does go through `fetch_registry`:
when the in-memory copy is missing or older than
``registry_cache_timeout`` it downloads plugins.json, and with no
network that waits out the timeout and retries. A caller that must
not block on the network (the installed-plugins list) uses
`get_cached_registry_info` instead.
Args:
plugin_id: Plugin identifier
@@ -999,3 +1004,52 @@ class _RegistryMixin:
registry = self.fetch_registry()
plugins = registry.get('plugins', []) or []
return self._match_registry_entry(plugins, plugin_id)
def get_cached_registry_info(self, plugin_id: str) -> Optional[Dict]:
"""The registry entry for ``plugin_id`` from the copy already in
memory, however old; never touches the network.
None when no registry has been loaded yet, or the plugin isn't in it.
When the copy is missing or past ``registry_cache_timeout`` this
starts `refresh_registry_in_background`, so a later call has it.
"""
cache = getattr(self, 'registry_cache', None)
cache_time = getattr(self, 'registry_cache_time', None)
if (not cache or not cache_time
or (time.time() - cache_time) >= self.registry_cache_timeout):
self.refresh_registry_in_background()
plugins = cache.get('plugins') if isinstance(cache, dict) else None
if not isinstance(plugins, list):
return None
return self._match_registry_entry(
[p for p in plugins if isinstance(p, dict)], plugin_id)
def refresh_registry_in_background(self) -> bool:
"""Fetch the registry on a daemon thread; True when one was started.
At most one runs at a time. After a fetch that left no registry in
memory (offline), no new one starts for ``_failure_backoff_seconds``,
so an offline Pi doesn't retry on every page load.
"""
with self._registry_refresh_lock:
running = self._registry_refresh_thread
if running is not None and running.is_alive():
return False
if time.time() < self._registry_refresh_retry_after:
return False
thread = threading.Thread(
target=self._background_registry_refresh,
name='registry-refresh', daemon=True)
self._registry_refresh_thread = thread
thread.start()
return True
def _background_registry_refresh(self) -> None:
try:
self.fetch_registry()
except Exception as e: # noqa: BLE001 - a background warm-up must not crash
self.logger.warning("Background registry refresh failed: %s", e)
if not getattr(self, 'registry_cache', None):
with self._registry_refresh_lock:
self._registry_refresh_retry_after = (
time.time() + self._failure_backoff_seconds)
+24 -5
View File
@@ -10,6 +10,9 @@ import subprocess # nosec B404 - list-form argv only, no shell # nosemgrep
from pathlib import Path
from typing import Dict, Optional, Tuple
from src.plugin_system.plugin_dirs import BACKUP_MARKER
from src.plugin_system.plugin_local_files import (
KNOWN_STATE_PATTERNS, is_known_state_file,
)
from src.plugin_system.repo_urls import same_repo
@@ -302,7 +305,11 @@ class _UpdateMixin:
installed = False
if installed:
self._discard_backup(plugin_id, backup_path, "update")
# install_plugin may land the new copy under the manifest id
# rather than the old directory name.
self._discard_backup(
plugin_id, backup_path, "update",
new_path=self._existing_install(plugin_id) or plugin_path)
return True
# Bad network, registry error...: the user keeps a working plugin.
@@ -509,8 +516,12 @@ class _UpdateMixin:
for line in untracked_result.stdout.strip().split('\n'):
if line.startswith('??'):
# Untracked file
file_path = line[3:].strip()
untracked_files.append(file_path)
file_path = line[3:].strip().strip('"')
# Tokens and secrets stay out of the
# stash (see below), so they alone are
# not a reason to stash.
if not is_known_state_file(file_path):
untracked_files.append(file_path)
# Check for tracked file changes
status_result = subprocess.run(
@@ -537,9 +548,17 @@ class _UpdateMixin:
if has_changes:
self.logger.info(f"Stashing local changes in {plugin_id} before update")
try:
# Use -u to include untracked files in stash
# Use -u to include untracked files in stash --
# except the plugin's tokens and secrets, which a
# repo may have forgotten to gitignore. The stash
# is never popped, so a stashed token.pickle would
# vanish from the plugin and break it.
stash_cmd = (
['git', '-C', str(plugin_path), 'stash', 'push', '-u',
'-m', f'LEDMatrix auto-stash before update {plugin_id}', '--', '.']
+ [f':(exclude,glob)**/{p}' for p in KNOWN_STATE_PATTERNS])
stash_result = subprocess.run(
['git', '-C', str(plugin_path), 'stash', 'push', '-u', '-m', f'LEDMatrix auto-stash before update {plugin_id}'],
stash_cmd,
capture_output=True,
text=True,
timeout=30,
+484
View File
@@ -0,0 +1,484 @@
"""Runs one screen: the ScreenRunner of docs/RUN_LOOP_REDESIGN.md.
``ScreenRunner.run(plan, plugin)`` draws a screen's first frame, runs the
frame loop its plan's ``frame_policy`` picks (125 Hz or 1 Hz), makes up the
minimum duration when the loop ended early, and returns one
:class:`Outcome` saying why the screen ended. Everything that touches the
plugin, the panel or the controller's state goes through a
:class:`ScreenHost` (the DisplayController); everything that reads or waits
on the clock goes through an injected :class:`FrameClock`. The runner itself
holds no state between screens.
What can end a screen early is decided at the runner's service points: after
each frame, after the frame loop, and after the make-up dwell. At each one
the host gathers a snapshot and asks the Arbiter, once, whether a Source in
``plan.preemptible_by`` now wants the panel (:meth:`ScreenHost.check`). A yes
is ``ExitReason.PREEMPTED``: the next pass of the loop decides what shows,
and the rotation does not advance past the screen that was cut short.
The frame pacing is the loop that used to be inline in
``DisplayController.run()``, unchanged: the 125 Hz loop paces to an 8 ms
deadline from the start of each frame (sleeping at least 1 ms, so a frame
that overran still yields the GIL), and the 1 Hz loop sleeps a flat second
between frames, woken early by a control socket command.
"""
import logging
from dataclasses import dataclass
from enum import Enum
from typing import Any, NamedTuple, Optional, Protocol, Tuple
from src.display_arbiter import FramePolicy, ScreenPlan, Source
__all__ = [
"AFTER_COMPLETED_LOOP",
"AFTER_LOOP",
"Checkpoint",
"DYNAMIC_GRACE",
"ExitReason",
"FINAL",
"FRAME",
"FirstFrame",
"FrameClock",
"HIGH_FPS_INTERVAL",
"NoticeRead",
"Outcome",
"STATIC_INTERVAL",
"Screen",
"ScreenHost",
"ScreenRunner",
"after_dwell",
]
#: Seconds between frames in the high-FPS loop (125 Hz), for scrolling plugins.
HIGH_FPS_INTERVAL = 0.008
#: Seconds between frames in the static loop (1 Hz).
STATIC_INTERVAL = 1.0
#: A dynamic-duration screen ends on cycle completion only this long after its
#: minimum, so timing jitter around the minimum can't end it early.
DYNAMIC_GRACE = 0.5
class ExitReason(Enum):
"""Why a screen ended. The value is the golden traces' exit column where
one exists (test/test_run_loop_golden.py)."""
#: The screen ran its target duration.
DURATION = "duration"
#: A dynamic-duration plugin finished its cycle after its minimum.
CYCLE_COMPLETE = "cycle-complete"
#: The first frame had nothing to show (display() returned False or
#: raised inside the executor), or no plugin draws the mode.
EMPTY = "empty"
#: The first frame's dispatch itself raised.
ERROR = "error"
#: A later frame returned False (a dynamic-duration screen on the 1 Hz
#: loop keeps going instead).
DISPLAY_FALSE = "display-false"
#: Another Source took the panel, or an on-demand session ran out before
#: the screen began. The rotation does not advance.
PREEMPTED = "preempted"
#: A plugin reload is waiting for the top of the loop. The screen is cut
#: short but counts as shown: the rotation advances, and the next pass
#: reloads before it draws.
RELOAD = "reload"
@dataclass(frozen=True)
class Outcome:
"""How a screen ended.
Attributes:
exit_reason: Why it ended.
elapsed: Seconds from the end of the first frame to the end.
preempted_by: For PREEMPTED, the plan that took the panel when the
Arbiter named one (None when the session simply ran out).
on_demand_active: Filled in by the controller when the screen is
over: an on-demand session was running at that moment.
still_live: Filled in by the controller: the mode's plugin still had
live content at that moment, which holds the rotation on it.
"""
exit_reason: ExitReason
elapsed: float = 0.0
preempted_by: Optional[ScreenPlan] = None
on_demand_active: bool = False
still_live: bool = False
class FrameClock(Protocol):
"""The clocks the runner reads and the sleep it paces with.
The shape of the ``time`` module, so production passes it (through an
indirection that lets tests patch the module) and the golden traces pass
their fake clock.
"""
def time(self) -> float:
"""Wall-clock seconds: what screen durations are measured in."""
def perf_counter(self) -> float:
"""A monotonic high-resolution clock: what the 8 ms pacing reads."""
def sleep(self, seconds: float) -> None:
"""Block for ``seconds``."""
class NoticeRead(Enum):
"""When a service point reads the WiFi notice file.
The read is throttled to once a second and deletes an expired file, so
*when* it happens is behaviour: each service point reads it exactly
when the loop always did.
"""
#: Not at all.
NEVER = "never"
#: Only if nothing cheaper has already ended the screen: no on-demand
#: session, the panel on, the mode unchanged and no live takeover.
IF_UNDECIDED = "if-undecided"
#: Whenever no on-demand session is running.
ALWAYS = "always"
@dataclass(frozen=True)
class Checkpoint:
"""What one kind of service point considers.
Attributes:
name: For logs and tests.
notice: When the WiFi notice is read (see NoticeRead).
notice_counts: Whether a pending notice ends the screen here. After
the make-up dwell it does only while the screen had time left.
reload: Whether a pending plugin reload ends the screen here. Only
between frames: once the frame loop is over the screen is too.
"""
name: str
notice: NoticeRead
reload: bool
notice_counts: bool = True
#: Between frames: the frame loops' check, and the socket wake in the 1 Hz wait.
FRAME = Checkpoint("frame", NoticeRead.IF_UNDECIDED, reload=True)
#: After a frame loop that ended early (display() returned False, a reload).
AFTER_LOOP = Checkpoint("after-loop", NoticeRead.IF_UNDECIDED, reload=False)
#: After a frame loop that ran its course: only a mode change or the schedule.
AFTER_COMPLETED_LOOP = Checkpoint("after-completed-loop", NoticeRead.NEVER, reload=False)
#: The last look before the rotation advances.
FINAL = Checkpoint("final", NoticeRead.NEVER, reload=False)
def after_dwell(time_left: bool) -> Checkpoint:
"""After the make-up dwell: a notice that cut it short ends the screen,
so the mode resumes after the notice instead of rotating past it."""
return Checkpoint("after-dwell", NoticeRead.ALWAYS, reload=False,
notice_counts=time_left)
class FirstFrame(NamedTuple):
"""What the first frame's dispatch returned (see _dispatch_first_frame)."""
shown: bool
raised: bool
accepts_display_mode: bool
@dataclass
class Screen:
"""One running screen: its completed plan, the plugin drawing it and
when it started. Mutable only in that the runner owns it."""
plan: ScreenPlan
plugin: Any
accepts_display_mode: bool
start: float
@property
def mode(self) -> Optional[str]:
return self.plan.mode
class ScreenHost(Protocol):
"""The controller's side of a screen. See DisplayController."""
def first_frame(self, plan: ScreenPlan, plugin: Any) -> FirstFrame:
"""Draw the first frame through the plugin executor."""
def complete_plan(self, plan: ScreenPlan, plugin: Any) -> Optional[ScreenPlan]:
"""The plan with the plugin's durations, dynamic flag and frame
policy, read after the first frame. None when an on-demand session
has no time left for it."""
def draw(self, screen: Screen) -> Any:
"""One later frame: what display() returned."""
def after_frame(self, screen: Screen) -> None:
"""After a frame that did not end the screen (the follower frame)."""
def tick(self) -> None:
"""Plugin updates that have come due (throttled)."""
def service(self, screen: Screen) -> Optional[Tuple[str, ...]]:
"""Apply pending changes (on-demand requests, schedule, brightness,
finished reloads). Returns the live modes when a live-priority scan
was due, else None."""
def wait_frame(self, interval: float, screen: Screen) -> Optional[ScreenPlan]:
"""The 1 Hz loop's sleep between frames. The plan that takes the
panel when a control socket command ended the screen, else None."""
def check(self, screen: Screen, checkpoint: Checkpoint,
live_scan: Optional[Tuple[str, ...]] = None) -> Optional[ScreenPlan]:
"""The service point: the plan that now takes the panel from this
screen, or None while it holds. One Arbiter.decide() call."""
def dwell(self, seconds: float) -> None:
"""Sleep up to ``seconds``, servicing changes; returns early on one."""
def cycle_complete(self, screen: Screen) -> bool:
"""The plugin's dynamic-duration cycle is complete."""
class ScreenRunner:
"""Runs one screen at a time for a ScreenHost. See the module docstring."""
def __init__(self, clock: FrameClock, host: ScreenHost,
log: Optional[logging.Logger] = None):
self.clock = clock
self.host = host
# The controller passes its own logger, so these lines keep the
# source they always had in the journal.
self.log = log or logging.getLogger(__name__)
# -- the screen ------------------------------------------------------
def run(self, plan: ScreenPlan, plugin: Any) -> Outcome:
"""Run ``plan``'s screen, drawn by ``plugin`` (None: nothing draws it)."""
if plugin is None:
return Outcome(ExitReason.EMPTY)
first = self.host.first_frame(plan, plugin)
if not first.shown:
return Outcome(ExitReason.ERROR if first.raised else ExitReason.EMPTY)
completed = self.host.complete_plan(plan, plugin)
if completed is None:
return Outcome(ExitReason.PREEMPTED)
screen = Screen(completed, plugin, first.accepts_display_mode,
start=self.clock.time())
if completed.frame_policy is FramePolicy.HIGH_FPS:
reason, by = self._high_fps_loop(screen)
else:
reason, by = self._static_loop(screen)
if reason is ExitReason.PREEMPTED:
# The service point that ended the loop has decided; looking
# again now, at the same instant, gives the same answer.
return self._outcome(screen, reason, by)
loop_completed = reason in (ExitReason.DURATION, ExitReason.CYCLE_COMPLETE)
# LOAD-BEARING: a change the frame loop did not end on (a dwell
# inside it, a later frame returning False) must not fall into the
# make-up dwell below. It can run for the rest of the screen's
# duration, and a freshly requested on-demand mode would sit
# invisible for that long -- or be clobbered by a queued stop.
by = self.host.check(screen, AFTER_COMPLETED_LOOP if loop_completed else AFTER_LOOP)
if by is not None:
return self._outcome(screen, ExitReason.PREEMPTED, by)
# Honour the minimum duration when a static, non-dynamic screen's
# loop ended early. A screen cut short for a plugin reload is over:
# the dwell returns at once and the rotation advances.
if (not completed.dynamic and not loop_completed
and completed.frame_policy is not FramePolicy.HIGH_FPS):
elapsed = self.clock.time() - screen.start
remaining = max(0.0, self._max(screen) - elapsed)
if remaining > 0:
self.host.dwell(remaining)
time_left = self.clock.time() - screen.start < self._max(screen)
by = self.host.check(screen, after_dwell(time_left))
if by is not None:
return self._outcome(screen, ExitReason.PREEMPTED, by)
if completed.dynamic:
self._log_dynamic_end(screen)
# The dwells above return early when a pending change (on-demand
# started or stopped, the panel scheduled off) has already decided
# what comes next; rotating now would skip it.
by = self.host.check(screen, FINAL)
if by is not None:
return self._outcome(screen, ExitReason.PREEMPTED, by)
return self._outcome(screen, reason, None)
def _outcome(self, screen: Screen, reason: ExitReason,
by: Optional[ScreenPlan]) -> Outcome:
return Outcome(reason, self.clock.time() - screen.start, preempted_by=by)
@staticmethod
def _max(screen: Screen) -> float:
return float(screen.plan.max_duration or 0.0)
@staticmethod
def _min(screen: Screen) -> float:
return float(screen.plan.min_duration or 0.0)
@staticmethod
def _ended_by(by: ScreenPlan) -> ExitReason:
return ExitReason.RELOAD if by.source is Source.RELOAD else ExitReason.PREEMPTED
# -- the frame loops ---------------------------------------------------
def _high_fps_loop(self, screen: Screen) -> Tuple[ExitReason, Optional[ScreenPlan]]:
"""Ultra-smooth frames for scrolling plugins (8 ms = 125 FPS)."""
clock, host, log = self.clock, self.host, self.log
interval = HIGH_FPS_INTERVAL
log.debug("Entering high-FPS loop for %s with display_interval=%.3fs (%.1f FPS)",
screen.mode, interval, 1.0 / interval)
target = self._max(screen)
while True:
frame_start = clock.perf_counter()
try:
result = host.draw(screen)
if isinstance(result, bool) and not result:
log.debug("Display returned False, breaking early")
return ExitReason.DISPLAY_FALSE, None
except Exception: # pylint: disable=broad-except
log.exception("Error during display update")
# Multi-display sync: send follower frame after each render
host.after_frame(screen)
host.tick()
# Throttled: one clock compare between passes. A live-priority
# scan, when one is due, happens here, before the sleep, as it
# always has; the Arbiter weighs it after the sleep.
live_scan = host.service(screen)
# Pace to the frame deadline rather than sleeping a flat
# interval on top of the work. display() has already blocked on
# the panel's vsync by this point, so an unconditional sleep is
# added to a wait that already happened. Measured on a 2x128x64
# chain at limit_refresh_rate_hz=100: ~4ms of render plus a flat
# 8ms put each iteration at ~12ms against a 10ms refresh grid, so
# every swap missed a refresh and the loop settled at 50fps where
# display_interval asks for 125 -- and with zero headroom, ~14% of
# frames slipped a further refresh, which is what reads as scroll
# stutter.
remaining = interval - (clock.perf_counter() - frame_start)
# Yield even when the frame overran its budget, so plugin update
# threads and the web UI are not starved of the GIL.
clock.sleep(remaining if remaining > 0 else 0.001)
by = host.check(screen, FRAME, live_scan)
if by is not None:
log.debug("Mode changed during high-FPS loop, breaking early")
return self._ended_by(by), by
elapsed = clock.time() - screen.start
if elapsed >= target:
log.debug("Reached high-FPS target duration %.2fs for mode %s",
target, screen.mode)
return ExitReason.DURATION, None
if self._should_exit_dynamic(screen, elapsed):
log.debug("Dynamic duration cycle complete for %s after %.2fs",
screen.mode, elapsed)
return ExitReason.CYCLE_COMPLETE, None
def _static_loop(self, screen: Screen) -> Tuple[ExitReason, Optional[ScreenPlan]]:
"""One frame a second for everything else."""
clock, host, log = self.clock, self.host, self.log
interval = STATIC_INTERVAL
log.debug("Entering normal FPS loop for %s with display_interval=%.3fs",
screen.mode, interval)
target = self._max(screen)
dynamic = screen.plan.dynamic
while True:
# Wakes for a control socket command and applies it at once,
# instead of up to a second later.
by = host.wait_frame(interval, screen)
if by is not None:
log.info("Mode changed during display loop from %s to %s (%s), "
"breaking early", screen.mode, by.mode, by.source.value)
return self._ended_by(by), by
host.tick()
elapsed = clock.time() - screen.start
if elapsed >= target:
log.debug("Reached standard target duration %.2fs for mode %s",
target, screen.mode)
return ExitReason.DURATION, None
try:
result = host.draw(screen)
if isinstance(result, bool) and not result:
# A dynamic-duration screen doesn't end on False: it
# keeps looping until its cycle completes or its maximum.
if not dynamic:
log.info("Display returned False for %s (no dynamic duration), "
"breaking early", screen.mode)
return ExitReason.DISPLAY_FALSE, None
log.debug("Display returned False for %s (dynamic duration enabled), "
"continuing loop", screen.mode)
except Exception: # pylint: disable=broad-except
log.exception("Error during display update")
# Multi-display sync: send follower frame after each render
host.after_frame(screen)
live_scan = host.service(screen)
by = host.check(screen, FRAME, live_scan)
if by is not None:
log.info("Mode changed during display loop from %s to %s (%s), "
"breaking early", screen.mode, by.mode, by.source.value)
return self._ended_by(by), by
if self._should_exit_dynamic(screen, elapsed):
log.info("Dynamic duration cycle complete for %s after %.2fs",
screen.mode, elapsed)
return ExitReason.CYCLE_COMPLETE, None
# -- dynamic duration --------------------------------------------------
def _should_exit_dynamic(self, screen: Screen, elapsed: float) -> bool:
if not screen.plan.dynamic:
return False
minimum = self._min(screen)
# A small grace period after min_duration prevents premature exits
# due to timing issues.
if elapsed < minimum + DYNAMIC_GRACE:
self.log.debug(
"_should_exit_dynamic: elapsed %.2fs < min_duration %.2fs + grace %.2fs, "
"returning False", elapsed, minimum, DYNAMIC_GRACE)
return False
cycle_complete = self.host.cycle_complete(screen)
self.log.debug(
"_should_exit_dynamic: elapsed %.2fs >= min %.2fs, cycle_complete=%s, returning %s",
elapsed, minimum + DYNAMIC_GRACE, cycle_complete, cycle_complete)
if cycle_complete:
self.log.debug("Cycle complete detected for %s after %.2fs (min: %.2fs, grace: %.2fs)",
screen.mode, elapsed, minimum, DYNAMIC_GRACE)
return cycle_complete
def _log_dynamic_end(self, screen: Screen) -> None:
"""How a dynamic-duration screen ended, for the log. Asks the plugin
once more whether its cycle is complete, as the loop always did."""
elapsed_total = self.clock.time() - screen.start
cycle_done = self.host.cycle_complete(screen)
minimum, maximum = self._min(screen), self._max(screen)
if cycle_done:
self.log.info(
"Dynamic duration cycle completed for %s after %.2fs "
"(target: %.2fs, min: %.2fs, max: %.2fs)",
screen.mode, elapsed_total, maximum, minimum, maximum)
elif elapsed_total >= maximum:
self.log.info(
"Dynamic duration cap reached before cycle completion for %s "
"(%.2fs/%ds, min: %.2fs)",
screen.mode, elapsed_total, int(maximum), minimum)
else:
self.log.debug(
"Dynamic duration cycle in progress for %s: %.2fs elapsed "
"(target: %.2fs, min: %.2fs, max: %.2fs)",
screen.mode, elapsed_total, maximum, minimum, maximum)
+2 -50
View File
@@ -18,11 +18,10 @@ import math
import sys
import time
import threading
from typing import Optional, Dict, Any, FrozenSet, List, Callable, TYPE_CHECKING
from typing import Optional, Dict, Any, List, Callable, TYPE_CHECKING
from src import display_watchdog
from src.common import render_gate
from src.plugin_system.base_plugin import finite_seconds
from src.vegas_mode.config import VegasModeConfig
from src.vegas_mode.elements import LiveEpochs
from src.vegas_mode.plugin_adapter import PluginAdapter
@@ -54,14 +53,6 @@ _FPS_HEARTBEAT_INTERVAL = 300.0
#: every plugin. Game state doesn't change within a quarter second.
_LIVE_PRIORITY_CHECK_INTERVAL = 0.25
#: Seconds a static pause shows a plugin whose display duration can't be
#: used, as long as the rotation shows it: 30 when get_display_duration()
#: raises or answers something that is not a number
#: (DisplayController._get_display_duration), 15 when it answers a number at
#: or below zero (DisplayController._resolve_durations).
_UNREADABLE_DURATION = 30.0
_NOT_POSITIVE_DURATION = 15.0
def _percentile(ordered: List[float], fraction: float) -> float:
"""Nearest-rank percentile of an already-sorted list.
@@ -101,9 +92,6 @@ class VegasModeCoordinator:
_live_reason: Optional[str] = None
# Set only while Vegas has changed the GIL switch interval; read with getattr.
_saved_switch_interval: Optional[float]
#: Plugins already warned about a display duration the pause can't use,
#: so a bad setting logs once, not at every turn. Replaced, not mutated.
_duration_warned: FrozenSet[str] = frozenset()
def __init__(
self,
@@ -1022,7 +1010,7 @@ class VegasModeCoordinator:
# Wait for the plugin's display duration. Monotonic, like the
# iteration clock: an NTP step on an RTC-less Pi would otherwise
# end the pause at once or stretch it by the correction.
duration = self._static_pause_duration(plugin)
duration = plugin.get_display_duration()
start = time.monotonic()
while time.monotonic() - start < duration:
@@ -1058,42 +1046,6 @@ class VegasModeCoordinator:
return True
def _static_pause_duration(self, plugin: 'BasePlugin') -> float:
"""Seconds a static pause shows ``plugin``: its display duration,
read the way the rotation reads it.
Several plugins return their display_duration setting straight from
config.json, so one saved as "20" or null came back as a string or
None; comparing it with the clock raised, and the pause's broad
except ended the pause at every one of the plugin's turns. inf
paused until something interrupted it, and NaN, False, 0 or a
negative number ended the pause at once. A numeric string counts
(finite_seconds); anything else, or a raise, gets
_UNREADABLE_DURATION, and a number at or below zero
_NOT_POSITIVE_DURATION, logged once per plugin.
"""
try:
value = plugin.get_display_duration()
except Exception as err: # pylint: disable=broad-except
problem = f"get_display_duration() raised {type(err).__name__}: {err}"
fallback = _UNREADABLE_DURATION
else:
seconds = finite_seconds(value)
if seconds is not None and seconds > 0:
return seconds
if seconds is None:
problem = f"display duration {value!r} is not a number"
fallback = _UNREADABLE_DURATION
else:
problem = f"display duration {value!r} is not above zero"
fallback = _NOT_POSITIVE_DURATION
plugin_id = plugin.plugin_id
if plugin_id not in self._duration_warned:
self._duration_warned = self._duration_warned | {plugin_id}
logger.warning("[%s] %s; its static pause lasts %.0fs (logged once)",
plugin_id, problem, fallback)
return fallback
def _end_static_pause(self) -> None:
"""End static pause and restore scroll state."""
should_resume_scrolling = False
+19 -2
View File
@@ -143,12 +143,24 @@ class FakeCache:
def __init__(self):
self.data: Dict[str, Any] = {}
self.cache_dir = "/nonexistent/run-loop-harness"
self._writes = 0
self._written: Dict[str, int] = {}
def get(self, key, max_age=None, memory_ttl=None):
return self.data.get(key)
def set(self, key, data, ttl=None):
self.data[key] = data
# Every write is a new file, as DiskCache's rename makes it.
self._writes += 1
self._written[key] = self._writes
def file_signature(self, key):
"""CacheManager.file_signature: None without a file, else a value
that changes with every write."""
if key not in self.data:
return None
return (self._written.get(key, 0), 0, 0)
def delete(self, key):
self.data.pop(key, None)
@@ -724,11 +736,14 @@ class RunLoopHarness:
self.clock.at(t, post)
def restore_on_demand(self, plugin_id: str, mode: Optional[str] = None,
duration: Optional[float] = None, pinned: bool = False):
duration: Optional[float] = None, pinned: bool = False,
named_mode: Optional[str] = None):
"""Start with an on-demand session resumed from the cache, as after
a restart: the state _select_startup_plugins restores, then
_populate_on_demand_modes_from_plugin, as __init__ calls it."""
_populate_on_demand_modes_from_plugin, as __init__ calls it. A
session that cannot resume is logged as ``on-demand-error``."""
dc = self.controller
dc._on_demand_named_mode = named_mode
dc.on_demand_active = True
dc.on_demand_plugin_id = plugin_id
dc.on_demand_mode = mode
@@ -739,6 +754,8 @@ class RunLoopHarness:
dc.on_demand_status = 'active'
dc.on_demand_schedule_override = True
dc._populate_on_demand_modes_from_plugin()
if dc.on_demand_status == 'error':
self.log("on-demand-error", dc.on_demand_last_error)
def wifi_message(self, t: float, message: str, duration: float = 5):
def write():
+29
View File
@@ -0,0 +1,29 @@
{
"screens": [
[0.0, "clock", 5.0, "on-demand-start", 6, false],
[5.0, "sports_live", 15.0, "duration", 15, true],
[20.0, "sports_recent", 15.0, "duration", 15, true],
[35.0, "sports_upcoming", 5.0, "on-demand-requested-stop", 6, true],
[40.0, "clock", 20.0, "duration", 20, true],
[60.0, "sports_live", 15.0, "display-false", 11, true],
[75.0, "sports_recent", 15.0, "duration", 15, true],
[90.0, "sports_upcoming", 10.0, "on-demand-start", 11, true],
[100.0, "sports_live", 0.0, "empty", 1, true],
[100.0, "sports_recent", 15.0, "duration", 15, true],
[115.0, "sports_upcoming", 15.0, "duration", 15, true],
[130.0, "sports_live", 0.0, "empty", 1, true],
[130.0, "sports_recent", 10.0, "on-demand-requested-stop", 11, true],
[140.0, "sports_upcoming", 15.0, "duration", 15, true],
[155.0, "clock", 5.0, "horizon", 5, true]
],
"events": [
[5.0, "request", "start:n1"],
[5.0, "on-demand-start", "sports"],
[40.0, "request", "stop:n2"],
[40.0, "on-demand-requested-stop"],
[100.0, "request", "start:n3"],
[100.0, "on-demand-start", "sports"],
[140.0, "request", "stop:n4"],
[140.0, "on-demand-requested-stop"]
]
}
@@ -0,0 +1,10 @@
{
"screens": [
[0.0, "clock", 20.0, "duration", 20, false],
[20.0, "weather", 20.0, "duration", 20, true],
[40.0, "clock", 20.0, "horizon", 20, true]
],
"events": [
[0.0, "on-demand-error", "restore-failed"]
]
}
+6 -2
View File
@@ -59,8 +59,8 @@ server has none.
| `unit/test_page_registry.js` | no | The page lifecycle in `js/core/registry.js` (a minimal DOM shim): one `init` per `data-page` root, `destroy` and an aborted `ctx.signal` when htmx swaps it away, a vetoed swap keeps it, lazy page modules, a root removed without htmx swept on the next swap |
| `unit/test_core_modules.js` | no | `js/core/api.js` (JSON envelope, HTTP/`status: error`/network errors, abort passthrough, the #683 login redirect, same-server paths only) and `js/core/facade.js` (`window.LEDMatrix`, deprecated aliases) |
| `unit/test_overview_reconciliation_poll.js` | no | The Overview's reconciliation-banner poll from `partials/overview.html`, run in a vm: it gives up after a bounded number of requests when the status never says done, runs only while the Overview is on screen (`LEDVisibility`, its own key), and stops once the banner is shown |
| `unit/test_display_partial_ids.js` | no | `partials/display.html`: every literal `getElementById()` in its inline scripts names an id the partial renders, and moving the brightness slider (the shipped script, in a vm with a fake DOM) updates its label without throwing |
| `unit/test_general_web_login_token.js` | no | `window.webLogin.createToken` from `partials/general.html`, run in a vm: a created API token clears the form's `data-dirty` mark (so a reload does not ask "Leave site?"), a refused one keeps it |
| `unit/test_display_partial_ids.js` | no | `js/pages/display.js` started on a fake root that answers only for the ids `partials/display.html` renders: every id it looks up (with every listener and timer it set fired) exists, and moving the brightness slider updates its label without throwing |
| `unit/test_general_web_login_token.js` | no | `createToken` from `js/pages/general.js`, imported with a fake DOM and fetch: a created API token clears the form's `data-dirty` mark (so a reload does not ask "Leave site?"), a refused one keeps it |
| `unit/test_plugin_action_delegation.js` | no | The document-level card-action delegation and `handlePluginAction` from `plugins_manager.js`, run with the handler inside an IIFE as in the real file: each action is handled once, a Starlark app uninstall goes to `DELETE /starlark/apps/<id>`, and an uninstall is confirmed once |
| `dom/test_installed_dom.js` | yes | The toolbar in a real DOM: pill/search/sort interaction, the HTMX partial re-swap, and a `getComputedStyle` check that `.filter-pill[data-active]` really matches the emitted markup |
| `dom/test_store_dom.js` | yes | Store pagination, per-page, category, tri-state Installed button, and persistence across a re-boot, against the live registry |
@@ -69,6 +69,10 @@ server has none.
| `dom/test_durations_page.js` | yes | The Rotation tab (`js/pages/durations.js`) with the real `plugin-order-list.js` widget: one plugin-list request per swap, one move per click after repeated swaps, a swap cancels the request in flight, a late widget is waited for |
| `dom/test_operation_history_page.js` | yes | The Operation History tab (`js/pages/operation-history.js`): one request per swap and per Refresh, the plugin filter filled once, paging, filters, search, Clear, error/login states, hostile values stay text |
| `dom/test_raw_json_page.js` | yes | The Config Editor tab (`js/pages/raw-json.js`): one POST per Save after repeated swaps, Format/Validate, invalid JSON never sent, a save survives a swap, the old global entry points |
| `dom/test_schedule_page.js` | yes | The Schedule tab (`js/pages/schedule.js`) with the real `schedule-picker` widget: both pickers drawn once per swap from the saved config, one notification per save answer after repeated swaps, the brightness label, a late widget waited for, the old global entry points |
| `dom/test_visibility_service.js` | yes (no server) | `js/core/visibility.js` with the real `LEDVisibility` from `app-shell.js` and the real registry: start/stop with the active tab and the browser tab's visibility, no interval while hidden or after a swap-out, registrations independent, the no-`LEDVisibility` fallback |
| `dom/test_display_page.js` | yes | The Display tab (`js/pages/display.js`) with the real `plugin-order-list` widget and `LEDVisibility`: one page, one sync interval and one action per control after repeated swaps, the sync poll only while on screen and never after a swap-out, sync states as text, the debounced scroll-speed hint, `updateSyncUI`'s entry point |
| `dom/test_general_page.js` | yes | The General tab (`js/pages/general.js`) with the real `timezone-selector` widget: the picker drawn once per swap, one request per Security action after repeated swaps, hostile token names stay text, refused/network/login answers, a write survives a swap, `webLogin`'s entry points |
| `dom/test_backup_restore_page.js` | yes | The Backup & Restore tab (`js/pages/backup-restore.js`): one request per action after repeated swaps, the upload and restore options, reads cancelled and writes not on a swap, hostile names stay text, the old global entry points |
| `dom/test_tools_sections.js` | yes | The Tools tab's MQTT bridge and Pixlet editor sections: form prefill, the write-only password (blank means unchanged), the running-session banner and countdown, and that the editor link points at the host you loaded the page from |
+325
View File
@@ -0,0 +1,325 @@
// The Display tab as a page module (static/v3/js/pages/display.js), in a
// real DOM (jsdom) with the real server-rendered partial, the real
// plugin-order-list widget, the real window.LEDVisibility (app-shell.js)
// behind ctx.visibility, and the real API's answer shapes. Built like
// test_cache_page.js:
//
// * the partial ships no <script> and no inline handlers; its root is
// data-page="display"
// * after five swaps: one mounted page, the Vegas order drawn once, each
// control acting once (brightness, resolution, Vegas and double-sided
// toggles, the Advanced section toggle, one debounced scroll-speed hint
// request)
// * the sync status is polled only while the Display tab is on screen and
// the browser tab visible: no interval runs while hidden, and none after
// the partial is swapped out
// * sync states drawn as text; a failed poll says "unavailable", a login
// redirect draws nothing
// * a widget that loads late is waited for, and a page swapped away while
// waiting starts nothing
// * window.updateSyncUI's entry point still works
const http = require('http');
const fs = require('fs');
const path = require('path');
const { pathToFileURL } = require('url');
const { JSDOM, VirtualConsole } = require('jsdom');
const BASE = process.env.BASE || 'http://localhost:5000';
const JS = path.resolve(__dirname, '../../../web_interface/static/v3/js');
const get = p => new Promise((res, rej) =>
http.get(BASE + p, r => { let d = ''; r.on('data', c => d += c); r.on('end', () => res(d)); }).on('error', rej));
const load = f => import(pathToFileURL(path.join(JS, f)).href);
const tick = ms => new Promise(r => setTimeout(r, ms || 0));
let pass = 0, fail = 0;
const ok = (l, c, x) => c ? (pass++, console.log(' ok ' + l))
: (fail++, console.log(' FAIL ' + l + (x !== undefined ? ' -> ' + JSON.stringify(x).slice(0, 300) : '')));
(async () => {
const partial = await get('/partials/display');
const realSync = JSON.parse(await get('/api/v3/sync/status'));
const smooth = JSON.parse(await get('/api/v3/config/scroll-speed-advice?speed=50&min=1&max=200'));
const rough = JSON.parse(await get('/api/v3/config/scroll-speed-advice?speed=37&min=1&max=200'));
const { createRegistry } = await load('core/registry.js');
const { createApi } = await load('core/api.js');
const { createVisibility } = await load('core/visibility.js');
const displayPage = await load('pages/display.js');
console.log('\n── Display tab: page module (real DOM) ──');
ok('the partial ships no inline script', !/<script/i.test(partial));
ok('the partial has no inline handlers', !/\son(click|change|input)=/i.test(partial));
ok('the partial root is data-page="display"', /data-page="display"/.test(partial));
ok('the Advanced toggle names its action',
/data-action="toggle-section"\s+data-section="display-section-advanced-hardware"/.test(partial));
ok('the real sync status answers in the shape the page reads',
realSync.status === 'success' && realSync.data && typeof realSync.data.state === 'string', realSync);
ok('the real scroll-speed advice answers in the shape the page reads',
smooth.status === 'success' && smooth.data.applied && Array.isArray(rough.data.alternatives)
&& rough.data.alternatives.length > 0, rough);
const errs = [];
const logged = [];
const vc = new VirtualConsole();
vc.on('jsdomError', e => errs.push(String(e.message || e).split('\n')[0]));
vc.on('error', (...a) => logged.push(a.join(' ')));
const dom = new JSDOM(`<!doctype html><html><body><div id="display-content">${partial}</div></body></html>`,
{ url: BASE + '/', virtualConsole: vc, runScripts: 'outside-only' });
const { window } = dom;
const doc = window.document;
const panel = doc.getElementById('display-content');
// The browser tab's visibility and the app's active tab, under test control.
let hidden = false;
Object.defineProperty(doc, 'hidden', { get: () => hidden, configurable: true });
const setHidden = v => { hidden = v; doc.dispatchEvent(new window.Event('visibilitychange')); };
const setTab = tab => doc.dispatchEvent(new window.CustomEvent('ledmatrix:tab-changed', { detail: { tab } }));
// Intervals, counted. Timeouts (the hint's debounce, the widget retry) are real.
const intervals = new Map();
let nextInterval = 1;
window.setInterval = (fn, ms) => { const id = nextInterval++; intervals.set(id, { fn, ms }); return id; };
window.clearInterval = id => { intervals.delete(id); };
const fireIntervals = () => [...intervals.values()].forEach(i => i.fn());
const HOSTILE = '<img src=x onerror="window.pwned=1">';
const plugins = [
{ id: 'clock', name: 'Clock', enabled: true },
{ id: 'weather', name: HOSTILE, enabled: true },
];
let syncAnswer = { status: 'success', data: { role: 'leader', state: 'no_peer' } };
let syncMode = 'ok';
let advice = smooth;
const requests = [];
function fakeFetch(url, init = {}) {
requests.push(url);
const respond = (status, body, headers) => Promise.resolve({
status, ok: status >= 200 && status < 300,
headers: { get: h => (headers || {})[h] || null },
json: () => Promise.resolve(body),
text: () => Promise.resolve(JSON.stringify(body)),
});
if (url === '/api/v3/plugins/installed') return respond(200, { status: 'success', data: { plugins } });
if (url.startsWith('/api/v3/config/scroll-speed-advice?')) return respond(200, advice);
if (url === '/api/v3/sync/status') {
if (syncMode === 'network') return Promise.reject(new TypeError('Failed to fetch'));
if (syncMode === 'login') return respond(401, { status: 'error' }, { 'X-LEDMatrix-Login': '/login' });
return respond(200, syncAnswer);
}
return respond(404, { status: 'error', message: 'unexpected ' + url });
}
window.fetch = fakeFetch;
// The shell: LEDVisibility (no Alpine here, so the active tab is the last
// ledmatrix:tab-changed; the SSE streams open stand-in EventSources), and
// the shared toggleSection the Advanced button calls.
window.getApp = () => null;
window.EventSource = class { addEventListener() {} removeEventListener() {} close() {} };
window.eval(fs.readFileSync(path.join(JS, 'app-shell.js'), 'utf8'));
const toggled = [];
window.toggleSection = id => toggled.push(id);
window.eval(fs.readFileSync(path.join(JS, 'widgets/plugin-order-list.js'), 'utf8'));
const widget = window.PluginOrderList;
ok('the widget script defines PluginOrderList', !!(widget && widget.init));
const visibility = createVisibility({ window });
const registry = createRegistry({
document: doc,
context: { api: createApi({ fetch: fakeFetch }), notify: () => {} },
mountContext: ctx => ({ visibility: visibility.forPage(ctx) }),
});
registry.register('display', displayPage);
const $ = id => doc.getElementById(id);
const root = () => doc.querySelector('[data-page="display"]');
const count = prefix => requests.filter(u => u.startsWith(prefix)).length;
const syncPolls = () => count('/api/v3/sync/status');
async function swap(html) {
panel.dispatchEvent(new window.CustomEvent('htmx:beforeSwap', { bubbles: true, detail: { target: panel, shouldSwap: true } }));
panel.innerHTML = html === undefined ? partial : html;
panel.dispatchEvent(new window.CustomEvent('htmx:afterSwap', { bubbles: true, detail: { target: panel } }));
await tick(20);
}
function fire(el, type) { el.dispatchEvent(new window.Event(type, { bubbles: true })); }
function setRole(role) { $('sync_role').value = role; fire($('sync_role'), 'change'); }
setTab('display');
await registry.start();
await tick(200);
// ── first load ──────────────────────────────────────────────────────────
ok('one plugin-list request on start', count('/api/v3/plugins/installed') === 1, requests);
ok('one scroll-speed hint request on start (after the debounce)', count('/api/v3/config/scroll-speed-advice') === 1, requests);
ok('the saved role is standalone: no sync request, no interval work',
$('sync_role').value === 'standalone' && syncPolls() === 0, [$('sync_role').value, syncPolls()]);
ok('the sync poll interval runs while the tab is on screen', intervals.size === 1
&& [...intervals.values()][0].ms === 5000, intervals.size);
ok('the status bar is hidden for standalone', $('sync_status_bar').classList.contains('hidden'));
// ── five swaps ──────────────────────────────────────────────────────────
for (let i = 0; i < 5; i++) await swap();
await tick(200);
ok('one mounted page after five swaps', registry.list().length === 1, registry.list().length);
ok('one sync interval, not six', intervals.size === 1, intervals.size);
ok('one plugin-list request per swap', count('/api/v3/plugins/installed') === 6, count('/api/v3/plugins/installed'));
ok('the Vegas order drawn once, not stacked', doc.querySelectorAll('#vegas_plugin_order .plugin-order-item').length === 2,
doc.querySelectorAll('#vegas_plugin_order .plugin-order-item').length);
ok('a hostile plugin name is shown as text', $('vegas_plugin_order').textContent.includes(HOSTILE)
&& !doc.querySelector('#vegas_plugin_order img') && !window.pwned);
// ── the controls ────────────────────────────────────────────────────────
$('brightness').value = '42';
fire($('brightness'), 'input');
ok('the brightness value follows the slider', $('brightness-value').textContent === '42', $('brightness-value').textContent);
$('rows').value = '32'; $('cols').value = '64'; $('chain_length').value = '3'; $('parallel').value = '2';
fire($('parallel'), 'input');
ok('the resolution readout is cols x chain by rows x parallel',
$('display-resolution-value').textContent === '192 × 64 pixels', $('display-resolution-value').textContent);
$('orientation').value = '90';
fire($('orientation'), 'change');
ok('...swapped for a 90-degree orientation', $('display-resolution-value').textContent === '64 × 192 pixels',
$('display-resolution-value').textContent);
$('rows').value = '';
fire($('rows'), 'input');
ok('...and a dash while a field is empty', $('display-resolution-value').textContent === '—');
for (const [box, settings, shown] of [['vegas_scroll_enabled', 'vegas_scroll_settings', 'block'],
['double_sided_enabled', 'double_sided_settings', 'grid']]) {
$(box).checked = true; fire($(box), 'change');
const on = $(settings).style.display;
$(box).checked = false; fire($(box), 'change');
ok(`${box} shows and hides its settings`, on === shown && $(settings).style.display === 'none',
[on, $(settings).style.display]);
}
root().querySelector('[data-action="toggle-section"]').click();
ok('the Advanced button toggles its section once', toggled.join() === 'display-section-advanced-hardware', toggled);
// ── the scroll-speed hint ───────────────────────────────────────────────
const hints = count('/api/v3/config/scroll-speed-advice');
advice = rough;
for (const v of ['36', '37', '38']) { $('vegas_scroll_speed').value = v; fire($('vegas_scroll_speed'), 'input'); }
ok('the speed value follows the slider', $('vegas_scroll_speed_value').textContent === '38');
await tick(250);
ok('three quick moves make one hint request', count('/api/v3/config/scroll-speed-advice') === hints + 1,
count('/api/v3/config/scroll-speed-advice') - hints);
ok('...for the last speed', requests.filter(u => u.includes('advice')).pop().includes('speed=38'));
const buttons = $('vegas_scroll_speed_hint').querySelectorAll('button');
ok('a rough speed offers the smooth ones', buttons.length === rough.data.alternatives.length
&& /will run as/.test($('vegas_scroll_speed_hint').textContent), $('vegas_scroll_speed_hint').textContent);
buttons[0].click();
ok('picking one sets the slider', $('vegas_scroll_speed').value === String(Math.round(rough.data.alternatives[0].pixels_per_second))
&& $('vegas_scroll_speed_value').textContent === $('vegas_scroll_speed').value, $('vegas_scroll_speed').value);
advice = smooth;
await tick(250);
ok('...and asks again', count('/api/v3/config/scroll-speed-advice') === hints + 2);
ok('a smooth speed says so', /^Smooth on this panel/.test($('vegas_scroll_speed_hint').textContent),
$('vegas_scroll_speed_hint').textContent);
// ── sync: the role ──────────────────────────────────────────────────────
setRole('leader');
await tick(20);
ok('choosing Leader shows the status bar', !$('sync_status_bar').classList.contains('hidden'));
ok('...hides Position', $('setting-display-sync_follower_position').style.display === 'none');
ok('...and asks for the status once', syncPolls() === 1, syncPolls());
ok('...drawn as text', $('sync_status_content').textContent.includes('No follower detected'),
$('sync_status_content').textContent);
setRole('follower');
await tick(20);
ok('choosing Follower shows Position', $('setting-display-sync_follower_position').style.display === '');
// ── sync: the poll runs only while on screen ────────────────────────────
let polls = syncPolls();
fireIntervals();
await tick(20);
ok('each interval tick polls once', syncPolls() === polls + 1, syncPolls() - polls);
setTab('logs');
ok('switching to another tab clears the interval', intervals.size === 0, intervals.size);
polls = syncPolls();
setTab('display');
await tick(20);
ok('switching back polls at once', syncPolls() === polls + 1 && intervals.size === 1, [syncPolls() - polls, intervals.size]);
setHidden(true);
ok('hiding the browser tab clears the interval', intervals.size === 0, intervals.size);
polls = syncPolls();
setHidden(false);
await tick(20);
ok('showing it polls at once', syncPolls() === polls + 1 && intervals.size === 1, [syncPolls() - polls, intervals.size]);
// ── sync: states ────────────────────────────────────────────────────────
async function poll() { fireIntervals(); await tick(20); return $('sync_status_content').textContent; }
syncAnswer = { status: 'success', data: { role: 'leader', state: 'connected', peer_ip: HOSTILE, peer_chain: 2 } };
ok('a connected follower, its address as text', (await poll()).includes('Follower connected — ' + HOSTILE)
&& !$('sync_status_content').querySelector('img'), $('sync_status_content').textContent);
syncAnswer = { status: 'success', data: { role: 'leader', state: 'incompatible', error: 'rows differ ' + HOSTILE } };
ok('incompatible panels show the reason as text', (await poll()).includes('incompatible')
&& !$('sync_error_detail').classList.contains('hidden') && $('sync_error_text').textContent === 'rows differ ' + HOSTILE
&& !$('sync_error_detail').querySelector('img'));
syncAnswer = { status: 'success', data: realSync.data };
ok('the real server\'s answer is drawn', (await poll()).length > 0, $('sync_status_content').textContent);
syncMode = 'network';
ok('a failed poll says unavailable', (await poll()).includes('Sync status unavailable'));
syncAnswer = { status: 'success', data: { role: 'follower', state: 'follower', leader_ip: '10.0.0.2' } };
syncMode = 'ok';
ok('receiving from a leader', (await poll()).includes('Receiving from leader — 10.0.0.2'));
syncMode = 'login';
ok('a login redirect draws nothing', (await poll()).includes('Receiving from leader'));
syncMode = 'ok';
// ── standalone stops asking ─────────────────────────────────────────────
setRole('standalone');
polls = syncPolls();
fireIntervals();
await tick(20);
ok('standalone hides the bar and the poll asks nothing',
$('sync_status_bar').classList.contains('hidden') && syncPolls() === polls, syncPolls() - polls);
// ── window.updateSyncUI ─────────────────────────────────────────────────
$('sync_role').value = 'leader';
polls = syncPolls();
displayPage.updateSyncUI();
await tick(20);
ok('updateSyncUI() applies the role and asks once',
!$('sync_status_bar').classList.contains('hidden') && syncPolls() === polls + 1, syncPolls() - polls);
// ── swapped out ─────────────────────────────────────────────────────────
await swap('<p>another tab</p>');
ok('nothing left mounted', registry.list().length === 0, registry.list().length);
ok('no interval left running', intervals.size === 0, intervals.size);
polls = syncPolls();
setTab('overview');
setTab('display');
setHidden(true);
setHidden(false);
await tick(20);
ok('a swapped-out page never polls again', syncPolls() === polls && intervals.size === 0, syncPolls() - polls);
const hintsGone = count('/api/v3/config/scroll-speed-advice');
await swap();
$('vegas_scroll_speed').value = '40';
fire($('vegas_scroll_speed'), 'input');
await swap('<p>another tab</p>');
await tick(250);
ok('a hint still waiting out its debounce at the swap is never asked for',
count('/api/v3/config/scroll-speed-advice') === hintsGone, count('/api/v3/config/scroll-speed-advice') - hintsGone);
// ── the widget loads late ───────────────────────────────────────────────
delete window.PluginOrderList;
const beforeLate = count('/api/v3/plugins/installed');
await swap();
ok('no list request while the widget is missing', count('/api/v3/plugins/installed') === beforeLate);
window.PluginOrderList = widget;
await tick(150);
ok('the list starts once the widget arrives', count('/api/v3/plugins/installed') === beforeLate + 1);
delete window.PluginOrderList;
await swap();
const beforeGone = count('/api/v3/plugins/installed');
await swap('<p>another tab</p>');
window.PluginOrderList = widget;
await tick(250);
ok('a page swapped away while waiting starts nothing', count('/api/v3/plugins/installed') === beforeGone);
ok('no console errors', logged.length === 0, logged);
ok('no DOM errors', errs.length === 0, errs);
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);
})().catch(e => { console.error(e); process.exit(1); });
+272
View File
@@ -0,0 +1,272 @@
// The General tab as a page module (static/v3/js/pages/general.js), in a real
// DOM (jsdom) with the real server-rendered partial, the real timezone
// widget and the real web-login endpoints' answer shapes. Built like
// test_cache_page.js:
//
// * the partial ships no <script> and no inline handlers; its root is
// data-page="general" and the Security section's forms and buttons name
// an action
// * the timezone picker is drawn once per swap-in, with the saved zone
// * after five swaps, each Security action makes exactly one request
// * a login change is a write: a swap does not cancel it, its result is
// still reported, and nothing is drawn into the page that has gone
// * token names reach the page as text
// * the settings form itself is left to htmx
// * window.webLogin's entry points still work
const http = require('http');
const fs = require('fs');
const path = require('path');
const { pathToFileURL } = require('url');
const { JSDOM, VirtualConsole } = require('jsdom');
const BASE = process.env.BASE || 'http://localhost:5000';
const JS = path.resolve(__dirname, '../../../web_interface/static/v3/js');
const get = p => new Promise((res, rej) =>
http.get(BASE + p, r => { let d = ''; r.on('data', c => d += c); r.on('end', () => res(d)); }).on('error', rej));
const load = f => import(pathToFileURL(path.join(JS, f)).href);
const tick = ms => new Promise(r => setTimeout(r, ms || 0));
let pass = 0, fail = 0;
const ok = (l, c, x) => c ? (pass++, console.log(' ok ' + l))
: (fail++, console.log(' FAIL ' + l + (x !== undefined ? ' -> ' + JSON.stringify(x).slice(0, 300) : '')));
(async () => {
const partial = await get('/partials/general');
const realTokens = JSON.parse(await get('/api/v3/auth/tokens'));
const { createRegistry } = await load('core/registry.js');
const { createApi } = await load('core/api.js');
const generalPage = await load('pages/general.js');
console.log('\n── General tab: page module (real DOM) ──');
ok('the partial ships no inline script', !/<script/i.test(partial));
ok('the partial has no inline click or submit handlers', !/\son(click|submit|input)=/i.test(partial));
ok('the partial root is data-page="general"', /data-page="general"/.test(partial));
const security = /id="web-login-settings"/.test(partial);
ok('the server renders the Security section (it has a login store)', security);
ok('the real token list answers in the shape the section shows',
realTokens.status === 'success' && realTokens.data && Array.isArray(realTokens.data.tokens), realTokens);
ok('the Security forms name their action',
/<form[^>]*data-action="set-password"/.test(partial) && /<form[^>]*data-action="create-token"/.test(partial));
ok('the Copy button names its action', /data-action="copy-token"/.test(partial));
const errs = [];
const logged = [];
const vc = new VirtualConsole();
vc.on('jsdomError', e => errs.push(String(e.message || e).split('\n')[0]));
vc.on('error', (...a) => logged.push(a.join(' ')));
const dom = new JSDOM(`<!doctype html><html><body><div id="general-content">${partial}</div></body></html>`,
{ url: BASE + '/', virtualConsole: vc, runScripts: 'outside-only' });
const { window } = dom;
const doc = window.document;
const panel = doc.getElementById('general-content');
require('../led_escape').install(window);
window.eval(fs.readFileSync(path.join(JS, 'widgets/registry.js'), 'utf8'));
window.eval(fs.readFileSync(path.join(JS, 'widgets/timezone-selector.js'), 'utf8'));
const widgets = window.LEDMatrixWidgets;
ok('the widget scripts register timezone-selector', !!(widgets && widgets.get('timezone-selector')));
let confirmAnswer = true;
const confirms = [];
window.confirm = m => { confirms.push(m); return confirmAnswer; };
const reloads = [];
window.htmx = { ajax: (method, url, opts) => reloads.push([method, url, opts.target]) };
const HOSTILE = '<img src=x onerror="window.pwned=1">';
let mode = 'ok';
let nextId = 1;
const requests = [];
const pending = [];
function fakeFetch(url, init) {
requests.push({ url, method: init.method, body: init.body ? JSON.parse(init.body) : undefined });
const respond = (status, body, headers) => Promise.resolve({
status, ok: status >= 200 && status < 300,
headers: { get: h => (headers || {})[h] || null },
text: () => Promise.resolve(JSON.stringify(body)),
});
if (mode === 'network') return Promise.reject(new TypeError('Failed to fetch'));
if (mode === 'login') return respond(401, { status: 'error' }, { 'X-LEDMatrix-Login': '/login' });
if (mode === 'refuse') return respond(400, { status: 'error', message: 'Give the token a name.' });
if (url === '/api/v3/auth/tokens' && init.method === 'POST') {
const id = 'tok' + (nextId++);
const name = JSON.parse(init.body).name;
const answer = () => respond(201, {
status: 'success', message: 'Token created. Copy it now: it is not shown again.',
data: { token: 'lmx_' + id, record: { id, name, prefix: 'lmx_' + id.slice(0, 3), created_at: '2026-10-04T00:00:00' } },
});
if (mode === 'hang') return new Promise(resolve => pending.push(() => resolve(answer())));
return answer();
}
if (url.startsWith('/api/v3/auth/tokens/') && init.method === 'DELETE') {
return respond(200, { status: 'success', message: 'Token revoked.', data: { tokens: [] } });
}
if (url === '/api/v3/auth/password') {
return respond(200, { status: 'success', message: 'Login is on. Other browsers now need the password.' });
}
return respond(404, { status: 'error', message: 'unexpected ' + url });
}
const notes = [];
const registry = createRegistry({
document: doc,
context: { api: createApi({ fetch: fakeFetch }), notify: (m, t) => notes.push([m, t]) },
});
registry.register('general', generalPage);
const $ = id => doc.getElementById(id);
const root = () => doc.querySelector('[data-page="general"]');
const timezoneWidgets = () => $('timezone_container').querySelectorAll('.timezone-selector-widget').length;
const rows = () => doc.querySelectorAll('#web-login-tokens [data-token-id]');
const calls = (method, prefix) => requests.filter(r => r.method === method && r.url.startsWith(prefix));
const lastNote = () => notes[notes.length - 1] || [];
function submit(form) {
const event = new window.Event('submit', { bubbles: true, cancelable: true });
form.dispatchEvent(event);
return event;
}
const form = action => root().querySelector(`form[data-action="${action}"]`);
async function swap(html) {
panel.dispatchEvent(new window.CustomEvent('htmx:beforeSwap', { bubbles: true, detail: { target: panel, shouldSwap: true } }));
panel.innerHTML = html === undefined ? partial : html;
panel.dispatchEvent(new window.CustomEvent('htmx:afterSwap', { bubbles: true, detail: { target: panel } }));
await tick(20);
}
await registry.start();
await tick(20);
// ── the timezone picker ─────────────────────────────────────────────────
const savedZone = $('timezone_container').dataset.timezone;
ok('the partial carries the saved timezone', !!savedZone, savedZone);
ok('the timezone picker is drawn once', timezoneWidgets() === 1, timezoneWidgets());
ok('...holding the saved zone', $('timezone_data') && $('timezone_data').value === savedZone,
$('timezone_data') && $('timezone_data').value);
ok('...posted as "timezone"', $('timezone_data') && $('timezone_data').name === 'timezone');
for (let i = 0; i < 5; i++) await swap();
ok('one mounted page after five swaps', registry.list().length === 1, registry.list().length);
ok('the timezone picker is drawn once, not stacked', timezoneWidgets() === 1, timezoneWidgets());
// ── the settings form is htmx's ─────────────────────────────────────────
const settings = root().querySelector('form[hx-post="/api/v3/config/main"]');
ok('submitting the settings form is not prevented', settings && !submit(settings).defaultPrevented);
ok('...and makes no request of the page\'s own', requests.length === 0, requests.length);
if (security) {
// ── create a token ────────────────────────────────────────────────────
const before = rows().length;
const create = form('create-token');
create.querySelector('[name="name"]').value = HOSTILE;
create.setAttribute('data-dirty', '');
ok('Create token is handled by the page', submit(create).defaultPrevented);
await tick(20);
ok('one POST to /api/v3/auth/tokens', calls('POST', '/api/v3/auth/tokens').length === 1, requests);
ok('...with the name typed', calls('POST', '/api/v3/auth/tokens')[0].body.name === HOSTILE);
ok('a row is added', rows().length === before + 1, rows().length);
ok('the hostile token name is shown as text', root().querySelector('#web-login-tokens').textContent.includes(HOSTILE));
ok('...and created no element', !root().querySelector('#web-login-tokens img') && !window.pwned);
ok('the "No tokens yet" line is gone', !root().querySelector('#web-login-tokens [data-empty]'));
ok('the token is shown once', $('web-login-new-token-value').textContent === 'lmx_tok1'
&& !$('web-login-new-token').classList.contains('hidden'));
ok('the form is clean again (no "Leave site?")', !create.hasAttribute('data-dirty'));
ok('one success notification', lastNote()[1] === 'success' && /Token created/.test(lastNote()[0]), notes);
// ── copy it (plain http: not a secure context, so it is selected) ────
root().querySelector('button[data-action="copy-token"]').click();
ok('Copy selects the token where the clipboard API is unavailable',
window.getSelection().toString() === 'lmx_tok1' && /Selected/.test(lastNote()[0]), lastNote());
// ── revoke it (the row drawn by the page, so delegation covers it) ───
confirmAnswer = false;
const added = rows()[rows().length - 1];
added.querySelector('button[data-action="revoke-token"]').click();
await tick(20);
ok('a cancelled Revoke sends nothing', calls('DELETE', '/api/v3/auth/tokens/').length === 0);
ok('...after asking with the token name as written', confirms.length === 1 && confirms[0].includes(HOSTILE), confirms);
confirmAnswer = true;
added.querySelector('button[data-action="revoke-token"]').click();
await tick(20);
ok('Revoke sends one DELETE for that token',
calls('DELETE', '/api/v3/auth/tokens/').length === 1 && calls('DELETE', '/api/v3/auth/tokens/')[0].url === '/api/v3/auth/tokens/tok1',
calls('DELETE', '/api/v3/auth/tokens/'));
ok('...and removes its row', rows().length === before, rows().length);
// ── the password ──────────────────────────────────────────────────────
const pw = form('set-password');
pw.querySelector('[name="new_password"]').value = 'correct horse battery';
pw.querySelector('[name="confirm_password"]').value = 'correct horse batterY';
submit(pw);
await tick(20);
ok('mismatched passwords are never sent', calls('POST', '/api/v3/auth/password').length === 0);
ok('...and say so', lastNote()[1] === 'error' && /do not match/.test(lastNote()[0]), lastNote());
pw.querySelector('[name="confirm_password"]').value = 'correct horse battery';
submit(pw);
await tick(20);
const sent = calls('POST', '/api/v3/auth/password');
ok('a matching password is sent once', sent.length === 1, sent.length);
ok('...with the current password only when the form has one',
sent[0] && sent[0].body.new_password === 'correct horse battery'
&& (('current_password' in sent[0].body) === !!pw.querySelector('[name="current_password"]')), sent[0]);
ok('...and the section is reloaded once', reloads.length === 1 && reloads[0][1] === '/v3/partials/general'
&& reloads[0][2] === '#general-content', reloads);
// ── refused, network failure, login redirect ──────────────────────────
mode = 'refuse';
submit(form('create-token'));
await tick(20);
ok('a refused request shows the server message', lastNote()[1] === 'error' && lastNote()[0] === 'Give the token a name.', lastNote());
mode = 'network';
submit(form('create-token'));
await tick(20);
ok('a network failure says the request failed', lastNote()[1] === 'error' && /^Request failed: /.test(lastNote()[0]), lastNote());
mode = 'login';
const quiet = notes.length;
submit(form('create-token'));
await tick(20);
ok('the login redirect shows nothing (the page is leaving)', notes.length === quiet, notes.slice(quiet));
// ── a write survives a swap ───────────────────────────────────────────
mode = 'hang';
await swap();
const rowsBefore = rows().length;
form('create-token').querySelector('[name="name"]').value = 'Late';
submit(form('create-token'));
await tick(5);
await swap();
pending.shift()();
await tick(20);
ok('a token created before a swap is still reported', lastNote()[1] === 'success', lastNote());
ok('...and draws nothing into the new page', rows().length === rowsBefore
&& $('web-login-new-token').classList.contains('hidden'), rows().length);
mode = 'ok';
// ── window.webLogin ────────────────────────────────────────────────────
const viaAlias = calls('POST', '/api/v3/auth/tokens').length;
form('create-token').querySelector('[name="name"]').value = 'Alias';
await generalPage.webLogin.createToken(form('create-token'));
ok('webLogin.createToken(form) creates one token', calls('POST', '/api/v3/auth/tokens').length === viaAlias + 1);
ok('webLogin has the five old methods',
['setPassword', 'disable', 'createToken', 'copyToken', 'revoke'].every(m => typeof generalPage.webLogin[m] === 'function'));
}
// ── the widget loads late ───────────────────────────────────────────────
delete window.LEDMatrixWidgets;
await swap();
ok('nothing drawn while the widget is missing', timezoneWidgets() === 0, timezoneWidgets());
window.LEDMatrixWidgets = widgets;
await tick(150);
ok('drawn once the widget arrives', timezoneWidgets() === 1, timezoneWidgets());
delete window.LEDMatrixWidgets;
await swap();
const kept = root();
await swap('<p>another tab</p>');
window.LEDMatrixWidgets = widgets;
await tick(250);
ok('a page swapped away while waiting draws nothing', kept.querySelectorAll('.timezone-selector-widget').length === 0);
ok('nothing left mounted', registry.list().length === 0, registry.list().length);
ok('no console errors', logged.length === 0, logged);
ok('no DOM errors', errs.length === 0, errs);
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);
})().catch(e => { console.error(e); process.exit(1); });
+167
View File
@@ -0,0 +1,167 @@
// The Schedule tab as a page module (static/v3/js/pages/schedule.js), in a
// real DOM (jsdom) with the real server-rendered partial and the real
// widget registry and schedule-picker widget. Built like test_cache_page.js:
//
// * the partial ships no <script> and no inline handlers; its root is
// data-page="schedule" and carries both saved schedules as JSON
// * both pickers are drawn once per swap-in, from the saved config, however
// many swaps came first
// * each form's save is reported in exactly one notification (the forms
// are marked data-reports-result so app.js stays quiet)
// * the dim brightness label follows the slider
// * a widget that loads late is waited for, and a page swapped away while
// waiting draws nothing
// * the old globals' entry points still work
const http = require('http');
const fs = require('fs');
const path = require('path');
const { pathToFileURL } = require('url');
const { JSDOM, VirtualConsole } = require('jsdom');
const BASE = process.env.BASE || 'http://localhost:5000';
const JS = path.resolve(__dirname, '../../../web_interface/static/v3/js');
const get = p => new Promise((res, rej) =>
http.get(BASE + p, r => { let d = ''; r.on('data', c => d += c); r.on('end', () => res(d)); }).on('error', rej));
const load = f => import(pathToFileURL(path.join(JS, f)).href);
const tick = ms => new Promise(r => setTimeout(r, ms || 0));
let pass = 0, fail = 0;
const ok = (l, c, x) => c ? (pass++, console.log(' ok ' + l))
: (fail++, console.log(' FAIL ' + l + (x !== undefined ? ' -> ' + JSON.stringify(x).slice(0, 300) : '')));
(async () => {
const partial = await get('/partials/schedule');
const { createRegistry } = await load('core/registry.js');
const schedulePage = await load('pages/schedule.js');
console.log('\n── Schedule tab: page module (real DOM) ──');
ok('the partial ships no inline script', !/<script/i.test(partial));
ok('the partial has no inline handlers', !/\son(click|input|change|submit)=/i.test(partial));
ok('the forms carry no hx-on handler', !/hx-on/i.test(partial));
ok('the partial root is data-page="schedule"', /data-page="schedule"/.test(partial));
ok('both forms are marked data-reports-result',
(partial.match(/<form[^>]*data-reports-result/g) || []).length === 2);
const errs = [];
const logged = [];
const vc = new VirtualConsole();
vc.on('jsdomError', e => errs.push(String(e.message || e).split('\n')[0]));
vc.on('error', (...a) => logged.push(a.join(' ')));
const dom = new JSDOM(`<!doctype html><html><body><div id="schedule-content">${partial}</div></body></html>`,
{ url: BASE + '/', virtualConsole: vc, runScripts: 'outside-only' });
const { window } = dom;
const doc = window.document;
const panel = doc.getElementById('schedule-content');
// base.html defines LEDEscape (app-early.js) before any tab loads; the
// widget escapes with it.
require('../led_escape').install(window);
window.eval(fs.readFileSync(path.join(JS, 'widgets/registry.js'), 'utf8'));
window.eval(fs.readFileSync(path.join(JS, 'widgets/schedule-picker.js'), 'utf8'));
const widgets = window.LEDMatrixWidgets;
ok('the widget scripts register schedule-picker', !!(widgets && widgets.get('schedule-picker')));
const notes = [];
const registry = createRegistry({
document: doc,
context: { api: null, notify: (m, t) => notes.push([m, t]) },
});
registry.register('schedule', schedulePage);
const $ = id => doc.getElementById(id);
const root = () => doc.querySelector('[data-page="schedule"]');
const saved = key => JSON.parse(root().dataset[key]);
const drawn = () => ['schedule_picker_container', 'dim_schedule_picker_container']
.map(id => $(id).querySelectorAll('.schedule-picker-widget').length);
async function swap(html) {
panel.dispatchEvent(new window.CustomEvent('htmx:beforeSwap', { bubbles: true, detail: { target: panel, shouldSwap: true } }));
panel.innerHTML = html === undefined ? partial : html;
panel.dispatchEvent(new window.CustomEvent('htmx:afterSwap', { bubbles: true, detail: { target: panel } }));
await tick(20);
}
function answer(formId, xhr) {
$(formId).dispatchEvent(new window.CustomEvent('htmx:afterRequest', {
bubbles: true, detail: { xhr, elt: $(formId), successful: xhr.status < 300 } }));
}
await registry.start();
await tick(20);
// ── first load ──────────────────────────────────────────────────────────
ok('both pickers drawn once', drawn().join() === '1,1', drawn());
const schedule = saved('scheduleConfig');
const dim = saved('dimScheduleConfig');
ok('the saved config reaches the page as JSON', schedule && typeof schedule === 'object' && dim && typeof dim === 'object');
const mode = cfg => cfg.mode ? cfg.mode.replace('-', '_') : (cfg.days ? 'per_day' : 'global');
ok('the display picker shows the saved mode', $('schedule_mode_value').value === mode(schedule),
[$('schedule_mode_value').value, schedule.mode]);
ok('the dim picker shows the saved mode', $('dim_schedule_mode_value').value === mode(dim),
[$('dim_schedule_mode_value').value, dim.mode]);
ok('the dim picker shows the saved start time',
$('dim_schedule_start_time_hidden').value === (dim.start_time || '20:00'), $('dim_schedule_start_time_hidden').value);
// ── repeated swaps ──────────────────────────────────────────────────────
for (let i = 0; i < 5; i++) await swap();
ok('one mounted page after five swaps', registry.list().length === 1, registry.list().length);
ok('each picker drawn once, not stacked', drawn().join() === '1,1', drawn());
const okXhr = body => ({ status: 200, responseText: JSON.stringify(body) });
answer('schedule_form', okXhr({ status: 'success', message: 'Schedule configuration saved successfully' }));
ok('a schedule save is reported once', notes.length === 1, notes);
ok('...with the server message and status',
notes[0] && notes[0][0] === 'Schedule configuration saved successfully' && notes[0][1] === 'success', notes[0]);
answer('dim_schedule_form', okXhr({ status: 'success' }));
ok('a dim schedule save without a message says so',
notes.length === 2 && notes[1][0] === 'Dim schedule settings saved' && notes[1][1] === 'success', notes[1]);
answer('schedule_form', { status: 400, responseText: JSON.stringify({ status: 'error' }) });
ok('a refused save without a message says so',
notes.length === 3 && notes[2][0] === 'Error saving schedule' && notes[2][1] === 'error', notes[2]);
answer('dim_schedule_form', { status: 502, responseText: '<html>Bad gateway</html>' });
ok('a non-JSON answer is an error',
notes.length === 4 && notes[3][0] === 'Invalid response from server' && notes[3][1] === 'error', notes[3]);
answer('schedule_form', { status: 200, responseText: 'null' });
ok('a JSON null answer is an error, not a crash',
notes.length === 5 && notes[4][1] === 'error', notes[4]);
// An htmx request from elsewhere on the page (outside both forms) is not a save.
root().querySelector('.settings-filter').dispatchEvent(new window.CustomEvent('htmx:afterRequest', {
bubbles: true, detail: { xhr: okXhr({ status: 'success', message: 'x' }) } }));
ok('a request from outside the two forms reports nothing', notes.length === 5, notes.length);
// ── the brightness label ────────────────────────────────────────────────
$('dim_brightness').value = '42';
$('dim_brightness').dispatchEvent(new window.Event('input', { bubbles: true }));
ok('the dim brightness label follows the slider', $('dim_brightness_display').textContent === '42%',
$('dim_brightness_display').textContent);
// ── the widget loads late ───────────────────────────────────────────────
delete window.LEDMatrixWidgets;
await swap();
ok('nothing drawn while the widget is missing', drawn().join() === '0,0', drawn());
window.LEDMatrixWidgets = widgets;
await tick(150);
ok('drawn once the widget arrives', drawn().join() === '1,1', drawn());
delete window.LEDMatrixWidgets;
await swap();
const kept = root();
await swap('<p>another tab</p>');
window.LEDMatrixWidgets = widgets;
await tick(250);
ok('a page swapped away while waiting draws nothing',
kept.querySelectorAll('.schedule-picker-widget').length === 0);
ok('nothing left mounted', registry.list().length === 0, registry.list().length);
// ── the old globals ─────────────────────────────────────────────────────
await swap();
const before = notes.length;
schedulePage.handleScheduleResponse({ target: $('schedule_form'), detail: { xhr: okXhr({ status: 'success' }) } });
schedulePage.handleDimScheduleResponse({ target: $('dim_schedule_form'), detail: { xhr: okXhr({ status: 'success' }) } });
ok('handleScheduleResponse(event) and handleDimScheduleResponse(event) report once each',
notes.length === before + 2 && notes[before][0] === 'Schedule settings saved'
&& notes[before + 1][0] === 'Dim schedule settings saved', notes.slice(before));
ok('no console errors', logged.length === 0, logged);
ok('no DOM errors', errs.length === 0, errs);
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);
})().catch(e => { console.error(e); process.exit(1); });
+211
View File
@@ -0,0 +1,211 @@
// The page-visibility service (static/v3/js/core/visibility.js), in a real
// DOM (jsdom) with the real window.LEDVisibility from app-shell.js and the
// real page registry, wired the way core/boot.js wires them (each mount gets
// ctx.visibility from mountContext):
//
// * whileVisible(start, stop) runs start() only while the page's tab is the
// active tab AND the browser tab is visible, stop() when either changes
// * every(ms, fn) calls fn at once and then on an interval while visible;
// no interval is left running while hidden
// * everything a page registered stops when the page is swapped out, and a
// page that registers after it was destroyed starts nothing
// * registrations never replace each other (two timers on one page, two
// pages, or a classic partial's own LEDVisibility key)
// * without LEDVisibility, the browser tab's visibility alone decides
//
// Needs jsdom but no server.
const fs = require('fs');
const path = require('path');
const { pathToFileURL } = require('url');
const { JSDOM, VirtualConsole } = require('jsdom');
const JS = path.resolve(__dirname, '../../../web_interface/static/v3/js');
const load = f => import(pathToFileURL(path.join(JS, f)).href);
const tick = ms => new Promise(r => setTimeout(r, ms || 0));
let pass = 0, fail = 0;
const ok = (l, c, x) => c ? (pass++, console.log(' ok ' + l))
: (fail++, console.log(' FAIL ' + l + (x !== undefined ? ' -> ' + JSON.stringify(x).slice(0, 300) : '')));
(async () => {
const { createRegistry } = await load('core/registry.js');
const { createVisibility } = await load('core/visibility.js');
console.log('\n── Page visibility service (real DOM, real LEDVisibility) ──');
const errs = [];
const logged = [];
const vc = new VirtualConsole();
vc.on('jsdomError', e => errs.push(String(e.message || e).split('\n')[0]));
vc.on('error', (...a) => logged.push(a.join(' ')));
const dom = new JSDOM('<!doctype html><html><body><div id="display-content"></div><div id="logs-content"></div></body></html>',
{ url: 'http://localhost/', virtualConsole: vc, runScripts: 'outside-only' });
const { window } = dom;
const doc = window.document;
// The browser tab's visibility, under the test's control.
let hidden = false;
Object.defineProperty(doc, 'hidden', { get: () => hidden, configurable: true });
function setHidden(value) {
hidden = value;
doc.dispatchEvent(new window.Event('visibilitychange'));
}
function setTab(tab) {
doc.dispatchEvent(new window.CustomEvent('ledmatrix:tab-changed', { detail: { tab } }));
}
// Intervals, counted: the point is that none is left running.
const intervals = new Map();
let nextInterval = 1;
window.setInterval = (fn, ms) => { const id = nextInterval++; intervals.set(id, { fn, ms }); return id; };
window.clearInterval = id => { intervals.delete(id); };
const fireIntervals = () => [...intervals.values()].forEach(i => i.fn());
// The real LEDVisibility (app-shell.js). Alpine is absent, so the active
// tab is the last ledmatrix:tab-changed. The SSE streams are not under
// test: they open stand-in EventSources.
window.getApp = () => null;
window.EventSource = class { addEventListener() {} removeEventListener() {} close() {} };
window.eval(fs.readFileSync(path.join(JS, 'app-shell.js'), 'utf8'));
ok('app-shell.js defines LEDVisibility', !!(window.LEDVisibility && window.LEDVisibility.onActive));
const visibility = createVisibility({ window });
const registry = createRegistry({
document: doc,
context: {},
mountContext: ctx => ({ visibility: visibility.forPage(ctx) }),
});
const log = [];
let polls = 0;
const handles = [];
registry.register('display', {
init(root, ctx) {
handles.push(ctx.visibility);
ctx.visibility.whileVisible(() => log.push('start'), () => log.push('stop'));
ctx.visibility.every(5000, () => { polls++; });
},
});
let otherRuns = 0;
registry.register('logs', {
init(root, ctx) { ctx.visibility.every(1000, () => { otherRuns++; }); },
});
const panel = doc.getElementById('display-content');
async function swap(html) {
panel.dispatchEvent(new window.CustomEvent('htmx:beforeSwap', { bubbles: true, detail: { target: panel, shouldSwap: true } }));
panel.innerHTML = html;
panel.dispatchEvent(new window.CustomEvent('htmx:afterSwap', { bubbles: true, detail: { target: panel } }));
await tick(10);
}
// A classic partial's own registration, keyed by its tab name: the
// service's registrations must not replace it, nor it them.
let classic = 0;
window.LEDVisibility.onActive('display', () => { classic++; }, () => {});
setTab('overview');
await registry.start();
await swap('<div data-page="display"></div>');
// ── mounted on another tab ──────────────────────────────────────────────
ok('mounted while another tab is active: nothing starts', log.length === 0 && polls === 0, [log, polls]);
ok('...and no interval runs', intervals.size === 0, intervals.size);
ok('isVisible() is false', handles[0].isVisible() === false);
ok('the page\'s tab is its name', handles[0].tab === 'display');
// ── its tab comes on screen ─────────────────────────────────────────────
setTab('display');
ok('switching to the tab runs start()', log.join() === 'start', log);
ok('every() calls fn at once', polls === 1, polls);
ok('...and sets one interval at the asked period', intervals.size === 1 && [...intervals.values()][0].ms === 5000,
[...intervals.values()].map(i => i.ms));
ok('isVisible() is true', handles[0].isVisible() === true);
ok('the classic registration still runs alongside', classic === 1, classic);
fireIntervals();
fireIntervals();
ok('fn runs on each interval', polls === 3, polls);
// ── the browser tab is hidden, then shown ───────────────────────────────
setHidden(true);
ok('hiding the browser tab runs stop()', log.join() === 'start,stop', log);
ok('...and clears the interval', intervals.size === 0, intervals.size);
ok('isVisible() is false while hidden', handles[0].isVisible() === false);
setHidden(false);
ok('showing it again runs start()', log.join() === 'start,stop,start', log);
ok('...and fn at once, with one interval again', polls === 4 && intervals.size === 1, [polls, intervals.size]);
// ── another tab ─────────────────────────────────────────────────────────
setTab('logs');
ok('switching away runs stop() and clears the interval', log.join() === 'start,stop,start,stop' && intervals.size === 0,
[log, intervals.size]);
setTab('display');
ok('switching back restarts it', log.length === 5 && polls === 5 && intervals.size === 1, [log, polls]);
// ── the partial is swapped out while on screen ──────────────────────────
await swap('<p>no page here</p>');
ok('a swap-out stops it', log[log.length - 1] === 'stop', log);
ok('...and leaves no interval running', intervals.size === 0, intervals.size);
const before = [log.length, polls];
setTab('overview');
setTab('display');
setHidden(true);
setHidden(false);
ok('a destroyed page never starts again', log.length === before[0] && polls === before[1], [log, polls]);
ok('the classic registration keeps running after the swap', classic === 5, classic);
// ── five swaps, then one page ───────────────────────────────────────────
for (let i = 0; i < 5; i++) await swap('<div data-page="display"></div>');
ok('after five swaps one interval runs, not five', intervals.size === 1, intervals.size);
const pollsBefore = polls;
fireIntervals();
ok('...and one poll per tick', polls === pollsBefore + 1, polls - pollsBefore);
// ── two timers on one page, and an end function ─────────────────────────
const h = handles[handles.length - 1];
let a = 0, b = 0;
const endA = h.every(100, () => { a++; });
h.every(200, () => { b++; });
ok('two more timers on one page both start', a === 1 && b === 1 && intervals.size === 3, [a, b, intervals.size]);
endA();
endA();
ok('an end function stops just its own timer (twice is harmless)', intervals.size === 2, intervals.size);
// ── a page that registers after it was destroyed ────────────────────────
const gone = handles[handles.length - 1];
await swap('<p>gone</p>');
ok('the swap-out clears every timer the page had', intervals.size === 0, intervals.size);
let late = 0;
const end = gone.every(1000, () => { late++; });
ok('registering after destroy starts nothing', late === 0 && intervals.size === 0 && typeof end === 'function');
// ── a start() that throws ───────────────────────────────────────────────
await swap('<div data-page="display"></div>');
const h2 = handles[handles.length - 1];
const loggedBefore = logged.length;
let afterThrow = 0;
h2.whileVisible(() => { throw new Error('boom'); }, () => {});
h2.whileVisible(() => { afterThrow++; }, () => {});
ok('a throwing start() is logged', logged.length === loggedBefore + 1 && /boom|start failed/.test(logged.join()),
logged.slice(loggedBefore));
ok('...and the next registration still starts', afterThrow === 1);
await swap('<p>gone</p>');
// ── without LEDVisibility (a page outside base.html) ────────────────────
const bare = createVisibility({ window, tracker: () => null }).forPage({ name: 'standalone', signal: new window.AbortController().signal });
const seen = [];
bare.whileVisible(() => seen.push('start'), () => seen.push('stop'));
ok('without LEDVisibility, a visible document starts at once', seen.join() === 'start', seen);
setHidden(true);
ok('...and hiding it stops', seen.join() === 'start,stop', seen);
ok('...isVisible() follows the document', bare.isVisible() === false);
setHidden(false);
ok('...and showing it starts again', seen.join() === 'start,stop,start', seen);
ok('registrations need functions', (() => { try { h2.whileVisible(null, null); return false; } catch { return true; } })());
ok('every() needs a positive period', (() => { try { h2.every(0, () => {}); return false; } catch { return true; } })());
ok('the logs page was never on screen: it never ran', otherRuns === 0, otherRuns);
ok('no DOM errors', errs.length === 0, errs);
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);
})().catch(e => { console.error(e); process.exit(1); });
+3 -1
View File
@@ -36,7 +36,9 @@ const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.js',
const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js',
'dom/test_tools_sections.js', 'dom/test_cache_page.js',
'dom/test_durations_page.js', 'dom/test_operation_history_page.js',
'dom/test_raw_json_page.js', 'dom/test_backup_restore_page.js'];
'dom/test_raw_json_page.js', 'dom/test_backup_restore_page.js',
'dom/test_schedule_page.js', 'dom/test_general_page.js',
'dom/test_visibility_service.js', 'dom/test_display_page.js'];
function reachable(url) {
return new Promise(res => {
+76 -68
View File
@@ -1,21 +1,22 @@
// The Display tab's inline script must only look up elements the partial
// The Display tab's page module must only look up elements the partial
// renders.
//
// Its brightness slider handler also wrote to #brightness-display, a "LED
// brightness: N%" line that #387 removed from partials/display.html. The
// Its brightness slider handler once also wrote to #brightness-display, a
// "LED brightness: N%" line that #387 removed from partials/display.html. The
// lookup returned null, so every movement of the slider threw a TypeError.
// This checks every literal getElementById() in the partial's inline scripts
// against the ids its markup renders, and runs the shipped script in a vm
// with a fake DOM (null for an id the markup lacks, as in a browser) to move
// the slider.
// This imports the shipped module (static/v3/js/pages/display.js), starts it
// on a fake root that answers only for the ids the partial's markup renders
// (null for any other, as in a browser), fires every listener it registered,
// and checks that every id it asked for exists. Then it moves the slider.
//
// No jsdom and no server needed.
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const { pathToFileURL } = require('url');
const PARTIAL = path.resolve(__dirname, '../../../web_interface/templates/v3/partials/display.html');
const JS = path.resolve(__dirname, '../../../web_interface/static/v3/js');
let pass = 0, fail = 0;
const ok = (label, cond, extra) => cond
@@ -23,86 +24,93 @@ const ok = (label, cond, extra) => cond
: (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' ' + JSON.stringify(extra) : '')));
const html = fs.readFileSync(PARTIAL, 'utf8');
const blocks = [...html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/gi)];
const scripts = blocks.map(m => m[1]);
// The markup is what lies between the script blocks (sliced around them, not
// a replace(), which CodeQL reads as an incomplete HTML sanitizer).
let markup = '';
let from = 0;
for (const m of blocks) {
markup += html.slice(from, m.index);
from = m.index + m[0].length;
}
markup += html.slice(from);
const rendered = new Set([...markup.matchAll(/\bid="([^"{}]+)"/g)].map(m => m[1]));
const rendered = new Set([...html.matchAll(/\bid="([^"{}]+)"/g)].map(m => m[1]));
console.log('\n── Display partial: element lookups ──');
// 1. Static: every literal lookup names an id the partial renders.
const lookups = scripts.flatMap(s => [...s.matchAll(/getElementById\('([^']+)'\)/g)].map(m => m[1]));
const missing = [...new Set(lookups.filter(id => !rendered.has(id)))];
ok('the inline scripts look elements up', lookups.length > 0, lookups.length);
ok('every looked-up id is rendered by the partial', missing.length === 0, missing);
// 2. Behaviour: moving the brightness slider updates its label and throws nothing.
function fakeElement(id) {
const listeners = {};
const classes = new Set();
return {
id, value: '', textContent: '', min: '', max: '', checked: false,
id, value: '1', textContent: '', min: '', max: '', checked: false,
style: {}, dataset: {}, className: '',
classList: {
add: c => classes.add(c), remove: c => classes.delete(c),
toggle: (c, on) => (on === undefined ? (classes.has(c) ? classes.delete(c) : classes.add(c)) : (on ? classes.add(c) : classes.delete(c))),
contains: c => classes.has(c),
},
addEventListener: (type, fn) => { (listeners[type] ||= []).push(fn); },
dispatchEvent() { return true; },
appendChild() {},
getAttribute: () => null,
listeners,
};
}
const main = scripts.find(s => s.includes("getElementById('brightness')"));
ok('found the script that wires the brightness slider', !!main);
if (main) {
const elements = new Map();
const document = {
readyState: 'complete',
hidden: false,
getElementById: id => {
if (!rendered.has(id)) return null;
if (!elements.has(id)) elements.set(id, fakeElement(id));
return elements.get(id);
},
createElement: () => fakeElement(''),
createTextNode: () => ({}),
addEventListener() {},
};
const window = {
LEDEscape: { html: v => String(v), attr: v => String(v) },
LEDVisibility: { onActive() {} },
};
const context = {
window, document, console, URLSearchParams,
fetch: () => new Promise(() => {}),
setTimeout: () => 0, clearTimeout() {}, setInterval: () => 0, clearInterval() {},
};
vm.createContext(context);
let loadError = null;
try { vm.runInContext(main, context); } catch (e) { loadError = e; }
ok('the script loads', !loadError, loadError && String(loadError));
(async () => {
console.log('\n── Display page module: element lookups ──');
const display = await import(pathToFileURL(path.join(JS, 'pages/display.js')).href);
const slider = elements.get('brightness');
const handlers = (slider && slider.listeners.input) || [];
ok('the slider has an input handler', handlers.length > 0);
const asked = new Set();
const elements = new Map();
const timers = [];
const win = {
setTimeout: fn => { timers.push(fn); return timers.length; },
clearTimeout() {},
URLSearchParams,
Event: class { constructor(type) { this.type = type; } },
PluginOrderList: { init() {} },
};
const doc = { defaultView: win, createElement: () => fakeElement(''), createTextNode: () => ({}) };
const rootListeners = {};
const root = {
ownerDocument: doc,
querySelector(sel) {
const m = /^#([\w-]+)$/.exec(sel);
if (!m) throw new Error('unexpected selector ' + sel);
asked.add(m[1]);
if (!rendered.has(m[1])) return null;
if (!elements.has(m[1])) elements.set(m[1], fakeElement(m[1]));
return elements.get(m[1]);
},
addEventListener: (type, fn) => { (rootListeners[type] ||= []).push(fn); },
contains: () => true,
};
const never = () => new Promise(() => {});
const polls = [];
const ctx = {
root, name: 'display', state: {}, signal: { aborted: false },
api: { get: never },
visibility: { every: (ms, fn) => { polls.push(ms); fn(); return () => {}; } },
};
let loadError = null;
try { display.init(root, ctx); } catch (e) { loadError = e; }
ok('init() runs', !loadError, loadError && String(loadError));
ok('the sync status is polled through ctx.visibility', polls.length === 1 && polls[0] === 5000, polls);
// Fire everything it wired, so every lookup it can make is made.
let thrown = null;
try {
for (const el of elements.values()) {
for (const fns of Object.values(el.listeners)) fns.forEach(fn => fn.call(el, { target: el }));
}
while (timers.length) timers.shift()();
} catch (e) { thrown = e; }
ok('its listeners and timers run without throwing', !thrown, thrown && String(thrown));
const missing = [...asked].filter(id => !rendered.has(id));
ok('it looks elements up', asked.size > 10, asked.size);
ok('every looked-up id is rendered by the partial', missing.length === 0, missing);
const slider = elements.get('brightness') || fakeElement('brightness');
const handlers = slider.listeners.input || [];
ok('the slider has an input handler', handlers.length > 0);
slider.value = '42';
thrown = null;
try { handlers.forEach(fn => fn.call(slider, { target: slider })); } catch (e) { thrown = e; }
ok('moving the slider throws nothing', !thrown, thrown && String(thrown));
ok('...and shows the new value', elements.get('brightness-value').textContent === '42',
elements.get('brightness-value').textContent);
}
const label = elements.get('brightness-value');
ok('...and shows the new value', !!label && label.textContent === '42', label && label.textContent);
console.log(`\n${pass} passed, ${fail} failed\n`);
process.exit(fail ? 1 : 0);
display.destroy(root, ctx);
console.log(`\n${pass} passed, ${fail} failed\n`);
process.exit(fail ? 1 : 0);
})().catch(e => { console.error(e); process.exit(1); });
+31 -44
View File
@@ -3,32 +3,24 @@
// app.js marks a form data-dirty on any input in it and clears the mark only
// after a successful htmx request; its beforeunload handler then asks "Leave
// site?" while any visible form is still dirty. The token form posts with
// fetch (window.webLogin.createToken in partials/general.html), so after a
// token was created the form stayed dirty and reloading the page while the
// General tab was open prompted about changes that had been saved.
// fetch (createToken in static/v3/js/pages/general.js), so after a token was
// created the form stayed dirty and reloading the page while the General tab
// was open prompted about changes that had been saved.
//
// Runs the shipped inline script in a vm with a fake fetch and DOM -- no jsdom
// and no server needed.
// Imports the shipped page module and runs createToken with a fake fetch and
// DOM -- no jsdom and no server needed.
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const { pathToFileURL } = require('url');
const PARTIAL = path.resolve(__dirname, '../../../web_interface/templates/v3/partials/general.html');
const JS = path.resolve(__dirname, '../../../web_interface/static/v3/js');
const load = f => import(pathToFileURL(path.join(JS, f)).href);
let pass = 0, fail = 0;
const ok = (label, cond, extra) => cond
? (pass++, console.log(' ok ' + label))
: (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' ' + JSON.stringify(extra) : '')));
function webLoginScript() {
const html = fs.readFileSync(PARTIAL, 'utf8');
const scripts = [...html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/gi)].map(m => m[1]);
const found = scripts.find(s => s.includes('window.webLogin = {'));
if (!found) throw new Error('webLogin script not found in general.html');
return found;
}
function el() {
const classes = new Set(['hidden']);
return {
@@ -38,29 +30,28 @@ function el() {
};
}
function load(answer) {
async function setup(answer) {
const { createApi } = await load('core/api.js');
const general = await load('pages/general.js');
const elements = {
'web-login-tokens': el(),
'web-login-new-token-value': el(),
'web-login-new-token': el(),
'#web-login-tokens': el(),
'#web-login-new-token-value': el(),
'#web-login-new-token': el(),
};
const notes = [];
const window = { showNotification: (m, t) => notes.push([m, t]), alert() {}, confirm: () => true };
const context = {
window, console,
document: {
getElementById: id => elements[id] || null,
createElement: () => el(),
querySelectorAll: () => [],
},
fetch: () => Promise.resolve({
ok: answer.ok, status: answer.ok ? 200 : 400,
json: () => Promise.resolve(answer.body),
}),
const doc = { createElement: () => el(), defaultView: { confirm: () => true } };
const root = { ownerDocument: doc, querySelector: sel => elements[sel] || null, querySelectorAll: () => [] };
const fetch = () => Promise.resolve({
ok: answer.ok, status: answer.ok ? 200 : 400,
headers: { get: () => null },
text: () => Promise.resolve(JSON.stringify(answer.body)),
});
const ctx = {
root, state: {}, signal: { aborted: false },
api: createApi({ fetch }),
notify: (m, t) => notes.push([m, t]),
};
vm.createContext(context);
vm.runInContext(webLoginScript(), context);
return { webLogin: context.window.webLogin, elements, notes };
return { general, ctx, elements, notes };
}
function dirtyForm() {
@@ -74,29 +65,25 @@ function dirtyForm() {
};
}
const flush = async () => { for (let i = 0; i < 10; i++) await new Promise(r => setImmediate(r)); };
(async () => {
console.log('\n── General tab: API token form ──');
{
const t = load({ ok: true, body: {
const t = await setup({ ok: true, body: {
status: 'success', message: 'Token created',
data: { token: 'lmx_secret', record: { id: 't1', name: 'Home Assistant', prefix: 'lmx_sec' } },
} });
const form = dirtyForm();
t.webLogin.createToken(form);
await flush();
ok('the new token is shown', t.elements['web-login-new-token-value'].textContent === 'lmx_secret');
await t.general.createToken(t.ctx, form);
ok('the new token is shown', t.elements['#web-login-new-token-value'].textContent === 'lmx_secret');
ok('a created token leaves the form clean (no "Leave site?" on reload)',
!form.hasAttribute('data-dirty'));
}
{
const t = load({ ok: false, body: { status: 'error', message: 'Name is required' } });
const t = await setup({ ok: false, body: { status: 'error', message: 'Name is required' } });
const form = dirtyForm();
t.webLogin.createToken(form);
await flush();
await t.general.createToken(t.ctx, form);
ok('a refused request reports the error', t.notes.some(([m, type]) => type === 'error' && /Name is required/.test(m)),
t.notes);
ok('...and keeps the form dirty: nothing was saved', form.hasAttribute('data-dirty'));
+4 -5
View File
@@ -103,17 +103,16 @@ const ESCAPERS = [
'static/v3/js/widgets/text-input.js', 'function escapeHtml(text) {', 'escapeHtml', false],
['slider.js (escapeAttr)',
'static/v3/js/widgets/slider.js', 'function escapeAttr(text) {', 'escapeAttr', false],
['display.html (escapeAttr)',
'templates/v3/partials/display.html', 'function escapeAttr(text) {', 'escapeAttr', false],
['tools.html (escHtml)',
'templates/v3/partials/tools.html', 'function escHtml(s) {', 'escHtml', false],
['tools.html (phEscape)',
'templates/v3/partials/tools.html', 'function phEscape(s) {', 'phEscape', false],
['logs.html (escapeHtml)',
'templates/v3/partials/logs.html', 'function escapeHtml(text) {', 'escapeHtml', false],
// cache.html, backup_restore.html and operation_history.html have no
// script any more: their js/pages/ modules draw server data with
// textContent, and each page's suite in test/js/dom/ checks a hostile value.
// cache.html, backup_restore.html, operation_history.html and display.html
// have no script any more (display.html's two escapers were never called):
// their js/pages/ modules draw server data with textContent, and each
// page's suite in test/js/dom/ checks a hostile value.
];
// The breakout payload: closes a double-quoted attribute and opens an event
+40
View File
@@ -242,6 +242,46 @@ function recorder(log) {
ok('has()', reg.has('dup') && !reg.has('nope'));
}
console.log('\n10. mountContext adds per-mount fields, after the shared ones');
{
const doc = new Doc();
const panel = doc.body.appendChild(new El('div', { id: 'panel' }));
panel.appendChild(new El('div', { id: 'a', [PAGE_ATTRIBUTE]: 'demo' }));
const seen = [];
const made = [];
const reg = createRegistry({
document: doc, context: { api: 'shared' }, logger: quiet,
mountContext(ctx) {
made.push([ctx.name, ctx.root.getAttribute('id'), !!ctx.signal, ctx.api]);
return { bound: { root: ctx.root, signal: ctx.signal } };
},
});
reg.register('demo', { init(root, ctx) { seen.push(ctx); } });
await reg.start();
await tick();
ok('mountContext sees the mount\'s name, root, signal and the shared services',
made.length === 1 && made[0].join() === 'demo,a,true,shared', made);
ok('...and its fields reach init()', seen.length === 1 && seen[0].bound && seen[0].bound.root === seen[0].root, seen.length);
fire(panel, 'htmx:beforeSwap', { target: panel, shouldSwap: true });
panel.replaceChildren(new El('div', { id: 'b', [PAGE_ATTRIBUTE]: 'demo' }));
fire(panel, 'htmx:afterSwap', { target: panel });
await tick();
ok('called again for each new mount, with that mount\'s signal',
made.length === 2 && seen.length === 2 && !!seen[1].bound && seen[1].bound.signal === seen[1].signal
&& seen[0].signal.aborted, made);
const errors = [];
const doc2 = new Doc();
doc2.body.appendChild(new El('div', { id: 'c', [PAGE_ATTRIBUTE]: 'demo' }));
const reg2 = createRegistry({ document: doc2, logger: { error: (...a) => errors.push(a.join(' ')) },
mountContext() { throw new Error('boom'); } });
let started = 0;
reg2.register('demo', { init() { started++; } });
await reg2.start();
await tick();
ok('a throwing mountContext is logged and the page still starts', started === 1 && errors.length === 1, errors);
}
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);
})().catch(e => { console.error(e); process.exit(1); });
+23 -4
View File
@@ -19,7 +19,7 @@ const ok = (label, cond, extra) => cond
? (pass++, console.log(' ok ' + label))
: (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' ' + JSON.stringify(extra) : '')));
function load() {
function load(notes) {
const handlers = {};
const listen = (target) => (type, fn) => { (handlers[target + ':' + type] ||= []).push(fn); };
const banner = { style: { display: 'none' } };
@@ -43,14 +43,18 @@ function load() {
removeItem: (k) => { delete store[k]; },
getItem: (k) => (k in store ? store[k] : null),
},
showNotification: () => {},
showNotification: (m, t) => { if (notes) notes.push([m, t]); },
setTimeout: () => 0,
};
vm.createContext(context);
vm.runInContext(fs.readFileSync(path.join(V3, 'app.js'), 'utf8'), context);
const afterRequest = (handlers['body:htmx:afterRequest'] || [])[0];
const fire = ({ status = 200, body, path: reqPath = '/api/v3/anything', reportsItself = false }) => {
const elt = { closest: () => (reportsItself ? {} : null) };
// `marks`: the attribute selectors the requesting element (or its form)
// matches, for app.js's elt.closest(<selector list>).
const fire = ({ status = 200, body, path: reqPath = '/api/v3/anything', reportsItself = false, marks = [] }) => {
const elt = {
closest: (sel) => (reportsItself || sel.split(',').some(s => marks.includes(s.trim())) ? {} : null),
};
afterRequest({
target: { closest: () => null },
detail: {
@@ -100,5 +104,20 @@ console.log('\nhtmx after-request follows the flag, not the URL');
ok('a flagged answer raises it, even from a form that reports itself', t.banner.style.display === 'block');
}
console.log('\nthe server message toast');
{
const notes = [];
const t = load(notes);
const answer = { status: 'success', message: 'Schedule saved' };
t.fire({ body: answer });
ok('a plain htmx request shows the server message', notes.length === 1 && notes[0][0] === 'Schedule saved', notes);
t.fire({ body: answer, marks: ['[hx-on\\:htmx\\:after-request]'] });
ok('a form with its own hx-on after-request handler does not', notes.length === 1, notes);
// Page modules (js/pages/schedule.js) report a form's save from a listener
// and mark the form data-reports-result instead of an hx-on attribute.
t.fire({ body: answer, marks: ['[data-reports-result]'] });
ok('nor does a form a page module reports for (data-reports-result)', notes.length === 1, notes);
}
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);
@@ -1,123 +0,0 @@
"""POST /plugins/install asks for a restart by the id the plugin installed as.
A store install needs a display restart when config.json already enables the
plugin (a reinstall, or a config carried over): the display loads a plugin
when its ``enabled`` flag changes, and this flag did not. The route read the
flag under the registry id it was given. An aliased entry installs under
another id -- ``weather`` installs a directory whose manifest declares
``ledmatrix-weather``, and its config section is ``ledmatrix-weather`` -- so
reinstalling an enabled Weather never reported that a restart was needed,
and the display kept running the old copy.
"""
import json
from unittest.mock import MagicMock
import pytest
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401
INSTALL = "/api/v3/plugins/install"
@pytest.fixture
def store(api_v3_module, tmp_path):
"""The store installs registry entry ``weather`` as ``installed_id``."""
manager = api_v3_module.api_v3.plugin_store_manager
manager.install_plugin.return_value = True
manager.get_registry_info.return_value = None
manager._find_plugin_path.return_value = None
def installs_as(installed_id):
path = tmp_path / installed_id
path.mkdir()
(path / "manifest.json").write_text(json.dumps({"id": installed_id}),
encoding="utf-8")
manager._find_plugin_path.side_effect = (
lambda pid: path if pid == "weather" else None)
manager.installs_as = installs_as
return manager
@pytest.fixture
def config(api_v3_module):
"""config.json with an ``enabled`` flag for each plugin id given."""
def sections(enabled):
api_v3_module.api_v3.config_manager.load_config.return_value = {
plugin_id: {"enabled": flag} for plugin_id, flag in enabled.items()}
return sections
@pytest.fixture
def queued(api_v3_module):
queue = MagicMock()
def enqueue(operation_type, plugin_id, operation_callback=None):
queue.callback_result = operation_callback(MagicMock())
return "op-1"
queue.enqueue_operation.side_effect = enqueue
api_v3_module.api_v3.operation_queue = queue
return queue
def _direct(client):
return client.post(INSTALL, json={"plugin_id": "weather"}).get_json()
def _queued(client, queue):
client.post(INSTALL, json={"plugin_id": "weather"})
return queue.callback_result
class TestDirectInstall:
def test_an_aliased_install_enabled_under_its_installed_id_asks_for_a_restart(
self, api_v3_client, store, config):
store.installs_as("ledmatrix-weather")
config({"ledmatrix-weather": True})
body = _direct(api_v3_client)
assert body["status"] == "success"
assert body["restart_required"] is True
assert body["restart_message"]
def test_an_enabled_section_under_the_registry_id_alone_does_not(
self, api_v3_client, store, config):
"""The display knows the plugin as ledmatrix-weather; nothing runs
under a section called weather."""
store.installs_as("ledmatrix-weather")
config({"weather": True})
assert _direct(api_v3_client)["restart_required"] is False
def test_an_aliased_install_that_is_not_enabled_needs_no_restart(
self, api_v3_client, store, config):
store.installs_as("ledmatrix-weather")
config({"ledmatrix-weather": False})
assert _direct(api_v3_client)["restart_required"] is False
def test_an_install_under_its_own_id_is_unchanged(self, api_v3_client, store, config):
store.installs_as("weather")
config({"weather": True})
assert _direct(api_v3_client)["restart_required"] is True
def test_an_install_that_cannot_be_found_uses_the_requested_id(
self, api_v3_client, store, config):
config({"weather": True})
assert _direct(api_v3_client)["restart_required"] is True
class TestQueuedInstall:
def test_an_aliased_install_enabled_under_its_installed_id_asks_for_a_restart(
self, api_v3_client, store, config, queued):
store.installs_as("ledmatrix-weather")
config({"ledmatrix-weather": True})
result = _queued(api_v3_client, queued)
assert result["success"] is True
assert result["restart_required"] is True
assert result["restart_message"]
def test_an_enabled_section_under_the_registry_id_alone_does_not(
self, api_v3_client, store, config, queued):
store.installs_as("ledmatrix-weather")
config({"weather": True})
assert _queued(api_v3_client, queued)["restart_required"] is False
+1 -1
View File
@@ -29,7 +29,7 @@ def installed(api_v3_module, api_v3_client, tmp_path):
api.plugin_catalog.plugins_dir = str(tmp_path) # no manifest on disk
api.plugin_catalog.get_all_plugin_info = MagicMock(return_value=[info])
api.plugin_catalog.get_plugin_display_modes = MagicMock(return_value=declared_modes)
api.plugin_store_manager.get_registry_info = MagicMock(return_value=None)
api.plugin_store_manager.get_cached_registry_info = MagicMock(return_value=None)
api.config_manager.load_config = MagicMock(return_value={})
response = api_v3_client.get('/api/v3/plugins/installed')
assert response.status_code == 200
+1 -1
View File
@@ -22,7 +22,7 @@ def installed(api_v3_module, api_v3_client, tmp_path):
info.update(manifest_extra)
api.plugin_catalog.plugins_dir = str(tmp_path) # no manifest on disk
api.plugin_catalog.get_all_plugin_info = MagicMock(return_value=[info])
api.plugin_store_manager.get_registry_info = MagicMock(return_value=None)
api.plugin_store_manager.get_cached_registry_info = MagicMock(return_value=None)
api.config_manager.load_config = MagicMock(return_value={})
response = api_v3_client.get('/api/v3/plugins/installed')
assert response.status_code == 200
+87 -7
View File
@@ -1,10 +1,13 @@
"""POST /display/on-demand/start and /stop: control socket first, mailbox fallback.
The routes hand the request to the display over the control socket
(src/ipc) and get an acknowledgement. On any failure -- no socket (a stopped
display, or one older than the socket), a timeout, a refusal, a bug in the
client -- they write the file mailbox exactly as they did before the socket
existed. These tests pin both paths, that exactly one of them is used, that
(src/ipc) and get an acknowledgement. When the socket could not carry the
request -- no socket (a stopped display, or one older than the socket), a
refused or timed-out connect, a display too old to know the command, a bug
in the client -- they write the file mailbox exactly as they did before the
socket existed. When the display had the request and failed it (a full
queue, bad arguments, no answer in time) the route says so and writes
nothing. These tests pin each path, that at most one of them is used, that
the response says which, and that the request id is the same either way (the
display deduplicates on it).
@@ -101,12 +104,15 @@ class TestSocketPath:
class TestMailboxFallback:
@pytest.mark.parametrize("reason", [
"no_socket", "refused", "timeout", "closed", "bad_response", "invalid_request",
"busy", "unknown_command", "unsupported_version", "disabled", "unsupported",
"busy", "forbidden", "unknown_command", "unsupported_version", "disabled",
"unsupported",
])
def test_any_socket_failure_writes_the_mailbox_as_before(
def test_a_request_the_socket_never_carried_writes_the_mailbox(
self, api_v3_client, service, reason):
# sent=False: the display never had it (no socket, a refused or
# timed-out connect, turned away at the door).
with patch(f"{CLIENT}.on_demand_start",
side_effect=control_client.ControlError(reason, "x")):
side_effect=control_client.ControlError(reason, "x", sent=False)):
resp = api_v3_client.post(START_URL, json={
"plugin_id": "weather", "mode": "weather_current",
"duration": 60, "pinned": True})
@@ -168,6 +174,60 @@ class TestMailboxFallback:
assert data["socket_error"] in ("disabled", "unsupported") # Linux, Windows
class TestTheDisplayHadIt:
"""Once the display has the request, its answer stands: no mailbox copy.
A busy queue, a refusal or silence after the request was sent mean the
display may have applied it, or would refuse the mailbox copy too, so
the route reports the failure instead of posting it a second time.
"""
@pytest.mark.parametrize("reason,status", [
("busy", 503), ("internal", 503), ("timeout", 503), ("closed", 503),
("bad_response", 503), ("invalid_args", 400),
])
def test_start_is_answered_with_the_failure(self, api_v3_client, service, reason, status):
with patch(f"{CLIENT}.on_demand_start",
side_effect=control_client.ControlError(reason, "x", sent=True)):
resp = api_v3_client.post(START_URL, json={"plugin_id": "weather"})
assert resp.status_code == status
body = resp.get_json()
assert body["status"] == "error"
assert body["data"]["transport"] == "socket"
assert body["data"]["socket_error"] == reason
assert _mailbox_writes(service["cache"]) == []
assert not [call for call in service["calls"] if call[0] == "systemctl"]
def test_stop_is_answered_with_the_failure(self, api_v3_client, service):
with patch(f"{CLIENT}.on_demand_stop",
side_effect=control_client.ControlError("busy", "x", sent=True)):
resp = api_v3_client.post(STOP_URL, json={})
assert resp.status_code == 503
assert _mailbox_writes(service["cache"]) == []
def test_a_stop_with_stop_service_still_stops_the_service(self, api_v3_client, service):
with patch(f"{CLIENT}.on_demand_stop",
side_effect=control_client.ControlError("timeout", "x", sent=True)), \
patch("web_interface.blueprints.api_v3.display._stop_display_service",
return_value={"active": False}) as stop:
resp = api_v3_client.post(STOP_URL, json={"stop_service": True})
assert resp.status_code == 200
data = resp.get_json()["data"]
assert data["transport"] == "socket" and data["socket_error"] == "timeout"
stop.assert_called_once()
assert _mailbox_writes(service["cache"]) == []
@pytest.mark.parametrize("reason", ["unknown_command", "unsupported_version"])
def test_an_older_display_that_does_not_speak_it_gets_the_mailbox(
self, api_v3_client, service, reason):
# The upgrade case: new web interface, display still on an old build.
with patch(f"{CLIENT}.on_demand_start",
side_effect=control_client.ControlError(reason, "x", sent=True)):
data = api_v3_client.post(START_URL, json={"plugin_id": "weather"}).get_json()["data"]
assert data["transport"] == "mailbox" and data["socket_error"] == reason
assert len(_mailbox_writes(service["cache"])) == 1
@pytest.mark.skipif(not c.socket_supported(), reason="AF_UNIX sockets are Linux/macOS only")
class TestRealSocket:
@pytest.fixture
@@ -204,3 +264,23 @@ class TestRealSocket:
data = api_v3_client.post(START_URL, json={"plugin_id": "weather"}).get_json()["data"]
assert data["transport"] == "mailbox" and data["socket_error"] == "no_socket"
assert len(_mailbox_writes(service["cache"])) == 1
def test_a_full_queue_is_reported_not_mailed(self, api_v3_client, service, monkeypatch):
import shutil
import tempfile
from src.ipc.server import ControlServer
d = tempfile.mkdtemp(prefix="lmipc-")
path = os.path.join(d, "control.sock")
server = ControlServer(path, status_provider=dict, queue_size=1)
assert server.start()
monkeypatch.setenv(c.SOCKET_PATH_ENV, path)
try:
first = api_v3_client.post(START_URL, json={"plugin_id": "weather"})
assert first.get_json()["data"]["transport"] == "socket"
second = api_v3_client.post(START_URL, json={"plugin_id": "weather"})
assert second.status_code == 503
assert second.get_json()["data"]["socket_error"] == "busy"
assert _mailbox_writes(service["cache"]) == []
finally:
server.close()
shutil.rmtree(d, ignore_errors=True)
+326
View File
@@ -0,0 +1,326 @@
"""Four web answers that disagreed with the rig they describe (found on ledpi).
1. POST /config/schedule refused the schedule GET returns on a fresh install
(config.template.json: per-day, every day off, schedule disabled) with
"At least one day must be enabled", as did /config/dim-schedule. A
disabled schedule needs no enabled day.
2. A brightness-only POST /config/main answered ``restart_required: true``,
though the display applies brightness live (brightness.set over the
socket, and the config watcher). The flag now says whether anything
changed that the running display does not pick up by itself.
3. /health stayed "healthy" with the display service stopped: only the
sub-checks changed. Service inactive, no socket and no live heartbeat
is now ``display_loop: stopped`` and "degraded".
4. /display/current-status kept answering ``is_display_active: true`` from
the cache for up to 120 s after the display stopped. With no socket and
no live heartbeat it is now unknown.
"""
import copy
import json
import os
import sys
import time
from pathlib import Path
from unittest.mock import patch
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent))
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
from src import display_watchdog # noqa: E402
from src.ipc import client as control_client # noqa: E402
from web_interface import display_state # noqa: E402
REPO = Path(__file__).resolve().parent.parent
TEMPLATE = json.loads((REPO / 'config' / 'config.template.json').read_text(encoding='utf-8'))
@pytest.fixture
def store(api_v3_module, monkeypatch):
state = {'config': {}, 'saves': 0}
api_v3_module.api_v3.config_manager.load_config.side_effect = \
lambda *a, **k: copy.deepcopy(state['config'])
def fake_save(_manager, config, **_kwargs):
state['config'] = copy.deepcopy(config)
state['saves'] += 1
return True, ''
monkeypatch.setattr(api_v3_module, '_save_config_atomic', fake_save)
return state
# --- 1. schedules ---------------------------------------------------------------
SCHEDULE_ROUTES = [('/api/v3/config/schedule', 'schedule'),
('/api/v3/config/dim-schedule', 'dim_schedule')]
@pytest.mark.parametrize('route,section', SCHEDULE_ROUTES)
def test_the_templates_disabled_per_day_schedule_saves_back(api_v3_client, store,
route, section):
stored = copy.deepcopy(TEMPLATE[section])
stored['mode'] = 'per-day'
assert stored['enabled'] is False
assert not any(day['enabled'] for day in stored['days'].values())
store['config'] = {section: copy.deepcopy(stored)}
read = api_v3_client.get(route).get_json()['data']
resp = api_v3_client.post(route, json=read)
assert resp.status_code == 200, resp.get_json()
saved = store['config'][section]
assert saved['enabled'] is False and saved['mode'] == 'per-day'
# The disabled days keep their times: switching one on finds them.
assert saved['days'] == stored['days']
@pytest.mark.parametrize('route,section', SCHEDULE_ROUTES)
def test_an_enabled_per_day_schedule_still_needs_a_day(api_v3_client, store, route, section):
body = copy.deepcopy(TEMPLATE[section])
body.update(enabled=True, mode='per-day')
resp = api_v3_client.post(route, json=body)
assert resp.status_code == 400
assert 'At least one day must be enabled' in resp.get_json()['message']
assert store['saves'] == 0
@pytest.mark.parametrize('route', [r for r, _ in SCHEDULE_ROUTES])
def test_the_pickers_form_post_with_every_day_off_saves(api_v3_client, store, route):
"""What schedule-picker.js posts: flat hidden inputs, booleans as strings,
times for every day."""
body = {'enabled': 'false', 'mode': 'per_day', 'start_time': '07:00', 'end_time': '23:00'}
for day in ('monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'):
body.update({f'{day}_enabled': 'false', f'{day}_start': '06:30', f'{day}_end': '22:15'})
resp = api_v3_client.post(route, json=body)
assert resp.status_code == 200, resp.get_json()
def test_an_invalid_time_on_a_disabled_day_is_dropped_not_refused(api_v3_client, store):
body = {'enabled': False, 'mode': 'per-day',
'days': {'monday': {'enabled': False, 'start_time': 'soon', 'end_time': '22:00'}}}
resp = api_v3_client.post('/api/v3/config/schedule', json=body)
assert resp.status_code == 200, resp.get_json()
assert store['config']['schedule']['days']['monday'] == {'enabled': False,
'end_time': '22:00'}
# --- 2. restart_required on /config/main ------------------------------------------
STORED_MAIN = {
'timezone': 'America/Chicago',
'display': {
'hardware': {'rows': 32, 'cols': 64, 'chain_length': 2, 'brightness': 90,
'disable_hardware_pulsing': False, 'inverse_colors': False,
'show_refresh_rate': False},
'runtime': {'gpio_slowdown': 4},
'display_durations': {'clock': 15},
'use_short_date_format': False,
},
}
@pytest.fixture
def main_store(store):
store['config'] = copy.deepcopy(STORED_MAIN)
return store
def _save_main(client, body):
with patch('web_interface.blueprints.api_v3.control_client.brightness_set',
side_effect=control_client.ControlError('no_socket', 'x')):
resp = client.post('/api/v3/config/main', data=json.dumps(body),
content_type='application/json')
assert resp.status_code == 200, resp.get_json()
return resp.get_json()
def test_a_brightness_only_save_needs_no_restart(api_v3_client, main_store):
body = _save_main(api_v3_client, {'brightness': 40})
assert main_store['config']['display']['hardware']['brightness'] == 40
assert body['restart_required'] is False
def test_a_brightness_save_on_a_config_without_a_display_section(api_v3_client, store):
"""The route creates display.hardware and display.runtime on the way;
empty sections are not a change."""
store['config'] = {}
assert _save_main(api_v3_client, {'brightness': 40})['restart_required'] is False
def test_the_display_form_with_only_brightness_changed_needs_no_restart(api_v3_client,
main_store):
hw = STORED_MAIN['display']['hardware']
body = {'__form_section': 'display', 'rows': 32, 'cols': 64, 'chain_length': 2,
'brightness': 55, 'gpio_slowdown': 4}
body.update({k: 'on' for k in ('disable_hardware_pulsing', 'inverse_colors',
'show_refresh_rate') if hw[k]})
assert _save_main(api_v3_client, body)['restart_required'] is False
def test_a_mode_duration_needs_no_restart(api_v3_client, main_store):
body = _save_main(api_v3_client, {'duration__clock': 40})
assert main_store['config']['display']['display_durations']['clock'] == 40
assert body['restart_required'] is False
@pytest.mark.parametrize('change', [{'rows': 64}, {'brightness': 40, 'chain_length': 3},
{'gpio_slowdown': 2}, {'timezone': 'UTC'}])
def test_a_setting_the_display_reads_at_startup_still_needs_one(api_v3_client, main_store,
change):
assert _save_main(api_v3_client, change)['restart_required'] is True
def test_restart_needed_compares_leaves():
from web_interface.blueprints.api_v3.config import restart_needed
before = {'display': {'hardware': {'brightness': 90, 'rows': 32}}}
assert not restart_needed(before, copy.deepcopy(before))
assert not restart_needed(before, {'display': {'hardware': {'brightness': 10, 'rows': 32},
'runtime': {}}})
assert restart_needed(before, {'display': {'hardware': {'brightness': 90}}}) # removed
assert not restart_needed({}, {'clock': {'enabled': True}}, live_paths=[('clock',)])
assert restart_needed({}, {'clockwork': {'enabled': True}}, live_paths=[('clock',)])
# --- 3 and 4. a stopped display -----------------------------------------------------
@pytest.fixture
def no_display(monkeypatch, tmp_path):
"""A Pi whose display service has stopped: the socket is expected here
but does not answer, and systemd took the heartbeat's directory away."""
monkeypatch.setattr(display_state, 'socket_supported', lambda: True)
monkeypatch.setattr(display_state, 'client_socket_paths', lambda: [str(tmp_path / 'gone')])
monkeypatch.setattr(display_state, 'read_state', lambda: None)
path = tmp_path / 'display-heartbeat.json'
monkeypatch.setattr(display_watchdog, 'HEARTBEAT_PATH', str(path))
def beat(age, pid=None):
path.write_text(json.dumps({'pid': os.getpid() if pid is None else pid,
'mono': time.monotonic() - age,
'wall': time.time() - age}))
return beat
@pytest.fixture
def service(monkeypatch):
status = {'active': False, 'returncode': 3, 'stdout': 'inactive', 'stderr': ''}
monkeypatch.setattr('web_interface.blueprints.api_v3.misc._get_display_service_status',
lambda: dict(status))
return status
@pytest.fixture
def fresh_preview(tmp_path, monkeypatch):
"""The preview frame the display left behind, under 60 s old: on its own
it kept the hardware check "connected"."""
from web_interface import display_preview
snapshot = tmp_path / 'preview.png'
snapshot.write_bytes(b'png')
monkeypatch.setattr(display_preview, 'SNAPSHOT_PATH', str(snapshot))
def _health(client):
resp = client.get('/api/v3/health')
assert resp.status_code == 200, resp.get_json()
return resp.get_json()['data']
class TestHealth:
def test_a_stopped_display_service_is_degraded(self, api_v3_client, no_display, service,
fresh_preview):
data = _health(api_v3_client)
assert data['services']['display_service']['status'] == 'inactive'
assert data['checks']['display_loop']['status'] == 'stopped'
assert data['status'] == 'degraded'
def test_a_service_still_starting_is_not(self, api_v3_client, no_display, service,
fresh_preview):
"""Active, before its socket and first heartbeat: not stopped."""
service.update(active=True, stdout='active', returncode=0)
data = _health(api_v3_client)
assert data['checks']['display_loop']['status'] == 'not_reported'
assert data['status'] == 'healthy'
def test_a_display_run_by_hand_is_not_stopped(self, api_v3_client, no_display, service,
fresh_preview):
"""The service is off but a display process beats (sudo python3 run.py)."""
no_display(age=2)
data = _health(api_v3_client)
assert data['checks']['display_loop']['status'] == 'running'
assert data['status'] == 'healthy'
@pytest.mark.parametrize('platform', ['no_unix_sockets', 'socket_off'])
def test_without_a_socket_to_expect_nothing_changes(self, api_v3_client, no_display,
service, fresh_preview, monkeypatch,
platform):
"""Windows and the dev server (no systemd unit), or the socket
deliberately off: no heartbeat is no signal, as before."""
if platform == 'no_unix_sockets':
monkeypatch.setattr(display_state, 'socket_supported', lambda: False)
else:
monkeypatch.setattr(display_state, 'client_socket_paths', lambda: [])
service.update(returncode=-1, stdout='', stderr='systemctl not found')
data = _health(api_v3_client)
assert data['checks']['display_loop']['status'] == 'not_reported'
assert data['status'] == 'healthy'
def test_the_status_only_answer_says_degraded(self, api_v3_client, no_display, service,
fresh_preview, monkeypatch):
monkeypatch.setattr('web_interface.blueprints.api_v3.misc.request_is_authenticated',
lambda: False)
resp = api_v3_client.get('/api/v3/health')
assert resp.get_json()['data'] == {'status': 'degraded'}
class TestCurrentStatus:
CACHED = {'mode': 'clock', 'plugin_id': 'clock', 'is_display_active': True,
'on_demand_active': False, 'last_updated': None}
@pytest.fixture
def cached(self, api_v3_module):
entry = dict(self.CACHED, last_updated=time.time() - 30)
cache = api_v3_module.api_v3.cache_manager
cache.get.side_effect = lambda key, *a, **kw: (
dict(entry) if key == 'display_current_state' else None)
return entry
def _status(self, client):
resp = client.get('/api/v3/display/current-status')
assert resp.status_code == 200
return resp.get_json()['data']
def test_a_stopped_display_is_not_reported_active(self, api_v3_client, no_display, cached):
data = self._status(api_v3_client)
assert not data.get('is_display_active')
assert data['mode'] is None and data['last_updated'] is None
assert data['source'] == 'cache'
def test_a_stale_heartbeat_is_not_active_either(self, api_v3_client, no_display, cached):
no_display(age=display_watchdog.HEARTBEAT_STALE_SECONDS + 5)
assert self._status(api_v3_client)['mode'] is None
@pytest.mark.skipif(os.name != 'posix', reason='process_exists answers only on POSIX')
def test_a_heartbeat_from_a_dead_process_is_not_active(self, api_v3_client, no_display,
cached):
no_display(age=1, pid=2 ** 22 + 12345)
assert self._status(api_v3_client)['mode'] is None
def test_a_live_heartbeat_without_a_socket_reads_the_cache(self, api_v3_client,
no_display, cached):
"""An older display with no socket, still running."""
no_display(age=2)
data = self._status(api_v3_client)
assert data['mode'] == 'clock' and data['is_display_active'] is True
@pytest.mark.parametrize('platform', ['no_unix_sockets', 'socket_off'])
def test_without_a_socket_to_expect_the_cache_answers(self, api_v3_client, no_display,
cached, monkeypatch, platform):
if platform == 'no_unix_sockets':
monkeypatch.setattr(display_state, 'socket_supported', lambda: False)
else:
monkeypatch.setattr(display_state, 'client_socket_paths', lambda: [])
data = self._status(api_v3_client)
assert data['mode'] == 'clock' and data['is_display_active'] is True
+399 -12
View File
@@ -9,6 +9,7 @@ that overrides the schedule and ends (#714).
"""
import itertools
from dataclasses import replace
import os
from unittest.mock import MagicMock, patch
@@ -19,6 +20,7 @@ os.environ.setdefault("EMULATOR", "true")
from src import display_arbiter # noqa: E402
from src.display_arbiter import ( # noqa: E402
SCHEDULED_OFF_DWELL,
SCREEN_PREEMPTERS,
WIFI_NOTICE_DWELL,
Arbiter,
ArbiterInputs,
@@ -26,6 +28,9 @@ from src.display_arbiter import ( # noqa: E402
ScreenPlan,
Source,
WifiNotice,
live_pick,
live_takeover,
on_demand_bound,
wifi_notice_preempts,
)
@@ -34,7 +39,9 @@ NOTICE = WifiNotice(message="Connected to HomeNet", expires_at=1_000.0)
OFF = Source.SCHEDULED_OFF
FOLLOW = Source.FOLLOWER
WIFI = Source.WIFI
ONDEM = Source.ON_DEMAND
LEGACY = Source.LEGACY
ROTATION = Source.ROTATION
# (schedule_on, on_demand_active, follower_active, notice) -> Source.
# Every combination of the stage-2 inputs: 2 x 2 x 2 x 2 = 16 rows.
@@ -46,17 +53,17 @@ DECIDE_TABLE = [
(False, False, True, None, OFF),
(False, False, True, NOTICE, OFF),
# Scheduled off, but on-demand overrides the gate.
(False, True, False, None, LEGACY), # on-demand: run() decides
(False, True, False, NOTICE, LEGACY), # on-demand outranks WiFi
(False, True, False, None, ONDEM), # on-demand overrides the gate
(False, True, False, NOTICE, ONDEM), # on-demand outranks WiFi
(False, True, True, None, FOLLOW), # follower outranks on-demand
(False, True, True, NOTICE, FOLLOW),
# Scheduled on.
(True, False, False, None, LEGACY), # live / Vegas / rotation
(True, False, False, None, ROTATION), # live / Vegas / rotation
(True, False, False, NOTICE, WIFI),
(True, False, True, None, FOLLOW),
(True, False, True, NOTICE, FOLLOW), # follower outranks WiFi
(True, True, False, None, LEGACY),
(True, True, False, NOTICE, LEGACY), # on-demand outranks WiFi
(True, True, False, None, ONDEM),
(True, True, False, NOTICE, ONDEM), # on-demand outranks WiFi
(True, True, True, None, FOLLOW),
(True, True, True, NOTICE, FOLLOW),
]
@@ -95,8 +102,15 @@ class TestDecide:
elif expected is WIFI:
assert plan == ScreenPlan(WIFI, max_duration=WIFI_NOTICE_DWELL,
notice=NOTICE)
elif expected is ONDEM:
# An empty state: a session with no modes, which the
# controller ends (see TestOnDemand for real sessions).
assert plan == ScreenPlan(ONDEM)
elif expected is ROTATION:
# No live scan and Vegas off: the rotation's (empty) mode.
assert plan == ScreenPlan(ROTATION, preemptible_by=SCREEN_PREEMPTERS)
else:
# A follower paces itself; LEGACY is run()'s existing code.
# A follower paces itself.
assert plan == ScreenPlan(expected)
def test_dwells_are_todays(self):
@@ -224,16 +238,389 @@ class TestControllerSnapshot:
assert (plan.source is OFF) is (not dc.is_display_active), time_str
return plan.source
assert step("22:59:30") is LEGACY
assert step("22:59:30") is ROTATION
assert step("23:00:00") is OFF # window ends
dc.on_demand_active = True
assert step("23:00:10") is LEGACY # on-demand overrides
assert step("23:00:10") is ONDEM # on-demand overrides
assert dc.on_demand_schedule_override is True
assert step("23:01:00") is LEGACY # next minute, still on
assert step("23:01:00") is ONDEM # next minute, still on
dc._reset_on_demand_fields() # session ends
assert step("23:01:20") is OFF # same minute: blanks
dc.on_demand_active = True
assert step("06:59:00") is LEGACY
assert step("07:00:00") is LEGACY # schedule back on mid-session
assert step("06:59:00") is ONDEM
assert step("07:00:00") is ONDEM # schedule back on mid-session
dc._reset_on_demand_fields()
assert step("07:00:30") is LEGACY
assert step("07:00:30") is ROTATION
# -- OnDemand (stage 3) ---------------------------------------------------
ON = ArbiterInputs(schedule_on=True, on_demand_active=True, follower_active=False)
def _session(modes=("a", "b", "c"), index=0, expires_at=None, current=None):
return ArbiterState(current_mode=current, on_demand_modes=tuple(modes),
on_demand_index=index, on_demand_expires_at=expires_at)
class TestOnDemand:
# (modes, index, expires_at, now) -> (mode, max_duration)
TABLE = [
(("a", "b", "c"), 0, None, 100.0, "a", None), # untimed
(("a", "b", "c"), 2, None, 100.0, "c", None),
(("a", "b", "c"), 3, None, 100.0, "a", None), # past the end: 0
(("a", "b", "c"), 9, None, 100.0, "a", None),
(("a",), 0, 130.0, 100.0, "a", 30.0), # 30 s left
(("a",), 0, 130.0, 130.0, "a", 0.0), # none left
(("a",), 0, 130.0, 200.0, "a", 0.0), # never negative
]
@pytest.mark.parametrize("modes,index,expires_at,now,mode,max_duration", TABLE)
def test_current_mode_and_time_left(self, modes, index, expires_at, now, mode,
max_duration):
plan = Arbiter.decide(_session(modes, index, expires_at), ON, now)
assert plan.source is ONDEM
assert plan.mode == mode
assert plan.max_duration == max_duration
assert plan.deadline == expires_at
def test_no_modes_left_is_a_plan_with_no_mode(self):
plan = Arbiter.decide(_session(modes=()), ON, 0.0)
assert plan == ScreenPlan(ONDEM)
def test_preemptible_by_the_schedule_a_reload_and_its_own_changes(self):
plan = Arbiter.decide(_session(), ON, 0.0)
assert {Source.SCHEDULED_OFF, ONDEM, Source.RELOAD} <= plan.preemptible_by
assert Source.FOLLOWER not in plan.preemptible_by
@pytest.mark.parametrize("index,expected_index,expected_mode", [
(0, 1, "b"), (1, 2, "c"), (2, 0, "a")])
def test_next_on_demand_wraps(self, index, expected_index, expected_mode):
nxt = _session(index=index).next_on_demand()
assert (nxt.on_demand_index, nxt.current_mode) == (expected_index, expected_mode)
def test_showing_a_plan_resets_an_index_past_the_end(self):
state = _session(index=5, current="x")
plan = Arbiter.decide(state, ON, 0.0)
shown = state.showing(plan)
assert (shown.on_demand_index, shown.current_mode) == (0, "a")
assert state.on_demand_index == 5 # not mutated
# (min, max, deadline, now) -> bounds. The bound applied after the first frame.
BOUND_TABLE = [
(10.0, 20.0, None, 0.0, (10.0, 20.0)), # untimed: unchanged
(10.0, 20.0, 100.0, 50.0, (10.0, 20.0)), # plenty left
(10.0, 20.0, 100.0, 85.0, (10.0, 15.0)), # max cut to what is left
(10.0, 20.0, 100.0, 95.0, (5.0, 5.0)), # both cut
(10.0, 20.0, 100.0, 100.0, None), # nothing left
(10.0, 20.0, 100.0, 150.0, None),
]
@pytest.mark.parametrize("min_d,max_d,deadline,now,expected", BOUND_TABLE)
def test_on_demand_bound(min_d, max_d, deadline, now, expected):
assert on_demand_bound(min_d, max_d, deadline, now) == expected
# -- Live (stage 3) -------------------------------------------------------
LIVE = Source.LIVE
# (live_modes, current_mode, advance) -> pick. _check_live_priority's rule.
PICK_TABLE = [
((), "clock", True, None),
(None, "clock", True, None),
(("nfl",), "clock", True, "nfl"), # not on a live mode: first
(("nfl", "nhl"), "clock", True, "nfl"),
(("nfl", "nhl"), "clock", False, "nfl"),
(("nfl", "nhl"), "nfl", True, "nhl"), # round-robin
(("nfl", "nhl"), "nhl", True, "nfl"), # wraps
(("nfl", "nhl"), "nhl", False, "nhl"), # a peek stays put
(("nfl",), "nfl", True, "nfl"), # one game: itself
]
@pytest.mark.parametrize("live,current,advance,expected", PICK_TABLE)
def test_live_pick(live, current, advance, expected):
assert live_pick(live, current, advance) == expected
def _below(live=None, vegas=False, keeps=False, yielded=False, on_demand=False):
"""Inputs for the Sources below the notice (no notice, no follower)."""
return ArbiterInputs(schedule_on=True, on_demand_active=on_demand,
follower_active=False, live_modes=live, vegas_enabled=vegas,
vegas_live_in_ticker=keeps, vegas_yielded=yielded)
ROT = ("clock", "weather", "nfl_live", "nhl_live")
def _rot(current="clock", index=0, resume=None, unshown=False):
return ArbiterState(current_mode=current, rotation=ROT, rotation_index=index,
live_resume_index=resume, live_takeover_unshown=unshown)
class TestLive:
# (state, inputs) -> (source, mode, ends_live)
TABLE = [
# Nothing live, nothing to resume.
(_rot(), _below(live=()), "below", None, False),
# Not scanned (on-demand, or the ticker keeps live content).
(_rot(), _below(live=None), "below", None, False),
# A game is live: it takes the panel.
(_rot(), _below(live=("nfl_live",)), LIVE, "nfl_live", False),
# Two: round-robin from the one showing.
(_rot("nfl_live", 2), _below(live=("nfl_live", "nhl_live")), LIVE, "nhl_live", False),
# ... unless a mid-screen takeover chose it and it has not shown yet.
(_rot("nfl_live", 2, resume=0, unshown=True),
_below(live=("nfl_live", "nhl_live")), LIVE, "nfl_live", False),
# Vegas keeps live content in its ticker: Live has no say at all,
# not even the resume.
(_rot(), _below(live=("nfl_live",), vegas=True, keeps=True), "below", None, False),
(_rot("nfl_live", 2, resume=1),
_below(live=(), vegas=True, keeps=True), "below", None, False),
# Vegas that yields to live content: Live outranks it.
(_rot(), _below(live=("nfl_live",), vegas=True), LIVE, "nfl_live", False),
# The game ended: the interrupted rotation resumes.
(_rot("nfl_live", 2, resume=1), _below(live=()), "below", None, True),
(_rot("nfl_live", 2, resume=1), _below(live=(), vegas=True), "below", None, True),
# On-demand outranks Live.
(ArbiterState(on_demand_modes=("x",)), _below(live=("nfl_live",), on_demand=True),
ONDEM, "x", False),
]
@pytest.mark.parametrize("state,inputs,source,mode,ends_live", TABLE)
def test_decide(self, state, inputs, source, mode, ends_live):
plan = Arbiter.decide(state, inputs, 0.0)
if source == "below":
assert plan.source not in (LIVE, ONDEM, OFF, FOLLOW, WIFI)
else:
assert plan.source is source
assert plan.mode == mode
assert plan.ends_live is ends_live
def test_a_live_plan_has_no_durations_until_its_first_frame(self):
plan = Arbiter.decide(_rot(), _below(live=("nfl_live",)), 0.0)
assert (plan.min_duration, plan.max_duration, plan.frame_policy) == (None, None, None)
class TestLiveTransitions:
def test_claim_saves_where_the_rotation_was(self):
nxt = _rot("weather", 1).claim_live("nfl_live")
assert (nxt.current_mode, nxt.rotation_index, nxt.live_resume_index) == ("nfl_live", 2, 1)
def test_a_second_claim_keeps_the_first_resume_point(self):
nxt = _rot("nfl_live", 2, resume=1).claim_live("nhl_live")
assert (nxt.current_mode, nxt.rotation_index, nxt.live_resume_index) == ("nhl_live", 3, 1)
def test_claiming_the_mode_showing_changes_nothing(self):
state = _rot("nfl_live", 2, resume=1)
assert state.claim_live("nfl_live") is state
def test_a_live_mode_outside_the_rotation_keeps_the_index(self):
nxt = _rot("weather", 1).claim_live("mlb_live")
assert (nxt.current_mode, nxt.rotation_index, nxt.live_resume_index) == ("mlb_live", 1, 1)
def test_release_resumes_and_forgets(self):
nxt = _rot("nhl_live", 3, resume=1).release_live()
assert (nxt.current_mode, nxt.rotation_index, nxt.live_resume_index) == ("weather", 1, None)
def test_release_wraps_a_resume_point_past_a_shortened_rotation(self):
nxt = _rot("nhl_live", 3, resume=6).release_live()
assert (nxt.current_mode, nxt.rotation_index) == ("nfl_live", 2) # 6 % 4
def test_release_with_nothing_to_resume_changes_nothing(self):
state = _rot("clock", 0)
assert state.release_live() is state
empty = ArbiterState(current_mode="x", live_resume_index=2)
assert empty.release_live() is empty # no rotation to resume into
# -- Vegas and Rotation (stage 3) -----------------------------------------
class TestVegasAndRotation:
# (state, inputs) -> (source, mode, ends_live). LEGACY now means Vegas only.
TABLE = [
(_rot("weather", 1), _below(live=()), ROTATION, "weather", False),
(_rot("weather", 1), _below(live=None), ROTATION, "weather", False),
(_rot("weather", 1), _below(live=(), vegas=True), LEGACY, None, False),
(_rot("weather", 1), _below(live=None, vegas=True, keeps=True), LEGACY, None, False),
# The iteration yielded: the screen it fell through to.
(_rot("weather", 1), _below(live=(), vegas=True, yielded=True),
ROTATION, "weather", False),
(_rot("weather", 1), _below(live=("nfl_live",), vegas=True, yielded=True),
LIVE, "nfl_live", False),
# Live priority just ended: the rotation resumes where it was cut.
(_rot("nhl_live", 3, resume=1), _below(live=()), ROTATION, "weather", True),
# ... and Vegas carries the resume through to its own pass.
(_rot("nhl_live", 3, resume=1), _below(live=(), vegas=True), LEGACY, None, True),
# A rotation that something moved off its list carries on from there.
(ArbiterState(current_mode=None, rotation=ROT), _below(live=()), ROTATION, None, False),
]
@pytest.mark.parametrize("state,inputs,source,mode,ends_live", TABLE)
def test_decide(self, state, inputs, source, mode, ends_live):
plan = Arbiter.decide(state, inputs, 0.0)
assert (plan.source, plan.mode, plan.ends_live) == (source, mode, ends_live)
def test_a_rotation_plan_may_be_preempted_by_everything_a_screen_watches(self):
plan = Arbiter.decide(_rot(), _below(live=()), 0.0)
assert plan.preemptible_by == SCREEN_PREEMPTERS
assert {OFF, ONDEM, WIFI, LIVE, ROTATION, Source.RELOAD} == SCREEN_PREEMPTERS
class _End:
def __init__(self, on_demand_active=False, still_live=False):
self.on_demand_active = on_demand_active
self.still_live = still_live
class TestAfter:
"""ArbiterState.after: _advance_after_screen's step."""
def test_the_rotation_advances(self):
nxt = _rot("weather", 1).after(_End())
assert (nxt.current_mode, nxt.rotation_index) == ("nfl_live", 2)
def test_it_wraps(self):
nxt = _rot("nhl_live", 3).after(_End())
assert (nxt.current_mode, nxt.rotation_index) == ("clock", 0)
def test_a_live_mode_still_live_holds(self):
state = _rot("nfl_live", 2)
assert state.after(_End(still_live=True)) is state
def test_an_on_demand_session_moves_to_its_next_mode(self):
state = replace(_session(index=1, current="b"), rotation=ROT, rotation_index=1)
nxt = state.after(_End(on_demand_active=True))
assert (nxt.current_mode, nxt.on_demand_index, nxt.rotation_index) == ("c", 2, 1)
def test_a_session_with_no_modes_is_left_to_the_controller(self):
state = ArbiterState(current_mode="x", rotation=ROT)
assert state.after(_End(on_demand_active=True)) is state
def test_no_rotation_no_step(self):
state = ArbiterState(current_mode="x")
assert state.after(_End()) is state
# -- Mid-screen: decide(..., running=plan) (stage 3) ----------------------
#
# What the ScreenRunner asks at each service point. These rows are what
# _check_live_takeover, _screen_preempted and _wifi_notice_pending answered
# between frames before stage 3, written out.
CLOCK = Arbiter.decide(ArbiterState(current_mode="clock"), _below(live=()), 0.0)
NFL = Arbiter.decide(ArbiterState(current_mode="clock"), _below(live=("nfl_live",)), 0.0)
OD = Arbiter.decide(_session(modes=("x", "y")), ON, 0.0)
FRESH = WifiNotice(message="AP mode", expires_at=1_000.0)
HELD = "held"
def _mid(on_demand=False, schedule_on=True, live=None, notice=None, reload=False):
return ArbiterInputs(schedule_on=schedule_on, on_demand_active=on_demand,
follower_active=False, live_modes=live, wifi_notice=notice,
reload_pending=reload)
# (running, current_mode, inputs, now) -> HELD or (source, mode)
MID_TABLE = [
# Nothing changed.
(CLOCK, "clock", _mid(), 500.0, HELD),
(CLOCK, "clock", _mid(live=()), 500.0, HELD),
# A game went live: it takes the panel (the first live mode).
(CLOCK, "clock", _mid(live=("nfl_live", "nhl_live")), 500.0, (LIVE, "nfl_live")),
# ... even with a notice pending: the claim is made now, and the next
# pass shows the notice first (top-of-pass order), then the game.
(CLOCK, "clock", _mid(live=("nfl_live",), notice=FRESH), 500.0, (LIVE, "nfl_live")),
# ... but not over the schedule or an on-demand session.
(CLOCK, "clock", _mid(live=("nfl_live",), schedule_on=False), 500.0, (OFF, None)),
(CLOCK, "x", _mid(live=("nfl_live",), on_demand=True), 500.0, (ONDEM, "x")),
# A screen already on a live mode is not taken over by another.
(CLOCK, "nfl_live", _mid(live=("nfl_live", "nhl_live")), 500.0, (ROTATION, "nfl_live")),
(NFL, "nfl_live", _mid(live=("nhl_live",)), 500.0, HELD),
# The mode moved under the screen: on-demand started, or ended, or the
# rotation was rebuilt.
(CLOCK, "x", _mid(on_demand=True), 500.0, (ONDEM, "x")),
(OD, "weather", _mid(), 500.0, (ROTATION, "weather")),
(CLOCK, "weather", _mid(), 500.0, (ROTATION, "weather")),
# An on-demand session that ends on the same mode keeps the screen.
(OD, "x", _mid(), 500.0, HELD),
# The schedule: off ends it; an on-demand override holds.
(CLOCK, "clock", _mid(schedule_on=False), 500.0, (OFF, None)),
(OD, "x", _mid(on_demand=True, schedule_on=False), 500.0, HELD),
# A WiFi notice, compared with its expiry; on-demand outranks it.
(CLOCK, "clock", _mid(notice=FRESH), 999.9, (WIFI, None)),
(CLOCK, "clock", _mid(notice=FRESH), 1_000.0, HELD),
(NFL, "nfl_live", _mid(notice=FRESH), 500.0, (WIFI, None)),
(OD, "x", _mid(on_demand=True, notice=FRESH), 500.0, HELD),
# A plugin reload waits at the top of the loop.
(CLOCK, "clock", _mid(reload=True), 500.0, (Source.RELOAD, None)),
(OD, "x", _mid(on_demand=True, reload=True), 500.0, (Source.RELOAD, None)),
# The order between them: a moved mode before the schedule, the
# schedule before a notice, a notice before a reload.
(CLOCK, "weather", _mid(schedule_on=False), 500.0, (ROTATION, "weather")),
(CLOCK, "clock", _mid(schedule_on=False, notice=FRESH), 500.0, (OFF, None)),
(CLOCK, "clock", _mid(notice=FRESH, reload=True), 500.0, (WIFI, None)),
]
class TestMidScreen:
@pytest.mark.parametrize("running,current,inputs,now,expected", MID_TABLE)
def test_decide(self, running, current, inputs, now, expected):
state = ArbiterState(current_mode=current)
plan = Arbiter.decide(state, inputs, now, running=running)
if expected == HELD:
assert plan is running
else:
assert plan is not running
assert (plan.source, plan.mode) == expected
def test_the_running_plans(self):
assert (CLOCK.source, CLOCK.mode) == (ROTATION, "clock")
assert (NFL.source, NFL.mode) == (LIVE, "nfl_live")
assert LIVE not in NFL.preemptible_by
assert (OD.source, OD.mode) == (ONDEM, "x")
def test_a_follower_and_vegas_never_preempt(self):
for plan in (CLOCK, NFL, OD):
assert FOLLOW not in plan.preemptible_by
assert LEGACY not in plan.preemptible_by
def test_nothing_in_preemptible_by_means_nothing_preempts(self):
bare = replace(CLOCK, preemptible_by=frozenset())
inputs = _mid(live=("nfl_live",), schedule_on=False, notice=FRESH, reload=True)
assert Arbiter.decide(ArbiterState(current_mode="weather"), inputs, 0.0,
running=bare) is bare
def test_mid_screen_decide_reads_no_clock(self):
boom = MagicMock(side_effect=AssertionError("decide read the clock"))
with patch("time.time", boom), patch("time.monotonic", boom):
for running, current, inputs, now, _ in MID_TABLE:
Arbiter.decide(ArbiterState(current_mode=current), inputs, now,
running=running)
# (current, inputs) -> live_takeover. The mode a mid-screen check claims.
TAKEOVER_TABLE = [
("clock", _mid(live=("nfl_live",)), "nfl_live"),
("clock", _mid(live=()), None),
("clock", _mid(live=None), None), # no scan was due
("nfl_live", _mid(live=("nfl_live",)), None),
("clock", _mid(live=("nfl_live",), on_demand=True), None),
("clock", _mid(live=("nfl_live",), schedule_on=False), None),
("clock", replace(_mid(live=("nfl_live",)), vegas_enabled=True,
vegas_live_in_ticker=True), None),
]
@pytest.mark.parametrize("current,inputs,expected", TAKEOVER_TABLE)
def test_live_takeover(current, inputs, expected):
assert live_takeover(ArbiterState(current_mode=current), inputs) == expected
+142
View File
@@ -0,0 +1,142 @@
"""The report of a scrolling screen held by its plugin's update().
While a plugin's update() runs it holds the plugin's lock, and its screen's
frames are skipped -- on a scroller, a frozen strip -- with nothing logged.
_note_display_hold times each such run and reports one of
DISPLAY_HOLD_REPORT_SECONDS or more.
"""
import threading
import types
from unittest.mock import MagicMock
import pytest
class _Clock:
"""display_controller's clock: moves only when run() sleeps or a test says."""
def __init__(self, start=10_000.0):
self.t = start
def now(self):
return self.t
def sleep(self, seconds):
self.t += max(seconds, 0.0005)
def module(self):
return types.SimpleNamespace(time=self.now, monotonic=self.now,
perf_counter=self.now, sleep=self.sleep)
@pytest.fixture
def clock(monkeypatch):
c = _Clock()
monkeypatch.setattr("src.display_controller.time", c.module())
return c
class _Locks:
"""get_plugin_lock for one plugin, whose lock the test can hold."""
def __init__(self):
self.lock = threading.Lock()
def __call__(self, plugin_id):
return self.lock
@pytest.fixture
def held(test_display_controller):
c = test_display_controller
locks = _Locks()
c.plugin_manager.get_plugin_lock = locks
c.plugin_manager._warn_rate_limited = MagicMock()
c.plugin_manager.health_tracker = MagicMock()
c._display_hold = None
return c, locks.lock
def _plugin(plugin_id):
p = MagicMock()
p.plugin_id = plugin_id
p.display.return_value = True
return p
class TestTheDisplayHoldReport:
def test_a_long_hold_is_reported_when_it_ends(self, held, clock):
c, lock = held
ticker = _plugin("ticker")
lock.acquire() # update() running
assert c._display_once(ticker, "ticker", False, report_hold=True) is True
clock.t += 0.2
assert c._display_once(ticker, "ticker", False, report_hold=True) is True
assert ticker.display.call_count == 0
c.plugin_manager._warn_rate_limited.assert_not_called()
clock.t += 0.2
lock.release() # update() done
c._display_once(ticker, "ticker", False, report_hold=True)
assert ticker.display.call_count == 1
key, message, plugin_id, ms = c.plugin_manager._warn_rate_limited.call_args[0]
assert key == "display-hold:ticker" and plugin_id == "ticker"
assert "held" in message and ms == pytest.approx(400.0)
c.plugin_manager.health_tracker.record_busy_skip.assert_called_once_with(
"ticker", "display hold", pytest.approx(0.4))
def test_a_short_hold_is_not(self, held, clock):
c, lock = held
ticker = _plugin("ticker")
lock.acquire()
c._display_once(ticker, "ticker", False, report_hold=True)
clock.t += 0.1
lock.release()
c._display_once(ticker, "ticker", False, report_hold=True)
c.plugin_manager._warn_rate_limited.assert_not_called()
c.plugin_manager.health_tracker.record_busy_skip.assert_not_called()
def test_a_hold_that_ends_on_another_plugins_screen_is_not_blamed_on_it(
self, held, clock):
c, lock = held
lock.acquire()
c._display_once(_plugin("ticker"), "ticker", False, report_hold=True)
clock.t += 1.0
lock.release()
c._display_once(_plugin("clock"), "clock", False, report_hold=True)
c.plugin_manager._warn_rate_limited.assert_not_called()
assert c._display_hold is None
def test_frames_that_draw_report_nothing(self, held, clock):
c, _lock = held
ticker = _plugin("ticker")
for _ in range(5):
c._display_once(ticker, "ticker", False, report_hold=True)
clock.t += 0.5
c.plugin_manager._warn_rate_limited.assert_not_called()
def test_the_1hz_loop_reports_no_holds(self, held, clock):
# A static screen's frames are a second apart: one skipped frame is
# not a measured hold, and nothing on the panel froze. (On ledpi the
# first version reported every such skip as "held 1000 ms".)
c, lock = held
board = _plugin("board")
lock.acquire()
c._display_once(board, "board", False)
clock.t += 1.0
lock.release()
c._display_once(board, "board", False)
c.plugin_manager._warn_rate_limited.assert_not_called()
c.plugin_manager.health_tracker.record_busy_skip.assert_not_called()
def test_a_run_left_open_is_dropped_by_a_1hz_frame(self, held, clock):
c, lock = held
ticker = _plugin("ticker")
lock.acquire()
c._display_once(ticker, "ticker", False, report_hold=True)
lock.release()
c._display_once(ticker, "ticker", False) # the 1 Hz loop draws
assert c._display_hold is None
clock.t += 5.0
c._display_once(ticker, "ticker", False, report_hold=True)
c.plugin_manager._warn_rate_limited.assert_not_called()
+151
View File
@@ -430,6 +430,157 @@ class TestClear:
assert "clear request" in response.get_json()["message"]
CLIENT = "web_interface.blueprints.api_v3.control_client"
def _mailbox_file(shared_cache):
_, _, directory = shared_cache
return directory / f"{ERROR_CLEAR_REQUEST_KEY}.json"
class TestClearOverTheSocket:
"""``errors.clear``: the display applies the clear before it answers, and
the mailbox is written only when the socket could not carry it."""
@pytest.fixture
def socket_up(self, display, monkeypatch):
"""The control socket, as the display serves it: errors_clear runs
the display's own handler against its publisher."""
from src.ipc import client as control_client
from src.ipc.contract import ErrorsClearArgs
_, publisher, _ = display
monkeypatch.setattr(errors, "_snapshot_publisher", publisher)
calls = []
def errors_clear(request_id, cutoff, **kw):
calls.append((request_id, cutoff))
return errors.apply_error_clear(request_id, ErrorsClearArgs(cutoff=cutoff))
monkeypatch.setattr(f"{CLIENT}.errors_clear", errors_clear)
assert control_client.errors_clear is errors_clear
return calls
def test_socket_clear_is_applied_before_the_answer(self, web, display, socket_up,
shared_cache):
aggregator, publisher, _ = display
for _ in range(3):
_fail(aggregator)
publisher.tick()
response = web.post("/api/v3/errors/clear", json={"all": True})
assert response.status_code == 200
body = response.get_json()
data = body["data"]
assert data["transport"] == "socket" and data["applied"] is True
assert data["cleared_count"] == 3
assert body["message"] == "Cleared all errors"
[(request_id, _)] = socket_up
assert data["request_id"] == request_id
# Applied already: no display tick needed, nothing pending.
assert aggregator.get_error_summary()["total_errors"] == 0
summary = _summary(web)
assert summary["total_errors"] == 0 and summary["clear_pending"] is False
# And no mailbox file.
assert not _mailbox_file(shared_cache).exists()
def test_an_older_mailbox_request_does_not_read_as_pending(self, web, display, socket_up,
shared_cache, monkeypatch):
# A clear that went to the mailbox while the socket was down, then a
# wider one over the socket: the old request has nothing left to hide.
from unittest.mock import patch
from src.ipc import client as control_client
aggregator, publisher, _ = display
_fail(aggregator)
publisher.tick()
with patch(f"{CLIENT}.errors_clear",
side_effect=control_client.ControlError("no_socket")):
data = web.post("/api/v3/errors/clear", json={"max_age_hours": 1}).get_json()["data"]
assert data["transport"] == "mailbox"
assert _mailbox_file(shared_cache).exists()
data = web.post("/api/v3/errors/clear", json={"all": True}).get_json()["data"]
assert data["transport"] == "socket"
assert _summary(web)["clear_pending"] is False
@pytest.mark.parametrize("reason", ["no_socket", "refused", "disabled", "unsupported"])
def test_no_socket_writes_the_mailbox(self, web, display, shared_cache, monkeypatch, reason):
from src.ipc import client as control_client
monkeypatch.setattr(f"{CLIENT}.errors_clear", MagicMock(
side_effect=control_client.ControlError(reason, sent=False)))
aggregator, publisher, _ = display
_fail(aggregator)
publisher.tick()
data = web.post("/api/v3/errors/clear", json={"all": True}).get_json()["data"]
assert data["transport"] == "mailbox" and data["applied"] is False
assert _mailbox_file(shared_cache).exists()
assert _summary(web)["clear_pending"] is True
publisher.tick()
assert aggregator.get_error_summary()["total_errors"] == 0
def test_an_older_display_gets_the_mailbox(self, web, display, shared_cache, monkeypatch):
# The upgrade case: new web interface, a display from before errors.clear.
from src.ipc import client as control_client
monkeypatch.setattr(f"{CLIENT}.errors_clear", MagicMock(
side_effect=control_client.ControlError("unknown_command", sent=True)))
aggregator, publisher, _ = display
_fail(aggregator)
publisher.tick()
data = web.post("/api/v3/errors/clear", json={"all": True}).get_json()["data"]
assert data["transport"] == "mailbox"
publisher.tick()
assert aggregator.get_error_summary()["total_errors"] == 0
@pytest.mark.parametrize("reason", ["internal", "timeout", "busy", "invalid_args"])
def test_a_display_that_had_it_and_failed_is_an_error(self, web, display, shared_cache,
monkeypatch, reason):
from src.ipc import client as control_client
monkeypatch.setattr(f"{CLIENT}.errors_clear", MagicMock(
side_effect=control_client.ControlError(reason, sent=True)))
response = web.post("/api/v3/errors/clear", json={"all": True})
assert response.status_code == 503
assert response.get_json()["context"]["socket_error"] == reason
assert not _mailbox_file(shared_cache).exists()
class TestPublisherMailboxPoll:
def test_the_mailbox_is_read_only_when_its_file_changed(self, display, shared_cache):
_, publisher, _ = display
_, web_cache, _ = shared_cache
publisher.tick()
publisher.cache_manager = MagicMock(wraps=publisher.cache_manager)
def reads():
return [c for c in publisher.cache_manager.get.call_args_list
if c.args[0] == ERROR_CLEAR_REQUEST_KEY]
for _ in range(5):
publisher.tick()
assert reads() == [] # no file: a stat per tick, no read
errors.request_error_clear(web_cache, 1.0)
publisher.tick()
assert len(reads()) == 1
for _ in range(5):
publisher.tick()
assert len(reads()) == 1 # unchanged file: not read again
errors.request_error_clear(web_cache, 2.0)
publisher.tick()
assert len(reads()) == 2
def test_clear_now_publishes_what_it_applied(self, display, shared_cache):
aggregator, publisher, _ = display
_, web_cache, _ = shared_cache
_fail(aggregator)
assert publisher.clear_now("sock-1", datetime.now().timestamp() + 1) == 1
snapshot = web_cache.get(ERROR_SNAPSHOT_KEY, max_age=None, memory_ttl=0)
assert snapshot["applied_clear_id"] == "sock-1"
assert snapshot["total_errors"] == 0
assert snapshot["applied_clear_cutoff"] is not None
def test_the_handler_needs_a_running_publisher(self, monkeypatch):
from src.ipc.contract import ErrorsClearArgs
monkeypatch.setattr(errors, "_snapshot_publisher", None)
with pytest.raises(RuntimeError):
errors.apply_error_clear("x", ErrorsClearArgs(cutoff=1.0))
@pytest.mark.skipif(not hasattr(os, "fchmod") or os.name == "nt",
reason="POSIX file modes")
def test_both_files_are_group_readable(web, display, shared_cache):
+168
View File
@@ -489,3 +489,171 @@ class TestConcurrency:
assert live["peak"] <= espn_dates.ESPN_CHUNK_WORKERS
assert live["peak"] > 1, "chunks should actually overlap"
class TestEdgeMonths:
"""A window's partial edge months are asked whole and trimmed.
The default scoreboard window -- a fortnight either side of today -- spans
two partial months, so it used to cost 29 day requests per league. ESPN's
``dates=YYYYMMDD`` means a US Eastern day (verified against the live API
on 2026-10-03, 417 of 417 soccer events), so a month answer trimmed to
the window's Eastern days is what the day requests returned.
"""
def test_a_fortnight_either_side_is_two_requests(self):
planned = espn_dates.espn_request_chunks(date(2026, 9, 20), date(2026, 10, 18))
assert planned == [
("202609", (date(2026, 9, 20), date(2026, 9, 30))),
("202610", (date(2026, 10, 1), date(2026, 10, 18))),
]
def test_a_live_polls_two_days_stay_two_days(self):
planned = espn_dates.espn_request_chunks(date(2026, 10, 2), date(2026, 10, 3))
assert planned == [("20261002", None), ("20261003", None)]
def test_the_threshold_is_inclusive(self):
n = espn_dates.ESPN_MONTH_COVER_MIN_DAYS
short = espn_dates.espn_request_chunks(date(2026, 10, 1), date(2026, 10, n - 1))
assert [chunk for chunk, _ in short] == [
"202610%02d" % day for day in range(1, n)]
enough = espn_dates.espn_request_chunks(date(2026, 10, 1), date(2026, 10, n))
assert enough == [("202610", (date(2026, 10, 1), date(2026, 10, n)))]
def test_whole_months_and_short_edges_are_unchanged(self):
planned = espn_dates.espn_request_chunks(date(2026, 8, 30), date(2026, 10, 2))
assert planned == [
("20260830", None), ("20260831", None), ("202609", None),
("20261001", None), ("20261002", None),
]
def test_without_time_zone_data_edges_stay_days(self, monkeypatch):
monkeypatch.setattr(espn_dates, "_EASTERN", None)
planned = espn_dates.espn_request_chunks(date(2026, 9, 20), date(2026, 10, 18))
assert len(planned) == 29
assert all(trim is None for _, trim in planned)
@pytest.mark.parametrize("start,end", [
(date(2026, 9, 20), date(2026, 10, 18)),
(date(2026, 1, 25), date(2026, 3, 3)),
(date(2026, 12, 20), date(2027, 1, 9)),
(date(2026, 10, 5), date(2026, 10, 9)),
])
def test_the_planned_requests_still_cover_every_day_exactly_once(self, start, end):
covered = []
for chunk, trim in espn_dates.espn_request_chunks(start, end):
if trim is None:
covered.extend(days_covered_by([chunk]))
else:
assert chunk == trim[0].strftime("%Y%m") == trim[1].strftime("%Y%m")
covered.extend(trim[0] + timedelta(days=offset)
for offset in range((trim[1] - trim[0]).days + 1))
expected = [start + timedelta(days=offset) for offset in range((end - start).days + 1)]
assert covered == expected
def test_a_trimmed_month_keeps_only_the_windows_eastern_days(self):
september = [
# 03:30Z on the 20th is still the 19th in New York: outside.
{"id": "before", "date": "2026-09-20T03:30Z"},
{"id": "first", "date": "2026-09-20T14:00Z"},
{"id": "late", "date": "2026-09-30T23:30Z"},
]
october = [
{"id": "oct1", "date": "2026-10-01T19:00Z"},
# 03:30Z on the 19th is the evening of the 18th in New York: inside.
{"id": "last", "date": "2026-10-19T03:30Z"},
{"id": "after", "date": "2026-10-19T14:00Z"},
{"id": "undated"},
]
session = FakeSession({"202609": september, "202610": october})
data = fetch_espn_date_chunks(session, URL, params={"dates": "20260920-20261018"})
assert sorted(call["dates"] for call in session.calls) == ["202609", "202610"]
# An event with no readable date is kept, never dropped on a guess.
assert [e["id"] for e in data["events"]] == ["first", "late", "oct1", "last", "undated"]
def test_eastern_standard_time_is_honoured_after_the_clocks_change(self):
# 2026-11-01 ends daylight saving: Eastern is UTC-5 from then on.
november = [
{"id": "out", "date": "2026-11-15T04:30Z"}, # Nov 14, 23:30 EST
{"id": "in", "date": "2026-11-15T05:30Z"}, # Nov 15, 00:30 EST
]
session = FakeSession({"202611": november})
data = fetch_espn_date_chunks(session, URL, params={"dates": "20261115-20261121"})
assert [e["id"] for e in data["events"]] == ["in"]
def test_a_capped_edge_month_re_asks_only_the_windows_days(self):
full = [{"id": "cap%d" % i, "date": "2026-10-05T18:00Z"} for i in range(ESPN_MAX_LIMIT)]
by_chunk = {"202610": full}
by_chunk.update({"202610%02d" % day: [{"id": "o%02d" % day}] for day in range(1, 32)})
session = FakeSession(by_chunk)
data = fetch_espn_date_chunks(session, URL, params={"dates": "20261001-20261010"})
sent = [call["dates"] for call in session.calls]
assert sent[0] == "202610"
assert sorted(sent[1:]) == ["202610%02d" % day for day in range(1, 11)]
assert [e["id"] for e in data["events"]] == ["o%02d" % day for day in range(1, 11)]
class TestProcessWideChunkCap:
"""The chunk cap holds across windows, not per window.
A soccer board starting eight leagues fetches sixteen windows at once.
With a pool of ``ESPN_CHUNK_WORKERS`` each, ~40 requests were in flight
and every one past a session's pool opened a connection -- and a DNS
lookup. On ledpi that was ~90 NameResolutionErrors per start.
"""
def test_concurrent_windows_share_one_budget(self):
live = {"now": 0, "peak": 0}
guard = threading.Lock()
class CountingSession(FakeSession):
def get(self, url, params=None, headers=None, timeout=None):
with guard:
live["now"] += 1
live["peak"] = max(live["peak"], live["now"])
try:
time.sleep(0.01)
return super().get(url, params=params, headers=headers, timeout=timeout)
finally:
with guard:
live["now"] -= 1
sessions = [CountingSession() for _ in range(6)]
# Six leagues, so the fetch service cannot merge them into one, on a
# host with no token bucket: earlier tests may have spent ESPN's
# burst, and a bucket paced at 20/s would serialise these by itself.
threads = [
threading.Thread(target=fetch_espn_date_chunks,
args=(session, "https://scores.example.test/league%d" % index),
kwargs={"params": {"dates": "20260101-20261231"}})
for index, session in enumerate(sessions)
]
for thread in threads:
thread.start()
for thread in threads:
thread.join(timeout=30)
assert all(len(session.calls) == 12 for session in sessions)
assert live["peak"] <= espn_dates.ESPN_CHUNK_WORKERS
assert live["peak"] > 1, "chunks should still overlap"
def test_a_fresh_process_skips_the_doomed_range_request():
"""Every start used to spend one 400 per window learning that ranges are
still rejected -- eleven at once from a soccer board. A new process now
starts inside the retry period instead."""
import subprocess
import sys
from pathlib import Path
out = subprocess.run(
[sys.executable, "-c",
"import src.common.espn_dates as e; print(e._ranges_known_rejected())"],
cwd=str(Path(__file__).resolve().parents[1]),
capture_output=True, text=True, timeout=60,
)
assert out.stdout.strip() == "True", out.stderr
+38 -2
View File
@@ -576,6 +576,42 @@ class TestCounters:
assert snap["hosts"]["site.api.espn.com"]["requests"] == 1
assert snap["totals"]["bytes"] == 3 * len(b'{"ok": 1}')
def test_wire_bytes_are_the_compressed_size(self, service):
# Built the way requests builds a real response: a urllib3
# HTTPResponse carrying a gzip body, decoded when .content is read.
import gzip
import io
from requests.adapters import HTTPAdapter
from urllib3.response import HTTPResponse
decoded = json.dumps({"events": [{"id": str(i), "name": "x" * 200}
for i in range(50)]}).encode()
wire = gzip.compress(decoded)
def handler(url, kwargs):
raw = HTTPResponse(body=io.BytesIO(wire), status=200,
headers={"Content-Encoding": "gzip",
"Content-Type": "application/json"},
preload_content=False, decode_content=True)
request = requests.Request("GET", url).prepare()
response = HTTPAdapter().build_response(request, raw)
response.content # what Session.get does for a non-streamed call
return response
response = service.get(FakeSession(handler), "https://site.api.espn.com/x")
assert response.content == decoded
totals = _counters(service)
assert totals["bytes"] == len(decoded)
assert totals["wire_bytes"] == len(wire) < len(decoded)
def test_wire_bytes_fall_back_to_the_decoded_size(self, service):
# No urllib3 response behind it (a test double, another adapter):
# count what is known rather than nothing.
service.get(FakeSession(), "https://api.test/x")
totals = _counters(service)
assert totals["wire_bytes"] == totals["bytes"] == len(b'{"ok": 1}')
def test_errors_and_http_errors(self, service):
def handler(url, kwargs):
if url.endswith("/down"):
@@ -670,14 +706,14 @@ class TestCallerIdentity:
assert _counters(global_service, plugin="football-scoreboard")["requests"] == 1
def test_espn_chunks_on_worker_threads_count_against_the_caller(self, global_service):
from src.common.espn_dates import espn_date_chunks, fetch_espn_date_chunks, parse_espn_date_range
from src.common.espn_dates import espn_request_chunks, fetch_espn_date_chunks, parse_espn_date_range
session = FakeSession(lambda url, kw: make_response(body=b'{"events": []}', url=url))
dates = "20260801-20261015"
with plugin_scope("baseball-scoreboard"):
fetch_espn_date_chunks(session, "https://site.api.espn.com/s/scoreboard",
params={"dates": dates})
chunks = len(espn_date_chunks(*parse_espn_date_range(dates)))
chunks = len(espn_request_chunks(*parse_espn_date_range(dates)))
assert chunks > 1
assert len(session.calls) == chunks
assert _counters(global_service, plugin="baseball-scoreboard")["requests"] == chunks
+143
View File
@@ -185,6 +185,149 @@ class TestPluginFonts:
assert fm.font_catalog["my-plugin::bundled"] == str(plugin_dir / "fonts" / "Bundled.ttf")
class TestForgetPluginFonts:
"""forget_plugin_fonts drops what a plugin's manifest registered. Before
it, unloading a plugin left its fonts resolvable and its cached font
objects alive until a restart."""
@staticmethod
def _register(fm, root, plugin_id, family="bundled"):
plugin_dir = root / plugin_id
(plugin_dir / "fonts").mkdir(parents=True, exist_ok=True)
font_file = plugin_dir / "fonts" / f"{family}.ttf"
if not font_file.exists(): # a loaded font may hold it open (Windows)
shutil.copy(resolve_asset_path("assets/fonts/PressStart2P-Regular.ttf"), font_file)
manifest = {"fonts": [{"family": family, "source": f"plugin://fonts/{family}.ttf"}]}
assert fm.register_plugin_fonts(plugin_id, manifest, plugin_dir=plugin_dir)
return plugin_dir
@staticmethod
def _entries_of(fm, plugin_id):
prefix = f"{plugin_id}::"
return {
"plugin_fonts": plugin_id in fm.plugin_fonts,
"plugin_font_catalogs": plugin_id in fm.plugin_font_catalogs,
"font_catalog": [k for k in fm.font_catalog if k.startswith(prefix)],
"font_cache": [k for k in fm.font_cache if k.startswith(prefix)],
}
NONE = {"plugin_fonts": False, "plugin_font_catalogs": False,
"font_catalog": [], "font_cache": []}
def test_unload_leaves_no_plugin_entries(self, fm, tmp_path):
self._register(fm, tmp_path, "alpha")
fm.resolve_font("alpha.title", "bundled", 8, plugin_id="alpha")
fm.get_font("alpha::bundled", 10)
assert self._entries_of(fm, "alpha")["font_cache"] # cached before
gen = fm.cache_generation
assert fm.forget_plugin_fonts("alpha") is True
assert self._entries_of(fm, "alpha") == self.NONE
assert fm.cache_generation == gen + 1
# The family no longer resolves to the plugin's file.
assert fm.font_catalog.get("alpha::bundled") is None
def test_other_plugins_and_core_fonts_are_untouched(self, fm, tmp_path):
self._register(fm, tmp_path, "alpha")
self._register(fm, tmp_path, "beta")
# A plugin whose id is a prefix of another's must not take it along.
self._register(fm, tmp_path, "alpha-two")
for pid in ("alpha", "beta", "alpha-two"):
fm.get_font(f"{pid}::bundled", 8)
core_font = fm.get_font("press_start", 8)
beta_before = self._entries_of(fm, "beta")
alpha_two_before = self._entries_of(fm, "alpha-two")
fm.forget_plugin_fonts("alpha")
assert self._entries_of(fm, "beta") == beta_before
assert self._entries_of(fm, "alpha-two") == alpha_two_before
assert fm.get_font("press_start", 8) is core_font
def test_reload_re_registers_cleanly(self, fm, tmp_path):
plugin_dir = self._register(fm, tmp_path, "alpha")
old = fm.get_font("alpha::bundled", 8)
fm.forget_plugin_fonts("alpha")
self._register(fm, tmp_path, "alpha")
assert fm.font_catalog["alpha::bundled"] == str(plugin_dir / "fonts" / "bundled.ttf")
font = fm.resolve_font("alpha.title", "bundled", 8, plugin_id="alpha")
assert isinstance(font, ImageFont.FreeTypeFont)
assert font is not old # loaded fresh, not the dropped cache entry
def test_a_family_the_new_manifest_drops_stops_resolving(self, fm, tmp_path):
self._register(fm, tmp_path, "alpha", family="old_face")
fm.forget_plugin_fonts("alpha")
self._register(fm, tmp_path, "alpha", family="new_face")
assert "alpha::old_face" not in fm.font_catalog
assert "alpha::new_face" in fm.font_catalog
def test_unknown_plugin_is_a_no_op(self, fm):
catalog = dict(fm.font_catalog)
gen = fm.cache_generation
assert fm.forget_plugin_fonts("never-registered") is False
assert fm.font_catalog == catalog
assert fm.cache_generation == gen
class TestPluginManagerReloadFonts:
"""Through PluginManager: unloading a plugin forgets its manifest fonts,
and reload_plugin (unload + load) registers them again so they resolve."""
PLUGIN_ID = "font-reload-demo"
MODULE = "plugin_font_reload_demo"
def test_unload_forgets_and_reload_resolves(self, tmp_path):
import sys
from src.plugin_system.plugin_manager import PluginManager
plugins_dir = tmp_path / "plugins"
plugin_dir = plugins_dir / self.PLUGIN_ID
(plugin_dir / "fonts").mkdir(parents=True)
shutil.copy(resolve_asset_path("assets/fonts/PressStart2P-Regular.ttf"),
plugin_dir / "fonts" / "Bundled.ttf")
manifest = {"id": self.PLUGIN_ID, "name": "Demo", "class_name": "Demo",
"entry_point": "manager.py",
"fonts": {"fonts": [{"family": "bundled",
"source": "plugin://fonts/Bundled.ttf"}]}}
(plugin_dir / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
(plugin_dir / "manager.py").write_text(
"class Demo:\n"
" def __init__(self, plugin_id, config, display_manager, cache_manager, plugin_manager):\n"
" self.enabled = True\n", encoding="utf-8")
pm = PluginManager(plugins_dir=str(plugins_dir))
fm = FontManager({})
pm.font_manager = fm
pm.plugin_manifests[self.PLUGIN_ID] = manifest
key = f"{self.PLUGIN_ID}::bundled"
try:
assert pm.load_plugin(self.PLUGIN_ID) is True
assert key in fm.font_catalog
fm.register_manager_font(self.PLUGIN_ID, "demo.title", "bundled", 8)
old = fm.resolve_font("demo.title", "bundled", 8, plugin_id=self.PLUGIN_ID)
assert pm.unload_plugin(self.PLUGIN_ID) is True
assert self.PLUGIN_ID not in fm.plugin_fonts
assert self.PLUGIN_ID not in fm.plugin_font_catalogs
assert key not in fm.font_catalog
assert not [k for k in fm.font_cache if k.startswith(f"{self.PLUGIN_ID}::")]
assert self.PLUGIN_ID not in fm.manager_fonts
assert pm.reload_plugin(self.PLUGIN_ID) is True
assert fm.font_catalog[key] == str(plugin_dir / "fonts" / "Bundled.ttf")
font = fm.resolve_font("demo.title", "bundled", 8, plugin_id=self.PLUGIN_ID)
assert isinstance(font, ImageFont.FreeTypeFont)
assert font is not old
finally:
sys.modules.pop(self.MODULE, None)
class TestDownloadFont:
"""_download_font: plugin fonts declared by URL, cached in temp_font_dir."""
+1 -1
View File
@@ -709,6 +709,6 @@ class TestTheControllersOwnScreens:
c._check_wifi_status_message.return_value = None
inputs = DisplayController._arbiter_inputs(c)
assert inputs.wifi_notice is None
assert Arbiter.decide(ArbiterState(), inputs, 0.0).source is Source.LEGACY
assert Arbiter.decide(ArbiterState(), inputs, 0.0).source is Source.ROTATION
assert dm.is_currently_scrolling()
assert dm._frame_hold == 2
@@ -0,0 +1,188 @@
"""GET /api/v3/plugins/installed never waits on the network for registry data.
The route used `get_registry_info`, which goes through `fetch_registry`: on a
cold (or expired) cache that downloads plugins.json from GitHub with a 10s
timeout and three attempts -- and, with no registry to fall back on, every
plugin's lookup repeated the whole cycle. The first plugin-list load after a
restart waited on GitHub, and offline it waited out every timeout.
Now the route reads the registry copy already in memory, however old, and a
missing or expired copy only starts a background refresh. These tests block
the network at the socket layer (DNS lookups hang, then fail) and assert the
request returns quickly without a single network attempt on the request path.
"""
import functools
import socket
import threading
import time
from unittest.mock import MagicMock
import pytest
import requests
from test._api_v3_test_helpers import ( # noqa: F401 - fixtures
api_v3_client, api_v3_module,
)
from src.plugin_system.store_manager import PluginStoreManager
# How long a blocked lookup hangs before failing: long enough that a single
# one on the request path blows the response budget below.
HANG_SECONDS = 1.5
FAST_SECONDS = 1.0
REGISTRY = {'plugins': [
{'id': 'weather', 'name': 'Weather', 'verified': True, 'latest_version': '1.2.0'},
]}
@pytest.fixture
def blocked_network(monkeypatch):
"""Every DNS lookup hangs, then fails; records the thread it came from."""
attempts = []
def hang_then_fail(host, *args, **kwargs):
attempts.append((host, threading.current_thread().name))
time.sleep(HANG_SECONDS)
raise socket.gaierror(-3, 'Temporary failure in name resolution (blocked by test)')
monkeypatch.setattr(socket, 'getaddrinfo', hang_then_fail)
return attempts
@pytest.fixture
def store(tmp_path, monkeypatch):
store = PluginStoreManager(plugins_dir=str(tmp_path / 'plugins'))
# One attempt, no pause between attempts, so a background refresh against
# the blocked network ends within the test (teardown joins it).
monkeypatch.setattr(store, '_http_get_with_retries', functools.partial(
PluginStoreManager._http_get_with_retries, store, max_retries=1))
yield store
thread = getattr(store, '_registry_refresh_thread', None)
if thread is not None:
thread.join(timeout=30)
@pytest.fixture
def get_installed(api_v3_module, api_v3_client, store, tmp_path):
api = api_v3_module.api_v3
api.plugin_store_manager = store
api.plugin_catalog.plugins_dir = str(tmp_path / 'plugins')
api.plugin_catalog.get_all_plugin_info = MagicMock(return_value=[
{'id': 'weather', 'name': 'Weather', 'version': '1.0.0'},
{'id': 'clock', 'name': 'Clock', 'version': '2.0.0'},
])
api.plugin_catalog.get_plugin_display_modes = MagicMock(return_value=[])
api.config_manager.load_config = MagicMock(return_value={})
def _get():
start = time.perf_counter()
response = api_v3_client.get('/api/v3/plugins/installed')
elapsed = time.perf_counter() - start
assert response.status_code == 200
plugins = {p['id']: p for p in response.get_json()['data']['plugins']}
return plugins, elapsed
return _get
def _request_path_attempts(attempts):
return [a for a in attempts if a[1] != 'registry-refresh']
def test_a_cold_cache_offline_returns_fast_without_registry_info(get_installed, blocked_network):
plugins, elapsed = get_installed()
assert _request_path_attempts(blocked_network) == []
assert elapsed < FAST_SECONDS, f"installed list took {elapsed:.2f}s with the network blocked"
weather = plugins['weather']
assert weather['latest_version'] == ''
assert weather['update_available'] is False
assert weather['verified'] is False
def test_a_stale_cache_is_used_as_is_without_a_fetch(get_installed, store, blocked_network):
store.registry_cache = REGISTRY
store.registry_cache_time = time.time() - store.registry_cache_timeout - 3600
plugins, elapsed = get_installed()
assert _request_path_attempts(blocked_network) == []
assert elapsed < FAST_SECONDS
weather = plugins['weather']
assert weather['latest_version'] == '1.2.0'
assert weather['update_available'] is True
assert weather['verified'] is True
assert plugins['clock']['latest_version'] == ''
def test_a_fresh_cache_starts_no_refresh(get_installed, store, blocked_network):
store.registry_cache = REGISTRY
store.registry_cache_time = time.time()
plugins, _ = get_installed()
assert blocked_network == []
assert store._registry_refresh_thread is None
assert plugins['weather']['update_available'] is True
def test_a_cold_cache_is_filled_in_the_background_for_the_next_load(get_installed, store, monkeypatch):
response = MagicMock()
response.json.return_value = REGISTRY
fetched_on = []
def fake_get(url, **kwargs):
fetched_on.append(threading.current_thread().name)
return response
monkeypatch.setattr(store, '_http_get_with_retries', fake_get)
first, _ = get_installed()
assert first['weather']['update_available'] is False
store._registry_refresh_thread.join(timeout=10)
second, _ = get_installed()
assert second['weather']['latest_version'] == '1.2.0'
assert second['weather']['update_available'] is True
# One background fetch for the whole listing, none on the request path.
assert fetched_on == ['registry-refresh']
def test_an_offline_background_refresh_backs_off(store, monkeypatch):
def offline(url, **kwargs):
raise requests.ConnectionError('blocked by test')
monkeypatch.setattr(store, '_http_get_with_retries', offline)
assert store.refresh_registry_in_background() is True
store._registry_refresh_thread.join(timeout=10)
assert store.registry_cache is None
# Offline: the next page load does not start another attempt straight away.
assert store.refresh_registry_in_background() is False
store._registry_refresh_retry_after = 0.0
assert store.refresh_registry_in_background() is True
def test_only_one_background_refresh_runs_at_a_time(store, monkeypatch):
release = threading.Event()
def slow(url, **kwargs):
release.wait(10)
raise requests.ConnectionError('blocked by test')
monkeypatch.setattr(store, '_http_get_with_retries', slow)
try:
assert store.refresh_registry_in_background() is True
assert store.refresh_registry_in_background() is False
finally:
release.set()
def test_get_registry_info_still_fetches_for_the_store(store, monkeypatch):
"""The store, install and update paths keep fetching a cold registry."""
response = MagicMock()
response.json.return_value = REGISTRY
monkeypatch.setattr(store, '_http_get_with_retries', MagicMock(return_value=response))
assert store.get_registry_info('weather')['latest_version'] == '1.2.0'
store._http_get_with_retries.assert_called_once()
+2 -1
View File
@@ -167,7 +167,8 @@ class TestOnDemandArgs:
def test_every_command_has_an_argument_type(self, cmd):
args = {Command.ON_DEMAND_START: {'plugin_id': 'p'},
Command.PLUGIN_RELOAD: {'plugin_id': 'p'},
Command.BRIGHTNESS_SET: {'brightness': 50}}.get(cmd, {})
Command.BRIGHTNESS_SET: {'brightness': 50},
Command.ERRORS_CLEAR: {'cutoff': 1790000000.0}}.get(cmd, {})
c.parse_args(cmd, args)
def test_hello_versions(self):
+15 -5
View File
@@ -41,6 +41,15 @@ def _reload(plugin_id):
return _command(Command.PLUGIN_RELOAD, PluginReloadArgs(plugin_id))
def _screen(mode):
"""A rotation screen of ``mode``, as the ScreenRunner hands it to the
1 Hz loop's frame wait."""
from src.display_arbiter import ArbiterState, rotation_plan
from src.screen_runner import Screen
plan = rotation_plan(ArbiterState(current_mode=mode))
return Screen(plan, plugin=None, accepts_display_mode=False, start=0.0)
@pytest.fixture
def dc(test_display_controller):
c = test_display_controller
@@ -253,7 +262,8 @@ class TestRealTimeWake:
dc.on_demand_active = False
dc._activate_on_demand = MagicMock(
side_effect=lambda request: setattr(dc, 'current_display_mode', 'weather'))
dc._wifi_notice_pending = MagicMock(return_value=False)
dc._wifi_notice_pending = MagicMock(return_value=False) # the dwell's check
dc._read_wifi_notice = MagicMock(return_value=None) # the frame wait's
dc._tick_plugin_updates = MagicMock()
dc._check_live_takeover = MagicMock()
dc.cache_manager.get = MagicMock(return_value=None)
@@ -282,10 +292,10 @@ class TestRealTimeWake:
dc.current_display_mode = 'clock'
stamps = []
t = self._post_later(server, self._start_line(f's{i}'), 0.05, stamps)
ended = dc._wait_frame_interval(1.0, 'clock')
ended = dc._wait_frame_interval(1.0, _screen('clock'))
woke = time.monotonic()
t.join()
assert ended is True
assert ended is not None
latencies.append(woke - stamps[0])
latencies.sort()
print(f"static-screen wake latency: median {latencies[5] * 1000:.2f} ms, "
@@ -315,7 +325,7 @@ class TestRealTimeWake:
'args': {'brightness': 33}}).encode()
started = time.monotonic()
t = self._post_later(server, line, 0.1, stamps)
assert dc._wait_frame_interval(0.5, 'clock') is False
assert dc._wait_frame_interval(0.5, _screen('clock')) is None
assert time.monotonic() - started >= 0.49
t.join()
dc.display_manager.set_brightness.assert_called_once_with(33)
@@ -323,7 +333,7 @@ class TestRealTimeWake:
def test_without_a_socket_it_is_a_plain_sleep(self, dc):
dc._control_server = None
started = time.monotonic()
assert dc._wait_frame_interval(0.2, 'clock') is False
assert dc._wait_frame_interval(0.2, _screen('clock')) is None
assert time.monotonic() - started >= 0.19
+509
View File
@@ -0,0 +1,509 @@
"""Stage 4 of the control socket: the file mailboxes are only a fallback.
* The client knows whether the display had the request (``ControlError.sent``)
and ``should_fall_back`` allows a mailbox write only when it did not, or
when the display is too old to know the command (the upgrade case).
* ``errors.clear`` is answered on the connection thread by a handler the
display registers; a display without one answers like an older display.
* The display looks at the on-demand mailbox once a second while the socket
is up (0.25 s without it), reads it only when its file changed, never
touches it for a socket command, and logs who still writes it.
* ``CacheManager.file_signature`` / ``MailboxWatch`` make a look one stat().
The web routes are covered in test_api_v3_on_demand_socket.py and
test_error_snapshot_cross_process.py.
"""
import json
import logging
import os
import socket
import time
from unittest.mock import MagicMock, patch
import pytest
from src.cache_manager import CacheManager, MailboxWatch
from src.ipc import client
from src.ipc import contract as c
from src.ipc.contract import Command, ErrorsClearArgs, OnDemandStartArgs, ProtocolError
from src.ipc.server import ControlServer, QueuedCommand
MAILBOX = 'display_on_demand_request'
# -- the client: was the request sent? ---------------------------------------------
class FakeSock:
"""Stands in for a connected socket in client._exchange."""
def __init__(self, replies=(), send_error=None, recv_error=None):
self.replies = list(replies)
self.send_error = send_error
self.recv_error = recv_error
self.sent = b''
def settimeout(self, _t):
pass
def sendall(self, data):
if self.send_error is not None:
raise self.send_error
self.sent += data
def recv(self, _n):
if self.recv_error is not None:
raise self.recv_error
return self.replies.pop(0) if self.replies else b''
def close(self):
pass
def _reply(request_id, **body):
return (json.dumps(dict({'v': 1, 'id': request_id}, **body)) + '\n').encode()
def _call(sock=None, connect_error=None, request_id='rid-1'):
with patch.object(client, 'socket_supported', return_value=True), \
patch.object(client, '_connect',
side_effect=connect_error, return_value=sock):
return client.request(Command.PING, {}, request_id=request_id, paths=['/x.sock'])
def _error(**kw):
with pytest.raises(client.ControlError) as e:
_call(**kw)
return e.value
class TestSent:
def test_an_answer_is_returned(self):
assert _call(FakeSock([_reply('rid-1', ok=True, result={'pong': True})])) == {'pong': True}
@pytest.mark.parametrize('reason', ['no_socket', 'refused', 'timeout', 'busy'])
def test_a_failed_connect_was_not_sent(self, reason):
e = _error(connect_error=client.ControlError(reason))
assert e.reason == reason and e.sent is False
assert client.should_fall_back(e)
def test_a_send_that_timed_out_was_not_sent(self):
e = _error(sock=FakeSock(send_error=socket.timeout()))
assert e.reason == 'timeout' and e.sent is False
assert client.should_fall_back(e)
def test_silence_after_the_request_was_sent(self):
e = _error(sock=FakeSock(recv_error=socket.timeout()))
assert e.reason == 'timeout' and e.sent is True
assert not client.should_fall_back(e)
def test_a_hang_up_after_the_request_was_sent(self):
e = _error(sock=FakeSock([]))
assert e.reason == 'closed' and e.sent is True
assert not client.should_fall_back(e)
def test_a_garbled_reply(self):
e = _error(sock=FakeSock([b'not json\n']))
assert e.reason == 'bad_response' and e.sent is True
assert not client.should_fall_back(e)
@pytest.mark.parametrize('code', ['busy', 'invalid_args', 'internal', 'pending', 'failed'])
def test_a_display_error_with_an_id_was_sent(self, code):
sock = FakeSock([_reply('rid-1', ok=False, error={'code': code, 'message': 'x'})])
e = _error(sock=sock)
assert e.reason == code and e.sent is True
assert not client.should_fall_back(e)
@pytest.mark.parametrize('code', ['forbidden', 'busy'])
def test_a_refusal_at_the_door_was_not_sent(self, code):
# forbidden, or too many connections: answered before the request
# was read, so with no id.
sock = FakeSock([(json.dumps({'v': 1, 'id': None, 'ok': False,
'error': {'code': code, 'message': 'x'}}) + '\n').encode()])
e = _error(sock=sock)
assert e.reason == code and e.sent is False
assert client.should_fall_back(e)
@pytest.mark.parametrize('code', ['unknown_command', 'unsupported_version'])
def test_an_older_display_is_fallen_back_from(self, code):
sock = FakeSock([_reply('rid-1', ok=False, error={'code': code, 'message': 'x'})])
e = _error(sock=sock)
assert e.sent is True
assert client.should_fall_back(e)
def test_a_request_refused_locally_never_left(self):
with pytest.raises(client.ControlError) as e:
client.request(Command.ERRORS_CLEAR, {'cutoff': 'soon'}, paths=['/x.sock'])
assert e.value.reason == 'invalid_request' and e.value.sent is False
assert client.should_fall_back(e.value)
def test_a_client_bug_falls_back(self):
assert client.should_fall_back(RuntimeError('boom'))
# -- errors.clear on the server ------------------------------------------------------
def _line(cmd, args, rid='r1'):
return c.encode_message({'v': 1, 'id': rid, 'cmd': cmd, 'args': args})
class TestErrorsClearOnTheServer:
def test_the_handler_answers_it(self, tmp_path):
seen = []
def handler(request_id, args):
seen.append((request_id, args))
return {'request_id': request_id, 'cutoff': args.cutoff, 'cleared': 4}
server = ControlServer(str(tmp_path / 's.sock'),
handlers={Command.ERRORS_CLEAR: handler})
response = server.handle_line(_line(Command.ERRORS_CLEAR, {'cutoff': 123}))
assert response.ok and response.result == {'request_id': 'r1', 'cutoff': 123.0,
'cleared': 4}
assert seen == [('r1', ErrorsClearArgs(cutoff=123.0))]
assert not server.has_pending # not queued for the render thread
def test_a_display_without_a_handler_answers_like_an_older_one(self, tmp_path):
server = ControlServer(str(tmp_path / 's.sock'))
response = server.handle_line(_line(Command.ERRORS_CLEAR, {'cutoff': 1}))
assert not response.ok and response.error.code == c.ErrorCode.UNKNOWN_COMMAND
def test_only_direct_commands_take_a_handler(self, tmp_path):
server = ControlServer(str(tmp_path / 's.sock'),
handlers={Command.ON_DEMAND_START: lambda *a: {}})
response = server.handle_line(_line(Command.ON_DEMAND_START, {'plugin_id': 'p'}))
assert response.ok and response.result['accepted'] is True # still queued
assert server.has_pending
def test_a_handler_error_is_contained(self, tmp_path):
def boom(*_a):
raise ValueError('disk gone')
server = ControlServer(str(tmp_path / 's.sock'), handlers={Command.ERRORS_CLEAR: boom})
response = server.handle_line(_line(Command.ERRORS_CLEAR, {'cutoff': 1}))
assert response.error.code == c.ErrorCode.INTERNAL
assert 'disk gone' not in response.error.message
def test_a_handler_can_refuse_with_a_code(self, tmp_path):
def refuse(*_a):
raise ProtocolError(c.ErrorCode.BUSY, 'later')
server = ControlServer(str(tmp_path / 's.sock'), handlers={Command.ERRORS_CLEAR: refuse})
assert server.handle_line(
_line(Command.ERRORS_CLEAR, {'cutoff': 1})).error.code == c.ErrorCode.BUSY
@pytest.mark.parametrize('cutoff', ['1', None, True, float('inf'), -1])
def test_bad_cutoffs_are_refused(self, cutoff):
with pytest.raises(ProtocolError) as e:
ErrorsClearArgs.from_dict({'cutoff': cutoff})
assert e.value.code == c.ErrorCode.INVALID_ARGS
def test_hello_lists_it(self, tmp_path):
server = ControlServer(str(tmp_path / 's.sock'))
result = server.handle_line(_line(Command.HELLO, {'versions': [1]})).result
assert Command.ERRORS_CLEAR in result['commands']
# -- the display's mailbox poll ------------------------------------------------------
class SignedCache:
"""The slice of CacheManager the poll uses, counting what it costs."""
def __init__(self):
self.data = {}
self.writes = 0
self.version = {}
self.reads = []
self.stats = 0
self.deletes = []
self.sets = []
def file_signature(self, key):
self.stats += 1
return (self.version[key], 0, 0) if key in self.data else None
def get(self, key, *a, **kw):
self.reads.append(key)
return self.data.get(key)
def set(self, key, value, *a, **kw):
self.sets.append(key)
self.data[key] = value
self.writes += 1
self.version[key] = self.writes
def delete(self, key):
self.deletes.append(key)
self.data.pop(key, None)
class FakeServer:
def __init__(self):
self.commands = []
@property
def has_pending(self):
return bool(self.commands)
def drain(self):
out, self.commands = self.commands, []
return out
class Clock:
def __init__(self):
self.t = 1000.0
def __call__(self):
return self.t
@pytest.fixture
def controller(test_display_controller, monkeypatch):
dc = test_display_controller
dc.cache_manager = SignedCache()
dc._activate_on_demand = MagicMock()
dc.on_demand_active = False
dc.on_demand_request_id = None
dc._last_on_demand_poll = None
dc._on_demand_mailbox = None
dc._mailbox_writers_logged = frozenset()
clock = Clock()
monkeypatch.setattr('src.display_controller.time.monotonic', clock)
dc.clock = clock
return dc
def _post(dc, rid, action='start', **fields):
dc.cache_manager.set(MAILBOX, dict({'request_id': rid, 'action': action}, **fields))
def _poll_for(dc, seconds, step=1 / 16): # exact in binary: no drift past a floor
end = dc.clock.t + seconds
while dc.clock.t < end:
dc._poll_on_demand_requests()
dc.clock.t += step
class TestMailboxCadence:
def test_without_a_socket_it_is_looked_at_every_quarter_second(self, controller):
controller._control_server = None
_poll_for(controller, 10.0)
assert 38 <= controller.cache_manager.stats <= 42
def test_with_a_socket_it_is_looked_at_once_a_second(self, controller):
controller._control_server = FakeServer()
_poll_for(controller, 10.0)
assert 9 <= controller.cache_manager.stats <= 11
def test_a_look_that_finds_nothing_reads_nothing(self, controller):
controller._control_server = FakeServer()
_poll_for(controller, 10.0)
assert controller.cache_manager.reads == []
def test_an_unchanged_mailbox_is_not_read_again(self, controller):
# An already-processed start the delete could not remove, say.
controller._control_server = FakeServer()
controller.cache_manager.delete = MagicMock() # the file stays
_post(controller, 'once', plugin_id='clock')
_poll_for(controller, 10.0)
assert controller.cache_manager.reads.count(MAILBOX) <= 2 # the read + the re-check
controller._activate_on_demand.assert_called_once()
def test_a_mailbox_request_lands_within_a_second_with_the_socket_up(self, controller):
# The upgrade case the other way round: a new display, and a web
# interface (or a plugin) that still writes the mailbox.
controller._control_server = FakeServer()
controller._poll_on_demand_requests()
controller.clock.t += 0.1
_post(controller, 'old-web', plugin_id='clock')
posted = controller.clock.t
while not controller._activate_on_demand.called:
controller._poll_on_demand_requests()
controller.clock.t += 0.05
assert controller.clock.t - posted < 1.5
assert controller.clock.t - posted <= controller.MAILBOX_POLL_INTERVAL_WITH_SOCKET + 0.06
assert MAILBOX in controller.cache_manager.deletes # consumed
def test_socket_commands_still_land_at_once(self, controller):
server = controller._control_server = FakeServer()
controller._poll_on_demand_requests()
server.commands.append(QueuedCommand('sock', Command.ON_DEMAND_START,
OnDemandStartArgs(plugin_id='clock'), time.time()))
controller._poll_on_demand_requests() # inside the mailbox interval
controller._activate_on_demand.assert_called_once()
class TestSocketCommandsLeaveTheMailboxAlone:
def test_a_socket_start_reads_and_deletes_no_mailbox(self, controller):
server = controller._control_server = FakeServer()
controller._poll_on_demand_requests()
before = list(controller.cache_manager.reads)
server.commands.append(QueuedCommand('s1', Command.ON_DEMAND_START,
OnDemandStartArgs(plugin_id='clock'), time.time()))
controller._poll_on_demand_requests()
controller._activate_on_demand.assert_called_once()
assert MAILBOX not in controller.cache_manager.reads[len(before):]
assert controller.cache_manager.deletes == []
def test_a_socket_stop_reads_and_deletes_no_mailbox(self, controller):
from src.ipc.contract import OnDemandStopArgs
controller.on_demand_active = True
controller._clear_on_demand = MagicMock()
server = controller._control_server = FakeServer()
server.commands.append(QueuedCommand('s2', Command.ON_DEMAND_STOP,
OnDemandStopArgs(), time.time()))
controller.clock.t += 5
controller._drain_control_commands()
controller._clear_on_demand.assert_called_once()
assert MAILBOX not in controller.cache_manager.reads
assert controller.cache_manager.deletes == []
def test_a_mailbox_copy_of_a_socket_command_is_dropped(self, controller):
# An older web interface timed out after the display queued the
# command, then wrote the mailbox too.
server = controller._control_server = FakeServer()
server.commands.append(QueuedCommand('both', Command.ON_DEMAND_START,
OnDemandStartArgs(plugin_id='clock'), time.time()))
controller._poll_on_demand_requests()
_post(controller, 'both', plugin_id='clock')
_poll_for(controller, 2.0)
controller._activate_on_demand.assert_called_once()
assert MAILBOX not in controller.cache_manager.data
class TestDeprecationLog:
def test_each_mailbox_writer_is_logged_once(self, controller, caplog):
controller._control_server = FakeServer()
caplog.set_level(logging.INFO, logger='src.display_controller')
for i, plugin in enumerate(['on-air', 'on-air', 'pomodoro-timer']):
_post(controller, f'r{i}', plugin_id=plugin)
_poll_for(controller, 1.2)
lines = [r.getMessage() for r in caplog.records if 'file mailbox' in r.getMessage()]
assert len(lines) == 2
assert 'on-air' in lines[0] and 'pomodoro-timer' in lines[1]
def test_nothing_is_logged_without_a_socket(self, controller, caplog):
controller._control_server = None
caplog.set_level(logging.INFO, logger='src.display_controller')
_post(controller, 'r', plugin_id='on-air')
_poll_for(controller, 1.0)
controller._activate_on_demand.assert_called_once()
assert not [r for r in caplog.records if 'file mailbox' in r.getMessage()]
# -- file_signature and MailboxWatch -------------------------------------------------
@pytest.fixture
def real_cache(tmp_path, monkeypatch):
monkeypatch.setattr(CacheManager, '_get_writable_cache_dir', lambda self: str(tmp_path))
cache = CacheManager()
yield cache
cache.stop_cleanup_thread()
class TestFileSignature:
def test_absent_key(self, real_cache):
assert real_cache.file_signature('nothing') is None
def test_every_write_is_a_new_signature(self, real_cache):
seen = set()
for i in range(20):
# Same size each time, written as fast as possible.
real_cache.set(MAILBOX, {'request_id': f'r{i:02d}'})
sig = real_cache.file_signature(MAILBOX)
assert isinstance(sig, tuple)
seen.add(sig)
assert len(seen) == 20
def test_gone_after_a_delete(self, real_cache):
real_cache.set(MAILBOX, {'a': 1})
real_cache.delete(MAILBOX)
assert real_cache.file_signature(MAILBOX) is None
class TestMailboxWatch:
def test_reads_once_per_write(self, real_cache):
watch = MailboxWatch(MAILBOX)
assert watch.changed(real_cache) is False # no file
real_cache.set(MAILBOX, {'request_id': 'a'})
assert watch.changed(real_cache) is True
assert watch.changed(real_cache) is False
real_cache.set(MAILBOX, {'request_id': 'b'})
assert watch.changed(real_cache) is True
def test_forget_reads_again(self, real_cache):
watch = MailboxWatch(MAILBOX)
real_cache.set(MAILBOX, {'request_id': 'a'})
assert watch.changed(real_cache) is True
watch.forget()
assert watch.changed(real_cache) is True
def test_a_rewrite_after_a_delete_is_seen(self, real_cache):
watch = MailboxWatch(MAILBOX)
real_cache.set(MAILBOX, {'request_id': 'a'})
assert watch.changed(real_cache)
real_cache.delete(MAILBOX)
assert watch.changed(real_cache) is False
real_cache.set(MAILBOX, {'request_id': 'a'})
assert watch.changed(real_cache) is True
def test_a_cache_that_cannot_tell_is_read_every_time(self):
watch = MailboxWatch(MAILBOX)
assert watch.changed(MagicMock()) is True
assert watch.changed(MagicMock()) is True
assert watch.changed(object()) is True
# -- end to end over a real socket ---------------------------------------------------
@pytest.mark.skipif(not c.socket_supported(), reason='AF_UNIX sockets are Linux/macOS only')
class TestOverTheSocket:
@pytest.fixture
def sock_path(self):
import shutil
import tempfile
d = tempfile.mkdtemp(prefix='lmipc-')
yield os.path.join(d, 'control.sock')
shutil.rmtree(d, ignore_errors=True)
def test_errors_clear_round_trip(self, sock_path):
def handler(request_id, args):
return {'request_id': request_id, 'cutoff': args.cutoff, 'cleared': 2}
server = ControlServer(sock_path, handlers={Command.ERRORS_CLEAR: handler})
assert server.start()
try:
result = client.errors_clear('clr-1', 1790000000.0, paths=[sock_path])
assert result == {'request_id': 'clr-1', 'cutoff': 1790000000.0, 'cleared': 2}
finally:
server.close()
def test_an_older_display_is_an_upgrade_fallback(self, sock_path):
server = ControlServer(sock_path) # no errors.clear handler
assert server.start()
try:
with pytest.raises(client.ControlError) as e:
client.errors_clear('clr-2', 1.0, paths=[sock_path])
assert e.value.reason == 'unknown_command' and e.value.sent is True
assert client.should_fall_back(e.value)
finally:
server.close()
def test_no_display_is_a_fallback(self, sock_path):
with pytest.raises(client.ControlError) as e:
client.errors_clear('clr-3', 1.0, paths=[sock_path])
assert e.value.reason == 'no_socket' and e.value.sent is False
assert client.should_fall_back(e.value)
def test_a_full_queue_is_not_a_fallback(self, sock_path):
server = ControlServer(sock_path, queue_size=1)
assert server.start()
try:
client.on_demand_start('q1', 'clock', None, paths=[sock_path])
with pytest.raises(client.ControlError) as e:
client.on_demand_start('q2', 'clock', None, paths=[sock_path])
assert e.value.reason == 'busy' and e.value.sent is True
assert not client.should_fall_back(e.value)
finally:
server.close()
+206
View File
@@ -0,0 +1,206 @@
"""src/malloc_tuning.py: glibc arena cap and periodic malloc_trim, ctypes mocked."""
import ctypes
from pathlib import Path
from unittest import mock
import pytest
from src import malloc_tuning as mt
class FakeLibc:
"""Stands in for ctypes.CDLL(None) on glibc: records calls."""
def __init__(self, trim_result=1, glibc=True):
self.trims = []
self.mallopts = []
self._trim_result = trim_result
if glibc:
self.gnu_get_libc_version = lambda: b'2.41'
self.malloc_trim = mock.Mock(side_effect=self._trim)
self.mallopt = mock.Mock(side_effect=self._mallopt)
def _trim(self, pad):
self.trims.append(pad)
if isinstance(self._trim_result, Exception):
raise self._trim_result
return self._trim_result
def _mallopt(self, param, value):
self.mallopts.append((param, value))
return 1
@pytest.fixture(autouse=True)
def fresh_libc(monkeypatch):
"""Each test loads the C library itself; nothing real is called."""
monkeypatch.setattr(mt, '_libc', mt._UNLOADED)
monkeypatch.delenv('MALLOC_ARENA_MAX', raising=False)
yield
def _on_glibc(monkeypatch, libc):
monkeypatch.setattr(mt.sys, 'platform', 'linux')
cdll = mock.Mock(return_value=libc)
monkeypatch.setattr(mt.ctypes, 'CDLL', cdll)
return cdll
class Clock:
def __init__(self, t=1000.0):
self.t = t
def __call__(self):
return self.t
# -- loading ----------------------------------------------------------------
@pytest.mark.parametrize('platform', ['win32', 'darwin', 'freebsd14'])
def test_not_linux_loads_nothing(monkeypatch, platform):
monkeypatch.setattr(mt.sys, 'platform', platform)
cdll = mock.Mock(side_effect=AssertionError('must not load'))
monkeypatch.setattr(mt.ctypes, 'CDLL', cdll)
assert mt._load_libc() is None
assert mt.cap_arenas() is False
trimmer = mt.MallocTrimmer(interval=0)
assert not trimmer.available
assert trimmer.maybe_trim() is False
cdll.assert_not_called()
def test_linux_without_glibc_is_a_noop(monkeypatch):
"""musl: no gnu_get_libc_version (and no malloc_trim) -- nothing is called."""
libc = FakeLibc(glibc=False)
del libc.malloc_trim
_on_glibc(monkeypatch, libc)
assert mt._load_libc() is None
assert mt.cap_arenas() is False
assert mt.MallocTrimmer(interval=0).maybe_trim() is False
assert libc.mallopts == []
def test_cdll_failure_is_a_noop(monkeypatch):
monkeypatch.setattr(mt.sys, 'platform', 'linux')
monkeypatch.setattr(mt.ctypes, 'CDLL', mock.Mock(side_effect=OSError('no libc')))
assert mt._load_libc() is None
assert mt.cap_arenas() is False
def test_loads_once(monkeypatch):
cdll = _on_glibc(monkeypatch, FakeLibc())
mt._load_libc()
mt._load_libc()
mt.MallocTrimmer()
assert cdll.call_count == 1
def test_declares_c_signatures(monkeypatch):
libc = FakeLibc()
_on_glibc(monkeypatch, libc)
mt._load_libc()
assert libc.malloc_trim.argtypes == [ctypes.c_size_t]
assert libc.mallopt.argtypes == [ctypes.c_int, ctypes.c_int]
# -- cap_arenas ---------------------------------------------------------------
def test_cap_arenas_calls_mallopt(monkeypatch):
libc = FakeLibc()
_on_glibc(monkeypatch, libc)
assert mt.cap_arenas() is True
assert libc.mallopts == [(mt.M_ARENA_MAX, 2)]
assert mt.M_ARENA_MAX == -8 # glibc's malloc.h
def test_cap_arenas_defers_to_the_environment(monkeypatch):
libc = FakeLibc()
_on_glibc(monkeypatch, libc)
monkeypatch.setenv('MALLOC_ARENA_MAX', '4')
assert mt.cap_arenas() is False
assert libc.mallopts == []
def test_cap_arenas_swallows_errors(monkeypatch):
libc = FakeLibc()
libc.mallopt = mock.Mock(side_effect=RuntimeError('boom'))
_on_glibc(monkeypatch, libc)
assert mt.cap_arenas() is False
def test_cap_arenas_matches_the_unit():
"""The in-process default is the value the unit's MALLOC_ARENA_MAX carries."""
unit = (Path(__file__).resolve().parent.parent / 'systemd' / 'ledmatrix.service').read_text()
assert f'Environment=MALLOC_ARENA_MAX={mt.DEFAULT_ARENA_MAX}\n' in unit
# -- MallocTrimmer ------------------------------------------------------------
def test_trim_waits_one_interval_then_rate_limits(monkeypatch):
libc = FakeLibc()
_on_glibc(monkeypatch, libc)
clock = Clock()
trimmer = mt.MallocTrimmer(interval=300, clock=clock)
assert trimmer.available
assert trimmer.maybe_trim() is False # start-up: not yet
clock.t += 299.9
assert trimmer.maybe_trim() is False
clock.t += 0.1
assert trimmer.maybe_trim() is True
assert libc.trims == [0]
clock.t += 100
assert trimmer.maybe_trim() is False # rate-limited
clock.t += 200
assert trimmer.maybe_trim() is True
assert libc.trims == [0, 0]
def test_trim_reports_nothing_released(monkeypatch):
libc = FakeLibc(trim_result=0)
_on_glibc(monkeypatch, libc)
clock = Clock()
trimmer = mt.MallocTrimmer(interval=10, clock=clock)
clock.t += 10
assert trimmer.maybe_trim() is False
assert libc.trims == [0]
def test_trim_failure_disables_trimming(monkeypatch):
libc = FakeLibc(trim_result=RuntimeError('boom'))
_on_glibc(monkeypatch, libc)
clock = Clock()
trimmer = mt.MallocTrimmer(interval=10, clock=clock)
clock.t += 10
assert trimmer.maybe_trim() is False
clock.t += 10
assert trimmer.maybe_trim() is False
assert libc.trims == [0] # not retried
assert not trimmer.available
# -- wiring -------------------------------------------------------------------
def test_run_py_caps_arenas_before_threads():
"""run.py applies the cap before the watchdog or the controller import."""
src = (Path(__file__).resolve().parent.parent / 'run.py').read_text()
cap = src.index('malloc_tuning.cap_arenas()')
assert cap < src.index('display_watchdog.watchdog.begin_startup()')
assert cap < src.index('from src.display_controller import main')
def test_render_loop_trims_between_screens():
src = (Path(__file__).resolve().parent.parent / 'src' / 'display_controller.py').read_text()
loop = src.index('display_watchdog.watchdog.loop_pass()')
trim = src.index('trimmer.maybe_trim()')
assert loop < trim < src.index('outcome = runner.run(plan, manager_to_display)')
@pytest.mark.skipif(not mt.sys.platform.startswith('linux'), reason='glibc only')
def test_real_libc_on_linux():
"""On a real Linux C library the calls go through without raising."""
if mt._load_libc() is None:
pytest.skip('not glibc')
trimmer = mt.MallocTrimmer(interval=0)
assert trimmer.available
assert trimmer.maybe_trim() in (True, False)
assert trimmer.available # did not fail and disable itself
+164
View File
@@ -0,0 +1,164 @@
"""Two on-demand edges seen on a rig.
* A request naming a ``*_live`` mode got HTTP 200 and a different mode on
the panel. The session's mode list kept live modes only when the plugin's
has_live_content() said so, and that is the live-priority question,
which the sports plugins answer for favourite teams only: fifteen college
games on, no favourite playing, and ``ncaa_fb_live`` became
``nfl_recent``.
* A restart during a session whose plugin then failed to load (its config
no longer validated) logged "No valid display modes found ... after
restoration" and left the session active with no modes: published as
active for a plugin that was not running, with its cached request kept
for the next restart.
"""
from unittest.mock import MagicMock
import pytest
SPORTS_MODES = ['nfl_live', 'nfl_recent', 'nfl_upcoming',
'ncaa_fb_live', 'ncaa_fb_recent', 'ncaa_fb_upcoming']
def _sports_plugin(has_live_content=False):
plugin = MagicMock(spec=['display', 'has_live_content', 'has_live_priority',
'get_live_modes'])
plugin.has_live_content.return_value = has_live_content
plugin.has_live_priority.return_value = True
plugin.get_live_modes.return_value = []
return plugin
def _register(controller, plugin_id, modes, plugin):
controller.plugin_display_modes[plugin_id] = list(modes)
for mode in modes:
controller.plugin_modes[mode] = plugin
controller.mode_to_plugin_id[mode] = plugin_id
if mode not in controller.available_modes:
controller.available_modes.append(mode)
@pytest.fixture
def football(test_display_controller):
c = test_display_controller
_register(c, 'football-scoreboard', SPORTS_MODES, _sports_plugin())
return c
class TestANamedLiveModeIsShown:
def test_it_is_the_first_screen(self, football):
football._activate_on_demand({'plugin_id': 'football-scoreboard',
'mode': 'ncaa_fb_live'})
assert football.on_demand_active
assert football.current_display_mode == 'ncaa_fb_live'
assert football.on_demand_mode == 'ncaa_fb_live'
def test_the_plugins_other_modes_follow_it(self, football):
football._activate_on_demand({'plugin_id': 'football-scoreboard',
'mode': 'ncaa_fb_live'})
assert football.on_demand_modes[0] == 'ncaa_fb_live'
assert set(football.on_demand_modes[1:]) == {
'nfl_recent', 'nfl_upcoming', 'ncaa_fb_recent', 'ncaa_fb_upcoming'}
def test_pinned_holds_it(self, football):
football._activate_on_demand({'plugin_id': 'football-scoreboard',
'mode': 'ncaa_fb_live', 'pinned': True})
assert football.on_demand_modes == ['ncaa_fb_live']
def test_a_bare_plugin_request_still_skips_quiet_live_modes(self, football):
"""Only a mode asked for by name is kept: a plugin-only request
resolves to the plugin's first mode (nfl_live), and opening on an
empty live screen there is what the ordering exists to avoid."""
football._activate_on_demand({'plugin_id': 'football-scoreboard'})
assert not any(m.endswith('_live') for m in football.on_demand_modes)
def test_a_named_second_live_mode_with_content_leads(self, test_display_controller):
"""With live content both live modes are kept, nfl_live first; a
request naming ncaa_fb_live must still open on it, not rotate away."""
c = test_display_controller
_register(c, 'football-scoreboard', SPORTS_MODES, _sports_plugin(has_live_content=True))
c._activate_on_demand({'plugin_id': 'football-scoreboard', 'mode': 'ncaa_fb_live'})
assert c.on_demand_modes[0] == 'ncaa_fb_live'
assert c.on_demand_modes.count('ncaa_fb_live') == 1
assert 'nfl_live' in c.on_demand_modes[1:]
def test_the_named_mode_survives_a_restart(self, football):
football._activate_on_demand({'plugin_id': 'football-scoreboard',
'mode': 'ncaa_fb_live'})
# The last on-demand config write, not the last write of any key: the
# font-usage publisher thread writes its own key at its own pace.
saved = [c for c in football.cache_manager.set.call_args_list
if c.args and c.args[0] == 'display_on_demand_config'][-1]
config = saved.args[1]
assert config['named_mode'] == 'ncaa_fb_live'
football._reset_on_demand_fields()
football._select_startup_plugins(['football-scoreboard'], config)
football._populate_on_demand_modes_from_plugin()
assert football.on_demand_modes[football.on_demand_mode_index] == 'ncaa_fb_live'
class TestARestoreWithNothingToResume:
@pytest.fixture
def restored(self, test_display_controller):
c = test_display_controller
c.config['clock-simple'] = {'enabled': True}
c._select_startup_plugins(['clock-simple'],
{'plugin_id': 'clock-simple', 'mode': 'clock-simple'})
assert c.on_demand_active
# The plugin's load then fails: nothing is registered for it.
c.cache_manager.clear_cache.reset_mock()
c._populate_on_demand_modes_from_plugin()
return c
def test_the_session_ends(self, restored):
assert not restored.on_demand_active
assert restored.on_demand_plugin_id is None
assert not restored.on_demand_schedule_override
def test_it_is_reported_as_an_error(self, restored):
assert restored.on_demand_status == 'error'
assert restored.on_demand_last_error == 'restore-failed'
# The last on-demand state write, not the last write of any key: the
# font-usage publisher thread writes its own key at its own pace.
published = [c for c in restored.cache_manager.set.call_args_list
if c.args and c.args[0] == 'display_on_demand_state'][-1]
assert published.args[1]['status'] == 'error'
assert published.args[1]['error'] == 'restore-failed'
def test_the_cached_request_is_dropped(self, restored):
restored.cache_manager.clear_cache.assert_any_call('display_on_demand_config')
def test_a_plugin_system_failure_ends_a_cached_session_not_yet_restored(
mock_config_manager, mock_display_manager, mock_cache_manager,
test_config_with_plugins, emulator_mode):
"""Initialization can fail before the cached session is read, with
on_demand_active still False: the session must still end, visibly."""
from unittest.mock import patch
from src.display_controller import DisplayController
mock_config_manager.get_config.return_value = test_config_with_plugins
mock_config_manager.load_config.return_value = test_config_with_plugins
mock_cache_manager._memory_cache['display_on_demand_config'] = {
'plugin_id': 'clock-simple', 'mode': 'clock-simple'}
with patch('src.display_controller.ConfigManager', return_value=mock_config_manager), \
patch('src.display_controller.DisplayManager', return_value=mock_display_manager), \
patch('src.display_controller.CacheManager', return_value=mock_cache_manager), \
patch('src.display_controller.FontManager'), \
patch('src.plugin_system.PluginManager', side_effect=RuntimeError("boom")):
controller = DisplayController()
try:
assert controller.plugin_manager is None
assert not controller.on_demand_active
assert controller.on_demand_status == 'error'
assert controller.on_demand_last_error == 'restore-failed'
mock_cache_manager.clear_cache.assert_any_call('display_on_demand_config')
finally:
try:
controller.cleanup()
except Exception:
pass
@@ -69,6 +69,7 @@ def test_fixed_plugin_loads_new_code_after_failed_load(plugin_env, first_source)
assert MODULE_NAME not in sys.modules
assert PLUGIN_ID not in pm.plugin_loader._loaded_modules
pm.font_manager.forget_manager_fonts.assert_called_with(PLUGIN_ID)
pm.font_manager.forget_plugin_fonts.assert_called_with(PLUGIN_ID)
(plugin_dir / "manager.py").write_text(_FIXED, encoding="utf-8")
assert pm.load_plugin(PLUGIN_ID) is True
+418
View File
@@ -0,0 +1,418 @@
"""Plugins asking for the screen in-process: BasePlugin.request_on_demand()
and end_on_demand().
A plugin running in the display process used to write the
``display_on_demand_request`` mailbox, which the display reads once a second
while the control socket is up. These tests pin the way in that replaces it:
* BasePlugin -> PluginManager -> DisplayController.submit_plugin_on_demand,
which only queues, from any thread;
* the render thread applies the queue where it applies socket commands,
through the mailbox's own handler, without the mailbox's read floor, and
woken by the control socket when it is up;
* a plugin's stop ends only its own session;
* no display to ask (the web interface's plugin manager, an old core's
plugin manager) answers None, which is a plugin's cue to fall back to the
mailbox;
* the mailbox still works for plugins that write it.
"""
import logging
import threading
import time
from unittest.mock import MagicMock
import pytest
from src.ipc.server import ControlServer
from src.plugin_system.base_plugin import BasePlugin
from src.plugin_system.plugin_manager import PluginManager
class _Plugin(BasePlugin):
def update(self):
pass
def display(self, force_clear=False):
pass
def _plugin(plugin_id, manager):
plugin = _Plugin.__new__(_Plugin)
plugin.plugin_id = plugin_id
plugin.plugin_manager = manager
return plugin
def _manager(handler=None):
manager = PluginManager.__new__(PluginManager)
manager.logger = logging.getLogger('test.plugin_on_demand')
if handler is not None:
manager.set_on_demand_handler(handler)
return manager
class _WakeServer:
"""The parts of ControlServer the controller uses, with no socket."""
def __init__(self):
self.woken = 0
self.has_pending = False
def wake(self):
self.woken += 1
self.has_pending = True
def drain(self):
self.has_pending = False
return []
@pytest.fixture
def controller(test_display_controller):
c_ = test_display_controller
c_.on_demand_active = False
c_.on_demand_request_id = None
c_._last_on_demand_poll = None
mailbox = {'value': None}
def fake_get(key, *a, **kw):
if key == 'display_on_demand_request':
return mailbox['value']
return None
c_.cache_manager.get = MagicMock(side_effect=fake_get)
c_.cache_manager.set = MagicMock()
c_.cache_manager.delete = MagicMock()
c_._activate_on_demand = MagicMock()
c_.mailbox = mailbox
return c_
@pytest.fixture
def wired(controller):
"""A real PluginManager wired to the controller, as __init__ wires it."""
manager = _manager(controller.submit_plugin_on_demand)
return controller, manager
class TestWiring:
def test_the_controller_wires_its_plugin_manager(self, controller):
controller.plugin_manager.set_on_demand_handler.assert_called_once_with(
controller.submit_plugin_on_demand)
def test_a_start_reaches_the_mailbox_handler(self, wired):
controller, manager = wired
rid = _plugin('pomodoro-timer', manager).request_on_demand(
mode='pomodoro', duration=30, pinned=True)
assert isinstance(rid, str) and rid
controller._activate_on_demand.assert_not_called() # only queued
controller._poll_on_demand_requests()
controller._activate_on_demand.assert_called_once()
request = controller._activate_on_demand.call_args.args[0]
assert request['request_id'] == rid
assert request['action'] == 'start'
assert request['plugin_id'] == 'pomodoro-timer'
assert request['mode'] == 'pomodoro'
assert request['duration'] == 30.0 and request['pinned'] is True
assert request['source'] == 'plugin'
assert controller.on_demand_request_id == rid
def test_a_plugin_request_never_touches_the_mailbox(self, wired):
controller, manager = wired
_plugin('on-air', manager).request_on_demand(mode='on_air')
controller._drain_control_commands()
controller._activate_on_demand.assert_called_once()
mailbox_reads = [call for call in controller.cache_manager.get.call_args_list
if call.args[0] == 'display_on_demand_request']
assert mailbox_reads == []
controller.cache_manager.delete.assert_not_called()
def test_requests_apply_in_order(self, wired):
controller, manager = wired
seen = []
controller._activate_on_demand = MagicMock(
side_effect=lambda r: seen.append(r['mode']))
plugin = _plugin('p', manager)
for mode in ('a', 'b', 'c'):
plugin.request_on_demand(mode=mode)
controller._poll_on_demand_requests()
assert seen == ['a', 'b', 'c']
def test_the_mailbox_still_works_for_older_plugins(self, wired):
controller, manager = wired
controller.mailbox['value'] = {'request_id': 'mb', 'action': 'start',
'plugin_id': 'birdnet-go'}
_plugin('on-air', manager).request_on_demand()
controller._poll_on_demand_requests()
ids = [call.args[0]['request_id'] for call in controller._activate_on_demand.call_args_list]
assert 'mb' in ids and len(ids) == 2
def test_a_failing_request_is_contained(self, wired):
controller, manager = wired
calls = []
def activate(request):
calls.append(request['mode'])
if request['mode'] == 'bad':
raise RuntimeError('plugin exploded')
controller._activate_on_demand = MagicMock(side_effect=activate)
plugin = _plugin('p', manager)
plugin.request_on_demand(mode='bad')
plugin.request_on_demand(mode='good')
controller._poll_on_demand_requests()
assert calls == ['bad', 'good']
class TestPromptness:
def test_a_plugin_request_skips_the_pending_changes_floor(self, wired):
controller, manager = wired
controller._control_server = None
controller._service_pending_changes()
_plugin('p', manager).request_on_demand()
controller._service_pending_changes() # well inside the 0.25 s floor
controller._activate_on_demand.assert_called_once()
def test_a_plugin_request_skips_the_mailbox_floor(self, wired):
controller, manager = wired
controller._control_server = _WakeServer()
controller._poll_on_demand_requests() # sets the 1 s mailbox floor
_plugin('p', manager).request_on_demand()
controller._poll_on_demand_requests()
controller._activate_on_demand.assert_called_once()
def test_it_wakes_the_control_socket_wait(self, wired):
controller, manager = wired
server = ControlServer('/nonexistent/control.sock') # never started
controller._control_server = server
assert not server.wait_for_command(0)
_plugin('p', manager).request_on_demand()
assert server.has_pending
assert controller._wait_for_control(5.0) is True # returns at once
assert controller._control_command_pending()
controller._poll_on_demand_requests()
controller._activate_on_demand.assert_called_once()
assert not server.has_pending
assert not controller._control_command_pending()
def test_without_a_socket_a_waiting_request_cuts_the_sleep(self, wired):
controller, manager = wired
controller._control_server = None
_plugin('p', manager).request_on_demand()
started = time.monotonic()
assert controller._wait_for_control(5.0) is True
assert time.monotonic() - started < 1.0
assert controller._control_command_pending()
def test_nothing_waiting_keeps_the_floor(self, controller):
controller._control_server = None
controller._poll_on_demand_requests = MagicMock()
controller._service_pending_changes()
controller._service_pending_changes()
assert controller._poll_on_demand_requests.call_count == 1
class TestThreads:
def test_requests_from_many_threads_all_land_in_order_per_thread(self, wired):
controller, manager = wired
seen = []
controller._activate_on_demand = MagicMock(
side_effect=lambda r: seen.append(r['mode']))
controller.PLUGIN_ON_DEMAND_QUEUE_SIZE = 10_000
threads_n, each = 8, 50
barrier = threading.Barrier(threads_n)
def ask(n):
plugin = _plugin(f'p{n}', manager)
barrier.wait()
for i in range(each):
assert plugin.request_on_demand(mode=f'{n}:{i}')
threads = [threading.Thread(target=ask, args=(n,)) for n in range(threads_n)]
for t in threads:
t.start()
# Drain while they ask, as the render thread would.
while any(t.is_alive() for t in threads):
controller._drain_control_commands()
for t in threads:
t.join()
controller._drain_control_commands()
assert len(seen) == threads_n * each
for n in range(threads_n):
mine = [int(m.split(':')[1]) for m in seen if m.startswith(f'{n}:')]
assert mine == list(range(each))
def test_a_full_queue_refuses(self, wired, caplog):
controller, manager = wired
controller.PLUGIN_ON_DEMAND_QUEUE_SIZE = 2
plugin = _plugin('p', manager)
assert plugin.request_on_demand()
assert plugin.request_on_demand()
assert plugin.request_on_demand() is None
assert 'queue full' in caplog.text
controller._poll_on_demand_requests()
assert controller._activate_on_demand.call_count == 2
assert plugin.request_on_demand() # room again
class TestStop:
def test_a_plugin_ends_its_own_session(self, wired):
controller, manager = wired
controller.on_demand_active = True
controller.on_demand_plugin_id = 'on-air'
controller._clear_on_demand = MagicMock()
assert _plugin('on-air', manager).end_on_demand()
controller._poll_on_demand_requests()
controller._clear_on_demand.assert_called_once_with(reason='requested-stop')
controller.cache_manager.delete.assert_not_called()
def test_a_plugin_cannot_end_another_plugins_session(self, wired):
controller, manager = wired
controller.on_demand_active = True
controller.on_demand_plugin_id = 'clock' # the user started it
controller.on_demand_request_id = 'user'
controller._clear_on_demand = MagicMock()
_plugin('pomodoro-timer', manager).end_on_demand()
controller._poll_on_demand_requests()
controller._clear_on_demand.assert_not_called()
assert controller.on_demand_request_id == 'user'
def test_a_stop_with_no_session_does_nothing(self, wired):
controller, manager = wired
controller.on_demand_status = 'error'
controller._clear_on_demand = MagicMock()
_plugin('on-air', manager).end_on_demand()
controller._poll_on_demand_requests()
controller._clear_on_demand.assert_not_called()
def test_a_mailbox_stop_still_ends_any_session(self, wired):
controller, _ = wired
controller.on_demand_active = True
controller.on_demand_plugin_id = 'clock'
controller._clear_on_demand = MagicMock()
controller.mailbox['value'] = {'request_id': 's', 'action': 'stop',
'plugin_id': 'on-air'}
controller._poll_on_demand_requests()
controller._clear_on_demand.assert_called_once_with(reason='requested-stop')
def test_start_then_stop_from_one_thread_ends_the_session(self, wired):
controller, manager = wired
def activate(request):
controller.on_demand_active = True
controller.on_demand_plugin_id = request['plugin_id']
controller._activate_on_demand = MagicMock(side_effect=activate)
controller._clear_on_demand = MagicMock()
plugin = _plugin('pomodoro-timer', manager)
plugin.request_on_demand(mode='pomodoro', pinned=True)
plugin.end_on_demand()
controller._poll_on_demand_requests()
controller._activate_on_demand.assert_called_once()
controller._clear_on_demand.assert_called_once_with(reason='requested-stop')
class TestNoDisplay:
"""None is a plugin's cue to write the mailbox instead."""
def test_a_manager_with_no_handler_answers_none(self):
plugin = _plugin('p', _manager())
assert plugin.request_on_demand() is None
assert plugin.end_on_demand() is None
def test_no_plugin_manager_answers_none(self):
plugin = _plugin('p', None)
assert plugin.request_on_demand() is None
assert plugin.end_on_demand() is None
def test_an_old_cores_plugin_manager_answers_none(self):
class OldManager:
plugin_manifests = {}
plugin = _plugin('p', OldManager())
assert plugin.request_on_demand() is None
assert plugin.end_on_demand() is None
def test_a_handler_that_raises_answers_none(self):
def broken(request):
raise RuntimeError('boom')
plugin = _plugin('p', _manager(broken))
assert plugin.request_on_demand() is None
assert plugin.end_on_demand() is None
def test_a_handler_that_refuses_answers_none(self):
plugin = _plugin('p', _manager(lambda request: False))
assert plugin.request_on_demand() is None
def test_a_controller_built_without_init_refuses(self):
from src.display_controller import DisplayController
bare = DisplayController.__new__(DisplayController)
assert bare.submit_plugin_on_demand({'action': 'start'}) is False
assert bare._plugin_on_demand_pending() is False
bare._drain_plugin_on_demand() # nothing to do, no error
def test_the_feature_detection_pattern(self):
"""The hasattr pattern from docs/PLUGIN_API_REFERENCE.md."""
writes = []
class OldCorePlugin: # an older core's BasePlugin has no such method
pass
for plugin, expect_mailbox in ((OldCorePlugin(), True),
(_plugin('p', _manager()), True),
(_plugin('p', _manager(lambda r: True)), False)):
writes.clear()
if not (hasattr(plugin, 'request_on_demand')
and plugin.request_on_demand(mode='m')):
writes.append('mailbox')
assert (writes == ['mailbox']) is expect_mailbox
class TestArguments:
def test_the_manager_shapes_the_request(self):
got = []
plugin = _plugin('p', _manager(lambda r: got.append(r) or True))
plugin.request_on_demand()
plugin.end_on_demand()
start, stop = got
assert start['plugin_id'] == 'p' and start['mode'] is None
assert start['duration'] is None and start['pinned'] is False
assert start['source'] == 'plugin' and start['timestamp'] > 0
assert stop == {'action': 'stop', 'plugin_id': 'p', 'request_id': stop['request_id'],
'timestamp': stop['timestamp'], 'source': 'plugin'}
assert start['request_id'] != stop['request_id']
@pytest.mark.parametrize('duration', [0, -5, float('inf'), float('nan')])
def test_no_positive_duration_means_no_limit(self, duration):
got = []
_plugin('p', _manager(lambda r: got.append(r) or True)).request_on_demand(
duration=duration)
assert got[0]['duration'] is None
@pytest.mark.parametrize('kwargs', [{'mode': 5}, {'mode': ''}, {'duration': '30'},
{'duration': True}])
def test_bad_arguments_raise(self, kwargs):
plugin = _plugin('p', _manager(lambda r: True))
with pytest.raises(ValueError):
plugin.request_on_demand(**kwargs)
class TestMockManagers:
def test_a_magicmock_manager_reads_as_not_taken(self):
"""A plugin's test with a MagicMock manager keeps its mailbox path."""
plugin = _plugin('p', MagicMock())
assert plugin.request_on_demand(mode='m') is None
assert plugin.end_on_demand() is None
plugin.plugin_manager.request_on_demand.assert_called_once_with(
'p', mode='m', duration=None, pinned=False)
plugin.plugin_manager.end_on_demand.assert_called_once_with('p')
def test_a_mocked_id_is_passed_through(self):
manager = MagicMock()
manager.request_on_demand.return_value = 'rid'
manager.end_on_demand.return_value = 'rid2'
plugin = _plugin('p', manager)
assert plugin.request_on_demand() == 'rid'
assert plugin.end_on_demand() == 'rid2'
+1 -1
View File
@@ -423,7 +423,7 @@ def web_listing(api_v3_module, api_v3_client, shared_cache, tmp_path): # noqa:
{"id": "clock", "name": "Clock", "version": "1.1.0"},
{"id": "weather", "name": "Weather", "version": "3.0.0"},
])
api.plugin_store_manager.get_registry_info = MagicMock(return_value=None)
api.plugin_store_manager.get_cached_registry_info = MagicMock(return_value=None)
api.plugin_store_manager._get_local_git_info = MagicMock(return_value=None)
api.config_manager.load_config = MagicMock(return_value={
"clock": {"enabled": True}, "weather": {"enabled": True}})
+28
View File
@@ -133,6 +133,32 @@ def scenario_on_demand_restored(h: RunLoopHarness):
h.restore_on_demand("sports", mode="sports_upcoming", duration=40)
def scenario_on_demand_named_live(h: RunLoopHarness):
# Games are on until t=70, but none involves a favourite, so
# has_live_content() (the live-priority answer) stays False throughout.
# A request naming sports_live still opens on it (it opened on
# sports_recent); asked for again after the games end, it has nothing to
# draw and the session moves on to the plugin's next mode.
h.add_plugin(FakePlugin("clock", ["clock"], duration=20))
h.add_plugin(FakePlugin(
"sports", ["sports_live", "sports_recent", "sports_upcoming"], duration=15,
live_priority=True,
content=lambda t, mode: mode != "sports_live" or t < 70))
h.on_demand_request(5, "n1", plugin_id="sports", mode="sports_live")
h.on_demand_request(40, "n2", action="stop")
h.on_demand_request(100, "n3", plugin_id="sports", mode="sports_live")
h.on_demand_request(140, "n4", action="stop")
def scenario_on_demand_restore_failed(h: RunLoopHarness):
# A restart during a session whose plugin then fails to load: the
# session ends as an error before the first screen, and the rotation
# runs normally from the top.
h.add_plugin(FakePlugin("clock", ["clock"], duration=20))
h.add_plugin(FakePlugin("weather", ["weather"], duration=20))
h.restore_on_demand("gone", mode="gone", duration=40)
def scenario_schedule(h: RunLoopHarness):
# The clock starts at 22:59:30. Off from 23:01 until 23:05 (the window
# spans midnight); dimmed from 23:00 until 23:01.
@@ -194,6 +220,8 @@ SCENARIOS = {
"on_demand": (scenario_on_demand, 240),
"on_demand_pinned": (scenario_on_demand_pinned, 160),
"on_demand_restored": (scenario_on_demand_restored, 100),
"on_demand_named_live": (scenario_on_demand_named_live, 160),
"on_demand_restore_failed": (scenario_on_demand_restore_failed, 60),
"schedule": (scenario_schedule, 400),
"wifi_notice": (scenario_wifi_notice, 150),
"follower": (scenario_follower, 80),
+455
View File
@@ -0,0 +1,455 @@
"""ScreenRunner (src/screen_runner.py) on a scripted host and a fake clock.
The golden traces (test_run_loop_golden.py) run it inside the real
DisplayController; these pin down its own contract: which ExitReason each
way of ending gives, the order it calls its host in, how the 125 Hz loop
paces, and which service point asks what.
"""
from typing import Any, List, Optional, Tuple
import pytest
from src.display_arbiter import (
RELOAD_PLAN, SCREEN_PREEMPTERS, FramePolicy, ScreenPlan, Source,
)
from src.screen_runner import (
AFTER_COMPLETED_LOOP, AFTER_LOOP, DYNAMIC_GRACE, FINAL, FRAME, HIGH_FPS_INTERVAL,
Checkpoint, ExitReason, FirstFrame, NoticeRead, Screen, ScreenRunner,
)
WIFI_PLAN = ScreenPlan(Source.WIFI)
class Clock:
"""time/perf_counter read ``now``; sleep advances it."""
def __init__(self):
self.now = 0.0
self.sleeps: List[float] = []
def time(self) -> float:
return self.now
def perf_counter(self) -> float:
return self.now
def sleep(self, seconds: float) -> None:
self.sleeps.append(round(seconds, 6))
self.now += seconds
class Host:
"""A ScreenHost whose answers are scripted; records every call."""
def __init__(self, clock: Clock, *, shown=True, raised=False, minimum=10.0,
maximum=10.0, dynamic=False, policy=FramePolicy.STATIC,
completed=True, draws=None, checks=None, work=0.0,
cycle_complete_at=None, dwell_to=None):
self.clock = clock
self.calls: List[Tuple[Any, ...]] = []
self.shown, self.raised = shown, raised
self.minimum, self.maximum = minimum, maximum
self.dynamic, self.policy, self.completed = dynamic, policy, completed
#: display() results for the frames after the first, in order.
self.draws = list(draws or [])
#: (checkpoint name, frame number) -> plan, or a callable(screen, cp).
self.checks = checks or {}
self.work = work
self.cycle_complete_at = cycle_complete_at
self.dwell_to = dwell_to
self.frames = 0
def first_frame(self, plan, plugin):
self.calls.append(("first", plan.mode))
return FirstFrame(self.shown, self.raised, False)
def complete_plan(self, plan, plugin):
self.calls.append(("complete",))
if not self.completed:
return None
return ScreenPlan(plan.source, mode=plan.mode, min_duration=self.minimum,
max_duration=self.maximum, dynamic=self.dynamic,
frame_policy=self.policy, preemptible_by=plan.preemptible_by)
def draw(self, screen):
self.frames += 1
self.calls.append(("draw", self.frames))
self.clock.now += self.work
return self.draws.pop(0) if self.draws else True
def after_frame(self, screen):
self.calls.append(("after_frame",))
def tick(self):
self.calls.append(("tick",))
def service(self, screen):
self.calls.append(("service",))
return ("scan", self.frames)
def wait_frame(self, interval, screen):
self.calls.append(("wait", interval))
self.clock.sleep(interval)
return self._scripted("wait", screen)
def check(self, screen, checkpoint, live_scan=None):
self.calls.append(("check", checkpoint.name, live_scan))
return self._scripted(checkpoint.name, screen, checkpoint)
def _scripted(self, name, screen, checkpoint=None):
answer = self.checks.get((name, self.frames))
if callable(answer):
return answer(screen, checkpoint)
return answer
def dwell(self, seconds):
self.calls.append(("dwell", round(seconds, 6)))
self.clock.now = self.dwell_to if self.dwell_to is not None else self.clock.now + seconds
def cycle_complete(self, screen):
self.calls.append(("cycle?",))
return self.cycle_complete_at is not None and self.clock.now >= self.cycle_complete_at
PLAN = ScreenPlan(Source.ROTATION, mode="clock", preemptible_by=SCREEN_PREEMPTERS)
def _run(**kwargs) -> Tuple[Any, Host, Clock]:
clock = Clock()
host = Host(clock, **kwargs)
outcome = ScreenRunner(clock, host).run(PLAN, plugin=object())
return outcome, host, clock
def _names(host, *kinds):
return [c for c in host.calls if c[0] in kinds]
class TestFirstFrame:
def test_no_plugin_is_empty_without_a_dispatch(self):
clock = Clock()
host = Host(clock)
outcome = ScreenRunner(clock, host).run(PLAN, plugin=None)
assert outcome.exit_reason is ExitReason.EMPTY
assert host.calls == []
@pytest.mark.parametrize("raised,reason", [(False, ExitReason.EMPTY),
(True, ExitReason.ERROR)])
def test_nothing_shown(self, raised, reason):
outcome, host, _ = _run(shown=False, raised=raised)
assert outcome.exit_reason is reason
assert host.calls == [("first", "clock")]
def test_an_on_demand_session_with_no_time_left(self):
outcome, host, _ = _run(completed=False)
assert outcome.exit_reason is ExitReason.PREEMPTED
assert outcome.preempted_by is None
assert host.calls == [("first", "clock"), ("complete",)]
class TestStaticLoop:
def test_runs_its_duration_one_frame_a_second(self):
outcome, host, clock = _run(maximum=5.0)
assert outcome.exit_reason is ExitReason.DURATION
assert outcome.elapsed == 5.0
# Frames at 1..4 s; the wait that reaches 5 s ends it before a fifth.
assert host.frames == 4
assert clock.sleeps == [1.0] * 5
# Each frame: wait, tick, draw, follower frame, service, the check.
assert host.calls[2:9] == [("wait", 1.0), ("tick",), ("draw", 1), ("after_frame",),
("service",), ("check", "frame", ("scan", 1)), ("wait", 1.0)]
# A completed loop: the after-loop look reads no notice, then FINAL.
assert host.calls[-2:] == [("check", "after-completed-loop", None), ("check", "final", None)]
def test_preempted_between_frames(self):
by = ScreenPlan(Source.ON_DEMAND, mode="x")
outcome, host, _ = _run(maximum=30.0, checks={("frame", 3): by})
assert outcome.exit_reason is ExitReason.PREEMPTED
assert outcome.preempted_by is by
assert outcome.elapsed == 3.0
# Decided at the service point: no second look, no dwell.
assert host.calls[-1] == ("check", "frame", ("scan", 3))
def test_preempted_by_a_socket_command_in_the_frame_wait(self):
outcome, host, _ = _run(maximum=30.0, checks={("wait", 2): WIFI_PLAN})
assert outcome.exit_reason is ExitReason.PREEMPTED
assert host.calls[-1] == ("wait", 1.0)
def test_display_false_makes_up_the_minimum(self):
outcome, host, _ = _run(maximum=12.0, draws=[True, False])
assert outcome.exit_reason is ExitReason.DISPLAY_FALSE
# The loop ended early: the after-loop look may read a notice, then
# the dwell makes up the rest of the 12 s.
assert _names(host, "check", "dwell")[-4:] == [
("check", "after-loop", None), ("dwell", 10.0),
("check", "after-dwell", None), ("check", "final", None)]
assert outcome.elapsed == 12.0
def test_a_notice_that_cuts_the_dwell_short_ends_the_screen(self):
def notice(screen, checkpoint):
assert checkpoint.notice is NoticeRead.ALWAYS
return WIFI_PLAN if checkpoint.notice_counts else None
outcome, _, _ = _run(maximum=12.0, draws=[False], dwell_to=6.0,
checks={("after-dwell", 1): notice})
assert outcome.exit_reason is ExitReason.PREEMPTED
def test_a_notice_after_a_full_dwell_does_not(self):
def notice(screen, checkpoint):
return WIFI_PLAN if checkpoint.notice_counts else None
outcome, _, _ = _run(maximum=12.0, draws=[False],
checks={("after-dwell", 1): notice})
assert outcome.exit_reason is ExitReason.DISPLAY_FALSE
def test_a_reload_ends_the_loop_but_the_screen_counts(self):
outcome, host, _ = _run(maximum=30.0, checks={("frame", 2): RELOAD_PLAN})
assert outcome.exit_reason is ExitReason.RELOAD
# Not decided at the service point: the after-loop look, the dwell
# (which returns at once while a reload waits) and FINAL follow.
assert ("check", "after-loop", None) in host.calls
assert ("check", "final", None) in host.calls
def test_a_change_after_the_loop_preempts(self):
by = ScreenPlan(Source.ROTATION, mode="weather")
outcome, _, _ = _run(maximum=3.0, checks={("final", 2): by})
assert outcome.exit_reason is ExitReason.PREEMPTED
assert outcome.preempted_by is by
def test_a_dynamic_screen_keeps_going_on_false(self):
outcome, host, _ = _run(minimum=3.0, maximum=8.0, dynamic=True,
draws=[False, False, False])
assert outcome.exit_reason is ExitReason.DURATION
assert host.frames == 7
class TestHighFpsLoop:
def test_paces_to_the_deadline(self):
outcome, host, clock = _run(maximum=0.05, policy=FramePolicy.HIGH_FPS, work=0.003)
assert outcome.exit_reason is ExitReason.DURATION
# 3 ms of drawing leaves 5 ms of an 8 ms frame to sleep.
assert set(clock.sleeps) == {round(HIGH_FPS_INTERVAL - 0.003, 6)}
def test_an_overrun_frame_still_yields(self):
_, _, clock = _run(maximum=0.05, policy=FramePolicy.HIGH_FPS, work=0.02)
assert set(clock.sleeps) == {0.001}
def test_service_before_the_sleep_check_after(self):
_, host, clock = _run(maximum=0.016, policy=FramePolicy.HIGH_FPS)
first = host.calls[2:7]
assert first == [("draw", 1), ("after_frame",), ("tick",), ("service",),
("check", "frame", ("scan", 1))]
assert clock.sleeps[0] == HIGH_FPS_INTERVAL
def test_preempted_after_the_frames_sleep(self):
outcome, _, clock = _run(maximum=30.0, policy=FramePolicy.HIGH_FPS,
checks={("frame", 3): WIFI_PLAN})
assert outcome.exit_reason is ExitReason.PREEMPTED
assert clock.now == pytest.approx(3 * HIGH_FPS_INTERVAL)
def test_display_false_has_no_make_up_dwell(self):
outcome, host, _ = _run(maximum=30.0, policy=FramePolicy.HIGH_FPS,
draws=[True, False])
assert outcome.exit_reason is ExitReason.DISPLAY_FALSE
assert not _names(host, "dwell")
class TestDynamicDuration:
def test_cycle_complete_after_the_minimum_and_grace(self):
outcome, host, _ = _run(minimum=3.0, maximum=20.0, dynamic=True,
cycle_complete_at=1.0)
assert outcome.exit_reason is ExitReason.CYCLE_COMPLETE
# Not asked before minimum + grace (3.5 s): the 1 Hz loop's first
# frame at or past it is the one at 4 s.
assert outcome.elapsed == 4.0
assert DYNAMIC_GRACE == 0.5
def test_capped_at_the_maximum(self):
outcome, host, _ = _run(minimum=3.0, maximum=6.0, dynamic=True)
assert outcome.exit_reason is ExitReason.DURATION
# The end-of-screen log asks the plugin once more, before FINAL.
assert host.calls[-3:] == [("check", "after-completed-loop", None), ("cycle?",),
("check", "final", None)]
def test_high_fps_cycle_complete(self):
outcome, _, _ = _run(minimum=0.02, maximum=1.0, dynamic=True,
policy=FramePolicy.HIGH_FPS, cycle_complete_at=0.0)
assert outcome.exit_reason is ExitReason.CYCLE_COMPLETE
assert outcome.elapsed >= 0.02 + DYNAMIC_GRACE
class TestCheckpoints:
@pytest.mark.parametrize("checkpoint,notice,reload", [
(FRAME, NoticeRead.IF_UNDECIDED, True),
(AFTER_LOOP, NoticeRead.IF_UNDECIDED, False),
(AFTER_COMPLETED_LOOP, NoticeRead.NEVER, False),
(FINAL, NoticeRead.NEVER, False),
])
def test_what_each_service_point_considers(self, checkpoint, notice, reload):
"""A reload counts only between frames; the notice file is read
where the loop read it before stage 3."""
assert isinstance(checkpoint, Checkpoint)
assert (checkpoint.notice, checkpoint.reload) == (notice, reload)
def test_the_screen_carries_the_completed_plan(self):
seen: List[Optional[Screen]] = []
def grab(screen, checkpoint):
seen.append(screen)
_run(maximum=2.0, checks={("frame", 1): grab})
assert seen[0].plan.frame_policy is FramePolicy.STATIC
assert seen[0].mode == "clock"
class TestControllerServicePoint:
"""DisplayController._screen_check: the reads it makes and what it claims,
on a controller built by the run-loop harness."""
@pytest.fixture
def dc(self, tmp_path):
import os
os.environ.setdefault("EMULATOR", "true")
from test._run_loop_harness import FakePlugin, RunLoopHarness
h = RunLoopHarness(tmp_path, horizon=10)
h.add_plugin(FakePlugin("clock", ["clock"], duration=20))
h.add_plugin(FakePlugin("sports", ["sports_live"], duration=20,
live=(0, 100), live_priority=True))
dc = h.controller
dc.current_display_mode = "clock"
dc.current_mode_index = 0
dc.reads = []
def read():
dc.reads.append(1)
return {"message": "AP mode", "expires_at": 1e12}
dc._check_wifi_status_message = read
return dc
@staticmethod
def _screen(mode="clock"):
from src.display_arbiter import ArbiterState, rotation_plan
return Screen(rotation_plan(ArbiterState(current_mode=mode)), plugin=None,
accepts_display_mode=False, start=0.0)
def test_a_pending_notice_is_read_and_ends_the_screen(self, dc):
by = dc._screen_check(self._screen(), FRAME)
assert by.source is Source.WIFI and len(dc.reads) == 1
def test_not_read_once_the_mode_has_moved(self, dc):
dc.current_display_mode = "sports_live"
by = dc._screen_check(self._screen(), FRAME)
assert by.source is Source.ROTATION and dc.reads == []
def test_not_read_while_scheduled_off(self, dc):
dc.is_display_active = False
by = dc._screen_check(self._screen(), FRAME)
assert by.source is Source.SCHEDULED_OFF and dc.reads == []
def test_not_read_during_on_demand(self, dc):
dc.on_demand_active = True
dc.on_demand_schedule_override = True
assert dc._screen_check(self._screen(), FRAME) is None
assert dc.reads == []
def test_a_live_takeover_is_claimed_before_the_notice(self, dc):
by = dc._screen_check(self._screen(), FRAME, live_scan=("sports_live",))
assert by.source is Source.LIVE and dc.reads == []
assert dc.current_display_mode == "sports_live"
assert dc._live_takeover_unshown is True and dc._live_resume_index == 0
def test_after_a_completed_loop_the_notice_is_not_read(self, dc):
assert dc._screen_check(self._screen(), AFTER_COMPLETED_LOOP) is None
assert dc.reads == []
def test_after_a_full_dwell_it_is_read_but_does_not_count(self, dc):
from src.screen_runner import after_dwell
assert dc._screen_check(self._screen(), after_dwell(False)) is None
assert len(dc.reads) == 1
def test_a_reload_counts_only_between_frames(self, dc):
dc._check_wifi_status_message = lambda: None
dc._pending_plugin_reloads = ("pending",)
assert dc._screen_check(self._screen(), FRAME) is RELOAD_PLAN
assert dc._screen_check(self._screen(), AFTER_LOOP) is None
def test_an_on_demand_index_past_a_shortened_list_starts_again(self, dc):
"""_take_plan writes the shown index back, so the session's next
step goes on from the mode actually shown."""
from src.display_arbiter import Arbiter, ArbiterInputs
dc.on_demand_active = True
dc.on_demand_modes = ["clock", "sports_live"]
dc.on_demand_mode_index = 5 # the list shrank under it
inputs = ArbiterInputs(schedule_on=True, on_demand_active=True,
follower_active=False)
plan = dc._take_plan(Arbiter.decide(dc._arbiter_state(), inputs, 0.0))
assert plan.mode == "clock" and dc.on_demand_mode_index == 0
dc._advance_on_demand()
assert dc.current_display_mode == "sports_live"
@pytest.mark.parametrize("policy,report_hold", [(FramePolicy.HIGH_FPS, True),
(FramePolicy.STATIC, False)])
def test_only_the_high_fps_loop_reports_a_held_frame(policy, report_hold):
"""#758's report_hold: the 125 Hz loop times frames held by update();
the 1 Hz loop's frames are a second apart and must not."""
from unittest.mock import MagicMock
from src.display_controller import _ScreenHost
controller = MagicMock()
plan = ScreenPlan(Source.ROTATION, mode="ticker", frame_policy=policy)
plugin = object()
_ScreenHost(controller).draw(Screen(plan, plugin, True, 0.0))
controller._display_once.assert_called_once_with(plugin, "ticker", True,
report_hold=report_hold)
def _rows(tmp_path, horizon, build):
import os
os.environ.setdefault("EMULATOR", "true")
from test._run_loop_harness import RunLoopHarness
h = RunLoopHarness(tmp_path, horizon=horizon)
build(h)
return h.run()["screens"]
class TestThroughRun:
"""Service points that only the full loop reaches, on the harness."""
def test_a_notice_pending_when_a_later_frame_is_empty_ends_the_screen(self, tmp_path):
"""The after-loop look reads the notice when the loop ended early: a
1 Hz screen whose second frame has nothing to show must not sit in
its make-up dwell (which only notices a notice that arrives during
it) while the notice expires."""
from test._run_loop_harness import FakePlugin
def build(h):
h.add_plugin(FakePlugin("flaky", ["flaky"], duration=12, first_frame_only=True))
h.add_plugin(FakePlugin("clock", ["clock"], duration=12))
h.wifi_message(0.5, "Connected to HomeNet", duration=5)
rows = _rows(tmp_path, 30, build)
assert rows[0][1] == "flaky" and rows[0][2] == 1.0
assert rows[1][1] == "<wifi>"
# ... and the mode it cut short comes back, not the next one.
after = next(row for row in rows[1:] if row[1] != "<wifi>")
assert after[1] == "flaky"
def test_vegas_yielding_to_a_follower_shows_a_rotation_screen_first(self, tmp_path):
"""Pins today's behaviour (docs/RUN_LOOP_REDESIGN.md, "may be
wrong"): the interrupt check stops the ticker for a follower, but
the yield path never looks at one, so a rotation screen runs its
full duration before the next pass hands the panel to the leader."""
from test._run_loop_harness import FakePlugin
def build(h):
h.add_plugin(FakePlugin("clock", ["clock"], duration=20))
h.add_plugin(FakePlugin("weather", ["weather"], duration=20))
h.enable_vegas(cycle=30)
h.sync.follower_windows = [(10, 60)]
rows = _rows(tmp_path, 50, build)
assert rows[0][1] == "<vegas>" and rows[0][3] == "vegas-interrupt"
assert 10.0 <= rows[0][0] + rows[0][2] <= 10.2
assert rows[1][1] == "clock" and rows[1][2] == 20.0
assert rows[2][1] == "<follower>"
+183
View File
@@ -0,0 +1,183 @@
"""A frame the preview throttle skipped still reaches the snapshot.
The preview snapshot (/api/v3/display/current, the web UI's live preview) is
only written from update_display(), at most once per write interval. A screen
that draws its card once and then holds it -- soccer's recent/upcoming cards
skip redundant redraws -- pushes exactly one frame. When that push lands inside
the interval, e.g. a few milliseconds after the on-demand start's clear wrote a
black frame, the throttle skips it and nothing ever writes it: on ledpi the
preview stayed black for soccer's whole 15 s screen while the panel showed the
card, and the next screen "rendered immediately".
Runs the real DisplayManager on the emulator, like test_display_dirty_tracking.
"""
import os
import sys
import types
os.environ["EMULATOR"] = "true"
import pytest
from PIL import Image
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
@pytest.fixture(scope="module")
def dm(tmp_path_factory):
from src.display_manager import DisplayManager
DisplayManager._instance = None
manager = DisplayManager({
"display": {
"hardware": {"rows": 32, "cols": 64, "chain_length": 2,
"parallel": 1, "brightness": 90},
"runtime": {"gpio_slowdown": 0},
},
}, suppress_test_pattern=True)
manager._snapshot_path = str(
tmp_path_factory.mktemp("owed_snapshot") / "led_matrix_preview.png")
yield manager
DisplayManager._instance = None
@pytest.fixture
def viewer(dm, monkeypatch, tmp_path):
"""A preview is open (1 s write interval); fresh snapshot bookkeeping."""
monkeypatch.setattr(dm, "_viewer_is_fresh", lambda now: True)
dm._viewer_was_fresh = True
dm._snapshot_path = str(tmp_path / "snap.png")
dm._last_snapshot_ts = 0.0
dm._last_snapshot_touch_ts = 0.0
dm._last_snapshot_digest = None
dm._saved_snapshot_digest = None
dm._snapshot_owed = False
dm.set_scrolling_state(False)
return dm
def _lit(path):
with Image.open(path) as img:
return sum(1 for p in img.convert("RGB").getdata() if max(p) > 20)
def _age_last_write(dm, seconds=2.0):
"""As if `seconds` had passed since the last snapshot write."""
dm._last_snapshot_ts -= seconds
dm._last_snapshot_touch_ts -= seconds
def _clear_then_draw_card(dm):
"""The on-demand start's clear, then the card a few ms later."""
dm.clear()
dm.update_display() # black frame: written
assert _lit(dm._snapshot_path) == 0
dm.draw.rectangle([4, 4, 40, 20], fill=(255, 255, 0))
dm.update_display() # the card: inside the interval
def _controller(dm):
from src import display_controller as dc_module
controller = dc_module.DisplayController.__new__(dc_module.DisplayController)
controller.plugin_manager = None
controller.display_manager = dm
return controller
class _HoldingPlugin:
"""Already showing its card: display() returns True and draws nothing."""
plugin_id = "holding"
def __init__(self):
self.calls = 0
def display(self, display_mode=None, force_clear=False):
self.calls += 1
return True
def test_a_held_card_reaches_the_preview_on_the_next_frame(viewer):
dm = viewer
_clear_then_draw_card(dm)
assert _lit(dm._snapshot_path) == 0 # the throttle skipped the card
controller = _controller(dm)
plugin = _HoldingPlugin()
_age_last_write(dm)
# The render loop's next frame: the plugin draws nothing and makes no
# update_display() call, as soccer's switch cards do.
assert controller._display_once(plugin, "soccer_eng.1_recent", True) is True
assert plugin.calls == 1
assert _lit(dm._snapshot_path) > 0
def test_the_owed_write_still_waits_out_the_interval(viewer, monkeypatch):
dm = viewer
_clear_then_draw_card(dm)
saves = []
monkeypatch.setattr(dm, "_save_snapshot", lambda image: saves.append(image))
dm.write_owed_snapshot() # still inside the interval
assert saves == []
_age_last_write(dm)
dm.write_owed_snapshot()
assert len(saves) == 1
# Written: nothing is owed, so later frames do no work and the unchanged
# frame is not encoded again.
assert dm._snapshot_owed is False
_age_last_write(dm)
dm.write_owed_snapshot()
assert len(saves) == 1
def test_a_failed_owed_write_stays_owed_and_is_retried(viewer, monkeypatch):
dm = viewer
_clear_then_draw_card(dm)
_age_last_write(dm)
attempts = []
def failing_save(image):
attempts.append(image)
raise OSError("disk full")
monkeypatch.setattr(dm, "_save_snapshot", failing_save)
dm.write_owed_snapshot() # the write fails
assert len(attempts) == 1
assert dm._snapshot_owed is True # still owed: a held screen
saves = [] # makes no update_display()
monkeypatch.setattr(dm, "_save_snapshot", lambda image: saves.append(image))
dm.write_owed_snapshot() # retried on the next frame
assert len(saves) == 1
assert dm._snapshot_owed is False
def test_nothing_owed_after_a_frame_that_was_written(viewer, monkeypatch):
dm = viewer
dm.draw.rectangle([0, 0, 8, 8], fill=(0, 255, 0))
dm.update_display() # due: written at once
assert dm._snapshot_owed is False
calls = []
monkeypatch.setattr(dm, "_write_snapshot_if_due",
lambda *a, **k: calls.append(a))
dm.write_owed_snapshot()
assert calls == []
def test_an_unchanged_frame_inside_the_interval_is_not_owed(viewer):
dm = viewer
dm.draw.rectangle([0, 0, 8, 8], fill=(0, 0, 255))
dm.update_display()
dm.update_display() # same frame, inside the interval
assert dm._snapshot_owed is False
def test_a_controller_without_the_hook_still_draws():
"""Controllers built without a display manager (tests) are unaffected."""
from src import display_controller as dc_module
controller = dc_module.DisplayController.__new__(dc_module.DisplayController)
controller.plugin_manager = None
plugin = _HoldingPlugin()
assert controller._display_once(plugin, "x", True) is True
controller.display_manager = types.SimpleNamespace()
assert controller._display_once(plugin, "x", True) is True
assert plugin.calls == 2
+280
View File
@@ -0,0 +1,280 @@
"""src.common.sports_favorites: behaviour, the _favorite_key seam, host contract.
The cases follow ledmatrix-plugins' ``scripts/test_favourite_matching.py``
(the tables the family 6 reconcile was checked against), with the favourites
given as each plugin's resolver hands them over: as typed for the
abbreviation sports, as ESPN team ids for an NRL-style host that overrides
``_favorite_key``.
"""
import ast
import logging
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
from src.common import sports_favorites
from src.common.sports_favorites import (
SportsFavoritesMixin,
SportsRecentFavoritesMixin,
SportsUpcomingFavoritesMixin,
)
from src.common.sports_helpers import SportsHelpersMixin
LOG = logging.getLogger("test_sports_favorites")
def _id_key(self, game, side):
"""NRL's override: the ESPN team id, None when it is missing."""
team_id = game.get(f"{side}_id")
return None if team_id is None else str(team_id)
def host(favorites, by_id=False, limit=3):
"""A manager stand-in: the three mixins over SportsHelpersMixin's default key."""
attrs = {"_favorite_key": _id_key} if by_id else {}
cls = type("Host", (SportsUpcomingFavoritesMixin, SportsRecentFavoritesMixin,
SportsFavoritesMixin, SportsHelpersMixin), attrs)
h = cls()
h.logger = LOG
h.favorite_teams = favorites
h.upcoming_games_to_show = h.recent_games_to_show = limit
return h
TEAM = {"1": "AAA", "2": "BBB", "3": "CCC", "4": "DDD", "41": "NEW", "42": "NEW"}
def match(home, away, **extra):
g = {"home_id": home, "home_abbr": TEAM[home], "away_id": away, "away_abbr": TEAM[away]}
g.update(extra)
return g
GAMES = {
"AAA home v BBB": match("1", "2"),
"BBB home v AAA": match("2", "1"),
"CCC v DDD": match("3", "4"),
"Knights (NEW 41) v CCC": match("41", "3"),
"Warriors (NEW 42) v CCC": match("42", "3"),
"AAA v BBB, no ids": {"home_abbr": "AAA", "away_abbr": "BBB"},
"ids 1 v 2, no abbrs": {"home_id": "1", "away_id": "2"},
"AAA v BBB, int ids": match("1", "2", home_id=1, away_id=2),
"lower-case abbrs": {"home_abbr": "aaa ", "away_abbr": "bbb"},
"empty game": {},
}
#: label -> favorite_teams as the resolver hands it over.
FAVORITES = {
"none": [],
"AAA": ["AAA"],
"aaa": ["aaa"],
"' AAA '": [" AAA "],
"1": ["1"],
"int 1": [1],
"AAA,CCC": ["AAA", "CCC"],
"NEW": ["NEW"],
"41": ["41"],
"'None'": ["None"],
"blank": ["", " "],
}
#: (favourites, game) -> answer with the abbreviation key, then the id key.
EXPECTED_IS_FAVORITE = {
"AAA home v BBB": {"AAA": "Y.", "aaa": "Y.", "' AAA '": "Y.", "1": ".Y", "int 1": ".Y",
"AAA,CCC": "Y."},
"BBB home v AAA": {"AAA": "Y.", "aaa": "Y.", "' AAA '": "Y.", "1": ".Y", "int 1": ".Y",
"AAA,CCC": "Y."},
"CCC v DDD": {"AAA,CCC": "Y."},
"Knights (NEW 41) v CCC": {"AAA,CCC": "Y.", "NEW": "Y.", "41": ".Y"},
"Warriors (NEW 42) v CCC": {"AAA,CCC": "Y.", "NEW": "Y."},
"AAA v BBB, no ids": {"AAA": "Y.", "aaa": "Y.", "' AAA '": "Y.", "AAA,CCC": "Y."},
"ids 1 v 2, no abbrs": {"1": ".Y", "int 1": ".Y"},
"AAA v BBB, int ids": {"AAA": "Y.", "aaa": "Y.", "' AAA '": "Y.", "1": ".Y",
"int 1": ".Y", "AAA,CCC": "Y."},
"lower-case abbrs": {"AAA": "Y.", "aaa": "Y.", "' AAA '": "Y.", "AAA,CCC": "Y."},
"empty game": {},
}
@pytest.mark.parametrize("game_label", sorted(GAMES))
@pytest.mark.parametrize("fav_label", sorted(FAVORITES))
def test_is_favorite_game(fav_label, game_label):
want = EXPECTED_IS_FAVORITE[game_label].get(fav_label, "..")
got = "".join("Y" if host(FAVORITES[fav_label], by_id)._is_favorite_game(dict(GAMES[game_label]))
else "." for by_id in (False, True))
assert got == want
class TestFavoriteCode:
@pytest.mark.parametrize("value, code", [
("bos", "BOS"), (" BOS ", "BOS"), ("BOS", "BOS"), (41, "41"),
("", None), (" ", None), (None, None),
])
def test_normalises(self, value, code):
assert SportsFavoritesMixin._favorite_code(value) == code
def test_a_missing_id_is_not_the_string_none(self):
"""str(None) would match a favourite typed "None"; None matches nothing."""
h = host(["None"], by_id=True)
assert h._is_favorite_game({"home_abbr": "AAA", "away_abbr": "BBB"}) is False
assert h._is_favorite_game({}) is False
def test_a_none_favorites_list_matches_nothing(self):
assert host(None)._is_favorite_game(dict(GAMES["AAA home v BBB"])) is False
# ---------------------------------------------------------------------------
# Selection. A shuffled slate: two games share id s2, two have no id, s9 has
# no start time. Hours from now; Recent gets the same slate in the past.
# ---------------------------------------------------------------------------
NOW = datetime(2026, 10, 5, 15, tzinfo=timezone.utc)
SLATE = (("s5", "41", "2", 5), ("s1", "1", "2", 1), ("s3", "4", "3", 3),
("s2", "3", "1", 2), ("s7", "2", "3", 7), ("s4", "1", "4", 4),
("s6", "42", "4", 6), ("s2", "1", "4", 8), ("s9", "1", "3", None),
(None, "3", "1", 9), (None, "2", "1", 10))
def slate(recent):
sign = -1 if recent else 1
games = []
for gid, home, away, hours in SLATE:
g = match(home, away, id=gid)
if hours is not None:
g["start_time_utc"] = NOW + timedelta(hours=sign * hours)
games.append(g)
return games
def pick(favorites, limit, recent, by_id=False):
h = host(favorites, by_id, limit)
method = h._select_recent_games_for_display if recent else h._select_games_for_display
return ",".join(g["id"] or "~" for g in method(slate(recent), favorites)) or "none"
ALL = "s1,s2,s3,s4,s5,s6,s7,s2,~,~,s9"
#: (favourites, per-team limit) -> picked ids, the same for Upcoming and Recent.
EXPECTED_SELECT = {
(("AAA",), 1): "s1", (("AAA",), 2): "s1,s2", (("AAA",), 5): "s1,s2,s4,~,~",
(("aaa",), 5): "s1,s2,s4,~,~", ((" AAA ",), 2): "s1,s2",
(("AAA", "CCC"), 1): "s1,s2", (("AAA", "CCC"), 2): "s1,s2,s3",
(("AAA", "CCC"), 5): "s1,s2,s3,s4,s7,~,~,s9",
(("AAA", "ZZZ"), 2): "s1,s2", (("NEW",), 2): "s5,s6",
(("ZZZ",), 2): "none", ((), 1): ALL,
}
@pytest.mark.parametrize("recent", [False, True], ids=["upcoming", "recent"])
@pytest.mark.parametrize("favorites, limit", sorted(EXPECTED_SELECT))
def test_select(favorites, limit, recent):
assert pick(list(favorites), limit, recent) == EXPECTED_SELECT[(favorites, limit)]
class TestSelectByTeamId:
"""The NRL-style host: the key is the team id, so NEW is two teams."""
@pytest.mark.parametrize("recent", [False, True])
def test_one_club_of_a_shared_abbreviation(self, recent):
assert pick(["41"], 2, recent, by_id=True) == "s5"
@pytest.mark.parametrize("recent", [False, True])
def test_an_unresolved_abbreviation_matches_nothing(self, recent):
assert pick(["NEW"], 2, recent, by_id=True) == "none"
def test_ids_select_like_abbreviations(self):
assert pick(["1"], 5, False, by_id=True) == "s1,s2,s4,~,~"
class TestSelectionRules:
def test_a_game_between_two_favourites_counts_for_both(self):
h = host(["AAA", "BBB"], limit=1)
picked = h._select_games_for_display(slate(False), ["AAA", "BBB"])
assert [g["id"] for g in picked] == ["s1"]
def test_games_without_an_id_are_never_duplicates(self):
games = [match("1", "2"), match("1", "3")]
assert len(host(["AAA"])._select_games_for_display(games, ["AAA"])) == 2
def test_a_reused_id_is_a_duplicate(self):
games = [match("1", "2", id="x"), match("1", "3", id="x")]
assert len(host(["AAA"])._select_games_for_display(games, ["AAA"])) == 1
def test_upcoming_is_soonest_first_and_recent_newest_first(self):
assert pick(["CCC"], 5, False) == "s2,s3,s7,~,s9"
assert pick(["CCC"], 5, True) == "s2,s3,s7,~,s9"
def test_the_handed_list_is_used_not_favorite_teams(self):
h = host(["CCC"], limit=1)
assert [g["id"] for g in h._select_games_for_display(slate(False), ["AAA"])] == ["s1"]
@pytest.mark.parametrize("recent", [False, True])
def test_the_summary_is_logged_at_info(self, recent, caplog):
with caplog.at_level(logging.INFO, logger=LOG.name):
pick(["AAA"], 1, recent)
name = "_select_recent_games_for_display" if recent else "_select_games_for_display"
assert [r.levelno for r in caplog.records if r.funcName == name
and r.levelno >= logging.INFO] == [logging.INFO]
# ---------------------------------------------------------------------------
# Carriers and host contract
# ---------------------------------------------------------------------------
MIXINS = {
"SportsFavoritesMixin": ["_favorite_code", "_is_favorite_game"],
"SportsUpcomingFavoritesMixin": ["_select_games_for_display"],
"SportsRecentFavoritesMixin": ["_select_recent_games_for_display"],
}
def _classes():
tree = ast.parse(Path(sports_favorites.__file__).read_text(encoding="utf-8"))
return {n.name: n for n in tree.body if isinstance(n, ast.ClassDef)}
def _self_reads(cls):
return {node.attr for node in ast.walk(cls)
if isinstance(node, ast.Attribute) and isinstance(node.ctx, ast.Load)
and isinstance(node.value, ast.Name) and node.value.id == "self"}
class TestHostContract:
def test_each_mixin_carries_only_its_class_methods(self):
"""So adopting one gives no manager a method it did not have."""
for name, methods in MIXINS.items():
mixin = getattr(sports_favorites, name)
assert sorted(n for n in vars(mixin) if not n.startswith("__")) == methods
def test_every_host_read_is_documented(self):
reads = set().union(*(_self_reads(c) for c in _classes().values()))
undocumented = sorted(n for n in reads if f"``{n}``" not in sports_favorites.__doc__)
assert undocumented == [], f"read but not in the host contract: {undocumented}"
def test_the_key_comes_from_sports_helpers(self):
"""The seam stays where 3.5.0 put it; this module only calls it."""
assert "_favorite_key" in vars(SportsHelpersMixin)
assert all("_favorite_key" not in vars(getattr(sports_favorites, n)) for n in MIXINS)
def test_no_other_shared_mixin_defines_these(self):
from src.common import sports_display_rules, sports_shared
others = [sports_shared.SportsCoreSharedMixin, sports_shared.SportsRecentSharedMixin,
sports_shared.SportsLiveSharedMixin, SportsHelpersMixin,
sports_display_rules.SportsGameRulesMixin]
for methods in MIXINS.values():
for name in methods:
assert not any(name in vars(o) for o in others), name
def test_the_shared_callers_reach_it(self):
"""_favorites_first and the live dwell ask _is_favorite_game; one body answers."""
from src.common.sports_display_rules import SportsGameRulesMixin
class Host(SportsGameRulesMixin, SportsFavoritesMixin, SportsHelpersMixin):
favorite_teams = ["aaa"]
game_display_duration = 15
non_favorite_live_game_duration = 5
assert Host()._effective_live_duration(dict(GAMES["AAA home v BBB"])) == 15
assert Host()._effective_live_duration(dict(GAMES["CCC v DDD"])) == 5
+135
View File
@@ -0,0 +1,135 @@
"""sports_favorites still matches every plugin copy, and only nrl overrides the key.
``src.common.sports_favorites`` was copied from the scoreboards once family 6
had made each method one body in all nine: ``SportsCore._favorite_code`` and
``_is_favorite_game``, ``SportsUpcoming._select_games_for_display`` and
``SportsRecent._select_recent_games_for_display``. The plugins delete their
copies once they floor on the release that ships this module. Until each has, a
copy that changes on its own is a fix one side has and the other lacks.
Point LEDMATRIX_PLUGINS at a ledmatrix-plugins checkout and each method is
compared with every plugin copy using ``scripts/sports_drift_report.py``'s own
normalisation (the AST with docstrings and annotations dropped), plus the
decorators. A copy that is gone counts as adopted when the plugin's
``sports.py`` names the module. The owner's decision that only nrl overrides
``_favorite_key`` (with the team id) is checked too; that override stays in
the plugin after adoption. Without the variable this skips: core CI has no
plugins checkout.
"""
import ast
import importlib.util
import os
from pathlib import Path
import pytest
from src.common import sports_favorites
REPO = Path(__file__).resolve().parents[1]
SPORTS = ("afl", "baseball", "basketball", "football", "hockey", "lacrosse",
"nrl", "soccer", "ufc")
#: plugin class -> (our mixin, the methods it carries)
CARRIERS = {
"SportsCore": ("SportsFavoritesMixin", ("_favorite_code", "_is_favorite_game")),
"SportsUpcoming": ("SportsUpcomingFavoritesMixin", ("_select_games_for_display",)),
"SportsRecent": ("SportsRecentFavoritesMixin", ("_select_recent_games_for_display",)),
}
#: The owner's decision (docs/SPORTS_UNIFICATION.md, family 6): the sports
#: that name a team by something other than its abbreviation.
OVERRIDES_FAVORITE_KEY = {"nrl"}
def _drift_report():
"""scripts/sports_drift_report.py, loaded by path (scripts/ is no package)."""
spec = importlib.util.spec_from_file_location(
"sports_drift_report", REPO / "scripts" / "sports_drift_report.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
DRIFT = _drift_report()
def _plugins_root():
root = DRIFT.resolve_plugins_dir(os.environ.get("LEDMATRIX_PLUGINS"))
if root is None:
pytest.skip("set LEDMATRIX_PLUGINS to a ledmatrix-plugins checkout to "
"compare this module against the plugin copies")
return root
def _class(tree, name):
return next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == name)
def _method(cls, name):
return next((n for n in cls.body
if isinstance(n, ast.FunctionDef) and n.name == name), None)
def _fingerprint(node):
return (DRIFT._digest(node, DRIFT._Canonical()),
tuple(ast.unparse(d) for d in node.decorator_list))
def _ours(mixin):
tree = ast.parse(Path(sports_favorites.__file__).read_text(encoding="utf-8"))
return _class(tree, mixin)
def _plugin_tree(root, sport):
source = (root / f"{sport}-scoreboard" / "sports.py").read_text(encoding="utf-8")
return source, ast.parse(source)
CASES = [(sport, cls, name) for sport in SPORTS
for cls, (_, names) in CARRIERS.items() for name in names]
@pytest.mark.parametrize("sport, cls, name", CASES)
def test_every_remaining_plugin_copy_matches(sport, cls, name):
source, tree = _plugin_tree(_plugins_root(), sport)
copy = _method(_class(tree, cls), name)
if copy is None:
assert sports_favorites.__name__ in source, (
f"{sport}: no {name} on {cls} and no {sports_favorites.__name__} import")
else:
ours = _method(_ours(CARRIERS[cls][0]), name)
assert _fingerprint(copy) == _fingerprint(ours), (
f"{cls}.{name} in {sport} differs from sports_favorites. "
f"Port the change to both, or stop treating it as shared.")
@pytest.mark.parametrize("sport", SPORTS)
def test_no_other_plugin_class_carries_a_copy(sport):
"""A copy on another class (afl's old SportsUpcoming._is_favorite_game) would shadow the shared one."""
_, tree = _plugin_tree(_plugins_root(), sport)
shared = {name: cls for cls, (_, names) in CARRIERS.items() for name in names}
strays = [f"{node.name}.{name}" for node in tree.body if isinstance(node, ast.ClassDef)
for name, home in shared.items()
if node.name != home and _method(node, name) is not None]
assert strays == []
def test_only_the_decided_sports_override_the_key():
root = _plugins_root()
overriding = {sport for sport in SPORTS
if any(_method(node, "_favorite_key") is not None
for node in _plugin_tree(root, sport)[1].body
if isinstance(node, ast.ClassDef))}
assert overriding == OVERRIDES_FAVORITE_KEY
def test_the_drift_report_still_calls_them_identical():
root = _plugins_root()
families = DRIFT.build(root, ("sports.py",))
rows = {(r["file"], r["family"]): r
for r in (DRIFT.summarise(k, v) for k, v in families.items())}
for _, names in CARRIERS.values():
for name in names:
row = rows.get(("sports.py", name))
assert row is None or row["worst_class_variants"] == 1, name
+275
View File
@@ -0,0 +1,275 @@
"""src.common.sports_game_over: behaviour, host contract and base order.
The matrix is ledmatrix-plugins' ``scripts/test_game_over_check.py`` (the
table the family 5 reconcile was checked against) folded to the three
``FINAL_PERIOD`` values the nine scoreboards declare: None (afl, baseball,
nrl, soccer, ufc), 4 (basketball, football, lacrosse) and 3 (hockey).
Baseball's postponed/suspended override stays in its plugin and is not here.
"""
import ast
import logging
import time
from pathlib import Path
import pytest
from src.common import sports_game_over
from src.common.sports_game_over import SportsGameOverMixin
from src.common.sports_shared import SportsLiveSharedMixin
LOG = logging.getLogger("test_sports_game_over")
def host(final_period):
"""A live manager stand-in declaring ``FINAL_PERIOD`` as a plugin does."""
cls = type("Live", (SportsGameOverMixin,), {"FINAL_PERIOD": final_period})
h = cls()
h.logger = LOG
return h
MISSING = object() # the key is absent from the game dict
def game(period_text="", period=MISSING, clock=MISSING, away="1", home="2"):
g = {"away_abbr": "AWY", "home_abbr": "HOM", "away_score": away,
"home_score": home, "period_text": period_text}
if period is not MISSING:
g["period"] = period
if clock is not MISSING:
g["clock"] = clock
return g
# ---------------------------------------------------------------------------
# The matrix: clock x period, for each FINAL_PERIOD. Scores 1-2.
# ---------------------------------------------------------------------------
FINAL_PERIODS = (None, 4, 3)
PERIODS = (MISSING, 1, 2, 3, 4, 5, 6)
CLOCKS = {"12:00": "12:00", "0:00": "0:00", ":00": ":00", "0.0": "0.0",
"-": "-", "''": "", "None": None, "missing": MISSING}
#: The period text each ESPN status carries. Only "Final" contains "final";
#: the method reads no status, so every other text answers the same row.
LIVE_TEXTS = {
"in progress": lambda p: "" if p is MISSING else f"P{p}",
"end of period": lambda p: "" if p is MISSING else f"End P{p}",
"halftime": lambda p: "Halftime",
"end of round": lambda p: "" if p is MISSING else f"End R{p}",
"postponed": lambda p: "Postponed",
}
#: clock -> one cell per period (missing, 1..6) for FINAL_PERIOD None, 4, 3.
EXPECTED_LIVE = {
"12:00": "....... ....... .......",
"0:00": "....... ....YYY ...YYYY",
":00": "....... ....YYY ...YYYY",
"0.0": "....... ....... .......",
"-": "....... ....... .......",
"''": "....... ....... .......",
"None": "....... ....... .......",
"missing": "....... ....... .......",
}
def row(text_for, clock):
return " ".join(
"".join("Y" if host(fp)._is_game_really_over(game(text_for(p), p, clock)) else "."
for p in PERIODS)
for fp in FINAL_PERIODS)
@pytest.mark.parametrize("status", sorted(LIVE_TEXTS))
@pytest.mark.parametrize("clock_label", sorted(CLOCKS))
def test_a_live_period_text(status, clock_label):
assert row(LIVE_TEXTS[status], CLOCKS[clock_label]) == EXPECTED_LIVE[clock_label]
@pytest.mark.parametrize("clock_label", sorted(CLOCKS))
def test_a_final_period_text_is_always_over(clock_label):
assert row(lambda p: "Final", CLOCKS[clock_label]) == "YYYYYYY YYYYYYY YYYYYYY"
#: label -> (game, one cell per FINAL_PERIOD None, 4, 3)
EDGES = {
"period_text None, P4 0:00": (game(None, 4, "0:00"), ".YY"),
"period None, 0:00": (game("", None, "0:00"), "..."),
"period 'OT', 0:00": (game("OT", "OT", "0:00"), "..."),
"period '4' (str), 0:00": (game("P4", "4", "0:00"), ".YY"),
"clock int 0, P4": (game("P4", 4, 0), "..."),
"clock float 0.0, P4": (game("P4", 4, 0.0), "..."),
"clock ' 0:00 ', P4": (game("P4", 4, " 0:00 "), ".YY"),
"clock '00:00', P4": (game("P4", 4, "00:00"), "..."),
"period_text 'Final/OT', P5 0:00": (game("Final/OT", 5, "0:00"), "YYY"),
"period_text 'FINAL', P1 12:00": (game("FINAL", 1, "12:00"), "YYY"),
}
@pytest.mark.parametrize("label", sorted(EDGES))
def test_edge_shapes(label):
g, want = EDGES[label]
got = "".join("Y" if host(fp)._is_game_really_over(dict(g)) else "." for fp in FINAL_PERIODS)
assert got == want
# ---------------------------------------------------------------------------
# The tie guard: level at 0:00 is overtime, not the end.
# ---------------------------------------------------------------------------
class TestTieGuard:
@pytest.mark.parametrize("fp,period", [(4, 4), (4, 5), (3, 3), (3, 4), (3, 5)])
def test_level_at_zero_is_not_over(self, fp, period):
assert host(fp)._is_game_really_over(game("", period, "0:00", "2", "2")) is False
def test_level_scores_compare_as_numbers(self):
assert host(4)._is_game_really_over(game("", 4, "0:00", 2, "2")) is False
assert host(4)._is_game_really_over(game("", 4, "0:00", " 2 ", "2")) is False
def test_a_game_that_ends_level_ends_on_final(self):
assert host(4)._is_game_really_over(game("Final/OT", 5, "0:00", "2", "2")) is True
def test_level_before_the_final_period_was_never_over(self):
assert host(4)._is_game_really_over(game("", 3, "0:00", "2", "2")) is False
@pytest.mark.parametrize("away,home", [
(MISSING, MISSING), (None, None), ("", ""), ("2", None),
("2.0", "2.0"), ({"value": 2}, {"value": 2}), ("inf", "inf"),
])
def test_an_unreadable_score_leaves_it_to_the_clock(self, away, home):
g = game("", 4, "0:00")
for key, value in (("away_score", away), ("home_score", home)):
if value is MISSING:
del g[key]
else:
g[key] = value
assert host(4)._is_game_really_over(g) is True
def test_float_infinity_does_not_raise(self):
assert host(4)._is_game_really_over(
game("", 4, "0:00", float("inf"), float("inf"))) is True
# ---------------------------------------------------------------------------
# ufc: ESPN MMA payloads, as ufc's _extract_game_details stores them
# (ledmatrix-plugins plugins/ufc-scoreboard/test/fixtures/espn_mma_round_states.json).
# ---------------------------------------------------------------------------
UFC_RECORDED = {
"in_round_3_of_3": ("R3", 3, "1:21"),
"break_after_round_1": ("R1", 1, "-"),
"end_of_round_after_stoppage": ("R2", 2, "0:51"),
"walkouts_five_rounder": ("", 0, "-"),
"final_five_round_decision": ("R5", 5, "5:00"),
"final_five_round_stoppage": ("R5", 5, "1:38"),
"final_three_round_decision": ("R3", 3, "5:00"),
"final_three_round_stoppage": ("R2", 2, "4:07"),
"break_after_round_4_of_5": ("R4", 4, "-"),
"end_of_round_5_awaiting_decision": ("R5", 5, "-"),
}
class TestUfc:
@pytest.mark.parametrize("name", sorted(UFC_RECORDED))
def test_no_recorded_state_is_over_here(self, name):
"""A finished bout leaves the live list on is_final, before this is asked."""
text, period, clock = UFC_RECORDED[name]
assert host(None)._is_game_really_over(game(text, period, clock, "0", "0")) is False
@pytest.mark.parametrize("fp", FINAL_PERIODS)
def test_a_round_break_dash_is_never_a_zero_clock(self, fp):
assert host(fp)._is_game_really_over(game("R4", 4, "-", "1", "2")) is False
@pytest.mark.parametrize("clock", ["0:00", None, MISSING])
def test_the_horn_does_not_end_a_bout(self, clock):
assert host(None)._is_game_really_over(game("R5", 5, clock, "1", "2")) is False
# ---------------------------------------------------------------------------
# Wiring: the default, overrides, and the live mixin's caller.
# ---------------------------------------------------------------------------
def test_the_default_is_no_clock_rule():
assert SportsGameOverMixin.FINAL_PERIOD is None
def test_an_override_defers_through_super():
"""baseball's BaseballLive: its own check first, then the shared one."""
class Baseballish(SportsGameOverMixin):
logger = LOG
def _is_game_really_over(self, game):
if game.get("status") == "status_postponed":
return True
return super()._is_game_really_over(game)
b = Baseballish()
assert b._is_game_really_over(dict(game("", 6, "0:00"), status="status_postponed")) is True
assert b._is_game_really_over(game("", 6, "0:00")) is False
assert b._is_game_really_over(game("Final", 9, None)) is True
class _Live(SportsGameOverMixin, SportsLiveSharedMixin):
"""A SportsLive stand-in in the documented base order."""
FINAL_PERIOD = 4
def __init__(self):
self.logger = LOG
self.stale_game_timeout = 600
self.game_update_timestamps = {}
class TestBaseOrder:
def test_the_documented_order_resolves_this_method(self):
assert _Live._is_game_really_over is SportsGameOverMixin._is_game_really_over
mro = _Live.__mro__
assert mro.index(SportsGameOverMixin) < mro.index(SportsLiveSharedMixin)
def test_neither_shared_mixin_defines_it(self):
"""So the base order cannot change which body runs."""
from src.common.sports_shared import SportsCoreSharedMixin
for mixin in (SportsLiveSharedMixin, SportsCoreSharedMixin):
assert "_is_game_really_over" not in vars(mixin)
def test_detect_stale_games_drops_an_over_game_through_it(self):
live = _Live()
live.game_update_timestamps = {"over": {"last_seen": time.time()},
"on": {"last_seen": time.time()}}
games = [dict(game("", 4, "0:00"), id="over"),
dict(game("", 4, "0:00", "2", "2"), id="on")]
live._detect_stale_games(games)
assert [g["id"] for g in games] == ["on"]
assert "over" not in live.game_update_timestamps
def test_the_class_value_wins_over_the_default(self):
assert _Live().FINAL_PERIOD == 4
assert _Live()._is_game_really_over(game("", 4, "0:00")) is True
# ---------------------------------------------------------------------------
# Host contract
# ---------------------------------------------------------------------------
def _self_reads():
tree = ast.parse(Path(sports_game_over.__file__).read_text(encoding="utf-8"))
cls = next(n for n in tree.body
if isinstance(n, ast.ClassDef) and n.name == "SportsGameOverMixin")
return {node.attr for node in ast.walk(cls)
if isinstance(node, ast.Attribute) and isinstance(node.ctx, ast.Load)
and isinstance(node.value, ast.Name) and node.value.id == "self"}
class TestHostContract:
def test_every_host_read_is_documented(self):
undocumented = sorted(n for n in _self_reads()
if f"``{n}``" not in sports_game_over.__doc__)
assert undocumented == [], f"read but not in the host contract: {undocumented}"
def test_the_mixin_creates_no_state(self):
assert "__init__" not in vars(SportsGameOverMixin)
assert not hasattr(SportsGameOverMixin, "logger")
assert sorted(n for n in vars(SportsGameOverMixin) if not n.startswith("__")) == [
"FINAL_PERIOD", "_is_game_really_over"]
+123
View File
@@ -0,0 +1,123 @@
"""sports_game_over still matches every plugin copy, and each plugin's FINAL_PERIOD.
``SportsGameOverMixin._is_game_really_over`` was copied from the scoreboards'
``SportsLive._is_game_really_over`` once family 5 had made the nine copies one
body. The plugins delete their copies once they floor on the release that
ships this module. Until each has, a copy that changes on its own is a fix one
side has and the other lacks.
Point LEDMATRIX_PLUGINS at a ledmatrix-plugins checkout and the method is
compared with every plugin copy using ``scripts/sports_drift_report.py``'s own
normalisation (the AST with docstrings and annotations dropped), plus the
decorators. A copy that is gone counts as adopted when the plugin's
``sports.py`` names the module. Each plugin's ``SportsLive.FINAL_PERIOD`` is
compared with the value the owner decided for its sport, which stays in the
plugin after adoption. Without the variable this skips: core CI has no plugins
checkout.
"""
import ast
import importlib.util
import os
from pathlib import Path
import pytest
from src.common import sports_game_over
REPO = Path(__file__).resolve().parents[1]
#: The owner's decision (docs/SPORTS_UNIFICATION.md, family 5): the period
#: from which a 0:00 clock ends a game, None where the clock never does.
FINAL_PERIOD = {
"afl": None, "baseball": None, "basketball": 4, "football": 4,
"hockey": 3, "lacrosse": 4, "nrl": None, "soccer": None, "ufc": None,
}
NAME = "_is_game_really_over"
def _drift_report():
"""scripts/sports_drift_report.py, loaded by path (scripts/ is no package)."""
spec = importlib.util.spec_from_file_location(
"sports_drift_report", REPO / "scripts" / "sports_drift_report.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
DRIFT = _drift_report()
def _plugins_root():
root = DRIFT.resolve_plugins_dir(os.environ.get("LEDMATRIX_PLUGINS"))
if root is None:
pytest.skip("set LEDMATRIX_PLUGINS to a ledmatrix-plugins checkout to "
"compare this module against the plugin copies")
return root
def _class(tree, name):
return next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == name)
def _method(cls):
return next((n for n in cls.body
if isinstance(n, ast.FunctionDef) and n.name == NAME), None)
def _fingerprint(node):
return (DRIFT._digest(node, DRIFT._Canonical()),
tuple(ast.unparse(d) for d in node.decorator_list))
def _final_period(cls):
for node in cls.body:
if (isinstance(node, (ast.Assign, ast.AnnAssign)) and node.value is not None):
target = node.targets[0] if isinstance(node, ast.Assign) else node.target
if isinstance(target, ast.Name) and target.id == "FINAL_PERIOD":
return ast.literal_eval(node.value)
raise AssertionError("SportsLive declares no FINAL_PERIOD")
def _ours():
tree = ast.parse(Path(sports_game_over.__file__).read_text(encoding="utf-8"))
return _class(tree, "SportsGameOverMixin")
def test_the_mixin_holds_one_method_and_the_default():
names = sorted(n.name if isinstance(n, ast.FunctionDef) else n.target.id
for n in _ours().body if isinstance(n, (ast.FunctionDef, ast.AnnAssign))
and (isinstance(n, ast.FunctionDef) or n.value is not None))
assert names == ["FINAL_PERIOD", NAME]
assert _final_period(_ours()) is None
@pytest.mark.parametrize("sport", sorted(FINAL_PERIOD))
def test_every_remaining_plugin_copy_matches(sport):
root = _plugins_root()
source = (root / f"{sport}-scoreboard" / "sports.py").read_text(encoding="utf-8")
live = _class(ast.parse(source), "SportsLive")
copy = _method(live)
if copy is None:
assert sports_game_over.__name__ in source, (
f"{sport}: no {NAME} on SportsLive and no {sports_game_over.__name__} import")
else:
assert _fingerprint(copy) == _fingerprint(_method(_ours())), (
f"{NAME} in {sport} differs from sports_game_over. "
f"Port the change to both, or stop treating it as shared.")
@pytest.mark.parametrize("sport", sorted(FINAL_PERIOD))
def test_every_plugin_declares_its_final_period(sport):
root = _plugins_root()
source = (root / f"{sport}-scoreboard" / "sports.py").read_text(encoding="utf-8")
assert _final_period(_class(ast.parse(source), "SportsLive")) == FINAL_PERIOD[sport]
def test_the_drift_report_still_calls_it_identical():
root = _plugins_root()
families = DRIFT.build(root, ("sports.py",))
rows = {(r["file"], r["family"]): r
for r in (DRIFT.summarise(k, v) for k, v in families.items())}
row = rows.get(("sports.py", NAME))
assert row is None or row["worst_class_variants"] == 1
+42 -12
View File
@@ -8,7 +8,9 @@ loses those tests with it.
The parity class is what keeps "byte-identical" true after this lands. Point
LEDMATRIX_PLUGINS at a ledmatrix-plugins checkout and every promoted body is
compared, as a docstring-stripped AST, against every plugin copy that carries
it. Without the variable it skips rather than fails, since core CI has no
it. A copy that is gone counts as adopted when the plugin imports
src.common.sports_helpers (plugins#563/#564 did that for every scoreboard).
Without the variable it skips rather than fails, since core CI has no
plugins checkout; ledmatrix-plugins CI runs the same comparison against core
(scripts/check_sports_helpers_parity.py, ledmatrix-plugins#495).
"""
@@ -572,6 +574,24 @@ def _core_definitions():
return out
def _sports_source(root, sport):
return (root / f"{sport}-scoreboard" / "sports.py").read_text(encoding="utf-8")
def _adopted(source):
"""Gone is fine once the plugin uses the module; otherwise the finder is
not seeing its copy."""
name = sports_helpers.__name__
for node in ast.walk(ast.parse(source)):
if isinstance(node, ast.ImportFrom):
if node.module == name or any(
f"{node.module}.{a.name}" == name for a in node.names):
return True
elif isinstance(node, ast.Import) and any(a.name == name for a in node.names):
return True
return False
class TestParityWithPlugins:
@pytest.mark.parametrize("name", sorted(PROMOTED))
def test_body_matches_every_plugin_copy(self, name):
@@ -580,11 +600,11 @@ class TestParityWithPlugins:
ours = _dump(_core_definitions()[name])
drifted, missing = [], []
for sport in carriers:
defs = _definitions(ast.parse(
(root / f"{sport}-scoreboard" / "sports.py").read_text(encoding="utf-8")))
theirs = defs[where].get(plugin_name)
source = _sports_source(root, sport)
theirs = _definitions(ast.parse(source))[where].get(plugin_name)
if theirs is None:
missing.append(sport)
if not _adopted(source):
missing.append(sport)
elif _dump(theirs) != ours:
drifted.append(sport)
assert missing == [], f"{plugin_name} no longer in: {missing}"
@@ -594,10 +614,20 @@ class TestParityWithPlugins:
@pytest.mark.parametrize("sport", SCOREBOARDS)
def test_constants_match(self, sport):
root = _plugins_root()
defs = _definitions(ast.parse(
(root / f"{sport}-scoreboard" / "sports.py").read_text(encoding="utf-8")))
assert ast.literal_eval(defs["module"]["_MIN_WINDOW_DAYS"].value) == MIN_WINDOW_DAYS
assert ast.literal_eval(defs["module"]["_MAX_WINDOW_DAYS"].value) == MAX_WINDOW_DAYS
gap = defs["SportsCore"]["_DWELL_REENTRY_GAP_SECONDS"].value
assert math.isclose(ast.literal_eval(gap), SportsHelpersMixin._DWELL_REENTRY_GAP_SECONDS)
source = _sports_source(_plugins_root(), sport)
defs = _definitions(ast.parse(source))
expected = {
("module", "_MIN_WINDOW_DAYS"): MIN_WINDOW_DAYS,
("module", "_MAX_WINDOW_DAYS"): MAX_WINDOW_DAYS,
("SportsCore", "_DWELL_REENTRY_GAP_SECONDS"):
SportsHelpersMixin._DWELL_REENTRY_GAP_SECONDS,
}
missing = []
for (where, name), value in expected.items():
node = defs[where].get(name)
if node is None:
if not _adopted(source):
missing.append(name)
else:
assert math.isclose(ast.literal_eval(node.value), value), name
assert missing == [], f"not found in {sport}: {missing}"
+130
View File
@@ -465,6 +465,136 @@ class TestLiveMixin:
# The safety property that makes it correct: 30s beats 600s.
assert h._idle_live_interval() == h.update_interval
# ---- every pending kickoff is honoured, not just the first ------------
#
# One stored kickoff held the slot through its grace and refused every
# later one; a later one that had passed by the time the grace ended was
# refused again as "already past". So of two favourites kicking off ten
# minutes apart, the second lost its grace: if ESPN had not flipped it live
# by the end of the FIRST game's grace, the back-off returned to its
# ceiling and the game was noticed up to that late. That bites whenever the
# first game is not live by then -- a rain delay, a postponement, ESPN slow
# to flip it -- since a live first game keeps the live cadence anyway.
@staticmethod
def _replay(monkeypatch, kickoffs, flips, until, poll=30, ceiling=900):
"""Drive a live manager's idle loop over a schedule on a fake clock.
``kickoffs`` are start offsets in seconds from t=0 (the first look),
``flips`` how long after its start ESPN reports each game live (None:
postponed, never live). Every
look offers each not-yet-live game, as the live loop does, then sleeps
for whatever the back-off returns. Returns, per game, how long after it
went live it was first seen live -- None if never.
"""
clock = [1_800_000_000.0]
monkeypatch.setattr(sports_shared.time, "time", lambda: clock[0])
h = _LiveHost(no_data_interval=300)
h.live_idle_max_interval = ceiling
h.update_interval = poll
h._empty_live_streak = 30 # idle all morning: at the ceiling
t0 = clock[0]
seen = [None] * len(kickoffs)
while clock[0] - t0 < until:
now = clock[0] - t0
live = [f is not None and k + f <= now for k, f in zip(kickoffs, flips)]
for i, is_live in enumerate(live):
if is_live and seen[i] is None:
seen[i] = now - (kickoffs[i] + flips[i])
start = datetime.fromtimestamp(t0 + kickoffs[i], tz=timezone.utc)
h._note_scheduled_start_candidate(
{"is_live": is_live, "is_halftime": False,
"start_time_utc": start})
# A real board keeps polling at the live cadence while anything is
# live; a game here stays live for an hour after it flips.
on = any(f is not None and k + f <= now < k + f + 3600
for k, f in zip(kickoffs, flips))
h._note_live_fetch(on)
clock[0] += poll if on else h._idle_live_interval()
assert len(getattr(h, "_later_scheduled_starts", None) or ()) <= sports_shared._KICKOFF_QUEUE_MAX
return seen
@pytest.mark.parametrize("kickoffs,flips", [
# (start offsets, ESPN's flip delay per game), both in seconds.
pytest.param([1800], [120], id="one kickoff, flipped 2 min late"),
pytest.param([1800, 2400], [0, 840],
id="first live on time, the second flipped 14 min late"),
pytest.param([1800, 2400], [None, 840],
id="first postponed, the second 10 min later flipped 14 min late"),
pytest.param([1800, 2400], [1200, 840],
id="first in a 20 min delay, the second flipped 14 min late"),
pytest.param([1800, 2100, 2520], [None, None, 600],
id="three inside one grace, the last flipped 10 min late"),
pytest.param([1800, 1800, 2400], [None, None, 700],
id="two at the same time, then one 10 min later"),
pytest.param([1800, 2700], [None, 840],
id="second kickoff 15 min later, flipped 14 min late"),
pytest.param([1800 + 60 * i for i in range(20)],
[None] * 19 + [840],
id="twenty kickoffs a minute apart overflow the queue"),
])
def test_every_pending_kickoff_gets_its_grace(self, monkeypatch, kickoffs, flips):
seen = self._replay(monkeypatch, kickoffs, flips,
until=max(kickoffs) + 3 * 3600)
late = [s for s, f in zip(seen, flips)
if f is not None and (s is None or s > 30)]
assert not late, "games noticed late (s after going live): %r" % (seen,)
def test_a_kickoff_that_never_flips_costs_one_grace_then_backs_off(self, monkeypatch):
# A postponed game keeps the live cadence for its grace and no longer:
# remembering more kickoffs must not pin the poll to dead ones.
clock = [1_800_000_000.0]
monkeypatch.setattr(sports_shared.time, "time", lambda: clock[0])
h = self._idle_host()
t0 = clock[0]
for offset in (600, 900):
h._note_scheduled_start_candidate(
{"start_time_utc": datetime.fromtimestamp(t0 + offset, tz=timezone.utc)})
clock[0] = t0 + 900 + sports_shared._KICKOFF_GRACE_SECONDS - 1
assert h._idle_live_interval() == h.update_interval
clock[0] = t0 + 900 + sports_shared._KICKOFF_GRACE_SECONDS + 1
assert h._idle_live_interval() == 900
assert not getattr(h, "_later_scheduled_starts", None)
def test_a_queued_kickoff_past_its_own_grace_is_skipped(self, monkeypatch):
# After a long sleep (or a run of looks that never woke the manager)
# several queued kickoffs may have gone stale at once. The one still
# inside its grace must win, not the first stale one in the queue.
clock = [1_800_000_000.0]
monkeypatch.setattr(sports_shared.time, "time", lambda: clock[0])
h = self._idle_host()
t0 = clock[0]
for offset in (600, 700, 1500):
h._note_scheduled_start_candidate(
{"start_time_utc": datetime.fromtimestamp(t0 + offset, tz=timezone.utc)})
clock[0] = t0 + 1500 + 150 # 600 and 700 are past their grace
assert h._idle_live_interval() == h.update_interval
assert h._next_scheduled_start_ts == t0 + 1500
def test_the_queue_keeps_the_earliest_kickoffs(self, monkeypatch):
clock = [1_800_000_000.0]
monkeypatch.setattr(sports_shared.time, "time", lambda: clock[0])
h = self._idle_host()
t0 = clock[0]
cap = sports_shared._KICKOFF_QUEUE_MAX
for offset in reversed(range(1, cap + 6)): # latest first
h._note_scheduled_start_candidate(
{"start_time_utc": datetime.fromtimestamp(t0 + 600 * offset, tz=timezone.utc)})
assert h._next_scheduled_start_ts == t0 + 600
assert h._later_scheduled_starts == [t0 + 600 * i for i in range(2, cap + 2)]
def test_a_kickoff_offered_twice_is_kept_once(self, monkeypatch):
clock = [1_800_000_000.0]
monkeypatch.setattr(sports_shared.time, "time", lambda: clock[0])
h = self._idle_host()
t0 = clock[0]
for _ in range(3):
for offset in (600, 1200):
h._note_scheduled_start_candidate(
{"start_time_utc": datetime.fromtimestamp(t0 + offset, tz=timezone.utc)})
assert h._next_scheduled_start_ts == t0 + 600
assert h._later_scheduled_starts == [t0 + 1200]
def test_finding_a_live_game_resets_the_streak(self):
h = _LiveHost()
h._note_live_fetch(False)
+11
View File
@@ -612,6 +612,12 @@ class TestEndToEnd:
cached['display_current_state'] = {'mode': 'from-cache', 'last_updated': 1}
path = str(tmp_path / 'control.sock')
monkeypatch.setenv(c.SOCKET_PATH_ENV, path)
# The display is this process, and its render loop is beating: the
# cache is then still its answer once the socket goes.
heartbeat = tmp_path / 'display-heartbeat.json'
heartbeat.write_text(json.dumps({'pid': os.getpid(), 'mono': time.monotonic(),
'wall': time.time()}))
monkeypatch.setattr(display_watchdog, 'HEARTBEAT_PATH', str(heartbeat))
hub = _hub_with_everything()
server = ControlServer(path, state_hub=hub, keepalive=0.2)
assert server.start()
@@ -638,3 +644,8 @@ class TestEndToEnd:
break
time.sleep(0.05)
assert (data['mode'], data['source']) == ('from-cache', 'cache')
# Stopped: systemd takes the heartbeat's directory with it, and the
# cache's last answer is no longer anyone's.
heartbeat.unlink()
data = _data(client, '/api/v3/display/current-status')
assert (data['mode'], data['source']) == (None, 'cache')
+244
View File
@@ -0,0 +1,244 @@
"""A plugin update must keep the files the plugin wrote beside itself.
Field incident, 2026-10-04: updating calendar 1.2.9 -> 1.2.12 from the web UI
replaced plugin-repos/calendar/ with the fresh download and deleted the old
copy -- and with it token.pickle and credentials.json, the plugin's Google
OAuth files. No release contains them (the repo gitignores them), so the hot
reload logged "Credentials file not found" and the calendar stayed broken
until the files were restored by hand.
Both update routes are covered: a monorepo plugin (registry ``plugin_path``),
which is reinstalled into a fresh directory, and a plugin installed from its
own git repository, which is updated with ``git pull`` after an auto-stash.
"""
import json
import shutil
import subprocess
import pytest
from src.plugin_system.plugin_local_files import (
is_known_state_file, local_files_to_keep,
)
from src.plugin_system.store_manager import PluginStoreManager
PLUGIN_ID = "calendar"
def _manifest(version):
return {"id": PLUGIN_ID, "name": "Calendar", "class_name": "CalendarPlugin",
"display_modes": ["calendar"], "version": version}
def _write_release(target, version):
"""What a download of ``version`` puts on disk."""
target.mkdir(parents=True, exist_ok=True)
(target / "manifest.json").write_text(json.dumps(_manifest(version)))
(target / "manager.py").write_text(f"VERSION = {version!r}\n")
(target / ".gitignore").write_text("credentials.json\ntoken.pickle\ncache/\n")
def _drop_local_files(plugin_dir):
"""What the plugin writes at runtime: OAuth files plus cached state."""
(plugin_dir / "token.pickle").write_bytes(b"\x80\x04oauth-token")
(plugin_dir / "credentials.json").write_text('{"installed": {}}')
(plugin_dir / "cache").mkdir()
(plugin_dir / "cache" / "events.json").write_text("[]")
def _assert_local_files_kept(plugin_dir):
assert (plugin_dir / "token.pickle").read_bytes() == b"\x80\x04oauth-token"
assert (plugin_dir / "credentials.json").read_text() == '{"installed": {}}'
assert (plugin_dir / "cache" / "events.json").read_text() == "[]"
def _leftover_backups(plugins_dir):
return [p.name for p in plugins_dir.iterdir() if "standalone-backup" in p.name]
@pytest.fixture
def store(tmp_path, monkeypatch):
mgr = PluginStoreManager(
plugins_dir=str(tmp_path / "plugin-repos"),
uninstalled_registry_path=str(tmp_path / "uninstalled.json"))
mgr.plugins_dir.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(mgr, "_install_dependencies", lambda *a, **k: True)
monkeypatch.setattr(mgr, "fetch_registry", lambda *a, **k: {"plugins": []})
return mgr
class TestMonorepoUpdate:
@pytest.fixture
def installed(self, store, monkeypatch):
registry_entry = {
"id": PLUGIN_ID, "repo": "https://github.com/ChuckBuilds/ledmatrix-plugins",
"plugin_path": "plugins/calendar", "branch": "main",
"latest_version": "1.2.9",
}
monkeypatch.setattr(store, "get_plugin_info", lambda *a, **k: registry_entry)
release = {"version": "1.2.9"}
def fake_monorepo_download(download_url, plugin_subpath, target):
assert plugin_subpath == "plugins/calendar"
_write_release(target, release["version"])
return True
monkeypatch.setattr(store, "_install_from_monorepo", fake_monorepo_download)
assert store.install_plugin(PLUGIN_ID) is True
def publish(version):
registry_entry["latest_version"] = release["version"] = version
return store, store.plugins_dir / PLUGIN_ID, publish
def test_update_keeps_token_and_gitignored_files(self, installed):
store, plugin_dir, publish = installed
_drop_local_files(plugin_dir)
publish("1.2.12")
assert store.update_plugin(PLUGIN_ID) is True
assert json.loads((plugin_dir / "manifest.json").read_text())["version"] == "1.2.12"
_assert_local_files_kept(plugin_dir)
assert _leftover_backups(store.plugins_dir) == []
def test_token_is_kept_even_when_the_release_does_not_gitignore_it(self, installed):
store, plugin_dir, publish = installed
(plugin_dir / ".gitignore").unlink()
(plugin_dir / "token.pickle").write_bytes(b"tok")
(plugin_dir / "config_secrets.json").write_text("{}")
publish("1.2.12")
assert store.update_plugin(PLUGIN_ID) is True
assert (plugin_dir / "token.pickle").read_bytes() == b"tok"
assert (plugin_dir / "config_secrets.json").read_text() == "{}"
def test_release_content_wins_and_old_code_is_not_carried(self, installed):
store, plugin_dir, publish = installed
# A file the old copy had that the new release dropped, byte code, and
# an old copy of a file the new release also ships.
(plugin_dir / "removed_module.py").write_text("OLD = True\n")
(plugin_dir / "__pycache__").mkdir()
(plugin_dir / "__pycache__" / "manager.cpython-313.pyc").write_bytes(b"pyc")
publish("1.2.12")
assert store.update_plugin(PLUGIN_ID) is True
assert not (plugin_dir / "removed_module.py").exists()
assert not (plugin_dir / "__pycache__").exists()
assert "1.2.12" in (plugin_dir / "manager.py").read_text()
def test_reinstall_over_an_existing_copy_keeps_them_too(self, installed):
store, plugin_dir, publish = installed
_drop_local_files(plugin_dir)
assert store.install_plugin(PLUGIN_ID) is True
_assert_local_files_kept(plugin_dir)
assert _leftover_backups(store.plugins_dir) == []
class TestInstallFromUrlReplace:
def test_replacing_an_installed_copy_keeps_the_token(self, store, monkeypatch):
plugin_dir = store.plugins_dir / PLUGIN_ID
_write_release(plugin_dir, "1.0.0")
_drop_local_files(plugin_dir)
def fake_clone(repo_url, target, branches):
_write_release(target, "2.0.0")
return "main"
monkeypatch.setattr(store, "_install_via_git", fake_clone)
result = store.install_from_url(
"https://github.com/example/ledmatrix-calendar", plugin_id=PLUGIN_ID)
assert result["success"] is True
assert json.loads((plugin_dir / "manifest.json").read_text())["version"] == "2.0.0"
_assert_local_files_kept(plugin_dir)
def _git(*args, cwd):
subprocess.run(["git", "-c", "user.email=t@example.com", "-c", "user.name=t",
"-c", "core.autocrlf=false", *args],
cwd=cwd, check=True, capture_output=True)
@pytest.mark.skipif(shutil.which("git") is None, reason="git not installed")
class TestGitRepoUpdate:
@pytest.fixture
def cloned(self, store, tmp_path, monkeypatch):
monkeypatch.setattr(store, "get_plugin_info", lambda *a, **k: None)
upstream = tmp_path / "upstream"
_write_release(upstream, "1.0.0")
# This repo does NOT gitignore the token: an untracked, non-ignored
# file is exactly what `git stash push -u` used to sweep away.
(upstream / ".gitignore").write_text("cache/\n")
_git("init", "-q", "-b", "main", cwd=upstream)
_git("add", ".", cwd=upstream)
_git("commit", "-qm", "1.0.0", cwd=upstream)
plugin_dir = store.plugins_dir / PLUGIN_ID
_git("clone", "-q", str(upstream), str(plugin_dir), cwd=tmp_path)
def publish(version):
(upstream / "manifest.json").write_text(json.dumps(_manifest(version)))
_git("commit", "-qam", version, cwd=upstream)
return store, plugin_dir, publish
def test_pull_update_keeps_untracked_token(self, cloned):
store, plugin_dir, publish = cloned
_drop_local_files(plugin_dir)
# An unrelated untracked file, so the update really does stash.
(plugin_dir / "notes.txt").write_text("scratch")
publish("1.1.0")
assert store.update_plugin(PLUGIN_ID) is True
assert json.loads((plugin_dir / "manifest.json").read_text())["version"] == "1.1.0"
_assert_local_files_kept(plugin_dir)
def test_token_alone_does_not_trigger_a_stash(self, cloned):
store, plugin_dir, publish = cloned
(plugin_dir / "token.pickle").write_bytes(b"tok")
publish("1.1.0")
assert store.update_plugin(PLUGIN_ID) is True
assert (plugin_dir / "token.pickle").read_bytes() == b"tok"
stashes = subprocess.run(["git", "-C", str(plugin_dir), "stash", "list"],
capture_output=True, text=True, check=True)
assert stashes.stdout.strip() == ""
class TestWhatIsKept:
@pytest.mark.parametrize("path,expected", [
("token.pickle", True),
("data/session.pickle", True),
("credentials.json", True),
("token.json", True),
("config_secrets.json", True),
(".pkce_code_verifier", True),
("manager.py", False),
("config.json", False),
])
def test_known_state_files(self, path, expected):
assert is_known_state_file(path) is expected
def test_gitignore_rules(self, tmp_path):
old, new = tmp_path / "old", tmp_path / "new"
new.mkdir()
for rel in ["a.log", "logs/x.txt", "sub/deep/b.log", "keep.log",
"anchored.txt", "sub/anchored.txt", "assets/x/y_backup/z.png",
"manager.py", "shipped.log"]:
(old / rel).parent.mkdir(parents=True, exist_ok=True)
(old / rel).write_text("x")
(new / "shipped.log").write_text("new")
(old / ".gitignore").write_text(
"# comment\n*.log\n!keep.log\nlogs/\n/anchored.txt\n"
"assets/**/*_backup/\n")
assert local_files_to_keep(old, new) == [
"a.log", "anchored.txt", "assets/x/y_backup/z.png",
"logs/x.txt", "sub/deep/b.log",
]
+1 -1
View File
@@ -446,7 +446,7 @@ class TestInstalledPluginsApi:
**(manifest_extra or {})}
api.plugin_catalog.get_all_plugin_info = MagicMock(return_value=[info])
api.plugin_catalog.get_plugin_directory = MagicMock(return_value=None)
api.plugin_store_manager.get_registry_info = MagicMock(return_value=None)
api.plugin_store_manager.get_cached_registry_info = MagicMock(return_value=None)
api.config_manager.load_config = MagicMock(return_value={'demo': config})
response = api_v3_client.get('/api/v3/plugins/installed')
assert response.status_code == 200
+1 -2
View File
@@ -219,8 +219,7 @@ class TestCoordinatorStaticPause:
def _plugin(self):
plugin = MagicMock()
plugin.plugin_id = 'clock'
# A moment: zero would pause 15 s, as the rotation shows it.
plugin.get_display_duration.return_value = 0.01
plugin.get_display_duration.return_value = 0
return plugin
def test_trigger_comes_from_the_pipeline(self):
-197
View File
@@ -1,197 +0,0 @@
"""A Vegas static pause lasts as long as the rotation shows the plugin.
The pause asked the plugin for get_display_duration() and compared the
answer with the clock. Several plugins (clock-simple, calendar, countdown)
return their display_duration setting as it is in config.json, so one saved
as "20" or null -- the raw config editor, a hand edit -- reached that
comparison as a string or None. The TypeError went to the pause's broad
except, which ended the pause: the plugin flashed up and the scroll went on,
at every one of its turns. inf paused until something interrupted it, and
NaN, False, 0 or a negative number ended the pause at once.
The pause now reads the answer the way the rotation does since #739, with
the same helper (base_plugin.finite_seconds): a numeric string counts;
anything else that is not a finite number, or a raise, gets the rotation's
30 s; a number at or below zero gets its 15 s.
"""
import logging
import os
import threading
from types import SimpleNamespace
from unittest.mock import MagicMock
os.environ.setdefault("EMULATOR", "true")
import pytest
from src.vegas_mode import coordinator
NOT_NUMBERS = [None, '', 'twenty', True, False, float('nan'), float('inf'),
'inf', '1e400', [20], {'seconds': 20}]
NOT_ABOVE_ZERO = [0, -5, '-5', '0']
NUMBERS = [('20', 20.0), (' 7.5 ', 7.5), (12, 12.0), (12.5, 12.5)]
class FakeClock:
"""time.monotonic/time.sleep for the pause loop: sleeping moves the clock."""
#: A pause still going after this long never ends (inf did that).
LIMIT = 3600.0
def __init__(self):
self.now = 0.0
def monotonic(self):
return self.now
def sleep(self, seconds):
self.now += seconds
if self.now > self.LIMIT:
raise RuntimeError("the static pause never ended")
@pytest.fixture
def clock(monkeypatch):
fake = FakeClock()
monkeypatch.setattr(coordinator, 'time', fake)
return fake
def _plugin(duration, plugin_id='clock-simple'):
plugin = MagicMock()
plugin.plugin_id = plugin_id
plugin.get_display_duration.return_value = duration
return plugin
def _coord(*plugins):
coord = coordinator.VegasModeCoordinator.__new__(coordinator.VegasModeCoordinator)
coord.render_pipeline = MagicMock()
coord.render_pipeline.get_scroll_position.return_value = 0
coord.display_manager = MagicMock()
locks = {plugin.plugin_id: threading.Lock() for plugin in plugins}
coord.plugin_manager = SimpleNamespace(get_plugin_lock=locks.__getitem__)
coord._state_lock = threading.Lock()
coord._static_pause_active = False
coord._saved_scroll_position = None
coord._should_stop = False
coord._live_priority_active = False
coord._live_priority_check = None
coord._interrupt_check = None
coord.stats = {'static_pauses': 0}
return coord
def _pause(coord, plugin, clock):
"""One static pause: (whether it completed, how long it lasted)."""
start = clock.now
completed = coord._handle_static_pause(plugin)
return completed, clock.now - start
class TestPauseLength:
@pytest.mark.parametrize('value, seconds', NUMBERS)
def test_numbers_and_numeric_strings_are_used(self, clock, value, seconds):
plugin = _plugin(value)
completed, lasted = _pause(_coord(plugin), plugin, clock)
assert completed is True
assert lasted == pytest.approx(seconds, abs=0.15)
@pytest.mark.parametrize('value', NOT_NUMBERS, ids=repr)
def test_anything_but_a_finite_number_pauses_for_30s(self, clock, value):
plugin = _plugin(value)
completed, lasted = _pause(_coord(plugin), plugin, clock)
assert completed is True
assert lasted == pytest.approx(30.0, abs=0.15)
plugin.display.assert_called_once_with(force_clear=True)
@pytest.mark.parametrize('value', NOT_ABOVE_ZERO, ids=repr)
def test_a_number_not_above_zero_pauses_for_15s(self, clock, value):
plugin = _plugin(value)
completed, lasted = _pause(_coord(plugin), plugin, clock)
assert completed is True
assert lasted == pytest.approx(15.0, abs=0.15)
def test_a_raising_get_display_duration_pauses_for_30s(self, clock):
plugin = _plugin(None)
plugin.get_display_duration.side_effect = KeyError('display_duration')
completed, lasted = _pause(_coord(plugin), plugin, clock)
assert completed is True
assert lasted == pytest.approx(30.0, abs=0.15)
def test_a_good_value_after_a_bad_one_is_used(self, clock):
plugin = _plugin(None)
coord = _coord(plugin)
assert _pause(coord, plugin, clock)[1] == pytest.approx(30.0, abs=0.15)
plugin.get_display_duration.return_value = 45
assert _pause(coord, plugin, clock)[1] == pytest.approx(45.0, abs=0.15)
def test_the_pause_can_still_be_interrupted(self, clock):
plugin = _plugin('twenty')
coord = _coord(plugin)
coord._interrupt_check = lambda: clock.now >= 5
completed, lasted = _pause(coord, plugin, clock)
assert completed is False
assert lasted == pytest.approx(5.0, abs=0.15)
class TestWarning:
def test_logged_once_per_plugin(self, clock, caplog):
clock_plugin = _plugin('twenty')
calendar = _plugin(None, plugin_id='calendar')
coord = _coord(clock_plugin, calendar)
with caplog.at_level(logging.WARNING, logger='src.vegas_mode.coordinator'):
for _ in range(3):
for plugin in (clock_plugin, calendar):
coord._handle_static_pause(plugin)
warnings = [r.getMessage() for r in caplog.records
if 'display duration' in r.getMessage()]
assert len(warnings) == 2
assert any('clock-simple' in m and "'twenty'" in m for m in warnings)
assert any('calendar' in m and 'None' in m for m in warnings)
class TestFiniteSeconds:
"""The shared rule: what counts as a number of seconds."""
@pytest.mark.parametrize('value, seconds', NUMBERS + [(0, 0.0), ('-5', -5.0)])
def test_numbers_and_numeric_strings(self, value, seconds):
from src.plugin_system.base_plugin import finite_seconds
result = finite_seconds(value)
assert result == seconds and isinstance(result, float)
@pytest.mark.parametrize('value', NOT_NUMBERS + [pytest.param(10 ** 400, id='10**400')],
ids=repr)
def test_anything_else_is_none(self, value):
from src.plugin_system.base_plugin import finite_seconds
assert finite_seconds(value) is None
def _rotation_seconds(plugin):
"""How long the rotation shows ``plugin`` (no dynamic duration, no
Rotation & Durations override): the two calls run() makes for a screen.
"""
from src.display_controller import DisplayController
dc = object.__new__(DisplayController)
dc.config = {}
dc.plugin_modes = {'mode': plugin}
return dc._resolve_durations(plugin, 'mode', dc._get_display_duration('mode'), False)[1]
class TestSameAsTheRotation:
"""The pause and the rotation share finite_seconds; this pins their
fallbacks (30 s, 15 s) to each other too."""
@pytest.mark.parametrize('value', [value for value, _ in NUMBERS]
+ NOT_NUMBERS + NOT_ABOVE_ZERO, ids=repr)
def test_the_pause_lasts_as_long_as_the_rotation_shows_it(self, clock, value):
plugin = _plugin(value)
expected = _rotation_seconds(plugin)
assert _pause(_coord(plugin), plugin, clock)[1] == pytest.approx(expected, abs=0.15)
def test_a_raise_too(self, clock):
plugin = _plugin(None)
plugin.get_display_duration.side_effect = KeyError('display_duration')
expected = _rotation_seconds(plugin)
assert _pause(_coord(plugin), plugin, clock)[1] == pytest.approx(expected, abs=0.15)
+3 -3
View File
@@ -551,7 +551,7 @@ class TestPluginsAPI:
mock_plugin_catalog.get_all_plugin_info.return_value = [
{'id': 'weather', 'name': 'Weather Plugin'}
]
api_v3.plugin_store_manager.get_registry_info.return_value = None
api_v3.plugin_store_manager.get_cached_registry_info.return_value = None
response = client.get('/api/v3/plugins/installed')
@@ -571,7 +571,7 @@ class TestPluginsAPI:
{'id': 'weather', 'name': 'Weather', 'version': '1.0.0'}
]
# Registry advertises a newer version than the installed one.
api_v3.plugin_store_manager.get_registry_info.return_value = {
api_v3.plugin_store_manager.get_cached_registry_info.return_value = {
'verified': True, 'latest_version': '1.2.0'
}
@@ -592,7 +592,7 @@ class TestPluginsAPI:
mock_plugin_catalog.get_all_plugin_info.return_value = [
{'id': 'weather', 'name': 'Weather', 'version': '1.2.0'}
]
api_v3.plugin_store_manager.get_registry_info.return_value = {
api_v3.plugin_store_manager.get_cached_registry_info.return_value = {
'verified': True, 'latest_version': '1.2.0'
}
+1 -1
View File
@@ -59,7 +59,7 @@ class TestInstalledList:
info = {"id": "demo", "name": "Demo", "version": "1.0.0",
"description": "stale cached copy", "loaded": False}
api.plugin_catalog.get_all_plugin_info = MagicMock(return_value=[info])
api.plugin_store_manager.get_registry_info = MagicMock(return_value=None)
api.plugin_store_manager.get_cached_registry_info = MagicMock(return_value=None)
api.plugin_store_manager._get_local_git_info = MagicMock(return_value=None)
api.config_manager.load_config = MagicMock(return_value={})
+41 -2
View File
@@ -43,7 +43,8 @@ def _url(path):
def test_the_module_directories_hold_modules():
assert {p.name for p in MODULES} >= {"boot.js", "registry.js", "api.js", "facade.js", "cache.js",
"durations.js", "operation-history.js", "raw-json.js",
"backup-restore.js"}
"backup-restore.js", "schedule.js", "general.js",
"visibility.js", "display.js"}
for directory in MODULE_DIRS:
# node needs this to import them in the JS tests; browsers ignore it.
assert '"type": "module"' in (directory / "package.json").read_text(encoding="utf-8")
@@ -104,6 +105,9 @@ CONVERTED = {
"operation-history": ("operation_history.html", "/partials/operation-history"),
"raw-json": ("raw_json.html", "/partials/raw-json"),
"backup-restore": ("backup_restore.html", "/partials/backup-restore"),
"schedule": ("schedule.html", "/partials/schedule"),
"general": ("general.html", "/partials/general"),
"display": ("display.html", "/partials/display"),
}
# Old window.* names that moved into a page module. Each stays as a
@@ -114,6 +118,9 @@ ALIASES = {
"saveMainConfig", "saveSecretsConfig"],
"backup-restore": ["exportBackup", "loadBackupList", "validateRestoreFile",
"clearRestore", "runRestore"],
"schedule": ["handleScheduleResponse", "handleDimScheduleResponse"],
"general": ["webLogin"],
"display": ["updateSyncUI"],
}
@@ -155,12 +162,15 @@ def test_moved_globals_stay_as_aliases(name):
assert f"'{global_name}'" in boot, f"boot.js does not alias {global_name}"
assert re.search(rf"^export (?:function|const) {global_name}\b", module, re.M), (
f"pages/{name}.js does not export {global_name}")
# No template defines them any more.
# No template defines them any more, or calls them (an inline handler
# would reach the page only through the deprecated alias).
for partial in PARTIALS.glob("*.html"):
text = partial.read_text(encoding="utf-8")
for global_name in ALIASES[name]:
assert f"window.{global_name} =" not in text, partial.name
assert f"function {global_name}(" not in text, partial.name
assert not re.search(rf"\b{global_name}\b", text), (
f"{partial.name} still names {global_name}")
def test_every_registered_page_has_its_module_and_partial():
@@ -180,3 +190,32 @@ def test_converted_partials_carry_no_inline_script():
if "data-page=" in text:
assert "<script" not in text.lower(), (
f"{partial.name} is a page module now; its code belongs in js/pages/")
def test_the_schedule_page_reads_its_config_back_intact():
"""schedule.html hands both saved schedules to pages/schedule.js as JSON in
single-quoted data attributes; a value with quotes or markup must neither
end the attribute nor change on the way."""
from html.parser import HTMLParser
import json
import web_interface.app as web_app
from flask import render_template
hostile = {"mode": "per-day", "start_time": "07:00", "note": "it's a \"<b>\" & '</div>"}
class Root(HTMLParser):
attrs = None
def handle_starttag(self, tag, attrs):
if dict(attrs).get("data-page") == "schedule":
self.attrs = dict(attrs)
with web_app.app.test_request_context():
html = render_template("v3/partials/schedule.html", schedule_config=hostile,
dim_schedule_config=None, normal_brightness=90)
parser = Root()
parser.feed(html)
assert parser.attrs is not None
assert json.loads(parser.attrs["data-schedule-config"]) == hostile
# A missing dim schedule arrives as null; the page treats that as {}.
assert json.loads(parser.attrs["data-dim-schedule-config"]) is None
@@ -26,13 +26,44 @@ import pytest
@pytest.fixture
def client():
def client(monkeypatch):
from web_interface import app as web_app
from web_interface.app import app
# The captive-portal before_request hook shells out to systemctl/nmcli
# whenever its 30s cache is cold, so on a Linux host whether a request
# here runs subprocess depended on how long ago the previous one was --
# and several tests below stub subprocess. Pin it: no test in this file
# is about AP mode.
monkeypatch.setattr(web_app, 'is_ap_mode_active', lambda: False)
# GET /plugins/installed looks up each plugin's registry entry, and on a
# cold cache that fetches plugins.json from GitHub -- so without a
# connection those tests sat in the HTTP retry loop. No test in this
# file is about the registry.
monkeypatch.setattr(web_app.plugin_store_manager, 'fetch_registry',
lambda *args, **kwargs: {'plugins': []})
app.config['TESTING'] = True
with app.test_client() as c:
yield c
@pytest.fixture(autouse=True)
def starlark_apps_dir(tmp_path, monkeypatch):
"""Point every Starlark storage path at tmp_path for every test.
The manifest, its directory and the lock file are three separate module
constants. Fixtures that redirected the first two but not the lock left
the lock pointing at the repo, and on Linux (where the lock is taken)
each test created starlark-apps/manifest.json.lock in the checkout. The
directory is not created here; tests that need it make it.
"""
from web_interface.blueprints import api_v3 as module
apps_dir = tmp_path / "starlark-apps"
monkeypatch.setattr(module, '_STARLARK_APPS_DIR', apps_dir)
monkeypatch.setattr(module, '_STARLARK_MANIFEST_FILE', apps_dir / 'manifest.json')
monkeypatch.setattr(module, '_STARLARK_MANIFEST_LOCK_FILE', apps_dir / 'manifest.json.lock')
return apps_dir
class TestRoutesAreRegistered:
"""The failure was a missing route, so check the URL map directly.
@@ -391,13 +422,9 @@ class TestTheManifestSurvivesConcurrentWriters:
"""
@pytest.fixture
def starlark_dir(self, tmp_path, monkeypatch):
from web_interface.blueprints import api_v3 as module
apps_dir = tmp_path / "starlark-apps"
apps_dir.mkdir()
monkeypatch.setattr(module, '_STARLARK_APPS_DIR', apps_dir)
monkeypatch.setattr(module, '_STARLARK_MANIFEST_FILE', apps_dir / 'manifest.json')
return apps_dir
def starlark_dir(self, starlark_apps_dir):
starlark_apps_dir.mkdir()
return starlark_apps_dir
def test_each_writer_gets_its_own_temp_file(self, starlark_dir):
from web_interface.blueprints import api_v3 as module
@@ -553,13 +580,9 @@ class TestTheManifestStaysRelocatable:
"""
@pytest.fixture
def starlark_dir(self, tmp_path, monkeypatch):
from web_interface.blueprints import api_v3 as module
apps_dir = tmp_path / "starlark-apps"
apps_dir.mkdir()
monkeypatch.setattr(module, '_STARLARK_APPS_DIR', apps_dir)
monkeypatch.setattr(module, '_STARLARK_MANIFEST_FILE', apps_dir / 'manifest.json')
return apps_dir
def starlark_dir(self, starlark_apps_dir):
starlark_apps_dir.mkdir()
return starlark_apps_dir
def _install(self, tmp_path):
from web_interface.blueprints import api_v3 as module
@@ -599,13 +622,10 @@ class TestManifestLockPreventsLostUpdates:
"""
@pytest.fixture
def starlark_dir(self, tmp_path, monkeypatch):
def starlark_dir(self, starlark_apps_dir):
from web_interface.blueprints import api_v3 as module
apps_dir = tmp_path / "starlark-apps"
apps_dir = starlark_apps_dir
apps_dir.mkdir()
monkeypatch.setattr(module, '_STARLARK_APPS_DIR', apps_dir)
monkeypatch.setattr(module, '_STARLARK_MANIFEST_FILE', apps_dir / 'manifest.json')
monkeypatch.setattr(module, '_STARLARK_MANIFEST_LOCK_FILE', apps_dir / 'manifest.json.lock')
module._write_starlark_manifest({'apps': {}})
return apps_dir
@@ -693,12 +713,10 @@ class TestConfigAndManifestStayInSync:
"""
@pytest.fixture
def app_dir(self, tmp_path, monkeypatch):
def app_dir(self, starlark_apps_dir):
from web_interface.blueprints import api_v3 as module
apps_dir = tmp_path / "starlark-apps"
apps_dir = starlark_apps_dir
apps_dir.mkdir()
monkeypatch.setattr(module, '_STARLARK_APPS_DIR', apps_dir)
monkeypatch.setattr(module, '_STARLARK_MANIFEST_FILE', apps_dir / 'manifest.json')
one_app_dir = apps_dir / 'demo'
one_app_dir.mkdir()
module._write_starlark_manifest({'apps': {'demo': {'name': 'Demo', 'enabled': True}}})
@@ -1042,13 +1060,18 @@ class TestTheStoreUsesTheTokenTheUserConfigured:
/plugins/store/github-status reported `authenticated: true` with a
rate_limit of 5000 while /starlark/repository/browse reported a limit of
60 -- the store going blank was that 60 running out.
The managers are attributes web_interface/app.py hangs on the blueprint
when it is imported, so they exist only once some earlier test has
imported the app. Every patch here passes create=True: these tests must
not depend on which test ran before them.
"""
def test_the_store_managers_token_is_used(self):
from web_interface.blueprints import api_v3 as mod
with patch.object(mod.api_v3, 'plugin_store_manager',
MagicMock(github_token='ghp_configured')):
MagicMock(github_token='ghp_configured'), create=True):
assert mod._starlark_github_token() == 'ghp_configured'
def test_a_hand_edited_config_key_still_works(self):
@@ -1057,8 +1080,8 @@ class TestTheStoreUsesTheTokenTheUserConfigured:
cfg = MagicMock()
cfg.load_config.return_value = {'github_token': 'ghp_by_hand'}
with patch.object(mod.api_v3, 'plugin_store_manager',
MagicMock(github_token=None)), \
patch.object(mod.api_v3, 'config_manager', cfg):
MagicMock(github_token=None), create=True), \
patch.object(mod.api_v3, 'config_manager', cfg, create=True):
assert mod._starlark_github_token() == 'ghp_by_hand'
def test_no_token_anywhere_is_not_an_error(self):
@@ -1067,8 +1090,8 @@ class TestTheStoreUsesTheTokenTheUserConfigured:
cfg = MagicMock()
cfg.load_config.return_value = {}
with patch.object(mod.api_v3, 'plugin_store_manager',
MagicMock(github_token=None)), \
patch.object(mod.api_v3, 'config_manager', cfg):
MagicMock(github_token=None), create=True), \
patch.object(mod.api_v3, 'config_manager', cfg, create=True):
assert mod._starlark_github_token() is None
def test_an_unreadable_config_does_not_take_the_store_down(self):
@@ -1077,8 +1100,8 @@ class TestTheStoreUsesTheTokenTheUserConfigured:
cfg = MagicMock()
cfg.load_config.side_effect = OSError("config.json is unreadable")
with patch.object(mod.api_v3, 'plugin_store_manager',
MagicMock(github_token=None)), \
patch.object(mod.api_v3, 'config_manager', cfg):
MagicMock(github_token=None), create=True), \
patch.object(mod.api_v3, 'config_manager', cfg, create=True):
assert mod._starlark_github_token() is None
def test_browse_hands_the_token_to_the_repository(self, client):
@@ -1092,7 +1115,7 @@ class TestTheStoreUsesTheTokenTheUserConfigured:
repo.return_value.get_rate_limit_info.return_value = {'remaining': 4999}
with patch.object(mod.api_v3, 'plugin_store_manager',
MagicMock(github_token='ghp_configured')), \
MagicMock(github_token='ghp_configured'), create=True), \
patch('web_interface.blueprints.api_v3._get_tronbyte_repository_class',
return_value=repo):
client.get('/api/v3/starlark/repository/browse')
@@ -1136,12 +1159,23 @@ class TestPixletEditorHostDefaultsButDoesNotOverride:
captured['env'] = env
return FakeProcess()
# Swap the route module's own ``subprocess`` binding, not the shared
# ``subprocess.Popen``: patching the attribute on the real module is
# process-wide, and the app's before_request hook (the captive-portal
# check) runs ``subprocess.run`` -- ``with Popen(...)`` -- whenever its
# 30s AP-mode cache is cold on a host with systemctl. On the Linux CI
# runner that handed it this FakeProcess and 500'd the request, but
# only when the previous request was more than 30s earlier.
fake_subprocess = types.ModuleType('subprocess')
fake_subprocess.__dict__.update(mod.subprocess.__dict__)
fake_subprocess.Popen = fake_popen
with patch.object(mod, '_validate_starlark_app_path',
return_value=(app_dir, None)), \
patch.object(mod, '_PIXLET_EDITOR_SCRIPT', script), \
patch.object(mod, '_PIXLET_EDITOR_STATE', state_file), \
patch.object(mod, '_find_pixlet_binary', return_value='/usr/bin/pixlet'), \
patch.object(mod.subprocess, 'Popen', side_effect=fake_popen), \
patch.object(mod, 'subprocess', fake_subprocess), \
patch.dict(os.environ):
if operator_host is None:
os.environ.pop('PIXLET_EDITOR_HOST', None)
@@ -1171,15 +1205,13 @@ class TestStandaloneRenderUsesTheDeviceLocation:
SCHEMA = {"schema": [{"typeOf": "location", "id": "location"}]}
@pytest.fixture
def app_dir(self, tmp_path, monkeypatch):
def app_dir(self, starlark_apps_dir, monkeypatch):
from web_interface.blueprints import api_v3 as module
apps_dir = tmp_path / "starlark-apps"
apps_dir = starlark_apps_dir
app_dir = apps_dir / "weather"
app_dir.mkdir(parents=True)
(app_dir / "weather.star").write_text("# app")
(app_dir / "schema.json").write_text(json.dumps(self.SCHEMA))
monkeypatch.setattr(module, '_STARLARK_APPS_DIR', apps_dir)
monkeypatch.setattr(module, '_STARLARK_MANIFEST_FILE', apps_dir / 'manifest.json')
(apps_dir / 'manifest.json').write_text(json.dumps(
{'apps': {'weather': {'star_file': 'weather.star'}}}))
config_manager = MagicMock()
@@ -128,6 +128,7 @@ class Web:
store = api.plugin_store_manager
store.plugins_dir = str(self.plugins_dir)
store.get_registry_info.return_value = None
store.get_cached_registry_info.return_value = None
store.get_plugin_info.return_value = None
store._get_local_git_info.return_value = None
store.install_plugin.return_value = True
@@ -221,7 +222,9 @@ class TestTheWebProcessNeverRunsAPlugin:
def test_saving_its_section_through_the_main_config(self, web):
body = web.post("/api/v3/config/main", {PLUGIN_ID: {"message": "via main"}})
assert web.stored()["message"] == "via main"
assert body["restart_required"] is True
# The display's config watcher hands the section to the running
# plugin (on_config_change), as for /plugins/config: no restart.
assert body["restart_required"] is False
assert web.ran() == []
def test_resetting_its_config(self, web):
+84 -11
View File
@@ -17,6 +17,8 @@ from src.pi5_matrix_support import is_raspberry_pi_5
from web_interface.cache import invalidate_cache
from web_interface.auth import SECTION as _WEB_AUTH_SECTION, strip_auth_section
import web_interface.blueprints.api_v3 as _pkg
import copy
from typing import Any, Dict, Iterable, Tuple
# Read through the module rather than bound by value: tests patch these
# as module attributes, and a value binding would not see the patch.
@@ -33,6 +35,50 @@ FORM_SECTION_FIELD = '__form_section'
GENERAL_FIELDS = ('timezone', 'city', 'state', 'country', 'web_display_autostart',
'plugins_directory', 'auto_update_enabled', 'auto_update_channel')
#: Settings in config.json the running display applies without a restart,
#: as key paths (a path covers everything under it). Brightness goes over the
#: control socket (brightness.set) and the config watcher's refresh
#: (DisplayController._refresh_config_cache); the per-mode durations are read
#: from the live config each time a mode starts (_get_display_duration).
#: Plugin sections are live as well (each plugin's on_config_change), and
#: save_main_config adds the ones a request saves.
LIVE_CONFIG_PATHS: Tuple[Tuple[str, ...], ...] = (
('display', 'hardware', 'brightness'),
('display', 'display_durations'),
)
_MISSING = object()
def _config_leaves(config: Any, prefix: Tuple[str, ...] = ()) -> Dict[Tuple[str, ...], Any]:
"""Every non-dict value in ``config``, by key path. An empty dict has none,
so a section created empty on the way to a field is not a change."""
if not isinstance(config, dict):
return {prefix: config}
leaves: Dict[Tuple[str, ...], Any] = {}
for key, value in config.items():
leaves.update(_config_leaves(value, prefix + (str(key),)))
return leaves
def restart_needed(before: Dict[str, Any], after: Dict[str, Any],
live_paths: Iterable[Tuple[str, ...]] = LIVE_CONFIG_PATHS) -> bool:
"""Does going from config ``before`` to ``after`` need a display restart?
True when anything changed outside ``live_paths``. A save that changes
only live settings, or nothing at all, does not.
"""
live = tuple(live_paths)
old, new = _config_leaves(before), _config_leaves(after)
for path in set(old) | set(new):
if old.get(path, _MISSING) == new.get(path, _MISSING):
continue
if not any(path[:len(prefix)] == prefix for prefix in live):
return True
return False
#: Top-level fields save_main_config stores somewhere of its own (location,
#: plugin_system, ...), never as a config key of the same name.
_MAPPED_TOP_LEVEL_FIELDS = GENERAL_FIELDS + (
@@ -72,6 +118,22 @@ def _day_setting(data, day, flat_key, nested_key):
return False, None
def _disabled_day_times(data, day, start_key, end_key):
"""The times posted for a day that is off, the valid ones.
Nothing reads them while the day is off, but the schedule picker posts
them and GET returns them, so keeping them means turning the day back on
finds what was there. An invalid one is dropped rather than refused, for
the same reason.
"""
times = {}
for field, key in (('start_time', start_key), ('end_time', end_key)):
value = _day_setting(data, day, key, field)[1]
if value and _validate_time_format(value)[0]:
times[field] = value
return times
@api_v3.route('/config/main', methods=['GET'])
def get_main_config():
"""Get main configuration, with credentials redacted."""
@@ -257,11 +319,16 @@ def save_schedule_config():
day_config['start_time'] = start_time
day_config['end_time'] = end_time
else:
day_config.update(_disabled_day_times(data, day, start_key, end_key))
schedule_config['days'][day] = day_config
# Validate that at least one day is enabled in per-day mode
if enabled_days_count == 0:
# An enabled per-day schedule needs a day to be on. A disabled
# one does not: every day off with the schedule off is what
# config.template.json ships, so refusing it meant a fresh
# install could not post back the schedule GET returned.
if enabled_days_count == 0 and enabled_value:
return error_response(
ErrorCode.VALIDATION_ERROR,
"At least one day must be enabled in per-day schedule mode",
@@ -465,11 +532,13 @@ def save_dim_schedule_config():
day_config['start_time'] = start_time
day_config['end_time'] = end_time
else:
day_config.update(_disabled_day_times(data, day, start_key, end_key))
dim_schedule_config['days'][day] = day_config
# Validate that at least one day is enabled in per-day mode
if enabled_days_count == 0:
# As for the on/off schedule: only an enabled one needs a day on.
if enabled_days_count == 0 and enabled_value:
return error_response(
ErrorCode.VALIDATION_ERROR,
"At least one day must be enabled in per-day dim schedule mode",
@@ -557,6 +626,8 @@ def save_main_config():
# Merge with existing config (similar to original implementation)
current_config = api_v3.config_manager.load_config()
# What was stored, to tell which settings this save changed.
stored_config = copy.deepcopy(current_config)
was_auto_update_enabled = bool((current_config.get('auto_update') or {}).get('enabled'))
is_general_update = any(k in data for k in GENERAL_FIELDS)
@@ -1194,13 +1265,15 @@ def save_main_config():
message = f'{message}. {note}'
except Exception:
logger.warning("Automatic update setup could not be started", exc_info=True)
# Display hardware, rotation/durations and general settings take
# effect after a display restart; the UI shows its restart banner on
# this flag.
extra = {'restart_required': True}
# Brightness is the exception: the display applies a saved one
# without a restart. Over the control socket it lands at once,
# instead of when the config watcher next looks (up to ~2 s).
# Display hardware, rotation order and general settings take effect
# after a display restart; the UI shows its restart banner on this
# flag. Brightness, mode durations and plugin settings are applied by
# the running display (LIVE_CONFIG_PATHS), so a save that changed
# only those -- or nothing -- does not ask for one.
live_paths = LIVE_CONFIG_PATHS + tuple((plugin_id,) for plugin_id in plugin_keys_to_remove)
extra = {'restart_required': restart_needed(stored_config, current_config, live_paths)}
# Over the control socket a saved brightness lands at once, instead
# of when the config watcher next looks (up to ~2 s).
if 'brightness' in data:
saved = (current_config.get('display', {}).get('hardware', {}) or {}).get('brightness')
if isinstance(saved, int) and not isinstance(saved, bool):
+61 -16
View File
@@ -33,16 +33,28 @@ def _cache_manager():
class _NotDelivered(Exception):
"""The display took an on-demand request over the socket and did not
accept it (``busy``, ``invalid_args``, ...) or did not answer in time."""
def __init__(self, reason):
super().__init__(reason)
self.reason = reason
def _deliver_on_demand(payload):
"""Hand an on-demand request to the display: control socket, else mailbox.
The socket (src/ipc) answers with an acknowledgement as soon as the
display has the command queued for its render thread. Any failure -- no
socket (the display is stopped or predates it), a timeout, a refusal --
writes the file mailbox instead, exactly as before the socket existed;
the display reads it within ON_DEMAND_POLL_INTERVAL. Both carry the same
request_id, so a request that reached the display both ways (a reply
that timed out after the command was queued) is still processed once.
display has the command queued for its render thread. The file mailbox
is written only when the socket could not carry the request at all
(``control_client.should_fall_back``): no socket (the display is stopped
or predates it), a refused connection, or a display too old to know the
command. The display reads it within its mailbox poll interval.
A display that had the request and refused it, or did not answer in
time, raises :class:`_NotDelivered`: a mailbox copy would be refused
the same way, or hide a stuck display behind a "success".
Returns ``(transport, socket_error)``: ``'socket'`` and None, or
``'mailbox'`` and the socket failure's reason code.
@@ -57,6 +69,10 @@ def _deliver_on_demand(payload):
return 'socket', None
except control_client.ControlError as e:
reason = _socket_reason_code(e.reason)
if not control_client.should_fall_back(e):
logger.warning("The display did not accept on-demand %s %s (%s)",
payload['action'], payload['request_id'], e)
raise _NotDelivered(reason) from None
if reason in _QUIET_SOCKET_REASONS:
logger.debug("On-demand %s via the mailbox: %s", payload['action'], e)
else:
@@ -69,6 +85,17 @@ def _deliver_on_demand(payload):
return 'mailbox', reason
def _not_delivered_response(request_id, action, reason):
"""The answer when the display had the request and did not accept it."""
status = 400 if reason == 'invalid_args' else 503
return jsonify({
'status': 'error',
'message': (f'The display service did not accept the on-demand {action} '
f'request ({reason})'),
'data': {'request_id': request_id, 'transport': 'socket', 'socket_error': reason},
}), status
def _withdraw_on_demand(request_id):
"""Take a start request the route has refused back out of the mailbox.
@@ -277,7 +304,10 @@ def start_on_demand_display():
'pinned': pinned,
'timestamp': _pkg.time.time()
}
transport, socket_error = _deliver_on_demand(request_payload)
try:
transport, socket_error = _deliver_on_demand(request_payload)
except _NotDelivered as e:
return _not_delivered_response(request_id, 'start', e.reason)
# A socket acknowledgement is the display itself answering: it is
# running and has the request queued, whatever systemd says (a display
@@ -312,9 +342,10 @@ def start_on_demand_display():
# MQTT on-demand command, which posts here with the default -- cold-
# restarted the display process: every plugin reloaded and the panel was
# blank for seconds. The restart bought nothing. The running process
# reads this mailbox every ON_DEMAND_POLL_INTERVAL (0.25s), from its
# dwell sleep, its render loops and Vegas's interrupt check as well as
# the main loop, and a restarted one got the request the same way: the
# looks at this mailbox at least once a second, from its dwell sleep,
# its render loops and Vegas's interrupt check as well as the main loop
# (DisplayController._mailbox_poll_interval), and a restarted one got
# the request the same way: the
# startup path only restores a session the display itself saved
# (display_on_demand_config), so it loaded nothing it would not have had.
service_result = None
@@ -365,7 +396,14 @@ def stop_on_demand_display():
'action': 'stop',
'timestamp': _pkg.time.time()
}
transport, socket_error = _deliver_on_demand(request_payload)
try:
transport, socket_error = _deliver_on_demand(request_payload)
except _NotDelivered as e:
if not stop_service:
return _not_delivered_response(request_id, 'stop', e.reason)
# Stopping the service ends on-demand too, whatever the display did
# with the request.
transport, socket_error = 'socket', e.reason
service_result = None
if stop_service:
@@ -386,15 +424,22 @@ def get_current_display_status():
Read from the display's state stream over the control socket when it is
available (``source: "socket"``). Otherwise from what the display
publishes to the shared cache (display_controller._publish_current_mode_state)
when the active mode changes (``source: "cache"``).
when the active mode changes (``source: "cache"``). Unknown (every field
None) when the socket and the heartbeat both say the display is gone
(display_state.display_gone).
"""
state = display_state.current_status(display_state.read_state())
snapshot = display_state.read_state()
state = display_state.current_status(snapshot)
source = 'socket'
if state is None:
source = 'cache'
cache = _cache_manager()
# memory_ttl=0: written by the display service; see get_on_demand_status.
state = cache.get('display_current_state', max_age=120, memory_ttl=0)
# A stopped display leaves its last answer in the cache, where it
# read as on (is_display_active: true) for the 120 s max_age. With
# no socket and no live heartbeat there is no display behind it.
if not display_state.display_gone(snapshot):
cache = _cache_manager()
# memory_ttl=0: written by the display service; see get_on_demand_status.
state = cache.get('display_current_state', max_age=120, memory_ttl=0)
if state is None:
state = {
'mode': None,
+55 -6
View File
@@ -102,6 +102,7 @@ def get_health():
# the only signal, as it always was.
# The display reports the same beat's age over the control socket's
# state stream, measured in memory; the file is the fallback.
snapshot = None
try:
snapshot = display_state.read_state()
if snapshot is not None:
@@ -132,6 +133,26 @@ def get_health():
'error': 'see logs for details'
}
# A stopped display service. The heartbeat's absence alone says
# nothing (the dev server, the emulator and Windows write none), so
# the overall status stayed "healthy" with the display down until the
# last preview frame it left aged past 60 s (hardware: stale). Together
# the three signals are definite: systemd says the service is not
# active, the control socket does not answer, and there is no live
# heartbeat (display_state.display_gone, which is never true where
# the platform has no socket or it is switched off).
try:
if (not display_service_status.get('active')
and display_state.display_gone(snapshot)):
health_status['checks']['display_loop'] = {
'status': 'stopped',
'note': 'The display service is not running',
'source': 'service',
}
except Exception:
logger.warning("Health check could not tell whether the display is stopped",
exc_info=True)
# Check hardware connectivity (if display manager available)
try:
snapshot_path = display_preview.SNAPSHOT_PATH
@@ -353,6 +374,23 @@ def _read_errors():
return snapshot, clear_request
def _send_error_clear(request_id, cutoff):
"""``errors.clear`` over the control socket: the display's answer, or None
when the socket could not carry it (no socket, or a display older than
the command) and the clear goes to the mailbox instead. A display that
took the request and failed raises ControlError (no second copy)."""
client = _pkg.control_client
try:
return client.errors_clear(request_id, cutoff)
except client.ControlError as e:
if not client.should_fall_back(e):
raise
_pkg._log_socket_failure('errors.clear', e, _pkg._socket_reason_code(e.reason))
except Exception: # never let the socket path break the route
logger.exception("Control socket client failed clearing errors; using the mailbox")
return None
@api_v3.route('/errors/summary', methods=['GET'])
def get_error_summary():
"""
@@ -450,7 +488,17 @@ def clear_old_errors():
now = _pkg.time.time()
cutoff = now if clear_all else now - max_age_hours * 3600
try:
result = _errors.request_error_clear(_errors_cache(), cutoff)
result = _errors.request_error_clear(_errors_cache(), cutoff,
send=_send_error_clear)
except _pkg.control_client.ControlError as e:
reason = _pkg._socket_reason_code(e.reason)
logger.warning("The display did not apply the error clear: %s", e)
return error_response(
error_code=ErrorCode.SYSTEM_ERROR,
message="The display service did not apply the clear",
context={'socket_error': reason},
status_code=503
)
except OSError as e:
logger.error("Could not record an error clear request: %s", e)
return error_response(
@@ -460,11 +508,12 @@ def clear_old_errors():
)
scope = "all errors" if clear_all else f"errors older than {max_age_hours} hours"
return success_response(
data=result,
message=(f"Clear of {scope} requested; the display service applies it "
f"within about {int(_errors.SNAPSHOT_TICK_INTERVAL)} seconds")
)
if result.get('applied'):
message = f"Cleared {scope}"
else:
message = (f"Clear of {scope} requested; the display service applies it "
f"within about {int(_errors.SNAPSHOT_TICK_INTERVAL)} seconds")
return success_response(data=result, message=message)
except Exception as e:
logger.error(f"Error clearing old errors: {e}", exc_info=True)
return error_response(
@@ -530,13 +530,11 @@ def install_plugin():
)
branch_msg = f" (branch: {branch})" if branch else ""
# plugin_id: the id to enable it by, and the id its config
# section is under (see _installed_plugin_id).
installed_id = _installed_plugin_id(plugin_id)
# plugin_id: the id to enable it by (see _installed_plugin_id).
return {'success': True,
'message': f'Plugin {plugin_id} installed successfully{branch_msg}',
'plugin_id': installed_id,
**_store_restart_fields('install', _plugin_enabled_in_config(installed_id))}
'plugin_id': _installed_plugin_id(plugin_id),
**_store_restart_fields('install', _plugin_enabled_in_config(plugin_id))}
else:
error_msg = f'Failed to install plugin {plugin_id}'
if branch:
@@ -590,11 +588,10 @@ def install_plugin():
)
branch_msg = f" (branch: {branch})" if branch else ""
installed_id = _installed_plugin_id(plugin_id)
return success_response(
message=f'Plugin installed successfully{branch_msg}',
extra={'plugin_id': installed_id,
**_store_restart_fields('install', _plugin_enabled_in_config(installed_id))})
extra={'plugin_id': _installed_plugin_id(plugin_id),
**_store_restart_fields('install', _plugin_enabled_in_config(plugin_id))})
else:
error_msg = f'Failed to install plugin {plugin_id}'
if branch:
+7 -2
View File
@@ -106,8 +106,13 @@ def get_installed_plugins():
plugin_config = {}
enabled = bool(plugin_config.get('enabled', False))
# Verified + latest published version from registry (no network call)
store_info = api_v3.plugin_store_manager.get_registry_info(plugin_id)
# Verified + latest published version from the registry copy already
# in memory. Never a fetch: on a cold cache get_registry_info would
# download plugins.json, and offline wait out its timeout and retries
# for every plugin. With no copy yet these are absent (no update or
# verified badge) and a background refresh fills them in for a
# later load.
store_info = api_v3.plugin_store_manager.get_cached_registry_info(plugin_id)
verified = store_info.get('verified', False) if store_info else False
latest_version = store_info.get('latest_version', '') if store_info else ''
installed_version = plugin_info.get('version', '')
+35 -2
View File
@@ -133,6 +133,39 @@ def on_demand_state(snapshot: Optional[Dict[str, Any]],
return state
def display_gone(snapshot: Optional[Dict[str, Any]]) -> bool:
"""Is there positively no display behind a fallback to the cache?
True only when the socket should be there (this platform has one and it
is not switched off) but gave no ``snapshot``, and the render loop's
heartbeat file says nothing is running either: it is absent (systemd
removes its directory when the service stops), stale, or written by a
process that no longer exists -- #726's rules for the runtime snapshot.
Then what the display last left in the cache is a dead process's answer.
False whenever the answer is in doubt: a snapshot came in, the socket is
off or unsupported (Windows, the test suite, a deliberate ``off``), or a
live heartbeat says the display is running without a socket (an older
display). Those read the cache exactly as before.
"""
if snapshot is not None:
return False
if not socket_supported() or not client_socket_paths():
return False
from src import display_watchdog
from src.plugin_system.plugin_runtime import process_exists
heartbeat = display_watchdog.read_heartbeat(display_watchdog.HEARTBEAT_PATH)
if heartbeat is None:
return True
age = display_watchdog.heartbeat_age(heartbeat)
if age is None or age >= display_watchdog.HEARTBEAT_STALE_SECONDS:
return True
pid = heartbeat.get('pid')
if isinstance(pid, int) and not isinstance(pid, bool) and process_exists(pid) is False:
return True
return False
def loop_heartbeat_age(snapshot: Optional[Dict[str, Any]]) -> Optional[float]:
"""The render loop's heartbeat age now; None when the display has no
beat to report yet (or there is no snapshot)."""
@@ -141,5 +174,5 @@ def loop_heartbeat_age(snapshot: Optional[Dict[str, Any]]) -> Optional[float]:
return control_client.snapshot_loop_age(snapshot)
__all__ = ['current_status', 'loop_heartbeat_age', 'on_demand_state', 'read_state',
'stop_subscription']
__all__ = ['current_status', 'display_gone', 'loop_heartbeat_age', 'on_demand_state',
'read_state', 'stop_subscription']

Some files were not shown because too many files have changed in this diff Show More