Compare commits

..
Author SHA1 Message Date
ChuckBuildsandClaude Opus 5.5 00456ebe99 perf(fetch): cache ESPN scoreboard windows without the parts nothing reads
The sports scoreboards cache their Recent/Upcoming window as the raw ESPN
response, and it stays parsed in the memory tier while fresh. Measured on
hdpi, most of it is never drawn: per-team stat leaders, athlete cards
(featuredAthletes, probables), team and event links, headlines, video
highlights and geo broadcasts. None of those keys is read by core or by any
plugin in ledmatrix-plugins.

BackgroundDataService now drops them from an ESPN /scoreboard response
before caching and delivering it (src/common/espn_payload.py), keeping
everything else. On the five hdpi windows that is 10.6MB -> 3.0MB of JSON
and ~40MB -> ~12MB of parsed objects, and parsing an expired window gets
3-4x cheaper. submit_fetch_request(slim_payload=False) caches a response
whole.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BkfgXMqqwn2w4NN7LRzhxy
2026-10-03 22:25:09 -04:00
136 changed files with 2050 additions and 14059 deletions
+12 -561
View File
@@ -19,347 +19,6 @@ 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
@@ -798,16 +457,6 @@ 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
@@ -856,54 +505,6 @@ 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.
@@ -1017,57 +618,6 @@ policies are unchanged.
the plugin leaves rotation until the cooldown ends, the same as a raising
`update()`. The display still moves straight on to the next mode. A hung
`display()` is still recorded once, as a hang.
- A plugin settings save that failed validation no longer leaks into the next
save. `ConfigManager.load_config()` returned its cached config itself (the
fast path from #410), so the form save's edits went into the cache before
validation ran, and a refused save left them there. The next save of any
other setting (another plugin's, a plugin toggle, the schedule) wrote them
to config.json: the refused value, and a nested secret typed into the same
form (`mqtt.password`, `league.espn_s2`, `flightaware.api_key`) in plain
text, because it had never reached config_secrets.json to be stripped.
The form also reloaded showing the refused values. `load_config()` now
returns a private copy, and the saves keep one, so nothing a caller edits
reaches the cache unless it is saved. The copy duplicates only the dicts
and lists (every other JSON value is immutable): 2.1 ms for a real 60 KiB
config on a Pi 4, against 6.8 ms for `copy.deepcopy`.
- `GET /api/v3/plugins/config` no longer returns secrets. It sent back the
plugin's section with config_secrets.json merged in, API keys and tokens
in plain text: the masking #276 added was dropped in #330. It also took
any id, so `?plugin_id=web_auth` returned the login's cookie-signing key
and password hash and `?plugin_id=github` the Plugin Store token. Secret
fields now come back blank, as the settings page renders them, and a
plugin with no schema has its credential-named fields blanked, as
`GET /config/main` does. Blank rather than the `••••••••` of
`GET /config/secrets`, because the save reads a blank secret as
"unchanged", so a client can post the response back without erasing
one. Core sections and malformed ids get a 400, as they already did from
reset and uninstall.
- Plugin settings with a table (a list of rows, such as geochron's cities
or the countdowns) save again when a text cell is blank or holds only
digits. A row posts its cells as `cities.0.timezone`, and the schema
lookup stopped at the list, so each cell was parsed with no schema: a
blank optional text cell became null, and a name like "2027" became a
number. Either failed validation, and every save of the page failed for
as long as the row existed. A plugin with a secret in its rows could not
be saved from the page at all, since the secret cell is drawn blank. The
lookup now steps from the index into the list's item schema.
- A plugin whose API key is required and has no default (youtube-stats)
can be saved from its settings page without typing the key in again. The
page draws a stored secret blank and posts the blank back; for a required
secret the save read that blank as null, failed validation, and refused
every save of the page. A blank secret field now means "unchanged", as it
already did for an optional one.
- `POST /api/v3/plugins/config` refuses a core section or a malformed
plugin id with a 400, as reset and uninstall already did.
`{"plugin_id": "display", ...}` merged unvalidated values into the core
display section (and added `"enabled": true` to it), and an id that was
not a string answered with a 500.
- A plugin text setting saves what was typed when that looks like a
boolean or JSON. The form save tried `true`/`false` and `[...]`/`{...}`
before it looked at the schema, so a text field holding "true", "False",
"[1, 2]" or "{}" was stored as a boolean, list or object, and the save
failed validation. Text fields, nullable ones included, are now taken as
typed; other types convert as before.
- A WiFi notice (such as "Connected to HomeNet" or "AP mode on") now shows
within about a second of being posted. It was only checked between
screens, so a 5 s notice posted during a 20 s screen expired before that
@@ -1076,42 +626,6 @@ policies are unchanged.
notice is what shows next, and Vegas resumes after it; before, a rotation
screen showed instead and the notice expired behind it. An active
on-demand session still holds the panel until it ends.
- The Config Editor tab no longer shows API keys and tokens in plain
text. Its `config_secrets.json` editor (`/partials/raw-json`) was filled
with the file as it is on disk, so while the web login is off (the
default) anyone who could reach the port could read every credential,
although `GET /api/v3/config/secrets` masks them. The editor now shows the
same masked values. Saving it unchanged changes nothing, because the save
drops the masks and merges onto the stored file; to change a secret,
replace its mask. A list of secrets still needs every entry's real value
to be changed. The `config.json` editor is unchanged: its save writes the
file as given, so a mask there would be stored.
- A disabled plugin keeps its place in the rotation order and its Vegas
exclusion when the Display or Rotation & Durations tab is saved. The order
lists show enabled plugins only and rewrite their hidden inputs from those
rows as soon as they are drawn, so any save of either tab stored the lists
without the disabled plugin. Once re-enabled, it came back at the end of
the rotation and scrolling in Vegas again. A disabled plugin's saved id
now stays in its saved place (`widgets/plugin-order-list.js`); the id of
a plugin that is no longer installed is still dropped.
- Restoring a backup with "Reinstall missing plugins" installs only the
plugins that are missing. Every plugin the backup listed was sent to the
store's install, which replaces an installed copy with a fresh download,
so a restore onto the same device re-downloaded all of them in one
request. A plugin installed from its own URL is not in the registry, so
its "reinstall" failed and the restore answered "Restore failed" while
the plugin sat there installed. An installed plugin, found by the store's
own lookup (registry aliases included), is now listed under Skipped as
`plugin:<id> (installed)`.
- `POST /api/v3/config/main` answers a JSON body that does not parse with
400 `Invalid JSON in request body`, as `/config/raw/main` does, and an
empty JSON body with 400 `No data provided`. Both were a 500
`CONFIG_SAVE_FAILED` suggesting file permissions and disk space, with a
traceback logged at ERROR: `get_json()` raised inside the handler's
catch-all.
- Fonts restored from a backup show up in the Fonts tab and the font
pickers straight away. The font catalog is cached for five minutes, and
upload and delete cleared it but a restore did not.
- A game that goes live now takes over the panel within about a second.
Live priority was only checked between screens, so a game that went live
during a 30 s screen waited for that screen to end. The frame loops and the
@@ -1121,45 +635,6 @@ policies are unchanged.
screen showed first and the game came after it. Each check also asks each
plugin `has_live_content()` once, where a plugin registered under several
modes used to be asked once per mode.
- A plugin action whose params hold `true`, `false` or `null` runs again.
`/api/v3/plugins/action` wrote the params into the source of the wrapper
that runs the plugin's script, and those JSON words are not Python, so the
wrapper stopped with a NameError and the action answered "Action failed".
The plugin file manager's category toggle sends `"enabled": true`, so
turning a category on or off in of-the-day always failed. The params now
reach the wrapper on its stdin; the script still receives them as JSON on
its own stdin, as before.
- An on-demand request that `/api/v3/display/on-demand/start` refuses no
longer runs later. With the display stopped the request goes to the
display's mailbox, and the display reads that mailbox for an hour without
looking at a request's age. So with "Start display service" unticked, the
answer was "Display service is not running", yet the next time the
display was started it ran that plugin, pinned if the request said so.
The same happened after "Failed to start display service". On either
refusal the route now takes its request back out of the mailbox, unless a
newer one has replaced it. A request the display acknowledges over the
control socket is now a success whatever systemd reports: a display run
by hand or in the emulator was told "not running" for a request it had
already taken, and with "Start display service" ticked the route tried to
start the service beside it.
- `/api/v3/plugins/operation/<id>` reports a queued operation as `pending`
instead of answering 500. The queue keeps an operation's callback among
its parameters until it runs, and the status route tried to send that
function as JSON. An install queued behind another plugin's install
failed every status poll until the first one finished. Parameters whose
name starts with `_` are internal and are no longer in the answer.
- A second click on Install while that plugin is still installing, or an
Uninstall during its install, now answers 409 "already has an install,
update or uninstall in progress" instead of 500 "An error occurred". The
first operation carried on either way. The uninstall route also stopped
recording a failed uninstall in the operation history for an uninstall
that never started.
- `/api/v3/plugins/<plugin_id>/static/<path>` serves images and other
binary files. It opened every file as UTF-8 text, so a plugin's icon or
preview image answered 500 `UnicodeDecodeError`. Files are now sent as
they are on disk, an image with its own content type; HTML, JavaScript,
CSS, JSON and other text keep the types they had. The path checks are
unchanged.
- The display schedule turns the panel off at exactly the end time. A window
now runs from its start time up to, but not including, its end time: with
07:00-23:00 the panel is on at 07:00 and off at 23:00. Before, the end
@@ -1167,31 +642,6 @@ policies are unchanged.
the panel went off at 23:00 or at 23:01 depending on when in the minute
that check ran. Windows that cross midnight and per-day schedules follow
the same rule, and so does the dim schedule.
- The MQTT bridge settings on the Tools tab can save a broker password with
TLS off. The server refuses that unless `allow_insecure_mqtt` is set, and
the form had no way to set it, so a password-protected broker on a home
network without TLS could not be saved from the web UI, and once such a
password was stored every later save failed too. While "Use TLS" is
unchecked the form now shows "Allow without TLS (trusted network)",
prefilled from the saved settings. It is off until ticked, so the server
still refuses a cleartext password by default.
- The Overview's plugin-config warning check stops polling. It asked
`/api/v3/plugins/reconciliation-status` every 2 s until startup
reconciliation reported done, and the route reports not done whenever its
status file is missing: reconciliation raised before writing it, or /tmp
was cleaned under a long-running web service. The page then sent that
request every 2 s for as long as it stayed open, whichever tab was showing.
It now gives up after a minute and only polls while the Overview is on
screen.
- Moving the Brightness slider on the Display tab no longer throws an error
in the browser console on every step. Its handler also updated a "LED
brightness" line that was removed from the page in #387; the lookup is
gone.
- Creating an API token on the General tab no longer leaves the page asking
"Leave site?" on reload. The unsaved-changes guard marks a form when you
type in it and clears the mark only after an htmx save, and the token form
saves with a plain request, so it stayed marked after the token was
created. It is cleared once the token is saved.
- An on-demand session that ends during scheduled-off hours, by expiring or
being stopped, blanks the panel within about a second. It used to stay on
until the next minute, because the once-a-minute schedule check had
@@ -1461,6 +911,18 @@ 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,
@@ -1718,17 +1180,6 @@ 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
+3 -11
View File
@@ -649,9 +649,7 @@ 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`) 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
> `web_interface/blueprints/api_v3/display.py`) write a request 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
@@ -749,14 +747,8 @@ keys helps troubleshoot stuck states.
"timestamp": 1234567890.123
}
```
**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
**Purpose:** Communication from web interface to display controller
**When Set:** API endpoint receives request
**Auto-Cleared:** After processing or 1 hour TTL
**2. display_on_demand_config** (No TTL)
+7 -12
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, 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 request (fallback) | cache `display_on_demand_request` | web, when the socket fails; four plugins write it directly | display: `_poll_on_demand_requests()` |
| 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 | 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 |
| Error clear | cache `plugin_error_clear_request` | web | display |
| 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,16 +58,11 @@ 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; 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()`.
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()`.
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.
+1 -3
View File
@@ -203,7 +203,6 @@ 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 |
@@ -219,6 +218,5 @@ 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()` | `forget_plugin_fonts()` (core calls it on unload) |
| `get_plugin_fonts()`, `unregister_plugin_fonts()` | — |
| `add_font()`, `remove_font()`, `validate_font()` | the web UI's Fonts tab |
+24 -133
View File
@@ -7,18 +7,14 @@ 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. 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.
wrote to the SD card. The file mailbox 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), `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`) |
| 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`) |
| 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 |
@@ -89,17 +85,6 @@ 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
@@ -382,9 +367,7 @@ 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 (the frame loops through the Arbiter's
mid-screen check, `Source.RELOAD`; the dwell through
`_plugin_reload_pending`).
reload as a reason to stop (`_screen_preempted`).
- 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
@@ -407,7 +390,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 floor on the mailbox read (0.25 s, 1 s since stage 4) does not apply to the queue, because
The 0.25 s floor on the mailbox read 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.
@@ -435,7 +418,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
is slower on purpose (see "The mailboxes now"). Commands still run only on the render thread: the
keeps its old delays. 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
@@ -453,74 +436,11 @@ 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.** 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.
**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.
## Robustness
@@ -537,10 +457,9 @@ 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 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.
- **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.
- **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
@@ -657,32 +576,15 @@ 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. **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.
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.
## Checking it on a device
@@ -697,18 +599,7 @@ 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. 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"
```
takes effect when the web service restarts after the user is added.
Brightness and a plugin reload:
-80
View File
@@ -488,78 +488,6 @@ 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.
@@ -1038,14 +966,6 @@ 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.
+28 -62
View File
@@ -159,16 +159,14 @@ there an unchecked checkbox — which the browser omits — is saved as
}
```
`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`.
`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`.)
A saved `brightness` reaches the panel without a restart. The route also
sends it to the running display over the control
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
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
@@ -248,10 +246,7 @@ Replace the schedule configuration.
```
A day whose `<day>_enabled` key is absent counts as enabled, with default
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`.
times `07:00`-`23:00`. At least one day must be enabled.
**Response**:
```json
@@ -348,11 +343,7 @@ 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. 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.
field `null`, either way.
### List Display Modes
@@ -464,7 +455,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 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.
- `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.
**Response**:
```json
@@ -489,19 +480,10 @@ 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.
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.
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.
### Stop On-Demand Display
@@ -2257,11 +2239,13 @@ error with `"all": true` (`max_age_hours` is then ignored).
}
```
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.
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`.
```json
{
@@ -2269,30 +2253,17 @@ before it answers: `applied` is `true`, `transport` is `"socket"`, and
"data": {
"cleared_count": 13,
"clear_requested": true,
"applied": true,
"transport": "socket",
"request_id": "5f0c1e...",
"cutoff": "2026-09-23T09:59:02.310000"
},
"message": "Cleared all errors"
"message": "Clear of all errors requested; the display service applies it within about 5 seconds"
}
```
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
`cleared_count` is how many of the reported errors the clear hides. It is
`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`. A
display that had the request and failed it (`internal`, a timeout after the
request was sent) answers `503`, with `context.socket_error`.
age-based clear over more errors than the report lists). A request that
could not be written to the shared cache answers `500`.
---
@@ -2310,11 +2281,7 @@ 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, 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
Windows), which does not affect the status. 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`.
@@ -2377,9 +2344,8 @@ 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`. As for the schedule above, an
enabled dim schedule needs at least one day enabled and a disabled one may
have every day off.
enabled with default times `20:00`-`07:00`; at least one day must be
enabled.
---
+83 -208
View File
@@ -36,189 +36,122 @@ 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, 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.
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.
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()`.
(`_arbiter_inputs`) and call `Arbiter.decide()`, which picks one of
steps 4-6 or returns `LEGACY` for steps 7-9 (stage 2).
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 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`).
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`).
## Design
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
```python
def run(self):
while True:
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
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
```
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 | Code |
| Order | Source | Offers a screen when | Today |
|---|---|---|---|
| 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` |
| gate | ScheduledOff | the schedule is off and no on-demand session overrides it | step 4 |
| 1 | Follower | a sync leader is driving this panel | step 5 |
| 2 | OnDemand | a session is active (its mode list, index, expiry and pin) | `_resolve_active_mode` |
| 3 | Wifi | a status message is pending and on-demand is not active | step 6 |
| 4 | Live | a live-priority plugin has live content (round-robin across several) | step 7 |
| 5 | Vegas | Vegas is enabled and nothing above wants the panel | step 8 |
| 6 | Rotation | always: `available_modes[current_mode_index]` | step 9 |
ScheduledOff is a gate in front of the Sources because that is how it works
today: a scheduled-off panel stays blank even for a follower, and only an
on-demand session overrides it.
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, running=None) -> ScreenPlan
Arbiter.decide(state, inputs, now) -> ScreenPlan
```
`decide` is a pure function: it does no I/O, takes no locks and does not
sleep. It can be tested with plain tables of (state, inputs, now) mapped to
an expected plan.
- `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`:
an expected plan. It returns a `ScreenPlan`:
| Field | Meaning |
|---|---|
| `source` | which Source won |
| `mode`, `plugin` | what to draw (None for a blank or follower plan); 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` |
| `mode`, `plugin` | what to draw (None for a blank or follower plan) |
| `min_duration`, `max_duration` | from `_resolve_durations` and `_clamp_to_on_demand` |
| `dynamic` | run until the plugin's cycle completes, between min and max |
| `frame_policy` | `HIGH_FPS` or `STATIC`, today `_needs_high_fps`; see stage 5 |
| `frame_policy` | today `_needs_high_fps` (125 Hz or 1 Hz); see stage 5 |
| `preemptible_by` | the Sources allowed to interrupt this plan mid-screen |
| `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, host: ScreenHost).run(plan, plugin) -> Outcome
ScreenRunner(clock: FrameClock).run(plan) -> Outcome(exit_reason, elapsed)
```
The ScreenRunner draws the first frame (`_dispatch_first_frame`), runs the
frame loop that the plan's frame policy selects, services pending changes
between frames, and returns one `ExitReason`:
| ExitReason | Golden-trace exit |
| ExitReason | Today's equivalent (golden-trace exit) |
|---|---|
| `DURATION` | target duration reached (`duration`) |
| `CYCLE_COMPLETE` | dynamic plugin finished after its minimum (`cycle-complete`) |
| `EMPTY` | first frame returned False, or no plugin (`empty`; `raised` when display() raised inside the executor; `no-plugin`, `breaker`) |
| `EMPTY` | first frame returned False (`empty`; `raised` when display() raised inside the executor) |
| `ERROR` | the dispatch itself raised (`error`) |
| `DISPLAY_FALSE` | a later frame returned False (`display-false`) |
| `PREEMPTED` | another Source took the panel (`on-demand-*`, `schedule-off`, `live`, `wifi`, ...) |
| `RELOAD` | a plugin reload is waiting: the screen ends early but counts as shown, and the rotation advances |
| `PREEMPTED` | another Source took the panel (`on-demand-*`, `schedule-off`, `vegas-interrupt`, ...) |
`PREEMPTED` replaces the five `current_display_mode != active_mode` checks.
The runner asks 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.
The runner asks the Arbiter, at the throttled service points it already has,
whether a Source in `plan.preemptible_by` now wants the panel.
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.
`FrameClock` provides `now()` and `sleep()`. In production it is
`time.monotonic`/`time.sleep`. In the golden traces it is the fake clock
that the harness patches in today.
## Stages
@@ -248,14 +181,13 @@ the controller's logger, so they keep their source in the journal.
- 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, and one that cannot resume (its plugin did not load); a
request naming a live mode the plugin's live check would drop
a restart
- 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 18 tests run in under a second. The goldens were generated from
- All 16 tests run in under a second. The goldens were generated from
main's `run()` before any code moved.
- Vegas uses `FakeVegas`, which implements only the contract the controller
depends on: `run_iteration()` returns True after its duration and False
@@ -317,84 +249,37 @@ 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` (done; awaiting the ledpi soak)
### Stage 3: ScreenRunner and `PREEMPTED`
The plan, from where stage 2 left off:
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:
1. `ArbiterState` gains the rotation index, the on-demand mode list, index,
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.
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.
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, before it merges.
Coordinate with whoever owns scroll performance (`docs/SCROLL_PERFORMANCE.md`).
so it needs a frame soak on ledpi, A/B against main. Coordinate with
whoever owns scroll performance (`docs/SCROLL_PERFORMANCE.md`).
### Stage 4: Vegas as a Source
@@ -459,13 +344,3 @@ 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.
+30 -85
View File
@@ -87,12 +87,10 @@ 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` | 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) |
| `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 |
Each is described in [src/common/README.md](../src/common/README.md).
@@ -104,8 +102,7 @@ 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_favorites.py` is what calls it.
`sports_helpers.py` holds `_favorite_key`, the override point listed below.
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
@@ -128,7 +125,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. `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 |
| `_favorite_key(game, side)` | Which view-model field identifies a team for favorites matching | `game["<side>_abbr"]` |
| `_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 |
@@ -137,7 +134,8 @@ constants rather than behavior:
| Attribute | Meaning | Default |
|---|---|---|
| `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` |
| `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) |
| `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
@@ -148,14 +146,11 @@ 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"`.
`FINAL_PERIOD` exists for the same reason in the opposite direction: a
`CLOCK_COUNTS_DOWN` 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 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.
clock-expiry branch there would evict live games.
`COALESCE_SCORING_SEQUENCE` is another of the same kind. In football one
`COALESCE_SCORING_SEQUENCE` is the third 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
@@ -303,49 +298,6 @@ 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
@@ -381,8 +333,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`, `COALESCE_SCORING_SEQUENCE` and `_favorite_key` are,
and add it to the tables above. Never a sport-name
`FINAL_PERIOD`, `CLOCK_COUNTS_DOWN`, `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
@@ -430,8 +382,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; 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) |
| 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 |
| 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 |
@@ -464,28 +416,21 @@ 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. 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.
- **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.
- **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
+6 -50
View File
@@ -42,8 +42,7 @@ 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
visibility.js ctx.visibility: a page's timers run only while it is on screen
(later) escape.js, notify.js, dialog.js, streams.js,
(later) escape.js, notify.js, dialog.js, streams.js, visibility.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)
@@ -82,20 +81,6 @@ 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:
@@ -118,7 +103,6 @@ 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
@@ -127,30 +111,6 @@ 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:
@@ -279,9 +239,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 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 |
| 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 |
| 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) |
@@ -298,7 +258,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` (the page-facing `ctx.visibility` is there since step 8; it reads the tracker from `app-shell.js`) |
| `LEDVisibility` | `app-shell.js` | `core/visibility.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
@@ -318,16 +278,12 @@ 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, `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_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_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
View File
@@ -36,10 +36,8 @@ 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
@@ -60,7 +58,6 @@ 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
@@ -91,7 +88,6 @@ 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,12 +14,6 @@ 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.2"
__version__ = "3.8.0"
+20 -1
View File
@@ -34,6 +34,7 @@ from src.common.fetch_service import (
plugin_scope,
share_connection_pool,
)
from src.common.espn_payload import is_espn_scoreboard_url, slim_scoreboard_payload
from src.common.espn_dates import (
RANGE_RETRY_SECONDS,
_note_range_rejected,
@@ -83,6 +84,10 @@ class FetchRequest:
# the cache with the callbacks suppressed -- joiners waiting forever for a
# fetch that did, in fact, succeed.
commit_claimed: bool = False
# Trim an ESPN scoreboard response before it is cached and delivered
# (src/common/espn_payload.py). Set by whoever created the request; a
# submitter that joins the fetch gets the same payload.
slim_payload: bool = True
result: Optional[Any] = None
error: Optional[str] = None
# The plugin that submitted the request, so the fetch service counts the
@@ -249,7 +254,8 @@ class BackgroundDataService:
timeout: Optional[int] = None,
max_retries: int = 3,
priority: int = 1,
callback: Optional[Callable] = None) -> str:
callback: Optional[Callable] = None,
slim_payload: bool = True) -> str:
"""
Submit a background fetch request.
@@ -265,6 +271,11 @@ class BackgroundDataService:
priority: Accepted for compatibility and ignored; requests run in
submission order.
callback: Optional callback function when request completes
slim_payload: Drop the parts of an ESPN scoreboard response no
scoreboard reads (stat leaders, athlete cards, links,
headlines, highlights) before caching it; see
src/common/espn_payload.py. Only ESPN /scoreboard URLs are
touched. Pass False to cache the response whole.
Returns:
Request ID for tracking the fetch operation
@@ -336,6 +347,7 @@ class BackgroundDataService:
priority=priority,
callback=callback,
owner=owner,
slim_payload=slim_payload,
)
with self._lock:
@@ -497,6 +509,13 @@ class BackgroundDataService:
)
return result
# Most of an ESPN scoreboard response is never drawn, and the
# cached copy stays parsed in the memory tier while it is fresh.
# Trimmed before the write so the cache, request.result and the
# callbacks all see the same payload. See src/common/espn_payload.py.
if request.slim_payload and is_espn_scoreboard_url(request.url):
slim_scoreboard_payload(data)
# Cache the data
self.cache_manager.set(request.cache_key, data)
+2 -3
View File
@@ -213,9 +213,8 @@ def list_installed_plugins(project_root: Path) -> List[Dict[str, Any]]:
The plugins are the ``manifest.json`` files in the configured plugin
directory (see :func:`_plugins_directory`), with the manifest's version;
``enabled`` is config.json's flag by the display's rule (a missing flag
is disabled). A restore installs each listed plugin that is missing and
takes enabled state from the restored config.json, so ``enabled`` is
informational.
is disabled). A restore reinstalls every listed plugin and takes enabled
state from the restored config.json, so ``enabled`` is informational.
``data/plugin_state.json`` is not read: it only ever repeated config's
enabled flags and the manifests' versions, and is retired (nothing
+1 -56
View File
@@ -28,7 +28,7 @@ import os
import time
from datetime import datetime
import pytz
from typing import Any, Dict, List, Optional, Tuple
from typing import Any, Dict, List, Optional
import logging
import threading
import tempfile
@@ -72,43 +72,6 @@ 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."""
@@ -333,24 +296,6 @@ class CacheManager:
"""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.
+3 -31
View File
@@ -44,10 +44,8 @@ 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 |
@@ -111,11 +109,8 @@ 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()`, `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.
results; `espn_date_chunks()`, `fetch_espn_date_chunks()`,
`clamp_espn_limit()` and `merge_scoreboard_payloads()` are the pieces.
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.
@@ -281,19 +276,6 @@ 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`
@@ -309,16 +291,6 @@ 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).
@@ -416,7 +388,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)` (3.8.1) draws the text in `outline_color` at
offsets=OUTLINE_SQUARE)` (Unreleased) 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
+22 -174
View File
@@ -26,30 +26,10 @@ 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. A process starts inside that
period, as if a range had just been rejected.
workaround retires itself if ESPN reverts.
ONE CACHE KEY PER SCOREBOARD
----------------------------
@@ -75,7 +55,7 @@ import re
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from datetime import date, datetime, timedelta, tzinfo
from datetime import date, datetime, timedelta
from functools import partial
from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple, cast
@@ -120,57 +100,16 @@ 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()
# 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
_ranges_rejected_until = 0.0
__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",
@@ -281,79 +220,6 @@ 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.
@@ -384,21 +250,13 @@ 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,
trims: Optional[Dict[str, Tuple[date, date]]] = None,
) -> Any:
) -> Optional[Dict[str, 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:
with _chunk_slots:
response = fetch_get(
session,
url,
@@ -408,30 +266,21 @@ def _fetch_one_chunk(
**_memo_kwargs(cache_max_age),
)
response.raise_for_status()
payload = response_json(response)
return cast(Optional[Dict[str, Any]], 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,
trims: Optional[Dict[str, Tuple[date, date]]] = None,
) -> List[Any]:
) -> List[Optional[Dict[str, 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 -- 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 --
over a hundred of them. 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.
@@ -444,7 +293,7 @@ def _fetch_chunks(
return []
fetch = partial(
_fetch_one_chunk, session, url, params, headers, timeout, logger,
cache_max_age=cache_max_age, trims=trims,
cache_max_age=cache_max_age,
)
if len(chunks) == 1:
return [fetch(chunks[0])]
@@ -491,9 +340,7 @@ def fetch_espn_date_chunks(
if span is None:
return None
planned = espn_request_chunks(*span)
chunks = [chunk for chunk, _ in planned]
trims = {chunk: trim for chunk, trim in planned if trim is not None}
chunks = espn_date_chunks(*span)
if logger:
logger.debug(
"Fetching ESPN date range %s as %d month/day chunks",
@@ -502,31 +349,32 @@ 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 (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.
# 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.
slots: List[Any] = results
capped: Dict[int, List[str]] = {}
for index, chunk in enumerate(chunks):
if slots[index] is not _CAPPED:
payload = slots[index]
if payload is None or len(chunk) != 6:
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,
)
trim = trims.get(chunk)
capped[index] = (_days_of_month(chunk) if trim is None
else espn_date_chunks(*trim))
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 capped:
days = [day for index in sorted(capped) for day in capped[index]]
+97
View File
@@ -0,0 +1,97 @@
"""Drop the parts of an ESPN scoreboard payload no scoreboard reads.
The sports scoreboards cache their Recent/Upcoming window (14 days back, 7
ahead) as the raw ESPN response, and that record stays parsed in the memory
cache for as long as it is fresh. Most of it is never drawn. Measured on hdpi
(2026-10-02) the MLB window was 3.35MB of JSON and 13.5MB of Python objects,
and the five windows together ~40MB, mostly in:
* ``competitors[].leaders`` / ``competitions[].leaders`` -- per-team and
per-game stat leaders (28% of the MLB window)
* ``competitors[].team.links`` / ``event.links`` -- web and app URLs
* ``status.featuredAthletes`` and ``competitors[].probables`` -- athlete
cards with headshots and season stats
* ``competitions[].headlines`` / ``highlights`` -- article and video blurbs
(28% of the college-football window)
* ``competitions[].geoBroadcasts``
None of those keys is read by core or by any plugin in ledmatrix-plugins
(checked 2026-10-02 across every scoreboard, the odds ticker and the
leaderboard), while everything that is read -- odds, records, linescores,
situation, statistics, notes, broadcasts, venue -- is kept. Dropping them
takes the five windows from ~40MB to ~12MB of parsed objects and the files from
10.6MB to 3.0MB, so the reads that parse an expired window on the render
thread get 3-4x cheaper too.
:func:`slim_scoreboard_payload` changes the payload in place, and only ever
removes the keys listed here: anything it does not know about is left alone.
"""
from typing import Any, Dict
from urllib.parse import urlsplit
# Per level of the payload, the keys removed. Kept deliberately explicit:
# adding a key here means checking that nothing reads it first.
_EVENT_DROP = ("links",)
_COMPETITION_DROP = ("leaders", "headlines", "highlights", "geoBroadcasts")
_STATUS_DROP = ("featuredAthletes",)
_COMPETITOR_DROP = ("leaders", "probables")
_TEAM_DROP = ("links",)
def is_espn_scoreboard_url(url: Any) -> bool:
"""Whether ``url`` is an ESPN site-API scoreboard endpoint."""
if not isinstance(url, str):
return False
try:
parts = urlsplit(url)
except ValueError:
return False
host = (parts.hostname or "").lower()
if host != "espn.com" and not host.endswith(".espn.com"):
return False
return parts.path.rstrip("/").endswith("/scoreboard")
def _drop(obj: Any, keys) -> None:
if isinstance(obj, dict):
for key in keys:
obj.pop(key, None)
def slim_scoreboard_payload(payload: Any) -> Any:
"""Remove the unread parts of an ESPN scoreboard payload, in place.
Returns ``payload`` for convenience. Anything that is not shaped like a
scoreboard (not a dict, no ``events`` list, odd entries) is passed over
untouched rather than raising.
"""
if not isinstance(payload, dict):
return payload
events = payload.get("events")
if not isinstance(events, list):
return payload
for event in events:
if not isinstance(event, dict):
continue
_drop(event, _EVENT_DROP)
competitions = event.get("competitions")
if not isinstance(competitions, list):
continue
for competition in competitions:
if not isinstance(competition, dict):
continue
_drop(competition, _COMPETITION_DROP)
_drop(competition.get("status"), _STATUS_DROP)
competitors = competition.get("competitors")
if not isinstance(competitors, list):
continue
for competitor in competitors:
if not isinstance(competitor, dict):
continue
_drop(competitor, _COMPETITOR_DROP)
_drop(competitor.get("team"), _TEAM_DROP)
return payload
__all__ = ["is_espn_scoreboard_url", "slim_scoreboard_payload"]
+1 -30
View File
@@ -59,10 +59,7 @@ 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 (``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,
**Counters.** Requests, merged requests, bytes, 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,
@@ -204,7 +201,6 @@ _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
@@ -620,30 +616,6 @@ 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)
@@ -1145,7 +1117,6 @@ 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
@@ -1,263 +0,0 @@
"""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
@@ -1,125 +0,0 @@
"""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"]
+12 -10
View File
@@ -34,9 +34,8 @@ 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 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.
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.
WHAT A HOST MUST PROVIDE
------------------------
@@ -48,8 +47,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; ``sports_favorites`` has the
shared body), only called when
``_is_favorite_game`` (called with a game), which stays per-plugin and is
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``
@@ -217,12 +216,15 @@ class SportsHelpersMixin:
rather than a branch so core never has to learn the string "nrl"::
def _favorite_key(self, game, side):
team_id = game.get(f"{side}_id")
return None if team_id is None else str(team_id)
return str(game.get(f"{side}_id"))
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.
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.
"""
return game.get(f"{side}_abbr")
+7 -55
View File
@@ -57,9 +57,7 @@ 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_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``.)
``_is_game_really_over``, ``_is_ranked_game``, ``_passes_other_filters``.
Of the fourteen shared class constants, thirteen are identical everywhere and
live here. Only ``_SCORE_PROBE_TEXT`` varies -- afl and basketball reach three digits
@@ -122,32 +120,6 @@ _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:
@@ -1322,11 +1294,11 @@ class SportsLiveSharedMixin:
otherwise look like another empty check and escalate the back-off
again, right when the game is actually starting.
"""
now = time.time()
start = _current_scheduled_start(self, now)
start = getattr(self, "_next_scheduled_start_ts", None)
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:
@@ -1341,16 +1313,6 @@ 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
@@ -1367,7 +1329,7 @@ class SportsLiveSharedMixin:
now = time.time()
if candidate <= now:
return
current = _current_scheduled_start(self, now)
current = getattr(self, "_next_scheduled_start_ts", None)
# 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
@@ -1377,20 +1339,10 @@ 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.
#
# 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:
if (current is None
or current <= now - _KICKOFF_GRACE_SECONDS
or candidate < current):
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
+9 -42
View File
@@ -46,35 +46,6 @@ from src.common.permission_utils import (
get_config_dir_mode
)
def _private_copy(config: Dict[str, Any]) -> Dict[str, Any]:
"""A deep copy of ``config`` that shares nothing with it.
load_config() hands one out per call, and the saves keep one, so the
cached config is never an object a caller holds. A web handler edits what
it loaded, validates, and may refuse the save; when the cache was that
same object, the refused edit stayed in it, and the next save of any
other setting wrote it to config.json -- a nested secret included, in
plain text, since it had never reached config_secrets.json to be
stripped.
The config is JSON data, so only its dicts and lists need copying; every
other value in it is immutable. On a Pi 4 with a real 60 KiB config this
takes 2.1 ms against copy.deepcopy's 6.8 ms, on a path ~30 handlers call
(a pickle round trip is no faster, 1.9 ms, and brings pickle into the
config path for nothing).
"""
return _copy_containers(config)
def _copy_containers(value: Any) -> Any:
if isinstance(value, dict):
return {key: _copy_containers(item) for key, item in value.items()}
if isinstance(value, list):
return [_copy_containers(item) for item in value]
return value
class ConfigManager:
"""
Reads and writes the main application configuration files.
@@ -155,10 +126,9 @@ class ConfigManager:
validate_after_write=validate_after_write
)
# Update in-memory config if save was successful. A copy: the caller
# still holds new_config_data (see _private_copy).
# Update in-memory config if save was successful
if result.status == SaveResultStatus.SUCCESS:
self.config = _private_copy(new_config_data)
self.config = new_config_data
# In-memory config now matches what was just written, so the
# load_config fast path may return it. It still carries the
# merged secrets that were stripped on disk; that matches a full
@@ -238,16 +208,14 @@ class ConfigManager:
Fast path: when config.json, config_secrets.json and the template
are all unchanged since the last successful load (mtime_ns + size),
a copy of the already-parsed self.config is returned without
touching the files.
Either way the caller gets its own copy (see _private_copy): editing
it changes nothing here until it is saved.
the already-parsed self.config is returned without touching the
files — same aliasing semantics as the full path, which also
returns self.config.
"""
try:
current_sig = self._files_signature()
if self.config and self._loaded_sig == current_sig:
return _private_copy(self.config)
return self.config
# Check if config file exists, if not create from template
if not os.path.exists(self.config_path):
@@ -281,7 +249,7 @@ class ConfigManager:
# Signature taken AFTER load + migration (migration may write the
# config back), so it reflects exactly what was read/written.
self._loaded_sig = self._files_signature()
return _private_copy(self.config)
return self.config
except FileNotFoundError as e:
# Only config.json can get here: a missing or unreadable secrets
@@ -387,9 +355,8 @@ class ConfigManager:
try:
atomic_write_json(self.config_path, config_to_write)
# Update the in-memory config to the new state (which includes
# secrets for runtime), as a copy -- see _private_copy
self.config = _private_copy(new_config_data)
# Update the in-memory config to the new state (which includes secrets for runtime)
self.config = new_config_data
self._loaded_sig = self._files_signature()
self.logger.info(f"Configuration successfully saved to {os.path.abspath(self.config_path)}")
if secrets_content:
+39 -404
View File
@@ -1,56 +1,41 @@
"""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 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 once per pass and returns a
:class:`ScreenPlan` naming the Source that gets the panel. It is a pure
function: no I/O, no clock reads (``now`` is passed in), no locks, and it
changes nothing it is given. That is what lets a plain table of cases test
the priority order, which used to exist only as the order of ``if`` blocks
in ``run()``.
The order is
The full order is
ScheduledOff (a gate), Follower, OnDemand, Wifi, Live, Vegas, Rotation
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.
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.
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.
The Wifi Source's mid-screen rule, :func:`wifi_notice_preempts`, lives here
too, so both of its answers -- at the top of a pass and between frames --
come from one module.
"""
from dataclasses import dataclass, replace
from dataclasses import dataclass
from enum import Enum
from typing import FrozenSet, Optional, Protocol, Tuple
from typing import Optional
__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",
]
@@ -68,41 +53,11 @@ class Source(Enum):
SCHEDULED_OFF = "scheduled-off"
FOLLOWER = "follower"
ON_DEMAND = "on-demand"
WIFI = "wifi"
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.
# Not decided by the Arbiter yet: on-demand, live priority, Vegas and the
# rotation are still chosen by run()'s own code. Stage 3 adds the
# OnDemand, Live and Rotation Sources; stage 4 adds Vegas.
LEGACY = "legacy"
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)
@@ -121,122 +76,11 @@ class WifiNotice:
class ArbiterState:
"""What the Arbiter remembers between passes.
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.
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.
"""
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:
@@ -251,122 +95,57 @@ 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, 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.
None when the Source paces itself: a follower frame, or LEGACY.
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)
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}
LEGACY_PLAN = ScreenPlan(Source.LEGACY)
class Arbiter:
"""Decides which Source gets the panel. Stateless; see the module docstring."""
@staticmethod
def decide(state: ArbiterState, inputs: ArbiterInputs, now: float,
running: Optional[ScreenPlan] = None) -> ScreenPlan:
def decide(state: ArbiterState, inputs: ArbiterInputs, now: float) -> 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.
state: What the Arbiter remembers between passes (nothing yet).
inputs: This pass's snapshot.
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.
now: Wall-clock time of the snapshot. No stage-2 Source reads it:
the top-of-pass WiFi check takes the notice as read, and only
the mid-screen check (:func:`wifi_notice_preempts`) compares
it with the expiry. It is in the signature for the Sources
stage 3 adds (on-demand expiry, durations).
Returns:
The winning Source's plan (LEGACY for Vegas), or ``running``.
The winning Source's plan, or LEGACY_PLAN when the winner is one
run() still decides itself.
"""
if running is not None:
return _hold_or_preempt(state, inputs, now, running)
del state, now # not read by the stage-2 Sources; see the docstring
# ScheduledOff is a gate, not a Source: a scheduled-off panel stays
# blank even for a follower, and only an on-demand session overrides
@@ -378,161 +157,17 @@ class Arbiter:
if inputs.follower_active:
return FOLLOWER_PLAN
# 2. OnDemand: the session's current mode. It outranks the notice.
# 2. OnDemand: decided by run() until stage 3. It outranks the notice.
if inputs.on_demand_active:
return _on_demand_plan(state, now)
return LEGACY_PLAN
# 3. Wifi: a pending notice, held for one short dwell per pass.
if inputs.wifi_notice is not None:
return ScreenPlan(Source.WIFI, max_duration=WIFI_NOTICE_DWELL,
notice=inputs.wifi_notice)
# 4. 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]
# 4-6. Live, Vegas, Rotation: still run()'s own code.
return LEGACY_PLAN
def wifi_notice_preempts(notice: Optional[WifiNotice], on_demand_active: bool,
+510 -799
View File
File diff suppressed because it is too large Load Diff
+2 -52
View File
@@ -317,11 +317,6 @@ 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()
@@ -1793,10 +1788,9 @@ 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, frame_changed)
viewer_fresh, digest != self._last_snapshot_digest)
else:
# Ask as if the frame had changed before paying to find out.
# decide() is monotone in frame_changed -- a SKIP for a
@@ -1808,36 +1802,22 @@ 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())
frame_changed = digest != self._last_snapshot_digest
if not frame_changed:
if digest == self._last_snapshot_digest:
# 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.)
@@ -1862,39 +1842,9 @@ 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
+30 -124
View File
@@ -490,13 +490,10 @@ 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, as a fallback
# ERROR_CLEAR_REQUEST_KEY written by the web interface only
#
# 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.
# A clear is asynchronous: the web interface records a request, and the
# display service applies it (clear_before) on its next tick and republishes.
# 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.
@@ -601,43 +598,13 @@ 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 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:
"""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)
except Exception:
self._mailbox.forget()
raise
if not isinstance(request, dict):
return False
request_id = request.get("request_id")
@@ -647,30 +614,14 @@ class ErrorSnapshotPublisher:
cutoff = float(request.get("cutoff"))
except (TypeError, ValueError):
cutoff = float("nan")
self._clear(request_id, cutoff)
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
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:
@@ -684,7 +635,13 @@ class ErrorSnapshotPublisher:
if (self._last_attempt is not None
and now - self._last_attempt < self.min_interval):
return False
self._publish(version, now)
# 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
return True
except Exception as err: # never let reporting break the display
_snapshot_logger.debug("Could not publish the plugin error snapshot: %s",
@@ -736,22 +693,6 @@ 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]]]:
@@ -790,15 +731,7 @@ def _pending_cutoff(snapshot: Optional[Dict[str, Any]],
cutoff = float(clear_request.get("cutoff"))
except (TypeError, ValueError):
return 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
return cutoff if math.isfinite(cutoff) else None
def _is_after(item: Any, field_name: str, cutoff: float) -> bool:
@@ -898,31 +831,13 @@ def _count_cleared(summary: Dict[str, Any], cutoff: float) -> Optional[int]:
return None
#: ``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]:
def request_error_clear(cache_manager: Any, cutoff: float) -> Dict[str, Any]:
"""Ask the display service to forget errors recorded at or before ``cutoff``.
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.
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.
A request the display has not applied yet is only ever widened: a later,
narrower one ("older than 24 hours" after "everything") overwriting it
@@ -933,21 +848,8 @@ def request_error_clear(cache_manager: Any, cutoff: float,
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": request_id,
"request_id": uuid.uuid4().hex,
"cutoff": cutoff,
"requested_at": time.time(),
}
@@ -955,5 +857,9 @@ def request_error_clear(cache_manager: Any, cutoff: float,
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 dict(answer, applied=False, transport="mailbox",
cleared_count=_count_cleared(before, cutoff))
return {
"cleared_count": _count_cleared(before, cutoff),
"clear_requested": True,
"request_id": request["request_id"],
"cutoff": datetime.fromtimestamp(cutoff).isoformat(),
}
-33
View File
@@ -222,39 +222,6 @@ 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"]
+10 -71
View File
@@ -3,13 +3,8 @@
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``. 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.
``reason``, and the caller falls back to the file mailbox. Nothing here
blocks for longer than ``timeout`` in total.
"""
from __future__ import annotations
@@ -27,7 +22,6 @@ from src.ipc.contract import (
SUBSCRIBE_KEEPALIVE_SECONDS,
SUPPORTED_VERSIONS,
Command,
ErrorCode,
FrameReader,
ProtocolError,
Request,
@@ -55,49 +49,17 @@ 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 = '', *, sent: bool = False):
def __init__(self, reason: str, message: str = ''):
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,
@@ -131,14 +93,11 @@ 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', sent=True)
raise ControlError('bad_response', 'the reply is for a different request')
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 '',
sent=response.id is not None)
error.message if error else '')
return dict(response.result or {})
@@ -183,31 +142,26 @@ 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', sent=sent)
raise ControlError('closed', 'the display closed the connection')
lines = reader.feed(data)
if lines:
return Response.from_dict(decode_message(lines[0]))
except socket.timeout:
raise ControlError('timeout', 'no reply in time', sent=sent) from None
raise ControlError('timeout', 'no reply in time') from None
except ProtocolError as e:
raise ControlError('bad_response', e.message, sent=sent) from None
except ControlError as e:
e.sent = e.sent or sent
raise ControlError('bad_response', e.message) from None
except ControlError:
raise
except OSError as e:
raise ControlError('closed', str(e), sent=sent) from None
raise ControlError('closed', str(e)) from None
# -- commands ---------------------------------------------------------------------------
@@ -273,21 +227,6 @@ 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)
+4 -44
View File
@@ -149,13 +149,12 @@ 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, ``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).
#: ``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).
COMMANDS: Tuple[str, ...] = (
Command.HELLO,
Command.PING,
@@ -166,15 +165,8 @@ 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})
@@ -573,32 +565,8 @@ 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,
ErrorsClearArgs]
BrightnessSetArgs, PluginReloadArgs, StateGetArgs, StateSubscribeArgs]
#: The arguments of a command that goes on the render thread's queue.
QueuedArgs = Union[OnDemandStartArgs, OnDemandStopArgs, BrightnessSetArgs, PluginReloadArgs]
@@ -613,7 +581,6 @@ _ARG_TYPES: Dict[str, Any] = {
Command.PLUGIN_RELOAD: PluginReloadArgs,
Command.STATE_GET: StateGetArgs,
Command.STATE_SUBSCRIBE: StateSubscribeArgs,
Command.ERRORS_CLEAR: ErrorsClearArgs,
}
@@ -686,13 +653,6 @@ 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?
+6 -57
View File
@@ -6,9 +6,7 @@ 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, and the few commands
that touch nothing the render thread owns (``errors.clear``) by a handler the
display registers, on the connection thread.
answered from a snapshot callable the display provides.
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
@@ -64,7 +62,6 @@ from src.ipc.contract import (
AWAITED_COMMANDS,
COMMANDS,
DEFAULT_SOCKET_DIR,
DIRECT_COMMANDS,
DEFAULT_SOCKET_PATH,
MAX_MESSAGE_BYTES,
MAX_SUBSCRIBERS,
@@ -110,8 +107,7 @@ 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`` instead of piling up work. The mailbox would not be read
#: either, so the web interface reports the failure rather than fall back.
#: is told ``busy`` (and falls back to the mailbox) instead of piling up work.
QUEUE_SIZE = 16
#: Timeout for one recv()/send() on a connection.
@@ -556,12 +552,6 @@ 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.
@@ -579,12 +569,9 @@ class ControlServer:
await_seconds: Optional[Mapping[str, float]] = None,
state_hub: Optional[StateHub] = None,
max_subscribers: int = MAX_SUBSCRIBERS,
keepalive: float = SUBSCRIBE_KEEPALIVE_SECONDS,
handlers: Optional[Mapping[str, DirectHandler]] = None):
keepalive: float = SUBSCRIBE_KEEPALIVE_SECONDS):
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)
@@ -772,22 +759,8 @@ 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.
Clears the wake flag first, so anything queued (or woken for) while
this runs wakes the next wait again.
"""
"""Every queued command, oldest first. Called from the render thread."""
commands: List[QueuedCommand] = []
self._pending.clear()
while True:
@@ -1055,9 +1028,6 @@ 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
@@ -1085,25 +1055,6 @@ 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.
@@ -1128,9 +1079,7 @@ 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,
handlers: Optional[Mapping[str, DirectHandler]] = None,
) -> Optional[ControlServer]:
state_hub: Optional[StateHub] = 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
@@ -1142,7 +1091,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, handlers=handlers)
state_hub=state_hub)
return server if server.start() else None
-123
View File
@@ -1,123 +0,0 @@
"""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
View File
@@ -1070,65 +1070,6 @@ 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
+2 -9
View File
@@ -48,19 +48,12 @@ class PluginOperation:
completed_at: Optional[datetime] = None
def to_dict(self) -> Dict[str, Any]:
"""Convert operation to dictionary for serialization.
Parameters whose name starts with ``_`` are internal and left out:
PluginOperationQueue keeps the operation's callback there as
``_callback`` until its worker runs it, and a pending operation's
status answered 500 because that function cannot be serialized.
"""
"""Convert operation to dictionary for serialization."""
return {
'operation_id': self.operation_id,
'operation_type': self.operation_type.value,
'plugin_id': self.plugin_id,
'parameters': {key: value for key, value in self.parameters.items()
if not str(key).startswith('_')},
'parameters': self.parameters,
'status': self.status.value,
'progress': self.progress,
'message': self.message,
-204
View File
@@ -1,204 +0,0 @@
"""
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
+9 -90
View File
@@ -15,7 +15,6 @@ 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
@@ -215,9 +214,6 @@ 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]]] = {}
@@ -601,21 +597,11 @@ 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)
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)
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): %s", plugin_id, name, e)
self.logger.debug("Could not forget fonts of %s: %s", plugin_id, e)
#: Config keys the **core** reads out of a plugin's own config block. The
#: plugin never declares them, so a schema with
@@ -860,9 +846,12 @@ 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 it reported using
# and the ones its manifest registered.
self._forget_plugin_fonts(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)
# Update state
self.state_manager.set_state(plugin_id, PluginState.UNLOADED)
@@ -1855,73 +1844,3 @@ 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
+4 -33
View File
@@ -22,7 +22,6 @@ 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,
)
@@ -93,9 +92,7 @@ class _InstallMixin:
raise
if installed:
self._discard_backup(
plugin_id, backup_path, "install",
new_path=self._existing_install(plugin_id) or plugin_path)
self._discard_backup(plugin_id, backup_path, "install")
return True
self._restore_backup(plugin_id, plugin_path, backup_path, "Install")
@@ -136,33 +133,8 @@ 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,
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
def _discard_backup(self, plugin_id: str, backup_path: Path, action: str) -> None:
"""Remove the set-aside copy after a successful (re)install."""
if not self._safe_remove_directory(backup_path):
self.logger.warning(
"%s of %s succeeded but the previous copy at %s could not be "
@@ -570,8 +542,7 @@ 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", new_path=final_path)
self._discard_backup(plugin_id, backup_path, "install")
# Install dependencies
self._install_dependencies(final_path)
-5
View File
@@ -138,11 +138,6 @@ 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
+3 -57
View File
@@ -7,7 +7,6 @@ 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
@@ -986,14 +985,10 @@ class _RegistryMixin:
def get_registry_info(self, plugin_id: str) -> Optional[Dict]:
"""
Get plugin information from the registry (plugins.json).
Get plugin information from the registry cache only (no GitHub API calls).
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.
Use this for lightweight lookups where only registry fields are needed
(e.g., verified status, latest_version).
Args:
plugin_id: Plugin identifier
@@ -1004,52 +999,3 @@ 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)
+4 -23
View File
@@ -10,9 +10,6 @@ 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
@@ -305,11 +302,7 @@ class _UpdateMixin:
installed = False
if installed:
# 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)
self._discard_backup(plugin_id, backup_path, "update")
return True
# Bad network, registry error...: the user keeps a working plugin.
@@ -516,11 +509,7 @@ class _UpdateMixin:
for line in untracked_result.stdout.strip().split('\n'):
if line.startswith('??'):
# Untracked file
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):
file_path = line[3:].strip()
untracked_files.append(file_path)
# Check for tracked file changes
@@ -548,17 +537,9 @@ 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 --
# 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])
# Use -u to include untracked files in stash
stash_result = subprocess.run(
stash_cmd,
['git', '-C', str(plugin_path), 'stash', 'push', '-u', '-m', f'LEDMatrix auto-stash before update {plugin_id}'],
capture_output=True,
text=True,
timeout=30,
-484
View File
@@ -1,484 +0,0 @@
"""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 -19
View File
@@ -143,24 +143,12 @@ 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)
@@ -736,14 +724,11 @@ 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,
named_mode: Optional[str] = None):
duration: Optional[float] = None, pinned: bool = False):
"""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. A
session that cannot resume is logged as ``on-demand-error``."""
_populate_on_demand_modes_from_plugin, as __init__ calls it."""
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
@@ -754,8 +739,6 @@ 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
@@ -1,29 +0,0 @@
{
"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"]
]
}
@@ -1,10 +0,0 @@
{
"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"]
]
}
-8
View File
@@ -50,7 +50,6 @@ server has none.
| `unit/test_store_categories.js` | no | The store's category filter (sandbox): the template ships only All Categories, the rest come from the store's plugins (one per category whatever its case), choosing one filters to it, and a swapped-in select is refilled from the cache keeping the choice |
| `unit/test_github_url_install.js` | no | Install Single Plugin (sandbox, the button as `plugins.html` ships it): no inline `onclick`, so a click or Enter sends exactly one `install-from-url` request and raises no error |
| `unit/test_render_cards.js` | no | `renderInstalledCards` markup, both empty states, and HTML-escaping of hostile plugin metadata |
| `unit/test_plugin_order_list.js` | no | `widgets/plugin-order-list.js` (the Vegas and rotation order lists): a disabled plugin, which gets no row, keeps its slot in the saved order and its Vegas exclusion when the list rewrites its hidden inputs, around reordering and include/exclude; an uninstalled plugin's id is dropped, a failed plugin list leaves the inputs as saved, and only string ids are carried over, once each |
| `unit/test_style_editor_element_keys.js` | no | `elementKeys()`/`styleRows()`/`positionRows()` from `widgets/style-editor.js`: every `customization.layout` entry gets exactly one row -- paired with its style element through core's `x-layout-key` (so `score` belongs to `score_text`, not a second row), or a position row of its own, leaves included -- since the widget claims the whole `layout` block from the generic fallback renderer |
| `unit/test_style_editor_layout_leaf_columns.js` | no | `columnsFor()` from `widgets/style-editor.js`: a layout-only key whose own value is a leaf (no x/y sub-object, e.g. a `show_logo` toggle) gets a self-keyed column instead of a blank, uneditable row |
| `unit/test_style_editor_layout_leaf_collision.js` | no | `columnsFor()` from `widgets/style-editor.js`: a layout-only leaf key still gets its own column even when its name collides with an unrelated element's style sub-field or another layout axis's sub-field |
@@ -58,9 +57,6 @@ server has none.
| `unit/test_store_registry_fields.js` | no | The store card's registry fields from `plugins_manager.js`: the commit that introduced the listed version (a hex SHA only, linked to that tree), the "Needs LEDMatrix X+" warning, a card from an older registry without either, and `isStorePluginInstalled` answering to `aliases` |
| `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 | `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,10 +65,6 @@ 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
@@ -1,325 +0,0 @@
// 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); });
+1 -5
View File
@@ -98,11 +98,7 @@ const ok = (l, c, x) => c ? (pass++, console.log(' ok ' + l))
const lists = () => requests.filter(r => r.url === '/api/v3/plugins/installed').length;
const $ = id => doc.getElementById(id);
// The rows' ids, in order. The input also keeps saved ids that have no row
// (a disabled plugin's place, see test/js/unit/test_plugin_order_list.js),
// and the saved order comes from whatever config the server has.
const SHOWN = plugins.filter(p => p.enabled).map(p => p.id);
const order = () => JSON.parse($('rotation_plugin_order_value').value || '[]').filter(id => SHOWN.includes(id));
const order = () => JSON.parse($('rotation_plugin_order_value').value || '[]');
async function swap() {
panel.dispatchEvent(new window.CustomEvent('htmx:beforeSwap', { bubbles: true, detail: { target: panel, shouldSwap: true } }));
panel.innerHTML = partial;
-272
View File
@@ -1,272 +0,0 @@
// 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
@@ -1,167 +0,0 @@
// 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); });
-42
View File
@@ -98,50 +98,8 @@ const get = p => new Promise((res, rej) =>
window.saveMqttBridge();
await tick(150);
ok('save includes password once typed', sent && sent.mqtt_password === 'typed-secret');
// A password with TLS off is refused unless allow_insecure_mqtt is set
// (CWE-319, api_v3/misc.py). The form has to be able to send it, or a
// plain-LAN broker with a password can never be saved from here.
const allowRow = () => $('mqtt-allow-insecure-row');
const shown = el => !!el && !el.classList.contains('hidden');
ok('allow-without-TLS control rendered', !!$('mqtt-allow-insecure'));
ok('allow-without-TLS starts as saved',
!!$('mqtt-allow-insecure') && $('mqtt-allow-insecure').checked === !!bridge.data.config.allow_insecure_mqtt);
ok('allow-without-TLS shown only while TLS is off',
shown(allowRow()) === !$('mqtt-tls').checked);
$('mqtt-tls').checked = true;
$('mqtt-tls').dispatchEvent(new window.Event('change', { bubbles: true }));
ok('ticking TLS hides it', !shown(allowRow()));
$('mqtt-tls').checked = false;
$('mqtt-tls').dispatchEvent(new window.Event('change', { bubbles: true }));
ok('unticking TLS shows it again', shown(allowRow()));
const setAllow = v => { if ($('mqtt-allow-insecure')) $('mqtt-allow-insecure').checked = v; };
setAllow(false);
window.saveMqttBridge();
await tick(150);
ok('save sends allow_insecure_mqtt false when unticked', !!sent && sent.allow_insecure_mqtt === false, sent);
setAllow(true);
window.saveMqttBridge();
await tick(150);
ok('save sends allow_insecure_mqtt true when ticked', !!sent && sent.allow_insecure_mqtt === true, sent);
onPut = null;
// Prefilled from the saved settings, and hidden while TLS is saved on.
bridgePayload = JSON.parse(JSON.stringify(bridge));
bridgePayload.data.config.allow_insecure_mqtt = true;
bridgePayload.data.config.mqtt_tls = false;
window.loadMqttBridge();
await tick(150);
ok('a saved opt-in is prefilled', !!$('mqtt-allow-insecure') && $('mqtt-allow-insecure').checked === true);
bridgePayload.data.config.mqtt_tls = true;
window.loadMqttBridge();
await tick(150);
ok('hidden on load when TLS is saved on', !shown(allowRow()));
bridgePayload = bridge;
window.loadMqttBridge();
await tick(150);
// ── Pixlet editor, idle ────────────────────────────────────────────────
const appIds = (apps.data.apps || []).map(a => a.id);
ok('editor lists the apps on disk',
-211
View File
@@ -1,211 +0,0 @@
// 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); });
+2 -8
View File
@@ -17,7 +17,6 @@ const fs = require('fs');
const BASE = process.env.BASE || 'http://localhost:5000';
const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.js',
'unit/test_plugin_order_list.js',
'unit/test_html_escaping.js', 'unit/test_style_editor_element_keys.js',
'unit/test_style_editor_layout_leaf_columns.js',
'unit/test_style_editor_layout_leaf_collision.js',
@@ -29,16 +28,11 @@ const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.js',
'unit/test_inline_handler_escaping.js',
'unit/test_plugin_action_delegation.js', 'unit/test_file_upload_widget.js',
'unit/test_store_registry_fields.js', 'unit/test_restart_banner.js',
'unit/test_page_registry.js', 'unit/test_core_modules.js',
'unit/test_overview_reconciliation_poll.js',
'unit/test_display_partial_ids.js',
'unit/test_general_web_login_token.js'];
'unit/test_page_registry.js', 'unit/test_core_modules.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_schedule_page.js', 'dom/test_general_page.js',
'dom/test_visibility_service.js', 'dom/test_display_page.js'];
'dom/test_raw_json_page.js', 'dom/test_backup_restore_page.js'];
function reachable(url) {
return new Promise(res => {
-116
View File
@@ -1,116 +0,0 @@
// The Display tab's page module must only look up elements the partial
// renders.
//
// 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 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 { 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
? (pass++, console.log(' ok ' + label))
: (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' ' + JSON.stringify(extra) : '')));
const html = fs.readFileSync(PARTIAL, 'utf8');
const rendered = new Set([...html.matchAll(/\bid="([^"{}]+)"/g)].map(m => m[1]));
function fakeElement(id) {
const listeners = {};
const classes = new Set();
return {
id, value: '1', textContent: '', min: '', max: '', checked: false,
style: {}, dataset: {}, className: '',
classList: {
add: c => classes.add(c), remove: c => classes.delete(c),
contains: c => classes.has(c),
},
addEventListener: (type, fn) => { (listeners[type] ||= []).push(fn); },
dispatchEvent() { return true; },
appendChild() {},
getAttribute: () => null,
listeners,
};
}
(async () => {
console.log('\n── Display page module: element lookups ──');
const display = await import(pathToFileURL(path.join(JS, 'pages/display.js')).href);
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));
const label = elements.get('brightness-value');
ok('...and shows the new value', !!label && label.textContent === '42', label && label.textContent);
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); });
@@ -1,94 +0,0 @@
// Creating an API token on the General tab must leave its form clean.
//
// 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 (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.
//
// Imports the shipped page module and runs createToken with a fake fetch and
// DOM -- no jsdom and no server needed.
const path = require('path');
const { pathToFileURL } = require('url');
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 el() {
const classes = new Set(['hidden']);
return {
textContent: '', dataset: {}, style: {}, className: '',
classList: { add: c => classes.add(c), remove: c => classes.delete(c), contains: c => classes.has(c) },
appendChild() {}, addEventListener() {}, querySelector: () => null,
};
}
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(),
};
const notes = [];
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]),
};
return { general, ctx, elements, notes };
}
function dirtyForm() {
const attrs = new Map([['data-dirty', '']]);
return {
querySelector: sel => (sel === '[name="name"]' ? { value: 'Home Assistant' } : null),
reset() {},
hasAttribute: name => attrs.has(name),
setAttribute: (name, value) => attrs.set(name, String(value)),
removeAttribute: name => attrs.delete(name),
};
}
(async () => {
console.log('\n── General tab: API token form ──');
{
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();
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 = await setup({ ok: false, body: { status: 'error', message: 'Name is required' } });
const form = dirtyForm();
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'));
}
console.log(`\n${pass} passed, ${fail} failed\n`);
process.exit(fail ? 1 : 0);
})().catch(e => { console.log('HARNESS ERROR: ' + e.stack); process.exit(1); });
+5 -4
View File
@@ -103,16 +103,17 @@ 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, 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.
// 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.
];
// The breakout payload: closes a double-quoted attribute and opens an event
@@ -1,137 +0,0 @@
// The Overview's "Plugin Config Warning" poll must end.
//
// The banner script in partials/overview.html asks
// /api/v3/plugins/reconciliation-status every 2 s until startup reconciliation
// says it is done. The route answers done: false whenever its status file is
// missing -- reconciliation raised before writing it, or /tmp was cleaned
// under a long-running web service -- so the poll used to run every 2 s for
// as long as the page stayed open, on every tab. It now gives up after a
// bounded number of tries and runs only while the Overview is on screen
// (LEDVisibility, like the other partials' pollers).
//
// Runs the shipped inline script in a vm with fake timers, fetch and DOM --
// no jsdom and no server needed.
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const PARTIAL = path.resolve(__dirname, '../../../web_interface/templates/v3/partials/overview.html');
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 bannerScript() {
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('ledmatrix-recon-dismissed'));
if (!found) throw new Error('reconciliation banner script not found in overview.html');
return found;
}
const flush = async () => { for (let i = 0; i < 10; i++) await new Promise(r => setImmediate(r)); };
function load({ payload, visibility = true }) {
const timers = new Map();
let nextId = 1;
const calls = [];
const banner = { style: { setProperty() {} }, dataset: {} };
const text = { textContent: '' };
const registrations = [];
const window = {};
if (visibility) {
window.LEDVisibility = {
onActive(tab, start, stop, key) { registrations.push({ tab, start, stop, key }); start(); },
};
}
const context = {
window,
document: {
getElementById: id => ({ 'reconciliation-banner': banner, 'reconciliation-banner-text': text })[id] || null,
},
sessionStorage: { getItem: () => null, setItem() {} },
fetch: (url) => {
calls.push(url);
return Promise.resolve({ json: () => Promise.resolve(payload()) });
},
setTimeout: (fn) => { const id = nextId++; timers.set(id, fn); return id; },
clearTimeout: (id) => { timers.delete(id); },
};
vm.createContext(context);
vm.runInContext(bannerScript(), context);
const fireTimers = async () => {
const due = [...timers.entries()];
timers.clear();
due.forEach(([, fn]) => fn());
await flush();
};
return { calls, timers, registrations, banner, text, window, fireTimers };
}
(async () => {
console.log('\n── Overview reconciliation poll ──');
// 1. A status file that never says done: the poll stops on its own.
{
const t = load({ payload: () => ({ status: 'success', data: { done: false, unresolved: [] } }) });
await flush();
for (let i = 0; i < 200; i++) await t.fireTimers();
ok('a status that never turns done stops being polled', t.timers.size === 0,
{ pending: t.timers.size, requests: t.calls.length });
ok('...after a bounded number of requests (at most 30, a minute at 2 s)',
t.calls.length > 1 && t.calls.length <= 30, t.calls.length);
}
// 2. Runs only while the Overview is on screen.
{
const t = load({ payload: () => ({ status: 'success', data: { done: false, unresolved: [] } }) });
await flush();
const reg = t.registrations[0];
ok('registers with LEDVisibility for the overview tab', !!reg && reg.tab === 'overview', reg && reg.tab);
ok('under its own key, so it does not replace another overview poller',
!!reg && !!reg.key && reg.key !== 'overview', reg && reg.key);
ok('first request goes out at once', t.calls.length === 1, t.calls.length);
if (reg) {
reg.stop();
ok('leaving the tab cancels the pending retry', t.timers.size === 0, t.timers.size);
for (let i = 0; i < 5; i++) await t.fireTimers();
ok('no requests while another tab is active', t.calls.length === 1, t.calls.length);
reg.start();
await flush();
ok('coming back asks again at once', t.calls.length === 2, t.calls.length);
ok('...and keeps polling', t.timers.size === 1, t.timers.size);
}
}
// 3. A finished reconciliation with findings shows the banner and stops.
{
let done = false;
const t = load({ payload: () => (done
? { status: 'success', data: { done: true, unresolved: [{ plugin_id: 'clock', type: 'plugin_missing_on_disk' }] } }
: { status: 'success', data: { done: false, unresolved: [] } }) });
await flush();
await t.fireTimers();
done = true;
await t.fireTimers();
ok('the banner names the finding once reconciliation is done',
t.text.textContent.includes('clock'), t.text.textContent);
const before = t.calls.length;
for (let i = 0; i < 5; i++) await t.fireTimers();
ok('no more requests once it is done', t.calls.length === before && t.timers.size === 0,
{ before, after: t.calls.length, pending: t.timers.size });
}
// 4. Without LEDVisibility (base.html always has it) it still runs, bounded.
{
const t = load({ visibility: false, payload: () => ({ status: 'success', data: { done: false } }) });
await flush();
ok('runs without LEDVisibility', t.calls.length === 1, t.calls.length);
for (let i = 0; i < 200; i++) await t.fireTimers();
ok('...and is still bounded', t.timers.size === 0 && t.calls.length <= 30, t.calls.length);
}
console.log(`\n${pass} passed, ${fail} failed\n`);
process.exit(fail ? 1 : 0);
})().catch(e => { console.log('HARNESS ERROR: ' + e.stack); process.exit(1); });
-40
View File
@@ -242,46 +242,6 @@ 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); });
-189
View File
@@ -1,189 +0,0 @@
// The shared plugin order list (widgets/plugin-order-list.js) keeps what it
// does not show.
//
// It lists enabled plugins only, and rewrites its hidden inputs from those
// rows as soon as it has drawn them. A disabled plugin's place in the order
// and its Vegas exclusion used to vanish from the inputs on that rewrite, so
// any later save of the Display or Rotation & Durations tab stored them
// without it: re-enabled, the plugin came back at the end of the rotation and
// scrolling in Vegas again. An uninstalled plugin's id is still dropped, as
// before, so the lists don't collect ids nothing can show. Runs the shipped
// widget in a vm with a minimal fake DOM -- no jsdom and no server needed, so
// it runs under test/test_js_unit_suites.py too.
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const WIDGET = path.resolve(__dirname, '../../../web_interface/static/v3/js/widgets/plugin-order-list.js');
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) : '')));
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
class FakeElement {
constructor(tag) {
this.tagName = tag.toUpperCase();
this.children = [];
this.parent = null;
this.dataset = {};
this.style = {};
this.className = '';
this.value = '';
this.checked = false;
this.listeners = {};
this._text = '';
}
appendChild(child) {
if (child.parent) child.parent.children = child.parent.children.filter(c => c !== child);
child.parent = this;
this.children.push(child);
return child;
}
insertBefore(child, ref) {
if (!ref) return this.appendChild(child);
if (child.parent) child.parent.children = child.parent.children.filter(c => c !== child);
child.parent = this;
this.children.splice(this.children.indexOf(ref), 0, child);
return child;
}
get previousElementSibling() {
const siblings = this.parent ? this.parent.children : [];
return siblings[siblings.indexOf(this) - 1] || null;
}
get nextElementSibling() {
const siblings = this.parent ? this.parent.children : [];
const i = siblings.indexOf(this);
return i < 0 ? null : siblings[i + 1] || null;
}
set textContent(value) { this._text = value; this.children = []; }
get textContent() { return this._text; }
setAttribute() {}
focus() {}
addEventListener(type, fn) { (this.listeners[type] ||= []).push(fn); }
fire(type, event) { (this.listeners[type] || []).forEach(fn => fn.call(this, event || {})); }
descendants() { return this.children.flatMap(c => [c, ...c.descendants()]); }
querySelectorAll(selector) {
const cls = selector.replace(/^\./, '');
return this.descendants().filter(e => e.className.split(/\s+/).includes(cls));
}
querySelector(selector) { return this.querySelectorAll(selector)[0] || null; }
}
/** Run the widget over `plugins` with the given saved inputs; resolves once it has drawn. */
async function mount({ plugins, order, excluded, fetchFails }) {
const els = {
list: new FakeElement('div'),
order: Object.assign(new FakeElement('input'), { value: JSON.stringify(order) }),
};
if (excluded !== undefined) {
els.excluded = Object.assign(new FakeElement('input'), { value: JSON.stringify(excluded) });
}
const context = {
// The widget logs a failed list; expected there, so kept off the output.
console: fetchFails ? Object.assign({}, console, { error: () => {} }) : console,
window: {},
document: {
getElementById: (id) => els[id] || null,
createElement: (tag) => new FakeElement(tag),
createTextNode: (text) => new FakeElement('#text'),
},
fetch: () => (fetchFails ? Promise.reject(new Error('service restarting')) : Promise.resolve({
json: () => Promise.resolve({ status: 'success', data: { plugins } }),
})),
};
vm.createContext(context);
vm.runInContext(fs.readFileSync(WIDGET, 'utf8'), context);
context.window.PluginOrderList.init({
containerId: 'list', orderInputId: 'order',
excludedInputId: excluded !== undefined ? 'excluded' : undefined,
});
await new Promise(resolve => setTimeout(resolve, 0));
const rows = () => els.list.querySelectorAll('.plugin-order-item');
return {
rows,
rowIds: () => rows().map(r => r.dataset.pluginId),
order: () => JSON.parse(els.order.value),
excluded: () => JSON.parse(els.excluded.value),
row: (id) => rows().find(r => r.dataset.pluginId === id),
};
}
const PLUGINS = [
{ id: 'weather', name: 'Weather', enabled: true },
{ id: 'clock', name: 'Clock', enabled: false },
{ id: 'stocks', name: 'Stocks', enabled: true },
];
(async () => {
console.log('\nVegas: a disabled plugin keeps its place and its exclusion');
{
const t = await mount({ plugins: PLUGINS, order: ['weather', 'clock', 'stocks'], excluded: ['clock'] });
ok('only enabled plugins get a row', same(t.rowIds(), ['weather', 'stocks']), t.rowIds());
ok('drawing the list keeps the disabled plugin in the order, in its place',
same(t.order(), ['weather', 'clock', 'stocks']), t.order());
ok('drawing the list keeps its exclusion', same(t.excluded(), ['clock']), t.excluded());
// Move Stocks up: the rows swap, and Clock stays in its saved slot.
const up = t.row('stocks').querySelectorAll('.plugin-order-move')[0];
up.fire('click');
ok('reordering the rows fills the other slots in the new order',
same(t.order(), ['stocks', 'clock', 'weather']), t.order());
const include = t.row('weather').querySelector('.plugin-order-include');
include.checked = false;
include.fire('change');
ok('unchecking a row adds it, and the disabled exclusion stays',
same([...t.excluded()].sort(), ['clock', 'weather']), t.excluded());
include.checked = true;
include.fire('change');
ok('checking it again removes only that one', same(t.excluded(), ['clock']), t.excluded());
}
console.log('\nRotation order: the same, without exclusions');
{
const plugins = [
{ id: 'clock', enabled: true },
{ id: 'off', enabled: false },
{ id: 'weather', enabled: true },
{ id: 'new', enabled: true },
];
const t = await mount({ plugins, order: ['clock', 'off', 'weather'] });
ok('the disabled plugin keeps its slot; a plugin not in the saved order goes last',
same(t.order(), ['clock', 'off', 'weather', 'new']), t.order());
}
console.log('\nAn uninstalled plugin is dropped; a failed list keeps everything');
{
const t = await mount({ plugins: PLUGINS, order: ['weather', 'gone', 'clock', 'stocks'],
excluded: ['gone', 'clock'] });
ok('the disabled plugin is kept and the uninstalled one dropped from the order',
same(t.order(), ['weather', 'clock', 'stocks']), t.order());
ok('and from the exclusions', same(t.excluded(), ['clock']), t.excluded());
}
{
const t = await mount({ plugins: PLUGINS, order: ['weather', 'gone', 'clock', 'stocks'],
excluded: ['gone', 'clock'], fetchFails: true });
// No installed list, so nothing can be told apart: no rows, and the
// inputs keep what was saved, uninstalled ids included.
ok('a failed plugin list draws no rows', t.rowIds().length === 0, t.rowIds());
ok('and leaves the saved order as it was',
same(t.order(), ['weather', 'gone', 'clock', 'stocks']), t.order());
ok('and the saved exclusions', same(t.excluded(), ['gone', 'clock']), t.excluded());
}
console.log('\nOnly what the server would accept is carried over');
{
const t = await mount({ plugins: PLUGINS, order: ['weather', 7, 'clock', null, 'clock', 'stocks'],
excluded: ['clock', 3, 'clock'] });
// /config/main refuses a list holding anything but strings, which would
// block every later Display save; a repeated id is kept once.
ok('non-string and repeated saved ids are dropped from the order',
same(t.order(), ['weather', 'clock', 'stocks']), t.order());
ok('and from the exclusions', same(t.excluded(), ['clock']), t.excluded());
}
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);
})().catch(e => { console.error(e); process.exit(1); });
+4 -23
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(notes) {
function load() {
const handlers = {};
const listen = (target) => (type, fn) => { (handlers[target + ':' + type] ||= []).push(fn); };
const banner = { style: { display: 'none' } };
@@ -43,18 +43,14 @@ function load(notes) {
removeItem: (k) => { delete store[k]; },
getItem: (k) => (k in store ? store[k] : null),
},
showNotification: (m, t) => { if (notes) notes.push([m, t]); },
showNotification: () => {},
setTimeout: () => 0,
};
vm.createContext(context);
vm.runInContext(fs.readFileSync(path.join(V3, 'app.js'), 'utf8'), context);
const afterRequest = (handlers['body:htmx:afterRequest'] || [])[0];
// `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),
};
const fire = ({ status = 200, body, path: reqPath = '/api/v3/anything', reportsItself = false }) => {
const elt = { closest: () => (reportsItself ? {} : null) };
afterRequest({
target: { closest: () => null },
detail: {
@@ -104,20 +100,5 @@ 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 -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_cached_registry_info = MagicMock(return_value=None)
api.plugin_store_manager.get_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_cached_registry_info = MagicMock(return_value=None)
api.plugin_store_manager.get_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
-97
View File
@@ -37,7 +37,6 @@ from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401
START_URL = "/api/v3/display/on-demand/start"
STOP_URL = "/api/v3/display/on-demand/stop"
MAILBOX = "display_on_demand_request"
DISPLAY = "web_interface.blueprints.api_v3.display"
@pytest.fixture
@@ -151,102 +150,6 @@ class TestStartWhileTheServiceIsStopped:
assert response.get_json()["status"] == "error"
class _Mailbox:
"""The CacheManager calls the routes make, over a dict."""
def __init__(self):
self.entries = {}
def set(self, key, value, ttl=None):
self.entries[key] = value
def get(self, key, max_age=300, memory_ttl=None):
return self.entries.get(key)
def delete(self, key):
self.entries.pop(key, None)
class TestARefusedStartLeavesNoRequestBehind:
"""A start the route answers with an error must not run later.
The request was posted (to the mailbox, with the display stopped) before
the route refused it, and the display reads the mailbox for an hour
without looking at a request's age. So "Display service is not running"
(start_service off) or "Failed to start display service" left the
request waiting, and the next time the display started -- minutes later,
by hand -- it ran that plugin, pinned if the request said so.
A socket acknowledgement is the other side of it: the display answered,
so it is running and has the request, whatever systemd says (a display
run by hand or in the emulator has no active unit). That is a success,
not "not running", and no unit is started beside it.
"""
@pytest.fixture
def mailbox(self, api_v3_module, service):
box = _Mailbox()
api_v3_module.api_v3.cache_manager = box
service["state"]["active"] = False
return box
@pytest.mark.parametrize("body", [
{"plugin_id": "weather", "start_service": False},
{"plugin_id": "weather"}, # start_service defaults on
])
def test_a_socket_ack_is_a_success_whatever_systemd_says(
self, api_v3_client, service, mailbox, body):
with patch(f"{DISPLAY}.control_client.on_demand_start",
side_effect=lambda request_id, *a: {"accepted": True}):
response = api_v3_client.post(START_URL, json=body)
assert response.status_code == 200, response.get_json()
assert response.get_json()["data"]["transport"] == "socket"
assert MAILBOX not in mailbox.entries
assert _systemctl_verbs(service["systemctl"]) == [], (
"a unit was started beside a display that answered the socket")
def test_without_start_service_the_request_is_taken_back(
self, api_v3_client, service, mailbox):
response = api_v3_client.post(START_URL, json={
"plugin_id": "weather", "pinned": True, "start_service": False})
assert response.status_code == 400
assert response.get_json()["status"] == "error"
assert MAILBOX not in mailbox.entries
def test_a_start_that_fails_takes_its_request_back(self, api_v3_client, service, mailbox):
service["systemctl"].side_effect = lambda args: {
"returncode": 1, "stdout": "", "stderr": "denied"}
response = api_v3_client.post(START_URL, json={"plugin_id": "weather"})
assert response.status_code == 500
assert MAILBOX not in mailbox.entries
def test_a_newer_request_is_left_alone_on_the_400(self, api_v3_client, service, mailbox):
newer = {"request_id": "someone-else", "action": "start", "plugin_id": "clock"}
def stopped_and_another_post_lands(*args):
mailbox.entries[MAILBOX] = newer
return {"active": False}
with patch(f"{DISPLAY}._get_display_service_status",
side_effect=stopped_and_another_post_lands):
response = api_v3_client.post(START_URL, json={
"plugin_id": "weather", "start_service": False})
assert response.status_code == 400
assert mailbox.entries[MAILBOX] is newer
def test_a_newer_request_is_left_alone_on_the_500(self, api_v3_client, service, mailbox):
newer = {"request_id": "someone-else", "action": "start", "plugin_id": "clock"}
def start_fails_after_another_post(args):
mailbox.entries[MAILBOX] = newer
return {"returncode": 1, "stdout": "", "stderr": "denied"}
service["systemctl"].side_effect = start_fails_after_another_post
response = api_v3_client.post(START_URL, json={"plugin_id": "weather"})
assert response.status_code == 500
assert mailbox.entries[MAILBOX] is newer
class TestStop:
def test_stop_posts_a_stop_request_and_leaves_the_service_running(
self, api_v3_client, service):
+7 -87
View File
@@ -1,13 +1,10 @@
"""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. 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
(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
the response says which, and that the request id is the same either way (the
display deduplicates on it).
@@ -104,15 +101,12 @@ class TestSocketPath:
class TestMailboxFallback:
@pytest.mark.parametrize("reason", [
"no_socket", "refused", "timeout", "closed", "bad_response", "invalid_request",
"busy", "forbidden", "unknown_command", "unsupported_version", "disabled",
"unsupported",
"busy", "unknown_command", "unsupported_version", "disabled", "unsupported",
])
def test_a_request_the_socket_never_carried_writes_the_mailbox(
def test_any_socket_failure_writes_the_mailbox_as_before(
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", sent=False)):
side_effect=control_client.ControlError(reason, "x")):
resp = api_v3_client.post(START_URL, json={
"plugin_id": "weather", "mode": "weather_current",
"duration": 60, "pinned": True})
@@ -174,60 +168,6 @@ 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
@@ -264,23 +204,3 @@ 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)
@@ -1,83 +0,0 @@
"""GET /api/v3/plugins/operation/<id> answers for an operation still waiting.
PluginOperationQueue keeps an operation's callback in its parameters, under
``_callback``, until the worker takes it to run. PluginOperation.to_dict()
returned the parameters as they were, so for a pending operation the route
handed jsonify a function and answered 500 "A system error occurred". That
is every poll of an install queued behind another plugin's: the second of
two installs read as broken until the first one finished.
"""
import json
import sys
import threading
from pathlib import Path
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.plugin_system.operation_queue import PluginOperationQueue # noqa: E402
from src.plugin_system.operation_types import ( # noqa: E402
OperationType, PluginOperation,
)
def _callback(op):
return {"success": True, "message": "done"}
class TestToDict:
def test_private_parameters_are_left_out(self):
op = PluginOperation(OperationType.INSTALL, "demo",
parameters={"_callback": _callback, "branch": "main"})
assert op.to_dict()["parameters"] == {"branch": "main"}
json.dumps(op.to_dict()) # serializable
def test_the_operation_keeps_its_callback_for_the_worker(self):
op = PluginOperation(OperationType.INSTALL, "demo",
parameters={"_callback": _callback})
op.to_dict()
assert op.parameters["_callback"] is _callback
def test_the_other_fields_are_unchanged(self):
op = PluginOperation(OperationType.UNINSTALL, "demo", operation_id="op-1")
assert op.to_dict() == {
"operation_id": "op-1", "operation_type": "uninstall", "plugin_id": "demo",
"parameters": {}, "status": "pending", "progress": 0.0, "message": "",
"error": None, "result": None,
"created_at": op.created_at.isoformat(), "started_at": None,
"completed_at": None,
}
class TestTheRoute:
@pytest.fixture
def busy_queue(self, api_v3_module):
"""A real queue whose worker is held by another plugin's operation."""
queue = PluginOperationQueue(max_history=10)
api_v3_module.api_v3.operation_queue = queue
started, release = threading.Event(), threading.Event()
def blocker(op):
started.set()
release.wait(10)
return {"success": True, "message": "done"}
queue.enqueue_operation(OperationType.INSTALL, "busy", operation_callback=blocker)
assert started.wait(5)
yield queue
release.set()
queue.shutdown()
def test_a_pending_operation_reports_pending(self, api_v3_client, busy_queue):
op_id = busy_queue.enqueue_operation(
OperationType.INSTALL, "demo", operation_callback=_callback)
response = api_v3_client.get(f"/api/v3/plugins/operation/{op_id}")
assert response.status_code == 200, response.get_json()
data = response.get_json()["data"]
assert data["status"] == "pending"
assert data["plugin_id"] == "demo"
assert "_callback" not in data["parameters"]
-26
View File
@@ -253,32 +253,6 @@ class TestVegasCycleDurations:
assert saved['config']['display']['display_durations'] == {'clock': 45}
class TestMalformedBody:
"""A JSON body that does not parse is the caller's mistake: a 400.
get_json() raised Werkzeug's BadRequest inside the handler's try, whose
catch-all answered 500 CONFIG_SAVE_FAILED with "check file permissions"
advice and logged a traceback at ERROR.
"""
def test_is_a_400_in_the_raw_routes_shape(self, api_v3_client, saved, api_v3_module):
api_v3_module.api_v3.config_manager.get_raw_file_content.return_value = {}
resp = api_v3_client.post('/api/v3/config/main', data='{not json',
content_type='application/json')
assert resp.status_code == 400
assert resp.get_json() == {'status': 'error', 'message': 'Invalid JSON in request body'}
assert 'config' not in saved
raw = api_v3_client.post('/api/v3/config/raw/main', data='{not json',
content_type='application/json')
assert (raw.status_code, raw.get_json()) == (400, resp.get_json())
def test_an_empty_json_post_is_still_no_data(self, api_v3_client, saved):
resp = api_v3_client.post('/api/v3/config/main', data='',
content_type='application/json')
assert resp.status_code == 400
assert resp.get_json()['message'] == 'No data provided'
class TestRawSaveStartsAutoUpdateSetup:
@pytest.fixture
def raw_env(self, api_v3_module, monkeypatch):
-93
View File
@@ -1,93 +0,0 @@
"""POST /api/v3/plugins/action hands ``params`` to the plugin's script intact.
The route runs the script through a generated wrapper, and the params went
into that wrapper as Python source: ``params = {json.dumps(params)}``. JSON is
not Python. ``true``, ``false`` and ``null`` are undefined names there, so any
params holding a boolean or a null died with a NameError before the script
ran. The plugin file manager's category toggle sends ``{"category_name": ...,
"enabled": true}``, so of-the-day's category toggle failed every time with
"Action failed".
The script's side of the contract is unchanged and pinned here too: the
params arrive on stdin as one JSON document, LEDMATRIX_ROOT is set, and what
the script prints to stdout is what the route parses.
"""
import json
import subprocess
import sys
from pathlib import Path
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
ACTION_URL = "/api/v3/plugins/action"
# The action script: report what it was handed, as JSON on stdout.
ECHO_SCRIPT = (
"import json, os, sys\n"
"raw = sys.stdin.read()\n"
"print(json.dumps({'status': 'success', 'got': json.loads(raw),\n"
" 'root': os.environ.get('LEDMATRIX_ROOT')}))\n"
)
@pytest.fixture
def echo_plugin(tmp_path, api_v3_module, monkeypatch):
plugin_dir = tmp_path / "demo"
plugin_dir.mkdir()
(plugin_dir / "manifest.json").write_text(json.dumps({
"id": "demo",
"web_ui_actions": [{"id": "toggle", "type": "script", "script": "echo.py"}],
}), encoding="utf-8")
(plugin_dir / "echo.py").write_text(ECHO_SCRIPT, encoding="utf-8")
api_v3_module.api_v3.plugin_catalog.get_plugin_directory.return_value = str(plugin_dir)
# The route runs `python3`; use this interpreter, so the test does not
# depend on what that name resolves to here.
real_run = subprocess.run
def run(cmd, *args, **kwargs):
if isinstance(cmd, list) and cmd and cmd[0] == "python3":
cmd = [sys.executable] + cmd[1:]
return real_run(cmd, *args, **kwargs)
monkeypatch.setattr(subprocess, "run", run)
return plugin_dir
@pytest.mark.parametrize("params", [
{"category_name": "jokes", "enabled": True}, # the file manager's toggle
{"category_name": "jokes", "enabled": False},
{"filename": None},
{"nested": {"list": [1, None, True, 2.5], "empty": {}}},
{"text": "café ✓ \U0001F600"},
{"text": "he said \"hi\" and 'bye' \\ ''' \"\"\" \n\t end"},
], ids=["true", "false", "null", "nested", "unicode", "quotes"])
def test_the_script_receives_the_params_it_was_sent(api_v3_client, echo_plugin, params):
response = api_v3_client.post(ACTION_URL, json={
"plugin_id": "demo", "action_id": "toggle", "params": params})
body = response.get_json()
assert response.status_code == 200, body
assert body["got"] == params
def test_a_param_cannot_run_code_in_the_wrapper(api_v3_client, echo_plugin, tmp_path):
marker = tmp_path / "PWNED"
hostile = "\"}\nopen(%r, 'w').write('ran')\n#" % str(marker)
params = {"name": hostile, "flag": True}
response = api_v3_client.post(ACTION_URL, json={
"plugin_id": "demo", "action_id": "toggle", "params": params})
assert response.status_code == 200, response.get_json()
assert response.get_json()["got"] == params
assert not marker.exists(), "a param value ran as code"
def test_the_script_still_gets_ledmatrix_root(api_v3_client, echo_plugin, api_v3_module):
response = api_v3_client.post(ACTION_URL, json={
"plugin_id": "demo", "action_id": "toggle", "params": {"enabled": True}})
assert response.status_code == 200, response.get_json()
assert response.get_json()["root"] == str(api_v3_module.PROJECT_ROOT)
@@ -1,89 +0,0 @@
"""A second install or uninstall while one is in progress is a 409, not a 500.
PluginOperationQueue refuses a second operation for a plugin that already
has one waiting or running (test_operation_queue_pending_and_trim.py), and
says so by raising ValueError. /plugins/install let that escape to the
blueprint's catch-all, so a double-clicked Install answered 500 "An error
occurred; see logs for details" while the first install carried on.
/plugins/uninstall caught it in its own catch-all: a 500 "Failed to
uninstall plugin", and an "uninstall failed" entry in the operation
history for an uninstall that never started.
"""
import sys
import threading
from pathlib import Path
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.plugin_system.operation_queue import PluginOperationQueue # noqa: E402
INSTALL = "/api/v3/plugins/install"
UNINSTALL = "/api/v3/plugins/uninstall"
@pytest.fixture
def installing(api_v3_module, tmp_path):
"""A real queue with an install of "clock" running and held there."""
queue = PluginOperationQueue(max_history=10)
api_v3_module.api_v3.operation_queue = queue
started, release = threading.Event(), threading.Event()
def slow_install(plugin_id, branch=None):
started.set()
release.wait(10)
return True
store = api_v3_module.api_v3.plugin_store_manager
store.install_plugin.side_effect = slow_install
store.get_registry_info.return_value = None
store.plugins_dir = str(tmp_path)
api_v3_module.api_v3.plugin_catalog.get_plugin_directory.return_value = None
yield {"queue": queue, "started": started, "store": store}
release.set()
queue.shutdown()
def _start_first_install(client, installing):
response = client.post(INSTALL, json={"plugin_id": "clock"})
assert response.status_code == 200, response.get_json()
assert installing["started"].wait(5)
def _failed_history(api_v3_module):
return [c for c in api_v3_module.api_v3.operation_history.record_operation.call_args_list
if c.kwargs.get("status") == "failed"]
def test_a_second_install_click_is_a_conflict(api_v3_client, api_v3_module, installing):
_start_first_install(api_v3_client, installing)
response = api_v3_client.post(INSTALL, json={"plugin_id": "clock"})
assert response.status_code == 409, response.get_json()
body = response.get_json()
assert body["status"] == "error"
assert body["error_code"] == "PLUGIN_OPERATION_CONFLICT"
assert "clock" in body["message"]
assert installing["store"].install_plugin.call_count == 1
assert _failed_history(api_v3_module) == []
def test_an_uninstall_during_the_install_is_a_conflict(api_v3_client, api_v3_module,
installing):
_start_first_install(api_v3_client, installing)
response = api_v3_client.post(UNINSTALL, json={"plugin_id": "clock"})
assert response.status_code == 409, response.get_json()
assert response.get_json()["error_code"] == "PLUGIN_OPERATION_CONFLICT"
assert _failed_history(api_v3_module) == [], (
"an uninstall that never started was recorded as failed")
api_v3_module.api_v3.plugin_store_manager.uninstall_plugin.assert_not_called()
def test_another_plugin_is_still_queued(api_v3_client, installing):
_start_first_install(api_v3_client, installing)
response = api_v3_client.post(INSTALL, json={"plugin_id": "weather"})
assert response.status_code == 200, response.get_json()
assert response.get_json()["data"]["operation_id"]
-73
View File
@@ -1,73 +0,0 @@
"""GET /api/v3/plugins/<plugin_id>/static/<path> serves binary files too.
The route opened every file as UTF-8 text, so an image -- what the API
reference says it is for, plugin previews and icons -- failed to decode and
answered 500 "UnicodeDecodeError". Files are now sent as bytes. The text
types the route always set are unchanged, and the path checks are pinned in
test_path_traversal_guards.py::TestServePluginStatic.
"""
import json
import sys
from pathlib import Path
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
PNG = (b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
b"\x08\x06\x00\x00\x00\x1f\x15\xc4\x89")
@pytest.fixture
def plugin_dir(tmp_path, api_v3_module):
d = tmp_path / "demo"
(d / "web_ui").mkdir(parents=True)
(d / "manifest.json").write_text(json.dumps({"id": "demo"}), encoding="utf-8")
api_v3_module.api_v3.plugin_catalog.get_plugin_directory.side_effect = (
lambda pid: str(d) if pid == "demo" else None)
return d
def _get(client, path):
return client.get(f"/api/v3/plugins/demo/static/{path}")
def test_an_image_is_served_as_its_bytes(api_v3_client, plugin_dir):
(plugin_dir / "web_ui" / "icon.png").write_bytes(PNG)
response = _get(api_v3_client, "web_ui/icon.png")
assert response.status_code == 200, response.get_json(silent=True)
assert response.mimetype == "image/png"
assert response.data == PNG
def test_an_unknown_binary_file_is_served_too(api_v3_client, plugin_dir):
blob = bytes(range(256))
(plugin_dir / "data.bin").write_bytes(blob)
response = _get(api_v3_client, "data.bin")
assert response.status_code == 200, response.get_json(silent=True)
assert response.data == blob
@pytest.mark.parametrize("name,mimetype", [
("page.html", "text/html"),
("app.js", "application/javascript"),
("style.css", "text/css"),
("data.json", "application/json"),
("notes.txt", "text/plain"),
("README.md", "text/plain"),
("helper.py", "text/plain"),
])
def test_text_files_keep_their_types(api_v3_client, plugin_dir, name, mimetype):
content = "caf\u00e9 \u2713 <p>hi</p>\n"
(plugin_dir / name).write_bytes(content.encode("utf-8"))
response = _get(api_v3_client, name)
assert response.status_code == 200
assert response.mimetype == mimetype
assert response.data == content.encode("utf-8")
def test_a_missing_file_is_still_a_404(api_v3_client, plugin_dir):
assert _get(api_v3_client, "nope.png").status_code == 404
-326
View File
@@ -1,326 +0,0 @@
"""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
+1 -38
View File
@@ -58,8 +58,7 @@ class TestFastPath:
for _ in range(10):
again = m.load_config()
assert counts["n"] == 0, "fast path must not re-open any config file"
assert again == first
assert again is not first # each caller gets its own copy, see below
assert again is first # same aliasing semantics as the full path
def test_config_change_triggers_reload(self, mgr):
m, config, secrets, template = mgr
@@ -99,33 +98,6 @@ class TestFastPath:
assert m.load_config()["timezone"] == "America/New_York"
class TestCallersGetACopy:
"""A web handler edits what load_config returned, then validates. When
validation failed, the edit stayed in the cache the fast path serves, and
the next unrelated save wrote it -- a nested secret included, in plain
text, because it had never reached config_secrets.json to be stripped."""
def test_editing_a_loaded_config_does_not_change_the_next_load(self, mgr):
m, config, secrets, template = mgr
loaded = m.load_config()
loaded["display"]["brightness"] = 1
loaded["weather"]["api_key"] = "typed-but-never-saved"
again = m.load_config()
assert again["display"]["brightness"] == 90
assert again["weather"]["api_key"] == "sek"
def test_the_full_path_also_returns_a_copy(self, mgr):
m, config, secrets, template = mgr
m.load_config()["display"]["brightness"] = 1 # first load: full path
assert m.load_config()["display"]["brightness"] == 90
def test_an_edit_never_reaches_a_later_save(self, mgr):
m, config, secrets, template = mgr
m.load_config()["display"]["new_secret"] = "hunter2" # then bailed out
m.save_config(m.load_config()) # some other handler saves
assert "hunter2" not in config.read_text()
class TestSaveCoherence:
def test_save_config_then_load_returns_saved_data(self, mgr, monkeypatch):
m, config, secrets, template = mgr
@@ -139,15 +111,6 @@ class TestSaveCoherence:
assert loaded["weather"]["api_key"] == "sek" # secrets survive in memory
assert counts["n"] == 0 # signature refreshed by save; no re-read
def test_the_saved_dict_does_not_become_the_cache(self, mgr):
m, config, secrets, template = mgr
m.load_config()
new = {"display": {"brightness": 42}, "timezone": "UTC",
"weather": {"api_key": "sek"}}
m.save_config(new)
new["display"]["brightness"] = 7 # the caller keeps using its dict
assert m.load_config()["display"]["brightness"] == 42
def test_cross_process_save_is_picked_up(self, mgr):
"""Another process writing config.json (different mtime) must bust
this process's fast path — the core cross-process guarantee."""
+1 -4
View File
@@ -143,10 +143,7 @@ class TestLoadFastPath:
manager = make_manager(tmp_path, config={"timezone": "UTC"})
first = manager.load_config()
second = manager.load_config()
# A copy of the cached dict, never the dict itself; that it is not
# re-read is test_config_load_cache's test_unchanged_files_are_not_reread
assert second == first
assert second is not first
assert second is first # same aliased dict, no re-read
def test_touching_secrets_file_invalidates_cache(self, tmp_path):
manager = make_manager(
+12 -399
View File
@@ -9,7 +9,6 @@ that overrides the schedule and ends (#714).
"""
import itertools
from dataclasses import replace
import os
from unittest.mock import MagicMock, patch
@@ -20,7 +19,6 @@ 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,
@@ -28,9 +26,6 @@ from src.display_arbiter import ( # noqa: E402
ScreenPlan,
Source,
WifiNotice,
live_pick,
live_takeover,
on_demand_bound,
wifi_notice_preempts,
)
@@ -39,9 +34,7 @@ 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.
@@ -53,17 +46,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, ONDEM), # on-demand overrides the gate
(False, True, False, NOTICE, ONDEM), # on-demand outranks WiFi
(False, True, False, None, LEGACY), # on-demand: run() decides
(False, True, False, NOTICE, LEGACY), # on-demand outranks WiFi
(False, True, True, None, FOLLOW), # follower outranks on-demand
(False, True, True, NOTICE, FOLLOW),
# Scheduled on.
(True, False, False, None, ROTATION), # live / Vegas / rotation
(True, False, False, None, LEGACY), # 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, ONDEM),
(True, True, False, NOTICE, ONDEM), # on-demand outranks WiFi
(True, True, False, None, LEGACY),
(True, True, False, NOTICE, LEGACY), # on-demand outranks WiFi
(True, True, True, None, FOLLOW),
(True, True, True, NOTICE, FOLLOW),
]
@@ -102,15 +95,8 @@ 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.
# A follower paces itself; LEGACY is run()'s existing code.
assert plan == ScreenPlan(expected)
def test_dwells_are_todays(self):
@@ -238,389 +224,16 @@ class TestControllerSnapshot:
assert (plan.source is OFF) is (not dc.is_display_active), time_str
return plan.source
assert step("22:59:30") is ROTATION
assert step("22:59:30") is LEGACY
assert step("23:00:00") is OFF # window ends
dc.on_demand_active = True
assert step("23:00:10") is ONDEM # on-demand overrides
assert step("23:00:10") is LEGACY # on-demand overrides
assert dc.on_demand_schedule_override is True
assert step("23:01:00") is ONDEM # next minute, still on
assert step("23:01:00") is LEGACY # 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 ONDEM
assert step("07:00:00") is ONDEM # schedule back on mid-session
assert step("06:59:00") is LEGACY
assert step("07:00:00") is LEGACY # schedule back on mid-session
dc._reset_on_demand_fields()
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
assert step("07:00:30") is LEGACY
-142
View File
@@ -1,142 +0,0 @@
"""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,157 +430,6 @@ 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,171 +489,3 @@ 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
+162
View File
@@ -0,0 +1,162 @@
"""Tests for src/common/espn_payload.py and its use by BackgroundDataService."""
import copy
import time
from unittest.mock import MagicMock, Mock, patch
import pytest
from src.background_data_service import BackgroundDataService, shutdown_background_service
from src.common.espn_payload import is_espn_scoreboard_url, slim_scoreboard_payload
SCOREBOARD = "https://site.api.espn.com/apis/site/v2/sports/baseball/mlb/scoreboard"
def _event():
"""One event carrying every key the slimming drops and a sample of the
keys scoreboards read, at the depth ESPN puts them."""
competitor = {
"id": "10",
"homeAway": "home",
"score": "5",
"team": {"abbreviation": "NYY", "logo": "https://a/l.png",
"links": [{"href": "https://espn.com/team"}]},
"records": [{"summary": "90-60"}],
"linescores": [{"value": 1}],
"statistics": [{"name": "hits", "displayValue": "9"}],
"leaders": [{"name": "avg", "leaders": [{"athlete": {"id": "1"}}]}],
"probables": [{"athlete": {"id": "2"}, "statistics": []}],
}
return {
"id": "401",
"date": "2026-10-01T23:05Z",
"links": [{"href": "https://espn.com/game"}],
"status": {"type": {"state": "post"}},
"competitions": [{
"status": {"type": {"state": "post", "shortDetail": "Final"},
"featuredAthletes": [{"athlete": {"id": "3"}}]},
"competitors": [competitor, dict(copy.deepcopy(competitor), homeAway="away")],
"odds": [{"details": "NYY -150", "overUnder": 8.5}],
"situation": {"outs": 2},
"notes": [{"headline": "Game 1"}],
"broadcasts": [{"names": ["FOX"]}],
"venue": {"fullName": "Yankee Stadium"},
"leaders": [{"name": "hits"}],
"headlines": [{"description": "recap"}],
"highlights": [{"links": {"source": {}}}],
"geoBroadcasts": [{"media": {"shortName": "FOX"}}],
}],
}
class TestSlimScoreboardPayload:
def test_drops_exactly_the_listed_keys(self):
payload = {"leagues": [{"id": "10"}], "events": [_event()]}
slim_scoreboard_payload(payload)
event = payload["events"][0]
competition = event["competitions"][0]
assert "links" not in event
for key in ("leaders", "headlines", "highlights", "geoBroadcasts"):
assert key not in competition
assert "featuredAthletes" not in competition["status"]
for competitor in competition["competitors"]:
assert "leaders" not in competitor
assert "probables" not in competitor
assert "links" not in competitor["team"]
def test_keeps_everything_else_unchanged(self):
"""Removing the dropped keys from the original by hand gives exactly
the slimmed payload: nothing else moved, changed or went missing."""
original = {"leagues": [{"id": "10"}], "events": [_event(), _event()]}
expected = copy.deepcopy(original)
for event in expected["events"]:
del event["links"]
competition = event["competitions"][0]
for key in ("leaders", "headlines", "highlights", "geoBroadcasts"):
del competition[key]
del competition["status"]["featuredAthletes"]
for competitor in competition["competitors"]:
del competitor["leaders"], competitor["probables"]
del competitor["team"]["links"]
assert slim_scoreboard_payload(original) == expected
def test_in_place_and_returns_payload(self):
payload = {"events": [_event()]}
assert slim_scoreboard_payload(payload) is payload
@pytest.mark.parametrize("payload", [
None, [], "x", {}, {"events": None}, {"events": "x"},
{"events": [None, 1, "x", {"competitions": None}]},
{"events": [{"competitions": [None, {"status": None, "competitors": None}]}]},
{"events": [{"competitions": [{"competitors": [None, {"team": None}]}]}]},
])
def test_odd_shapes_pass_through(self, payload):
before = copy.deepcopy(payload)
assert slim_scoreboard_payload(payload) == before
class TestIsEspnScoreboardUrl:
@pytest.mark.parametrize("url", [
SCOREBOARD,
SCOREBOARD + "/",
"http://site.api.espn.com/apis/site/v2/sports/football/college-football/scoreboard",
])
def test_scoreboards(self, url):
assert is_espn_scoreboard_url(url)
@pytest.mark.parametrize("url", [
None, "", 12,
"https://site.api.espn.com/apis/site/v2/sports/baseball/mlb/teams",
"https://site.api.espn.com/apis/site/v2/sports/football/nfl/summary",
"https://example.com/scoreboard",
"https://espn.com.evil.example/apis/x/scoreboard",
"https://notespn.com/apis/x/scoreboard",
])
def test_not_scoreboards(self, url):
assert not is_espn_scoreboard_url(url)
@pytest.fixture
def service():
shutdown_background_service()
cache = MagicMock()
cache.get.return_value = None
svc = BackgroundDataService(cache, max_workers=1, request_timeout=5)
yield svc
svc.shutdown(wait=False)
shutdown_background_service()
def _run(service, url, **kwargs):
response = Mock(status_code=200)
response.json.return_value = {"events": [_event()]}
response.raise_for_status.return_value = None
delivered = []
with patch.object(service.session, "get", return_value=response):
req_id = service.submit_fetch_request(
sport="mlb", year=2026, url=url, cache_key="mlb_schedule_window_14_7",
callback=lambda result: delivered.append(result.data), **kwargs)
deadline = time.time() + 5
while not service.is_request_complete(req_id) and time.time() < deadline:
time.sleep(0.02)
cached = service.cache_manager.set.call_args[0][1]
return cached, delivered
class TestBackgroundServiceSlims:
def test_espn_scoreboard_is_cached_and_delivered_slimmed(self, service):
cached, delivered = _run(service, SCOREBOARD)
competition = cached["events"][0]["competitions"][0]
assert "leaders" not in competition
assert "probables" not in competition["competitors"][0]
assert competition["odds"] and competition["situation"]
# The callback sees the very payload that was cached.
assert delivered and delivered[0] is cached
def test_opt_out_caches_whole_response(self, service):
cached, _ = _run(service, SCOREBOARD, slim_payload=False)
assert cached == {"events": [_event()]}
def test_other_urls_untouched(self, service):
cached, _ = _run(service, "https://example.com/feed")
assert cached == {"events": [_event()]}
+2 -38
View File
@@ -576,42 +576,6 @@ 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"):
@@ -706,14 +670,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_request_chunks, fetch_espn_date_chunks, parse_espn_date_range
from src.common.espn_dates import espn_date_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_request_chunks(*parse_espn_date_range(dates)))
chunks = len(espn_date_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,149 +185,6 @@ 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.ROTATION
assert Arbiter.decide(ArbiterState(), inputs, 0.0).source is Source.LEGACY
assert dm.is_currently_scrolling()
assert dm._frame_hold == 2
@@ -1,188 +0,0 @@
"""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()
+1 -2
View File
@@ -167,8 +167,7 @@ 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},
Command.ERRORS_CLEAR: {'cutoff': 1790000000.0}}.get(cmd, {})
Command.BRIGHTNESS_SET: {'brightness': 50}}.get(cmd, {})
c.parse_args(cmd, args)
def test_hello_versions(self):
+5 -15
View File
@@ -41,15 +41,6 @@ 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
@@ -262,8 +253,7 @@ 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) # the dwell's check
dc._read_wifi_notice = MagicMock(return_value=None) # the frame wait's
dc._wifi_notice_pending = MagicMock(return_value=False)
dc._tick_plugin_updates = MagicMock()
dc._check_live_takeover = MagicMock()
dc.cache_manager.get = MagicMock(return_value=None)
@@ -292,10 +282,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, _screen('clock'))
ended = dc._wait_frame_interval(1.0, 'clock')
woke = time.monotonic()
t.join()
assert ended is not None
assert ended is True
latencies.append(woke - stamps[0])
latencies.sort()
print(f"static-screen wake latency: median {latencies[5] * 1000:.2f} ms, "
@@ -325,7 +315,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, _screen('clock')) is None
assert dc._wait_frame_interval(0.5, 'clock') is False
assert time.monotonic() - started >= 0.49
t.join()
dc.display_manager.set_brightness.assert_called_once_with(33)
@@ -333,7 +323,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, _screen('clock')) is None
assert dc._wait_frame_interval(0.2, 'clock') is False
assert time.monotonic() - started >= 0.19
-509
View File
@@ -1,509 +0,0 @@
"""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
@@ -1,206 +0,0 @@
"""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
-19
View File
@@ -136,22 +136,3 @@ class TestCleartextCredentialsNeedAnExplicitOptIn:
"allow_insecure_mqtt": "false"})
assert r.status_code == 400
def test_the_settings_read_reports_the_opt_in(self, client, monkeypatch):
"""The Tools form prefills its "Allow without TLS" box from the GET.
Off until someone saves it on, so an untouched form sends false and
the guard above still refuses a cleartext password.
"""
c, _ = client
monkeypatch.setattr(misc, "_mqtt_bridge_service_state",
lambda: {"installed": False, "active": False, "enabled": False})
def read():
return c.get("/api/v3/integrations/mqtt-bridge").get_json()["data"]["config"]
assert read()["allow_insecure_mqtt"] is False
r = c.put(URL, json={"mqtt_password": "hunter2", "mqtt_tls": False,
"allow_insecure_mqtt": True})
assert r.status_code == 200, r.get_json()
assert read()["allow_insecure_mqtt"] is True
-164
View File
@@ -1,164 +0,0 @@
"""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,7 +69,6 @@ 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
@@ -1,418 +0,0 @@
"""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_cached_registry_info = MagicMock(return_value=None)
api.plugin_store_manager.get_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,32 +133,6 @@ 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.
@@ -220,8 +194,6 @@ 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
@@ -1,455 +0,0 @@
"""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
@@ -1,183 +0,0 @@
"""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
@@ -1,280 +0,0 @@
"""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
@@ -1,135 +0,0 @@
"""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
@@ -1,275 +0,0 @@
"""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
@@ -1,123 +0,0 @@
"""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
+11 -41
View File
@@ -8,9 +8,7 @@ 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. 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
it. 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).
"""
@@ -574,24 +572,6 @@ 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):
@@ -600,10 +580,10 @@ class TestParityWithPlugins:
ours = _dump(_core_definitions()[name])
drifted, missing = [], []
for sport in carriers:
source = _sports_source(root, sport)
theirs = _definitions(ast.parse(source))[where].get(plugin_name)
defs = _definitions(ast.parse(
(root / f"{sport}-scoreboard" / "sports.py").read_text(encoding="utf-8")))
theirs = defs[where].get(plugin_name)
if theirs is None:
if not _adopted(source):
missing.append(sport)
elif _dump(theirs) != ours:
drifted.append(sport)
@@ -614,20 +594,10 @@ class TestParityWithPlugins:
@pytest.mark.parametrize("sport", SCOREBOARDS)
def test_constants_match(self, sport):
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}"
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)
-130
View File
@@ -465,136 +465,6 @@ 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)

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