mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-05 14:55:08 +00:00
Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11cfa639ee | ||
|
|
ec95340d4a | ||
|
|
a669d781f5 | ||
|
|
c59a779381 | ||
|
|
c20c0beac2 | ||
|
|
6fb2dc3595 | ||
|
|
fd0a4b50f9 | ||
|
|
b638b91169 | ||
|
|
c461f4efdb | ||
|
|
bb475a79ea | ||
|
|
3f920f2899 | ||
|
|
0577c807eb | ||
|
|
5a7893b11a | ||
|
|
3866aa4519 | ||
|
|
26cae3e5d6 | ||
|
|
294bd522bd | ||
|
|
80048fe78e | ||
|
|
378478124c | ||
|
|
d447cdd965 | ||
|
|
ee7c3389f9 | ||
|
|
caec9f5bf5 |
+324
-29
@@ -19,27 +19,275 @@ accepts both, but the store flags the old spelling as deprecated
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Scroll speed: a panel slower than its refresh cap is reported
|
||||
### Removed: the cache-key mailboxes (control socket stage 5) -- breaking
|
||||
|
||||
- Scroll speeds are solved against `limit_refresh_rate_hz`, so a panel that
|
||||
cannot reach its cap ran every scroll slow by the shortfall, with no sign
|
||||
why (one Pi 4 on a 120 Hz cap refreshed at ~110 Hz: 60 px/s ran at 55).
|
||||
Once the display has measured the real rate over three windows of
|
||||
scrolling, a panel more than 3% short of the cap is logged once, as a
|
||||
warning from `src.common.frame_timing` that names a cap it can hold (a
|
||||
multiple of 10, 5% under the measurement). The Display tab shows the same
|
||||
under Limit Refresh Rate, with a button that fills it in, from the new
|
||||
`GET /api/v3/config/refresh-rate`. Not checked in the emulator or on the
|
||||
fallback canvas.
|
||||
- The frame-stats file records `planned_refresh_hz` (additive), and the
|
||||
scroll-speed advice behind the Vegas slider ignores a measurement written
|
||||
under a different cap. Until now, after the cap changed, the slider kept
|
||||
advising from the old rate until the display restarted.
|
||||
- New in `src.common.scroll_config`: `refresh_shortfall()`, `holdable_cap()`
|
||||
and `describe_refresh_shortfall()`.
|
||||
The control socket (`docs/IPC_CONTROL_SOCKET.md`) is now the only way the
|
||||
web interface sends the display a command. The file mailboxes it fell back
|
||||
to for one release are gone.
|
||||
|
||||
- **`display_on_demand_request` and `plugin_error_clear_request` are no
|
||||
longer written or read.** The display stops polling the on-demand mailbox
|
||||
(`MailboxWatch`, `MAILBOX_POLL_INTERVAL_WITH_SOCKET`,
|
||||
`_consume_on_demand_request` and the persisted
|
||||
`display_on_demand_processed_id` guard are removed), and the error
|
||||
publisher stops reading the clear request. `CacheManager.file_signature`,
|
||||
`src.cache_manager.MailboxWatch`, `src.ipc.client.should_fall_back` and
|
||||
`src.error_aggregator.ERROR_CLEAR_REQUEST_KEY` are removed.
|
||||
- **A write to either key is dropped, with one warning per writer.**
|
||||
`CacheManager.save_cache` (and so `set`) refuses `RETIRED_MAILBOX_KEYS`
|
||||
and logs `Ignored a write to the retired '<key>' cache key by plugin
|
||||
'<id>'`, naming the plugin from the call stack or the request. Plugins
|
||||
must use `BasePlugin.request_on_demand()` / `end_on_demand()` (3.8.1).
|
||||
In the official monorepo, birdnet-go, mqtt-notifications, on-air and
|
||||
pomodoro-timer still write the mailbox, but only as their fallback when
|
||||
those methods are missing or answer `None`.
|
||||
- **On-demand routes without a listening display.** `POST
|
||||
/api/v3/display/on-demand/start` with no display listening starts the
|
||||
service (when `start_service`, the default) and answers **`202`** with
|
||||
`status: "starting"` at once; a single background worker in the web
|
||||
process (`web_interface/on_demand_dispatch.py`) sends the request until
|
||||
the display acknowledges it, for up to 45 s (10 s for a service that is
|
||||
running but has no socket yet). `GET /display/on-demand/status` reports it
|
||||
(`starting`, then the display's state, or `error` / `start-timeout`), and
|
||||
`/display/current-status` adds `on_demand_pending`. A newer start replaces
|
||||
a pending one and a stop cancels it (`cancelled_request_id`). The web UI
|
||||
and the MQTT bridge treat `202` as taken. With the service stopped and
|
||||
`start_service` false it answers `400`. Every other socket failure (`unknown_command`
|
||||
from an older display, `disabled`/`unsupported`, `busy`, a timeout) is a
|
||||
`503`. `/stop` answers `503` when no display is listening, unless
|
||||
`stop_service` stops the service. `transport` is always `"socket"`; the
|
||||
`"mailbox"` value is gone.
|
||||
- **`POST /api/v3/errors/clear` without the socket answers `503`** (with
|
||||
`context.socket_error` and a message saying why) instead of recording a
|
||||
request. `clear_pending` in the error routes is now always `false`;
|
||||
`src.error_aggregator.read_error_report()` returns only the snapshot, and
|
||||
`error_summary_from_report()` / `plugin_health_from_report()` /
|
||||
`request_error_clear()` lose their clear-request arguments.
|
||||
- **Kept:** the display still writes `display_current_state`,
|
||||
`display_on_demand_state`, `plugin_runtime_snapshot` and the heartbeat
|
||||
file, because the web interface reads them whenever the socket cannot
|
||||
answer (a stopped or starting display, a web user not yet in the socket's
|
||||
group, Windows), and `display_on_demand_config`, its own record for
|
||||
resuming a session after a restart.
|
||||
- **Windows and `LEDMATRIX_CONTROL_SOCKET=off`:** with no socket, the web
|
||||
interface can no longer start or stop on-demand sessions or clear errors
|
||||
on a running display (the mailbox used to carry them).
|
||||
|
||||
## 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
|
||||
@@ -52,6 +300,32 @@ accepts both, but the store flags the old spelling as deprecated
|
||||
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
|
||||
|
||||
@@ -76,6 +350,18 @@ soccer-scoreboard 2.39.2, alternating runs: **~450 requests per start, peak
|
||||
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
|
||||
@@ -514,6 +800,16 @@ policies are unchanged.
|
||||
- `src/display_arbiter.py` -- the display loop's Arbiter (see Tooling).
|
||||
Core-internal: plugins have no reason to import it, so it sets no
|
||||
`ledmatrix_min_version` floor.
|
||||
- `src/common/sports_game_over.py` -- `SportsGameOverMixin`, sports
|
||||
consolidation family 5: `_is_game_really_over`, the scoreboards'
|
||||
`SportsLive` check that drops a game ESPN still lists as live, once the
|
||||
plugins made their five bodies one. Over on a final period text, or on a
|
||||
0:00 clock from period `FINAL_PERIOD` on unless the score is level (a tie
|
||||
at the end of regulation goes to overtime). `FINAL_PERIOD` is the per-sport
|
||||
class attribute, `None` by default (the clock never ends a game); the
|
||||
scoreboards declare 3 (hockey), 4 (basketball, football, lacrosse) or
|
||||
`None`. List the mixin before `SportsLiveSharedMixin`. A plugin may import
|
||||
it once it floors on 3.8.1, and deletes its copy then. (#770)
|
||||
|
||||
### Tooling
|
||||
|
||||
@@ -1167,18 +1463,6 @@ guard the import, since the loader's version check is advisory).
|
||||
processes, or turns the socket off with `off`. A non-root dev run uses a
|
||||
private per-user path under the temp directory.
|
||||
|
||||
### Scroll speed
|
||||
|
||||
- The Vegas Scroll Speed slider now says what the panel will do with the speed
|
||||
it is on, and offers the nearest smooth ones to click. Only speeds that advance
|
||||
a whole number of pixels per refresh look smooth, and which those are depends
|
||||
on the panel (`GET /api/v3/config/scroll-speed-advice`, built on
|
||||
`scroll_config.speed_advice()`; it uses the refresh the display measured, not
|
||||
the `limit_refresh_rate_hz` cap). The slider steps by 1 px/s instead of 5.
|
||||
- The default 50 px/s no longer snaps to a stepped 48 px/s (2 px every 5
|
||||
refreshes, 24 fps) on a 120 Hz panel: `solve_crisp()` now prefers 60 or 40 px/s,
|
||||
which move one pixel at a time. 100 Hz panels are unaffected.
|
||||
|
||||
### Update channels
|
||||
|
||||
- Devices no longer pick up every merge to `main`. A new setting,
|
||||
@@ -1436,6 +1720,17 @@ read any of them:
|
||||
|
||||
### Fixes
|
||||
|
||||
- Updating a plugin from the store no longer deletes the files it wrote
|
||||
beside itself. A monorepo update replaces the plugin directory with the
|
||||
fresh download and deletes the old copy, so calendar's Google OAuth files
|
||||
(`token.pickle`, `credentials.json`) were lost on every update and the
|
||||
calendar stopped until they were restored by hand. Before the old copy is
|
||||
removed, the update now copies over anything the plugin's `.gitignore`
|
||||
excludes plus known secret/state files (`*.pickle`, `token.json`,
|
||||
`credentials.json`, `config_secrets.json`, `.pkce_code_verifier`); files the
|
||||
new release ships are never overwritten, and byte code is not carried. A
|
||||
plugin updated with `git pull` no longer sweeps an untracked token into the
|
||||
auto-stash, which is never popped (`src/plugin_system/plugin_local_files.py`).
|
||||
- Quieter routine logging. Every rotation logged each mode twice
|
||||
("Switching to mode", then "Processing mode"), and a mode with nothing to
|
||||
show added "display() returned False" and "No content to display". Those
|
||||
|
||||
+17
-36
@@ -649,10 +649,11 @@ When nothing is running on demand, `data.state` is
|
||||
> on-demand machinery is internal — drive it through the REST endpoints
|
||||
> above (or the web UI buttons). The API handlers
|
||||
> (`start_on_demand_display()` / `stop_on_demand_display()` in
|
||||
> `web_interface/blueprints/api_v3/display.py`) write a request into the cache
|
||||
> manager under the `display_on_demand_request` key, which
|
||||
> `DisplayController._poll_on_demand_requests()`
|
||||
> (`src/display_controller.py`) picks up. A separate
|
||||
> `web_interface/blueprints/api_v3/display.py`) send the request over the
|
||||
> display's control socket ([IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md)),
|
||||
> the only way in (the `display_on_demand_request` cache-key mailbox is
|
||||
> gone). A plugin asks for the screen with `BasePlugin.request_on_demand()`
|
||||
> (see [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md)). A separate
|
||||
> `display_on_demand_config` key is used by the controller itself
|
||||
> during activation (`_activate_on_demand()`) to track what's
|
||||
> currently running, and is cleared by `_clear_on_demand()`.
|
||||
@@ -735,23 +736,12 @@ keys helps troubleshoot stuck states.
|
||||
|
||||
### Cache Keys
|
||||
|
||||
**1. display_on_demand_request** (TTL: 1 hour)
|
||||
```json
|
||||
{
|
||||
"request_id": "uuid-string",
|
||||
"action": "start|stop",
|
||||
"plugin_id": "plugin-name",
|
||||
"mode": "mode-name",
|
||||
"duration": 30.0,
|
||||
"pinned": true,
|
||||
"timestamp": 1234567890.123
|
||||
}
|
||||
```
|
||||
**Purpose:** Communication from web interface to display controller
|
||||
**When Set:** API endpoint receives request
|
||||
**Auto-Cleared:** After processing or 1 hour TTL
|
||||
Requests are not cache keys: they go over the control socket. The
|
||||
`display_on_demand_request` and `display_on_demand_processed_id` keys of
|
||||
earlier releases are no longer written or read; a leftover file is
|
||||
harmless and can be deleted.
|
||||
|
||||
**2. display_on_demand_config** (No TTL)
|
||||
**1. display_on_demand_config** (No TTL)
|
||||
```json
|
||||
{
|
||||
"mode": "mode-name",
|
||||
@@ -763,7 +753,7 @@ keys helps troubleshoot stuck states.
|
||||
**When Set:** Controller processes start request
|
||||
**Auto-Cleared:** When on-demand stops
|
||||
|
||||
**3. display_on_demand_state** (Continuously updated)
|
||||
**2. display_on_demand_state** (Continuously updated)
|
||||
```json
|
||||
{
|
||||
"active": true,
|
||||
@@ -777,31 +767,24 @@ keys helps troubleshoot stuck states.
|
||||
**When Set:** Every display loop iteration
|
||||
**Auto-Cleared:** Never (continuously updated)
|
||||
|
||||
**4. display_on_demand_processed_id** (TTL: 1 hour)
|
||||
```text
|
||||
"uuid-string-of-last-processed-request"
|
||||
```
|
||||
**Purpose:** Prevents duplicate request processing
|
||||
**When Set:** After processing request
|
||||
**Auto-Cleared:** After 1 hour TTL
|
||||
|
||||
### When Manual Clearing is Needed
|
||||
|
||||
**Scenario 1: Stuck in On-Demand State**
|
||||
- Symptom: Display stays on one plugin, won't return to rotation
|
||||
- Clear: `config`, `state`, `request`
|
||||
- Clear: `config`, `state`
|
||||
|
||||
**Scenario 2: Mode Switching Issues**
|
||||
- Symptom: Can't change to different plugin
|
||||
- Clear: `request`, `processed_id`, `state`
|
||||
- Clear: `state`, then restart the display
|
||||
|
||||
**Scenario 3: On-Demand Not Activating**
|
||||
- Symptom: Button click does nothing
|
||||
- Clear: `processed_id`, `request`
|
||||
- Symptom: Button click does nothing, or answers an error
|
||||
- Check the error's `socket_error` (see [IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md),
|
||||
"Checking it on a device"); no cache key is involved
|
||||
|
||||
**Scenario 4: After Service Crash**
|
||||
- Symptom: Strange behavior after crash/restart
|
||||
- Clear: All four keys
|
||||
- Clear: both keys
|
||||
|
||||
### Manual Recovery Procedures
|
||||
|
||||
@@ -838,8 +821,6 @@ from src.cache_manager import CacheManager
|
||||
cache = CacheManager()
|
||||
cache.clear_cache('display_on_demand_config')
|
||||
cache.clear_cache('display_on_demand_state')
|
||||
cache.clear_cache('display_on_demand_request')
|
||||
cache.clear_cache('display_on_demand_processed_id')
|
||||
```
|
||||
|
||||
> `CacheManager` also has a `delete(key)` method — a thin wrapper over
|
||||
|
||||
+14
-10
@@ -42,11 +42,11 @@ each other. They share three things:
|
||||
| State | Where | Written by | Read by |
|
||||
|---|---|---|---|
|
||||
| On-demand command | control socket `/run/ledmatrix/control.sock` ([IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md)) | web: `start_on_demand_display()` / `stop_on_demand_display()` in [`api_v3/display.py`](../web_interface/blueprints/api_v3/display.py), via [`src/ipc/client.py`](../src/ipc/client.py) | display: [`src/ipc/server.py`](../src/ipc/server.py) acks; the render thread applies it in `_poll_on_demand_requests()` |
|
||||
| On-demand request (fallback) | cache `display_on_demand_request` | web, when the socket fails; four plugins write it directly | display: `_poll_on_demand_requests()` |
|
||||
| On-demand request from a plugin | in memory: `BasePlugin.request_on_demand()` / `end_on_demand()` | a plugin in the display process | display: `submit_plugin_on_demand()` queues it; the render thread applies it in `_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 | cache `plugin_error_clear_request` | web | display |
|
||||
| Error clear | control socket `errors.clear` | web: `POST /api/v3/errors/clear` | display: applied before the socket answers |
|
||||
| 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,13 +58,17 @@ each other. They share three things:
|
||||
|
||||
The on-demand start route starts `ledmatrix.service` when it is not running
|
||||
(`start_service`, on by default) but never restarts a running one. The routes
|
||||
send the command over the display's control socket and get an ack; when that
|
||||
fails (a stopped display, one older than the socket) they write the mailbox
|
||||
instead, which the display reads every `ON_DEMAND_POLL_INTERVAL` (0.25s), from
|
||||
its dwell sleep, its render loops and Vegas's interrupt check as well as the
|
||||
main loop. Both ways end in the same handler, `_handle_on_demand_request()`.
|
||||
send the command over the display's control socket and get an ack. That is
|
||||
the only way in: the cache-key mailboxes (`display_on_demand_request`,
|
||||
`plugin_error_clear_request`) are gone, and a write to either is dropped
|
||||
with a warning. When no display is listening yet, the start route starts the
|
||||
service (if asked) and answers `202`; the web process's dispatcher
|
||||
([`on_demand_dispatch.py`](../web_interface/on_demand_dispatch.py)) sends the
|
||||
request once the socket is up, and the status routes report the outcome.
|
||||
Any other failure is answered as an error. Socket commands and plugins'
|
||||
in-process requests 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.
|
||||
for the protocol and the permission model.
|
||||
|
||||
### Web and display processes: who runs plugins
|
||||
|
||||
@@ -113,8 +117,8 @@ other web-UI action runs its script as a subprocess. A later, explicit
|
||||
|
||||
The **control socket** from the web process to the display
|
||||
([IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md)) carries on-demand
|
||||
commands and reloads an updated plugin; its next stages stream the
|
||||
display's state and retire the cache-key mailboxes. The plugin web-entry
|
||||
commands, reloads an updated plugin and streams the display's state; it
|
||||
replaced the cache-key mailboxes. The plugin web-entry
|
||||
contract above is still to come.
|
||||
|
||||
### Plugin state: desired, observed, and who owns it
|
||||
|
||||
@@ -203,6 +203,7 @@ Current methods:
|
||||
| `measure_text(text, font)` | `(width, height, baseline)` |
|
||||
| `get_font_height(font)` | Line height |
|
||||
| `register_plugin_fonts(plugin_id, font_manifest)` | Register a plugin's fonts (core calls it at load) |
|
||||
| `forget_plugin_fonts(plugin_id)` | Drop a plugin's manifest fonts and their cached objects (core calls it when a plugin unloads) |
|
||||
| `clear_cache()` | Drop cached fonts and metrics |
|
||||
| `font_catalog` (attribute) | Family name → file path |
|
||||
|
||||
@@ -218,5 +219,6 @@ Removed in 3.8.0, after logging a deprecation warning on first call since
|
||||
| `get_performance_stats()` | — |
|
||||
| `set_override()`, `remove_override()`, `get_overrides()` | a font field in your plugin's config schema |
|
||||
| `get_manager_fonts()`, `get_detected_fonts()` | — |
|
||||
| `get_plugin_fonts()`, `unregister_plugin_fonts()` | — |
|
||||
| `get_plugin_fonts()` | — |
|
||||
| `unregister_plugin_fonts()` | `forget_plugin_fonts()` (core calls it on unload) |
|
||||
| `add_font()`, `remove_font()`, `validate_font()` | the web UI's Fonts tab |
|
||||
|
||||
+175
-48
@@ -1,20 +1,24 @@
|
||||
# Control socket (web → display)
|
||||
|
||||
The display process serves a Unix socket that the web interface uses to send
|
||||
it commands and get an answer back. It replaces the cache-file "mailboxes" on
|
||||
it commands and get an answer back. It replaced the cache-file "mailboxes" on
|
||||
the SD card one command at a time. Stage 1 carries on-demand start, stop and
|
||||
status. Stage 2 makes those commands land within a frame on every kind of
|
||||
screen, and adds `brightness.set` and `plugin.reload`. Stage 3 adds a state
|
||||
stream (`state.get`, `state.subscribe`), so the web interface reads what the
|
||||
display is doing from the socket instead of from cache files the display
|
||||
wrote to the SD card. The file mailbox and the cache keys stay as a fallback
|
||||
for one release.
|
||||
display wrote to the SD card. Stage 4 makes the socket the only way a command goes
|
||||
while it works, and `errors.clear` replaces the last command that always
|
||||
went through a mailbox. Stage 5 removes the mailboxes: the web interface no
|
||||
longer writes them and the display no longer reads them, so the socket is
|
||||
the only way a command reaches the display (see "Without the socket"). The
|
||||
cache keys the display writes for readers stay as their fallback.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Socket | `/run/ledmatrix/control.sock` (tmpfs) |
|
||||
| Served by | the display process ([`src/ipc/server.py`](../src/ipc/server.py)), started by `DisplayController.run()` |
|
||||
| Used by | the web interface ([`src/ipc/client.py`](../src/ipc/client.py)): `POST /api/v3/display/on-demand/start` and `/stop`, `POST /api/v3/plugins/update` (reload), `POST /api/v3/config/main` (brightness); and through [`web_interface/display_state.py`](../web_interface/display_state.py) (the state stream), `GET /api/v3/display/current-status`, `/display/on-demand/status`, `/plugins/installed` (`runtime`), `/plugins/state` and the reconciliations, `/health` (`display_loop`) |
|
||||
| Used by | the web interface ([`src/ipc/client.py`](../src/ipc/client.py)): `POST /api/v3/display/on-demand/start` and `/stop`, `POST /api/v3/plugins/update` (reload), `POST /api/v3/config/main` (brightness), `POST /api/v3/errors/clear`; and through [`web_interface/display_state.py`](../web_interface/display_state.py) (the state stream), `GET /api/v3/display/current-status`, `/display/on-demand/status`, `/plugins/installed` (`runtime`), `/plugins/state` and the reconciliations, `/health` (`display_loop`) |
|
||||
| Contract | [`src/ipc/contract.py`](../src/ipc/contract.py): messages, versions, framing and the socket path; both sides import it |
|
||||
| Override | `LEDMATRIX_CONTROL_SOCKET=/some/path.sock` for both processes, or `=off` to disable it |
|
||||
|
||||
@@ -85,6 +89,17 @@ one. Clients branch on `error.code`, never on the message text.
|
||||
| `plugin.reload` | `{plugin_id}` | `{plugin_id, reloaded: true, version, modes}` | queued, awaited (10 s) |
|
||||
| `state.get` | `{since?, epoch?}` | a state snapshot (see "The state stream") | answered directly |
|
||||
| `state.subscribe` | — | a state snapshot, then pushed `state` / `tick` events | answered directly, then a stream |
|
||||
| `errors.clear` | `{cutoff: number}` (epoch seconds, finite, ≥ 0) | `{request_id, cutoff, cleared}` | answered directly, once applied |
|
||||
|
||||
`errors.clear` (stage 4) forgets the plugin errors the display recorded at
|
||||
or before `cutoff` and rewrites its error snapshot (`plugin_error_snapshot`)
|
||||
before it answers, so the web interface's next read already has it. The
|
||||
request `id` is the clear's id, which the snapshot reports as
|
||||
`applied_clear_id`. It is answered on the connection thread by a handler the
|
||||
display registers (`ControlServer(handlers=...)`, the contract's
|
||||
`DIRECT_COMMANDS`): the error aggregator and its publisher have their own
|
||||
locks, and nothing the render thread owns is touched. A display that has no
|
||||
handler answers `unknown_command`, as an older display does.
|
||||
|
||||
`duration` is a number of seconds, or a numeric string. `0`, `null` or `""`
|
||||
mean "until stopped". `pinned` must be a real boolean: the REST route has
|
||||
@@ -130,14 +145,17 @@ than the display's wait, so `pending` arrives before the client gives up.
|
||||
`v` the display does not speak gets `unsupported_version`. `hello` is checked
|
||||
by its `versions` list instead, and its result names the highest version both
|
||||
sides share, so a client can find out what a display supports before it
|
||||
relies on anything newer. The client sends `v: 1` and falls back to the
|
||||
mailbox when the display refuses it. It does not send `hello` first, which
|
||||
relies on anything newer. The client sends `v: 1`, and a route answers an
|
||||
error when the display refuses it. It does not send `hello` first, which
|
||||
saves a round trip.
|
||||
|
||||
New commands are added within a version, so stage 2 is still version 1. A
|
||||
display that does not know a command answers `unknown_command`, which the
|
||||
web interface treats like any other socket failure and falls back from, and
|
||||
`hello` lists the commands a display knows. The version changes only when the
|
||||
`hello` lists the commands a display knows.
|
||||
(Since stage 5, a command the display does not know is an error the route
|
||||
reports, telling the user to restart the display; there is no mailbox left
|
||||
to fall back to.) The version changes only when the
|
||||
envelope or the meaning of an existing command changes.
|
||||
|
||||
**Events.** `state.subscribe` is the one command with more than one message
|
||||
@@ -353,13 +371,12 @@ request, validates it against the contract, and then does one of two things:
|
||||
- For a query, it answers from a status snapshot the display provides
|
||||
(`DisplayController._control_status`). The snapshot only reads attributes.
|
||||
|
||||
The render thread drains the queue in `_poll_on_demand_requests()`, the same
|
||||
place it reads the mailbox:
|
||||
The render thread drains the queue in `_poll_on_demand_requests()`:
|
||||
|
||||
- An on-demand command goes to `_handle_on_demand_request()`, which is the
|
||||
mailbox's own handler. The two paths share all of their code: activation,
|
||||
the processed-id guard, error publishing, and resuming the rotation
|
||||
afterwards.
|
||||
- An on-demand command goes to `_handle_on_demand_request()`, which also
|
||||
handles plugins' own requests. The two paths share all of their code:
|
||||
activation, the request-id guard, error publishing, and resuming the
|
||||
rotation afterwards.
|
||||
- `brightness.set` is applied there and then (`_apply_control_brightness`),
|
||||
and the current frame is pushed again so the panel shows it.
|
||||
- `plugin.reload` starts at the top of the next loop pass, the place where
|
||||
@@ -367,7 +384,9 @@ place it reads the mailbox:
|
||||
Vegas iteration is on the stack (`_apply_pending_plugin_reloads`). Until
|
||||
then the current screen ends early, as it does for a WiFi notice: the
|
||||
frame loops, the dwell and Vegas's interrupt check all treat a pending
|
||||
reload as a reason to stop (`_screen_preempted`).
|
||||
reload as a reason to stop (the frame loops through the Arbiter's
|
||||
mid-screen check, `Source.RELOAD`; the dwell through
|
||||
`_plugin_reload_pending`).
|
||||
- Only the quick half of the reload runs on the render thread
|
||||
(`_start_plugin_reload`): the plugin's modes leave the rotation, its
|
||||
config subscription is dropped, and `PluginManager.detach_plugin` takes
|
||||
@@ -390,14 +409,13 @@ place it reads the mailbox:
|
||||
unloads it mid-load; a disable saved meanwhile is applied once the
|
||||
reload is done. A second reload of the same plugin runs after the first.
|
||||
|
||||
The 0.25 s floor on the mailbox read does not apply to the queue, because
|
||||
draining it costs no disk read. A queued command also lets
|
||||
`_service_pending_changes()` skip its own floor.
|
||||
Draining the queue costs no disk read, so it has no floor. A queued command
|
||||
also lets `_service_pending_changes()` skip its own 0.25 s floor.
|
||||
|
||||
### Waking the render thread (stage 2)
|
||||
|
||||
Stage 1 made the socket answer, but not land sooner: a queued command waited
|
||||
for the same polls the mailbox does. Measured on ledpi (Pi 4, 24 fps Vegas),
|
||||
for the same polls the mailbox did. Measured on ledpi (Pi 4, 24 fps Vegas),
|
||||
a start took 1.02 s on a static screen and about 0.4 s in Vegas either way.
|
||||
Now the queue wakes the render thread:
|
||||
|
||||
@@ -417,8 +435,7 @@ Now the queue wakes the render thread:
|
||||
- **Scrolling screens** already service pending changes every frame.
|
||||
|
||||
So a command lands within a millisecond or so on a static screen and in a
|
||||
dwell, and within one frame in Vegas and on a scrolling screen. The mailbox
|
||||
keeps its old delays. Commands still run only on the render thread: the
|
||||
dwell, and within one frame in Vegas and on a scrolling screen. Commands still run only on the render thread: the
|
||||
connection threads only queue them and set the event. The one exception is
|
||||
the slow half of `plugin.reload` (tearing down and loading the plugin),
|
||||
which runs on its own thread. Every change to the display's state still
|
||||
@@ -436,11 +453,91 @@ bookkeeping. A client's send to the render thread waking took 0.72 ms median
|
||||
Without a socket (Windows, `LEDMATRIX_CONTROL_SOCKET=off`) the waits are the
|
||||
plain sleeps they were.
|
||||
|
||||
**Exactly once.** A command and a mailbox write for the same request share
|
||||
one `request_id`. If the client times out after the display queued the
|
||||
command and then also writes the mailbox, the display processes the request
|
||||
once. The existing `on_demand_request_id` and processed-id checks drop the
|
||||
second copy.
|
||||
**Exactly once.** A request goes over the socket once. The web route sends
|
||||
it again only while no display is listening (see "Without the socket"), so
|
||||
a display gets it at most once; a start with a request id the display has
|
||||
just processed is dropped anyway (`on_demand_request_id`). The persisted
|
||||
`display_on_demand_processed_id` guard against a mailbox replayed after a
|
||||
restart went with the mailbox.
|
||||
|
||||
## Without the socket (stage 5)
|
||||
|
||||
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.display_not_listening()` picks out the one case a
|
||||
later retry can fix: nothing is listening (`no_socket`, `refused`) and the
|
||||
request was never sent.
|
||||
|
||||
| What happened | Example reasons | On-demand start | On-demand stop | `errors.clear` |
|
||||
|---|---|---|---|---|
|
||||
| No display listening | `no_socket`, `refused` | service stopped: `400` without `start_service`; with it, start the service and answer `202` (`status: "starting"`) at once; the dispatcher sends the request until the display takes it (up to 45 s), else `start-timeout`. Service running (still starting): the same `202`, sent for up to 10 s | `503` ("not running" / "may still be starting"); with `stop_service` the service is stopped and the route succeeds | `503` ("not running"; its errors are the last run's, and the next run starts with none) |
|
||||
| A display too old to know the command | `unknown_command`, `unsupported_version` | `503` | `503` | `503`, "restart it" |
|
||||
| No socket in this process | `disabled`, `unsupported` (Windows, `LEDMATRIX_CONTROL_SOCKET=off`) | `503` | `503` | `503` |
|
||||
| The display had it and failed, turned it away, or never answered | `busy`, `invalid_args`, `internal`, a timeout, a hang-up, `bad_response`, `forbidden` | `503` (`400` for `invalid_args`) | `503` (with `stop_service`: stopped anyway) | `503` |
|
||||
|
||||
Every error answer carries `socket_error` (a reason code, or `other`).
|
||||
Nothing is written to the cache in any of these cases.
|
||||
|
||||
**Waiting for a display that is starting.** The socket comes up when the
|
||||
display's run loop starts, after every plugin has loaded, which can take
|
||||
longer than a client waits (the MQTT bridge gives up after 15 s). So the
|
||||
start route never waits: it answers `202` with `status: "starting"`, and
|
||||
hands the request to the web process's one dispatcher
|
||||
([`web_interface/on_demand_dispatch.py`](../web_interface/on_demand_dispatch.py)).
|
||||
Its worker thread sends the request every 0.5 s while nothing is listening,
|
||||
until the display acknowledges it or the wait runs out (45 s after a cold
|
||||
start, `START_WAIT_SECONDS`; 10 s for a service that was already running,
|
||||
`ON_DEMAND_SOCKET_WAIT_RUNNING_SECONDS`). Any other failure ends it at once.
|
||||
One start is pending at a time: a newer start replaces it, and a stop
|
||||
cancels it (the stop then succeeds even with no display listening, and
|
||||
reports `cancelled_request_id`).
|
||||
|
||||
The outcome is reported where clients already look:
|
||||
`GET /display/on-demand/status` answers the pending start's state
|
||||
(`source: "web"`, `status: "starting"`, or `status: "error"` with `error:
|
||||
"start-timeout"` or the socket's reason) until the display publishes
|
||||
something newer, and `GET /display/current-status` adds it as
|
||||
`on_demand_pending`. Once the display has taken the request its own state
|
||||
is reported, as for any start.
|
||||
|
||||
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 are gone
|
||||
|
||||
| Former mailbox | Last written by | Now |
|
||||
|---|---|---|
|
||||
| `display_on_demand_request` | the web interface on fallback (stage 4); plugins that predate `BasePlugin.request_on_demand()` | not read. A write is dropped by `CacheManager.save_cache` (`RETIRED_MAILBOX_KEYS`) and logged once per writer as a warning that names the plugin when it can be told (the plugin instance on the call stack, else the `plugin_id` in the request) |
|
||||
| `plugin_error_clear_request` | the web interface on fallback (stage 4) | not read; a write is dropped and logged the same way |
|
||||
|
||||
A file left on the SD card by an older version is never read again, so it
|
||||
is harmless. `MailboxWatch` and
|
||||
`CacheManager.file_signature`, which made a look at a mailbox one `stat()`,
|
||||
went with them.
|
||||
|
||||
The four plugins that wrote `display_on_demand_request` (birdnet-go,
|
||||
mqtt-notifications, on-air, pomodoro-timer) use `request_on_demand()` on a
|
||||
core that has it, and fall back to the mailbox only when that method is
|
||||
missing or answers `None` (no display in the process, or a full queue).
|
||||
On a stage-5 core such a fallback write is dropped with the warning above.
|
||||
|
||||
### 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 on-demand 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. A
|
||||
plugin's stop ends only a session that plugin owns.
|
||||
|
||||
## Robustness
|
||||
|
||||
@@ -457,9 +554,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 falls
|
||||
back to the mailbox. A full queue means the render thread is stuck, and the
|
||||
systemd watchdog deals with that.
|
||||
- **Full queue.** When the queue is full, the client gets `busy`, and the web
|
||||
interface answers `503`. 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
|
||||
@@ -473,8 +570,8 @@ block the render loop or crash it:
|
||||
process created.
|
||||
- **Never fatal.** If the server cannot start (Windows, no `AF_UNIX`, a bind
|
||||
failure, `LEDMATRIX_CONTROL_SOCKET=off`), it logs that and the display runs
|
||||
as before. The web interface then uses the mailbox, and reads the cache
|
||||
keys and the heartbeat file.
|
||||
as before. The web interface then cannot send it commands (the routes
|
||||
answer `503`), and reads the cache keys and the heartbeat file.
|
||||
- **Subscribers (stage 3).** A `state.subscribe` connection gives its request
|
||||
slot back and takes one of 4 subscriber slots (`MAX_SUBSCRIBERS`). A fifth
|
||||
gets `busy`. So a few browsers' web processes holding streams can never
|
||||
@@ -532,11 +629,9 @@ device never touches the live display.
|
||||
## Stage plan
|
||||
|
||||
1. **On-demand, with acks (done, #706).** Contract, server, client.
|
||||
`on_demand.start`/`stop`/`status`, `hello`, `ping`. The REST routes try the
|
||||
socket first and report `transport: "socket" | "mailbox"` (plus
|
||||
`socket_error` on fallback). The mailbox is unchanged, and the plugins that
|
||||
write it directly (birdnet-go, mqtt-notifications, on-air, pomodoro-timer)
|
||||
keep working.
|
||||
`on_demand.start`/`stop`/`status`, `hello`, `ping`. The REST routes tried
|
||||
the socket first and reported `transport: "socket" | "mailbox"` (plus
|
||||
`socket_error` on fallback).
|
||||
2. **Commands that were restarts or polls (done).**
|
||||
- The render thread waits on the queue instead of sleeping, and Vegas
|
||||
checks it every frame, so a command lands within a frame on every kind
|
||||
@@ -576,15 +671,37 @@ device never touches the live display.
|
||||
an uninstall that keeps its config, still answer `restart_required`.
|
||||
They can now use a load/unload command and report the result the same
|
||||
way the update route does.
|
||||
4. **Retire the mailboxes.** After a release in which every device has had the
|
||||
socket, the web interface stops writing `display_on_demand_request`, and
|
||||
the display stops polling it, logging the plugins that still write it so
|
||||
they can move to an in-process `request_display()`. The other cache keys
|
||||
used as messages (`plugin_error_clear_request` and the remaining
|
||||
`display_*` keys) move to the socket or to tmpfs. The display also stops
|
||||
writing `display_current_state`, `display_on_demand_state` and
|
||||
`plugin_runtime_snapshot` once the web interface no longer falls back to
|
||||
them.
|
||||
4. **The mailboxes become a fallback (done).**
|
||||
- The web interface writes a mailbox only when the socket could not carry
|
||||
the request (`should_fall_back`); a display that had it and failed is
|
||||
answered as that.
|
||||
- `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.
|
||||
- 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 (done, the release after 3.8.1).** The web
|
||||
interface no longer writes `display_on_demand_request` or
|
||||
`plugin_error_clear_request`, and the display no longer reads them (see
|
||||
"Without the socket"). When no display is listening, the start route
|
||||
starts the service if asked, answers `202`, and the web process's
|
||||
dispatcher sends the request once the socket is up; every other failure
|
||||
is an error the route reports. A write to
|
||||
either key is dropped with a one-time warning naming the writer. The
|
||||
display still writes `display_current_state`, `display_on_demand_state`
|
||||
and `plugin_runtime_snapshot`: the web interface reads them whenever the
|
||||
socket cannot answer (a stopped or starting display, a web user not yet
|
||||
in the socket's group, a platform without Unix sockets), and
|
||||
`display_on_demand_config` is the display's own record for resuming a
|
||||
session after a restart. Retiring those keys is left for later.
|
||||
|
||||
## Checking it on a device
|
||||
|
||||
@@ -596,10 +713,20 @@ curl -s -X POST localhost:5000/api/v3/display/on-demand/start \
|
||||
# ... "transport": "socket"
|
||||
```
|
||||
|
||||
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.
|
||||
On an error, `socket_error` gives the reason. `no_socket` means the display
|
||||
is stopped or still starting. `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. `busy`, `timeout` and the like mean the display had the
|
||||
request and did not take it. Nothing is ever written to a 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|retired"
|
||||
```
|
||||
|
||||
Brightness and a plugin reload:
|
||||
|
||||
|
||||
@@ -488,6 +488,88 @@ 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 mailbox is gone** (see
|
||||
[IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md), stage 5): the display no
|
||||
longer reads it, and a write to it is dropped with a warning in the log,
|
||||
once per plugin:
|
||||
|
||||
```
|
||||
Ignored a write to the retired 'display_on_demand_request' cache key by plugin 'my-plugin': ...
|
||||
```
|
||||
|
||||
A plugin that only needs to run on cores with these methods calls them and
|
||||
treats `None` as "no display took it":
|
||||
|
||||
```python
|
||||
def _show_alert(self):
|
||||
if self.request_on_demand(mode="my_alert", duration=15) is None:
|
||||
self.logger.info("No display to show the alert on")
|
||||
```
|
||||
|
||||
A plugin that must also work on cores before them can keep the mailbox
|
||||
write as its fallback, guarded by `hasattr`: on those cores the display
|
||||
still reads it, and on a current core the write is only dropped and logged
|
||||
(when the method is missing it never runs at all). Raise
|
||||
`ledmatrix_min_version` to the release that added the methods once you no
|
||||
longer need it.
|
||||
|
||||
```python
|
||||
import time, uuid
|
||||
|
||||
def _show_alert(self):
|
||||
if hasattr(self, "request_on_demand"):
|
||||
self.request_on_demand(mode="my_alert", duration=15)
|
||||
return
|
||||
# A core older than request_on_demand(): the mailbox it still reads.
|
||||
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(),
|
||||
})
|
||||
```
|
||||
|
||||
`end_on_demand()` ends only the plugin's own session; a stop written to the
|
||||
mailbox on an older core ends any session, whoever started it.
|
||||
|
||||
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`. To test the path where the display takes the request, set
|
||||
`plugin_manager.request_on_demand.return_value = "some-id"`.
|
||||
|
||||
> The full source for `BasePlugin` lives in
|
||||
> `src/plugin_system/base_plugin.py`. If a method here disagrees with the
|
||||
> source, the source wins — please open an issue or PR to fix the doc.
|
||||
@@ -966,6 +1048,14 @@ if info:
|
||||
self.logger.info(f"Plugin: {info['name']}, Version: {info.get('version')}")
|
||||
```
|
||||
|
||||
#### `request_on_demand(plugin_id, mode=None, duration=None, pinned=False)` / `end_on_demand(plugin_id)`
|
||||
|
||||
What `BasePlugin.request_on_demand()` and `end_on_demand()` call, with the
|
||||
plugin's own id. Call those instead; see
|
||||
[On-demand display](#on-demand-display). The display controller routes them
|
||||
to itself with `set_on_demand_handler()`; a plugin manager without a
|
||||
display behind it answers `None`.
|
||||
|
||||
#### `get_all_plugin_info() -> List[Dict[str, Any]]`
|
||||
|
||||
Get information for all plugins.
|
||||
|
||||
+68
-24
@@ -464,7 +464,7 @@ Request a specific plugin to display on-demand.
|
||||
- `mode` (string, optional): Display mode name (plugin_id inferred if not provided)
|
||||
- `duration` (number, optional): Duration in seconds (0 = until stopped)
|
||||
- `pinned` (boolean, optional): Pin display (pause rotation)
|
||||
- `start_service` (boolean, optional): Start the display service if it is not running (default: true). A running service is never restarted: it picks the request up within about a quarter of a second. When false and the service is stopped, the route returns 400.
|
||||
- `start_service` (boolean, optional): Start the display service if it is not running (default: true). A running service is never restarted: it picks the request up within a frame over its control socket. A stopped one is started, and the route answers `202` at once (see below); the request is sent once the display's socket is up, which can take as long as the display takes to load its plugins. When false and the service is stopped, the route returns 400.
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
@@ -484,15 +484,52 @@ Request a specific plugin to display on-demand.
|
||||
|
||||
`service` is `null` when `start_service` is false.
|
||||
|
||||
`transport` says how the request reached the display: `"socket"` means the
|
||||
display's control socket acknowledged it (it is queued for the render thread,
|
||||
which wakes for it and applies it within a frame; see
|
||||
[IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md)), `"mailbox"` means it was
|
||||
written to the cache mailbox the display polls, as before the socket existed.
|
||||
With `"mailbox"`, `socket_error` gives the reason the socket was not used
|
||||
(`no_socket` when the display is stopped or predates the socket, `timeout`,
|
||||
`refused`, `busy`, ...). Either way the request is applied the same way;
|
||||
`request_id` is the same id in both.
|
||||
`transport` is always `"socket"`: the display's control socket acknowledged
|
||||
the request (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)).
|
||||
The `"mailbox"` value earlier releases could answer is gone with the
|
||||
mailbox: nothing is written to the cache.
|
||||
|
||||
**No display listening yet** (`no_socket`, `refused`: the service is
|
||||
stopped, or still loading its plugins). With the service stopped and
|
||||
`start_service` false, `400`. Otherwise the route starts the service if
|
||||
needed and answers at once:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "starting",
|
||||
"message": "The display service is starting; ...",
|
||||
"data": {"request_id": "uuid-here", "plugin_id": "football-scoreboard", "mode": "nfl_live",
|
||||
"duration": 45, "pinned": true, "service": {"active": true, "started": true},
|
||||
"transport": "socket", "socket_error": "no_socket",
|
||||
"pending": true, "wait_seconds": 45.0}
|
||||
}
|
||||
```
|
||||
|
||||
with HTTP `202`. The web process sends the request until the display takes
|
||||
it, for up to `wait_seconds` (45 after a cold start, 10 when the service was
|
||||
already running). Follow it with `GET /api/v3/display/on-demand/status`:
|
||||
its `state` is `{status: "starting", source: "web", request_id, ...}` while
|
||||
it waits, the display's own state once delivered, or `{status: "error",
|
||||
error: "start-timeout"}` (or the socket's reason) if it never was;
|
||||
`GET /api/v3/display/current-status` carries the same as
|
||||
`on_demand_pending`. A newer start replaces a pending one; a stop cancels it.
|
||||
|
||||
Otherwise, when the display did not take the request, the route answers an
|
||||
error with `status: "error"` and `data: {request_id, transport: "socket",
|
||||
socket_error}`:
|
||||
|
||||
- a full queue (`busy`), no answer after the request was sent (`timeout`,
|
||||
`closed`), a display older than the command (`unknown_command`), no
|
||||
socket in the web process (`disabled`, `unsupported`): `503` at once;
|
||||
- bad arguments (`invalid_args`): `400`.
|
||||
|
||||
The stop route answers the same errors (`503` when no display is
|
||||
listening, with a message saying whether the service is stopped), except
|
||||
that with `stop_service: true` it still stops the service and answers
|
||||
success, with `socket_error` set, and that a stop which cancelled a pending
|
||||
start succeeds with `cancelled_request_id` even when no display is
|
||||
listening.
|
||||
|
||||
### Stop On-Demand Display
|
||||
|
||||
@@ -522,7 +559,7 @@ Stop the current on-demand display.
|
||||
}
|
||||
```
|
||||
|
||||
`transport` and `socket_error` are as for start.
|
||||
`transport` and the errors are as for start; a stop is never retried.
|
||||
|
||||
---
|
||||
|
||||
@@ -2188,7 +2225,7 @@ Every response below adds three fields to the shape it always had:
|
||||
|---|---|
|
||||
| `snapshot_available` | `false` until the display service has reported (for example, it is not running). Counts are then zero. |
|
||||
| `generated_at` | When the display service produced the snapshot (ISO, the Pi's local time), or `null`. |
|
||||
| `clear_pending` | A clear has been requested and the display service has not applied it yet. |
|
||||
| `clear_pending` | Always `false`: a clear is applied before its route answers. Kept for compatibility. |
|
||||
|
||||
### Get Error Summary
|
||||
|
||||
@@ -2248,13 +2285,11 @@ error with `"all": true` (`max_age_hours` is then ignored).
|
||||
}
|
||||
```
|
||||
|
||||
The clear is asynchronous. The web interface records a request
|
||||
(`plugin_error_clear_request` in the shared cache), and the display service
|
||||
applies it within about 5 seconds, rebuilding its counts from the errors it
|
||||
keeps and republishing. Reads hide the cleared errors from the moment the
|
||||
request is recorded. Until the display service applies an age-based clear,
|
||||
`recent_errors` and `active_patterns` are already filtered but the counts
|
||||
are the old ones, and `clear_pending` is `true`.
|
||||
The clear goes to the display service over its control socket
|
||||
(`errors.clear`, see [IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md)), which
|
||||
applies it, rebuilding its counts from the errors it keeps, and republishes
|
||||
before it answers: `applied` is `true`, `transport` is `"socket"`, and
|
||||
`cleared_count` is the display's own count.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -2262,17 +2297,26 @@ are the old ones, and `clear_pending` is `true`.
|
||||
"data": {
|
||||
"cleared_count": 13,
|
||||
"clear_requested": true,
|
||||
"applied": true,
|
||||
"transport": "socket",
|
||||
"request_id": "5f0c1e...",
|
||||
"cutoff": "2026-09-23T09:59:02.310000"
|
||||
},
|
||||
"message": "Clear of all errors requested; the display service applies it within about 5 seconds"
|
||||
"message": "Cleared all errors"
|
||||
}
|
||||
```
|
||||
|
||||
`cleared_count` is how many of the reported errors the clear hides. It is
|
||||
`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`.
|
||||
`clear_requested`, `applied` and `transport` are always `true`, `true` and
|
||||
`"socket"`, and are kept for compatibility.
|
||||
|
||||
When the socket cannot carry the clear, the route answers `503` with
|
||||
`context.socket_error`, and nothing is cleared or recorded: the
|
||||
`plugin_error_clear_request` mailbox earlier releases fell back to is gone.
|
||||
The message says why: the display service is not running (its errors are
|
||||
then the last run's, and its next run starts with none), it is too old for
|
||||
`errors.clear` (restart it), the web process has no socket, or the display
|
||||
had the request and failed it (`internal`, a timeout after the request was
|
||||
sent).
|
||||
|
||||
---
|
||||
|
||||
|
||||
+205
-81
@@ -36,122 +36,189 @@ Each pass, in order:
|
||||
1. `loop_pass()` (watchdog). Apply a pending plugin enable/disable, then
|
||||
any plugin reloads the control socket asked for
|
||||
(`_apply_pending_plugin_reloads`; a pending reload ends the screen
|
||||
before it, like a WiFi notice, through `_screen_preempted`). The static
|
||||
screen's frame sleep and the dwell wait on the socket's queue instead of
|
||||
sleeping (`_wait_frame_interval`, `_sleep_with_plugin_updates`); without
|
||||
a socket, as in the golden traces, they are the plain sleeps.
|
||||
before it, like a WiFi notice, as `Source.RELOAD` at the runner's
|
||||
service points). The static screen's frame sleep and the dwell wait on
|
||||
the socket's queue instead of sleeping (`_wait_frame_interval`,
|
||||
`_sleep_with_plugin_updates`); without a socket, as in the golden
|
||||
traces, they are the plain sleeps.
|
||||
2. With no modes: dwell 1 s, next pass.
|
||||
3. Poll on-demand requests and expiry, release plugins loaded only for
|
||||
on-demand, tick plugin updates, drop an expired WiFi notice, evaluate
|
||||
the schedule (an on-demand session overrides scheduled-off), apply the
|
||||
brightness target. Then gather the Arbiter's inputs
|
||||
(`_arbiter_inputs`) and call `Arbiter.decide()`, which picks one of
|
||||
steps 4-6 or returns `LEGACY` for steps 7-9 (stage 2).
|
||||
(`_arbiter_inputs`) and call `Arbiter.decide()`.
|
||||
4. **Scheduled off:** blank, dwell up to 60 s. `_blank_while_scheduled_off`
|
||||
5. **Follower:** render one frame from the leader. `_run_follower_frame`
|
||||
6. **WiFi notice** (unless on-demand): draw it, dwell 0.5 s. `_show_wifi_notice`.
|
||||
It is also polled mid-screen (`_wifi_notice_pending`): the frame loops,
|
||||
the dwell sleep and an interrupted Vegas iteration end within about a
|
||||
second when one arrives, and a screen cut short resumes after it.
|
||||
7. **Live priority** (unless on-demand, or Vegas keeps live content in the
|
||||
ticker): switch to the next live mode, or resume the rotation. A game
|
||||
that goes live during a screen is caught sooner, by
|
||||
`_check_live_takeover` in the frame loops and the dwell sleep (at most
|
||||
once a second, and not while a live mode is showing).
|
||||
8. **Vegas** (unless on-demand, or live content preempts it): run one
|
||||
iteration of up to `max_cycle_duration`. A completed iteration ends the
|
||||
pass, and so does one that yielded for a WiFi notice or the schedule.
|
||||
Any other interrupted one falls through to step 9 in the same pass.
|
||||
9. **One screen:** pick the mode (`_resolve_active_mode`), the plugin
|
||||
(`_plugin_for_mode`), draw the first frame through the executor
|
||||
(`_dispatch_first_frame`). On no content, rotate at once
|
||||
(`_note_empty_pass`, `_skip_failed_plugin_modes`). Otherwise work out the
|
||||
bounds (`_track_dynamic_cycle`, `_resolve_durations`,
|
||||
`_clamp_to_on_demand`) and the frame rate (`_needs_high_fps`), run the
|
||||
125 Hz or 1 Hz frame loop, make up the minimum duration, then pick the
|
||||
next mode (`_advance_after_screen`).
|
||||
It also ends a running screen within about a second (the runner's
|
||||
service points), and a screen cut short resumes after it.
|
||||
7. **The Sources below the notice:** read whether Vegas is on and make the
|
||||
live-priority scan (`_arbiter_inputs_below_wifi`, where run() always
|
||||
read them), and call `decide()` again. It answers OnDemand (the
|
||||
session's current mode), Live (the next live mode, round-robin; a game
|
||||
that goes live during a screen takes over at the next service point, at
|
||||
most once a second), Vegas (`LEGACY`) or Rotation. `_take_plan` applies
|
||||
the answer: a live claim or the resume when live priority ends, the
|
||||
on-demand index.
|
||||
8. **Vegas** (`_run_vegas_iteration`): one iteration of up to
|
||||
`max_cycle_duration`. A completed iteration ends the pass, and so does
|
||||
one that yielded for the schedule, a reload or a WiFi notice. Any other
|
||||
interrupted one asks `decide()` once more (`vegas_yielded`): a game that
|
||||
stopped the ticker, or an on-demand session that started, shows next.
|
||||
9. **One screen:** pick the plugin (`_plugin_for_mode`) and hand the plan
|
||||
to the `ScreenRunner` (`src/screen_runner.py`). It draws the first frame
|
||||
through the executor (`_dispatch_first_frame`), has the controller fill
|
||||
in the plugin's durations, dynamic flag and frame policy
|
||||
(`_complete_plan`), runs the 125 Hz or 1 Hz frame loop with a service
|
||||
point after each frame, makes up the minimum duration, and returns an
|
||||
`Outcome`. On `PREEMPTED` the pass ends without advancing. On no
|
||||
content, rotate at once (`_note_empty_pass`, `_skip_failed_plugin_modes`).
|
||||
Otherwise `ArbiterState.after()` picks the next mode
|
||||
(`_advance_after_screen`).
|
||||
|
||||
The helpers named above were extracted in stage 1 without changing
|
||||
behaviour. Since stage 2 the choice between steps 4, 5, 6 and the rest is
|
||||
made by `Arbiter.decide()` in `src/display_arbiter.py`. The frame loops, the
|
||||
Vegas branch and every early exit are still inline in `run()`.
|
||||
|
||||
## Target design
|
||||
## Design
|
||||
|
||||
```python
|
||||
def run(self):
|
||||
while True:
|
||||
inputs = self._drain_inputs() # requests, schedule, config, sync
|
||||
plan = self.arbiter.decide(self.state, inputs, clock.now())
|
||||
outcome = self.runner.run(plan) # ExitReason + elapsed
|
||||
self.state = self.state.after(plan, outcome) # rotation, on-demand index, live resume
|
||||
inputs = self._arbiter_inputs() # schedule, follower, notice
|
||||
plan = Arbiter.decide(self._arbiter_state(), inputs, now)
|
||||
... # off / follower / notice
|
||||
plan = self._take_plan(Arbiter.decide(state, self._arbiter_inputs_below_wifi(inputs), now))
|
||||
if plan.source is Source.LEGACY: # Vegas, until stage 4
|
||||
plan = self._run_vegas_iteration(...)
|
||||
outcome = runner.run(plan, plugin) # ExitReason + elapsed
|
||||
if outcome.exit_reason is not ExitReason.PREEMPTED:
|
||||
self._advance_after_screen(plan, outcome) # ArbiterState.after
|
||||
```
|
||||
|
||||
The controller's attributes (`current_display_mode`, `current_mode_index`,
|
||||
`on_demand_*`, `_live_resume_index`) stay the record that the web UI, the
|
||||
control socket and the on-demand cache read. `_arbiter_state()` snapshots
|
||||
them into a frozen `ArbiterState`; the transitions are pure methods on it,
|
||||
and the controller writes their result back (`_adopt_state`).
|
||||
|
||||
### Sources
|
||||
|
||||
Each kind of content is a Source. A Source looks at the state and the
|
||||
inputs and either offers a screen or passes. The Arbiter asks them in this
|
||||
order:
|
||||
|
||||
| Order | Source | Offers a screen when | Today |
|
||||
| Order | Source | Offers a screen when | Code |
|
||||
|---|---|---|---|
|
||||
| gate | ScheduledOff | the schedule is off and no on-demand session overrides it | step 4 |
|
||||
| 1 | Follower | a sync leader is driving this panel | step 5 |
|
||||
| 2 | OnDemand | a session is active (its mode list, index, expiry and pin) | `_resolve_active_mode` |
|
||||
| 3 | Wifi | a status message is pending and on-demand is not active | step 6 |
|
||||
| 4 | Live | a live-priority plugin has live content (round-robin across several) | step 7 |
|
||||
| 5 | Vegas | Vegas is enabled and nothing above wants the panel | step 8 |
|
||||
| 6 | Rotation | always: `available_modes[current_mode_index]` | step 9 |
|
||||
| gate | ScheduledOff | the schedule is off and no on-demand session overrides it | `decide` |
|
||||
| 1 | Follower | a sync leader is driving this panel | `decide` |
|
||||
| 2 | OnDemand | a session is active (its mode list, index, expiry and pin) | `_on_demand_plan` |
|
||||
| 3 | Wifi | a status message is pending and on-demand is not active | `decide` |
|
||||
| 4 | Live | a live-priority plugin has live content (round-robin across several) | `live_pick` |
|
||||
| 5 | Vegas | Vegas is enabled and nothing above wants the panel | `LEGACY`, run by `_run_vegas_iteration` |
|
||||
| 6 | Rotation | always: the rotation's current mode | `rotation_plan` |
|
||||
|
||||
ScheduledOff is a gate in front of the Sources because that is how it works
|
||||
today: a scheduled-off panel stays blank even for a follower, and only an
|
||||
on-demand session overrides it.
|
||||
|
||||
The Rotation answers `state.current_mode`, not
|
||||
`available_modes[current_mode_index]`: the two agree except where something
|
||||
moved the panel off the list and the rotation carries on from there (a live
|
||||
mode no rotation entry names, or None after a session ended with no enabled
|
||||
mode to resume to), and `run()` always showed `current_display_mode`.
|
||||
|
||||
### Arbiter
|
||||
|
||||
```python
|
||||
Arbiter.decide(state, inputs, now) -> ScreenPlan
|
||||
Arbiter.decide(state, inputs, now, running=None) -> ScreenPlan
|
||||
```
|
||||
|
||||
`decide` is a pure function: it does no I/O, takes no locks and does not
|
||||
sleep. It can be tested with plain tables of (state, inputs, now) mapped to
|
||||
an expected plan. It returns a `ScreenPlan`:
|
||||
an expected plan.
|
||||
|
||||
- `ArbiterState`: the current mode; the rotation and its index; the
|
||||
on-demand session's modes, index, expiry and pin; the live resume point;
|
||||
whether a mid-screen takeover has not shown yet. Transitions:
|
||||
`next_on_demand`, `showing`, `claim_live`, `release_live`, `after`.
|
||||
- `ArbiterInputs`: whether the schedule has the panel on, an on-demand
|
||||
session, a follower, the WiFi notice, the live modes (None where no scan
|
||||
was made), whether Vegas is on and keeps live content in its ticker,
|
||||
whether this pass's Vegas iteration has yielded, and (mid-screen) whether
|
||||
a plugin reload is waiting.
|
||||
- `ScreenPlan`:
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `source` | which Source won |
|
||||
| `mode`, `plugin` | what to draw (None for a blank or follower plan) |
|
||||
| `min_duration`, `max_duration` | from `_resolve_durations` and `_clamp_to_on_demand` |
|
||||
| `mode`, `plugin` | what to draw (None for a blank or follower plan); the plugin id once resolved |
|
||||
| `min_duration`, `max_duration` | from `_resolve_durations` and the on-demand bound (`on_demand_bound`), filled in after the first frame; an on-demand plan's `max_duration` is what is left of the session at `now` |
|
||||
| `dynamic` | run until the plugin's cycle completes, between min and max |
|
||||
| `frame_policy` | today `_needs_high_fps` (125 Hz or 1 Hz); see stage 5 |
|
||||
| `frame_policy` | `HIGH_FPS` or `STATIC`, today `_needs_high_fps`; see stage 5 |
|
||||
| `preemptible_by` | the Sources allowed to interrupt this plan mid-screen |
|
||||
| `notice`, `deadline`, `ends_live` | the WiFi notice; the on-demand expiry for the bound; "live priority just ended, resume the rotation first" |
|
||||
|
||||
`decide` cannot ask a plugin anything, so the fields a plugin answers are
|
||||
filled in by the controller after the first frame, where they were always
|
||||
read (`_complete_plan`).
|
||||
|
||||
With `running`, `decide` answers the mid-screen question instead: `running`
|
||||
itself while the screen holds, else the plan that ends it
|
||||
(`_hold_or_preempt`), in the order the frame loops always checked:
|
||||
|
||||
1. Live: a game went live while a non-live screen runs. It is the one
|
||||
preemption that changes the state (the rotation moves to the live mode
|
||||
and remembers where it was), and it is claimed even when a WiFi notice
|
||||
is also pending; the next pass shows the notice, then the game.
|
||||
2. The panel's mode moved under the screen (on-demand started, ended or
|
||||
changed mode; the rotation was rebuilt).
|
||||
3. The schedule turned the panel off.
|
||||
4. A WiFi notice (unless on-demand outranks it), compared with its expiry.
|
||||
5. A plugin reload is waiting (between frames only).
|
||||
|
||||
Every screen is preemptible by the gate, OnDemand, Wifi, Live, Rotation and
|
||||
a reload (`SCREEN_PREEMPTERS`), except that a live screen leaves Live out
|
||||
(`LIVE_PREEMPTERS`): live games take turns between screens. A follower and
|
||||
Vegas are looked at only between screens.
|
||||
|
||||
### ScreenRunner
|
||||
|
||||
```python
|
||||
ScreenRunner(clock: FrameClock).run(plan) -> Outcome(exit_reason, elapsed)
|
||||
ScreenRunner(clock: FrameClock, host: ScreenHost).run(plan, plugin) -> Outcome
|
||||
```
|
||||
|
||||
The ScreenRunner draws the first frame (`_dispatch_first_frame`), runs the
|
||||
frame loop that the plan's frame policy selects, services pending changes
|
||||
between frames, and returns one `ExitReason`:
|
||||
|
||||
| ExitReason | Today's equivalent (golden-trace exit) |
|
||||
| ExitReason | Golden-trace exit |
|
||||
|---|---|
|
||||
| `DURATION` | target duration reached (`duration`) |
|
||||
| `CYCLE_COMPLETE` | dynamic plugin finished after its minimum (`cycle-complete`) |
|
||||
| `EMPTY` | first frame returned False (`empty`; `raised` when display() raised inside the executor) |
|
||||
| `EMPTY` | first frame returned False, or no plugin (`empty`; `raised` when display() raised inside the executor; `no-plugin`, `breaker`) |
|
||||
| `ERROR` | the dispatch itself raised (`error`) |
|
||||
| `DISPLAY_FALSE` | a later frame returned False (`display-false`) |
|
||||
| `PREEMPTED` | another Source took the panel (`on-demand-*`, `schedule-off`, `vegas-interrupt`, ...) |
|
||||
| `PREEMPTED` | another Source took the panel (`on-demand-*`, `schedule-off`, `live`, `wifi`, ...) |
|
||||
| `RELOAD` | a plugin reload is waiting: the screen ends early but counts as shown, and the rotation advances |
|
||||
|
||||
`PREEMPTED` replaces the five `current_display_mode != active_mode` checks.
|
||||
The runner asks the Arbiter, at the throttled service points it already has,
|
||||
whether a Source in `plan.preemptible_by` now wants the panel.
|
||||
The runner asks its host at named service points (`Checkpoint`): `FRAME`
|
||||
after each frame (and when a socket command wakes the 1 Hz wait),
|
||||
`AFTER_LOOP` / `AFTER_COMPLETED_LOOP` when the frame loop ends,
|
||||
`after_dwell` after the make-up dwell, and `FINAL` before the rotation
|
||||
advances. Each is one `decide(..., running=plan)` call
|
||||
(`DisplayController._screen_check`). The checkpoint says whether a pending
|
||||
reload counts there and when the WiFi notice file is read (`NoticeRead`):
|
||||
the read is throttled to once a second and deletes an expired file, so it
|
||||
happens exactly where the loop always read it.
|
||||
|
||||
`FrameClock` provides `now()` and `sleep()`. In production it is
|
||||
`time.monotonic`/`time.sleep`. In the golden traces it is the fake clock
|
||||
that the harness patches in today.
|
||||
In the 125 Hz loop the live-priority scan is made before the frame's sleep
|
||||
(`_screen_service`), at the moments it always was, and weighed by the
|
||||
service point after the sleep, where the loop always decided to end the
|
||||
screen.
|
||||
|
||||
`FrameClock` provides `time()`, `perf_counter()` and `sleep()`, the shape of
|
||||
the `time` module. In production it is `_ModuleClock`, which looks up
|
||||
`src.display_controller.time` on each call, so the golden traces' fake clock
|
||||
drives the runner as it drove the inline loops. The runner's log lines use
|
||||
the controller's logger, so they keep their source in the journal.
|
||||
|
||||
## Stages
|
||||
|
||||
@@ -250,37 +317,84 @@ What shipped:
|
||||
Source, the dwells, the expiry comparison, the snapshot's reads, each
|
||||
dispatch in `run()`); every one failed a test.
|
||||
|
||||
### Stage 3: ScreenRunner and `PREEMPTED`
|
||||
### Stage 3: ScreenRunner and `PREEMPTED` (done; awaiting the ledpi soak)
|
||||
|
||||
Move the two frame loops, the make-up dwell and the dynamic-duration exit
|
||||
into `ScreenRunner.run(plan)` with an injected `FrameClock`. Replace the
|
||||
five re-checks with `PREEMPTED`. Add the OnDemand, Live and Rotation Sources
|
||||
so `LEGACY` is left meaning only Vegas.
|
||||
|
||||
Concretely, from where stage 2 left off:
|
||||
The plan, from where stage 2 left off:
|
||||
|
||||
1. `ArbiterState` gains the rotation index, the on-demand mode list, index,
|
||||
expiry and pin, and the live resume point (today `current_mode_index`,
|
||||
`on_demand_*` and the live-priority stash). `ArbiterInputs` gains the
|
||||
live modes (`_collect_live_modes`) and whether Vegas is enabled and keeps
|
||||
live content in the ticker.
|
||||
2. OnDemand returns its current mode with `_clamp_to_on_demand`'s bound,
|
||||
reading `now` for the expiry. Live returns the next live mode
|
||||
(round-robin). Rotation returns `available_modes[current_mode_index]`.
|
||||
`ScreenPlan` gains `mode`, `plugin`, `min_duration`, `max_duration`,
|
||||
`dynamic`, `frame_policy` and `preemptible_by`.
|
||||
3. `ScreenRunner.run(plan)` returns an `ExitReason`; `state.after(plan,
|
||||
outcome)` replaces `_advance_after_screen` and the live-resume
|
||||
bookkeeping. Each mid-screen check asks `decide()` whether a Source in
|
||||
`plan.preemptible_by` now wins, so `_screen_preempted`,
|
||||
`_check_live_takeover` and `_wifi_notice_pending` become one call.
|
||||
expiry and pin, and the live resume point. `ArbiterInputs` gains the
|
||||
live modes and whether Vegas is enabled and keeps live content in the
|
||||
ticker.
|
||||
2. OnDemand returns its current mode with the session's bound, reading
|
||||
`now` for the expiry. Live returns the next live mode (round-robin).
|
||||
Rotation returns the rotation's mode. `ScreenPlan` gains `mode`,
|
||||
`plugin`, `min_duration`, `max_duration`, `dynamic`, `frame_policy` and
|
||||
`preemptible_by`.
|
||||
3. `ScreenRunner.run(plan)` returns an `ExitReason`; `state.after(outcome)`
|
||||
replaces `_advance_after_screen`'s step and the live-resume bookkeeping.
|
||||
Each mid-screen check asks `decide()` whether a Source in
|
||||
`plan.preemptible_by` now wins.
|
||||
4. The control socket (`_drain_control_commands`, `_wait_for_control`) and
|
||||
state publishing stay where they are; the runner calls them at its
|
||||
service points.
|
||||
|
||||
What shipped, one commit each: the runner; then the OnDemand, Live and
|
||||
Rotation Sources; then one `decide()` call at the service points.
|
||||
|
||||
- `src/screen_runner.py` (on the mypy ratchet): `ScreenRunner`,
|
||||
`FrameClock`, `ExitReason`, `Outcome`, `Checkpoint`, `NoticeRead`,
|
||||
`Screen` and the `ScreenHost` protocol, which `DisplayController`
|
||||
implements through `_ScreenHost` (one-line forwards to its own methods).
|
||||
The two frame loops, the make-up dwell and the dynamic-duration exit
|
||||
moved in unchanged, pacing included.
|
||||
- `src/display_arbiter.py`: `Source` gains `ON_DEMAND`, `LIVE`, `ROTATION`
|
||||
and `RELOAD`; `LEGACY` means only Vegas. `FramePolicy`. `ArbiterState`
|
||||
and `ArbiterInputs` as listed under "Arbiter". The pure helpers
|
||||
`on_demand_bound` (`_clamp_to_on_demand`), `live_pick`
|
||||
(`_check_live_priority`'s pick), `live_takeover` (the mid-screen claim)
|
||||
and `rotation_plan`.
|
||||
- A pass asks `decide()` twice: once with the inputs every pass reads, and
|
||||
once, only when nothing above the notice took the panel, with the Vegas
|
||||
check and the live scan, read where run() always read them (the scan
|
||||
asks every live-priority plugin, and the Vegas check applies queued
|
||||
Vegas config, so reading them earlier, on a follower or notice pass,
|
||||
would be a change). A Vegas iteration that yields asks a third time.
|
||||
- `_resolve_active_mode`, `_clamp_to_on_demand` and `_screen_preempted` are
|
||||
gone. `_apply_live_priority`, `_check_live_priority`,
|
||||
`_check_live_takeover` and `_wifi_notice_pending` remain (Vegas, the
|
||||
dwell sleep and the tests call them), built on the same pure rules.
|
||||
- `_sleep_with_plugin_updates` keeps its own break rules. It also serves
|
||||
the blank, the notice and the idle wait, which are not screens, and its
|
||||
rules are edge-triggered (an on-demand session starting on the mode
|
||||
already showing ends a dwell but not a frame loop); folding them into
|
||||
`decide()` would change behaviour.
|
||||
|
||||
Behaviour, checked three ways:
|
||||
|
||||
- Golden traces: unchanged, no regeneration.
|
||||
- Every harness run in the suite (67: the goldens plus the live-takeover,
|
||||
WiFi+live, socket-wake, plugin-reload, schedule and tick tests) was
|
||||
captured with every sleep, `display()` call, WiFi read, live scan,
|
||||
publish, dwell and scroll-state call logged, and diffed against
|
||||
`origin/main`. Identical, except:
|
||||
- a Vegas pass used to scan the live plugins twice at the same instant
|
||||
(step 7, then step 8's "is anything live?"); it scans once;
|
||||
- `_apply_live_priority(None)` calls that changed nothing are not made;
|
||||
- throttled WiFi reads that returned the cached answer (no side effect)
|
||||
after a notice had already ended the screen are not made;
|
||||
- in the 125 Hz loop the live scan still runs before the frame's sleep,
|
||||
but the claim is made by the service point after it, so the "live"
|
||||
state change happens 8 ms later. The screen ends at the same frame as
|
||||
before.
|
||||
- Tables: `test/test_display_arbiter.py` (OnDemand, Live, Vegas/Rotation,
|
||||
`after`, the 24-row mid-screen table, `live_takeover`) and
|
||||
`test/test_screen_runner.py` (the runner on a scripted host and fake
|
||||
clock; the controller's service point and the reads it makes). A
|
||||
mutation run broke each moved or new piece once; see the PR.
|
||||
|
||||
This stage touches frame pacing (the 8 ms deadline sleep, the 1 ms yield),
|
||||
so it needs a frame soak on ledpi, A/B against main. Coordinate with
|
||||
whoever owns scroll performance (`docs/SCROLL_PERFORMANCE.md`).
|
||||
so it needs a frame soak on ledpi, A/B against main, before it merges.
|
||||
Coordinate with whoever owns scroll performance (`docs/SCROLL_PERFORMANCE.md`).
|
||||
|
||||
### Stage 4: Vegas as a Source
|
||||
|
||||
@@ -345,3 +459,13 @@ PR that updates the affected trace and explains why. All six are fixed:
|
||||
|
||||
A new one found later goes the same way: record it here with the trace that
|
||||
shows it, then fix it in its own PR, not inside a restructure stage.
|
||||
|
||||
Open:
|
||||
|
||||
- Vegas stops for a sync follower (its interrupt check includes
|
||||
`is_follower_active`), but the yield path never looks at a follower, so a
|
||||
full rotation screen (20 s in the test) runs before the next pass hands
|
||||
the panel to the leader. Found by stage 3's mutation run;
|
||||
`test_screen_runner.py::TestThroughRun::test_vegas_yielding_to_a_follower_shows_a_rotation_screen_first`
|
||||
pins it. Stage 4, which drops the interrupt callback, is the natural
|
||||
place to fix it.
|
||||
|
||||
@@ -77,31 +77,6 @@ The Vegas **Scroll Speed** slider in the web UI shows the same thing live: a
|
||||
line under it says what your speed will run as on this panel, and links to the
|
||||
nearest smooth speeds.
|
||||
|
||||
### A panel that cannot reach its cap
|
||||
|
||||
Speeds are solved against `limit_refresh_rate_hz`, the configured cap, but a
|
||||
cap is only a ceiling: a long chain, a high `pwm_bits` or a big
|
||||
`gpio_slowdown` can leave the panel below it. One Pi 4 driving 2×128×64 on
|
||||
`adafruit-hat-pwm` with `pwm_bits 9` and `gpio_slowdown 5` measured
|
||||
107.6–113.1 Hz under a 120 Hz cap. Frames still move whole pixels, but
|
||||
every scroll runs that much slower than configured (60 px/s ran at 55 px/s),
|
||||
and the smooth speeds are the cap's rather than the panel's.
|
||||
|
||||
The display measures the real rate from its own frames. About a minute
|
||||
into scrolling, a panel more than 3% short of its cap is logged once:
|
||||
|
||||
```
|
||||
WARNING - src.common.frame_timing - The panel refreshes at about 113 Hz, below
|
||||
the 120 Hz that scroll speeds are planned for ... Set Limit Refresh Rate to
|
||||
100 Hz (web UI, Display tab), which this panel can hold, and restart.
|
||||
```
|
||||
|
||||
The Display tab says the same under **Limit Refresh Rate**, with a button
|
||||
that fills in the suggested cap (`GET /api/v3/config/refresh-rate`). The
|
||||
suggestion is a multiple of 10 at least 5% under the measurement, because
|
||||
an uncapped panel drifts and the measurement is the fast end of it. A cap the
|
||||
panel holds also stops the drift.
|
||||
|
||||
### How a slow speed stays crisp
|
||||
|
||||
`SwapOnVSync(canvas, framerate_fraction)` holds each frame for N panel
|
||||
|
||||
+36
-19
@@ -91,6 +91,7 @@ more. Shared sports code lives in `src/common`:
|
||||
| `sports_live_scroll.py` | next release | `SportsLiveScrollMixin` — rebuild a live scroll strip mid-cycle, keeping the marquee's place |
|
||||
| `sports_display_rules.py` | next release | `SportsCardOptionsMixin`, `SportsGameRulesMixin` — scorebug date options, the no-favourites filter, non-favourite live dwell |
|
||||
| `sports_font_path.py` | next release | `resolve_font_path` — what the plugins' `_resolve_font_path` copies return |
|
||||
| `sports_game_over.py` | 3.8.1 | `SportsGameOverMixin` — `_is_game_really_over`, with the `FINAL_PERIOD` seam (family 5) |
|
||||
|
||||
Each is described in [src/common/README.md](../src/common/README.md).
|
||||
|
||||
@@ -134,8 +135,7 @@ constants rather than behavior:
|
||||
|
||||
| Attribute | Meaning | Default |
|
||||
|---|---|---|
|
||||
| `FINAL_PERIOD` | Period at/after which a zero clock can mean "over" | `4` (hockey overrides to `3`) |
|
||||
| `CLOCK_COUNTS_DOWN` | Whether `0:00` means "expired" | `True` (soccer/afl/nrl override to `False` — their clocks count up, so `0:00` is kickoff) |
|
||||
| `FINAL_PERIOD` | Period from which a 0:00 clock ends a game (`sports_game_over`) | `None`: the clock never ends a game (afl, nrl, soccer, baseball, ufc). Hockey sets `3`; basketball, football and lacrosse `4` |
|
||||
| `COALESCE_SCORING_SEQUENCE` | Fold score increments arriving during an active celebration into that one celebration | `False` (football overrides to `True` — a touchdown lands as +6, then +1 for the extra point) |
|
||||
|
||||
### Why these are seams and not branches
|
||||
@@ -146,11 +146,14 @@ so NRL matches favorites on team ID. Flattening every plugin to abbreviations
|
||||
would silently select the wrong club for NRL users. The base declares the seam,
|
||||
NRL fills it, and core never learns the string `"nrl"`.
|
||||
|
||||
`CLOCK_COUNTS_DOWN` exists for the same reason in the opposite direction: a
|
||||
`FINAL_PERIOD` exists for the same reason in the opposite direction: a
|
||||
soccer clock reading `0:00` means the match has not kicked off, so running the
|
||||
clock-expiry branch there would evict live games.
|
||||
clock-expiry rule there would evict live games. Those sports declare `None`,
|
||||
and so do baseball (innings, not a clock) and ufc (a bout ends only on ESPN's
|
||||
final status). One attribute covers both questions, whether the clock can end
|
||||
a game and from which period, so no separate count-down flag was added.
|
||||
|
||||
`COALESCE_SCORING_SEQUENCE` is the third of the same kind. In football one
|
||||
`COALESCE_SCORING_SEQUENCE` is another of the same kind. In football one
|
||||
scoring play arrives as two score updates, so the follow-up must be folded into
|
||||
the first celebration; in soccer two increments a few seconds apart are two real
|
||||
goals, and folding them would swallow one. Neither default is "right" — which is
|
||||
@@ -298,6 +301,20 @@ 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.
|
||||
|
||||
### Why the method changes
|
||||
|
||||
Byte-identical promotion has nearly run dry. Measured on ledmatrix-plugins
|
||||
@@ -333,8 +350,8 @@ game-over check); the report measures each method in it. The procedure:
|
||||
line in each plugin.
|
||||
- *A per-sport fact* (hockey ends in period 3; a soccer clock counts up).
|
||||
Make it a declared class constant or override point with a default, as
|
||||
`FINAL_PERIOD`, `CLOCK_COUNTS_DOWN`, `COALESCE_SCORING_SEQUENCE` and
|
||||
`_favorite_key` are, and add it to the tables above. Never a sport-name
|
||||
`FINAL_PERIOD`, `COALESCE_SCORING_SEQUENCE` and `_favorite_key` are,
|
||||
and add it to the tables above. Never a sport-name
|
||||
branch: core must not learn sport names.
|
||||
- *A product difference*: anything a user can see (which games show, a
|
||||
colour, a date, a badge, how long a screen stays). The owner picks the
|
||||
@@ -382,7 +399,7 @@ release.
|
||||
| # | Family | Methods (variants) | Why here |
|
||||
|---|---|---|---|
|
||||
| 4 | Identical sweep | `manager.py`: `_dispatch_switch_refresh`, `_favorite_team_is_live`, `get_vegas_priority_weight`, `_game_involves`, `_favorite_scan_targets`, `_favorite_scan_games`, `_get_total_games_for_manager` (all nine, 1); the live-scroll helpers `_preserving_scroll_position`, `_refresh_live_scroll_managers`, `_live_scroll_managers`, `_note_live_scroll_built`, `_live_scroll_needs_rebuild`, `_live_scroll_fields` (eight, 1). `sports.py`: `_card_option`, `_filtered_or_all`, `_effective_live_duration`, `_recent_date_text` (eight, 1). 58 identical families in all | Nothing to decide; brings `manager.py` into core as a `SportsPluginHostMixin`. `_resolve_font_path` (identical in nine `sports.py` and eight renderers) becomes `sports_font_path.resolve_font_path`, not `font_layout.resolve_asset_path`, which skips the cwd. Core side done; see [Stage 4](#stage-4-the-identical-sweep-core-done-adoption-waits-for-a-release) |
|
||||
| 5 | Game-over check | `SportsLive._is_game_really_over` (5) | Pure logic, no pixels; its seams (`FINAL_PERIOD`, `CLOCK_COUNTS_DOWN`) were designed in B1. The pilot for the procedure |
|
||||
| 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 |
|
||||
| 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 |
|
||||
@@ -416,17 +433,17 @@ family 9 prepares.
|
||||
Owner calls to make before (or while) reconciling. Items marked *verify* are
|
||||
suspected behaviour that needs a payload or a rig to confirm first.
|
||||
|
||||
- **5, game-over check.** Which rule each sport gets: the clock never ends a
|
||||
game in afl, nrl and soccer (`CLOCK_COUNTS_DOWN = False`); hockey ends at
|
||||
0:00 from period 3, basketball, football and lacrosse from period 4.
|
||||
baseball and ufc share a copy that reads a missing clock as "0:00": dormant
|
||||
in baseball (its games carry no `period`), and not triggered by ufc's round
|
||||
breaks either. ESPN sends a break as `STATUS_END_OF_ROUND` with displayClock
|
||||
`-`, not `0:00` (verified against recorded payloads; ledmatrix-plugins#580
|
||||
pins it). Whatever rule ufc gets must not read `-` as `0:00`. Decide ufc's
|
||||
rule: no clock rule (ESPN's `STATUS_FINAL` is the only end signal it needs;
|
||||
this also closes a ~1 s window at the horn when the ticking clock reads
|
||||
`0:00`), or its own final period.
|
||||
- **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.** 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
|
||||
|
||||
@@ -42,7 +42,8 @@ static/v3/js/
|
||||
registry.js page lifecycle: init/destroy on htmx swaps
|
||||
api.js fetch wrapper for /api/v3 (JSON envelope, login redirect)
|
||||
facade.js window.LEDMatrix and deprecated aliases
|
||||
(later) escape.js, notify.js, dialog.js, streams.js, visibility.js,
|
||||
visibility.js ctx.visibility: a page's timers run only while it is on screen
|
||||
(later) escape.js, notify.js, dialog.js, streams.js,
|
||||
store.js (the one installed-plugin store), form/renderer.js
|
||||
pages/ one module per tab partial
|
||||
cache.js export init(root, ctx), destroy(root, ctx)
|
||||
@@ -81,6 +82,20 @@ The conventions the converted pages share:
|
||||
to the module's export of the same name and warns once.
|
||||
- **Timers are cleared in `destroy()`**, the one thing `ctx.signal` cannot
|
||||
undo by itself.
|
||||
- **Polling goes through `ctx.visibility`.** A refresh that repeats
|
||||
(`ctx.visibility.every(ms, fn)`) or work that should run only while the
|
||||
page is on screen (`ctx.visibility.whileVisible(start, stop)`) is
|
||||
registered there, never with a bare `setInterval`. It runs only while the
|
||||
page's tab is the active tab and the browser tab is visible, and it ends
|
||||
when the page is destroyed, with no code in `destroy()`.
|
||||
- **A page reports its own htmx saves.** A form whose result a page module
|
||||
shows (an `htmx:afterRequest` listener on the page root, in place of an
|
||||
`hx-on` attribute naming a global) carries `data-reports-result`. `app.js`
|
||||
then leaves the server's message to the page, as it does for a form with
|
||||
an `hx-on` after-request handler, so a save shows one notification.
|
||||
- **Server data for the module goes in `data-*` attributes**, as JSON where
|
||||
it is structured (`data-schedule-config='{{ schedule_config | tojson }}'`),
|
||||
not templated into a script.
|
||||
|
||||
`core/registry.js` handles the rest:
|
||||
|
||||
@@ -103,6 +118,7 @@ Each mount gets a `ctx` object:
|
||||
| `ctx.state` | A per-mount object for the page's own state |
|
||||
| `ctx.api` | Shared service from `boot.js` |
|
||||
| `ctx.notify` | Shared service from `boot.js` |
|
||||
| `ctx.visibility` | This page's handle on `core/visibility.js` (below), made per mount by `boot.js` through the registry's `mountContext` option |
|
||||
|
||||
A page that passes `{ signal: ctx.signal }` to `addEventListener` and
|
||||
`fetch` needs no teardown code. Its listeners and in-flight requests go
|
||||
@@ -111,6 +127,30 @@ example: its delete buttons use one delegated listener, rows are built with
|
||||
`textContent` rather than markup strings, and a newer load supersedes an
|
||||
older one.
|
||||
|
||||
### Page visibility
|
||||
|
||||
`core/visibility.js` gives each mounted page `ctx.visibility`:
|
||||
|
||||
| Member | What it does |
|
||||
|---|---|
|
||||
| `whileVisible(start, stop)` | Runs `start()` when the page comes on screen (at once, if it mounts on screen) and `stop()` when it leaves. Returns a function that ends the registration, running `stop()` first if needed |
|
||||
| `every(ms, fn)` | `fn()` at once, then every `ms` while on screen. The interval is cleared while hidden and restarted, with an immediate `fn()`, when the page is back. Returns the same kind of end function |
|
||||
| `isVisible()` | True while the page is on screen |
|
||||
| `tab` | The tab the page belongs to: its name, or `forPage(ctx, { tab })` |
|
||||
|
||||
"On screen" means the page's tab is the active tab and the browser tab is
|
||||
visible. Everything a page registered ends when its `ctx.signal` aborts,
|
||||
after `destroy()`, so a swapped-out partial leaves no interval behind.
|
||||
|
||||
The answer comes from `window.LEDVisibility` (`app-shell.js`), read at call
|
||||
time, so the page modules and the classic partials that still call it
|
||||
(Overview, Logs, Tools) agree on the active tab, and the SSE streams keep
|
||||
pausing with them. Each registration takes its own `LEDVisibility` key, so
|
||||
registrations never replace each other or a classic partial's. Without
|
||||
`LEDVisibility` (a page outside `base.html`), the browser tab's visibility
|
||||
alone decides. Moving the tracker itself into the module (the shell table
|
||||
below) changes only `core/visibility.js`.
|
||||
|
||||
### One facade
|
||||
|
||||
`window.LEDMatrix` is the only global the module code adds:
|
||||
@@ -239,9 +279,9 @@ are the inline script in each partial today.
|
||||
| 3 | Operation History | 293 lines, now 0 | **Done in stage 2.** Read-only list; rows drawn with `textContent`, the search debounce cleared on destroy. The "Showing x to y" counters now also reset when nothing matches |
|
||||
| 4 | Config Editor (`raw_json.html`) | 212 lines, now 0 | **Done in stage 2.** Plain textareas (no CodeMirror on this page). It defined 5 globals after all (`formatJson`, `manualValidateJson`, `validateJSON`, `saveMainConfig`, `saveSecretsConfig`); nothing else used them, and they are deprecated aliases now. The live "Invalid JSON" line no longer puts the parser's message into `innerHTML` |
|
||||
| 5 | Backup & Restore | 232 lines, now 0 | **Done in stage 2.** Its 5 globals (`exportBackup`, `loadBackupList`, `validateRestoreFile`, `clearRestore`, `runRestore`) are deprecated aliases; the buttons are delegated `data-action`s. Uploads go through `ctx.api.request(..., { body: formData })` (`api.js` gained a raw `body` option) |
|
||||
| 6 | Schedule | 193 | 2 globals used as `hx-on` response handlers. Moves `hx-on` handlers into page listeners |
|
||||
| 7 | General | 147 | `webLogin` global and the security section. The first page that touches login |
|
||||
| 8 | Display | 231 | First page with `LEDVisibility` timers: those move to a `ctx.visibility` service that stops on destroy |
|
||||
| 6 | Schedule | 193 lines, now 0 | **Done in stage 3.** Its 2 `hx-on` response handlers (`handleScheduleResponse`, `handleDimScheduleResponse`) are one `htmx:afterRequest` listener on the page root, and deprecated aliases. The forms are marked `data-reports-result` so `app.js` does not repeat the server's message. The saved schedules reach the module as JSON in `data-schedule-config` / `data-dim-schedule-config` instead of being templated into the script |
|
||||
| 7 | General | 153 lines, now 0 | **Done in stage 3.** The Security section's three forms and two buttons are delegated `data-action`s (one submit and one click listener); `window.webLogin` is a deprecated alias of an object with its five methods. Login requests go through `ctx.api`, so the login redirect is quiet. The settings form keeps its `hx-on` call to the shared `showSaveResult`, as Rotation's does |
|
||||
| 8 | Display | 292 lines (2 scripts), now 0 | **Done in stage 4.** The first page with a timer: the 5 s multi-display sync poll is `ctx.visibility.every(5000, ...)` (above), so it runs only while the tab is on screen and stops when the partial is swapped out. Its one global, `updateSyncUI` (the Role menu's `onchange`), is a deprecated alias; the Advanced section's `onclick` is a delegated `data-action="toggle-section"` that calls the shared `toggleSection`. The status poll and the scroll-speed hint go through `ctx.api` with `ctx.signal`, as does the Vegas order widget's plugin-list request. The settings form keeps its `hx-on` call to `showSaveResult` and its `onsubmit` call to `fixInvalidNumberInputs`, as Rotation's does |
|
||||
| 9 | Overview | 410 (4 scripts) | First-run surface: Getting Started, update banner, live preview. Five globals |
|
||||
| 10 | WiFi | 364 | `x-data="wifiSetup()"` is defined by its own script. Moves to `Alpine.data()` registered from the module. AP-mode first screen, so it needs the AP-mode test on a real device |
|
||||
| 11 | Fonts | 681 | Large, but self-contained (6 globals) |
|
||||
@@ -258,7 +298,7 @@ the order:
|
||||
| `showNotification` | 4 versions | `core/notify.js` |
|
||||
| The modal helper | `utils/dialog.js` | `core/dialog.js` |
|
||||
| SSE streams | `app-shell.js` | `core/streams.js` |
|
||||
| `LEDVisibility` | `app-shell.js` | `core/visibility.js` |
|
||||
| `LEDVisibility` | `app-shell.js` | `core/visibility.js` (the page-facing `ctx.visibility` is there since step 8; it reads the tracker from `app-shell.js`) |
|
||||
|
||||
Each move leaves the old global as an alias. When the last inline script is
|
||||
gone, the script re-execution in `htmx-config.js` and the "HTMX never
|
||||
@@ -278,12 +318,16 @@ Unit suites need only node. They import the shipped modules directly:
|
||||
|
||||
| Suite | Kind | What it covers |
|
||||
|---|---|---|
|
||||
| `unit/test_page_registry.js` | Unit, minimal DOM shim | The lifecycle: one init per root, destroy on swap, a veto keeps the page, swaps elsewhere leave it alone, the sweep, lazy loading, a destroy while loading, error containment |
|
||||
| `unit/test_page_registry.js` | Unit, minimal DOM shim | The lifecycle: one init per root, destroy on swap, a veto keeps the page, swaps elsewhere leave it alone, the sweep, lazy loading, a destroy while loading, error containment, `mountContext` fields per mount |
|
||||
| `dom/test_visibility_service.js` | DOM: real `LEDVisibility` from `app-shell.js`, real registry, no server | `whileVisible` and `every` start and stop with the active tab and the browser tab's visibility; no interval runs while hidden or after a swap-out; one interval after five swaps; registrations never replace each other or a classic partial's; a destroyed page registers nothing; a throwing `start()` is contained; the no-`LEDVisibility` fallback |
|
||||
| `unit/test_core_modules.js` | Unit | `api.js` (envelope, errors, abort, login redirect, path check) and `facade.js` (facade, aliases) |
|
||||
| `dom/test_cache_page.js` | DOM: real partial, real API shape | No inline script; one request per swap and per Refresh after five swaps; a cancelled request draws nothing; hostile keys stay text; delete, empty, error, network and login states |
|
||||
| `dom/test_durations_page.js` | DOM: real partial, real widget, real API shape | One plugin-list request per swap; Move down moves one place after five swaps; the swap cancels a request in flight; a late-loading widget is waited for, and a page swapped away while waiting starts nothing; hostile names stay text |
|
||||
| `dom/test_operation_history_page.js` | DOM: real partial, real API shape | One history request per swap and per Refresh; the plugin filter filled once (from `PluginAPI`'s cache when loaded); paging, filters, debounced search, Clear (one DELETE), error/network/login states, cancel on swap; hostile ids, users and errors stay text |
|
||||
| `dom/test_raw_json_page.js` | DOM: real partial, real config | One POST per Save after five swaps, to the right file; Format and Validate act once; invalid JSON never sent and its message stays text; a save survives a swap and is still reported; the old globals' entry points |
|
||||
| `dom/test_schedule_page.js` | DOM: real partial, real widget | Both pickers drawn once per swap from the saved config; after five swaps each form's answer is one notification (message, fallback, refused, non-JSON, `null`), a request from outside the forms none; the brightness label; a late widget waited for, a page swapped away while waiting draws nothing; the old globals' entry points |
|
||||
| `dom/test_display_page.js` | DOM: real partial, real widget, real `LEDVisibility`, real API shape | After five swaps one page, one sync interval, the Vegas order drawn once and each control acting once (brightness, resolution, the two show/hide toggles, the Advanced toggle, one debounced hint request); the sync poll only while on screen and never after a swap-out; sync states and hostile peer names as text, failure and login answers; a late widget waited for; `updateSyncUI`'s entry point |
|
||||
| `dom/test_general_page.js` | DOM: real partial, real widget, real API shape | The timezone picker drawn once per swap with the saved zone; the settings form left to htmx; after five swaps each Security action makes one request (create, copy, revoke and its cancel, password and its mismatch); hostile token names stay text; refused, network and login answers; a create made before a swap is still reported and draws nothing; `webLogin`'s entry points |
|
||||
| `dom/test_backup_restore_page.js` | DOM: real partial, real API shape | One request per Refresh, Delete, Export (busy button ignores a second click), Inspect and Restore after five swaps; the upload's fields and the six restore options; reads cancelled by a swap, writes not; hostile file and host names stay text; the old globals' entry points |
|
||||
| `test/web_interface/test_es_modules.py` | pytest | MIME type; `no-cache` without `?v` and immutable with it; `boot.js` loads last; every import resolves inside `core/` and `pages/`; the converted pages are exactly the registered ones, each with its module, `init`, and one root in the rendered partial; a converted partial has no `<script>` and no `onclick`; every moved global is aliased in `boot.js` and exported by its module, and no template defines it any more |
|
||||
| `test/test_field_model_parity.py` | pytest | The model against the macro for every available schema |
|
||||
|
||||
@@ -38,6 +38,7 @@ src/common/sports_celebration.py
|
||||
src/common/sports_display_rules.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
|
||||
@@ -88,6 +89,7 @@ src/plugin_system/testing/vegas.py
|
||||
src/plugin_system/vegas_elements.py
|
||||
src/redaction.py
|
||||
src/scan_order.py
|
||||
src/screen_runner.py
|
||||
src/startup_validator.py
|
||||
src/vegas_mode/__init__.py
|
||||
src/vegas_mode/config.py
|
||||
|
||||
+1
-1
@@ -4,5 +4,5 @@ LEDMatrix Display System
|
||||
Core source package for the LED Matrix Display project.
|
||||
"""
|
||||
|
||||
__version__ = "3.8.0"
|
||||
__version__ = "3.8.1"
|
||||
|
||||
|
||||
+62
-1
@@ -25,6 +25,7 @@ Typical plugin usage::
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
import pytz
|
||||
@@ -72,6 +73,62 @@ def _outlived(record: Any, max_age: Optional[float], now: float) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
#: Cache keys that were file "mailboxes" from the web interface (and some
|
||||
#: plugins) to the display. The control socket replaced them, and nothing
|
||||
#: reads them any more, so a write is refused rather than left on the SD card
|
||||
#: for nobody: see :func:`_refuse_retired_mailbox_write`.
|
||||
RETIRED_MAILBOX_KEYS = frozenset({'display_on_demand_request', 'plugin_error_clear_request'})
|
||||
|
||||
#: (key, writer) pairs already warned about, so a plugin that writes on every
|
||||
#: event logs once per process, not once per write.
|
||||
_retired_writers_warned: set = set()
|
||||
_retired_writers_lock = threading.Lock()
|
||||
|
||||
|
||||
def _retired_mailbox_writer(data: Any) -> str:
|
||||
"""Name whoever is writing a retired mailbox key, as well as can be told.
|
||||
|
||||
The plugin instance on the call stack when there is one (a ``self`` with
|
||||
a string ``plugin_id`` and a ``cache_manager``: what BasePlugin gives
|
||||
every plugin), else the ``plugin_id`` the request itself names, else
|
||||
``'unknown'``.
|
||||
"""
|
||||
frame = sys._getframe(2) # pylint: disable=protected-access
|
||||
depth = 0
|
||||
while frame is not None and depth < 25:
|
||||
owner = frame.f_locals.get('self')
|
||||
plugin_id = getattr(owner, 'plugin_id', None) if owner is not None else None
|
||||
if isinstance(plugin_id, str) and plugin_id and hasattr(owner, 'cache_manager'):
|
||||
return f"plugin '{plugin_id}'"
|
||||
frame = frame.f_back
|
||||
depth += 1
|
||||
payload = data.get('data', data) if isinstance(data, dict) else None
|
||||
named = payload.get('plugin_id') if isinstance(payload, dict) else None
|
||||
if isinstance(named, str) and named:
|
||||
return f"plugin '{named}' (named in the request)"
|
||||
return 'unknown'
|
||||
|
||||
|
||||
def _refuse_retired_mailbox_write(logger: logging.Logger, key: str, data: Any) -> None:
|
||||
"""Warn, once per writer, that a write to a retired mailbox key was dropped."""
|
||||
try:
|
||||
writer = _retired_mailbox_writer(data)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
writer = 'unknown'
|
||||
with _retired_writers_lock:
|
||||
if (key, writer) in _retired_writers_warned:
|
||||
return
|
||||
_retired_writers_warned.add((key, writer))
|
||||
if key == 'display_on_demand_request':
|
||||
hint = ("call self.request_on_demand() / self.end_on_demand() instead "
|
||||
"(BasePlugin, LEDMatrix 3.8.1 and later)")
|
||||
else:
|
||||
hint = "clear errors through POST /api/v3/errors/clear instead"
|
||||
logger.warning("Ignored a write to the retired '%s' cache key by %s: the display no "
|
||||
"longer reads this file mailbox. Update it to %s. (Logged once per writer.)",
|
||||
key, writer, hint)
|
||||
|
||||
|
||||
class CacheManager:
|
||||
"""Manages caching of API responses to reduce API calls."""
|
||||
|
||||
@@ -295,7 +352,7 @@ class CacheManager:
|
||||
def _get_cache_path(self, key: str) -> Optional[str]:
|
||||
"""Get the path for a cache file."""
|
||||
return self._disk_cache_component.get_cache_path(key)
|
||||
|
||||
|
||||
def 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.
|
||||
|
||||
@@ -333,6 +390,10 @@ class CacheManager:
|
||||
key: Cache key
|
||||
data: Data to cache
|
||||
"""
|
||||
if key in RETIRED_MAILBOX_KEYS:
|
||||
_refuse_retired_mailbox_write(self.logger, key, data)
|
||||
return
|
||||
|
||||
# Periodic cleanup before adding new entries
|
||||
self._cleanup_memory_cache()
|
||||
|
||||
|
||||
+12
-1
@@ -46,6 +46,7 @@ Rules for the package:
|
||||
| [`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_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 |
|
||||
@@ -294,6 +295,16 @@ path as given when it exists (relative to the cwd), else
|
||||
`font_layout.resolve_asset_path(path)`. What the scoreboards'
|
||||
`_resolve_font_path` copies return on a core that ships it.
|
||||
|
||||
### sports_game_over
|
||||
|
||||
[`sports_game_over.py`](sports_game_over.py). `SportsGameOverMixin`:
|
||||
`_is_game_really_over(game)`, the `SportsLive` check that drops a game ESPN
|
||||
still lists as live (`SportsLiveSharedMixin._detect_stale_games` calls it).
|
||||
Over on a final period text, or on a 0:00 clock from period `FINAL_PERIOD`
|
||||
on unless the score is level. `FINAL_PERIOD` is a class attribute the host
|
||||
sets per sport; the default `None` means the clock never ends a game. List
|
||||
it before `SportsLiveSharedMixin`.
|
||||
|
||||
### sports_game_renderer
|
||||
|
||||
[`sports_game_renderer.py`](sports_game_renderer.py).
|
||||
@@ -391,7 +402,7 @@ Created by `DisplayController`; works with any plugin.
|
||||
`draw_multiline_text()`, `create_text_image()`.
|
||||
|
||||
`draw_text_outlined(draw, xy, text, font, fill, outline_color=(0, 0, 0),
|
||||
offsets=OUTLINE_SQUARE)` (Unreleased) draws the text in `outline_color` at
|
||||
offsets=OUTLINE_SQUARE)` (3.8.1) draws the text in `outline_color` at
|
||||
each offset, then in `fill` on top: the same pixels as one `draw.text` per
|
||||
offset, but the string is rasterized once. `OUTLINE_SQUARE` is the
|
||||
eight-sided one-pixel outline the scoreboards draw, `OUTLINE_CROSS` the
|
||||
|
||||
@@ -59,7 +59,10 @@ says how old with ``cache_max_age`` (``fetch_get(..., cache_max_age=ttl)``;
|
||||
Identical means what the validator store keys on: URL, query, effective
|
||||
headers and, for a session with cookies or auth, the session.
|
||||
|
||||
**Counters.** Requests, merged requests, bytes, 304s, errors, HTTP errors,
|
||||
**Counters.** Requests, merged requests, bytes (``bytes`` decoded, as the
|
||||
caller reads them; ``wire_bytes`` as they crossed the network, which is
|
||||
what a metered connection pays for -- ESPN gzips, so the two differ ~14x),
|
||||
304s, errors, HTTP errors,
|
||||
adapter retries, throttled requests and seconds waited, plus requests
|
||||
answered without the network: ``memo_hits`` (the response cache) and
|
||||
``cache_hits`` / ``legacy_cache_hits`` (a shared ESPN scoreboard cache entry,
|
||||
@@ -201,6 +204,7 @@ _COUNTER_FIELDS = (
|
||||
"throttled", # requests that waited for a host budget
|
||||
"overruns", # requests that went after max_wait_seconds anyway
|
||||
"bytes", # decoded response body bytes received
|
||||
"wire_bytes", # body bytes as they came off the socket (still compressed)
|
||||
"wait_seconds", # time spent waiting for host budgets
|
||||
"memo_hits", # answered from the response cache (max-age); nothing sent
|
||||
"cache_hits", # scoreboard fetches answered from a shared ESPN cache entry
|
||||
@@ -616,6 +620,30 @@ def _body_of(response: Any) -> Optional[bytes]:
|
||||
return content if isinstance(content, bytes) else None
|
||||
|
||||
|
||||
def _wire_bytes_of(response: Any, body: Optional[bytes]) -> int:
|
||||
"""How many body bytes came off the socket for ``response``: the
|
||||
compressed size when the server sent gzip, which ESPN does for every
|
||||
scoreboard (63 KB on the wire for an 865 KB college football Saturday).
|
||||
|
||||
urllib3's ``HTTPResponse.tell()`` counts the raw bytes read before
|
||||
decoding. A response without one (a test double, an adapter that is not
|
||||
urllib3) or one whose body was not read is counted at its decoded size,
|
||||
or as 0, so the counter never claims less than it can prove.
|
||||
"""
|
||||
if body is None:
|
||||
return 0
|
||||
raw = getattr(response, "raw", None)
|
||||
tell = getattr(raw, "tell", None)
|
||||
if callable(tell):
|
||||
try:
|
||||
read = tell()
|
||||
except Exception:
|
||||
read = None
|
||||
if isinstance(read, int) and not isinstance(read, bool) and read > 0:
|
||||
return read
|
||||
return len(body)
|
||||
|
||||
|
||||
def _retries_of(response: Any) -> int:
|
||||
raw = getattr(response, "raw", None)
|
||||
retries = getattr(raw, "retries", None)
|
||||
@@ -1117,6 +1145,7 @@ class FetchService:
|
||||
http_errors=int(status is not None and status >= 400),
|
||||
retries=_retries_of(response),
|
||||
bytes=len(body) if body is not None else 0,
|
||||
wire_bytes=_wire_bytes_of(response, body),
|
||||
throttled=int(waited > 0), overruns=int(overrun),
|
||||
wait_seconds=waited)
|
||||
except Exception:
|
||||
|
||||
@@ -135,8 +135,6 @@ import time
|
||||
import traceback
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple, TypedDict
|
||||
|
||||
from src.common import scroll_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Bumped when a field changes meaning, so a reader can refuse stale files.
|
||||
@@ -181,12 +179,6 @@ MAX_REFRESH_DROP = 0.2
|
||||
#: trusted -- about a second of scrolling.
|
||||
MIN_FRAMES_FOR_REFRESH = 90
|
||||
|
||||
#: Trusted windows, counting the one that adopted the period, before a panel
|
||||
#: slower than its cap is reported. The estimate can still fall (the refresh
|
||||
#: rate rise) by up to MAX_REFRESH_DROP per window early on; the warning
|
||||
#: should not name a rate one more window would have corrected.
|
||||
REFRESH_CHECK_WINDOWS = 3
|
||||
|
||||
FLUSH_INTERVAL = 10.0
|
||||
|
||||
#: A scroll's last frame older than this is a stall worth a stack dump.
|
||||
@@ -449,12 +441,6 @@ class FrameTimingRecorder:
|
||||
1.0 / refresh_hz if refresh_hz and refresh_hz > 0 else None)
|
||||
# The first estimate, until a second window agrees with it.
|
||||
self._refresh_candidate: Optional[float] = None
|
||||
# The rate scroll speeds are solved against; see plan_refresh().
|
||||
self.planned_refresh_hz: Optional[float] = None
|
||||
# Trusted windows seen since the period was adopted, until the
|
||||
# shortfall check has run.
|
||||
self._refresh_windows = 0
|
||||
self._shortfall_checked = True
|
||||
self.totals: Dict[str, Any] = {
|
||||
"static_frames": 0,
|
||||
"scroll_frames": 0,
|
||||
@@ -653,12 +639,7 @@ class FrameTimingRecorder:
|
||||
self._refresh_candidate = estimate
|
||||
elif current * (1.0 - MAX_REFRESH_DROP) <= estimate < current:
|
||||
self.refresh_period = estimate
|
||||
if self.refresh_period is not None:
|
||||
self._refresh_windows += 1
|
||||
period = self.refresh_period
|
||||
if (period and not self._shortfall_checked
|
||||
and self._refresh_windows >= REFRESH_CHECK_WINDOWS):
|
||||
self._check_refresh_shortfall(1.0 / period)
|
||||
|
||||
histograms = self.histograms
|
||||
for frame in batch:
|
||||
@@ -707,25 +688,6 @@ class FrameTimingRecorder:
|
||||
elif missed <= -1:
|
||||
totals["early_frames"] += 1
|
||||
|
||||
def plan_refresh(self, hz: Optional[float]) -> None:
|
||||
"""Say what rate scroll speeds are solved against, before frames arrive.
|
||||
|
||||
``DisplayManager.refresh_hz``: the configured cap. Once the measured
|
||||
rate has held for :data:`REFRESH_CHECK_WINDOWS` windows, a panel that
|
||||
falls short of it is logged once, with a cap it can hold (see
|
||||
:func:`src.common.scroll_config.refresh_shortfall`). The display
|
||||
manager calls this only for a real panel.
|
||||
"""
|
||||
self.planned_refresh_hz = hz
|
||||
self._shortfall_checked = not hz
|
||||
|
||||
def _check_refresh_shortfall(self, measured_hz: float) -> None:
|
||||
"""Log, once, a panel that cannot reach the rate speeds assume."""
|
||||
self._shortfall_checked = True
|
||||
shortfall = scroll_config.refresh_shortfall(measured_hz, self.planned_refresh_hz)
|
||||
if shortfall:
|
||||
logger.warning(scroll_config.describe_refresh_shortfall(shortfall))
|
||||
|
||||
def snapshot(self) -> Dict[str, Any]:
|
||||
"""The JSON document: cumulative since this process started."""
|
||||
if not self._binding_checked:
|
||||
@@ -742,9 +704,6 @@ class FrameTimingRecorder:
|
||||
"bucket_ms": BUCKET_MS,
|
||||
"freeze_seconds": FREEZE_SECONDS,
|
||||
"measured_refresh_hz": round(1.0 / period, 2) if period else None,
|
||||
# Additive: what scroll speeds were solved against, so a reader can
|
||||
# tell a stale file (written under another cap) from this one.
|
||||
"planned_refresh_hz": self.planned_refresh_hz,
|
||||
"binding_releases_gil": self._binding_gil,
|
||||
"info": info,
|
||||
"totals": copy.deepcopy(self.totals),
|
||||
|
||||
@@ -538,66 +538,3 @@ def speed_advice(
|
||||
"smooth": smooth,
|
||||
"alternatives": [as_dict(c) for c in alternatives],
|
||||
}
|
||||
|
||||
|
||||
#: A measured refresh this far below the rate speeds are planned for means
|
||||
#: the panel cannot reach its cap. Smaller gaps are the cap's own slack and
|
||||
#: the estimate's: one rig measured 99.95 Hz under a 100 Hz cap.
|
||||
REFRESH_SHORTFALL = 0.03
|
||||
|
||||
#: How far under the measured rate a suggested cap sits. The measurement is
|
||||
#: the fast end of the panel's refreshes (frame_timing takes the 10th
|
||||
#: percentile of intervals), and an uncapped panel drifts: one read
|
||||
#: 107.6-113.1 Hz over 15 seconds. A cap inside that band would not hold.
|
||||
CAP_HEADROOM = 0.05
|
||||
|
||||
|
||||
def holdable_cap(measured_hz: Any) -> Optional[int]:
|
||||
"""A refresh cap the panel can hold: a multiple of 10, 5% under what it measured.
|
||||
|
||||
A multiple of 10 because its whole-pixel speeds are round numbers (a
|
||||
100 Hz cap gives 50 and 100 px/s). None without a usable measurement, or
|
||||
when the panel is too slow for any cap of 10 Hz or more.
|
||||
"""
|
||||
hz = _coerce(measured_hz)
|
||||
if hz is None:
|
||||
return None
|
||||
cap = int(hz * (1.0 - CAP_HEADROOM) // 10) * 10
|
||||
return cap if cap >= 10 else None
|
||||
|
||||
|
||||
def refresh_shortfall(measured_hz: Any, planned_hz: Any) -> Optional[Dict[str, Any]]:
|
||||
"""When the panel refreshes measurably slower than speeds are planned for.
|
||||
|
||||
``planned_hz`` is what :func:`configure` solves against -- the
|
||||
``limit_refresh_rate_hz`` cap, or :data:`DEFAULT_REFRESH_HZ` when it is 0.
|
||||
A panel that cannot reach it still moves whole pixels per frame, but every
|
||||
speed runs slow by the shortfall and the ladder of smooth speeds is the
|
||||
cap's, not the panel's. None when there is no measurement, or the panel
|
||||
reaches the cap (or beats it, as some do by a few Hz).
|
||||
"""
|
||||
measured, planned = _coerce(measured_hz), _coerce(planned_hz)
|
||||
if measured is None or planned is None:
|
||||
return None
|
||||
if measured >= planned * (1.0 - REFRESH_SHORTFALL):
|
||||
return None
|
||||
return {
|
||||
"measured_hz": round(measured, 1),
|
||||
"planned_hz": round(planned, 1),
|
||||
"suggested_cap_hz": holdable_cap(measured),
|
||||
"slow_percent": round((1.0 - measured / planned) * 100),
|
||||
}
|
||||
|
||||
|
||||
def describe_refresh_shortfall(shortfall: Dict[str, Any]) -> str:
|
||||
"""One log line for :func:`refresh_shortfall`'s answer."""
|
||||
text = (
|
||||
f"The panel refreshes at about {shortfall['measured_hz']:.0f} Hz, below "
|
||||
f"the {shortfall['planned_hz']:.0f} Hz that scroll speeds are planned "
|
||||
f"for (display.hardware.limit_refresh_rate_hz), so every scroll runs "
|
||||
f"about {shortfall['slow_percent']}% slower than configured and the "
|
||||
f"smooth speeds are worked out for a rate this panel never reaches.")
|
||||
if shortfall.get("suggested_cap_hz"):
|
||||
text += (f" Set Limit Refresh Rate to {shortfall['suggested_cap_hz']} Hz "
|
||||
f"(web UI, Display tab), which this panel can hold, and restart.")
|
||||
return text
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Whether a game ESPN still lists as live has in fact ended (sports family 5).
|
||||
|
||||
``SportsGameOverMixin._is_game_really_over`` is the scoreboards'
|
||||
``SportsLive._is_game_really_over``, reconciled in ledmatrix-plugins
|
||||
#625 from five bodies into one and copied here under
|
||||
its existing name. ``SportsLiveSharedMixin._detect_stale_games``
|
||||
(``src.common.sports_shared``) calls it on every live game, and the plugins'
|
||||
live-priority filters call it too, to drop a game ESPN still reports as
|
||||
in progress.
|
||||
|
||||
A game is over when its period text says final. From period ``FINAL_PERIOD``
|
||||
on, a clock reading 0:00 ends it too, unless the score is level: a tie at the
|
||||
end of regulation goes to overtime (or a shootout), and a game that does end
|
||||
tied says final. Only a clock *string* is read ("0:00" and ":00" are zero;
|
||||
":40", "0.0" and ESPN's "-" between MMA rounds are not), and a missing or
|
||||
unreadable score leaves the decision to the clock.
|
||||
|
||||
``FINAL_PERIOD`` is the one per-sport fact, a class attribute rather than a
|
||||
sport-name branch. The scoreboards declare it on their ``SportsLive``:
|
||||
|
||||
- 3: hockey;
|
||||
- 4: basketball, football, lacrosse;
|
||||
- ``None`` (this default; the clock never ends a game): afl, nrl and soccer,
|
||||
whose clocks count up; baseball, which has innings; ufc, whose bouts end
|
||||
only on ESPN's final status.
|
||||
|
||||
A sport can still override the method and defer to it, as baseball's
|
||||
``BaseballLive`` does to end postponed and suspended games first.
|
||||
|
||||
A new module rather than another method on ``sports_shared``, for the reason
|
||||
``sports_helpers`` gives: a missing module fails at load, where the version
|
||||
checks see it; a missing method fails mid-update.
|
||||
|
||||
WHAT A HOST MUST PROVIDE
|
||||
------------------------
|
||||
Derived by walking every ``self.<attr>`` the mixin reads; the host-contract
|
||||
test in ``test/test_sports_game_over.py`` fails if a read is added without
|
||||
being listed here.
|
||||
|
||||
- ``logger`` -- a ``logging.Logger``; the method logs its verdict at DEBUG.
|
||||
- ``FINAL_PERIOD`` -- defaulted here to ``None``; set it on the host class.
|
||||
|
||||
The method reads the game dict's ``away_abbr``, ``home_abbr``,
|
||||
``period_text``, ``period``, ``clock``, ``away_score`` and ``home_score``
|
||||
(``_extract_game_details_common``'s keys); any of them may be missing or
|
||||
null.
|
||||
|
||||
BASE ORDER
|
||||
----------
|
||||
List the mixin before ``SportsLiveSharedMixin`` --
|
||||
``class SportsLive(SportsGameOverMixin, SportsLiveSharedMixin, SportsCore)`` --
|
||||
so the shared mixin's ``_detect_stale_games`` finds this method through the
|
||||
MRO. Neither shared mixin defines it, so the order does not change which body
|
||||
runs today; it keeps the method next to its caller should one ever be added
|
||||
there. A method on the plugin's own class still wins, and its ``super()``
|
||||
reaches this one. The mixin has no ``__init__`` and no state.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, Optional
|
||||
|
||||
|
||||
class SportsGameOverMixin:
|
||||
"""The live manager's "is this game really over?" check. See module docstring."""
|
||||
|
||||
# The host contract, declared for type checking only.
|
||||
logger: logging.Logger
|
||||
|
||||
#: Period from which a 0:00 clock ends a game; None: the clock never does.
|
||||
FINAL_PERIOD: Optional[int] = None
|
||||
|
||||
def _is_game_really_over(self, game: Dict) -> bool:
|
||||
"""Whether a game ESPN still lists as live has in fact ended.
|
||||
|
||||
It has when its period text says final. From period ``FINAL_PERIOD``
|
||||
on, a clock at 0:00 ends it too, unless the score is level: a tie at
|
||||
the end of regulation goes to overtime, and a game that does end tied
|
||||
says final. With ``FINAL_PERIOD = None`` the clock never ends a game.
|
||||
"""
|
||||
game_str = f"{game.get('away_abbr')}@{game.get('home_abbr')}"
|
||||
|
||||
# ESPN can send the key as null, and .get()'s default only covers a
|
||||
# missing key, so a None here crashed the whole live update.
|
||||
raw_period_text = game.get("period_text")
|
||||
period_text = raw_period_text.lower() if isinstance(raw_period_text, str) else ""
|
||||
if "final" in period_text:
|
||||
self.logger.debug(
|
||||
f"_is_game_really_over({game_str}): "
|
||||
f"returning True - 'final' in period_text='{period_text}'"
|
||||
)
|
||||
return True
|
||||
|
||||
# Same for a null or non-numeric period: treat it as period 0.
|
||||
try:
|
||||
period = int(game.get("period") or 0)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
period = 0
|
||||
# Only a clock string is read: "0:00" and ":00" are zero; ":40" is not.
|
||||
clock = game.get("clock")
|
||||
clock_at_zero = isinstance(clock, str) and clock.replace(":", "").strip() in ("000", "00")
|
||||
|
||||
if self.FINAL_PERIOD is not None and period >= self.FINAL_PERIOD and clock_at_zero:
|
||||
try:
|
||||
tied = int(game["away_score"]) == int(game["home_score"])
|
||||
except (KeyError, TypeError, ValueError, OverflowError):
|
||||
tied = False # a missing or unreadable score leaves it to the clock
|
||||
if not tied:
|
||||
self.logger.debug(
|
||||
f"_is_game_really_over({game_str}): "
|
||||
f"returning True - clock at 0:00 (clock='{clock}', period={period})"
|
||||
)
|
||||
return True
|
||||
self.logger.debug(
|
||||
f"_is_game_really_over({game_str}): "
|
||||
f"returning False - tied at 0:00 (period={period}), overtime next"
|
||||
)
|
||||
return False
|
||||
|
||||
self.logger.debug(
|
||||
f"_is_game_really_over({game_str}): returning False"
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
__all__ = ["SportsGameOverMixin"]
|
||||
@@ -57,7 +57,9 @@ Methods that stay per-plugin, because they are not identical across the eight
|
||||
``_get_layout_offset``, ``_by_importance``, ``_other_games_window``,
|
||||
``_upcoming_date_and_time_text``, ``_extract_game_details_common``,
|
||||
``_load_division_team_ids``, ``_get_timezone``, ``_is_favorite_game``,
|
||||
``_is_game_really_over``, ``_is_ranked_game``, ``_passes_other_filters``.
|
||||
``_is_ranked_game``, ``_passes_other_filters``. (``_is_game_really_over``,
|
||||
which ``_detect_stale_games`` below calls, was here too until the plugins
|
||||
reconciled it; it is now ``src.common.sports_game_over``.)
|
||||
|
||||
Of the fourteen shared class constants, thirteen are identical everywhere and
|
||||
live here. Only ``_SCORE_PROBE_TEXT`` varies -- afl and basketball reach three digits
|
||||
@@ -120,6 +122,32 @@ _DEFAULT_LIVE_IDLE_MAX_SECONDS = 900
|
||||
_KICKOFF_GRACE_SECONDS = 900
|
||||
#: Fallback cadence around a kickoff when the manager has no update_interval.
|
||||
_KICKOFF_POLL_FLOOR = 30
|
||||
#: How many kickoffs after the current one a live manager remembers. Only the
|
||||
#: earliest few can matter before the next look refreshes the list, so this
|
||||
#: bounds the memory without dropping a kickoff the board would wait for.
|
||||
_KICKOFF_QUEUE_MAX = 8
|
||||
|
||||
|
||||
def _current_scheduled_start(host: Any, now: float) -> Optional[float]:
|
||||
"""The kickoff a live manager is honouring now, promoting the next queued one.
|
||||
|
||||
``_next_scheduled_start_ts`` is the kickoff being honoured: the earliest
|
||||
one ahead of us, or one that has just passed and is inside its grace.
|
||||
Kickoffs behind it wait in ``_later_scheduled_starts``. When the current
|
||||
one's grace runs out, the earliest queued kickoff that is not itself past
|
||||
its grace takes over -- including one that has already passed, so a
|
||||
second kickoff inside the first one's grace still gets a grace of its own.
|
||||
"""
|
||||
current: Optional[float] = getattr(host, "_next_scheduled_start_ts", None)
|
||||
if current and current > now - _KICKOFF_GRACE_SECONDS:
|
||||
return current
|
||||
queued: Optional[List[float]] = getattr(host, "_later_scheduled_starts", None)
|
||||
if queued:
|
||||
alive = sorted(s for s in queued if s > now - _KICKOFF_GRACE_SECONDS)
|
||||
current = alive.pop(0) if alive else None
|
||||
host._later_scheduled_starts = alive
|
||||
host._next_scheduled_start_ts = current
|
||||
return current if current and current > now - _KICKOFF_GRACE_SECONDS else None
|
||||
|
||||
|
||||
def _resolve_font_path(path: str) -> str:
|
||||
@@ -1294,11 +1322,11 @@ class SportsLiveSharedMixin:
|
||||
otherwise look like another empty check and escalate the back-off
|
||||
again, right when the game is actually starting.
|
||||
"""
|
||||
start = getattr(self, "_next_scheduled_start_ts", None)
|
||||
now = time.time()
|
||||
start = _current_scheduled_start(self, now)
|
||||
if not start:
|
||||
return interval
|
||||
live = getattr(self, "update_interval", None) or _KICKOFF_POLL_FLOOR
|
||||
now = time.time()
|
||||
if now < start:
|
||||
return max(live, min(interval, int(start - now)))
|
||||
if now - start <= _KICKOFF_GRACE_SECONDS:
|
||||
@@ -1313,6 +1341,16 @@ class SportsLiveSharedMixin:
|
||||
already has. Self-correcting: a stored start that has passed is
|
||||
replaced by the next one offered, so a postponed game cannot pin the
|
||||
cadence to a kickoff that never happens.
|
||||
|
||||
Every pending kickoff is honoured, not just the first. A kickoff that
|
||||
arrives while an earlier one is inside its grace is queued in
|
||||
``_later_scheduled_starts`` (the earliest _KICKOFF_QUEUE_MAX of them)
|
||||
and takes over when that grace ends, with a grace of its own. Keeping
|
||||
only the one kickoff dropped the second of two favourites starting
|
||||
within the grace of each other: it was refused while the first held
|
||||
the slot, and refused again once it had passed, so if ESPN had not
|
||||
flipped it live by the end of the first grace the back-off went
|
||||
straight back to its ceiling and the game was noticed up to that late.
|
||||
"""
|
||||
if not isinstance(details, dict):
|
||||
return
|
||||
@@ -1329,7 +1367,7 @@ class SportsLiveSharedMixin:
|
||||
now = time.time()
|
||||
if candidate <= now:
|
||||
return
|
||||
current = getattr(self, "_next_scheduled_start_ts", None)
|
||||
current = _current_scheduled_start(self, now)
|
||||
# A kickoff that has only just passed is *kept*, not replaced by the
|
||||
# next one on the card. Replacing it immediately is what made the grace
|
||||
# window in _clamp_to_scheduled_start dead code: the moment 13:00 came
|
||||
@@ -1339,10 +1377,20 @@ class SportsLiveSharedMixin:
|
||||
# polled at 13:00:45, found nothing live because ESPN had not flipped
|
||||
# the status yet, and then went quiet for the next quarter of an hour,
|
||||
# which is the behaviour this whole clamp exists to prevent.
|
||||
if (current is None
|
||||
or current <= now - _KICKOFF_GRACE_SECONDS
|
||||
or candidate < current):
|
||||
#
|
||||
# Nor is it forgotten: whichever kickoff loses is queued behind the
|
||||
# one honoured now, so it gets its own grace when that one's ends.
|
||||
if current is None:
|
||||
self._next_scheduled_start_ts = candidate
|
||||
return
|
||||
if candidate == current:
|
||||
return
|
||||
if candidate < current:
|
||||
self._next_scheduled_start_ts, candidate = candidate, current
|
||||
queued = getattr(self, "_later_scheduled_starts", None) or []
|
||||
if candidate not in queued:
|
||||
self._later_scheduled_starts = sorted(
|
||||
[*queued, candidate])[:_KICKOFF_QUEUE_MAX]
|
||||
|
||||
#: How long a game that finished live is still reported by
|
||||
#: finished_games_snapshot(): long enough for the recent-games list, which
|
||||
|
||||
+404
-39
@@ -1,41 +1,56 @@
|
||||
"""What the panel shows next: the Arbiter of docs/RUN_LOOP_REDESIGN.md.
|
||||
|
||||
``Arbiter.decide(state, inputs, now)`` takes a snapshot that
|
||||
``DisplayController.run()`` gathers once per pass and returns a
|
||||
:class:`ScreenPlan` naming the Source that gets the panel. It is a pure
|
||||
function: no I/O, no clock reads (``now`` is passed in), no locks, and it
|
||||
changes nothing it is given. That is what lets a plain table of cases test
|
||||
the priority order, which used to exist only as the order of ``if`` blocks
|
||||
in ``run()``.
|
||||
``DisplayController.run()`` gathers and returns a :class:`ScreenPlan` naming
|
||||
the Source that gets the panel. It is a pure function: no I/O, no clock
|
||||
reads (``now`` is passed in), no locks, and it changes nothing it is given.
|
||||
That is what lets a plain table of cases test the priority order, which
|
||||
used to exist only as the order of ``if`` blocks in ``run()``.
|
||||
|
||||
The full order is
|
||||
The order is
|
||||
|
||||
ScheduledOff (a gate), Follower, OnDemand, Wifi, Live, Vegas, Rotation
|
||||
|
||||
Stage 2 decides the gate, Follower and Wifi. Every other case returns a
|
||||
``LEGACY`` plan, meaning "carry on with run()'s existing code" (live
|
||||
priority, Vegas, then one rotation screen). OnDemand is in the order already
|
||||
because it outranks the WiFi notice: an active session is a ``LEGACY`` plan
|
||||
even when a notice is pending.
|
||||
Every Source but Vegas is decided here (stage 3). Vegas is the ``LEGACY``
|
||||
plan: the Arbiter picks it, but its iteration is still run()'s own code
|
||||
until stage 4.
|
||||
|
||||
The Wifi Source's mid-screen rule, :func:`wifi_notice_preempts`, lives here
|
||||
too, so both of its answers -- at the top of a pass and between frames --
|
||||
come from one module.
|
||||
A pass asks twice: once with the inputs every pass reads (the gate,
|
||||
Follower, OnDemand, Wifi), and once more, only when nothing above the
|
||||
notice took the panel, with the inputs the Sources below it need (whether
|
||||
Vegas is on, the live-priority scan), read where run() always read them.
|
||||
|
||||
``decide(..., running=plan)`` is the other question, asked by the
|
||||
ScreenRunner (src/screen_runner.py) at its service points: does a Source
|
||||
in ``plan.preemptible_by`` now take the panel from the screen that is
|
||||
running? The mid-screen rules are :func:`_hold_or_preempt`.
|
||||
|
||||
The state transitions (the next on-demand mode, a live claim and its
|
||||
release, the rotation's step after a screen) are pure methods of
|
||||
:class:`ArbiterState`; the controller applies what they return.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, replace
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
from typing import FrozenSet, Optional, Protocol, Tuple
|
||||
|
||||
__all__ = [
|
||||
"Arbiter",
|
||||
"ArbiterInputs",
|
||||
"ArbiterState",
|
||||
"FramePolicy",
|
||||
"LIVE_PREEMPTERS",
|
||||
"SCHEDULED_OFF_DWELL",
|
||||
"SCREEN_PREEMPTERS",
|
||||
"ScreenEnd",
|
||||
"ScreenPlan",
|
||||
"Source",
|
||||
"WIFI_NOTICE_DWELL",
|
||||
"WifiNotice",
|
||||
"live_pick",
|
||||
"live_takeover",
|
||||
"on_demand_bound",
|
||||
"rotation_plan",
|
||||
"wifi_notice_preempts",
|
||||
]
|
||||
|
||||
@@ -53,11 +68,41 @@ class Source(Enum):
|
||||
|
||||
SCHEDULED_OFF = "scheduled-off"
|
||||
FOLLOWER = "follower"
|
||||
ON_DEMAND = "on-demand"
|
||||
WIFI = "wifi"
|
||||
# Not decided by the Arbiter yet: on-demand, live priority, Vegas and the
|
||||
# rotation are still chosen by run()'s own code. Stage 3 adds the
|
||||
# OnDemand, Live and Rotation Sources; stage 4 adds Vegas.
|
||||
LIVE = "live"
|
||||
# Vegas: the Arbiter picks it, but its iteration is still run()'s own
|
||||
# code (and its interrupt callback a second copy of this order) until
|
||||
# stage 4 makes it a Source driven frame by frame.
|
||||
LEGACY = "legacy"
|
||||
ROTATION = "rotation"
|
||||
# Not a screen: a plugin reload waits at the top of the loop. It ends a
|
||||
# screen between frames (the screen counts as shown and the rotation
|
||||
# moves on), and the next pass reloads before it draws.
|
||||
RELOAD = "reload"
|
||||
|
||||
|
||||
class FramePolicy(Enum):
|
||||
"""How often a screen draws: today's two frame loops (see
|
||||
DisplayController._needs_high_fps). Stage 5 lets plugins declare it."""
|
||||
|
||||
#: The 125 Hz loop, paced to an 8 ms deadline: scrolling plugins.
|
||||
HIGH_FPS = "high-fps"
|
||||
#: The 1 Hz loop.
|
||||
STATIC = "static"
|
||||
|
||||
|
||||
class ScreenEnd(Protocol):
|
||||
"""What ArbiterState.after needs to know about how a screen ended
|
||||
(screen_runner.Outcome, filled in by the controller)."""
|
||||
|
||||
@property
|
||||
def on_demand_active(self) -> bool:
|
||||
"""An on-demand session was running when the screen ended."""
|
||||
|
||||
@property
|
||||
def still_live(self) -> bool:
|
||||
"""The mode's plugin still had live content: hold the rotation."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -76,11 +121,122 @@ class WifiNotice:
|
||||
class ArbiterState:
|
||||
"""What the Arbiter remembers between passes.
|
||||
|
||||
Nothing yet: the stage-2 Sources decide from the inputs alone. The
|
||||
on-demand index, the rotation index and the live resume point move here
|
||||
with their Sources in stage 3.
|
||||
A snapshot of the controller's own fields, taken when decide() is
|
||||
called (DisplayController._arbiter_state); the transitions below return
|
||||
the next state, and the controller writes it back.
|
||||
|
||||
Attributes:
|
||||
current_mode: The mode on the panel or about to be
|
||||
(``current_display_mode``).
|
||||
on_demand_modes: The on-demand session's modes, in the order it
|
||||
shows them (a pinned mode already moved to the front when the
|
||||
session started, by _apply_on_demand_pin).
|
||||
on_demand_index: Which of them is showing.
|
||||
on_demand_expires_at: When the session ends (wall clock), or None
|
||||
for a session with no duration.
|
||||
on_demand_pinned: The session was started pinned. Carried for the
|
||||
snapshot; the pin itself is already in ``on_demand_modes``.
|
||||
rotation: The rotation's modes (``available_modes``).
|
||||
rotation_index: Where the rotation is (``current_mode_index``).
|
||||
live_resume_index: Where the rotation was when live priority took
|
||||
the panel, so it resumes there once nothing is live; None while
|
||||
live priority holds nothing.
|
||||
live_takeover_unshown: A mid-screen takeover chose current_mode and
|
||||
it has not been shown yet, so the next pass must not advance the
|
||||
live round-robin past it.
|
||||
"""
|
||||
|
||||
current_mode: Optional[str] = None
|
||||
on_demand_modes: Tuple[str, ...] = ()
|
||||
on_demand_index: int = 0
|
||||
on_demand_expires_at: Optional[float] = None
|
||||
on_demand_pinned: bool = False
|
||||
rotation: Tuple[str, ...] = ()
|
||||
rotation_index: int = 0
|
||||
live_resume_index: Optional[int] = None
|
||||
live_takeover_unshown: bool = False
|
||||
|
||||
def next_on_demand(self) -> "ArbiterState":
|
||||
"""The session's next mode, wrapping round. Needs a mode list."""
|
||||
index = (self.on_demand_index + 1) % len(self.on_demand_modes)
|
||||
return replace(self, on_demand_index=index,
|
||||
current_mode=self.on_demand_modes[index])
|
||||
|
||||
def claim_live(self, mode: str) -> "ArbiterState":
|
||||
"""Live priority takes the panel for ``mode``.
|
||||
|
||||
The rotation's position is saved only on the first claim, not on
|
||||
each re-check while the hold continues, so it resumes where live
|
||||
priority interrupted it instead of after the live mode (which would
|
||||
skip every mode between the two).
|
||||
"""
|
||||
if self.current_mode == mode:
|
||||
return self
|
||||
resume = self.rotation_index if self.live_resume_index is None else self.live_resume_index
|
||||
index = self.rotation.index(mode) if mode in self.rotation else self.rotation_index
|
||||
return replace(self, current_mode=mode, rotation_index=index,
|
||||
live_resume_index=resume)
|
||||
|
||||
def after(self, outcome: "ScreenEnd") -> "ArbiterState":
|
||||
"""The state once a screen has run its course: the next mode.
|
||||
|
||||
An on-demand session moves to its next mode. Otherwise the rotation
|
||||
advances -- unless the mode just shown is a live-priority mode that
|
||||
is still live, which holds the panel. A session with no modes left
|
||||
is ended by the controller before it asks (that is not pure: it
|
||||
resumes the rotation and clears the cache).
|
||||
"""
|
||||
if outcome.on_demand_active:
|
||||
return self.next_on_demand() if self.on_demand_modes else self
|
||||
if outcome.still_live or not self.rotation:
|
||||
return self
|
||||
index = (self.rotation_index + 1) % len(self.rotation)
|
||||
return replace(self, rotation_index=index, current_mode=self.rotation[index])
|
||||
|
||||
def release_live(self) -> "ArbiterState":
|
||||
"""Nothing is live any more: the rotation resumes where it was."""
|
||||
if self.live_resume_index is None or not self.rotation:
|
||||
return self
|
||||
index = self.live_resume_index % len(self.rotation)
|
||||
return replace(self, current_mode=self.rotation[index], rotation_index=index,
|
||||
live_resume_index=None)
|
||||
|
||||
def showing(self, plan: "ScreenPlan") -> "ArbiterState":
|
||||
"""The state once ``plan`` is on the panel.
|
||||
|
||||
An on-demand plan puts the session's index on the mode it shows (an
|
||||
index past the end of a shortened list starts it again at 0).
|
||||
"""
|
||||
state = replace(self, current_mode=plan.mode)
|
||||
if plan.source is Source.ON_DEMAND and self.on_demand_modes:
|
||||
state = replace(state, on_demand_index=_on_demand_index(self))
|
||||
return state
|
||||
|
||||
|
||||
def _on_demand_index(state: ArbiterState) -> int:
|
||||
"""The session's index, or 0 once it is past the end of its list."""
|
||||
index = state.on_demand_index
|
||||
return index if index < len(state.on_demand_modes) else 0
|
||||
|
||||
|
||||
def on_demand_bound(min_duration: float, max_duration: float,
|
||||
deadline: Optional[float],
|
||||
now: float) -> Optional[Tuple[float, float]]:
|
||||
"""Shorten a screen's (min, max) seconds to what is left of a timed
|
||||
on-demand session ending at ``deadline``. None when nothing is left.
|
||||
|
||||
The OnDemand Source's bound, applied after the screen's first frame,
|
||||
where it always was (``now`` is read then).
|
||||
"""
|
||||
if deadline is None:
|
||||
return min_duration, max_duration
|
||||
remaining = max(0.0, deadline - now)
|
||||
min_duration = min(min_duration, remaining)
|
||||
max_duration = min(max_duration, remaining)
|
||||
if max_duration <= 0:
|
||||
return None
|
||||
return min_duration, max_duration
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ArbiterInputs:
|
||||
@@ -95,57 +251,122 @@ class ArbiterInputs:
|
||||
when it could win (the panel is on, and neither a follower nor
|
||||
on-demand outranks it), because reading it has side effects: a
|
||||
1 Hz throttle and deleting an expired file.
|
||||
live_modes: The modes with live content, from a live-priority scan,
|
||||
in registration order; None when no scan was made (on-demand,
|
||||
Vegas keeping live content in its ticker, a throttled
|
||||
mid-screen check). A scan asks every live-priority plugin, so it
|
||||
is made only where run() always made it.
|
||||
vegas_enabled: Vegas mode is on (and no on-demand session holds it
|
||||
off).
|
||||
vegas_live_in_ticker: Vegas keeps live content in its ticker
|
||||
instead of yielding the panel to it.
|
||||
vegas_yielded: This pass's Vegas iteration has run and yielded, so
|
||||
the Vegas Source passes and the screen it fell through to is
|
||||
decided.
|
||||
reload_pending: Mid-screen only: a plugin reload is waiting for the
|
||||
top of the loop, at a service point where that ends the screen.
|
||||
"""
|
||||
|
||||
schedule_on: bool
|
||||
on_demand_active: bool
|
||||
follower_active: bool
|
||||
wifi_notice: Optional[WifiNotice] = None
|
||||
live_modes: Optional[Tuple[str, ...]] = None
|
||||
vegas_enabled: bool = False
|
||||
vegas_live_in_ticker: bool = False
|
||||
vegas_yielded: bool = False
|
||||
reload_pending: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScreenPlan:
|
||||
"""The Arbiter's answer for one pass.
|
||||
|
||||
decide() is pure, so it cannot ask a plugin anything: the fields a
|
||||
plugin answers (its durations, whether it runs a dynamic cycle, how
|
||||
often it draws) are filled in by the controller after the screen's first
|
||||
frame, when they have always been read (DisplayController.complete_plan).
|
||||
|
||||
Attributes:
|
||||
source: The Source that gets the panel.
|
||||
mode: The display mode to draw (None for a blank, follower or notice).
|
||||
plugin: The id of the plugin drawing ``mode``, once resolved.
|
||||
min_duration: Seconds the screen runs at least (dynamic duration).
|
||||
max_duration: How long the plan holds the panel, in seconds, at most
|
||||
(its dwell ends early when what the panel should show changes).
|
||||
None when the Source paces itself: a follower frame, or LEGACY.
|
||||
None when the Source paces itself (a follower frame, Vegas), and
|
||||
for a rotation or live plan until its first frame.
|
||||
dynamic: Run until the plugin's cycle completes, between min and max.
|
||||
frame_policy: Which frame loop the screen runs.
|
||||
preemptible_by: The Sources that may end the screen mid-way.
|
||||
notice: The WiFi notice to draw, for a WIFI plan.
|
||||
deadline: For an on-demand plan, when the session ends (wall
|
||||
clock): after the first frame the screen's durations are cut to
|
||||
what is left (:func:`on_demand_bound`).
|
||||
ends_live: Nothing is live any more and live priority had
|
||||
interrupted the rotation: taking this plan resumes the rotation
|
||||
where it was (ArbiterState.release_live) before it shows.
|
||||
"""
|
||||
|
||||
source: Source
|
||||
mode: Optional[str] = None
|
||||
plugin: Optional[str] = None
|
||||
min_duration: Optional[float] = None
|
||||
max_duration: Optional[float] = None
|
||||
dynamic: bool = False
|
||||
frame_policy: Optional[FramePolicy] = None
|
||||
preemptible_by: FrozenSet[Source] = frozenset()
|
||||
notice: Optional[WifiNotice] = None
|
||||
deadline: Optional[float] = None
|
||||
ends_live: bool = False
|
||||
|
||||
|
||||
SCHEDULED_OFF_PLAN = ScreenPlan(Source.SCHEDULED_OFF, max_duration=SCHEDULED_OFF_DWELL)
|
||||
FOLLOWER_PLAN = ScreenPlan(Source.FOLLOWER)
|
||||
LEGACY_PLAN = ScreenPlan(Source.LEGACY)
|
||||
RELOAD_PLAN = ScreenPlan(Source.RELOAD)
|
||||
|
||||
#: What may end a screen mid-way: the schedule, an on-demand session
|
||||
#: starting or ending, a WiFi notice, a live game, the rotation moving
|
||||
#: under the screen, and a plugin reload. Not a follower or Vegas: those
|
||||
#: are only looked at between screens.
|
||||
SCREEN_PREEMPTERS: FrozenSet[Source] = frozenset(
|
||||
{Source.SCHEDULED_OFF, Source.ON_DEMAND, Source.WIFI, Source.LIVE, Source.ROTATION,
|
||||
Source.RELOAD})
|
||||
|
||||
#: A live screen is not preempted by Live: live games take turns between
|
||||
#: screens, never mid-screen.
|
||||
LIVE_PREEMPTERS: FrozenSet[Source] = SCREEN_PREEMPTERS - {Source.LIVE}
|
||||
|
||||
|
||||
class Arbiter:
|
||||
"""Decides which Source gets the panel. Stateless; see the module docstring."""
|
||||
|
||||
@staticmethod
|
||||
def decide(state: ArbiterState, inputs: ArbiterInputs, now: float) -> ScreenPlan:
|
||||
def decide(state: ArbiterState, inputs: ArbiterInputs, now: float,
|
||||
running: Optional[ScreenPlan] = None) -> ScreenPlan:
|
||||
"""The plan for this pass, from the Sources in priority order.
|
||||
|
||||
With ``running``, the question is the ScreenRunner's at one of its
|
||||
service points instead: does a Source in ``running.preemptible_by``
|
||||
now take the panel from that screen? The answer is ``running``
|
||||
itself (the same object) while it holds, else the plan that ends it.
|
||||
See :func:`_hold_or_preempt` for the rules.
|
||||
|
||||
Args:
|
||||
state: What the Arbiter remembers between passes (nothing yet).
|
||||
state: What the Arbiter remembers between passes.
|
||||
inputs: This pass's snapshot.
|
||||
now: Wall-clock time of the snapshot. No stage-2 Source reads it:
|
||||
the top-of-pass WiFi check takes the notice as read, and only
|
||||
the mid-screen check (:func:`wifi_notice_preempts`) compares
|
||||
it with the expiry. It is in the signature for the Sources
|
||||
stage 3 adds (on-demand expiry, durations).
|
||||
now: Wall-clock time of the snapshot. The OnDemand Source reads
|
||||
it for what is left of a timed session, and the mid-screen
|
||||
WiFi rule (:func:`wifi_notice_preempts`) to compare with the
|
||||
notice's expiry. The top-of-pass WiFi check does not: it
|
||||
takes the notice as read.
|
||||
running: The screen on the panel, for a mid-screen check.
|
||||
|
||||
Returns:
|
||||
The winning Source's plan, or LEGACY_PLAN when the winner is one
|
||||
run() still decides itself.
|
||||
The winning Source's plan (LEGACY for Vegas), or ``running``.
|
||||
"""
|
||||
del state, now # not read by the stage-2 Sources; see the docstring
|
||||
if running is not None:
|
||||
return _hold_or_preempt(state, inputs, now, running)
|
||||
|
||||
# ScheduledOff is a gate, not a Source: a scheduled-off panel stays
|
||||
# blank even for a follower, and only an on-demand session overrides
|
||||
@@ -157,17 +378,161 @@ class Arbiter:
|
||||
if inputs.follower_active:
|
||||
return FOLLOWER_PLAN
|
||||
|
||||
# 2. OnDemand: decided by run() until stage 3. It outranks the notice.
|
||||
# 2. OnDemand: the session's current mode. It outranks the notice.
|
||||
if inputs.on_demand_active:
|
||||
return LEGACY_PLAN
|
||||
return _on_demand_plan(state, now)
|
||||
|
||||
# 3. Wifi: a pending notice, held for one short dwell per pass.
|
||||
if inputs.wifi_notice is not None:
|
||||
return ScreenPlan(Source.WIFI, max_duration=WIFI_NOTICE_DWELL,
|
||||
notice=inputs.wifi_notice)
|
||||
|
||||
# 4-6. Live, Vegas, Rotation: still run()'s own code.
|
||||
return LEGACY_PLAN
|
||||
# 4. Live: the next live game, round-robin across several. With
|
||||
# nothing live, a rotation that live priority interrupted resumes.
|
||||
ends_live = False
|
||||
if _live_applies(inputs):
|
||||
pick = live_pick(inputs.live_modes, state.current_mode,
|
||||
advance=not state.live_takeover_unshown)
|
||||
if pick is not None:
|
||||
return ScreenPlan(Source.LIVE, mode=pick, preemptible_by=LIVE_PREEMPTERS)
|
||||
ends_live = state.live_resume_index is not None and bool(state.rotation)
|
||||
|
||||
# 5. Vegas: one iteration of the ticker, run by run()'s own code
|
||||
# until stage 4. Passes once this pass's iteration has yielded.
|
||||
if inputs.vegas_enabled and not inputs.vegas_yielded:
|
||||
return ScreenPlan(Source.LEGACY, ends_live=ends_live)
|
||||
|
||||
# 6. Rotation: the rotation's current mode (after the resume, when
|
||||
# live priority just ended).
|
||||
return rotation_plan(state.release_live() if ends_live else state,
|
||||
ends_live=ends_live)
|
||||
|
||||
|
||||
def _hold_or_preempt(state: ArbiterState, inputs: ArbiterInputs, now: float,
|
||||
running: ScreenPlan) -> ScreenPlan:
|
||||
"""The mid-screen rules: ``running``, or the plan that ends it.
|
||||
|
||||
What the frame loops used to check one by one (_check_live_takeover,
|
||||
then _screen_preempted with _wifi_notice_pending in it, before stage 3),
|
||||
in their order:
|
||||
|
||||
1. Live: a game went live while a non-live screen runs (the inputs
|
||||
carry a scan only when one was due, at most once a second). Checked
|
||||
first because it is the one preemption that changes the state -- the
|
||||
rotation moves to the live mode and remembers where it was -- and it
|
||||
still happens when a WiFi notice is also pending: the next pass then
|
||||
shows the notice, and the game after it.
|
||||
2. The panel's mode moved under the screen: an on-demand session
|
||||
started, ended or changed mode, or the rotation was rebuilt (a
|
||||
plugin enabled, disabled or reloaded).
|
||||
3. The schedule turned the panel off.
|
||||
4. A WiFi notice arrived (unless on-demand outranks it), compared with
|
||||
its expiry because the read throttle can hand back a stale one.
|
||||
5. A plugin reload is waiting at the top of the loop.
|
||||
|
||||
A follower and Vegas are never mid-screen preemptions; they are looked
|
||||
at between screens.
|
||||
"""
|
||||
by = running.preemptible_by
|
||||
if Source.LIVE in by:
|
||||
takeover = live_takeover(state, inputs)
|
||||
if takeover is not None:
|
||||
return ScreenPlan(Source.LIVE, mode=takeover, preemptible_by=LIVE_PREEMPTERS)
|
||||
if state.current_mode != running.mode:
|
||||
source = Source.ON_DEMAND if inputs.on_demand_active else Source.ROTATION
|
||||
if source in by:
|
||||
return ScreenPlan(source, mode=state.current_mode, preemptible_by=SCREEN_PREEMPTERS)
|
||||
if (Source.SCHEDULED_OFF in by and not inputs.schedule_on
|
||||
and not inputs.on_demand_active):
|
||||
return SCHEDULED_OFF_PLAN
|
||||
notice = inputs.wifi_notice
|
||||
if (Source.WIFI in by and notice is not None
|
||||
and wifi_notice_preempts(notice, inputs.on_demand_active, now)):
|
||||
return ScreenPlan(Source.WIFI, max_duration=WIFI_NOTICE_DWELL, notice=notice)
|
||||
if Source.RELOAD in by and inputs.reload_pending:
|
||||
return RELOAD_PLAN
|
||||
return running
|
||||
|
||||
|
||||
def live_takeover(state: ArbiterState, inputs: ArbiterInputs) -> Optional[str]:
|
||||
"""The live mode that takes the panel mid-screen, or None.
|
||||
|
||||
The first live mode, when a scan found one and the panel is not on a
|
||||
live mode already. Never while on-demand holds the panel, while it is
|
||||
scheduled off, or while Vegas keeps live content in its ticker.
|
||||
"""
|
||||
if not _live_applies(inputs) or inputs.on_demand_active or not inputs.schedule_on:
|
||||
return None
|
||||
live = inputs.live_modes
|
||||
if not live or state.current_mode in live:
|
||||
return None
|
||||
return live[0]
|
||||
|
||||
|
||||
def _on_demand_plan(state: ArbiterState, now: float) -> ScreenPlan:
|
||||
"""The OnDemand Source: the session's current mode.
|
||||
|
||||
``max_duration`` is what is left of a timed session at ``now`` (None
|
||||
without a duration); ``deadline`` carries the expiry so the bound can be
|
||||
applied again after the first frame. A session with no modes left (its
|
||||
plugin was unloaded under it) gets a plan with no mode: the controller
|
||||
ends the session and shows the rotation's mode instead.
|
||||
"""
|
||||
modes = state.on_demand_modes
|
||||
if not modes:
|
||||
return ScreenPlan(Source.ON_DEMAND)
|
||||
expires_at = state.on_demand_expires_at
|
||||
remaining = None if expires_at is None else max(0.0, expires_at - now)
|
||||
return ScreenPlan(Source.ON_DEMAND, mode=modes[_on_demand_index(state)],
|
||||
max_duration=remaining, deadline=expires_at,
|
||||
preemptible_by=SCREEN_PREEMPTERS)
|
||||
|
||||
|
||||
def rotation_plan(state: ArbiterState, ends_live: bool = False) -> ScreenPlan:
|
||||
"""The Rotation Source: the mode the rotation is on.
|
||||
|
||||
That is ``state.current_mode``, which is ``rotation[rotation_index]``
|
||||
except where something moved the panel off the list and the rotation
|
||||
carries on from there: a live mode no rotation entry names, or None
|
||||
when a session ended with no enabled mode to resume to.
|
||||
"""
|
||||
return ScreenPlan(Source.ROTATION, mode=state.current_mode, ends_live=ends_live,
|
||||
preemptible_by=SCREEN_PREEMPTERS)
|
||||
|
||||
|
||||
def _live_applies(inputs: ArbiterInputs) -> bool:
|
||||
"""Whether the Live Source has a say: a scan was made, and Vegas is not
|
||||
keeping live content in its ticker (where the live plugin takes extra
|
||||
turns in the marquee instead of the panel)."""
|
||||
if inputs.live_modes is None:
|
||||
return False
|
||||
return not (inputs.vegas_enabled and inputs.vegas_live_in_ticker)
|
||||
|
||||
|
||||
def live_pick(live_modes: Optional[Tuple[str, ...]], current_mode: Optional[str],
|
||||
advance: bool) -> Optional[str]:
|
||||
"""The live mode to show, or None when nothing is live.
|
||||
|
||||
When several plugins are live at once this round-robins between them, so
|
||||
the panel alternates each dwell instead of pinning to the first one
|
||||
registered. The mode on the panel is the cursor, so this stays right as
|
||||
games start and end.
|
||||
|
||||
Args:
|
||||
live_modes: The live modes, in registration order.
|
||||
current_mode: The mode on the panel.
|
||||
advance: True for the rotation's pick (the live mode after the one
|
||||
showing). False for a peek (the one showing if it is still live,
|
||||
else the first), which Vegas uses to ask whether anything is.
|
||||
"""
|
||||
if not live_modes:
|
||||
return None
|
||||
if current_mode in live_modes:
|
||||
if advance:
|
||||
index = live_modes.index(current_mode)
|
||||
return live_modes[(index + 1) % len(live_modes)]
|
||||
return current_mode
|
||||
return live_modes[0]
|
||||
|
||||
|
||||
def wifi_notice_preempts(notice: Optional[WifiNotice], on_demand_active: bool,
|
||||
|
||||
+693
-623
File diff suppressed because it is too large
Load Diff
@@ -393,11 +393,6 @@ class DisplayManager:
|
||||
|
||||
self._setup_matrix()
|
||||
logger.info("Matrix setup completed in %.3f seconds", time.time() - start_time)
|
||||
# Only a real panel's swaps wait on its refresh: the emulator and the
|
||||
# fallback canvas pace themselves, so "slower than the cap" would be
|
||||
# noise there.
|
||||
if self.matrix is not None and os.environ.get('EMULATOR', 'false') != 'true':
|
||||
self.frame_timing.plan_refresh(self.refresh_hz)
|
||||
self._setup_scan_order_compensation()
|
||||
|
||||
font_time = time.time()
|
||||
@@ -1506,9 +1501,7 @@ class DisplayManager:
|
||||
fractional-pixel motion. See src/common/scroll_config.py.
|
||||
|
||||
Note this is the configured *cap*, not necessarily what the panel
|
||||
achieves -- scripts/scroll_speeds.py --measure reports the real rate,
|
||||
and the frame-timing recorder logs a warning, with a cap the panel can
|
||||
hold, once it has measured a panel that falls short of this.
|
||||
achieves -- scripts/scroll_speeds.py --measure reports the real rate.
|
||||
"""
|
||||
hardware = (self.config.get('display') or {}).get('hardware') or {}
|
||||
try:
|
||||
|
||||
+101
-128
@@ -19,7 +19,7 @@ import uuid
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional, Any, Callable, Tuple
|
||||
from typing import Dict, List, Optional, Any, Callable
|
||||
import logging
|
||||
|
||||
from src.exceptions import LEDMatrixError
|
||||
@@ -487,30 +487,28 @@ def record_error(
|
||||
# service publishes to the shared cache directory -- the same channel, and the
|
||||
# same file permissions, as display_current_state and plugin_metrics_snapshot: files
|
||||
# are 0660 and carry the cache directory's group, so root writes and the web
|
||||
# user reads, and the other way round for the clear request.
|
||||
# user reads.
|
||||
#
|
||||
# ERROR_SNAPSHOT_KEY written by the display service only
|
||||
# ERROR_CLEAR_REQUEST_KEY written by the web interface only
|
||||
#
|
||||
# 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.
|
||||
# The web interface never writes the snapshot itself: two writers would race,
|
||||
# and a snapshot owned by the web user is one more file root's write has to
|
||||
# replace.
|
||||
# A clear goes over the control socket (``errors.clear``): the display applies
|
||||
# it (clear_before) and republishes the snapshot before it answers. When the
|
||||
# socket cannot carry it, the clear fails and the route says so: the
|
||||
# ``plugin_error_clear_request`` file mailbox it used to fall back to is gone.
|
||||
# A stopped display's errors go anyway: its next run publishes an empty
|
||||
# snapshot over the old one. The web interface never writes the snapshot
|
||||
# itself: two writers would race, and a snapshot owned by the web user is one
|
||||
# more file root's write has to replace.
|
||||
|
||||
ERROR_SNAPSHOT_KEY = "plugin_error_snapshot"
|
||||
ERROR_CLEAR_REQUEST_KEY = "plugin_error_clear_request"
|
||||
|
||||
#: Shortest gap between two snapshot writes, in seconds. A plugin failing in
|
||||
#: a tight loop changes the aggregator many times a second; the snapshot is
|
||||
#: rewritten at most this often, and only when something changed.
|
||||
SNAPSHOT_MIN_INTERVAL = 10.0
|
||||
|
||||
#: How often the display service checks for changes and clear requests. A
|
||||
#: check is an in-memory comparison plus reading one small file.
|
||||
#: How often the display service checks for changes. A check is an
|
||||
#: in-memory comparison.
|
||||
SNAPSHOT_TICK_INTERVAL = 5.0
|
||||
|
||||
_SNAPSHOT_RECENT_ERRORS = 20
|
||||
@@ -579,8 +577,8 @@ class ErrorSnapshotPublisher:
|
||||
Runs in the display service only. tick() is the whole job; start() just
|
||||
calls it from a daemon thread every SNAPSHOT_TICK_INTERVAL seconds, which
|
||||
also means errors recorded while a write was being throttled still reach
|
||||
the cache once the interval has passed, and a clear request is applied
|
||||
even when no new error arrives to trigger a publish.
|
||||
the cache once the interval has passed. A clear (``errors.clear`` over
|
||||
the control socket) is applied by :meth:`clear_now`.
|
||||
|
||||
Nothing here raises: a failure to read or write the cache is logged at
|
||||
debug and retried on a later tick.
|
||||
@@ -602,46 +600,48 @@ class ErrorSnapshotPublisher:
|
||||
self._stop = threading.Event()
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
|
||||
def _apply_clear_request(self) -> bool:
|
||||
"""Honour a clear request we have not applied yet. True if one was."""
|
||||
request = self.cache_manager.get(ERROR_CLEAR_REQUEST_KEY, max_age=None, memory_ttl=0)
|
||||
if not isinstance(request, dict):
|
||||
return False
|
||||
request_id = request.get("request_id")
|
||||
if not isinstance(request_id, str) or not request_id or request_id == self._applied_clear_id:
|
||||
return False
|
||||
try:
|
||||
cutoff = float(request.get("cutoff"))
|
||||
except (TypeError, ValueError):
|
||||
cutoff = float("nan")
|
||||
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)
|
||||
# A malformed request is acknowledged too, so it is not retried forever.
|
||||
self._applied_clear_id = request_id
|
||||
return True
|
||||
return cleared
|
||||
|
||||
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
|
||||
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."""
|
||||
"""Publish if due. True if a snapshot was written."""
|
||||
with self._tick_lock:
|
||||
try:
|
||||
cleared = self._apply_clear_request()
|
||||
version = self.aggregator.version
|
||||
now = self._clock()
|
||||
if not cleared:
|
||||
if version == self._published_version:
|
||||
return False
|
||||
if (self._last_attempt is not None
|
||||
and now - self._last_attempt < self.min_interval):
|
||||
return False
|
||||
# Stamp the attempt before writing: a cache that keeps failing
|
||||
# is retried at the throttled rate, not on every tick.
|
||||
self._last_attempt = now
|
||||
snapshot = self.aggregator.build_snapshot()
|
||||
snapshot["applied_clear_id"] = self._applied_clear_id
|
||||
self.cache_manager.set(ERROR_SNAPSHOT_KEY, snapshot)
|
||||
self._published_version = version
|
||||
if version == self._published_version:
|
||||
return False
|
||||
if (self._last_attempt is not None
|
||||
and now - self._last_attempt < self.min_interval):
|
||||
return False
|
||||
self._publish(version, now)
|
||||
return True
|
||||
except Exception as err: # never let reporting break the display
|
||||
_snapshot_logger.debug("Could not publish the plugin error snapshot: %s",
|
||||
@@ -693,18 +693,31 @@ 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]]]:
|
||||
"""The display service's latest snapshot and the latest clear request.
|
||||
def read_error_report(cache_manager: Any) -> Optional[Dict[str, Any]]:
|
||||
"""The display service's latest snapshot, or None before it has published.
|
||||
|
||||
memory_ttl=0: both keys are written by the other process, so only the
|
||||
file is current.
|
||||
memory_ttl=0: the display writes the key, so only the file is current.
|
||||
"""
|
||||
snapshot = cache_manager.get(ERROR_SNAPSHOT_KEY, max_age=None, memory_ttl=0)
|
||||
clear_request = cache_manager.get(ERROR_CLEAR_REQUEST_KEY, max_age=None, memory_ttl=0)
|
||||
return (snapshot if isinstance(snapshot, dict) else None,
|
||||
clear_request if isinstance(clear_request, dict) else None)
|
||||
return snapshot if isinstance(snapshot, dict) else None
|
||||
|
||||
|
||||
def _epoch(iso: Any) -> Optional[float]:
|
||||
@@ -717,28 +730,6 @@ def _epoch(iso: Any) -> Optional[float]:
|
||||
return None
|
||||
|
||||
|
||||
def _pending_cutoff(snapshot: Optional[Dict[str, Any]],
|
||||
clear_request: Optional[Dict[str, Any]]) -> Optional[float]:
|
||||
"""The cutoff of a clear the snapshot has not applied yet, if any."""
|
||||
if not clear_request:
|
||||
return None
|
||||
request_id = clear_request.get("request_id")
|
||||
if not request_id:
|
||||
return None
|
||||
if snapshot is not None and snapshot.get("applied_clear_id") == request_id:
|
||||
return None
|
||||
try:
|
||||
cutoff = float(clear_request.get("cutoff"))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return cutoff if math.isfinite(cutoff) else None
|
||||
|
||||
|
||||
def _is_after(item: Any, field_name: str, cutoff: float) -> bool:
|
||||
when = _epoch(item.get(field_name)) if isinstance(item, dict) else None
|
||||
return when is not None and when > cutoff
|
||||
|
||||
|
||||
def _empty_summary(snapshot: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
return {
|
||||
"session_start": snapshot.get("session_start") if snapshot else None,
|
||||
@@ -751,11 +742,11 @@ def _empty_summary(snapshot: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def error_summary_from_report(snapshot: Optional[Dict[str, Any]],
|
||||
clear_request: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
def error_summary_from_report(snapshot: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"""The /errors/summary payload: get_error_summary()'s shape plus
|
||||
``generated_at``, ``snapshot_available`` and ``clear_pending``."""
|
||||
cutoff = _pending_cutoff(snapshot, clear_request)
|
||||
``generated_at``, ``snapshot_available`` and ``clear_pending`` (always
|
||||
False now: a clear is applied before its route answers; kept for API
|
||||
compatibility)."""
|
||||
summary = _empty_summary(snapshot)
|
||||
if snapshot is not None:
|
||||
for name, default in summary.items():
|
||||
@@ -764,32 +755,17 @@ def error_summary_from_report(snapshot: Optional[Dict[str, Any]],
|
||||
isinstance(default, float) and isinstance(value, int)) or (
|
||||
name == "session_start" and isinstance(value, str)):
|
||||
summary[name] = value
|
||||
if cutoff is not None:
|
||||
recent = summary["recent_errors"]
|
||||
newest = _epoch(recent[-1].get("timestamp")) if recent and isinstance(recent[-1], dict) else None
|
||||
if newest is None or newest <= cutoff:
|
||||
# Everything the display has reported predates the clear.
|
||||
summary = _empty_summary(snapshot)
|
||||
else:
|
||||
# Only part of it does. The lists can be filtered exactly; the
|
||||
# counts cannot, and stay as reported until the display
|
||||
# applies the clear (clear_pending says so).
|
||||
summary["recent_errors"] = [r for r in recent if _is_after(r, "timestamp", cutoff)]
|
||||
summary["active_patterns"] = {
|
||||
k: p for k, p in summary["active_patterns"].items()
|
||||
if _is_after(p, "last_seen", cutoff)
|
||||
}
|
||||
summary["generated_at"] = snapshot.get("generated_at") if snapshot else None
|
||||
summary["snapshot_available"] = snapshot is not None
|
||||
summary["clear_pending"] = cutoff is not None
|
||||
summary["clear_pending"] = False
|
||||
return summary
|
||||
|
||||
|
||||
def plugin_health_from_report(snapshot: Optional[Dict[str, Any]],
|
||||
clear_request: Optional[Dict[str, Any]],
|
||||
plugin_id: str) -> Dict[str, Any]:
|
||||
"""The /errors/plugin/<id> payload: get_plugin_health()'s shape plus
|
||||
``generated_at``, ``snapshot_available`` and ``clear_pending``."""
|
||||
``generated_at``, ``snapshot_available`` and ``clear_pending`` (always
|
||||
False, as in error_summary_from_report)."""
|
||||
health: Dict[str, Any] = {
|
||||
"plugin_id": plugin_id,
|
||||
"status": "healthy",
|
||||
@@ -798,19 +774,15 @@ def plugin_health_from_report(snapshot: Optional[Dict[str, Any]],
|
||||
"recent_error_count": 0,
|
||||
"last_error": None,
|
||||
}
|
||||
cutoff = _pending_cutoff(snapshot, clear_request)
|
||||
table = snapshot.get("plugin_health") if snapshot else None
|
||||
entry = table.get(plugin_id) if isinstance(table, dict) else None
|
||||
if isinstance(entry, dict):
|
||||
# last_error is the plugin's newest error: if even that predates a
|
||||
# pending clear, so does everything else the display reported for it.
|
||||
if cutoff is None or _is_after(entry.get("last_error"), "timestamp", cutoff):
|
||||
for name in ("status", "total_errors", "error_types", "recent_error_count", "last_error"):
|
||||
if name in entry:
|
||||
health[name] = entry[name]
|
||||
for name in ("status", "total_errors", "error_types", "recent_error_count", "last_error"):
|
||||
if name in entry:
|
||||
health[name] = entry[name]
|
||||
health["generated_at"] = snapshot.get("generated_at") if snapshot else None
|
||||
health["snapshot_available"] = snapshot is not None
|
||||
health["clear_pending"] = cutoff is not None
|
||||
health["clear_pending"] = False
|
||||
return health
|
||||
|
||||
|
||||
@@ -831,35 +803,36 @@ def _count_cleared(summary: Dict[str, Any], cutoff: float) -> Optional[int]:
|
||||
return None
|
||||
|
||||
|
||||
def request_error_clear(cache_manager: Any, cutoff: float) -> Dict[str, Any]:
|
||||
#: ``send(request_id, cutoff)`` hands a clear to the display over the control
|
||||
#: socket and returns its ErrorsClearResult. It raises when the socket could
|
||||
#: not carry it or the display failed it (``src.ipc.client.ControlError``).
|
||||
ClearSender = Callable[[str, float], Dict[str, Any]]
|
||||
|
||||
|
||||
def request_error_clear(cache_manager: Any, cutoff: float,
|
||||
send: ClearSender) -> Dict[str, Any]:
|
||||
"""Ask the display service to forget errors recorded at or before ``cutoff``.
|
||||
|
||||
Returns ``request_id``, ``cutoff`` (ISO, local time), ``cleared_count``
|
||||
(see _count_cleared) and ``clear_requested``. Raises OSError when the
|
||||
request did not reach the shared cache, since a cache without a usable
|
||||
directory accepts set() and keeps nothing.
|
||||
Over the control socket: the display applies the clear and republishes
|
||||
its snapshot before it answers, so nothing is written here. Whatever
|
||||
``send`` raises reaches the caller.
|
||||
|
||||
A request the display has not applied yet is only ever widened: a later,
|
||||
narrower one ("older than 24 hours" after "everything") overwriting it
|
||||
would otherwise bring back the errors the first one hid.
|
||||
Returns ``request_id``, ``cutoff`` (ISO, local time), ``cleared_count``
|
||||
(the display's own count, else an estimate from the snapshot; see
|
||||
_count_cleared), and ``clear_requested``, ``applied`` and ``transport``,
|
||||
which are always True, True and ``"socket"`` now and are kept for API
|
||||
compatibility.
|
||||
"""
|
||||
snapshot, clear_request = read_error_report(cache_manager)
|
||||
pending = _pending_cutoff(snapshot, clear_request)
|
||||
if pending is not None:
|
||||
cutoff = max(cutoff, pending)
|
||||
before = error_summary_from_report(snapshot, clear_request)
|
||||
request = {
|
||||
"request_id": uuid.uuid4().hex,
|
||||
"cutoff": cutoff,
|
||||
"requested_at": time.time(),
|
||||
}
|
||||
cache_manager.set(ERROR_CLEAR_REQUEST_KEY, request)
|
||||
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")
|
||||
before = error_summary_from_report(read_error_report(cache_manager))
|
||||
request_id = uuid.uuid4().hex
|
||||
result = send(request_id, cutoff)
|
||||
count = result.get("cleared") if isinstance(result, dict) else None
|
||||
return {
|
||||
"cleared_count": _count_cleared(before, cutoff),
|
||||
"clear_requested": True,
|
||||
"request_id": request["request_id"],
|
||||
"request_id": request_id,
|
||||
"cutoff": datetime.fromtimestamp(cutoff).isoformat(),
|
||||
"applied": True,
|
||||
"transport": "socket",
|
||||
"cleared_count": (count if isinstance(count, int) and not isinstance(count, bool)
|
||||
else _count_cleared(before, cutoff)),
|
||||
}
|
||||
|
||||
@@ -222,6 +222,39 @@ class FontManager:
|
||||
logger.error(f"Error registering fonts for plugin {plugin_id}: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
def forget_plugin_fonts(self, plugin_id: str) -> bool:
|
||||
"""Drop the fonts ``plugin_id``'s manifest registered: its manifest
|
||||
and catalog, its ``plugin_id::family`` entries in font_catalog, and
|
||||
cached font objects for those families. Called by core when a plugin
|
||||
is unloaded, so a reload registers from its current manifest and a
|
||||
removed plugin's fonts stop resolving.
|
||||
|
||||
FontManager takes no locks; like forget_manager_fonts this relies on
|
||||
single dict operations being atomic and iterates snapshots, so a
|
||||
render thread calling get_font() meanwhile cannot break it. Returns
|
||||
True if the plugin had registered fonts.
|
||||
"""
|
||||
prefix = f"{plugin_id}::"
|
||||
manifest = self.plugin_fonts.pop(plugin_id, None)
|
||||
catalog = self.plugin_font_catalogs.pop(plugin_id, None)
|
||||
# Every namespaced entry, not just the families in the catalog: one
|
||||
# whose file failed to load never made it into the catalog, and a
|
||||
# caller may have added one directly.
|
||||
for family in list(self.font_catalog):
|
||||
if family.startswith(prefix):
|
||||
self.font_catalog.pop(family, None)
|
||||
# get_font() keys the cache f"{family}_{size_px}".
|
||||
dropped = [key for key in list(self.font_cache) if key.startswith(prefix)]
|
||||
for key in dropped:
|
||||
self.font_cache.pop(key, None)
|
||||
if dropped:
|
||||
# Font objects someone may hold were dropped; see cache_generation.
|
||||
self.cache_generation += 1
|
||||
if manifest is None and catalog is None:
|
||||
return False
|
||||
logger.info("Forgot fonts of plugin %s", plugin_id)
|
||||
return True
|
||||
|
||||
def _validate_font_manifest(self, font_manifest: Dict[str, Any]) -> bool:
|
||||
"""Validate the structure of a plugin's font manifest."""
|
||||
required_fields = ["fonts"]
|
||||
|
||||
+70
-14
@@ -3,8 +3,13 @@
|
||||
Every failure -- no socket (the display is stopped, or predates the socket),
|
||||
a refused or timed-out connection, a reply that breaks the contract, or an
|
||||
error the display returned -- raises :class:`ControlError` with a short
|
||||
``reason``, and the caller falls back to the file mailbox. Nothing here
|
||||
blocks for longer than ``timeout`` in total.
|
||||
``reason``. Nothing here blocks for longer than ``timeout`` in total.
|
||||
|
||||
There is no other way to reach the display: the file mailboxes the web
|
||||
interface used to fall back to are gone. :func:`display_not_listening` tells
|
||||
a caller when no display is listening yet (it is stopped, or still
|
||||
starting), the one case where sending the same request again later, once a
|
||||
display is up, can work.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -22,6 +27,7 @@ from src.ipc.contract import (
|
||||
SUBSCRIBE_KEEPALIVE_SECONDS,
|
||||
SUPPORTED_VERSIONS,
|
||||
Command,
|
||||
ErrorCode,
|
||||
FrameReader,
|
||||
ProtocolError,
|
||||
Request,
|
||||
@@ -37,8 +43,7 @@ from src.ipc.contract import (
|
||||
|
||||
#: Total budget for one request: connect, send and the reply. The display
|
||||
#: answers from a thread that does no rendering, normally within a few
|
||||
#: milliseconds; this only bounds a wedged one. The web route then falls back
|
||||
#: to the mailbox, so a timeout costs this much latency and nothing else.
|
||||
#: milliseconds; this only bounds a wedged one.
|
||||
DEFAULT_TIMEOUT_SECONDS = 1.0
|
||||
|
||||
|
||||
@@ -49,17 +54,46 @@ class ControlError(Exception):
|
||||
``refused``, ``timeout``, ``closed``, ``bad_response``, ``invalid_request``.
|
||||
When the display answered with an error, ``reason`` is that error's
|
||||
:class:`~src.ipc.contract.ErrorCode` (``busy``, ``unknown_command``, ...).
|
||||
|
||||
``sent`` is True once the whole request was written to a connected
|
||||
display, which may then have acted on it. A refusal the display sends
|
||||
before it reads anything (``forbidden``, too many connections) carries
|
||||
no request id and leaves ``sent`` False.
|
||||
"""
|
||||
|
||||
def __init__(self, reason: str, message: str = ''):
|
||||
def __init__(self, reason: str, message: str = '', *, sent: bool = False):
|
||||
super().__init__(reason, message)
|
||||
self.reason = reason
|
||||
self.message = message
|
||||
self.sent = sent
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f'{self.reason}: {self.message}' if self.message else self.reason
|
||||
|
||||
|
||||
#: Answers from a display that read the request but does not speak it: one
|
||||
#: older than the command (an upgrade in progress) or the protocol version.
|
||||
UPGRADE_REASONS = frozenset({ErrorCode.UNKNOWN_COMMAND, ErrorCode.UNSUPPORTED_VERSION})
|
||||
|
||||
#: Transport reasons that mean nothing is listening at the socket: no socket
|
||||
#: file (the display is stopped, or has not reached its run loop), or a file
|
||||
#: nobody accepts on (a stale socket) or that this user may not open.
|
||||
NOT_LISTENING_REASONS = frozenset({'no_socket', 'refused'})
|
||||
|
||||
|
||||
def display_not_listening(error: BaseException) -> bool:
|
||||
"""True when ``error`` says no display took the request because none is
|
||||
listening: it never reached one (``sent`` is False) and the reason is in
|
||||
:data:`NOT_LISTENING_REASONS`. A display that is started, or finishes
|
||||
starting, may take the same request later. False for everything else:
|
||||
a display that had the request and failed it, one too old to know the
|
||||
command, a client that cannot use the socket at all (``disabled``,
|
||||
``unsupported``), and an exception that is not a :class:`ControlError`.
|
||||
"""
|
||||
return (isinstance(error, ControlError) and not error.sent
|
||||
and error.reason in NOT_LISTENING_REASONS)
|
||||
|
||||
|
||||
def request(cmd: str, args: Optional[Mapping[str, Any]] = None, *,
|
||||
request_id: Optional[str] = None,
|
||||
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
||||
@@ -93,11 +127,14 @@ def request(cmd: str, args: Optional[Mapping[str, Any]] = None, *,
|
||||
# A refusal before the request was read (forbidden, too many
|
||||
# connections) carries no id.
|
||||
if response.id != request_id and not (response.id is None and not response.ok):
|
||||
raise ControlError('bad_response', 'the reply is for a different request')
|
||||
raise ControlError('bad_response', 'the reply is for a different request', sent=True)
|
||||
if not response.ok:
|
||||
error = response.error
|
||||
# No id: refused at the door (forbidden, too many connections),
|
||||
# before the display read the request.
|
||||
raise ControlError(error.code if error else 'bad_response',
|
||||
error.message if error else '')
|
||||
error.message if error else '',
|
||||
sent=response.id is not None)
|
||||
return dict(response.result or {})
|
||||
|
||||
|
||||
@@ -142,26 +179,31 @@ def _connect(paths: Sequence[str], deadline: float) -> socket.socket:
|
||||
|
||||
|
||||
def _exchange(sock: socket.socket, payload: bytes, deadline: float) -> Response:
|
||||
"""Send ``payload`` and read the reply. A failure once the whole request
|
||||
is written raises with ``sent=True``: the display may have it."""
|
||||
sent = False
|
||||
try:
|
||||
sock.settimeout(_remaining(deadline))
|
||||
sock.sendall(payload)
|
||||
sent = True
|
||||
reader = FrameReader(MAX_MESSAGE_BYTES)
|
||||
while True:
|
||||
sock.settimeout(_remaining(deadline))
|
||||
data = sock.recv(4096)
|
||||
if not data:
|
||||
raise ControlError('closed', 'the display closed the connection')
|
||||
raise ControlError('closed', 'the display closed the connection', sent=sent)
|
||||
lines = reader.feed(data)
|
||||
if lines:
|
||||
return Response.from_dict(decode_message(lines[0]))
|
||||
except socket.timeout:
|
||||
raise ControlError('timeout', 'no reply in time') from None
|
||||
raise ControlError('timeout', 'no reply in time', sent=sent) from None
|
||||
except ProtocolError as e:
|
||||
raise ControlError('bad_response', e.message) from None
|
||||
except ControlError:
|
||||
raise ControlError('bad_response', e.message, sent=sent) from None
|
||||
except ControlError as e:
|
||||
e.sent = e.sent or sent
|
||||
raise
|
||||
except OSError as e:
|
||||
raise ControlError('closed', str(e)) from None
|
||||
raise ControlError('closed', str(e), sent=sent) from None
|
||||
|
||||
|
||||
# -- commands ---------------------------------------------------------------------------
|
||||
@@ -172,8 +214,8 @@ def on_demand_start(request_id: str, plugin_id: Optional[str], mode: Optional[st
|
||||
paths: Optional[Sequence[str]] = None) -> Dict[str, Any]:
|
||||
"""Ask the display to show a plugin now. Returns the ack; raises :class:`ControlError`.
|
||||
|
||||
``request_id`` doubles as the on-demand request id, so a request that a
|
||||
timed-out caller then also writes to the mailbox is processed only once.
|
||||
``request_id`` doubles as the on-demand request id, so a request sent
|
||||
twice with the same id is processed only once.
|
||||
"""
|
||||
args = {'plugin_id': plugin_id, 'mode': mode, 'duration': duration, 'pinned': pinned}
|
||||
return request(Command.ON_DEMAND_START, args, request_id=request_id,
|
||||
@@ -227,6 +269,20 @@ 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.
|
||||
"""
|
||||
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)
|
||||
|
||||
+54
-15
@@ -85,8 +85,8 @@ DEFAULT_SOCKET_PATH = DEFAULT_SOCKET_DIR + '/' + SOCKET_NAME
|
||||
|
||||
#: Overrides the socket path for both processes (a dev checkout, a second
|
||||
#: instance, tests). One of :data:`DISABLED_VALUES` turns the socket off: the
|
||||
#: display does not serve it and the web interface goes straight to the
|
||||
#: file mailbox.
|
||||
#: display does not serve it, and the web interface cannot send it commands
|
||||
#: (it still reads the state the display writes to the cache).
|
||||
SOCKET_PATH_ENV = 'LEDMATRIX_CONTROL_SOCKET'
|
||||
DISABLED_VALUES = frozenset({'off', '0', 'false', 'no', 'none', 'disabled'})
|
||||
|
||||
@@ -149,12 +149,13 @@ class Command:
|
||||
PLUGIN_RELOAD = 'plugin.reload'
|
||||
STATE_GET = 'state.get'
|
||||
STATE_SUBSCRIBE = 'state.subscribe'
|
||||
ERRORS_CLEAR = 'errors.clear'
|
||||
|
||||
|
||||
#: Every command version 1 defines, in the order ``hello`` reports them.
|
||||
#: ``brightness.set`` and ``plugin.reload`` came in stage 2, and ``state.get``
|
||||
#: and ``state.subscribe`` in stage 3, all within version 1 (see the module
|
||||
#: docstring on adding commands).
|
||||
#: ``brightness.set`` and ``plugin.reload`` came in stage 2, ``state.get``
|
||||
#: and ``state.subscribe`` in stage 3, and ``errors.clear`` in stage 4, all
|
||||
#: within version 1 (see the module docstring on adding commands).
|
||||
COMMANDS: Tuple[str, ...] = (
|
||||
Command.HELLO,
|
||||
Command.PING,
|
||||
@@ -165,8 +166,15 @@ COMMANDS: Tuple[str, ...] = (
|
||||
Command.PLUGIN_RELOAD,
|
||||
Command.STATE_GET,
|
||||
Command.STATE_SUBSCRIBE,
|
||||
Command.ERRORS_CLEAR,
|
||||
)
|
||||
|
||||
#: Commands the connection thread answers itself, through a handler the
|
||||
#: display registers (``ControlServer(handlers=...)``), because they touch
|
||||
#: nothing the render thread owns. A display that registered none answers
|
||||
#: ``unknown_command``, and the client falls back as from an older display.
|
||||
DIRECT_COMMANDS = frozenset({Command.ERRORS_CLEAR})
|
||||
|
||||
#: Commands that are queued for the render thread.
|
||||
QUEUED_COMMANDS = frozenset({Command.ON_DEMAND_START, Command.ON_DEMAND_STOP,
|
||||
Command.BRIGHTNESS_SET, Command.PLUGIN_RELOAD})
|
||||
@@ -367,8 +375,8 @@ def _optional_name(args: Mapping[str, Any], key: str) -> Optional[str]:
|
||||
def _optional_duration(value: Any) -> Optional[float]:
|
||||
"""Seconds, or None for "until stopped". 0 means the same as None.
|
||||
|
||||
Numbers and numeric strings are accepted, the same as the REST route and
|
||||
the file mailbox take them; anything else is refused rather than guessed.
|
||||
Numbers and numeric strings are accepted, the same as the REST route
|
||||
takes them; anything else is refused rather than guessed.
|
||||
"""
|
||||
if value is None or value == '':
|
||||
return None
|
||||
@@ -410,7 +418,7 @@ class HelloArgs:
|
||||
class OnDemandStartArgs:
|
||||
"""``on_demand.start``: show a plugin (or one of its modes) now.
|
||||
|
||||
The same fields the file mailbox carries. At least one of ``plugin_id``
|
||||
The same fields as the REST route's body. At least one of ``plugin_id``
|
||||
and ``mode`` is required; the display resolves the other.
|
||||
"""
|
||||
plugin_id: Optional[str] = None
|
||||
@@ -565,8 +573,32 @@ class StateSubscribeArgs:
|
||||
return cls()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ErrorsClearArgs:
|
||||
"""``errors.clear``: forget the plugin errors recorded at or before
|
||||
``cutoff`` (seconds since the epoch), as ``POST /api/v3/errors/clear``
|
||||
asks. The request id is the clear's id, which the display's error
|
||||
snapshot then reports as ``applied_clear_id``.
|
||||
"""
|
||||
cutoff: float
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {'cutoff': self.cutoff}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, args: Mapping[str, Any]) -> 'ErrorsClearArgs':
|
||||
value = args.get('cutoff')
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise ProtocolError(ErrorCode.INVALID_ARGS, 'cutoff must be a number of seconds')
|
||||
if not math.isfinite(value) or value < 0:
|
||||
raise ProtocolError(ErrorCode.INVALID_ARGS,
|
||||
'cutoff must be a finite, non-negative number of seconds')
|
||||
return cls(cutoff=float(value))
|
||||
|
||||
|
||||
CommandArgs = Union[HelloArgs, OnDemandStartArgs, OnDemandStopArgs, NoArgs,
|
||||
BrightnessSetArgs, PluginReloadArgs, StateGetArgs, StateSubscribeArgs]
|
||||
BrightnessSetArgs, PluginReloadArgs, StateGetArgs, StateSubscribeArgs,
|
||||
ErrorsClearArgs]
|
||||
|
||||
#: The arguments of a command that goes on the render thread's queue.
|
||||
QueuedArgs = Union[OnDemandStartArgs, OnDemandStopArgs, BrightnessSetArgs, PluginReloadArgs]
|
||||
@@ -581,6 +613,7 @@ _ARG_TYPES: Dict[str, Any] = {
|
||||
Command.PLUGIN_RELOAD: PluginReloadArgs,
|
||||
Command.STATE_GET: StateGetArgs,
|
||||
Command.STATE_SUBSCRIBE: StateSubscribeArgs,
|
||||
Command.ERRORS_CLEAR: ErrorsClearArgs,
|
||||
}
|
||||
|
||||
|
||||
@@ -595,13 +628,12 @@ def parse_args(cmd: str, args: Mapping[str, Any]) -> CommandArgs:
|
||||
|
||||
def on_demand_request(request_id: str, args: Union[OnDemandStartArgs, OnDemandStopArgs],
|
||||
timestamp: float) -> Dict[str, Any]:
|
||||
"""The file-mailbox payload for a queued on-demand command.
|
||||
"""The on-demand request dict for a queued on-demand command.
|
||||
|
||||
The display hands socket commands to the same code that handles the
|
||||
mailbox (``DisplayController._handle_on_demand_request``), so a command
|
||||
behaves identically whichever way it arrived, and a request that came
|
||||
both ways (a client that timed out and fell back) is processed once: the
|
||||
request id is the same.
|
||||
The display hands socket commands to the same code that handles
|
||||
plugins' own requests (``DisplayController._handle_on_demand_request``),
|
||||
so a command behaves identically whichever way it arrived. (This was
|
||||
the file mailbox's payload, which the display no longer reads.)
|
||||
"""
|
||||
if isinstance(args, OnDemandStartArgs):
|
||||
return {'request_id': request_id, 'action': 'start', 'plugin_id': args.plugin_id,
|
||||
@@ -653,6 +685,13 @@ class PluginReloadResult(TypedDict):
|
||||
modes: List[str]
|
||||
|
||||
|
||||
class ErrorsClearResult(TypedDict):
|
||||
"""``errors.clear``, once applied and the error snapshot republished."""
|
||||
request_id: str
|
||||
cutoff: float
|
||||
cleared: int
|
||||
|
||||
|
||||
class LoopState(TypedDict):
|
||||
"""``loop``: is the render loop still going round?
|
||||
|
||||
|
||||
+68
-16
@@ -3,10 +3,12 @@
|
||||
A small threaded server on a Unix stream socket (``/run/ledmatrix/control.sock``
|
||||
by default; see :mod:`src.ipc.contract` for the protocol). It never touches
|
||||
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.
|
||||
queue and acknowledged, and the render thread drains that queue
|
||||
(``DisplayController._poll_on_demand_requests``), handing each on-demand
|
||||
command to the code that handles plugins' own requests. 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.
|
||||
|
||||
The queue also wakes the render thread: :meth:`ControlServer.wait_for_command`
|
||||
is what it waits on in place of a sleep, so a command lands within a frame on
|
||||
@@ -62,6 +64,7 @@ from src.ipc.contract import (
|
||||
AWAITED_COMMANDS,
|
||||
COMMANDS,
|
||||
DEFAULT_SOCKET_DIR,
|
||||
DIRECT_COMMANDS,
|
||||
DEFAULT_SOCKET_PATH,
|
||||
MAX_MESSAGE_BYTES,
|
||||
MAX_SUBSCRIBERS,
|
||||
@@ -107,7 +110,8 @@ MAX_CLIENTS = 8
|
||||
|
||||
#: Commands waiting for the render thread. It drains them at least every
|
||||
#: 0.25 s, so a full queue means the render thread is stuck, and the client
|
||||
#: is told ``busy`` (and falls back to the mailbox) instead of piling up work.
|
||||
#: is told ``busy`` instead of piling up work, and the web interface reports
|
||||
#: the failure.
|
||||
QUEUE_SIZE = 16
|
||||
|
||||
#: Timeout for one recv()/send() on a connection.
|
||||
@@ -181,7 +185,7 @@ class QueuedCommand:
|
||||
outcome: Optional[CommandOutcome] = field(default=None, compare=False, repr=False)
|
||||
|
||||
def as_on_demand_request(self) -> Dict[str, Any]:
|
||||
"""The mailbox-shaped payload the display's on-demand handler takes."""
|
||||
"""The on-demand request dict the display's on-demand handler takes."""
|
||||
if not isinstance(self.args, (OnDemandStartArgs, OnDemandStopArgs)):
|
||||
raise TypeError(f'{self.cmd} is not an on-demand command')
|
||||
return on_demand_request(self.request_id, self.args, self.received_at)
|
||||
@@ -552,6 +556,12 @@ def server_socket_path(environ: Optional[Mapping[str, str]] = None) -> Optional[
|
||||
|
||||
StatusProvider = Callable[[], Dict[str, Any]]
|
||||
|
||||
#: A handler for one of DIRECT_COMMANDS, ``(request_id, args) -> result``. It
|
||||
#: runs on the connection thread, so it must not touch what the render thread
|
||||
#: owns. It may raise ProtocolError to answer with that error's code; any
|
||||
#: other exception is answered ``internal``.
|
||||
DirectHandler = Callable[[str, Any], Mapping[str, Any]]
|
||||
|
||||
|
||||
class ControlServer:
|
||||
"""Serves the control socket on background threads.
|
||||
@@ -569,9 +579,12 @@ class ControlServer:
|
||||
await_seconds: Optional[Mapping[str, float]] = None,
|
||||
state_hub: Optional[StateHub] = None,
|
||||
max_subscribers: int = MAX_SUBSCRIBERS,
|
||||
keepalive: float = SUBSCRIBE_KEEPALIVE_SECONDS):
|
||||
keepalive: float = SUBSCRIBE_KEEPALIVE_SECONDS,
|
||||
handlers: Optional[Mapping[str, DirectHandler]] = None):
|
||||
self.path = path
|
||||
self.state_hub = state_hub
|
||||
self._handlers: Dict[str, DirectHandler] = {
|
||||
cmd: fn for cmd, fn in (handlers or {}).items() if cmd in DIRECT_COMMANDS}
|
||||
self._subscriber_slots = threading.BoundedSemaphore(max_subscribers)
|
||||
self._keepalive = keepalive
|
||||
self._await_seconds: Dict[str, float] = dict(AWAIT_SECONDS)
|
||||
@@ -606,8 +619,8 @@ class ControlServer:
|
||||
def start(self) -> bool:
|
||||
"""Bind and start serving. False (logged) when the socket cannot be served.
|
||||
|
||||
Never raises: without the socket the web interface uses the file
|
||||
mailbox, exactly as before.
|
||||
Never raises: without the socket the display runs, but the web
|
||||
interface cannot send it commands.
|
||||
"""
|
||||
if not socket_supported():
|
||||
logger.debug("Control socket not started: no Unix sockets on this platform")
|
||||
@@ -619,7 +632,7 @@ class ControlServer:
|
||||
self._bind()
|
||||
except OSError as e:
|
||||
logger.warning("Control socket not started at %s (%s); the web interface "
|
||||
"will use the file mailbox", self.path, e)
|
||||
"cannot send this display commands", self.path, e)
|
||||
self._close_socket()
|
||||
return False
|
||||
self._stopping.clear()
|
||||
@@ -759,8 +772,22 @@ class ControlServer:
|
||||
"""
|
||||
return self._pending.wait(timeout)
|
||||
|
||||
def wake(self) -> None:
|
||||
"""Wake the render thread as a queued command would, with nothing queued.
|
||||
|
||||
For work that reaches the display another way in the same process (a
|
||||
plugin's on-demand request, ``DisplayController.submit_plugin_on_demand``):
|
||||
the render thread returns from :meth:`wait_for_command` and drains,
|
||||
and reads the caller's own queue there. Safe from any thread.
|
||||
"""
|
||||
self._pending.set()
|
||||
|
||||
def drain(self) -> List[QueuedCommand]:
|
||||
"""Every queued command, oldest first. Called from the render thread."""
|
||||
"""Every queued command, oldest first. Called from the render thread.
|
||||
|
||||
Clears the wake flag first, so anything queued (or woken for) while
|
||||
this runs wakes the next wait again.
|
||||
"""
|
||||
commands: List[QueuedCommand] = []
|
||||
self._pending.clear()
|
||||
while True:
|
||||
@@ -1028,6 +1055,9 @@ class ControlServer:
|
||||
snap = hub.snapshot()
|
||||
return Response.success(request.id, fit_snapshot(snap), v=request.v)
|
||||
|
||||
if request.cmd in DIRECT_COMMANDS:
|
||||
return self._direct(request, args)
|
||||
|
||||
if request.cmd in QUEUED_COMMANDS and isinstance(args, (
|
||||
OnDemandStartArgs, OnDemandStopArgs, BrightnessSetArgs, PluginReloadArgs)):
|
||||
awaited = request.cmd in AWAITED_COMMANDS
|
||||
@@ -1055,6 +1085,25 @@ class ControlServer:
|
||||
return Response.failure(request.id, ErrorCode.INTERNAL,
|
||||
f'{request.cmd} is not implemented', v=request.v)
|
||||
|
||||
def _direct(self, request: Request, args: Any) -> Response:
|
||||
"""A command the display answers on this thread (DIRECT_COMMANDS)."""
|
||||
handler = self._handlers.get(request.cmd)
|
||||
if handler is None:
|
||||
# Answered as an older display would, so the client falls back.
|
||||
return Response.failure(request.id, ErrorCode.UNKNOWN_COMMAND,
|
||||
f'{request.cmd} is not served by this display',
|
||||
v=request.v)
|
||||
try:
|
||||
result = handler(request.id, args)
|
||||
except ProtocolError as e:
|
||||
return Response.failure(request.id, e.code, e.message, v=request.v)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
logger.exception("Control socket: %s %s failed", request.cmd, request.id)
|
||||
return Response.failure(request.id, ErrorCode.INTERNAL,
|
||||
'the display failed to apply it', v=request.v)
|
||||
logger.info("Control socket applied %s %s", request.cmd, request.id)
|
||||
return Response.success(request.id, dict(result), v=request.v)
|
||||
|
||||
def _await_outcome(self, request: Request, outcome: CommandOutcome) -> Response:
|
||||
"""Answer an awaited command once the render thread has applied it.
|
||||
|
||||
@@ -1079,19 +1128,22 @@ class ControlServer:
|
||||
def start_control_server(status_provider: Optional[StatusProvider] = None,
|
||||
cache_dir: Optional[str] = None,
|
||||
environ: Optional[Mapping[str, str]] = None,
|
||||
state_hub: Optional[StateHub] = None) -> Optional[ControlServer]:
|
||||
state_hub: Optional[StateHub] = None,
|
||||
handlers: Optional[Mapping[str, DirectHandler]] = None,
|
||||
) -> Optional[ControlServer]:
|
||||
"""Start the display's control socket, or return None when it can't run.
|
||||
|
||||
None covers Windows, ``LEDMATRIX_CONTROL_SOCKET=off`` and any failure to
|
||||
bind; in every case the web interface falls back to the file mailbox
|
||||
and to the cache keys the display still writes.
|
||||
bind; in every case the web interface cannot send the display commands,
|
||||
and reads the cache keys the display still writes.
|
||||
"""
|
||||
path = server_socket_path(environ)
|
||||
if path is None:
|
||||
logger.debug("Control socket disabled or unsupported here; using the file mailbox only")
|
||||
logger.debug("Control socket disabled or unsupported here; the web interface "
|
||||
"cannot send this display commands")
|
||||
return None
|
||||
server = ControlServer(path, status_provider, resolve_socket_group(cache_dir),
|
||||
state_hub=state_hub)
|
||||
state_hub=state_hub, handlers=handlers)
|
||||
return server if server.start() else None
|
||||
|
||||
|
||||
|
||||
@@ -1070,6 +1070,65 @@ class BasePlugin(ABC):
|
||||
if callable(notify):
|
||||
notify(self.plugin_id)
|
||||
|
||||
def request_on_demand(self, mode: Optional[str] = None,
|
||||
duration: Optional[float] = None,
|
||||
pinned: bool = False) -> Optional[str]:
|
||||
"""
|
||||
Take the screen now: show this plugin on demand. Safe from any thread.
|
||||
|
||||
For a plugin that reacts to something outside the rotation -- an MQTT
|
||||
message, a timer, a detection -- and wants the panel for it. The
|
||||
request goes straight to the display in this process and is applied
|
||||
on its render thread within a frame or so, exactly like an on-demand
|
||||
start from the web interface.
|
||||
|
||||
Args:
|
||||
mode: One of this plugin's display modes; None for its first.
|
||||
duration: Seconds to show it before the rotation resumes; None
|
||||
(or zero) for no limit, until end_on_demand() or the user
|
||||
stops it.
|
||||
pinned: Stay on ``mode`` instead of cycling through the
|
||||
plugin's other modes.
|
||||
|
||||
Returns:
|
||||
The request id once the display has queued it, or None when
|
||||
there is no display in this process to ask (the web interface,
|
||||
scripts/check_plugin.py) or its queue is full. Nothing else can
|
||||
take the request then: the ``display_on_demand_request`` file
|
||||
mailbox older cores read is gone, and a write to it is dropped
|
||||
with a warning. See "On-demand display" in
|
||||
docs/PLUGIN_API_REFERENCE.md.
|
||||
|
||||
Example::
|
||||
|
||||
if self.request_on_demand(mode='my_alert', duration=15) is None:
|
||||
self.logger.info("No display to show the alert on")
|
||||
"""
|
||||
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
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
"""
|
||||
Files a plugin writes beside itself at runtime, which an update must keep.
|
||||
|
||||
A store update replaces a plugin's directory with a fresh download and then
|
||||
deletes the old copy. Anything the plugin created there -- OAuth tokens, a
|
||||
client-secrets file, a PKCE verifier, cached state -- is in no release, so the
|
||||
fresh download does not contain it and deleting the old copy destroys it. On
|
||||
2026-10-04 updating calendar 1.2.9 -> 1.2.12 that way deleted its
|
||||
``token.pickle`` and ``credentials.json``, and the calendar stopped until they
|
||||
were restored from a backup.
|
||||
|
||||
What counts as "the plugin's own local file" is the union of:
|
||||
|
||||
* :data:`KNOWN_STATE_PATTERNS` -- secret and state files plugins are known to
|
||||
write, kept even when a plugin forgot to gitignore them; and
|
||||
* whatever the plugin's own ``.gitignore`` (old copy or new) excludes. A file
|
||||
the author ignores is by definition not part of a release.
|
||||
|
||||
A file the new release ships is never overwritten: tracked content wins. Byte
|
||||
code (``__pycache__``, ``*.pyc``) and ``.git`` are never carried, since they
|
||||
belong to the old code rather than to the user.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fnmatch
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Iterable, List, Optional, Pattern, Tuple
|
||||
|
||||
__all__ = [
|
||||
'KNOWN_STATE_PATTERNS',
|
||||
'carry_over_local_files',
|
||||
'is_known_state_file',
|
||||
'local_files_to_keep',
|
||||
]
|
||||
|
||||
# Basename globs. Kept even when the plugin's .gitignore does not list them.
|
||||
KNOWN_STATE_PATTERNS: Tuple[str, ...] = (
|
||||
'token.pickle',
|
||||
'*.pickle',
|
||||
'token.json',
|
||||
'credentials.json',
|
||||
'config_secrets.json',
|
||||
'.pkce_code_verifier',
|
||||
)
|
||||
|
||||
_NEVER_CARRY_DIRS = frozenset({'.git', '__pycache__'})
|
||||
_NEVER_CARRY_SUFFIXES = ('.pyc', '.pyo')
|
||||
|
||||
|
||||
def is_known_state_file(rel_path: str) -> bool:
|
||||
"""True when ``rel_path``'s basename is a known secret/state file."""
|
||||
name = rel_path.replace('\\', '/').rsplit('/', 1)[-1]
|
||||
return any(fnmatch.fnmatchcase(name, p) for p in KNOWN_STATE_PATTERNS)
|
||||
|
||||
|
||||
class _GitIgnore:
|
||||
"""The subset of gitignore semantics plugin .gitignore files use.
|
||||
|
||||
Supports comments, ``!`` negation (last match wins), a trailing ``/`` for
|
||||
directory-only patterns, anchoring by a leading or embedded ``/``, ``*``,
|
||||
``?``, ``[...]`` and ``**``. As in git, a file under an ignored directory
|
||||
is ignored regardless of later negations.
|
||||
"""
|
||||
|
||||
def __init__(self, lines: Iterable[str]):
|
||||
self._rules: List[Tuple[Pattern[str], bool, bool]] = []
|
||||
for raw in lines:
|
||||
line = raw.rstrip('\n').rstrip()
|
||||
if not line or line.startswith('#'):
|
||||
continue
|
||||
negate = line.startswith('!')
|
||||
if negate:
|
||||
line = line[1:]
|
||||
elif line.startswith('\\'):
|
||||
line = line[1:]
|
||||
dir_only = line.endswith('/')
|
||||
line = line.rstrip('/')
|
||||
if not line:
|
||||
continue
|
||||
anchored = '/' in line
|
||||
line = line.lstrip('/')
|
||||
body = self._translate(line)
|
||||
regex = body if anchored else r'(?:.*/)?' + body
|
||||
self._rules.append((re.compile(r'\A' + regex + r'\Z'), negate, dir_only))
|
||||
|
||||
@staticmethod
|
||||
def _translate(pattern: str) -> str:
|
||||
out, i, n = [], 0, len(pattern)
|
||||
while i < n:
|
||||
if pattern.startswith('**/', i):
|
||||
out.append(r'(?:.*/)?')
|
||||
i += 3
|
||||
elif pattern.startswith('/**', i) and i + 3 == n:
|
||||
out.append(r'/.*')
|
||||
i += 3
|
||||
elif pattern.startswith('**', i):
|
||||
out.append(r'.*')
|
||||
i += 2
|
||||
elif pattern[i] == '*':
|
||||
out.append(r'[^/]*')
|
||||
i += 1
|
||||
elif pattern[i] == '?':
|
||||
out.append(r'[^/]')
|
||||
i += 1
|
||||
elif pattern[i] == '[':
|
||||
end = pattern.find(']', i + 1)
|
||||
if end == -1:
|
||||
out.append(re.escape('['))
|
||||
i += 1
|
||||
else:
|
||||
cls = pattern[i + 1:end]
|
||||
if cls.startswith('!'):
|
||||
cls = '^' + cls[1:]
|
||||
out.append('[' + cls.replace('\\', '\\\\') + ']')
|
||||
i = end + 1
|
||||
else:
|
||||
out.append(re.escape(pattern[i]))
|
||||
i += 1
|
||||
return ''.join(out)
|
||||
|
||||
def _decide(self, rel: str, is_dir: bool) -> Optional[bool]:
|
||||
verdict = None
|
||||
for regex, negate, dir_only in self._rules:
|
||||
if dir_only and not is_dir:
|
||||
continue
|
||||
if regex.match(rel):
|
||||
verdict = not negate
|
||||
return verdict
|
||||
|
||||
def ignores(self, rel_path: str) -> bool:
|
||||
if not self._rules:
|
||||
return False
|
||||
parts = rel_path.replace('\\', '/').split('/')
|
||||
for depth in range(1, len(parts)):
|
||||
if self._decide('/'.join(parts[:depth]), True):
|
||||
return True
|
||||
return bool(self._decide('/'.join(parts), False))
|
||||
|
||||
|
||||
def _read_gitignore(plugin_dir: Path) -> List[str]:
|
||||
try:
|
||||
return (plugin_dir / '.gitignore').read_text(
|
||||
encoding='utf-8', errors='replace').splitlines()
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
def local_files_to_keep(old_dir: Path, new_dir: Path) -> List[str]:
|
||||
"""Relative paths (``/``-separated) in ``old_dir`` to copy into ``new_dir``.
|
||||
|
||||
Regular files only; symlinks and anything the new release already ships
|
||||
are skipped.
|
||||
"""
|
||||
old_dir, new_dir = Path(old_dir), Path(new_dir)
|
||||
ignore = _GitIgnore(_read_gitignore(old_dir) + _read_gitignore(new_dir))
|
||||
keep: List[str] = []
|
||||
for root, dirs, files in os.walk(old_dir):
|
||||
dirs[:] = sorted(d for d in dirs if d not in _NEVER_CARRY_DIRS
|
||||
and not os.path.islink(os.path.join(root, d)))
|
||||
rel_root = os.path.relpath(root, old_dir)
|
||||
for name in sorted(files):
|
||||
if name.endswith(_NEVER_CARRY_SUFFIXES):
|
||||
continue
|
||||
full = os.path.join(root, name)
|
||||
if os.path.islink(full) or not os.path.isfile(full):
|
||||
continue
|
||||
rel = name if rel_root == '.' else f"{rel_root}/{name}".replace('\\', '/')
|
||||
if not (is_known_state_file(rel) or ignore.ignores(rel)):
|
||||
continue
|
||||
if os.path.lexists(new_dir / rel):
|
||||
continue
|
||||
keep.append(rel)
|
||||
return keep
|
||||
|
||||
|
||||
def carry_over_local_files(
|
||||
old_dir: Path, new_dir: Path
|
||||
) -> Tuple[List[str], List[Tuple[str, str]]]:
|
||||
"""Copy the plugin's local files from ``old_dir`` into ``new_dir``.
|
||||
|
||||
Copies rather than moves, so ``old_dir`` stays a complete copy until the
|
||||
caller deletes it. Returns ``(copied, failed)`` where ``failed`` pairs a
|
||||
relative path with the error; the caller should keep ``old_dir`` when
|
||||
anything failed.
|
||||
"""
|
||||
copied: List[str] = []
|
||||
failed: List[Tuple[str, str]] = []
|
||||
try:
|
||||
candidates = local_files_to_keep(old_dir, new_dir)
|
||||
except OSError as e:
|
||||
return copied, [('.', str(e))]
|
||||
for rel in candidates:
|
||||
dest = Path(new_dir) / rel
|
||||
try:
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(Path(old_dir) / rel, dest)
|
||||
copied.append(rel)
|
||||
except OSError as e:
|
||||
failed.append((rel, str(e)))
|
||||
return copied, failed
|
||||
@@ -15,6 +15,7 @@ import sys
|
||||
import time
|
||||
import threading
|
||||
import types
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Callable, Dict, List, NamedTuple, Optional, Any, Tuple, Union
|
||||
import logging
|
||||
@@ -214,6 +215,9 @@ class PluginManager:
|
||||
# add_update_listener(). A tuple, replaced rather than mutated, so the
|
||||
# worker can iterate it without a lock.
|
||||
self._update_listeners: Tuple[Callable[[str], None], ...] = ()
|
||||
# Where plugins' on-demand requests go: the display controller's
|
||||
# submit_plugin_on_demand. See set_on_demand_handler().
|
||||
self._on_demand_handler: Optional[Callable[[Dict[str, Any]], bool]] = None
|
||||
# Config changes that found the plugin's lock busy, latest per plugin,
|
||||
# with the instance they were meant for. See apply_config_change().
|
||||
self._deferred_config_changes: Dict[str, Tuple[Any, Dict[str, Any]]] = {}
|
||||
@@ -597,11 +601,21 @@ class PluginManager:
|
||||
self.plugin_loader.unregister_plugin_modules(plugin_id)
|
||||
except Exception as e: # pragma: no cover - defensive
|
||||
self.logger.debug("Could not drop modules of %s: %s", plugin_id, e)
|
||||
try:
|
||||
if self.font_manager is not None and hasattr(self.font_manager, 'forget_manager_fonts'):
|
||||
self.font_manager.forget_manager_fonts(plugin_id)
|
||||
except Exception as e:
|
||||
self.logger.debug("Could not forget fonts of %s: %s", plugin_id, e)
|
||||
self._forget_plugin_fonts(plugin_id)
|
||||
|
||||
def _forget_plugin_fonts(self, plugin_id: str) -> None:
|
||||
"""Drop what the FontManager holds for a plugin: the fonts its
|
||||
instance reported using (the Fonts tab's "Used by") and the fonts its
|
||||
manifest registered. Never raises."""
|
||||
if self.font_manager is None:
|
||||
return
|
||||
for name in ('forget_manager_fonts', 'forget_plugin_fonts'):
|
||||
if not hasattr(self.font_manager, name):
|
||||
continue
|
||||
try:
|
||||
getattr(self.font_manager, name)(plugin_id)
|
||||
except Exception as e:
|
||||
self.logger.debug("Could not forget fonts of %s (%s): %s", plugin_id, name, e)
|
||||
|
||||
#: Config keys the **core** reads out of a plugin's own config block. The
|
||||
#: plugin never declares them, so a schema with
|
||||
@@ -846,12 +860,9 @@ class PluginManager:
|
||||
# Delegate sub-module and cached-module cleanup to the loader
|
||||
self.plugin_loader.unregister_plugin_modules(plugin_id)
|
||||
|
||||
# Its font registrations go with it (the Fonts tab's "Used by").
|
||||
try:
|
||||
if self.font_manager is not None and hasattr(self.font_manager, 'forget_manager_fonts'):
|
||||
self.font_manager.forget_manager_fonts(plugin_id)
|
||||
except Exception as e:
|
||||
self.logger.debug("Could not forget fonts of %s: %s", plugin_id, e)
|
||||
# Its font registrations go with it: the fonts it reported using
|
||||
# and the ones its manifest registered.
|
||||
self._forget_plugin_fonts(plugin_id)
|
||||
|
||||
# Update state
|
||||
self.state_manager.set_state(plugin_id, PluginState.UNLOADED)
|
||||
@@ -1844,3 +1855,73 @@ class PluginManager:
|
||||
done = sorted(self._completed_updates)
|
||||
self._completed_updates.clear()
|
||||
return done
|
||||
|
||||
# -- on-demand requests from plugins -------------------------------------
|
||||
|
||||
def set_on_demand_handler(
|
||||
self, handler: Optional[Callable[[Dict[str, Any]], bool]]) -> None:
|
||||
"""Route plugins' on-demand requests to ``handler`` (None: nowhere).
|
||||
|
||||
The display controller sets its ``submit_plugin_on_demand`` here
|
||||
before any plugin loads. The handler takes an on-demand request dict
|
||||
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
|
||||
|
||||
@@ -22,6 +22,7 @@ from src.plugin_system.plugin_loader import (
|
||||
contained_plugin_dir, requirements_to_install,
|
||||
)
|
||||
from src.plugin_system.plugin_dirs import BACKUP_MARKER
|
||||
from src.plugin_system.plugin_local_files import carry_over_local_files
|
||||
from src.plugin_system.repo_urls import (
|
||||
USER_AGENT, github_api_headers, github_owner_repo, normalize_repo_url,
|
||||
)
|
||||
@@ -92,7 +93,9 @@ class _InstallMixin:
|
||||
raise
|
||||
|
||||
if installed:
|
||||
self._discard_backup(plugin_id, backup_path, "install")
|
||||
self._discard_backup(
|
||||
plugin_id, backup_path, "install",
|
||||
new_path=self._existing_install(plugin_id) or plugin_path)
|
||||
return True
|
||||
|
||||
self._restore_backup(plugin_id, plugin_path, backup_path, "Install")
|
||||
@@ -133,8 +136,33 @@ class _InstallMixin:
|
||||
return f"could not set aside {plugin_path}: {e}"
|
||||
return None
|
||||
|
||||
def _discard_backup(self, plugin_id: str, backup_path: Path, action: str) -> None:
|
||||
"""Remove the set-aside copy after a successful (re)install."""
|
||||
def _discard_backup(
|
||||
self, plugin_id: str, backup_path: Path, action: str,
|
||||
new_path: Optional[Path] = None,
|
||||
) -> None:
|
||||
"""Remove the set-aside copy after a successful (re)install.
|
||||
|
||||
With ``new_path`` (where the new copy landed), first carries the
|
||||
plugin's own runtime files -- OAuth tokens, client secrets, anything
|
||||
its .gitignore excludes -- from the old copy into the new one: no
|
||||
release contains them, so deleting the old copy would destroy them.
|
||||
See src/plugin_system/plugin_local_files.py. If any could not be
|
||||
copied the old copy is kept, so nothing is lost.
|
||||
"""
|
||||
if new_path is not None and new_path.is_dir():
|
||||
copied, failed = carry_over_local_files(backup_path, new_path)
|
||||
if copied:
|
||||
self.logger.info(
|
||||
"Kept %d local file(s) of %s across the %s: %s",
|
||||
len(copied), plugin_id, action, ", ".join(copied))
|
||||
if failed:
|
||||
self.logger.error(
|
||||
"Could not carry %s's local files into the new copy (%s); "
|
||||
"the previous copy is kept at %s -- copy them back by hand",
|
||||
plugin_id,
|
||||
"; ".join(f"{rel}: {err}" for rel, err in failed),
|
||||
backup_path)
|
||||
return
|
||||
if not self._safe_remove_directory(backup_path):
|
||||
self.logger.warning(
|
||||
"%s of %s succeeded but the previous copy at %s could not be "
|
||||
@@ -542,7 +570,8 @@ class _InstallMixin:
|
||||
raise
|
||||
temp_dir = None # Prevent cleanup since we moved it
|
||||
if backup_path is not None:
|
||||
self._discard_backup(plugin_id, backup_path, "install")
|
||||
self._discard_backup(
|
||||
plugin_id, backup_path, "install", new_path=final_path)
|
||||
|
||||
# Install dependencies
|
||||
self._install_dependencies(final_path)
|
||||
|
||||
@@ -138,6 +138,11 @@ class PluginStoreManager(_RegistryMixin, _InstallMixin, _UpdateMixin):
|
||||
# the registry cache expires. Only one thread fetches; others wait and
|
||||
# then get the result from the warm cache (double-checked locking).
|
||||
self._registry_fetch_lock = threading.Lock()
|
||||
# refresh_registry_in_background: the one refresh thread, and when
|
||||
# an offline one may be retried (see that method).
|
||||
self._registry_refresh_lock = threading.Lock()
|
||||
self._registry_refresh_thread: Optional[threading.Thread] = None
|
||||
self._registry_refresh_retry_after = 0.0
|
||||
|
||||
# Per-plugin locks for _reinstall_with_rollback: the web UI runs
|
||||
# Flask with threaded=True, so two overlapping requests for the
|
||||
|
||||
@@ -7,6 +7,7 @@ methods reach shared state and helpers through ``self``.
|
||||
|
||||
import json
|
||||
import requests
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime
|
||||
@@ -985,10 +986,14 @@ class _RegistryMixin:
|
||||
|
||||
def get_registry_info(self, plugin_id: str) -> Optional[Dict]:
|
||||
"""
|
||||
Get plugin information from the registry cache only (no GitHub API calls).
|
||||
Get plugin information from the registry (plugins.json).
|
||||
|
||||
Use this for lightweight lookups where only registry fields are needed
|
||||
(e.g., verified status, latest_version).
|
||||
Makes no GitHub API calls, but it does go through `fetch_registry`:
|
||||
when the in-memory copy is missing or older than
|
||||
``registry_cache_timeout`` it downloads plugins.json, and with no
|
||||
network that waits out the timeout and retries. A caller that must
|
||||
not block on the network (the installed-plugins list) uses
|
||||
`get_cached_registry_info` instead.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
@@ -999,3 +1004,52 @@ class _RegistryMixin:
|
||||
registry = self.fetch_registry()
|
||||
plugins = registry.get('plugins', []) or []
|
||||
return self._match_registry_entry(plugins, plugin_id)
|
||||
|
||||
def get_cached_registry_info(self, plugin_id: str) -> Optional[Dict]:
|
||||
"""The registry entry for ``plugin_id`` from the copy already in
|
||||
memory, however old; never touches the network.
|
||||
|
||||
None when no registry has been loaded yet, or the plugin isn't in it.
|
||||
When the copy is missing or past ``registry_cache_timeout`` this
|
||||
starts `refresh_registry_in_background`, so a later call has it.
|
||||
"""
|
||||
cache = getattr(self, 'registry_cache', None)
|
||||
cache_time = getattr(self, 'registry_cache_time', None)
|
||||
if (not cache or not cache_time
|
||||
or (time.time() - cache_time) >= self.registry_cache_timeout):
|
||||
self.refresh_registry_in_background()
|
||||
plugins = cache.get('plugins') if isinstance(cache, dict) else None
|
||||
if not isinstance(plugins, list):
|
||||
return None
|
||||
return self._match_registry_entry(
|
||||
[p for p in plugins if isinstance(p, dict)], plugin_id)
|
||||
|
||||
def refresh_registry_in_background(self) -> bool:
|
||||
"""Fetch the registry on a daemon thread; True when one was started.
|
||||
|
||||
At most one runs at a time. After a fetch that left no registry in
|
||||
memory (offline), no new one starts for ``_failure_backoff_seconds``,
|
||||
so an offline Pi doesn't retry on every page load.
|
||||
"""
|
||||
with self._registry_refresh_lock:
|
||||
running = self._registry_refresh_thread
|
||||
if running is not None and running.is_alive():
|
||||
return False
|
||||
if time.time() < self._registry_refresh_retry_after:
|
||||
return False
|
||||
thread = threading.Thread(
|
||||
target=self._background_registry_refresh,
|
||||
name='registry-refresh', daemon=True)
|
||||
self._registry_refresh_thread = thread
|
||||
thread.start()
|
||||
return True
|
||||
|
||||
def _background_registry_refresh(self) -> None:
|
||||
try:
|
||||
self.fetch_registry()
|
||||
except Exception as e: # noqa: BLE001 - a background warm-up must not crash
|
||||
self.logger.warning("Background registry refresh failed: %s", e)
|
||||
if not getattr(self, 'registry_cache', None):
|
||||
with self._registry_refresh_lock:
|
||||
self._registry_refresh_retry_after = (
|
||||
time.time() + self._failure_backoff_seconds)
|
||||
|
||||
@@ -10,6 +10,9 @@ import subprocess # nosec B404 - list-form argv only, no shell # nosemgrep
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional, Tuple
|
||||
from src.plugin_system.plugin_dirs import BACKUP_MARKER
|
||||
from src.plugin_system.plugin_local_files import (
|
||||
KNOWN_STATE_PATTERNS, is_known_state_file,
|
||||
)
|
||||
from src.plugin_system.repo_urls import same_repo
|
||||
|
||||
|
||||
@@ -302,7 +305,11 @@ class _UpdateMixin:
|
||||
installed = False
|
||||
|
||||
if installed:
|
||||
self._discard_backup(plugin_id, backup_path, "update")
|
||||
# install_plugin may land the new copy under the manifest id
|
||||
# rather than the old directory name.
|
||||
self._discard_backup(
|
||||
plugin_id, backup_path, "update",
|
||||
new_path=self._existing_install(plugin_id) or plugin_path)
|
||||
return True
|
||||
|
||||
# Bad network, registry error...: the user keeps a working plugin.
|
||||
@@ -509,8 +516,12 @@ class _UpdateMixin:
|
||||
for line in untracked_result.stdout.strip().split('\n'):
|
||||
if line.startswith('??'):
|
||||
# Untracked file
|
||||
file_path = line[3:].strip()
|
||||
untracked_files.append(file_path)
|
||||
file_path = line[3:].strip().strip('"')
|
||||
# Tokens and secrets stay out of the
|
||||
# stash (see below), so they alone are
|
||||
# not a reason to stash.
|
||||
if not is_known_state_file(file_path):
|
||||
untracked_files.append(file_path)
|
||||
|
||||
# Check for tracked file changes
|
||||
status_result = subprocess.run(
|
||||
@@ -537,9 +548,17 @@ class _UpdateMixin:
|
||||
if has_changes:
|
||||
self.logger.info(f"Stashing local changes in {plugin_id} before update")
|
||||
try:
|
||||
# Use -u to include untracked files in stash
|
||||
# Use -u to include untracked files in stash --
|
||||
# except the plugin's tokens and secrets, which a
|
||||
# repo may have forgotten to gitignore. The stash
|
||||
# is never popped, so a stashed token.pickle would
|
||||
# vanish from the plugin and break it.
|
||||
stash_cmd = (
|
||||
['git', '-C', str(plugin_path), 'stash', 'push', '-u',
|
||||
'-m', f'LEDMatrix auto-stash before update {plugin_id}', '--', '.']
|
||||
+ [f':(exclude,glob)**/{p}' for p in KNOWN_STATE_PATTERNS])
|
||||
stash_result = subprocess.run(
|
||||
['git', '-C', str(plugin_path), 'stash', 'push', '-u', '-m', f'LEDMatrix auto-stash before update {plugin_id}'],
|
||||
stash_cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
|
||||
@@ -0,0 +1,484 @@
|
||||
"""Runs one screen: the ScreenRunner of docs/RUN_LOOP_REDESIGN.md.
|
||||
|
||||
``ScreenRunner.run(plan, plugin)`` draws a screen's first frame, runs the
|
||||
frame loop its plan's ``frame_policy`` picks (125 Hz or 1 Hz), makes up the
|
||||
minimum duration when the loop ended early, and returns one
|
||||
:class:`Outcome` saying why the screen ended. Everything that touches the
|
||||
plugin, the panel or the controller's state goes through a
|
||||
:class:`ScreenHost` (the DisplayController); everything that reads or waits
|
||||
on the clock goes through an injected :class:`FrameClock`. The runner itself
|
||||
holds no state between screens.
|
||||
|
||||
What can end a screen early is decided at the runner's service points: after
|
||||
each frame, after the frame loop, and after the make-up dwell. At each one
|
||||
the host gathers a snapshot and asks the Arbiter, once, whether a Source in
|
||||
``plan.preemptible_by`` now wants the panel (:meth:`ScreenHost.check`). A yes
|
||||
is ``ExitReason.PREEMPTED``: the next pass of the loop decides what shows,
|
||||
and the rotation does not advance past the screen that was cut short.
|
||||
|
||||
The frame pacing is the loop that used to be inline in
|
||||
``DisplayController.run()``, unchanged: the 125 Hz loop paces to an 8 ms
|
||||
deadline from the start of each frame (sleeping at least 1 ms, so a frame
|
||||
that overran still yields the GIL), and the 1 Hz loop sleeps a flat second
|
||||
between frames, woken early by a control socket command.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any, NamedTuple, Optional, Protocol, Tuple
|
||||
|
||||
from src.display_arbiter import FramePolicy, ScreenPlan, Source
|
||||
|
||||
__all__ = [
|
||||
"AFTER_COMPLETED_LOOP",
|
||||
"AFTER_LOOP",
|
||||
"Checkpoint",
|
||||
"DYNAMIC_GRACE",
|
||||
"ExitReason",
|
||||
"FINAL",
|
||||
"FRAME",
|
||||
"FirstFrame",
|
||||
"FrameClock",
|
||||
"HIGH_FPS_INTERVAL",
|
||||
"NoticeRead",
|
||||
"Outcome",
|
||||
"STATIC_INTERVAL",
|
||||
"Screen",
|
||||
"ScreenHost",
|
||||
"ScreenRunner",
|
||||
"after_dwell",
|
||||
]
|
||||
|
||||
#: Seconds between frames in the high-FPS loop (125 Hz), for scrolling plugins.
|
||||
HIGH_FPS_INTERVAL = 0.008
|
||||
|
||||
#: Seconds between frames in the static loop (1 Hz).
|
||||
STATIC_INTERVAL = 1.0
|
||||
|
||||
#: A dynamic-duration screen ends on cycle completion only this long after its
|
||||
#: minimum, so timing jitter around the minimum can't end it early.
|
||||
DYNAMIC_GRACE = 0.5
|
||||
|
||||
|
||||
class ExitReason(Enum):
|
||||
"""Why a screen ended. The value is the golden traces' exit column where
|
||||
one exists (test/test_run_loop_golden.py)."""
|
||||
|
||||
#: The screen ran its target duration.
|
||||
DURATION = "duration"
|
||||
#: A dynamic-duration plugin finished its cycle after its minimum.
|
||||
CYCLE_COMPLETE = "cycle-complete"
|
||||
#: The first frame had nothing to show (display() returned False or
|
||||
#: raised inside the executor), or no plugin draws the mode.
|
||||
EMPTY = "empty"
|
||||
#: The first frame's dispatch itself raised.
|
||||
ERROR = "error"
|
||||
#: A later frame returned False (a dynamic-duration screen on the 1 Hz
|
||||
#: loop keeps going instead).
|
||||
DISPLAY_FALSE = "display-false"
|
||||
#: Another Source took the panel, or an on-demand session ran out before
|
||||
#: the screen began. The rotation does not advance.
|
||||
PREEMPTED = "preempted"
|
||||
#: A plugin reload is waiting for the top of the loop. The screen is cut
|
||||
#: short but counts as shown: the rotation advances, and the next pass
|
||||
#: reloads before it draws.
|
||||
RELOAD = "reload"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Outcome:
|
||||
"""How a screen ended.
|
||||
|
||||
Attributes:
|
||||
exit_reason: Why it ended.
|
||||
elapsed: Seconds from the end of the first frame to the end.
|
||||
preempted_by: For PREEMPTED, the plan that took the panel when the
|
||||
Arbiter named one (None when the session simply ran out).
|
||||
on_demand_active: Filled in by the controller when the screen is
|
||||
over: an on-demand session was running at that moment.
|
||||
still_live: Filled in by the controller: the mode's plugin still had
|
||||
live content at that moment, which holds the rotation on it.
|
||||
"""
|
||||
|
||||
exit_reason: ExitReason
|
||||
elapsed: float = 0.0
|
||||
preempted_by: Optional[ScreenPlan] = None
|
||||
on_demand_active: bool = False
|
||||
still_live: bool = False
|
||||
|
||||
|
||||
class FrameClock(Protocol):
|
||||
"""The clocks the runner reads and the sleep it paces with.
|
||||
|
||||
The shape of the ``time`` module, so production passes it (through an
|
||||
indirection that lets tests patch the module) and the golden traces pass
|
||||
their fake clock.
|
||||
"""
|
||||
|
||||
def time(self) -> float:
|
||||
"""Wall-clock seconds: what screen durations are measured in."""
|
||||
|
||||
def perf_counter(self) -> float:
|
||||
"""A monotonic high-resolution clock: what the 8 ms pacing reads."""
|
||||
|
||||
def sleep(self, seconds: float) -> None:
|
||||
"""Block for ``seconds``."""
|
||||
|
||||
|
||||
class NoticeRead(Enum):
|
||||
"""When a service point reads the WiFi notice file.
|
||||
|
||||
The read is throttled to once a second and deletes an expired file, so
|
||||
*when* it happens is behaviour: each service point reads it exactly
|
||||
when the loop always did.
|
||||
"""
|
||||
|
||||
#: Not at all.
|
||||
NEVER = "never"
|
||||
#: Only if nothing cheaper has already ended the screen: no on-demand
|
||||
#: session, the panel on, the mode unchanged and no live takeover.
|
||||
IF_UNDECIDED = "if-undecided"
|
||||
#: Whenever no on-demand session is running.
|
||||
ALWAYS = "always"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Checkpoint:
|
||||
"""What one kind of service point considers.
|
||||
|
||||
Attributes:
|
||||
name: For logs and tests.
|
||||
notice: When the WiFi notice is read (see NoticeRead).
|
||||
notice_counts: Whether a pending notice ends the screen here. After
|
||||
the make-up dwell it does only while the screen had time left.
|
||||
reload: Whether a pending plugin reload ends the screen here. Only
|
||||
between frames: once the frame loop is over the screen is too.
|
||||
"""
|
||||
|
||||
name: str
|
||||
notice: NoticeRead
|
||||
reload: bool
|
||||
notice_counts: bool = True
|
||||
|
||||
|
||||
#: Between frames: the frame loops' check, and the socket wake in the 1 Hz wait.
|
||||
FRAME = Checkpoint("frame", NoticeRead.IF_UNDECIDED, reload=True)
|
||||
#: After a frame loop that ended early (display() returned False, a reload).
|
||||
AFTER_LOOP = Checkpoint("after-loop", NoticeRead.IF_UNDECIDED, reload=False)
|
||||
#: After a frame loop that ran its course: only a mode change or the schedule.
|
||||
AFTER_COMPLETED_LOOP = Checkpoint("after-completed-loop", NoticeRead.NEVER, reload=False)
|
||||
#: The last look before the rotation advances.
|
||||
FINAL = Checkpoint("final", NoticeRead.NEVER, reload=False)
|
||||
|
||||
|
||||
def after_dwell(time_left: bool) -> Checkpoint:
|
||||
"""After the make-up dwell: a notice that cut it short ends the screen,
|
||||
so the mode resumes after the notice instead of rotating past it."""
|
||||
return Checkpoint("after-dwell", NoticeRead.ALWAYS, reload=False,
|
||||
notice_counts=time_left)
|
||||
|
||||
|
||||
class FirstFrame(NamedTuple):
|
||||
"""What the first frame's dispatch returned (see _dispatch_first_frame)."""
|
||||
|
||||
shown: bool
|
||||
raised: bool
|
||||
accepts_display_mode: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class Screen:
|
||||
"""One running screen: its completed plan, the plugin drawing it and
|
||||
when it started. Mutable only in that the runner owns it."""
|
||||
|
||||
plan: ScreenPlan
|
||||
plugin: Any
|
||||
accepts_display_mode: bool
|
||||
start: float
|
||||
|
||||
@property
|
||||
def mode(self) -> Optional[str]:
|
||||
return self.plan.mode
|
||||
|
||||
|
||||
class ScreenHost(Protocol):
|
||||
"""The controller's side of a screen. See DisplayController."""
|
||||
|
||||
def first_frame(self, plan: ScreenPlan, plugin: Any) -> FirstFrame:
|
||||
"""Draw the first frame through the plugin executor."""
|
||||
|
||||
def complete_plan(self, plan: ScreenPlan, plugin: Any) -> Optional[ScreenPlan]:
|
||||
"""The plan with the plugin's durations, dynamic flag and frame
|
||||
policy, read after the first frame. None when an on-demand session
|
||||
has no time left for it."""
|
||||
|
||||
def draw(self, screen: Screen) -> Any:
|
||||
"""One later frame: what display() returned."""
|
||||
|
||||
def after_frame(self, screen: Screen) -> None:
|
||||
"""After a frame that did not end the screen (the follower frame)."""
|
||||
|
||||
def tick(self) -> None:
|
||||
"""Plugin updates that have come due (throttled)."""
|
||||
|
||||
def service(self, screen: Screen) -> Optional[Tuple[str, ...]]:
|
||||
"""Apply pending changes (on-demand requests, schedule, brightness,
|
||||
finished reloads). Returns the live modes when a live-priority scan
|
||||
was due, else None."""
|
||||
|
||||
def wait_frame(self, interval: float, screen: Screen) -> Optional[ScreenPlan]:
|
||||
"""The 1 Hz loop's sleep between frames. The plan that takes the
|
||||
panel when a control socket command ended the screen, else None."""
|
||||
|
||||
def check(self, screen: Screen, checkpoint: Checkpoint,
|
||||
live_scan: Optional[Tuple[str, ...]] = None) -> Optional[ScreenPlan]:
|
||||
"""The service point: the plan that now takes the panel from this
|
||||
screen, or None while it holds. One Arbiter.decide() call."""
|
||||
|
||||
def dwell(self, seconds: float) -> None:
|
||||
"""Sleep up to ``seconds``, servicing changes; returns early on one."""
|
||||
|
||||
def cycle_complete(self, screen: Screen) -> bool:
|
||||
"""The plugin's dynamic-duration cycle is complete."""
|
||||
|
||||
|
||||
class ScreenRunner:
|
||||
"""Runs one screen at a time for a ScreenHost. See the module docstring."""
|
||||
|
||||
def __init__(self, clock: FrameClock, host: ScreenHost,
|
||||
log: Optional[logging.Logger] = None):
|
||||
self.clock = clock
|
||||
self.host = host
|
||||
# The controller passes its own logger, so these lines keep the
|
||||
# source they always had in the journal.
|
||||
self.log = log or logging.getLogger(__name__)
|
||||
|
||||
# -- the screen ------------------------------------------------------
|
||||
|
||||
def run(self, plan: ScreenPlan, plugin: Any) -> Outcome:
|
||||
"""Run ``plan``'s screen, drawn by ``plugin`` (None: nothing draws it)."""
|
||||
if plugin is None:
|
||||
return Outcome(ExitReason.EMPTY)
|
||||
first = self.host.first_frame(plan, plugin)
|
||||
if not first.shown:
|
||||
return Outcome(ExitReason.ERROR if first.raised else ExitReason.EMPTY)
|
||||
completed = self.host.complete_plan(plan, plugin)
|
||||
if completed is None:
|
||||
return Outcome(ExitReason.PREEMPTED)
|
||||
screen = Screen(completed, plugin, first.accepts_display_mode,
|
||||
start=self.clock.time())
|
||||
|
||||
if completed.frame_policy is FramePolicy.HIGH_FPS:
|
||||
reason, by = self._high_fps_loop(screen)
|
||||
else:
|
||||
reason, by = self._static_loop(screen)
|
||||
if reason is ExitReason.PREEMPTED:
|
||||
# The service point that ended the loop has decided; looking
|
||||
# again now, at the same instant, gives the same answer.
|
||||
return self._outcome(screen, reason, by)
|
||||
|
||||
loop_completed = reason in (ExitReason.DURATION, ExitReason.CYCLE_COMPLETE)
|
||||
# LOAD-BEARING: a change the frame loop did not end on (a dwell
|
||||
# inside it, a later frame returning False) must not fall into the
|
||||
# make-up dwell below. It can run for the rest of the screen's
|
||||
# duration, and a freshly requested on-demand mode would sit
|
||||
# invisible for that long -- or be clobbered by a queued stop.
|
||||
by = self.host.check(screen, AFTER_COMPLETED_LOOP if loop_completed else AFTER_LOOP)
|
||||
if by is not None:
|
||||
return self._outcome(screen, ExitReason.PREEMPTED, by)
|
||||
|
||||
# Honour the minimum duration when a static, non-dynamic screen's
|
||||
# loop ended early. A screen cut short for a plugin reload is over:
|
||||
# the dwell returns at once and the rotation advances.
|
||||
if (not completed.dynamic and not loop_completed
|
||||
and completed.frame_policy is not FramePolicy.HIGH_FPS):
|
||||
elapsed = self.clock.time() - screen.start
|
||||
remaining = max(0.0, self._max(screen) - elapsed)
|
||||
if remaining > 0:
|
||||
self.host.dwell(remaining)
|
||||
time_left = self.clock.time() - screen.start < self._max(screen)
|
||||
by = self.host.check(screen, after_dwell(time_left))
|
||||
if by is not None:
|
||||
return self._outcome(screen, ExitReason.PREEMPTED, by)
|
||||
|
||||
if completed.dynamic:
|
||||
self._log_dynamic_end(screen)
|
||||
|
||||
# The dwells above return early when a pending change (on-demand
|
||||
# started or stopped, the panel scheduled off) has already decided
|
||||
# what comes next; rotating now would skip it.
|
||||
by = self.host.check(screen, FINAL)
|
||||
if by is not None:
|
||||
return self._outcome(screen, ExitReason.PREEMPTED, by)
|
||||
return self._outcome(screen, reason, None)
|
||||
|
||||
def _outcome(self, screen: Screen, reason: ExitReason,
|
||||
by: Optional[ScreenPlan]) -> Outcome:
|
||||
return Outcome(reason, self.clock.time() - screen.start, preempted_by=by)
|
||||
|
||||
@staticmethod
|
||||
def _max(screen: Screen) -> float:
|
||||
return float(screen.plan.max_duration or 0.0)
|
||||
|
||||
@staticmethod
|
||||
def _min(screen: Screen) -> float:
|
||||
return float(screen.plan.min_duration or 0.0)
|
||||
|
||||
@staticmethod
|
||||
def _ended_by(by: ScreenPlan) -> ExitReason:
|
||||
return ExitReason.RELOAD if by.source is Source.RELOAD else ExitReason.PREEMPTED
|
||||
|
||||
# -- the frame loops ---------------------------------------------------
|
||||
|
||||
def _high_fps_loop(self, screen: Screen) -> Tuple[ExitReason, Optional[ScreenPlan]]:
|
||||
"""Ultra-smooth frames for scrolling plugins (8 ms = 125 FPS)."""
|
||||
clock, host, log = self.clock, self.host, self.log
|
||||
interval = HIGH_FPS_INTERVAL
|
||||
log.debug("Entering high-FPS loop for %s with display_interval=%.3fs (%.1f FPS)",
|
||||
screen.mode, interval, 1.0 / interval)
|
||||
target = self._max(screen)
|
||||
while True:
|
||||
frame_start = clock.perf_counter()
|
||||
try:
|
||||
result = host.draw(screen)
|
||||
if isinstance(result, bool) and not result:
|
||||
log.debug("Display returned False, breaking early")
|
||||
return ExitReason.DISPLAY_FALSE, None
|
||||
except Exception: # pylint: disable=broad-except
|
||||
log.exception("Error during display update")
|
||||
|
||||
# Multi-display sync: send follower frame after each render
|
||||
host.after_frame(screen)
|
||||
host.tick()
|
||||
# Throttled: one clock compare between passes. A live-priority
|
||||
# scan, when one is due, happens here, before the sleep, as it
|
||||
# always has; the Arbiter weighs it after the sleep.
|
||||
live_scan = host.service(screen)
|
||||
|
||||
# Pace to the frame deadline rather than sleeping a flat
|
||||
# interval on top of the work. display() has already blocked on
|
||||
# the panel's vsync by this point, so an unconditional sleep is
|
||||
# added to a wait that already happened. Measured on a 2x128x64
|
||||
# chain at limit_refresh_rate_hz=100: ~4ms of render plus a flat
|
||||
# 8ms put each iteration at ~12ms against a 10ms refresh grid, so
|
||||
# every swap missed a refresh and the loop settled at 50fps where
|
||||
# display_interval asks for 125 -- and with zero headroom, ~14% of
|
||||
# frames slipped a further refresh, which is what reads as scroll
|
||||
# stutter.
|
||||
remaining = interval - (clock.perf_counter() - frame_start)
|
||||
# Yield even when the frame overran its budget, so plugin update
|
||||
# threads and the web UI are not starved of the GIL.
|
||||
clock.sleep(remaining if remaining > 0 else 0.001)
|
||||
|
||||
by = host.check(screen, FRAME, live_scan)
|
||||
if by is not None:
|
||||
log.debug("Mode changed during high-FPS loop, breaking early")
|
||||
return self._ended_by(by), by
|
||||
|
||||
elapsed = clock.time() - screen.start
|
||||
if elapsed >= target:
|
||||
log.debug("Reached high-FPS target duration %.2fs for mode %s",
|
||||
target, screen.mode)
|
||||
return ExitReason.DURATION, None
|
||||
if self._should_exit_dynamic(screen, elapsed):
|
||||
log.debug("Dynamic duration cycle complete for %s after %.2fs",
|
||||
screen.mode, elapsed)
|
||||
return ExitReason.CYCLE_COMPLETE, None
|
||||
|
||||
def _static_loop(self, screen: Screen) -> Tuple[ExitReason, Optional[ScreenPlan]]:
|
||||
"""One frame a second for everything else."""
|
||||
clock, host, log = self.clock, self.host, self.log
|
||||
interval = STATIC_INTERVAL
|
||||
log.debug("Entering normal FPS loop for %s with display_interval=%.3fs",
|
||||
screen.mode, interval)
|
||||
target = self._max(screen)
|
||||
dynamic = screen.plan.dynamic
|
||||
while True:
|
||||
# Wakes for a control socket command and applies it at once,
|
||||
# instead of up to a second later.
|
||||
by = host.wait_frame(interval, screen)
|
||||
if by is not None:
|
||||
log.info("Mode changed during display loop from %s to %s (%s), "
|
||||
"breaking early", screen.mode, by.mode, by.source.value)
|
||||
return self._ended_by(by), by
|
||||
host.tick()
|
||||
|
||||
elapsed = clock.time() - screen.start
|
||||
if elapsed >= target:
|
||||
log.debug("Reached standard target duration %.2fs for mode %s",
|
||||
target, screen.mode)
|
||||
return ExitReason.DURATION, None
|
||||
|
||||
try:
|
||||
result = host.draw(screen)
|
||||
if isinstance(result, bool) and not result:
|
||||
# A dynamic-duration screen doesn't end on False: it
|
||||
# keeps looping until its cycle completes or its maximum.
|
||||
if not dynamic:
|
||||
log.info("Display returned False for %s (no dynamic duration), "
|
||||
"breaking early", screen.mode)
|
||||
return ExitReason.DISPLAY_FALSE, None
|
||||
log.debug("Display returned False for %s (dynamic duration enabled), "
|
||||
"continuing loop", screen.mode)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
log.exception("Error during display update")
|
||||
|
||||
# Multi-display sync: send follower frame after each render
|
||||
host.after_frame(screen)
|
||||
|
||||
live_scan = host.service(screen)
|
||||
by = host.check(screen, FRAME, live_scan)
|
||||
if by is not None:
|
||||
log.info("Mode changed during display loop from %s to %s (%s), "
|
||||
"breaking early", screen.mode, by.mode, by.source.value)
|
||||
return self._ended_by(by), by
|
||||
|
||||
if self._should_exit_dynamic(screen, elapsed):
|
||||
log.info("Dynamic duration cycle complete for %s after %.2fs",
|
||||
screen.mode, elapsed)
|
||||
return ExitReason.CYCLE_COMPLETE, None
|
||||
|
||||
# -- dynamic duration --------------------------------------------------
|
||||
|
||||
def _should_exit_dynamic(self, screen: Screen, elapsed: float) -> bool:
|
||||
if not screen.plan.dynamic:
|
||||
return False
|
||||
minimum = self._min(screen)
|
||||
# A small grace period after min_duration prevents premature exits
|
||||
# due to timing issues.
|
||||
if elapsed < minimum + DYNAMIC_GRACE:
|
||||
self.log.debug(
|
||||
"_should_exit_dynamic: elapsed %.2fs < min_duration %.2fs + grace %.2fs, "
|
||||
"returning False", elapsed, minimum, DYNAMIC_GRACE)
|
||||
return False
|
||||
cycle_complete = self.host.cycle_complete(screen)
|
||||
self.log.debug(
|
||||
"_should_exit_dynamic: elapsed %.2fs >= min %.2fs, cycle_complete=%s, returning %s",
|
||||
elapsed, minimum + DYNAMIC_GRACE, cycle_complete, cycle_complete)
|
||||
if cycle_complete:
|
||||
self.log.debug("Cycle complete detected for %s after %.2fs (min: %.2fs, grace: %.2fs)",
|
||||
screen.mode, elapsed, minimum, DYNAMIC_GRACE)
|
||||
return cycle_complete
|
||||
|
||||
def _log_dynamic_end(self, screen: Screen) -> None:
|
||||
"""How a dynamic-duration screen ended, for the log. Asks the plugin
|
||||
once more whether its cycle is complete, as the loop always did."""
|
||||
elapsed_total = self.clock.time() - screen.start
|
||||
cycle_done = self.host.cycle_complete(screen)
|
||||
minimum, maximum = self._min(screen), self._max(screen)
|
||||
if cycle_done:
|
||||
self.log.info(
|
||||
"Dynamic duration cycle completed for %s after %.2fs "
|
||||
"(target: %.2fs, min: %.2fs, max: %.2fs)",
|
||||
screen.mode, elapsed_total, maximum, minimum, maximum)
|
||||
elif elapsed_total >= maximum:
|
||||
self.log.info(
|
||||
"Dynamic duration cap reached before cycle completion for %s "
|
||||
"(%.2fs/%ds, min: %.2fs)",
|
||||
screen.mode, elapsed_total, int(maximum), minimum)
|
||||
else:
|
||||
self.log.debug(
|
||||
"Dynamic duration cycle in progress for %s: %.2fs elapsed "
|
||||
"(target: %.2fs, min: %.2fs, max: %.2fs)",
|
||||
screen.mode, elapsed_total, maximum, minimum, maximum)
|
||||
@@ -143,12 +143,17 @@ 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 delete(self, key):
|
||||
self.data.pop(key, None)
|
||||
@@ -717,10 +722,23 @@ class RunLoopHarness:
|
||||
self.controller.available_modes.append(mode)
|
||||
|
||||
def on_demand_request(self, t: float, request_id: str, action: str = "start", **fields):
|
||||
"""An on-demand start or stop from the web interface at ``t``: a
|
||||
command on the control socket (served for the run if no test did),
|
||||
which is the only way the web interface reaches the display."""
|
||||
from src.ipc.contract import Command, parse_args
|
||||
from src.ipc.server import QueuedCommand
|
||||
|
||||
server = self.controller._control_server
|
||||
if not isinstance(server, FakeControlServer):
|
||||
server = self.control_socket()
|
||||
cmd = Command.ON_DEMAND_START if action == "start" else Command.ON_DEMAND_STOP
|
||||
command = QueuedCommand(request_id=request_id, cmd=cmd,
|
||||
args=parse_args(cmd, fields if action == "start" else {}),
|
||||
received_at=0.0)
|
||||
|
||||
def post():
|
||||
self.log("request", f"{action}:{request_id}")
|
||||
self.cache.set("display_on_demand_request",
|
||||
{"request_id": request_id, "action": action, **fields})
|
||||
server.queue.append(command)
|
||||
self.clock.at(t, post)
|
||||
|
||||
def restore_on_demand(self, plugin_id: str, mode: Optional[str] = None,
|
||||
|
||||
@@ -328,6 +328,20 @@ def _hermetic_control_socket(monkeypatch):
|
||||
monkeypatch.setenv(SOCKET_PATH_ENV, 'off')
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_pending_on_demand_dispatch():
|
||||
"""Drop the web process's on-demand dispatcher after each test, so a
|
||||
start one test left pending is not still being sent in the next."""
|
||||
yield
|
||||
module = sys.modules.get('web_interface.on_demand_dispatch')
|
||||
if module is None:
|
||||
return
|
||||
dispatcher = module.current()
|
||||
if dispatcher is not None:
|
||||
dispatcher.cancel('test-teardown')
|
||||
module.reset_for_tests()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _hermetic_unit_refresh(monkeypatch, tmp_path_factory):
|
||||
"""Keep updates' systemd unit refresh off the host.
|
||||
|
||||
Vendored
-9
@@ -175,15 +175,6 @@
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/config/refresh-rate",
|
||||
"api_v3.get_refresh_rate",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/config/schedule",
|
||||
"api_v3.get_schedule_config",
|
||||
|
||||
+3
-3
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"screens": [
|
||||
[0.0, "clock", 20.0, "duration", 20, false],
|
||||
[20.0, "weather", 5.0, "on-demand-start", 6, true],
|
||||
[20.0, "weather", 5.0, "on-demand-start", 5, true],
|
||||
[25.0, "sports_recent", 15.0, "duration", 15, true],
|
||||
[40.0, "sports_upcoming", 15.0, "duration", 15, true],
|
||||
[55.0, "sports_recent", 15.0, "duration", 15, true],
|
||||
[70.0, "sports_upcoming", 15.0, "duration", 15, true],
|
||||
[85.0, "sports_recent", 10.0, "on-demand-requested-stop", 11, true],
|
||||
[85.0, "sports_recent", 10.0, "on-demand-requested-stop", 10, true],
|
||||
[95.0, "weather", 20.0, "duration", 20, true],
|
||||
[115.0, "sports_recent", 15.0, "duration", 15, true],
|
||||
[130.0, "sports_upcoming", 15.0, "duration", 15, true],
|
||||
[145.0, "clock", 5.0, "on-demand-start", 6, true],
|
||||
[145.0, "clock", 5.0, "on-demand-start", 5, true],
|
||||
[150.0, "weather", 20.0, "duration", 20, true],
|
||||
[170.0, "weather", 10.0, "on-demand-expired", 10, true],
|
||||
[180.0, "clock", 20.0, "duration", 20, true],
|
||||
|
||||
+4
-4
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"screens": [
|
||||
[0.0, "clock", 5.0, "on-demand-start", 6, false],
|
||||
[0.0, "clock", 5.0, "on-demand-start", 5, 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],
|
||||
[35.0, "sports_upcoming", 5.0, "on-demand-requested-stop", 5, 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],
|
||||
[90.0, "sports_upcoming", 10.0, "on-demand-start", 10, 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],
|
||||
[130.0, "sports_recent", 10.0, "on-demand-requested-stop", 10, true],
|
||||
[140.0, "sports_upcoming", 15.0, "duration", 15, true],
|
||||
[155.0, "clock", 5.0, "horizon", 5, true]
|
||||
],
|
||||
|
||||
+2
-2
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"screens": [
|
||||
[0.0, "clock", 12.0, "on-demand-start", 13, false],
|
||||
[0.0, "clock", 12.0, "on-demand-start", 12, false],
|
||||
[12.0, "sports_upcoming", 15.0, "duration", 15, true],
|
||||
[27.0, "sports_upcoming", 15.0, "duration", 15, true],
|
||||
[42.0, "sports_upcoming", 15.0, "duration", 15, true],
|
||||
[57.0, "sports_upcoming", 15.0, "duration", 15, true],
|
||||
[72.0, "sports_upcoming", 8.0, "on-demand-start", 9, true],
|
||||
[72.0, "sports_upcoming", 8.0, "on-demand-start", 8, true],
|
||||
[80.0, "app_a", 0.0, "empty", 1, true],
|
||||
[80.0, "app_b", 10.0, "duration", 10, true],
|
||||
[90.0, "app_a", 0.0, "empty", 1, true],
|
||||
|
||||
+11
-11
@@ -6,22 +6,22 @@
|
||||
[70.255, "sports_live", 20.0, "duration", 20, true],
|
||||
[90.255, "sports_live", 20.0, "display-false", 11, false],
|
||||
[110.255, "<vegas>", 30.008, "duration", 3751, null],
|
||||
[140.263, "<vegas>", 10.0, "on-demand-start", 1250, null],
|
||||
[150.263, "clock", 20.0, "duration", 20, true],
|
||||
[170.263, "clock", 5.0, "on-demand-expired", 5, true],
|
||||
[175.263, "<vegas>", 24.959, "vegas-interrupt", 3120, null],
|
||||
[200.222, "<wifi>", 3.0, "duration", 6, null],
|
||||
[203.222, "<vegas>", 30.008, "duration", 3751, null],
|
||||
[233.23, "<vegas>", 26.77, "horizon", 3347, null]
|
||||
[140.263, "<vegas>", 9.744, "on-demand-start", 1218, null],
|
||||
[150.007, "clock", 20.0, "duration", 20, true],
|
||||
[170.007, "clock", 5.0, "on-demand-expired", 5, true],
|
||||
[175.007, "<vegas>", 25.999, "vegas-interrupt", 3250, null],
|
||||
[201.006, "<wifi>", 2.0, "duration", 4, null],
|
||||
[203.006, "<vegas>", 30.008, "duration", 3751, null],
|
||||
[233.014, "<vegas>", 26.986, "horizon", 3374, null]
|
||||
],
|
||||
"events": [
|
||||
[70.255, "vegas-live"],
|
||||
[70.255, "live", "sports_live"],
|
||||
[150.0, "request", "start:v1"],
|
||||
[150.263, "on-demand-start", "clock"],
|
||||
[150.263, "vegas-interrupt"],
|
||||
[175.263, "on-demand-expired"],
|
||||
[150.007, "on-demand-start", "clock"],
|
||||
[150.007, "vegas-interrupt"],
|
||||
[175.007, "on-demand-expired"],
|
||||
[200.0, "wifi-file", "Connected to HomeNet"],
|
||||
[200.222, "vegas-interrupt"]
|
||||
[201.006, "vegas-interrupt"]
|
||||
]
|
||||
}
|
||||
|
||||
+6
-2
@@ -59,8 +59,8 @@ server has none.
|
||||
| `unit/test_page_registry.js` | no | The page lifecycle in `js/core/registry.js` (a minimal DOM shim): one `init` per `data-page` root, `destroy` and an aborted `ctx.signal` when htmx swaps it away, a vetoed swap keeps it, lazy page modules, a root removed without htmx swept on the next swap |
|
||||
| `unit/test_core_modules.js` | no | `js/core/api.js` (JSON envelope, HTTP/`status: error`/network errors, abort passthrough, the #683 login redirect, same-server paths only) and `js/core/facade.js` (`window.LEDMatrix`, deprecated aliases) |
|
||||
| `unit/test_overview_reconciliation_poll.js` | no | The Overview's reconciliation-banner poll from `partials/overview.html`, run in a vm: it gives up after a bounded number of requests when the status never says done, runs only while the Overview is on screen (`LEDVisibility`, its own key), and stops once the banner is shown |
|
||||
| `unit/test_display_partial_ids.js` | no | `partials/display.html`: every literal `getElementById()` in its inline scripts names an id the partial renders, and moving the brightness slider (the shipped script, in a vm with a fake DOM) updates its label without throwing |
|
||||
| `unit/test_general_web_login_token.js` | no | `window.webLogin.createToken` from `partials/general.html`, run in a vm: a created API token clears the form's `data-dirty` mark (so a reload does not ask "Leave site?"), a refused one keeps it |
|
||||
| `unit/test_display_partial_ids.js` | no | `js/pages/display.js` started on a fake root that answers only for the ids `partials/display.html` renders: every id it looks up (with every listener and timer it set fired) exists, and moving the brightness slider updates its label without throwing |
|
||||
| `unit/test_general_web_login_token.js` | no | `createToken` from `js/pages/general.js`, imported with a fake DOM and fetch: a created API token clears the form's `data-dirty` mark (so a reload does not ask "Leave site?"), a refused one keeps it |
|
||||
| `unit/test_plugin_action_delegation.js` | no | The document-level card-action delegation and `handlePluginAction` from `plugins_manager.js`, run with the handler inside an IIFE as in the real file: each action is handled once, a Starlark app uninstall goes to `DELETE /starlark/apps/<id>`, and an uninstall is confirmed once |
|
||||
| `dom/test_installed_dom.js` | yes | The toolbar in a real DOM: pill/search/sort interaction, the HTMX partial re-swap, and a `getComputedStyle` check that `.filter-pill[data-active]` really matches the emitted markup |
|
||||
| `dom/test_store_dom.js` | yes | Store pagination, per-page, category, tri-state Installed button, and persistence across a re-boot, against the live registry |
|
||||
@@ -69,6 +69,10 @@ server has none.
|
||||
| `dom/test_durations_page.js` | yes | The Rotation tab (`js/pages/durations.js`) with the real `plugin-order-list.js` widget: one plugin-list request per swap, one move per click after repeated swaps, a swap cancels the request in flight, a late widget is waited for |
|
||||
| `dom/test_operation_history_page.js` | yes | The Operation History tab (`js/pages/operation-history.js`): one request per swap and per Refresh, the plugin filter filled once, paging, filters, search, Clear, error/login states, hostile values stay text |
|
||||
| `dom/test_raw_json_page.js` | yes | The Config Editor tab (`js/pages/raw-json.js`): one POST per Save after repeated swaps, Format/Validate, invalid JSON never sent, a save survives a swap, the old global entry points |
|
||||
| `dom/test_schedule_page.js` | yes | The Schedule tab (`js/pages/schedule.js`) with the real `schedule-picker` widget: both pickers drawn once per swap from the saved config, one notification per save answer after repeated swaps, the brightness label, a late widget waited for, the old global entry points |
|
||||
| `dom/test_visibility_service.js` | yes (no server) | `js/core/visibility.js` with the real `LEDVisibility` from `app-shell.js` and the real registry: start/stop with the active tab and the browser tab's visibility, no interval while hidden or after a swap-out, registrations independent, the no-`LEDVisibility` fallback |
|
||||
| `dom/test_display_page.js` | yes | The Display tab (`js/pages/display.js`) with the real `plugin-order-list` widget and `LEDVisibility`: one page, one sync interval and one action per control after repeated swaps, the sync poll only while on screen and never after a swap-out, sync states as text, the debounced scroll-speed hint, `updateSyncUI`'s entry point |
|
||||
| `dom/test_general_page.js` | yes | The General tab (`js/pages/general.js`) with the real `timezone-selector` widget: the picker drawn once per swap, one request per Security action after repeated swaps, hostile token names stay text, refused/network/login answers, a write survives a swap, `webLogin`'s entry points |
|
||||
| `dom/test_backup_restore_page.js` | yes | The Backup & Restore tab (`js/pages/backup-restore.js`): one request per action after repeated swaps, the upload and restore options, reads cancelled and writes not on a swap, hostile names stay text, the old global entry points |
|
||||
| `dom/test_tools_sections.js` | yes | The Tools tab's MQTT bridge and Pixlet editor sections: form prefill, the write-only password (blank means unchanged), the running-session banner and countdown, and that the editor link points at the host you loaded the page from |
|
||||
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
// The Display tab as a page module (static/v3/js/pages/display.js), in a
|
||||
// real DOM (jsdom) with the real server-rendered partial, the real
|
||||
// plugin-order-list widget, the real window.LEDVisibility (app-shell.js)
|
||||
// behind ctx.visibility, and the real API's answer shapes. Built like
|
||||
// test_cache_page.js:
|
||||
//
|
||||
// * the partial ships no <script> and no inline handlers; its root is
|
||||
// data-page="display"
|
||||
// * after five swaps: one mounted page, the Vegas order drawn once, each
|
||||
// control acting once (brightness, resolution, Vegas and double-sided
|
||||
// toggles, the Advanced section toggle, one debounced scroll-speed hint
|
||||
// request)
|
||||
// * the sync status is polled only while the Display tab is on screen and
|
||||
// the browser tab visible: no interval runs while hidden, and none after
|
||||
// the partial is swapped out
|
||||
// * sync states drawn as text; a failed poll says "unavailable", a login
|
||||
// redirect draws nothing
|
||||
// * a widget that loads late is waited for, and a page swapped away while
|
||||
// waiting starts nothing
|
||||
// * window.updateSyncUI's entry point still works
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { pathToFileURL } = require('url');
|
||||
const { JSDOM, VirtualConsole } = require('jsdom');
|
||||
|
||||
const BASE = process.env.BASE || 'http://localhost:5000';
|
||||
const JS = path.resolve(__dirname, '../../../web_interface/static/v3/js');
|
||||
const get = p => new Promise((res, rej) =>
|
||||
http.get(BASE + p, r => { let d = ''; r.on('data', c => d += c); r.on('end', () => res(d)); }).on('error', rej));
|
||||
const load = f => import(pathToFileURL(path.join(JS, f)).href);
|
||||
const tick = ms => new Promise(r => setTimeout(r, ms || 0));
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
const ok = (l, c, x) => c ? (pass++, console.log(' ok ' + l))
|
||||
: (fail++, console.log(' FAIL ' + l + (x !== undefined ? ' -> ' + JSON.stringify(x).slice(0, 300) : '')));
|
||||
|
||||
(async () => {
|
||||
const partial = await get('/partials/display');
|
||||
const realSync = JSON.parse(await get('/api/v3/sync/status'));
|
||||
const smooth = JSON.parse(await get('/api/v3/config/scroll-speed-advice?speed=50&min=1&max=200'));
|
||||
const rough = JSON.parse(await get('/api/v3/config/scroll-speed-advice?speed=37&min=1&max=200'));
|
||||
const { createRegistry } = await load('core/registry.js');
|
||||
const { createApi } = await load('core/api.js');
|
||||
const { createVisibility } = await load('core/visibility.js');
|
||||
const displayPage = await load('pages/display.js');
|
||||
|
||||
console.log('\n── Display tab: page module (real DOM) ──');
|
||||
ok('the partial ships no inline script', !/<script/i.test(partial));
|
||||
ok('the partial has no inline handlers', !/\son(click|change|input)=/i.test(partial));
|
||||
ok('the partial root is data-page="display"', /data-page="display"/.test(partial));
|
||||
ok('the Advanced toggle names its action',
|
||||
/data-action="toggle-section"\s+data-section="display-section-advanced-hardware"/.test(partial));
|
||||
ok('the real sync status answers in the shape the page reads',
|
||||
realSync.status === 'success' && realSync.data && typeof realSync.data.state === 'string', realSync);
|
||||
ok('the real scroll-speed advice answers in the shape the page reads',
|
||||
smooth.status === 'success' && smooth.data.applied && Array.isArray(rough.data.alternatives)
|
||||
&& rough.data.alternatives.length > 0, rough);
|
||||
|
||||
const errs = [];
|
||||
const logged = [];
|
||||
const vc = new VirtualConsole();
|
||||
vc.on('jsdomError', e => errs.push(String(e.message || e).split('\n')[0]));
|
||||
vc.on('error', (...a) => logged.push(a.join(' ')));
|
||||
const dom = new JSDOM(`<!doctype html><html><body><div id="display-content">${partial}</div></body></html>`,
|
||||
{ url: BASE + '/', virtualConsole: vc, runScripts: 'outside-only' });
|
||||
const { window } = dom;
|
||||
const doc = window.document;
|
||||
const panel = doc.getElementById('display-content');
|
||||
|
||||
// The browser tab's visibility and the app's active tab, under test control.
|
||||
let hidden = false;
|
||||
Object.defineProperty(doc, 'hidden', { get: () => hidden, configurable: true });
|
||||
const setHidden = v => { hidden = v; doc.dispatchEvent(new window.Event('visibilitychange')); };
|
||||
const setTab = tab => doc.dispatchEvent(new window.CustomEvent('ledmatrix:tab-changed', { detail: { tab } }));
|
||||
// Intervals, counted. Timeouts (the hint's debounce, the widget retry) are real.
|
||||
const intervals = new Map();
|
||||
let nextInterval = 1;
|
||||
window.setInterval = (fn, ms) => { const id = nextInterval++; intervals.set(id, { fn, ms }); return id; };
|
||||
window.clearInterval = id => { intervals.delete(id); };
|
||||
const fireIntervals = () => [...intervals.values()].forEach(i => i.fn());
|
||||
|
||||
const HOSTILE = '<img src=x onerror="window.pwned=1">';
|
||||
const plugins = [
|
||||
{ id: 'clock', name: 'Clock', enabled: true },
|
||||
{ id: 'weather', name: HOSTILE, enabled: true },
|
||||
];
|
||||
let syncAnswer = { status: 'success', data: { role: 'leader', state: 'no_peer' } };
|
||||
let syncMode = 'ok';
|
||||
let advice = smooth;
|
||||
const requests = [];
|
||||
function fakeFetch(url, init = {}) {
|
||||
requests.push(url);
|
||||
const respond = (status, body, headers) => Promise.resolve({
|
||||
status, ok: status >= 200 && status < 300,
|
||||
headers: { get: h => (headers || {})[h] || null },
|
||||
json: () => Promise.resolve(body),
|
||||
text: () => Promise.resolve(JSON.stringify(body)),
|
||||
});
|
||||
if (url === '/api/v3/plugins/installed') return respond(200, { status: 'success', data: { plugins } });
|
||||
if (url.startsWith('/api/v3/config/scroll-speed-advice?')) return respond(200, advice);
|
||||
if (url === '/api/v3/sync/status') {
|
||||
if (syncMode === 'network') return Promise.reject(new TypeError('Failed to fetch'));
|
||||
if (syncMode === 'login') return respond(401, { status: 'error' }, { 'X-LEDMatrix-Login': '/login' });
|
||||
return respond(200, syncAnswer);
|
||||
}
|
||||
return respond(404, { status: 'error', message: 'unexpected ' + url });
|
||||
}
|
||||
window.fetch = fakeFetch;
|
||||
|
||||
// The shell: LEDVisibility (no Alpine here, so the active tab is the last
|
||||
// ledmatrix:tab-changed; the SSE streams open stand-in EventSources), and
|
||||
// the shared toggleSection the Advanced button calls.
|
||||
window.getApp = () => null;
|
||||
window.EventSource = class { addEventListener() {} removeEventListener() {} close() {} };
|
||||
window.eval(fs.readFileSync(path.join(JS, 'app-shell.js'), 'utf8'));
|
||||
const toggled = [];
|
||||
window.toggleSection = id => toggled.push(id);
|
||||
window.eval(fs.readFileSync(path.join(JS, 'widgets/plugin-order-list.js'), 'utf8'));
|
||||
const widget = window.PluginOrderList;
|
||||
ok('the widget script defines PluginOrderList', !!(widget && widget.init));
|
||||
|
||||
const visibility = createVisibility({ window });
|
||||
const registry = createRegistry({
|
||||
document: doc,
|
||||
context: { api: createApi({ fetch: fakeFetch }), notify: () => {} },
|
||||
mountContext: ctx => ({ visibility: visibility.forPage(ctx) }),
|
||||
});
|
||||
registry.register('display', displayPage);
|
||||
|
||||
const $ = id => doc.getElementById(id);
|
||||
const root = () => doc.querySelector('[data-page="display"]');
|
||||
const count = prefix => requests.filter(u => u.startsWith(prefix)).length;
|
||||
const syncPolls = () => count('/api/v3/sync/status');
|
||||
async function swap(html) {
|
||||
panel.dispatchEvent(new window.CustomEvent('htmx:beforeSwap', { bubbles: true, detail: { target: panel, shouldSwap: true } }));
|
||||
panel.innerHTML = html === undefined ? partial : html;
|
||||
panel.dispatchEvent(new window.CustomEvent('htmx:afterSwap', { bubbles: true, detail: { target: panel } }));
|
||||
await tick(20);
|
||||
}
|
||||
function fire(el, type) { el.dispatchEvent(new window.Event(type, { bubbles: true })); }
|
||||
function setRole(role) { $('sync_role').value = role; fire($('sync_role'), 'change'); }
|
||||
|
||||
setTab('display');
|
||||
await registry.start();
|
||||
await tick(200);
|
||||
|
||||
// ── first load ──────────────────────────────────────────────────────────
|
||||
ok('one plugin-list request on start', count('/api/v3/plugins/installed') === 1, requests);
|
||||
ok('one scroll-speed hint request on start (after the debounce)', count('/api/v3/config/scroll-speed-advice') === 1, requests);
|
||||
ok('the saved role is standalone: no sync request, no interval work',
|
||||
$('sync_role').value === 'standalone' && syncPolls() === 0, [$('sync_role').value, syncPolls()]);
|
||||
ok('the sync poll interval runs while the tab is on screen', intervals.size === 1
|
||||
&& [...intervals.values()][0].ms === 5000, intervals.size);
|
||||
ok('the status bar is hidden for standalone', $('sync_status_bar').classList.contains('hidden'));
|
||||
|
||||
// ── five swaps ──────────────────────────────────────────────────────────
|
||||
for (let i = 0; i < 5; i++) await swap();
|
||||
await tick(200);
|
||||
ok('one mounted page after five swaps', registry.list().length === 1, registry.list().length);
|
||||
ok('one sync interval, not six', intervals.size === 1, intervals.size);
|
||||
ok('one plugin-list request per swap', count('/api/v3/plugins/installed') === 6, count('/api/v3/plugins/installed'));
|
||||
ok('the Vegas order drawn once, not stacked', doc.querySelectorAll('#vegas_plugin_order .plugin-order-item').length === 2,
|
||||
doc.querySelectorAll('#vegas_plugin_order .plugin-order-item').length);
|
||||
ok('a hostile plugin name is shown as text', $('vegas_plugin_order').textContent.includes(HOSTILE)
|
||||
&& !doc.querySelector('#vegas_plugin_order img') && !window.pwned);
|
||||
|
||||
// ── the controls ────────────────────────────────────────────────────────
|
||||
$('brightness').value = '42';
|
||||
fire($('brightness'), 'input');
|
||||
ok('the brightness value follows the slider', $('brightness-value').textContent === '42', $('brightness-value').textContent);
|
||||
|
||||
$('rows').value = '32'; $('cols').value = '64'; $('chain_length').value = '3'; $('parallel').value = '2';
|
||||
fire($('parallel'), 'input');
|
||||
ok('the resolution readout is cols x chain by rows x parallel',
|
||||
$('display-resolution-value').textContent === '192 × 64 pixels', $('display-resolution-value').textContent);
|
||||
$('orientation').value = '90';
|
||||
fire($('orientation'), 'change');
|
||||
ok('...swapped for a 90-degree orientation', $('display-resolution-value').textContent === '64 × 192 pixels',
|
||||
$('display-resolution-value').textContent);
|
||||
$('rows').value = '';
|
||||
fire($('rows'), 'input');
|
||||
ok('...and a dash while a field is empty', $('display-resolution-value').textContent === '—');
|
||||
|
||||
for (const [box, settings, shown] of [['vegas_scroll_enabled', 'vegas_scroll_settings', 'block'],
|
||||
['double_sided_enabled', 'double_sided_settings', 'grid']]) {
|
||||
$(box).checked = true; fire($(box), 'change');
|
||||
const on = $(settings).style.display;
|
||||
$(box).checked = false; fire($(box), 'change');
|
||||
ok(`${box} shows and hides its settings`, on === shown && $(settings).style.display === 'none',
|
||||
[on, $(settings).style.display]);
|
||||
}
|
||||
|
||||
root().querySelector('[data-action="toggle-section"]').click();
|
||||
ok('the Advanced button toggles its section once', toggled.join() === 'display-section-advanced-hardware', toggled);
|
||||
|
||||
// ── the scroll-speed hint ───────────────────────────────────────────────
|
||||
const hints = count('/api/v3/config/scroll-speed-advice');
|
||||
advice = rough;
|
||||
for (const v of ['36', '37', '38']) { $('vegas_scroll_speed').value = v; fire($('vegas_scroll_speed'), 'input'); }
|
||||
ok('the speed value follows the slider', $('vegas_scroll_speed_value').textContent === '38');
|
||||
await tick(250);
|
||||
ok('three quick moves make one hint request', count('/api/v3/config/scroll-speed-advice') === hints + 1,
|
||||
count('/api/v3/config/scroll-speed-advice') - hints);
|
||||
ok('...for the last speed', requests.filter(u => u.includes('advice')).pop().includes('speed=38'));
|
||||
const buttons = $('vegas_scroll_speed_hint').querySelectorAll('button');
|
||||
ok('a rough speed offers the smooth ones', buttons.length === rough.data.alternatives.length
|
||||
&& /will run as/.test($('vegas_scroll_speed_hint').textContent), $('vegas_scroll_speed_hint').textContent);
|
||||
buttons[0].click();
|
||||
ok('picking one sets the slider', $('vegas_scroll_speed').value === String(Math.round(rough.data.alternatives[0].pixels_per_second))
|
||||
&& $('vegas_scroll_speed_value').textContent === $('vegas_scroll_speed').value, $('vegas_scroll_speed').value);
|
||||
advice = smooth;
|
||||
await tick(250);
|
||||
ok('...and asks again', count('/api/v3/config/scroll-speed-advice') === hints + 2);
|
||||
ok('a smooth speed says so', /^Smooth on this panel/.test($('vegas_scroll_speed_hint').textContent),
|
||||
$('vegas_scroll_speed_hint').textContent);
|
||||
|
||||
// ── sync: the role ──────────────────────────────────────────────────────
|
||||
setRole('leader');
|
||||
await tick(20);
|
||||
ok('choosing Leader shows the status bar', !$('sync_status_bar').classList.contains('hidden'));
|
||||
ok('...hides Position', $('setting-display-sync_follower_position').style.display === 'none');
|
||||
ok('...and asks for the status once', syncPolls() === 1, syncPolls());
|
||||
ok('...drawn as text', $('sync_status_content').textContent.includes('No follower detected'),
|
||||
$('sync_status_content').textContent);
|
||||
setRole('follower');
|
||||
await tick(20);
|
||||
ok('choosing Follower shows Position', $('setting-display-sync_follower_position').style.display === '');
|
||||
|
||||
// ── sync: the poll runs only while on screen ────────────────────────────
|
||||
let polls = syncPolls();
|
||||
fireIntervals();
|
||||
await tick(20);
|
||||
ok('each interval tick polls once', syncPolls() === polls + 1, syncPolls() - polls);
|
||||
setTab('logs');
|
||||
ok('switching to another tab clears the interval', intervals.size === 0, intervals.size);
|
||||
polls = syncPolls();
|
||||
setTab('display');
|
||||
await tick(20);
|
||||
ok('switching back polls at once', syncPolls() === polls + 1 && intervals.size === 1, [syncPolls() - polls, intervals.size]);
|
||||
setHidden(true);
|
||||
ok('hiding the browser tab clears the interval', intervals.size === 0, intervals.size);
|
||||
polls = syncPolls();
|
||||
setHidden(false);
|
||||
await tick(20);
|
||||
ok('showing it polls at once', syncPolls() === polls + 1 && intervals.size === 1, [syncPolls() - polls, intervals.size]);
|
||||
|
||||
// ── sync: states ────────────────────────────────────────────────────────
|
||||
async function poll() { fireIntervals(); await tick(20); return $('sync_status_content').textContent; }
|
||||
syncAnswer = { status: 'success', data: { role: 'leader', state: 'connected', peer_ip: HOSTILE, peer_chain: 2 } };
|
||||
ok('a connected follower, its address as text', (await poll()).includes('Follower connected — ' + HOSTILE)
|
||||
&& !$('sync_status_content').querySelector('img'), $('sync_status_content').textContent);
|
||||
syncAnswer = { status: 'success', data: { role: 'leader', state: 'incompatible', error: 'rows differ ' + HOSTILE } };
|
||||
ok('incompatible panels show the reason as text', (await poll()).includes('incompatible')
|
||||
&& !$('sync_error_detail').classList.contains('hidden') && $('sync_error_text').textContent === 'rows differ ' + HOSTILE
|
||||
&& !$('sync_error_detail').querySelector('img'));
|
||||
syncAnswer = { status: 'success', data: realSync.data };
|
||||
ok('the real server\'s answer is drawn', (await poll()).length > 0, $('sync_status_content').textContent);
|
||||
syncMode = 'network';
|
||||
ok('a failed poll says unavailable', (await poll()).includes('Sync status unavailable'));
|
||||
syncAnswer = { status: 'success', data: { role: 'follower', state: 'follower', leader_ip: '10.0.0.2' } };
|
||||
syncMode = 'ok';
|
||||
ok('receiving from a leader', (await poll()).includes('Receiving from leader — 10.0.0.2'));
|
||||
syncMode = 'login';
|
||||
ok('a login redirect draws nothing', (await poll()).includes('Receiving from leader'));
|
||||
syncMode = 'ok';
|
||||
|
||||
// ── standalone stops asking ─────────────────────────────────────────────
|
||||
setRole('standalone');
|
||||
polls = syncPolls();
|
||||
fireIntervals();
|
||||
await tick(20);
|
||||
ok('standalone hides the bar and the poll asks nothing',
|
||||
$('sync_status_bar').classList.contains('hidden') && syncPolls() === polls, syncPolls() - polls);
|
||||
|
||||
// ── window.updateSyncUI ─────────────────────────────────────────────────
|
||||
$('sync_role').value = 'leader';
|
||||
polls = syncPolls();
|
||||
displayPage.updateSyncUI();
|
||||
await tick(20);
|
||||
ok('updateSyncUI() applies the role and asks once',
|
||||
!$('sync_status_bar').classList.contains('hidden') && syncPolls() === polls + 1, syncPolls() - polls);
|
||||
|
||||
// ── swapped out ─────────────────────────────────────────────────────────
|
||||
await swap('<p>another tab</p>');
|
||||
ok('nothing left mounted', registry.list().length === 0, registry.list().length);
|
||||
ok('no interval left running', intervals.size === 0, intervals.size);
|
||||
polls = syncPolls();
|
||||
setTab('overview');
|
||||
setTab('display');
|
||||
setHidden(true);
|
||||
setHidden(false);
|
||||
await tick(20);
|
||||
ok('a swapped-out page never polls again', syncPolls() === polls && intervals.size === 0, syncPolls() - polls);
|
||||
const hintsGone = count('/api/v3/config/scroll-speed-advice');
|
||||
await swap();
|
||||
$('vegas_scroll_speed').value = '40';
|
||||
fire($('vegas_scroll_speed'), 'input');
|
||||
await swap('<p>another tab</p>');
|
||||
await tick(250);
|
||||
ok('a hint still waiting out its debounce at the swap is never asked for',
|
||||
count('/api/v3/config/scroll-speed-advice') === hintsGone, count('/api/v3/config/scroll-speed-advice') - hintsGone);
|
||||
|
||||
// ── the widget loads late ───────────────────────────────────────────────
|
||||
delete window.PluginOrderList;
|
||||
const beforeLate = count('/api/v3/plugins/installed');
|
||||
await swap();
|
||||
ok('no list request while the widget is missing', count('/api/v3/plugins/installed') === beforeLate);
|
||||
window.PluginOrderList = widget;
|
||||
await tick(150);
|
||||
ok('the list starts once the widget arrives', count('/api/v3/plugins/installed') === beforeLate + 1);
|
||||
delete window.PluginOrderList;
|
||||
await swap();
|
||||
const beforeGone = count('/api/v3/plugins/installed');
|
||||
await swap('<p>another tab</p>');
|
||||
window.PluginOrderList = widget;
|
||||
await tick(250);
|
||||
ok('a page swapped away while waiting starts nothing', count('/api/v3/plugins/installed') === beforeGone);
|
||||
|
||||
ok('no console errors', logged.length === 0, logged);
|
||||
ok('no DOM errors', errs.length === 0, errs);
|
||||
|
||||
console.log(`\n${pass} passed, ${fail} failed`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
})().catch(e => { console.error(e); process.exit(1); });
|
||||
@@ -0,0 +1,272 @@
|
||||
// The General tab as a page module (static/v3/js/pages/general.js), in a real
|
||||
// DOM (jsdom) with the real server-rendered partial, the real timezone
|
||||
// widget and the real web-login endpoints' answer shapes. Built like
|
||||
// test_cache_page.js:
|
||||
//
|
||||
// * the partial ships no <script> and no inline handlers; its root is
|
||||
// data-page="general" and the Security section's forms and buttons name
|
||||
// an action
|
||||
// * the timezone picker is drawn once per swap-in, with the saved zone
|
||||
// * after five swaps, each Security action makes exactly one request
|
||||
// * a login change is a write: a swap does not cancel it, its result is
|
||||
// still reported, and nothing is drawn into the page that has gone
|
||||
// * token names reach the page as text
|
||||
// * the settings form itself is left to htmx
|
||||
// * window.webLogin's entry points still work
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { pathToFileURL } = require('url');
|
||||
const { JSDOM, VirtualConsole } = require('jsdom');
|
||||
|
||||
const BASE = process.env.BASE || 'http://localhost:5000';
|
||||
const JS = path.resolve(__dirname, '../../../web_interface/static/v3/js');
|
||||
const get = p => new Promise((res, rej) =>
|
||||
http.get(BASE + p, r => { let d = ''; r.on('data', c => d += c); r.on('end', () => res(d)); }).on('error', rej));
|
||||
const load = f => import(pathToFileURL(path.join(JS, f)).href);
|
||||
const tick = ms => new Promise(r => setTimeout(r, ms || 0));
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
const ok = (l, c, x) => c ? (pass++, console.log(' ok ' + l))
|
||||
: (fail++, console.log(' FAIL ' + l + (x !== undefined ? ' -> ' + JSON.stringify(x).slice(0, 300) : '')));
|
||||
|
||||
(async () => {
|
||||
const partial = await get('/partials/general');
|
||||
const realTokens = JSON.parse(await get('/api/v3/auth/tokens'));
|
||||
const { createRegistry } = await load('core/registry.js');
|
||||
const { createApi } = await load('core/api.js');
|
||||
const generalPage = await load('pages/general.js');
|
||||
|
||||
console.log('\n── General tab: page module (real DOM) ──');
|
||||
ok('the partial ships no inline script', !/<script/i.test(partial));
|
||||
ok('the partial has no inline click or submit handlers', !/\son(click|submit|input)=/i.test(partial));
|
||||
ok('the partial root is data-page="general"', /data-page="general"/.test(partial));
|
||||
const security = /id="web-login-settings"/.test(partial);
|
||||
ok('the server renders the Security section (it has a login store)', security);
|
||||
ok('the real token list answers in the shape the section shows',
|
||||
realTokens.status === 'success' && realTokens.data && Array.isArray(realTokens.data.tokens), realTokens);
|
||||
ok('the Security forms name their action',
|
||||
/<form[^>]*data-action="set-password"/.test(partial) && /<form[^>]*data-action="create-token"/.test(partial));
|
||||
ok('the Copy button names its action', /data-action="copy-token"/.test(partial));
|
||||
|
||||
const errs = [];
|
||||
const logged = [];
|
||||
const vc = new VirtualConsole();
|
||||
vc.on('jsdomError', e => errs.push(String(e.message || e).split('\n')[0]));
|
||||
vc.on('error', (...a) => logged.push(a.join(' ')));
|
||||
const dom = new JSDOM(`<!doctype html><html><body><div id="general-content">${partial}</div></body></html>`,
|
||||
{ url: BASE + '/', virtualConsole: vc, runScripts: 'outside-only' });
|
||||
const { window } = dom;
|
||||
const doc = window.document;
|
||||
const panel = doc.getElementById('general-content');
|
||||
require('../led_escape').install(window);
|
||||
window.eval(fs.readFileSync(path.join(JS, 'widgets/registry.js'), 'utf8'));
|
||||
window.eval(fs.readFileSync(path.join(JS, 'widgets/timezone-selector.js'), 'utf8'));
|
||||
const widgets = window.LEDMatrixWidgets;
|
||||
ok('the widget scripts register timezone-selector', !!(widgets && widgets.get('timezone-selector')));
|
||||
|
||||
let confirmAnswer = true;
|
||||
const confirms = [];
|
||||
window.confirm = m => { confirms.push(m); return confirmAnswer; };
|
||||
const reloads = [];
|
||||
window.htmx = { ajax: (method, url, opts) => reloads.push([method, url, opts.target]) };
|
||||
|
||||
const HOSTILE = '<img src=x onerror="window.pwned=1">';
|
||||
let mode = 'ok';
|
||||
let nextId = 1;
|
||||
const requests = [];
|
||||
const pending = [];
|
||||
function fakeFetch(url, init) {
|
||||
requests.push({ url, method: init.method, body: init.body ? JSON.parse(init.body) : undefined });
|
||||
const respond = (status, body, headers) => Promise.resolve({
|
||||
status, ok: status >= 200 && status < 300,
|
||||
headers: { get: h => (headers || {})[h] || null },
|
||||
text: () => Promise.resolve(JSON.stringify(body)),
|
||||
});
|
||||
if (mode === 'network') return Promise.reject(new TypeError('Failed to fetch'));
|
||||
if (mode === 'login') return respond(401, { status: 'error' }, { 'X-LEDMatrix-Login': '/login' });
|
||||
if (mode === 'refuse') return respond(400, { status: 'error', message: 'Give the token a name.' });
|
||||
if (url === '/api/v3/auth/tokens' && init.method === 'POST') {
|
||||
const id = 'tok' + (nextId++);
|
||||
const name = JSON.parse(init.body).name;
|
||||
const answer = () => respond(201, {
|
||||
status: 'success', message: 'Token created. Copy it now: it is not shown again.',
|
||||
data: { token: 'lmx_' + id, record: { id, name, prefix: 'lmx_' + id.slice(0, 3), created_at: '2026-10-04T00:00:00' } },
|
||||
});
|
||||
if (mode === 'hang') return new Promise(resolve => pending.push(() => resolve(answer())));
|
||||
return answer();
|
||||
}
|
||||
if (url.startsWith('/api/v3/auth/tokens/') && init.method === 'DELETE') {
|
||||
return respond(200, { status: 'success', message: 'Token revoked.', data: { tokens: [] } });
|
||||
}
|
||||
if (url === '/api/v3/auth/password') {
|
||||
return respond(200, { status: 'success', message: 'Login is on. Other browsers now need the password.' });
|
||||
}
|
||||
return respond(404, { status: 'error', message: 'unexpected ' + url });
|
||||
}
|
||||
const notes = [];
|
||||
const registry = createRegistry({
|
||||
document: doc,
|
||||
context: { api: createApi({ fetch: fakeFetch }), notify: (m, t) => notes.push([m, t]) },
|
||||
});
|
||||
registry.register('general', generalPage);
|
||||
|
||||
const $ = id => doc.getElementById(id);
|
||||
const root = () => doc.querySelector('[data-page="general"]');
|
||||
const timezoneWidgets = () => $('timezone_container').querySelectorAll('.timezone-selector-widget').length;
|
||||
const rows = () => doc.querySelectorAll('#web-login-tokens [data-token-id]');
|
||||
const calls = (method, prefix) => requests.filter(r => r.method === method && r.url.startsWith(prefix));
|
||||
const lastNote = () => notes[notes.length - 1] || [];
|
||||
function submit(form) {
|
||||
const event = new window.Event('submit', { bubbles: true, cancelable: true });
|
||||
form.dispatchEvent(event);
|
||||
return event;
|
||||
}
|
||||
const form = action => root().querySelector(`form[data-action="${action}"]`);
|
||||
async function swap(html) {
|
||||
panel.dispatchEvent(new window.CustomEvent('htmx:beforeSwap', { bubbles: true, detail: { target: panel, shouldSwap: true } }));
|
||||
panel.innerHTML = html === undefined ? partial : html;
|
||||
panel.dispatchEvent(new window.CustomEvent('htmx:afterSwap', { bubbles: true, detail: { target: panel } }));
|
||||
await tick(20);
|
||||
}
|
||||
|
||||
await registry.start();
|
||||
await tick(20);
|
||||
|
||||
// ── the timezone picker ─────────────────────────────────────────────────
|
||||
const savedZone = $('timezone_container').dataset.timezone;
|
||||
ok('the partial carries the saved timezone', !!savedZone, savedZone);
|
||||
ok('the timezone picker is drawn once', timezoneWidgets() === 1, timezoneWidgets());
|
||||
ok('...holding the saved zone', $('timezone_data') && $('timezone_data').value === savedZone,
|
||||
$('timezone_data') && $('timezone_data').value);
|
||||
ok('...posted as "timezone"', $('timezone_data') && $('timezone_data').name === 'timezone');
|
||||
|
||||
for (let i = 0; i < 5; i++) await swap();
|
||||
ok('one mounted page after five swaps', registry.list().length === 1, registry.list().length);
|
||||
ok('the timezone picker is drawn once, not stacked', timezoneWidgets() === 1, timezoneWidgets());
|
||||
|
||||
// ── the settings form is htmx's ─────────────────────────────────────────
|
||||
const settings = root().querySelector('form[hx-post="/api/v3/config/main"]');
|
||||
ok('submitting the settings form is not prevented', settings && !submit(settings).defaultPrevented);
|
||||
ok('...and makes no request of the page\'s own', requests.length === 0, requests.length);
|
||||
|
||||
if (security) {
|
||||
// ── create a token ────────────────────────────────────────────────────
|
||||
const before = rows().length;
|
||||
const create = form('create-token');
|
||||
create.querySelector('[name="name"]').value = HOSTILE;
|
||||
create.setAttribute('data-dirty', '');
|
||||
ok('Create token is handled by the page', submit(create).defaultPrevented);
|
||||
await tick(20);
|
||||
ok('one POST to /api/v3/auth/tokens', calls('POST', '/api/v3/auth/tokens').length === 1, requests);
|
||||
ok('...with the name typed', calls('POST', '/api/v3/auth/tokens')[0].body.name === HOSTILE);
|
||||
ok('a row is added', rows().length === before + 1, rows().length);
|
||||
ok('the hostile token name is shown as text', root().querySelector('#web-login-tokens').textContent.includes(HOSTILE));
|
||||
ok('...and created no element', !root().querySelector('#web-login-tokens img') && !window.pwned);
|
||||
ok('the "No tokens yet" line is gone', !root().querySelector('#web-login-tokens [data-empty]'));
|
||||
ok('the token is shown once', $('web-login-new-token-value').textContent === 'lmx_tok1'
|
||||
&& !$('web-login-new-token').classList.contains('hidden'));
|
||||
ok('the form is clean again (no "Leave site?")', !create.hasAttribute('data-dirty'));
|
||||
ok('one success notification', lastNote()[1] === 'success' && /Token created/.test(lastNote()[0]), notes);
|
||||
|
||||
// ── copy it (plain http: not a secure context, so it is selected) ────
|
||||
root().querySelector('button[data-action="copy-token"]').click();
|
||||
ok('Copy selects the token where the clipboard API is unavailable',
|
||||
window.getSelection().toString() === 'lmx_tok1' && /Selected/.test(lastNote()[0]), lastNote());
|
||||
|
||||
// ── revoke it (the row drawn by the page, so delegation covers it) ───
|
||||
confirmAnswer = false;
|
||||
const added = rows()[rows().length - 1];
|
||||
added.querySelector('button[data-action="revoke-token"]').click();
|
||||
await tick(20);
|
||||
ok('a cancelled Revoke sends nothing', calls('DELETE', '/api/v3/auth/tokens/').length === 0);
|
||||
ok('...after asking with the token name as written', confirms.length === 1 && confirms[0].includes(HOSTILE), confirms);
|
||||
confirmAnswer = true;
|
||||
added.querySelector('button[data-action="revoke-token"]').click();
|
||||
await tick(20);
|
||||
ok('Revoke sends one DELETE for that token',
|
||||
calls('DELETE', '/api/v3/auth/tokens/').length === 1 && calls('DELETE', '/api/v3/auth/tokens/')[0].url === '/api/v3/auth/tokens/tok1',
|
||||
calls('DELETE', '/api/v3/auth/tokens/'));
|
||||
ok('...and removes its row', rows().length === before, rows().length);
|
||||
|
||||
// ── the password ──────────────────────────────────────────────────────
|
||||
const pw = form('set-password');
|
||||
pw.querySelector('[name="new_password"]').value = 'correct horse battery';
|
||||
pw.querySelector('[name="confirm_password"]').value = 'correct horse batterY';
|
||||
submit(pw);
|
||||
await tick(20);
|
||||
ok('mismatched passwords are never sent', calls('POST', '/api/v3/auth/password').length === 0);
|
||||
ok('...and say so', lastNote()[1] === 'error' && /do not match/.test(lastNote()[0]), lastNote());
|
||||
pw.querySelector('[name="confirm_password"]').value = 'correct horse battery';
|
||||
submit(pw);
|
||||
await tick(20);
|
||||
const sent = calls('POST', '/api/v3/auth/password');
|
||||
ok('a matching password is sent once', sent.length === 1, sent.length);
|
||||
ok('...with the current password only when the form has one',
|
||||
sent[0] && sent[0].body.new_password === 'correct horse battery'
|
||||
&& (('current_password' in sent[0].body) === !!pw.querySelector('[name="current_password"]')), sent[0]);
|
||||
ok('...and the section is reloaded once', reloads.length === 1 && reloads[0][1] === '/v3/partials/general'
|
||||
&& reloads[0][2] === '#general-content', reloads);
|
||||
|
||||
// ── refused, network failure, login redirect ──────────────────────────
|
||||
mode = 'refuse';
|
||||
submit(form('create-token'));
|
||||
await tick(20);
|
||||
ok('a refused request shows the server message', lastNote()[1] === 'error' && lastNote()[0] === 'Give the token a name.', lastNote());
|
||||
mode = 'network';
|
||||
submit(form('create-token'));
|
||||
await tick(20);
|
||||
ok('a network failure says the request failed', lastNote()[1] === 'error' && /^Request failed: /.test(lastNote()[0]), lastNote());
|
||||
mode = 'login';
|
||||
const quiet = notes.length;
|
||||
submit(form('create-token'));
|
||||
await tick(20);
|
||||
ok('the login redirect shows nothing (the page is leaving)', notes.length === quiet, notes.slice(quiet));
|
||||
|
||||
// ── a write survives a swap ───────────────────────────────────────────
|
||||
mode = 'hang';
|
||||
await swap();
|
||||
const rowsBefore = rows().length;
|
||||
form('create-token').querySelector('[name="name"]').value = 'Late';
|
||||
submit(form('create-token'));
|
||||
await tick(5);
|
||||
await swap();
|
||||
pending.shift()();
|
||||
await tick(20);
|
||||
ok('a token created before a swap is still reported', lastNote()[1] === 'success', lastNote());
|
||||
ok('...and draws nothing into the new page', rows().length === rowsBefore
|
||||
&& $('web-login-new-token').classList.contains('hidden'), rows().length);
|
||||
mode = 'ok';
|
||||
|
||||
// ── window.webLogin ────────────────────────────────────────────────────
|
||||
const viaAlias = calls('POST', '/api/v3/auth/tokens').length;
|
||||
form('create-token').querySelector('[name="name"]').value = 'Alias';
|
||||
await generalPage.webLogin.createToken(form('create-token'));
|
||||
ok('webLogin.createToken(form) creates one token', calls('POST', '/api/v3/auth/tokens').length === viaAlias + 1);
|
||||
ok('webLogin has the five old methods',
|
||||
['setPassword', 'disable', 'createToken', 'copyToken', 'revoke'].every(m => typeof generalPage.webLogin[m] === 'function'));
|
||||
}
|
||||
|
||||
// ── the widget loads late ───────────────────────────────────────────────
|
||||
delete window.LEDMatrixWidgets;
|
||||
await swap();
|
||||
ok('nothing drawn while the widget is missing', timezoneWidgets() === 0, timezoneWidgets());
|
||||
window.LEDMatrixWidgets = widgets;
|
||||
await tick(150);
|
||||
ok('drawn once the widget arrives', timezoneWidgets() === 1, timezoneWidgets());
|
||||
|
||||
delete window.LEDMatrixWidgets;
|
||||
await swap();
|
||||
const kept = root();
|
||||
await swap('<p>another tab</p>');
|
||||
window.LEDMatrixWidgets = widgets;
|
||||
await tick(250);
|
||||
ok('a page swapped away while waiting draws nothing', kept.querySelectorAll('.timezone-selector-widget').length === 0);
|
||||
ok('nothing left mounted', registry.list().length === 0, registry.list().length);
|
||||
|
||||
ok('no console errors', logged.length === 0, logged);
|
||||
ok('no DOM errors', errs.length === 0, errs);
|
||||
|
||||
console.log(`\n${pass} passed, ${fail} failed`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
})().catch(e => { console.error(e); process.exit(1); });
|
||||
@@ -0,0 +1,167 @@
|
||||
// The Schedule tab as a page module (static/v3/js/pages/schedule.js), in a
|
||||
// real DOM (jsdom) with the real server-rendered partial and the real
|
||||
// widget registry and schedule-picker widget. Built like test_cache_page.js:
|
||||
//
|
||||
// * the partial ships no <script> and no inline handlers; its root is
|
||||
// data-page="schedule" and carries both saved schedules as JSON
|
||||
// * both pickers are drawn once per swap-in, from the saved config, however
|
||||
// many swaps came first
|
||||
// * each form's save is reported in exactly one notification (the forms
|
||||
// are marked data-reports-result so app.js stays quiet)
|
||||
// * the dim brightness label follows the slider
|
||||
// * a widget that loads late is waited for, and a page swapped away while
|
||||
// waiting draws nothing
|
||||
// * the old globals' entry points still work
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { pathToFileURL } = require('url');
|
||||
const { JSDOM, VirtualConsole } = require('jsdom');
|
||||
|
||||
const BASE = process.env.BASE || 'http://localhost:5000';
|
||||
const JS = path.resolve(__dirname, '../../../web_interface/static/v3/js');
|
||||
const get = p => new Promise((res, rej) =>
|
||||
http.get(BASE + p, r => { let d = ''; r.on('data', c => d += c); r.on('end', () => res(d)); }).on('error', rej));
|
||||
const load = f => import(pathToFileURL(path.join(JS, f)).href);
|
||||
const tick = ms => new Promise(r => setTimeout(r, ms || 0));
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
const ok = (l, c, x) => c ? (pass++, console.log(' ok ' + l))
|
||||
: (fail++, console.log(' FAIL ' + l + (x !== undefined ? ' -> ' + JSON.stringify(x).slice(0, 300) : '')));
|
||||
|
||||
(async () => {
|
||||
const partial = await get('/partials/schedule');
|
||||
const { createRegistry } = await load('core/registry.js');
|
||||
const schedulePage = await load('pages/schedule.js');
|
||||
|
||||
console.log('\n── Schedule tab: page module (real DOM) ──');
|
||||
ok('the partial ships no inline script', !/<script/i.test(partial));
|
||||
ok('the partial has no inline handlers', !/\son(click|input|change|submit)=/i.test(partial));
|
||||
ok('the forms carry no hx-on handler', !/hx-on/i.test(partial));
|
||||
ok('the partial root is data-page="schedule"', /data-page="schedule"/.test(partial));
|
||||
ok('both forms are marked data-reports-result',
|
||||
(partial.match(/<form[^>]*data-reports-result/g) || []).length === 2);
|
||||
|
||||
const errs = [];
|
||||
const logged = [];
|
||||
const vc = new VirtualConsole();
|
||||
vc.on('jsdomError', e => errs.push(String(e.message || e).split('\n')[0]));
|
||||
vc.on('error', (...a) => logged.push(a.join(' ')));
|
||||
const dom = new JSDOM(`<!doctype html><html><body><div id="schedule-content">${partial}</div></body></html>`,
|
||||
{ url: BASE + '/', virtualConsole: vc, runScripts: 'outside-only' });
|
||||
const { window } = dom;
|
||||
const doc = window.document;
|
||||
const panel = doc.getElementById('schedule-content');
|
||||
// base.html defines LEDEscape (app-early.js) before any tab loads; the
|
||||
// widget escapes with it.
|
||||
require('../led_escape').install(window);
|
||||
window.eval(fs.readFileSync(path.join(JS, 'widgets/registry.js'), 'utf8'));
|
||||
window.eval(fs.readFileSync(path.join(JS, 'widgets/schedule-picker.js'), 'utf8'));
|
||||
const widgets = window.LEDMatrixWidgets;
|
||||
ok('the widget scripts register schedule-picker', !!(widgets && widgets.get('schedule-picker')));
|
||||
|
||||
const notes = [];
|
||||
const registry = createRegistry({
|
||||
document: doc,
|
||||
context: { api: null, notify: (m, t) => notes.push([m, t]) },
|
||||
});
|
||||
registry.register('schedule', schedulePage);
|
||||
|
||||
const $ = id => doc.getElementById(id);
|
||||
const root = () => doc.querySelector('[data-page="schedule"]');
|
||||
const saved = key => JSON.parse(root().dataset[key]);
|
||||
const drawn = () => ['schedule_picker_container', 'dim_schedule_picker_container']
|
||||
.map(id => $(id).querySelectorAll('.schedule-picker-widget').length);
|
||||
async function swap(html) {
|
||||
panel.dispatchEvent(new window.CustomEvent('htmx:beforeSwap', { bubbles: true, detail: { target: panel, shouldSwap: true } }));
|
||||
panel.innerHTML = html === undefined ? partial : html;
|
||||
panel.dispatchEvent(new window.CustomEvent('htmx:afterSwap', { bubbles: true, detail: { target: panel } }));
|
||||
await tick(20);
|
||||
}
|
||||
function answer(formId, xhr) {
|
||||
$(formId).dispatchEvent(new window.CustomEvent('htmx:afterRequest', {
|
||||
bubbles: true, detail: { xhr, elt: $(formId), successful: xhr.status < 300 } }));
|
||||
}
|
||||
|
||||
await registry.start();
|
||||
await tick(20);
|
||||
|
||||
// ── first load ──────────────────────────────────────────────────────────
|
||||
ok('both pickers drawn once', drawn().join() === '1,1', drawn());
|
||||
const schedule = saved('scheduleConfig');
|
||||
const dim = saved('dimScheduleConfig');
|
||||
ok('the saved config reaches the page as JSON', schedule && typeof schedule === 'object' && dim && typeof dim === 'object');
|
||||
const mode = cfg => cfg.mode ? cfg.mode.replace('-', '_') : (cfg.days ? 'per_day' : 'global');
|
||||
ok('the display picker shows the saved mode', $('schedule_mode_value').value === mode(schedule),
|
||||
[$('schedule_mode_value').value, schedule.mode]);
|
||||
ok('the dim picker shows the saved mode', $('dim_schedule_mode_value').value === mode(dim),
|
||||
[$('dim_schedule_mode_value').value, dim.mode]);
|
||||
ok('the dim picker shows the saved start time',
|
||||
$('dim_schedule_start_time_hidden').value === (dim.start_time || '20:00'), $('dim_schedule_start_time_hidden').value);
|
||||
|
||||
// ── repeated swaps ──────────────────────────────────────────────────────
|
||||
for (let i = 0; i < 5; i++) await swap();
|
||||
ok('one mounted page after five swaps', registry.list().length === 1, registry.list().length);
|
||||
ok('each picker drawn once, not stacked', drawn().join() === '1,1', drawn());
|
||||
|
||||
const okXhr = body => ({ status: 200, responseText: JSON.stringify(body) });
|
||||
answer('schedule_form', okXhr({ status: 'success', message: 'Schedule configuration saved successfully' }));
|
||||
ok('a schedule save is reported once', notes.length === 1, notes);
|
||||
ok('...with the server message and status',
|
||||
notes[0] && notes[0][0] === 'Schedule configuration saved successfully' && notes[0][1] === 'success', notes[0]);
|
||||
answer('dim_schedule_form', okXhr({ status: 'success' }));
|
||||
ok('a dim schedule save without a message says so',
|
||||
notes.length === 2 && notes[1][0] === 'Dim schedule settings saved' && notes[1][1] === 'success', notes[1]);
|
||||
answer('schedule_form', { status: 400, responseText: JSON.stringify({ status: 'error' }) });
|
||||
ok('a refused save without a message says so',
|
||||
notes.length === 3 && notes[2][0] === 'Error saving schedule' && notes[2][1] === 'error', notes[2]);
|
||||
answer('dim_schedule_form', { status: 502, responseText: '<html>Bad gateway</html>' });
|
||||
ok('a non-JSON answer is an error',
|
||||
notes.length === 4 && notes[3][0] === 'Invalid response from server' && notes[3][1] === 'error', notes[3]);
|
||||
answer('schedule_form', { status: 200, responseText: 'null' });
|
||||
ok('a JSON null answer is an error, not a crash',
|
||||
notes.length === 5 && notes[4][1] === 'error', notes[4]);
|
||||
// An htmx request from elsewhere on the page (outside both forms) is not a save.
|
||||
root().querySelector('.settings-filter').dispatchEvent(new window.CustomEvent('htmx:afterRequest', {
|
||||
bubbles: true, detail: { xhr: okXhr({ status: 'success', message: 'x' }) } }));
|
||||
ok('a request from outside the two forms reports nothing', notes.length === 5, notes.length);
|
||||
|
||||
// ── the brightness label ────────────────────────────────────────────────
|
||||
$('dim_brightness').value = '42';
|
||||
$('dim_brightness').dispatchEvent(new window.Event('input', { bubbles: true }));
|
||||
ok('the dim brightness label follows the slider', $('dim_brightness_display').textContent === '42%',
|
||||
$('dim_brightness_display').textContent);
|
||||
|
||||
// ── the widget loads late ───────────────────────────────────────────────
|
||||
delete window.LEDMatrixWidgets;
|
||||
await swap();
|
||||
ok('nothing drawn while the widget is missing', drawn().join() === '0,0', drawn());
|
||||
window.LEDMatrixWidgets = widgets;
|
||||
await tick(150);
|
||||
ok('drawn once the widget arrives', drawn().join() === '1,1', drawn());
|
||||
|
||||
delete window.LEDMatrixWidgets;
|
||||
await swap();
|
||||
const kept = root();
|
||||
await swap('<p>another tab</p>');
|
||||
window.LEDMatrixWidgets = widgets;
|
||||
await tick(250);
|
||||
ok('a page swapped away while waiting draws nothing',
|
||||
kept.querySelectorAll('.schedule-picker-widget').length === 0);
|
||||
ok('nothing left mounted', registry.list().length === 0, registry.list().length);
|
||||
|
||||
// ── the old globals ─────────────────────────────────────────────────────
|
||||
await swap();
|
||||
const before = notes.length;
|
||||
schedulePage.handleScheduleResponse({ target: $('schedule_form'), detail: { xhr: okXhr({ status: 'success' }) } });
|
||||
schedulePage.handleDimScheduleResponse({ target: $('dim_schedule_form'), detail: { xhr: okXhr({ status: 'success' }) } });
|
||||
ok('handleScheduleResponse(event) and handleDimScheduleResponse(event) report once each',
|
||||
notes.length === before + 2 && notes[before][0] === 'Schedule settings saved'
|
||||
&& notes[before + 1][0] === 'Dim schedule settings saved', notes.slice(before));
|
||||
|
||||
ok('no console errors', logged.length === 0, logged);
|
||||
ok('no DOM errors', errs.length === 0, errs);
|
||||
|
||||
console.log(`\n${pass} passed, ${fail} failed`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
})().catch(e => { console.error(e); process.exit(1); });
|
||||
@@ -0,0 +1,211 @@
|
||||
// The page-visibility service (static/v3/js/core/visibility.js), in a real
|
||||
// DOM (jsdom) with the real window.LEDVisibility from app-shell.js and the
|
||||
// real page registry, wired the way core/boot.js wires them (each mount gets
|
||||
// ctx.visibility from mountContext):
|
||||
//
|
||||
// * whileVisible(start, stop) runs start() only while the page's tab is the
|
||||
// active tab AND the browser tab is visible, stop() when either changes
|
||||
// * every(ms, fn) calls fn at once and then on an interval while visible;
|
||||
// no interval is left running while hidden
|
||||
// * everything a page registered stops when the page is swapped out, and a
|
||||
// page that registers after it was destroyed starts nothing
|
||||
// * registrations never replace each other (two timers on one page, two
|
||||
// pages, or a classic partial's own LEDVisibility key)
|
||||
// * without LEDVisibility, the browser tab's visibility alone decides
|
||||
//
|
||||
// Needs jsdom but no server.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { pathToFileURL } = require('url');
|
||||
const { JSDOM, VirtualConsole } = require('jsdom');
|
||||
|
||||
const JS = path.resolve(__dirname, '../../../web_interface/static/v3/js');
|
||||
const load = f => import(pathToFileURL(path.join(JS, f)).href);
|
||||
const tick = ms => new Promise(r => setTimeout(r, ms || 0));
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
const ok = (l, c, x) => c ? (pass++, console.log(' ok ' + l))
|
||||
: (fail++, console.log(' FAIL ' + l + (x !== undefined ? ' -> ' + JSON.stringify(x).slice(0, 300) : '')));
|
||||
|
||||
(async () => {
|
||||
const { createRegistry } = await load('core/registry.js');
|
||||
const { createVisibility } = await load('core/visibility.js');
|
||||
|
||||
console.log('\n── Page visibility service (real DOM, real LEDVisibility) ──');
|
||||
const errs = [];
|
||||
const logged = [];
|
||||
const vc = new VirtualConsole();
|
||||
vc.on('jsdomError', e => errs.push(String(e.message || e).split('\n')[0]));
|
||||
vc.on('error', (...a) => logged.push(a.join(' ')));
|
||||
const dom = new JSDOM('<!doctype html><html><body><div id="display-content"></div><div id="logs-content"></div></body></html>',
|
||||
{ url: 'http://localhost/', virtualConsole: vc, runScripts: 'outside-only' });
|
||||
const { window } = dom;
|
||||
const doc = window.document;
|
||||
|
||||
// The browser tab's visibility, under the test's control.
|
||||
let hidden = false;
|
||||
Object.defineProperty(doc, 'hidden', { get: () => hidden, configurable: true });
|
||||
function setHidden(value) {
|
||||
hidden = value;
|
||||
doc.dispatchEvent(new window.Event('visibilitychange'));
|
||||
}
|
||||
function setTab(tab) {
|
||||
doc.dispatchEvent(new window.CustomEvent('ledmatrix:tab-changed', { detail: { tab } }));
|
||||
}
|
||||
|
||||
// Intervals, counted: the point is that none is left running.
|
||||
const intervals = new Map();
|
||||
let nextInterval = 1;
|
||||
window.setInterval = (fn, ms) => { const id = nextInterval++; intervals.set(id, { fn, ms }); return id; };
|
||||
window.clearInterval = id => { intervals.delete(id); };
|
||||
const fireIntervals = () => [...intervals.values()].forEach(i => i.fn());
|
||||
|
||||
// The real LEDVisibility (app-shell.js). Alpine is absent, so the active
|
||||
// tab is the last ledmatrix:tab-changed. The SSE streams are not under
|
||||
// test: they open stand-in EventSources.
|
||||
window.getApp = () => null;
|
||||
window.EventSource = class { addEventListener() {} removeEventListener() {} close() {} };
|
||||
window.eval(fs.readFileSync(path.join(JS, 'app-shell.js'), 'utf8'));
|
||||
ok('app-shell.js defines LEDVisibility', !!(window.LEDVisibility && window.LEDVisibility.onActive));
|
||||
|
||||
const visibility = createVisibility({ window });
|
||||
const registry = createRegistry({
|
||||
document: doc,
|
||||
context: {},
|
||||
mountContext: ctx => ({ visibility: visibility.forPage(ctx) }),
|
||||
});
|
||||
|
||||
const log = [];
|
||||
let polls = 0;
|
||||
const handles = [];
|
||||
registry.register('display', {
|
||||
init(root, ctx) {
|
||||
handles.push(ctx.visibility);
|
||||
ctx.visibility.whileVisible(() => log.push('start'), () => log.push('stop'));
|
||||
ctx.visibility.every(5000, () => { polls++; });
|
||||
},
|
||||
});
|
||||
let otherRuns = 0;
|
||||
registry.register('logs', {
|
||||
init(root, ctx) { ctx.visibility.every(1000, () => { otherRuns++; }); },
|
||||
});
|
||||
|
||||
const panel = doc.getElementById('display-content');
|
||||
async function swap(html) {
|
||||
panel.dispatchEvent(new window.CustomEvent('htmx:beforeSwap', { bubbles: true, detail: { target: panel, shouldSwap: true } }));
|
||||
panel.innerHTML = html;
|
||||
panel.dispatchEvent(new window.CustomEvent('htmx:afterSwap', { bubbles: true, detail: { target: panel } }));
|
||||
await tick(10);
|
||||
}
|
||||
|
||||
// A classic partial's own registration, keyed by its tab name: the
|
||||
// service's registrations must not replace it, nor it them.
|
||||
let classic = 0;
|
||||
window.LEDVisibility.onActive('display', () => { classic++; }, () => {});
|
||||
|
||||
setTab('overview');
|
||||
await registry.start();
|
||||
await swap('<div data-page="display"></div>');
|
||||
|
||||
// ── mounted on another tab ──────────────────────────────────────────────
|
||||
ok('mounted while another tab is active: nothing starts', log.length === 0 && polls === 0, [log, polls]);
|
||||
ok('...and no interval runs', intervals.size === 0, intervals.size);
|
||||
ok('isVisible() is false', handles[0].isVisible() === false);
|
||||
ok('the page\'s tab is its name', handles[0].tab === 'display');
|
||||
|
||||
// ── its tab comes on screen ─────────────────────────────────────────────
|
||||
setTab('display');
|
||||
ok('switching to the tab runs start()', log.join() === 'start', log);
|
||||
ok('every() calls fn at once', polls === 1, polls);
|
||||
ok('...and sets one interval at the asked period', intervals.size === 1 && [...intervals.values()][0].ms === 5000,
|
||||
[...intervals.values()].map(i => i.ms));
|
||||
ok('isVisible() is true', handles[0].isVisible() === true);
|
||||
ok('the classic registration still runs alongside', classic === 1, classic);
|
||||
fireIntervals();
|
||||
fireIntervals();
|
||||
ok('fn runs on each interval', polls === 3, polls);
|
||||
|
||||
// ── the browser tab is hidden, then shown ───────────────────────────────
|
||||
setHidden(true);
|
||||
ok('hiding the browser tab runs stop()', log.join() === 'start,stop', log);
|
||||
ok('...and clears the interval', intervals.size === 0, intervals.size);
|
||||
ok('isVisible() is false while hidden', handles[0].isVisible() === false);
|
||||
setHidden(false);
|
||||
ok('showing it again runs start()', log.join() === 'start,stop,start', log);
|
||||
ok('...and fn at once, with one interval again', polls === 4 && intervals.size === 1, [polls, intervals.size]);
|
||||
|
||||
// ── another tab ─────────────────────────────────────────────────────────
|
||||
setTab('logs');
|
||||
ok('switching away runs stop() and clears the interval', log.join() === 'start,stop,start,stop' && intervals.size === 0,
|
||||
[log, intervals.size]);
|
||||
setTab('display');
|
||||
ok('switching back restarts it', log.length === 5 && polls === 5 && intervals.size === 1, [log, polls]);
|
||||
|
||||
// ── the partial is swapped out while on screen ──────────────────────────
|
||||
await swap('<p>no page here</p>');
|
||||
ok('a swap-out stops it', log[log.length - 1] === 'stop', log);
|
||||
ok('...and leaves no interval running', intervals.size === 0, intervals.size);
|
||||
const before = [log.length, polls];
|
||||
setTab('overview');
|
||||
setTab('display');
|
||||
setHidden(true);
|
||||
setHidden(false);
|
||||
ok('a destroyed page never starts again', log.length === before[0] && polls === before[1], [log, polls]);
|
||||
ok('the classic registration keeps running after the swap', classic === 5, classic);
|
||||
|
||||
// ── five swaps, then one page ───────────────────────────────────────────
|
||||
for (let i = 0; i < 5; i++) await swap('<div data-page="display"></div>');
|
||||
ok('after five swaps one interval runs, not five', intervals.size === 1, intervals.size);
|
||||
const pollsBefore = polls;
|
||||
fireIntervals();
|
||||
ok('...and one poll per tick', polls === pollsBefore + 1, polls - pollsBefore);
|
||||
|
||||
// ── two timers on one page, and an end function ─────────────────────────
|
||||
const h = handles[handles.length - 1];
|
||||
let a = 0, b = 0;
|
||||
const endA = h.every(100, () => { a++; });
|
||||
h.every(200, () => { b++; });
|
||||
ok('two more timers on one page both start', a === 1 && b === 1 && intervals.size === 3, [a, b, intervals.size]);
|
||||
endA();
|
||||
endA();
|
||||
ok('an end function stops just its own timer (twice is harmless)', intervals.size === 2, intervals.size);
|
||||
|
||||
// ── a page that registers after it was destroyed ────────────────────────
|
||||
const gone = handles[handles.length - 1];
|
||||
await swap('<p>gone</p>');
|
||||
ok('the swap-out clears every timer the page had', intervals.size === 0, intervals.size);
|
||||
let late = 0;
|
||||
const end = gone.every(1000, () => { late++; });
|
||||
ok('registering after destroy starts nothing', late === 0 && intervals.size === 0 && typeof end === 'function');
|
||||
|
||||
// ── a start() that throws ───────────────────────────────────────────────
|
||||
await swap('<div data-page="display"></div>');
|
||||
const h2 = handles[handles.length - 1];
|
||||
const loggedBefore = logged.length;
|
||||
let afterThrow = 0;
|
||||
h2.whileVisible(() => { throw new Error('boom'); }, () => {});
|
||||
h2.whileVisible(() => { afterThrow++; }, () => {});
|
||||
ok('a throwing start() is logged', logged.length === loggedBefore + 1 && /boom|start failed/.test(logged.join()),
|
||||
logged.slice(loggedBefore));
|
||||
ok('...and the next registration still starts', afterThrow === 1);
|
||||
await swap('<p>gone</p>');
|
||||
|
||||
// ── without LEDVisibility (a page outside base.html) ────────────────────
|
||||
const bare = createVisibility({ window, tracker: () => null }).forPage({ name: 'standalone', signal: new window.AbortController().signal });
|
||||
const seen = [];
|
||||
bare.whileVisible(() => seen.push('start'), () => seen.push('stop'));
|
||||
ok('without LEDVisibility, a visible document starts at once', seen.join() === 'start', seen);
|
||||
setHidden(true);
|
||||
ok('...and hiding it stops', seen.join() === 'start,stop', seen);
|
||||
ok('...isVisible() follows the document', bare.isVisible() === false);
|
||||
setHidden(false);
|
||||
ok('...and showing it starts again', seen.join() === 'start,stop,start', seen);
|
||||
|
||||
ok('registrations need functions', (() => { try { h2.whileVisible(null, null); return false; } catch { return true; } })());
|
||||
ok('every() needs a positive period', (() => { try { h2.every(0, () => {}); return false; } catch { return true; } })());
|
||||
ok('the logs page was never on screen: it never ran', otherRuns === 0, otherRuns);
|
||||
ok('no DOM errors', errs.length === 0, errs);
|
||||
|
||||
console.log(`\n${pass} passed, ${fail} failed`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
})().catch(e => { console.error(e); process.exit(1); });
|
||||
+5
-2
@@ -32,11 +32,14 @@ const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.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_general_web_login_token.js',
|
||||
'unit/test_on_demand_starting.js'];
|
||||
const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js',
|
||||
'dom/test_tools_sections.js', 'dom/test_cache_page.js',
|
||||
'dom/test_durations_page.js', 'dom/test_operation_history_page.js',
|
||||
'dom/test_raw_json_page.js', 'dom/test_backup_restore_page.js'];
|
||||
'dom/test_raw_json_page.js', 'dom/test_backup_restore_page.js',
|
||||
'dom/test_schedule_page.js', 'dom/test_general_page.js',
|
||||
'dom/test_visibility_service.js', 'dom/test_display_page.js'];
|
||||
|
||||
function reachable(url) {
|
||||
return new Promise(res => {
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
// The Display tab's inline script must only look up elements the partial
|
||||
// The Display tab's page module must only look up elements the partial
|
||||
// renders.
|
||||
//
|
||||
// Its brightness slider handler also wrote to #brightness-display, a "LED
|
||||
// brightness: N%" line that #387 removed from partials/display.html. The
|
||||
// Its brightness slider handler once also wrote to #brightness-display, a
|
||||
// "LED brightness: N%" line that #387 removed from partials/display.html. The
|
||||
// lookup returned null, so every movement of the slider threw a TypeError.
|
||||
// This checks every literal getElementById() in the partial's inline scripts
|
||||
// against the ids its markup renders, and runs the shipped script in a vm
|
||||
// with a fake DOM (null for an id the markup lacks, as in a browser) to move
|
||||
// the slider.
|
||||
// This imports the shipped module (static/v3/js/pages/display.js), starts it
|
||||
// on a fake root that answers only for the ids the partial's markup renders
|
||||
// (null for any other, as in a browser), fires every listener it registered,
|
||||
// and checks that every id it asked for exists. Then it moves the slider.
|
||||
//
|
||||
// No jsdom and no server needed.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
const { pathToFileURL } = require('url');
|
||||
|
||||
const PARTIAL = path.resolve(__dirname, '../../../web_interface/templates/v3/partials/display.html');
|
||||
const JS = path.resolve(__dirname, '../../../web_interface/static/v3/js');
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
const ok = (label, cond, extra) => cond
|
||||
@@ -23,86 +24,93 @@ const ok = (label, cond, extra) => cond
|
||||
: (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' ' + JSON.stringify(extra) : '')));
|
||||
|
||||
const html = fs.readFileSync(PARTIAL, 'utf8');
|
||||
const blocks = [...html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/gi)];
|
||||
const scripts = blocks.map(m => m[1]);
|
||||
// The markup is what lies between the script blocks (sliced around them, not
|
||||
// a replace(), which CodeQL reads as an incomplete HTML sanitizer).
|
||||
let markup = '';
|
||||
let from = 0;
|
||||
for (const m of blocks) {
|
||||
markup += html.slice(from, m.index);
|
||||
from = m.index + m[0].length;
|
||||
}
|
||||
markup += html.slice(from);
|
||||
const rendered = new Set([...markup.matchAll(/\bid="([^"{}]+)"/g)].map(m => m[1]));
|
||||
const rendered = new Set([...html.matchAll(/\bid="([^"{}]+)"/g)].map(m => m[1]));
|
||||
|
||||
console.log('\n── Display partial: element lookups ──');
|
||||
|
||||
// 1. Static: every literal lookup names an id the partial renders.
|
||||
const lookups = scripts.flatMap(s => [...s.matchAll(/getElementById\('([^']+)'\)/g)].map(m => m[1]));
|
||||
const missing = [...new Set(lookups.filter(id => !rendered.has(id)))];
|
||||
ok('the inline scripts look elements up', lookups.length > 0, lookups.length);
|
||||
ok('every looked-up id is rendered by the partial', missing.length === 0, missing);
|
||||
|
||||
// 2. Behaviour: moving the brightness slider updates its label and throws nothing.
|
||||
function fakeElement(id) {
|
||||
const listeners = {};
|
||||
const classes = new Set();
|
||||
return {
|
||||
id, value: '', textContent: '', min: '', max: '', checked: false,
|
||||
id, value: '1', textContent: '', min: '', max: '', checked: false,
|
||||
style: {}, dataset: {}, className: '',
|
||||
classList: {
|
||||
add: c => classes.add(c), remove: c => classes.delete(c),
|
||||
toggle: (c, on) => (on === undefined ? (classes.has(c) ? classes.delete(c) : classes.add(c)) : (on ? classes.add(c) : classes.delete(c))),
|
||||
contains: c => classes.has(c),
|
||||
},
|
||||
addEventListener: (type, fn) => { (listeners[type] ||= []).push(fn); },
|
||||
dispatchEvent() { return true; },
|
||||
appendChild() {},
|
||||
getAttribute: () => null,
|
||||
listeners,
|
||||
};
|
||||
}
|
||||
|
||||
const main = scripts.find(s => s.includes("getElementById('brightness')"));
|
||||
ok('found the script that wires the brightness slider', !!main);
|
||||
if (main) {
|
||||
const elements = new Map();
|
||||
const document = {
|
||||
readyState: 'complete',
|
||||
hidden: false,
|
||||
getElementById: id => {
|
||||
if (!rendered.has(id)) return null;
|
||||
if (!elements.has(id)) elements.set(id, fakeElement(id));
|
||||
return elements.get(id);
|
||||
},
|
||||
createElement: () => fakeElement(''),
|
||||
createTextNode: () => ({}),
|
||||
addEventListener() {},
|
||||
};
|
||||
const window = {
|
||||
LEDEscape: { html: v => String(v), attr: v => String(v) },
|
||||
LEDVisibility: { onActive() {} },
|
||||
};
|
||||
const context = {
|
||||
window, document, console, URLSearchParams,
|
||||
fetch: () => new Promise(() => {}),
|
||||
setTimeout: () => 0, clearTimeout() {}, setInterval: () => 0, clearInterval() {},
|
||||
};
|
||||
vm.createContext(context);
|
||||
let loadError = null;
|
||||
try { vm.runInContext(main, context); } catch (e) { loadError = e; }
|
||||
ok('the script loads', !loadError, loadError && String(loadError));
|
||||
(async () => {
|
||||
console.log('\n── Display page module: element lookups ──');
|
||||
const display = await import(pathToFileURL(path.join(JS, 'pages/display.js')).href);
|
||||
|
||||
const slider = elements.get('brightness');
|
||||
const handlers = (slider && slider.listeners.input) || [];
|
||||
ok('the slider has an input handler', handlers.length > 0);
|
||||
const asked = new Set();
|
||||
const elements = new Map();
|
||||
const timers = [];
|
||||
const win = {
|
||||
setTimeout: fn => { timers.push(fn); return timers.length; },
|
||||
clearTimeout() {},
|
||||
URLSearchParams,
|
||||
Event: class { constructor(type) { this.type = type; } },
|
||||
PluginOrderList: { init() {} },
|
||||
};
|
||||
const doc = { defaultView: win, createElement: () => fakeElement(''), createTextNode: () => ({}) };
|
||||
const rootListeners = {};
|
||||
const root = {
|
||||
ownerDocument: doc,
|
||||
querySelector(sel) {
|
||||
const m = /^#([\w-]+)$/.exec(sel);
|
||||
if (!m) throw new Error('unexpected selector ' + sel);
|
||||
asked.add(m[1]);
|
||||
if (!rendered.has(m[1])) return null;
|
||||
if (!elements.has(m[1])) elements.set(m[1], fakeElement(m[1]));
|
||||
return elements.get(m[1]);
|
||||
},
|
||||
addEventListener: (type, fn) => { (rootListeners[type] ||= []).push(fn); },
|
||||
contains: () => true,
|
||||
};
|
||||
const never = () => new Promise(() => {});
|
||||
const polls = [];
|
||||
const ctx = {
|
||||
root, name: 'display', state: {}, signal: { aborted: false },
|
||||
api: { get: never },
|
||||
visibility: { every: (ms, fn) => { polls.push(ms); fn(); return () => {}; } },
|
||||
};
|
||||
|
||||
let loadError = null;
|
||||
try { display.init(root, ctx); } catch (e) { loadError = e; }
|
||||
ok('init() runs', !loadError, loadError && String(loadError));
|
||||
ok('the sync status is polled through ctx.visibility', polls.length === 1 && polls[0] === 5000, polls);
|
||||
|
||||
// Fire everything it wired, so every lookup it can make is made.
|
||||
let thrown = null;
|
||||
try {
|
||||
for (const el of elements.values()) {
|
||||
for (const fns of Object.values(el.listeners)) fns.forEach(fn => fn.call(el, { target: el }));
|
||||
}
|
||||
while (timers.length) timers.shift()();
|
||||
} catch (e) { thrown = e; }
|
||||
ok('its listeners and timers run without throwing', !thrown, thrown && String(thrown));
|
||||
|
||||
const missing = [...asked].filter(id => !rendered.has(id));
|
||||
ok('it looks elements up', asked.size > 10, asked.size);
|
||||
ok('every looked-up id is rendered by the partial', missing.length === 0, missing);
|
||||
|
||||
const slider = elements.get('brightness') || fakeElement('brightness');
|
||||
const handlers = slider.listeners.input || [];
|
||||
ok('the slider has an input handler', handlers.length > 0);
|
||||
slider.value = '42';
|
||||
thrown = null;
|
||||
try { handlers.forEach(fn => fn.call(slider, { target: slider })); } catch (e) { thrown = e; }
|
||||
ok('moving the slider throws nothing', !thrown, thrown && String(thrown));
|
||||
ok('...and shows the new value', elements.get('brightness-value').textContent === '42',
|
||||
elements.get('brightness-value').textContent);
|
||||
}
|
||||
const label = elements.get('brightness-value');
|
||||
ok('...and shows the new value', !!label && label.textContent === '42', label && label.textContent);
|
||||
|
||||
console.log(`\n${pass} passed, ${fail} failed\n`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
display.destroy(root, ctx);
|
||||
console.log(`\n${pass} passed, ${fail} failed\n`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
})().catch(e => { console.error(e); process.exit(1); });
|
||||
|
||||
@@ -3,32 +3,24 @@
|
||||
// app.js marks a form data-dirty on any input in it and clears the mark only
|
||||
// after a successful htmx request; its beforeunload handler then asks "Leave
|
||||
// site?" while any visible form is still dirty. The token form posts with
|
||||
// fetch (window.webLogin.createToken in partials/general.html), so after a
|
||||
// token was created the form stayed dirty and reloading the page while the
|
||||
// General tab was open prompted about changes that had been saved.
|
||||
// fetch (createToken in static/v3/js/pages/general.js), so after a token was
|
||||
// created the form stayed dirty and reloading the page while the General tab
|
||||
// was open prompted about changes that had been saved.
|
||||
//
|
||||
// Runs the shipped inline script in a vm with a fake fetch and DOM -- no jsdom
|
||||
// and no server needed.
|
||||
// Imports the shipped page module and runs createToken with a fake fetch and
|
||||
// DOM -- no jsdom and no server needed.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
const { pathToFileURL } = require('url');
|
||||
|
||||
const PARTIAL = path.resolve(__dirname, '../../../web_interface/templates/v3/partials/general.html');
|
||||
const JS = path.resolve(__dirname, '../../../web_interface/static/v3/js');
|
||||
const load = f => import(pathToFileURL(path.join(JS, f)).href);
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
const ok = (label, cond, extra) => cond
|
||||
? (pass++, console.log(' ok ' + label))
|
||||
: (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' ' + JSON.stringify(extra) : '')));
|
||||
|
||||
function webLoginScript() {
|
||||
const html = fs.readFileSync(PARTIAL, 'utf8');
|
||||
const scripts = [...html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/gi)].map(m => m[1]);
|
||||
const found = scripts.find(s => s.includes('window.webLogin = {'));
|
||||
if (!found) throw new Error('webLogin script not found in general.html');
|
||||
return found;
|
||||
}
|
||||
|
||||
function el() {
|
||||
const classes = new Set(['hidden']);
|
||||
return {
|
||||
@@ -38,29 +30,28 @@ function el() {
|
||||
};
|
||||
}
|
||||
|
||||
function load(answer) {
|
||||
async function setup(answer) {
|
||||
const { createApi } = await load('core/api.js');
|
||||
const general = await load('pages/general.js');
|
||||
const elements = {
|
||||
'web-login-tokens': el(),
|
||||
'web-login-new-token-value': el(),
|
||||
'web-login-new-token': el(),
|
||||
'#web-login-tokens': el(),
|
||||
'#web-login-new-token-value': el(),
|
||||
'#web-login-new-token': el(),
|
||||
};
|
||||
const notes = [];
|
||||
const window = { showNotification: (m, t) => notes.push([m, t]), alert() {}, confirm: () => true };
|
||||
const context = {
|
||||
window, console,
|
||||
document: {
|
||||
getElementById: id => elements[id] || null,
|
||||
createElement: () => el(),
|
||||
querySelectorAll: () => [],
|
||||
},
|
||||
fetch: () => Promise.resolve({
|
||||
ok: answer.ok, status: answer.ok ? 200 : 400,
|
||||
json: () => Promise.resolve(answer.body),
|
||||
}),
|
||||
const doc = { createElement: () => el(), defaultView: { confirm: () => true } };
|
||||
const root = { ownerDocument: doc, querySelector: sel => elements[sel] || null, querySelectorAll: () => [] };
|
||||
const fetch = () => Promise.resolve({
|
||||
ok: answer.ok, status: answer.ok ? 200 : 400,
|
||||
headers: { get: () => null },
|
||||
text: () => Promise.resolve(JSON.stringify(answer.body)),
|
||||
});
|
||||
const ctx = {
|
||||
root, state: {}, signal: { aborted: false },
|
||||
api: createApi({ fetch }),
|
||||
notify: (m, t) => notes.push([m, t]),
|
||||
};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(webLoginScript(), context);
|
||||
return { webLogin: context.window.webLogin, elements, notes };
|
||||
return { general, ctx, elements, notes };
|
||||
}
|
||||
|
||||
function dirtyForm() {
|
||||
@@ -74,29 +65,25 @@ function dirtyForm() {
|
||||
};
|
||||
}
|
||||
|
||||
const flush = async () => { for (let i = 0; i < 10; i++) await new Promise(r => setImmediate(r)); };
|
||||
|
||||
(async () => {
|
||||
console.log('\n── General tab: API token form ──');
|
||||
|
||||
{
|
||||
const t = load({ ok: true, body: {
|
||||
const t = await setup({ ok: true, body: {
|
||||
status: 'success', message: 'Token created',
|
||||
data: { token: 'lmx_secret', record: { id: 't1', name: 'Home Assistant', prefix: 'lmx_sec' } },
|
||||
} });
|
||||
const form = dirtyForm();
|
||||
t.webLogin.createToken(form);
|
||||
await flush();
|
||||
ok('the new token is shown', t.elements['web-login-new-token-value'].textContent === 'lmx_secret');
|
||||
await t.general.createToken(t.ctx, form);
|
||||
ok('the new token is shown', t.elements['#web-login-new-token-value'].textContent === 'lmx_secret');
|
||||
ok('a created token leaves the form clean (no "Leave site?" on reload)',
|
||||
!form.hasAttribute('data-dirty'));
|
||||
}
|
||||
|
||||
{
|
||||
const t = load({ ok: false, body: { status: 'error', message: 'Name is required' } });
|
||||
const t = await setup({ ok: false, body: { status: 'error', message: 'Name is required' } });
|
||||
const form = dirtyForm();
|
||||
t.webLogin.createToken(form);
|
||||
await flush();
|
||||
await t.general.createToken(t.ctx, form);
|
||||
ok('a refused request reports the error', t.notes.some(([m, type]) => type === 'error' && /Name is required/.test(m)),
|
||||
t.notes);
|
||||
ok('...and keeps the form dirty: nothing was saved', form.hasAttribute('data-dirty'));
|
||||
|
||||
@@ -103,17 +103,16 @@ const ESCAPERS = [
|
||||
'static/v3/js/widgets/text-input.js', 'function escapeHtml(text) {', 'escapeHtml', false],
|
||||
['slider.js (escapeAttr)',
|
||||
'static/v3/js/widgets/slider.js', 'function escapeAttr(text) {', 'escapeAttr', false],
|
||||
['display.html (escapeAttr)',
|
||||
'templates/v3/partials/display.html', 'function escapeAttr(text) {', 'escapeAttr', false],
|
||||
['tools.html (escHtml)',
|
||||
'templates/v3/partials/tools.html', 'function escHtml(s) {', 'escHtml', false],
|
||||
['tools.html (phEscape)',
|
||||
'templates/v3/partials/tools.html', 'function phEscape(s) {', 'phEscape', false],
|
||||
['logs.html (escapeHtml)',
|
||||
'templates/v3/partials/logs.html', 'function escapeHtml(text) {', 'escapeHtml', false],
|
||||
// cache.html, backup_restore.html and operation_history.html have no
|
||||
// script any more: their js/pages/ modules draw server data with
|
||||
// textContent, and each page's suite in test/js/dom/ checks a hostile value.
|
||||
// cache.html, backup_restore.html, operation_history.html and display.html
|
||||
// have no script any more (display.html's two escapers were never called):
|
||||
// their js/pages/ modules draw server data with textContent, and each
|
||||
// page's suite in test/js/dom/ checks a hostile value.
|
||||
];
|
||||
|
||||
// The breakout payload: closes a double-quoted attribute and opens an event
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// POST /api/v3/display/on-demand/start answers 202 with status "starting"
|
||||
// when the display service has to be started first: the request is taken,
|
||||
// and the web process sends it once the display listens. "Preview on
|
||||
// display" (app.js) must read that as taken -- an info toast and the
|
||||
// floating preview opened -- not as a failure. Runs the shipped app.js in a
|
||||
// vm with a minimal fake DOM, as test_restart_banner.js does.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
const V3 = path.resolve(__dirname, '../../../web_interface/static/v3');
|
||||
|
||||
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 load(answer) {
|
||||
const notes = [];
|
||||
const opened = [];
|
||||
const noop = () => {};
|
||||
const document = {
|
||||
body: { addEventListener: noop },
|
||||
addEventListener: noop,
|
||||
getElementById: () => null,
|
||||
querySelector: () => null,
|
||||
querySelectorAll: () => [],
|
||||
};
|
||||
const window = { addEventListener: noop, getApp: () => null };
|
||||
const context = {
|
||||
window, document, console,
|
||||
sessionStorage: { setItem: noop, removeItem: noop, getItem: () => null },
|
||||
showNotification: (m, t) => notes.push([m, t]),
|
||||
setTimeout: () => 0,
|
||||
fetch: () => Promise.resolve({ json: () => Promise.resolve(answer) }),
|
||||
};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(fs.readFileSync(path.join(V3, 'app.js'), 'utf8'), context);
|
||||
window.toggleFloatingPreview = (open) => opened.push(open);
|
||||
return { window, notes, opened };
|
||||
}
|
||||
|
||||
async function preview(answer) {
|
||||
const t = load(answer);
|
||||
t.window.previewPluginNow('weather');
|
||||
for (let i = 0; i < 5; i++) await Promise.resolve();
|
||||
return t;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
console.log('\npreviewPluginNow');
|
||||
{
|
||||
const t = await preview({ status: 'starting', message: 'The display service is starting',
|
||||
data: { request_id: 'r1', pending: true } });
|
||||
ok('a 202 "starting" answer is an info toast, not an error',
|
||||
t.notes.length === 1 && t.notes[0][1] === 'info', t.notes);
|
||||
ok('and the preview opens', t.opened.length === 1 && t.opened[0] === true, t.opened);
|
||||
}
|
||||
{
|
||||
const t = await preview({ status: 'success', data: { request_id: 'r1' } });
|
||||
ok('a 200 success still opens it', t.opened.length === 1 && t.notes[0][1] === 'success', t.notes);
|
||||
}
|
||||
{
|
||||
const t = await preview({ status: 'error', message: 'no display' });
|
||||
ok('an error does not', t.opened.length === 0 && t.notes[0][1] === 'error', t.notes);
|
||||
}
|
||||
console.log(`\n${pass} passed, ${fail} failed`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
})();
|
||||
@@ -242,6 +242,46 @@ function recorder(log) {
|
||||
ok('has()', reg.has('dup') && !reg.has('nope'));
|
||||
}
|
||||
|
||||
console.log('\n10. mountContext adds per-mount fields, after the shared ones');
|
||||
{
|
||||
const doc = new Doc();
|
||||
const panel = doc.body.appendChild(new El('div', { id: 'panel' }));
|
||||
panel.appendChild(new El('div', { id: 'a', [PAGE_ATTRIBUTE]: 'demo' }));
|
||||
const seen = [];
|
||||
const made = [];
|
||||
const reg = createRegistry({
|
||||
document: doc, context: { api: 'shared' }, logger: quiet,
|
||||
mountContext(ctx) {
|
||||
made.push([ctx.name, ctx.root.getAttribute('id'), !!ctx.signal, ctx.api]);
|
||||
return { bound: { root: ctx.root, signal: ctx.signal } };
|
||||
},
|
||||
});
|
||||
reg.register('demo', { init(root, ctx) { seen.push(ctx); } });
|
||||
await reg.start();
|
||||
await tick();
|
||||
ok('mountContext sees the mount\'s name, root, signal and the shared services',
|
||||
made.length === 1 && made[0].join() === 'demo,a,true,shared', made);
|
||||
ok('...and its fields reach init()', seen.length === 1 && seen[0].bound && seen[0].bound.root === seen[0].root, seen.length);
|
||||
fire(panel, 'htmx:beforeSwap', { target: panel, shouldSwap: true });
|
||||
panel.replaceChildren(new El('div', { id: 'b', [PAGE_ATTRIBUTE]: 'demo' }));
|
||||
fire(panel, 'htmx:afterSwap', { target: panel });
|
||||
await tick();
|
||||
ok('called again for each new mount, with that mount\'s signal',
|
||||
made.length === 2 && seen.length === 2 && !!seen[1].bound && seen[1].bound.signal === seen[1].signal
|
||||
&& seen[0].signal.aborted, made);
|
||||
|
||||
const errors = [];
|
||||
const doc2 = new Doc();
|
||||
doc2.body.appendChild(new El('div', { id: 'c', [PAGE_ATTRIBUTE]: 'demo' }));
|
||||
const reg2 = createRegistry({ document: doc2, logger: { error: (...a) => errors.push(a.join(' ')) },
|
||||
mountContext() { throw new Error('boom'); } });
|
||||
let started = 0;
|
||||
reg2.register('demo', { init() { started++; } });
|
||||
await reg2.start();
|
||||
await tick();
|
||||
ok('a throwing mountContext is logged and the page still starts', started === 1 && errors.length === 1, errors);
|
||||
}
|
||||
|
||||
console.log(`\n${pass} passed, ${fail} failed`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
})().catch(e => { console.error(e); process.exit(1); });
|
||||
|
||||
@@ -19,7 +19,7 @@ const ok = (label, cond, extra) => cond
|
||||
? (pass++, console.log(' ok ' + label))
|
||||
: (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' ' + JSON.stringify(extra) : '')));
|
||||
|
||||
function load() {
|
||||
function load(notes) {
|
||||
const handlers = {};
|
||||
const listen = (target) => (type, fn) => { (handlers[target + ':' + type] ||= []).push(fn); };
|
||||
const banner = { style: { display: 'none' } };
|
||||
@@ -43,14 +43,18 @@ function load() {
|
||||
removeItem: (k) => { delete store[k]; },
|
||||
getItem: (k) => (k in store ? store[k] : null),
|
||||
},
|
||||
showNotification: () => {},
|
||||
showNotification: (m, t) => { if (notes) notes.push([m, t]); },
|
||||
setTimeout: () => 0,
|
||||
};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(fs.readFileSync(path.join(V3, 'app.js'), 'utf8'), context);
|
||||
const afterRequest = (handlers['body:htmx:afterRequest'] || [])[0];
|
||||
const fire = ({ status = 200, body, path: reqPath = '/api/v3/anything', reportsItself = false }) => {
|
||||
const elt = { closest: () => (reportsItself ? {} : null) };
|
||||
// `marks`: the attribute selectors the requesting element (or its form)
|
||||
// matches, for app.js's elt.closest(<selector list>).
|
||||
const fire = ({ status = 200, body, path: reqPath = '/api/v3/anything', reportsItself = false, marks = [] }) => {
|
||||
const elt = {
|
||||
closest: (sel) => (reportsItself || sel.split(',').some(s => marks.includes(s.trim())) ? {} : null),
|
||||
};
|
||||
afterRequest({
|
||||
target: { closest: () => null },
|
||||
detail: {
|
||||
@@ -100,5 +104,20 @@ console.log('\nhtmx after-request follows the flag, not the URL');
|
||||
ok('a flagged answer raises it, even from a form that reports itself', t.banner.style.display === 'block');
|
||||
}
|
||||
|
||||
console.log('\nthe server message toast');
|
||||
{
|
||||
const notes = [];
|
||||
const t = load(notes);
|
||||
const answer = { status: 'success', message: 'Schedule saved' };
|
||||
t.fire({ body: answer });
|
||||
ok('a plain htmx request shows the server message', notes.length === 1 && notes[0][0] === 'Schedule saved', notes);
|
||||
t.fire({ body: answer, marks: ['[hx-on\\:htmx\\:after-request]'] });
|
||||
ok('a form with its own hx-on after-request handler does not', notes.length === 1, notes);
|
||||
// Page modules (js/pages/schedule.js) report a form's save from a listener
|
||||
// and mark the form data-reports-result instead of an hx-on attribute.
|
||||
t.fire({ body: answer, marks: ['[data-reports-result]'] });
|
||||
ok('nor does a form a page module reports for (data-reports-result)', notes.length === 1, notes);
|
||||
}
|
||||
|
||||
console.log(`\n${pass} passed, ${fail} failed`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
|
||||
@@ -54,7 +54,10 @@ class TestOnDemandStart:
|
||||
def service(self, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_catalog = None
|
||||
api_v3_module.api_v3.config_manager = None
|
||||
with patch("web_interface.blueprints.api_v3.display._get_display_service_status",
|
||||
# The display answers the control socket (the only way in).
|
||||
with patch("web_interface.blueprints.api_v3.display.control_client.on_demand_start",
|
||||
side_effect=lambda request_id, *a: {"accepted": True}), \
|
||||
patch("web_interface.blueprints.api_v3.display._get_display_service_status",
|
||||
return_value={"active": True}), \
|
||||
patch("web_interface.blueprints.api_v3.display._stop_display_service") as stop, \
|
||||
patch("web_interface.blueprints.api_v3.display._ensure_display_service_running",
|
||||
|
||||
@@ -29,7 +29,7 @@ def installed(api_v3_module, api_v3_client, tmp_path):
|
||||
api.plugin_catalog.plugins_dir = str(tmp_path) # no manifest on disk
|
||||
api.plugin_catalog.get_all_plugin_info = MagicMock(return_value=[info])
|
||||
api.plugin_catalog.get_plugin_display_modes = MagicMock(return_value=declared_modes)
|
||||
api.plugin_store_manager.get_registry_info = MagicMock(return_value=None)
|
||||
api.plugin_store_manager.get_cached_registry_info = MagicMock(return_value=None)
|
||||
api.config_manager.load_config = MagicMock(return_value={})
|
||||
response = api_v3_client.get('/api/v3/plugins/installed')
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -22,7 +22,7 @@ def installed(api_v3_module, api_v3_client, tmp_path):
|
||||
info.update(manifest_extra)
|
||||
api.plugin_catalog.plugins_dir = str(tmp_path) # no manifest on disk
|
||||
api.plugin_catalog.get_all_plugin_info = MagicMock(return_value=[info])
|
||||
api.plugin_store_manager.get_registry_info = MagicMock(return_value=None)
|
||||
api.plugin_store_manager.get_cached_registry_info = MagicMock(return_value=None)
|
||||
api.config_manager.load_config = MagicMock(return_value={})
|
||||
response = api_v3_client.get('/api/v3/plugins/installed')
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -77,11 +77,22 @@ def fresh_web_process(api_v3_module, plugins_dir):
|
||||
|
||||
@pytest.fixture
|
||||
def display_service(api_v3_module):
|
||||
"""Keep on-demand start away from systemctl and the real cache."""
|
||||
cache = api_v3_module.api_v3.cache_manager = MagicMock()
|
||||
with patch('web_interface.blueprints.api_v3.display._get_display_service_status') as status:
|
||||
"""Keep on-demand start away from systemctl and the real cache; the
|
||||
display's control socket is a mock that acks. What it was sent is
|
||||
recorded as ``.set(cmd, request)`` calls on the yielded mock."""
|
||||
api_v3_module.api_v3.cache_manager = MagicMock()
|
||||
sent = MagicMock()
|
||||
|
||||
def ack(request_id, plugin_id, mode, *a, **kw):
|
||||
sent.set('on_demand.start', {'request_id': request_id,
|
||||
'plugin_id': plugin_id, 'mode': mode})
|
||||
return {'accepted': True}
|
||||
|
||||
with patch('web_interface.blueprints.api_v3.display._get_display_service_status') as status, \
|
||||
patch('web_interface.blueprints.api_v3.display.control_client.on_demand_start',
|
||||
side_effect=ack):
|
||||
status.return_value = {'active': True}
|
||||
yield cache
|
||||
yield sent
|
||||
|
||||
|
||||
def _start(client, **body):
|
||||
|
||||
@@ -5,18 +5,20 @@ the web UI and the MQTT bridge send) as "restart": with the service running it
|
||||
ran ``systemctl stop``, slept 1.5s and started it again. Every on-demand or
|
||||
"Preview on display" click therefore cold-restarted the display process --
|
||||
every plugin reloaded, the panel blank for seconds -- to deliver a request the
|
||||
running process polls for every ON_DEMAND_POLL_INTERVAL anyway (see
|
||||
test_on_demand_mailbox.py and test_display_pending_changes.py for the display
|
||||
side: the mailbox is read mid-dwell, mid-screen and mid-Vegas-iteration).
|
||||
running process takes within a frame over its control socket anyway (see
|
||||
test_display_pending_changes.py for the display side: commands land
|
||||
mid-dwell, mid-screen and mid-Vegas-iteration).
|
||||
|
||||
The restart did not buy anything either: a freshly started display restores
|
||||
only the on-demand session it saved itself (``display_on_demand_config``), so
|
||||
the new request reached it through the same mailbox, one cold start later.
|
||||
only the on-demand session it saved itself (``display_on_demand_config``).
|
||||
|
||||
This file previously pinned that restart path (it guarded a broken
|
||||
``import _pkg.time`` inside it). The path is gone; these tests pin its
|
||||
replacement: a running service is left alone, a stopped one is started (only
|
||||
when start_service is set), and the request lands in the mailbox either way.
|
||||
when start_service is set), and the request goes over the control socket
|
||||
either way -- to a stopped display once it has started and its socket is up,
|
||||
sent by the web process's dispatcher after the route has answered 202.
|
||||
Nothing is ever written to the cache: the file mailbox is gone (stage 5).
|
||||
|
||||
The service helpers are patched where they run. display.py binds
|
||||
_get_display_service_status by value, while _ensure_display_service_running
|
||||
@@ -25,6 +27,7 @@ _run_systemctl_command is the one place a systemctl command is issued.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -34,6 +37,8 @@ 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.ipc import client as control_client # noqa: E402
|
||||
|
||||
START_URL = "/api/v3/display/on-demand/start"
|
||||
STOP_URL = "/api/v3/display/on-demand/stop"
|
||||
MAILBOX = "display_on_demand_request"
|
||||
@@ -46,7 +51,10 @@ def service(api_v3_module):
|
||||
|
||||
plugin_manager and config_manager are None so the route skips plugin
|
||||
resolution (not what is under test here). The cache is the blueprint's
|
||||
MagicMock cache_manager, so mailbox writes are visible as set() calls.
|
||||
MagicMock cache_manager, so a write would be visible as a set() call.
|
||||
|
||||
The control socket answers while the service is active and is missing
|
||||
(``no_socket``) while it is not; ``sent`` records what it carried.
|
||||
"""
|
||||
api_v3_module.api_v3.plugin_catalog = None
|
||||
api_v3_module.api_v3.config_manager = None
|
||||
@@ -62,7 +70,32 @@ def service(api_v3_module):
|
||||
state["active"] = False
|
||||
return {"returncode": 0, "stdout": "", "stderr": ""}
|
||||
|
||||
with patch("web_interface.blueprints.api_v3._get_display_service_status",
|
||||
sent = []
|
||||
|
||||
def socket(action):
|
||||
def call(request_id, *args, **kwargs):
|
||||
if not state["active"]:
|
||||
raise control_client.ControlError("no_socket", "x", sent=False)
|
||||
sent.append((action, request_id) + args)
|
||||
return {"accepted": True, "request_id": request_id}
|
||||
return call
|
||||
|
||||
class Clock:
|
||||
now = 1000.0
|
||||
|
||||
def monotonic(self):
|
||||
return self.now
|
||||
|
||||
def time(self):
|
||||
return self.now
|
||||
|
||||
def sleep(self, seconds):
|
||||
self.now += seconds
|
||||
|
||||
with patch("web_interface.blueprints.api_v3.time", Clock()), \
|
||||
patch(f"{DISPLAY}.control_client.on_demand_start", side_effect=socket("start")), \
|
||||
patch(f"{DISPLAY}.control_client.on_demand_stop", side_effect=socket("stop")), \
|
||||
patch("web_interface.blueprints.api_v3._get_display_service_status",
|
||||
side_effect=status), \
|
||||
patch("web_interface.blueprints.api_v3.display._get_display_service_status",
|
||||
side_effect=status), \
|
||||
@@ -74,6 +107,7 @@ def service(api_v3_module):
|
||||
"systemctl": run_systemctl,
|
||||
"stop_service": stop_service,
|
||||
"cache": api_v3_module.api_v3.cache_manager,
|
||||
"sent": sent,
|
||||
}
|
||||
|
||||
|
||||
@@ -99,19 +133,15 @@ class TestStartWhileTheServiceIsRunning:
|
||||
assert _systemctl_verbs(service["systemctl"]) == [], (
|
||||
"a running display service was sent a systemctl command")
|
||||
|
||||
def test_the_request_is_posted_for_the_running_display(self, api_v3_client, service):
|
||||
def test_the_request_is_sent_to_the_running_display(self, api_v3_client, service):
|
||||
response = api_v3_client.post(
|
||||
START_URL, json={"plugin_id": "weather", "mode": "weather_current",
|
||||
"duration": 60, "pinned": True})
|
||||
data = response.get_json()["data"]
|
||||
writes = _mailbox_writes(service["cache"])
|
||||
assert len(writes) == 1
|
||||
assert writes[0]["action"] == "start"
|
||||
assert writes[0]["request_id"] == data["request_id"]
|
||||
assert writes[0]["plugin_id"] == "weather"
|
||||
assert writes[0]["mode"] == "weather_current"
|
||||
assert writes[0]["duration"] == 60
|
||||
assert writes[0]["pinned"] is True
|
||||
assert service["sent"] == [("start", data["request_id"], "weather",
|
||||
"weather_current", 60, True)]
|
||||
assert data["transport"] == "socket"
|
||||
assert _mailbox_writes(service["cache"]) == []
|
||||
|
||||
def test_the_response_reports_the_service_was_not_started(self, api_v3_client, service):
|
||||
data = api_v3_client.post(START_URL, json={"plugin_id": "weather"}).get_json()["data"]
|
||||
@@ -129,11 +159,20 @@ class TestStartWhileTheServiceIsStopped:
|
||||
def test_start_service_starts_it_once_and_never_stops_it(self, api_v3_client, service):
|
||||
service["state"]["active"] = False
|
||||
response = api_v3_client.post(START_URL, json={"plugin_id": "weather"})
|
||||
assert response.status_code == 200, response.get_json()
|
||||
# Answered at once: the web process sends the request in the
|
||||
# background once the started display listens.
|
||||
assert response.status_code == 202, response.get_json()
|
||||
assert response.get_json()["status"] == "starting"
|
||||
assert _systemctl_verbs(service["systemctl"]) == ["start"]
|
||||
service["stop_service"].assert_not_called()
|
||||
# Written before the start, so the new process finds it on its first poll.
|
||||
assert len(_mailbox_writes(service["cache"])) == 1
|
||||
from web_interface import on_demand_dispatch
|
||||
dispatcher = on_demand_dispatch.current()
|
||||
deadline = time.monotonic() + 5
|
||||
while dispatcher.pending() and time.monotonic() < deadline:
|
||||
time.sleep(0.01)
|
||||
assert dispatcher.status()["status"] == "delivered"
|
||||
assert [s[0] for s in service["sent"]] == ["start"]
|
||||
assert _mailbox_writes(service["cache"]) == []
|
||||
|
||||
def test_without_start_service_it_is_left_stopped(self, api_v3_client, service):
|
||||
service["state"]["active"] = False
|
||||
@@ -141,6 +180,7 @@ class TestStartWhileTheServiceIsStopped:
|
||||
START_URL, json={"plugin_id": "weather", "start_service": "false"})
|
||||
assert response.status_code == 400
|
||||
assert _systemctl_verbs(service["systemctl"]) == []
|
||||
assert service["sent"] == []
|
||||
|
||||
def test_a_start_that_fails_is_reported(self, api_v3_client, service):
|
||||
service["state"]["active"] = False
|
||||
@@ -151,31 +191,12 @@ 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.
|
||||
With the mailbox, the request was posted before the route refused it, and
|
||||
a display started later ran it. Now nothing is written anywhere: the
|
||||
request only ever goes over the socket, to a display that answers.
|
||||
|
||||
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
|
||||
@@ -184,76 +205,48 @@ class TestARefusedStartLeavesNoRequestBehind:
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def mailbox(self, api_v3_module, service):
|
||||
box = _Mailbox()
|
||||
api_v3_module.api_v3.cache_manager = box
|
||||
def stopped(self, service):
|
||||
service["state"]["active"] = False
|
||||
return box
|
||||
return service
|
||||
|
||||
@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):
|
||||
self, api_v3_client, stopped, 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"]) == [], (
|
||||
assert _systemctl_verbs(stopped["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):
|
||||
def test_without_start_service_nothing_is_left_behind(self, api_v3_client, stopped):
|
||||
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
|
||||
assert stopped["cache"].set.call_count == 0
|
||||
assert stopped["sent"] == []
|
||||
|
||||
def test_a_start_that_fails_takes_its_request_back(self, api_v3_client, service, mailbox):
|
||||
service["systemctl"].side_effect = lambda args: {
|
||||
def test_a_start_that_fails_leaves_nothing_behind(self, api_v3_client, stopped):
|
||||
stopped["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
|
||||
assert stopped["cache"].set.call_count == 0
|
||||
assert stopped["sent"] == []
|
||||
|
||||
|
||||
class TestStop:
|
||||
def test_stop_posts_a_stop_request_and_leaves_the_service_running(
|
||||
def test_stop_sends_a_stop_request_and_leaves_the_service_running(
|
||||
self, api_v3_client, service):
|
||||
response = api_v3_client.post(STOP_URL, json={})
|
||||
assert response.status_code == 200, response.get_json()
|
||||
writes = _mailbox_writes(service["cache"])
|
||||
assert [w["action"] for w in writes] == ["stop"]
|
||||
assert [s[0] for s in service["sent"]] == ["stop"]
|
||||
assert _mailbox_writes(service["cache"]) == []
|
||||
service["stop_service"].assert_not_called()
|
||||
assert _systemctl_verbs(service["systemctl"]) == []
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"""POST /display/on-demand/start and /stop: control socket first, mailbox fallback.
|
||||
"""POST /display/on-demand/start and /stop: the control socket is the only way.
|
||||
|
||||
The routes hand the request to the display over the control socket
|
||||
(src/ipc) and get an acknowledgement. On any failure -- no socket (a stopped
|
||||
display, or one older than the socket), a timeout, a refusal, a bug in the
|
||||
client -- they write the file mailbox exactly as they did before the socket
|
||||
existed. These tests pin both paths, that exactly one of them is used, that
|
||||
the response says which, and that the request id is the same either way (the
|
||||
display deduplicates on it).
|
||||
(src/ipc) and get an acknowledgement. The file mailbox they used to fall
|
||||
back to (``display_on_demand_request``) is gone (stage 5), so nothing is
|
||||
ever written to the cache. When no display is listening (a stopped one, or
|
||||
one still starting) the start route starts the service if asked to and
|
||||
sends the request again once the socket is up; every other failure is
|
||||
answered as what it is.
|
||||
|
||||
The socket client is patched at the route's module attribute; the last class
|
||||
runs a real server on a temp socket (Linux/macOS only).
|
||||
@@ -14,6 +14,7 @@ runs a real server on a temp socket (Linux/macOS only).
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -30,11 +31,12 @@ START_URL = "/api/v3/display/on-demand/start"
|
||||
STOP_URL = "/api/v3/display/on-demand/stop"
|
||||
MAILBOX = "display_on_demand_request"
|
||||
CLIENT = "web_interface.blueprints.api_v3.display.control_client"
|
||||
DISPLAY = "web_interface.blueprints.api_v3.display"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service(api_v3_module):
|
||||
"""A running display service; records systemctl calls and mailbox writes."""
|
||||
"""A running display service; records systemctl calls and cache writes."""
|
||||
api_v3_module.api_v3.plugin_catalog = None
|
||||
api_v3_module.api_v3.config_manager = None
|
||||
state = {"active": True}
|
||||
@@ -98,74 +100,351 @@ class TestSocketPath:
|
||||
assert _mailbox_writes(service["cache"]) == []
|
||||
|
||||
|
||||
class TestMailboxFallback:
|
||||
@pytest.mark.parametrize("reason", [
|
||||
"no_socket", "refused", "timeout", "closed", "bad_response", "invalid_request",
|
||||
"busy", "unknown_command", "unsupported_version", "disabled", "unsupported",
|
||||
])
|
||||
def test_any_socket_failure_writes_the_mailbox_as_before(
|
||||
self, api_v3_client, service, reason):
|
||||
with patch(f"{CLIENT}.on_demand_start",
|
||||
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})
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()["data"]
|
||||
assert data["transport"] == "mailbox"
|
||||
assert data["socket_error"] == reason
|
||||
[write] = _mailbox_writes(service["cache"])
|
||||
assert write["request_id"] == data["request_id"]
|
||||
assert write["action"] == "start"
|
||||
assert (write["plugin_id"], write["mode"], write["duration"], write["pinned"]) == \
|
||||
("weather", "weather_current", 60, True)
|
||||
class FakeTime:
|
||||
"""Stands in for the route's ``time`` module: sleep() moves the clock."""
|
||||
|
||||
def test_a_client_bug_still_falls_back(self, api_v3_client, service):
|
||||
def __init__(self):
|
||||
self.now = 1000.0
|
||||
self.sleeps = 0
|
||||
|
||||
def monotonic(self):
|
||||
return self.now
|
||||
|
||||
def time(self):
|
||||
return self.now
|
||||
|
||||
def sleep(self, seconds):
|
||||
self.sleeps += 1
|
||||
self.now += seconds
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clock():
|
||||
fake = FakeTime()
|
||||
with patch("web_interface.blueprints.api_v3.time", fake):
|
||||
yield fake
|
||||
|
||||
|
||||
def _attempts(*outcomes, calls=None, clock=None):
|
||||
"""A fake on_demand_start/stop that answers ``outcomes`` in turn (an
|
||||
exception is raised, anything else acks); the last one repeats."""
|
||||
outcomes = list(outcomes)
|
||||
seen = calls if calls is not None else []
|
||||
|
||||
def attempt(request_id, *a, **kw):
|
||||
seen.append(clock.now if clock is not None else None)
|
||||
outcome = outcomes.pop(0) if len(outcomes) > 1 else outcomes[0]
|
||||
if isinstance(outcome, BaseException):
|
||||
raise outcome
|
||||
return _ack(request_id)
|
||||
return attempt
|
||||
|
||||
|
||||
def _until(predicate, timeout=5.0):
|
||||
end = time.monotonic() + timeout
|
||||
while time.monotonic() < end:
|
||||
if predicate():
|
||||
return True
|
||||
time.sleep(0.005)
|
||||
return False
|
||||
|
||||
|
||||
def _no_socket():
|
||||
return control_client.ControlError("no_socket", "x", sent=False)
|
||||
|
||||
|
||||
class TestNoDisplayListening:
|
||||
"""No socket to talk to: the display is stopped, or still starting.
|
||||
|
||||
The route answers at once (202, ``status: "starting"``) and the web
|
||||
process's dispatcher (web_interface/on_demand_dispatch.py) sends the
|
||||
request until the display acknowledges it; the status routes report the
|
||||
outcome. The dispatcher runs on real time with short waits here.
|
||||
"""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def quick(self, monkeypatch, service):
|
||||
from web_interface import on_demand_dispatch
|
||||
monkeypatch.setattr(on_demand_dispatch, "RETRY_INTERVAL", 0.01)
|
||||
monkeypatch.setattr(on_demand_dispatch, "START_WAIT_SECONDS", 2.0)
|
||||
monkeypatch.setattr(f"{DISPLAY}.ON_DEMAND_SOCKET_WAIT_RUNNING_SECONDS", 1.0)
|
||||
service["cache"].get.return_value = None # the display has published nothing
|
||||
|
||||
@staticmethod
|
||||
def _outcome(status=None):
|
||||
from web_interface import on_demand_dispatch
|
||||
d = on_demand_dispatch.current()
|
||||
assert d is not None
|
||||
assert _until(lambda: not d.pending()), "the dispatcher never finished"
|
||||
return d.status()
|
||||
|
||||
def test_a_display_still_starting_gets_the_request_once_it_listens(
|
||||
self, api_v3_client, service):
|
||||
calls = []
|
||||
with patch(f"{CLIENT}.on_demand_start",
|
||||
side_effect=_attempts(_no_socket(), _no_socket(), "ack", calls=calls)):
|
||||
resp = api_v3_client.post(START_URL, json={"plugin_id": "weather"})
|
||||
assert resp.status_code == 202, resp.get_json()
|
||||
body = resp.get_json()
|
||||
assert body["status"] == "starting"
|
||||
data = body["data"]
|
||||
assert data["pending"] is True and data["socket_error"] == "no_socket"
|
||||
assert data["service"]["started"] is False
|
||||
outcome = self._outcome()
|
||||
assert outcome["status"] == "delivered"
|
||||
assert outcome["request_id"] == data["request_id"]
|
||||
assert len(calls) == 3
|
||||
assert not [c for c in service["calls"] if c[0] == "systemctl"]
|
||||
assert _mailbox_writes(service["cache"]) == []
|
||||
|
||||
def test_a_stopped_display_is_started_and_answered_at_once(self, api_v3_client, service):
|
||||
service["state"]["active"] = False
|
||||
with patch(f"{CLIENT}.on_demand_start",
|
||||
side_effect=_attempts(*[_no_socket()] * 6, "ack")) as start:
|
||||
started = time.monotonic()
|
||||
resp = api_v3_client.post(START_URL, json={"plugin_id": "weather",
|
||||
"duration": 30})
|
||||
answered = time.monotonic() - started
|
||||
assert resp.status_code == 202, resp.get_json()
|
||||
data = resp.get_json()["data"]
|
||||
assert data["service"]["started"] is True
|
||||
from web_interface import on_demand_dispatch
|
||||
assert data["wait_seconds"] == on_demand_dispatch.START_WAIT_SECONDS
|
||||
outcome = self._outcome()
|
||||
assert answered < 1.0, "the route waited for the display"
|
||||
assert service["calls"] == [("systemctl", "start")]
|
||||
assert outcome["status"] == "delivered"
|
||||
# The same request, the same id, every time.
|
||||
assert {call.args[0] for call in start.call_args_list} == {data["request_id"]}
|
||||
assert _mailbox_writes(service["cache"]) == []
|
||||
|
||||
def test_while_pending_the_status_routes_say_starting(self, api_v3_client, service):
|
||||
service["state"]["active"] = False
|
||||
with patch(f"{CLIENT}.on_demand_start", side_effect=_no_socket()):
|
||||
rid = api_v3_client.post(START_URL, json={"plugin_id": "weather"}) \
|
||||
.get_json()["data"]["request_id"]
|
||||
status = api_v3_client.get("/api/v3/display/on-demand/status").get_json()["data"]
|
||||
assert status["source"] == "web"
|
||||
assert status["state"]["status"] == "starting"
|
||||
assert status["state"]["request_id"] == rid
|
||||
assert status["state"]["plugin_id"] == "weather"
|
||||
current = api_v3_client.get("/api/v3/display/current-status").get_json()["data"]
|
||||
assert current["on_demand_pending"]["status"] == "starting"
|
||||
|
||||
def test_a_display_that_never_comes_up_is_a_start_timeout(self, api_v3_client, service):
|
||||
service["state"]["active"] = False
|
||||
with patch(f"{CLIENT}.on_demand_start", side_effect=_no_socket()):
|
||||
resp = api_v3_client.post(START_URL, json={"plugin_id": "weather"})
|
||||
assert resp.status_code == 202
|
||||
outcome = self._outcome()
|
||||
assert outcome["status"] == "error" and outcome["error"] == "start-timeout"
|
||||
status = api_v3_client.get("/api/v3/display/on-demand/status").get_json()["data"]
|
||||
assert status["state"]["status"] == "error"
|
||||
assert status["state"]["error"] == "start-timeout"
|
||||
current = api_v3_client.get("/api/v3/display/current-status").get_json()["data"]
|
||||
assert current["on_demand_pending"]["error"] == "start-timeout"
|
||||
|
||||
def test_a_later_display_state_replaces_the_failure(self, api_v3_client, service):
|
||||
service["state"]["active"] = False
|
||||
with patch(f"{CLIENT}.on_demand_start", side_effect=_no_socket()):
|
||||
api_v3_client.post(START_URL, json={"plugin_id": "weather"})
|
||||
failed_at = self._outcome()["last_updated"]
|
||||
later = {"active": True, "status": "active", "plugin_id": "clock",
|
||||
"last_updated": failed_at + 5}
|
||||
service["cache"].get.return_value = later
|
||||
status = api_v3_client.get("/api/v3/display/on-demand/status").get_json()["data"]
|
||||
assert status["state"]["plugin_id"] == "clock" and status["source"] == "cache"
|
||||
|
||||
def test_a_running_service_without_a_socket_is_waited_for_less(
|
||||
self, api_v3_client, service):
|
||||
with patch(f"{CLIENT}.on_demand_start", side_effect=_no_socket()):
|
||||
resp = api_v3_client.post(START_URL, json={"plugin_id": "weather"})
|
||||
assert resp.status_code == 202
|
||||
assert resp.get_json()["data"]["wait_seconds"] == 1.0
|
||||
assert self._outcome()["error"] == "start-timeout"
|
||||
assert not [c for c in service["calls"] if c[0] == "systemctl"]
|
||||
|
||||
def test_a_different_failure_while_waiting_ends_it(self, api_v3_client, service):
|
||||
calls = []
|
||||
busy = control_client.ControlError("busy", "x", sent=True)
|
||||
with patch(f"{CLIENT}.on_demand_start",
|
||||
side_effect=_attempts(_no_socket(), _no_socket(), busy, calls=calls)):
|
||||
assert api_v3_client.post(START_URL, json={"plugin_id": "weather"}).status_code == 202
|
||||
outcome = self._outcome()
|
||||
assert outcome["status"] == "error" and outcome["error"] == "busy"
|
||||
assert len(calls) == 3
|
||||
|
||||
def test_a_stop_while_pending_cancels_it(self, api_v3_client, service):
|
||||
service["state"]["active"] = False
|
||||
calls = []
|
||||
with patch(f"{CLIENT}.on_demand_start",
|
||||
side_effect=_attempts(_no_socket(), calls=calls)), \
|
||||
patch(f"{CLIENT}.on_demand_stop", side_effect=_no_socket()):
|
||||
rid = api_v3_client.post(START_URL, json={"plugin_id": "weather"}) \
|
||||
.get_json()["data"]["request_id"]
|
||||
resp = api_v3_client.post(STOP_URL, json={})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
data = resp.get_json()["data"]
|
||||
assert data["cancelled_request_id"] == rid
|
||||
outcome = self._outcome()
|
||||
n = len(calls)
|
||||
time.sleep(0.05)
|
||||
assert len(calls) == n, "the cancelled start was still being sent"
|
||||
assert outcome["status"] == "idle" and outcome["last_event"] == "requested-stop"
|
||||
status = api_v3_client.get("/api/v3/display/on-demand/status").get_json()["data"]
|
||||
assert status["state"]["status"] == "idle" and status["source"] != "web"
|
||||
|
||||
def test_a_new_start_supersedes_the_pending_one(self, api_v3_client, service):
|
||||
service["state"]["active"] = False
|
||||
with patch(f"{CLIENT}.on_demand_start", side_effect=_no_socket()):
|
||||
old = api_v3_client.post(START_URL, json={"plugin_id": "weather"}) \
|
||||
.get_json()["data"]["request_id"]
|
||||
sent = []
|
||||
service["state"]["active"] = True
|
||||
with patch(f"{CLIENT}.on_demand_start",
|
||||
side_effect=lambda rid, *a: sent.append(rid) or {"accepted": True}):
|
||||
resp = api_v3_client.post(START_URL, json={"plugin_id": "clock"})
|
||||
assert resp.status_code == 200
|
||||
outcome = self._outcome()
|
||||
assert sent == [resp.get_json()["data"]["request_id"]]
|
||||
assert old not in sent
|
||||
assert outcome["status"] == "idle" and outcome["last_event"] == "superseded"
|
||||
|
||||
def test_a_service_that_will_not_start_is_an_error(self, api_v3_client, service, clock):
|
||||
service["state"]["active"] = False
|
||||
with patch("web_interface.blueprints.api_v3._run_systemctl_command",
|
||||
return_value={"returncode": 1, "stdout": "", "stderr": "nope"}), \
|
||||
patch(f"{CLIENT}.on_demand_start", side_effect=_no_socket()) as start:
|
||||
resp = api_v3_client.post(START_URL, json={"plugin_id": "weather"})
|
||||
assert resp.status_code == 500
|
||||
assert "Failed to start display service" in resp.get_json()["message"]
|
||||
assert start.call_count == 1
|
||||
|
||||
def test_stop_with_no_display_running_is_an_error(self, api_v3_client, service, clock):
|
||||
service["state"]["active"] = False
|
||||
with patch(f"{CLIENT}.on_demand_stop", side_effect=_no_socket()) as stop:
|
||||
resp = api_v3_client.post(STOP_URL, json={})
|
||||
assert resp.status_code == 503
|
||||
body = resp.get_json()
|
||||
assert "not running" in body["message"]
|
||||
assert body["data"]["socket_error"] == "no_socket"
|
||||
assert stop.call_count == 1 and clock.sleeps == 0
|
||||
assert service["calls"] == []
|
||||
assert _mailbox_writes(service["cache"]) == []
|
||||
|
||||
def test_stop_with_a_running_service_but_no_socket_is_an_error(
|
||||
self, api_v3_client, service, clock):
|
||||
with patch(f"{CLIENT}.on_demand_stop",
|
||||
side_effect=control_client.ControlError("refused", "x")):
|
||||
resp = api_v3_client.post(STOP_URL, json={})
|
||||
assert resp.status_code == 503
|
||||
assert "still be starting" in resp.get_json()["message"]
|
||||
|
||||
def test_stop_with_stop_service_stops_it_anyway(self, api_v3_client, service, clock):
|
||||
with patch(f"{CLIENT}.on_demand_stop", side_effect=_no_socket()), \
|
||||
patch(f"{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
|
||||
assert resp.get_json()["data"]["socket_error"] == "no_socket"
|
||||
stop.assert_called_once()
|
||||
assert _mailbox_writes(service["cache"]) == []
|
||||
|
||||
def test_the_socket_is_off_in_the_test_suite(self, api_v3_client, service):
|
||||
# conftest's _hermetic_control_socket: a suite run on a device must
|
||||
# not drive the live display. Nothing is retried or started for it.
|
||||
assert os.environ[c.SOCKET_PATH_ENV] == "off"
|
||||
service["state"]["active"] = False
|
||||
resp = api_v3_client.post(START_URL, json={"plugin_id": "weather"})
|
||||
assert resp.status_code == 503
|
||||
assert resp.get_json()["data"]["socket_error"] in ("disabled", "unsupported")
|
||||
assert service["calls"] == []
|
||||
|
||||
|
||||
class TestNothingElseIsRetried:
|
||||
@pytest.mark.parametrize("reason,sent,status", [
|
||||
("timeout", False, 503), ("busy", False, 503), ("forbidden", False, 503),
|
||||
("invalid_request", False, 503), ("disabled", False, 503),
|
||||
("unsupported", False, 503), ("unknown_command", True, 503),
|
||||
("unsupported_version", True, 503),
|
||||
])
|
||||
def test_answered_at_once_and_nothing_written(self, api_v3_client, service, clock,
|
||||
reason, sent, status):
|
||||
service["state"]["active"] = False
|
||||
with patch(f"{CLIENT}.on_demand_start",
|
||||
side_effect=control_client.ControlError(reason, "x", sent=sent)) as start:
|
||||
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"]["socket_error"] == reason
|
||||
assert start.call_count == 1 and clock.sleeps == 0
|
||||
assert service["calls"] == []
|
||||
|
||||
def test_a_client_bug_is_an_error(self, api_v3_client, service):
|
||||
with patch(f"{CLIENT}.on_demand_start", side_effect=RuntimeError("boom")):
|
||||
resp = api_v3_client.post(START_URL, json={"plugin_id": "weather"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.status_code == 503
|
||||
assert resp.get_json()["data"]["socket_error"] == "internal"
|
||||
assert len(_mailbox_writes(service["cache"])) == 1
|
||||
assert _mailbox_writes(service["cache"]) == []
|
||||
|
||||
def test_an_unknown_reason_is_reported_as_other(self, api_v3_client, service):
|
||||
# Only known codes are echoed back; anything else stays server-side.
|
||||
with patch(f"{CLIENT}.on_demand_start",
|
||||
side_effect=control_client.ControlError("/run/secret/path", "x")):
|
||||
data = api_v3_client.post(START_URL, json={"plugin_id": "weather"}).get_json()["data"]
|
||||
assert data["transport"] == "mailbox"
|
||||
assert data["socket_error"] == "other"
|
||||
assert len(_mailbox_writes(service["cache"])) == 1
|
||||
resp = api_v3_client.post(START_URL, json={"plugin_id": "weather"})
|
||||
assert resp.status_code == 503
|
||||
assert resp.get_json()["data"]["socket_error"] == "other"
|
||||
assert "/run/secret" not in resp.get_data(as_text=True)
|
||||
|
||||
def test_every_display_error_code_is_reportable(self):
|
||||
from web_interface.blueprints.api_v3 import display
|
||||
codes = {v for k, v in vars(c.ErrorCode).items() if not k.startswith("_")}
|
||||
assert codes <= set(display._REPORTABLE_SOCKET_REASONS)
|
||||
|
||||
def test_stop_falls_back(self, api_v3_client, service):
|
||||
with patch(f"{CLIENT}.on_demand_stop",
|
||||
side_effect=control_client.ControlError("timeout")):
|
||||
data = api_v3_client.post(STOP_URL, json={}).get_json()["data"]
|
||||
assert data["transport"] == "mailbox"
|
||||
[write] = _mailbox_writes(service["cache"])
|
||||
assert write == {"request_id": data["request_id"], "action": "stop",
|
||||
"timestamp": write["timestamp"]}
|
||||
|
||||
def test_a_stopped_display_gets_the_mailbox_before_it_is_started(
|
||||
self, api_v3_client, service):
|
||||
service["state"]["active"] = False
|
||||
class TestTheDisplayHadIt:
|
||||
"""Once the display has the request, its answer stands.
|
||||
|
||||
A busy queue, a refusal or silence after the request was sent mean the
|
||||
display may have applied it, so the route reports the failure and does
|
||||
not send it again.
|
||||
"""
|
||||
|
||||
@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("no_socket")):
|
||||
side_effect=control_client.ControlError(reason, "x", sent=True)):
|
||||
resp = api_v3_client.post(START_URL, json={"plugin_id": "weather"})
|
||||
assert resp.status_code == 200
|
||||
assert service["calls"] == [("cache", MAILBOX), ("systemctl", "start")]
|
||||
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_the_socket_is_off_in_the_test_suite(self, api_v3_client, service):
|
||||
# conftest's _hermetic_control_socket: a suite run on a device must
|
||||
# not drive the live display.
|
||||
assert os.environ[c.SOCKET_PATH_ENV] == "off"
|
||||
data = api_v3_client.post(START_URL, json={"plugin_id": "weather"}).get_json()["data"]
|
||||
assert data["transport"] == "mailbox"
|
||||
assert data["socket_error"] in ("disabled", "unsupported") # Linux, Windows
|
||||
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.skipif(not c.socket_supported(), reason="AF_UNIX sockets are Linux/macOS only")
|
||||
@@ -199,8 +478,31 @@ class TestRealSocket:
|
||||
assert data["transport"] == "socket"
|
||||
assert [x.request_id for x in live.drain()] == [data["request_id"]]
|
||||
|
||||
def test_a_display_that_went_away_falls_back(self, api_v3_client, service, live):
|
||||
def test_a_display_that_went_away_is_an_error(self, api_v3_client, service, live,
|
||||
monkeypatch):
|
||||
monkeypatch.setattr(f"{DISPLAY}.ON_DEMAND_SOCKET_WAIT_RUNNING_SECONDS", 0.3)
|
||||
live.close()
|
||||
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
|
||||
resp = api_v3_client.post(START_URL, json={"plugin_id": "weather"})
|
||||
assert resp.status_code == 503
|
||||
assert resp.get_json()["data"]["socket_error"] == "no_socket"
|
||||
assert _mailbox_writes(service["cache"]) == []
|
||||
|
||||
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)
|
||||
|
||||
+399
-12
@@ -9,6 +9,7 @@ that overrides the schedule and ends (#714).
|
||||
"""
|
||||
|
||||
import itertools
|
||||
from dataclasses import replace
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@@ -19,6 +20,7 @@ os.environ.setdefault("EMULATOR", "true")
|
||||
from src import display_arbiter # noqa: E402
|
||||
from src.display_arbiter import ( # noqa: E402
|
||||
SCHEDULED_OFF_DWELL,
|
||||
SCREEN_PREEMPTERS,
|
||||
WIFI_NOTICE_DWELL,
|
||||
Arbiter,
|
||||
ArbiterInputs,
|
||||
@@ -26,6 +28,9 @@ from src.display_arbiter import ( # noqa: E402
|
||||
ScreenPlan,
|
||||
Source,
|
||||
WifiNotice,
|
||||
live_pick,
|
||||
live_takeover,
|
||||
on_demand_bound,
|
||||
wifi_notice_preempts,
|
||||
)
|
||||
|
||||
@@ -34,7 +39,9 @@ NOTICE = WifiNotice(message="Connected to HomeNet", expires_at=1_000.0)
|
||||
OFF = Source.SCHEDULED_OFF
|
||||
FOLLOW = Source.FOLLOWER
|
||||
WIFI = Source.WIFI
|
||||
ONDEM = Source.ON_DEMAND
|
||||
LEGACY = Source.LEGACY
|
||||
ROTATION = Source.ROTATION
|
||||
|
||||
# (schedule_on, on_demand_active, follower_active, notice) -> Source.
|
||||
# Every combination of the stage-2 inputs: 2 x 2 x 2 x 2 = 16 rows.
|
||||
@@ -46,17 +53,17 @@ DECIDE_TABLE = [
|
||||
(False, False, True, None, OFF),
|
||||
(False, False, True, NOTICE, OFF),
|
||||
# Scheduled off, but on-demand overrides the gate.
|
||||
(False, True, False, None, LEGACY), # on-demand: run() decides
|
||||
(False, True, False, NOTICE, LEGACY), # on-demand outranks WiFi
|
||||
(False, True, False, None, ONDEM), # on-demand overrides the gate
|
||||
(False, True, False, NOTICE, ONDEM), # on-demand outranks WiFi
|
||||
(False, True, True, None, FOLLOW), # follower outranks on-demand
|
||||
(False, True, True, NOTICE, FOLLOW),
|
||||
# Scheduled on.
|
||||
(True, False, False, None, LEGACY), # live / Vegas / rotation
|
||||
(True, False, False, None, ROTATION), # live / Vegas / rotation
|
||||
(True, False, False, NOTICE, WIFI),
|
||||
(True, False, True, None, FOLLOW),
|
||||
(True, False, True, NOTICE, FOLLOW), # follower outranks WiFi
|
||||
(True, True, False, None, LEGACY),
|
||||
(True, True, False, NOTICE, LEGACY), # on-demand outranks WiFi
|
||||
(True, True, False, None, ONDEM),
|
||||
(True, True, False, NOTICE, ONDEM), # on-demand outranks WiFi
|
||||
(True, True, True, None, FOLLOW),
|
||||
(True, True, True, NOTICE, FOLLOW),
|
||||
]
|
||||
@@ -95,8 +102,15 @@ class TestDecide:
|
||||
elif expected is WIFI:
|
||||
assert plan == ScreenPlan(WIFI, max_duration=WIFI_NOTICE_DWELL,
|
||||
notice=NOTICE)
|
||||
elif expected is ONDEM:
|
||||
# An empty state: a session with no modes, which the
|
||||
# controller ends (see TestOnDemand for real sessions).
|
||||
assert plan == ScreenPlan(ONDEM)
|
||||
elif expected is ROTATION:
|
||||
# No live scan and Vegas off: the rotation's (empty) mode.
|
||||
assert plan == ScreenPlan(ROTATION, preemptible_by=SCREEN_PREEMPTERS)
|
||||
else:
|
||||
# A follower paces itself; LEGACY is run()'s existing code.
|
||||
# A follower paces itself.
|
||||
assert plan == ScreenPlan(expected)
|
||||
|
||||
def test_dwells_are_todays(self):
|
||||
@@ -224,16 +238,389 @@ class TestControllerSnapshot:
|
||||
assert (plan.source is OFF) is (not dc.is_display_active), time_str
|
||||
return plan.source
|
||||
|
||||
assert step("22:59:30") is LEGACY
|
||||
assert step("22:59:30") is ROTATION
|
||||
assert step("23:00:00") is OFF # window ends
|
||||
dc.on_demand_active = True
|
||||
assert step("23:00:10") is LEGACY # on-demand overrides
|
||||
assert step("23:00:10") is ONDEM # on-demand overrides
|
||||
assert dc.on_demand_schedule_override is True
|
||||
assert step("23:01:00") is LEGACY # next minute, still on
|
||||
assert step("23:01:00") is ONDEM # next minute, still on
|
||||
dc._reset_on_demand_fields() # session ends
|
||||
assert step("23:01:20") is OFF # same minute: blanks
|
||||
dc.on_demand_active = True
|
||||
assert step("06:59:00") is LEGACY
|
||||
assert step("07:00:00") is LEGACY # schedule back on mid-session
|
||||
assert step("06:59:00") is ONDEM
|
||||
assert step("07:00:00") is ONDEM # schedule back on mid-session
|
||||
dc._reset_on_demand_fields()
|
||||
assert step("07:00:30") is LEGACY
|
||||
assert step("07:00:30") is ROTATION
|
||||
|
||||
|
||||
# -- OnDemand (stage 3) ---------------------------------------------------
|
||||
|
||||
ON = ArbiterInputs(schedule_on=True, on_demand_active=True, follower_active=False)
|
||||
|
||||
|
||||
def _session(modes=("a", "b", "c"), index=0, expires_at=None, current=None):
|
||||
return ArbiterState(current_mode=current, on_demand_modes=tuple(modes),
|
||||
on_demand_index=index, on_demand_expires_at=expires_at)
|
||||
|
||||
|
||||
class TestOnDemand:
|
||||
|
||||
# (modes, index, expires_at, now) -> (mode, max_duration)
|
||||
TABLE = [
|
||||
(("a", "b", "c"), 0, None, 100.0, "a", None), # untimed
|
||||
(("a", "b", "c"), 2, None, 100.0, "c", None),
|
||||
(("a", "b", "c"), 3, None, 100.0, "a", None), # past the end: 0
|
||||
(("a", "b", "c"), 9, None, 100.0, "a", None),
|
||||
(("a",), 0, 130.0, 100.0, "a", 30.0), # 30 s left
|
||||
(("a",), 0, 130.0, 130.0, "a", 0.0), # none left
|
||||
(("a",), 0, 130.0, 200.0, "a", 0.0), # never negative
|
||||
]
|
||||
|
||||
@pytest.mark.parametrize("modes,index,expires_at,now,mode,max_duration", TABLE)
|
||||
def test_current_mode_and_time_left(self, modes, index, expires_at, now, mode,
|
||||
max_duration):
|
||||
plan = Arbiter.decide(_session(modes, index, expires_at), ON, now)
|
||||
assert plan.source is ONDEM
|
||||
assert plan.mode == mode
|
||||
assert plan.max_duration == max_duration
|
||||
assert plan.deadline == expires_at
|
||||
|
||||
def test_no_modes_left_is_a_plan_with_no_mode(self):
|
||||
plan = Arbiter.decide(_session(modes=()), ON, 0.0)
|
||||
assert plan == ScreenPlan(ONDEM)
|
||||
|
||||
def test_preemptible_by_the_schedule_a_reload_and_its_own_changes(self):
|
||||
plan = Arbiter.decide(_session(), ON, 0.0)
|
||||
assert {Source.SCHEDULED_OFF, ONDEM, Source.RELOAD} <= plan.preemptible_by
|
||||
assert Source.FOLLOWER not in plan.preemptible_by
|
||||
|
||||
@pytest.mark.parametrize("index,expected_index,expected_mode", [
|
||||
(0, 1, "b"), (1, 2, "c"), (2, 0, "a")])
|
||||
def test_next_on_demand_wraps(self, index, expected_index, expected_mode):
|
||||
nxt = _session(index=index).next_on_demand()
|
||||
assert (nxt.on_demand_index, nxt.current_mode) == (expected_index, expected_mode)
|
||||
|
||||
def test_showing_a_plan_resets_an_index_past_the_end(self):
|
||||
state = _session(index=5, current="x")
|
||||
plan = Arbiter.decide(state, ON, 0.0)
|
||||
shown = state.showing(plan)
|
||||
assert (shown.on_demand_index, shown.current_mode) == (0, "a")
|
||||
assert state.on_demand_index == 5 # not mutated
|
||||
|
||||
|
||||
# (min, max, deadline, now) -> bounds. The bound applied after the first frame.
|
||||
BOUND_TABLE = [
|
||||
(10.0, 20.0, None, 0.0, (10.0, 20.0)), # untimed: unchanged
|
||||
(10.0, 20.0, 100.0, 50.0, (10.0, 20.0)), # plenty left
|
||||
(10.0, 20.0, 100.0, 85.0, (10.0, 15.0)), # max cut to what is left
|
||||
(10.0, 20.0, 100.0, 95.0, (5.0, 5.0)), # both cut
|
||||
(10.0, 20.0, 100.0, 100.0, None), # nothing left
|
||||
(10.0, 20.0, 100.0, 150.0, None),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("min_d,max_d,deadline,now,expected", BOUND_TABLE)
|
||||
def test_on_demand_bound(min_d, max_d, deadline, now, expected):
|
||||
assert on_demand_bound(min_d, max_d, deadline, now) == expected
|
||||
|
||||
|
||||
# -- Live (stage 3) -------------------------------------------------------
|
||||
|
||||
LIVE = Source.LIVE
|
||||
|
||||
# (live_modes, current_mode, advance) -> pick. _check_live_priority's rule.
|
||||
PICK_TABLE = [
|
||||
((), "clock", True, None),
|
||||
(None, "clock", True, None),
|
||||
(("nfl",), "clock", True, "nfl"), # not on a live mode: first
|
||||
(("nfl", "nhl"), "clock", True, "nfl"),
|
||||
(("nfl", "nhl"), "clock", False, "nfl"),
|
||||
(("nfl", "nhl"), "nfl", True, "nhl"), # round-robin
|
||||
(("nfl", "nhl"), "nhl", True, "nfl"), # wraps
|
||||
(("nfl", "nhl"), "nhl", False, "nhl"), # a peek stays put
|
||||
(("nfl",), "nfl", True, "nfl"), # one game: itself
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("live,current,advance,expected", PICK_TABLE)
|
||||
def test_live_pick(live, current, advance, expected):
|
||||
assert live_pick(live, current, advance) == expected
|
||||
|
||||
|
||||
def _below(live=None, vegas=False, keeps=False, yielded=False, on_demand=False):
|
||||
"""Inputs for the Sources below the notice (no notice, no follower)."""
|
||||
return ArbiterInputs(schedule_on=True, on_demand_active=on_demand,
|
||||
follower_active=False, live_modes=live, vegas_enabled=vegas,
|
||||
vegas_live_in_ticker=keeps, vegas_yielded=yielded)
|
||||
|
||||
|
||||
ROT = ("clock", "weather", "nfl_live", "nhl_live")
|
||||
|
||||
|
||||
def _rot(current="clock", index=0, resume=None, unshown=False):
|
||||
return ArbiterState(current_mode=current, rotation=ROT, rotation_index=index,
|
||||
live_resume_index=resume, live_takeover_unshown=unshown)
|
||||
|
||||
|
||||
class TestLive:
|
||||
|
||||
# (state, inputs) -> (source, mode, ends_live)
|
||||
TABLE = [
|
||||
# Nothing live, nothing to resume.
|
||||
(_rot(), _below(live=()), "below", None, False),
|
||||
# Not scanned (on-demand, or the ticker keeps live content).
|
||||
(_rot(), _below(live=None), "below", None, False),
|
||||
# A game is live: it takes the panel.
|
||||
(_rot(), _below(live=("nfl_live",)), LIVE, "nfl_live", False),
|
||||
# Two: round-robin from the one showing.
|
||||
(_rot("nfl_live", 2), _below(live=("nfl_live", "nhl_live")), LIVE, "nhl_live", False),
|
||||
# ... unless a mid-screen takeover chose it and it has not shown yet.
|
||||
(_rot("nfl_live", 2, resume=0, unshown=True),
|
||||
_below(live=("nfl_live", "nhl_live")), LIVE, "nfl_live", False),
|
||||
# Vegas keeps live content in its ticker: Live has no say at all,
|
||||
# not even the resume.
|
||||
(_rot(), _below(live=("nfl_live",), vegas=True, keeps=True), "below", None, False),
|
||||
(_rot("nfl_live", 2, resume=1),
|
||||
_below(live=(), vegas=True, keeps=True), "below", None, False),
|
||||
# Vegas that yields to live content: Live outranks it.
|
||||
(_rot(), _below(live=("nfl_live",), vegas=True), LIVE, "nfl_live", False),
|
||||
# The game ended: the interrupted rotation resumes.
|
||||
(_rot("nfl_live", 2, resume=1), _below(live=()), "below", None, True),
|
||||
(_rot("nfl_live", 2, resume=1), _below(live=(), vegas=True), "below", None, True),
|
||||
# On-demand outranks Live.
|
||||
(ArbiterState(on_demand_modes=("x",)), _below(live=("nfl_live",), on_demand=True),
|
||||
ONDEM, "x", False),
|
||||
]
|
||||
|
||||
@pytest.mark.parametrize("state,inputs,source,mode,ends_live", TABLE)
|
||||
def test_decide(self, state, inputs, source, mode, ends_live):
|
||||
plan = Arbiter.decide(state, inputs, 0.0)
|
||||
if source == "below":
|
||||
assert plan.source not in (LIVE, ONDEM, OFF, FOLLOW, WIFI)
|
||||
else:
|
||||
assert plan.source is source
|
||||
assert plan.mode == mode
|
||||
assert plan.ends_live is ends_live
|
||||
|
||||
def test_a_live_plan_has_no_durations_until_its_first_frame(self):
|
||||
plan = Arbiter.decide(_rot(), _below(live=("nfl_live",)), 0.0)
|
||||
assert (plan.min_duration, plan.max_duration, plan.frame_policy) == (None, None, None)
|
||||
|
||||
|
||||
class TestLiveTransitions:
|
||||
|
||||
def test_claim_saves_where_the_rotation_was(self):
|
||||
nxt = _rot("weather", 1).claim_live("nfl_live")
|
||||
assert (nxt.current_mode, nxt.rotation_index, nxt.live_resume_index) == ("nfl_live", 2, 1)
|
||||
|
||||
def test_a_second_claim_keeps_the_first_resume_point(self):
|
||||
nxt = _rot("nfl_live", 2, resume=1).claim_live("nhl_live")
|
||||
assert (nxt.current_mode, nxt.rotation_index, nxt.live_resume_index) == ("nhl_live", 3, 1)
|
||||
|
||||
def test_claiming_the_mode_showing_changes_nothing(self):
|
||||
state = _rot("nfl_live", 2, resume=1)
|
||||
assert state.claim_live("nfl_live") is state
|
||||
|
||||
def test_a_live_mode_outside_the_rotation_keeps_the_index(self):
|
||||
nxt = _rot("weather", 1).claim_live("mlb_live")
|
||||
assert (nxt.current_mode, nxt.rotation_index, nxt.live_resume_index) == ("mlb_live", 1, 1)
|
||||
|
||||
def test_release_resumes_and_forgets(self):
|
||||
nxt = _rot("nhl_live", 3, resume=1).release_live()
|
||||
assert (nxt.current_mode, nxt.rotation_index, nxt.live_resume_index) == ("weather", 1, None)
|
||||
|
||||
def test_release_wraps_a_resume_point_past_a_shortened_rotation(self):
|
||||
nxt = _rot("nhl_live", 3, resume=6).release_live()
|
||||
assert (nxt.current_mode, nxt.rotation_index) == ("nfl_live", 2) # 6 % 4
|
||||
|
||||
def test_release_with_nothing_to_resume_changes_nothing(self):
|
||||
state = _rot("clock", 0)
|
||||
assert state.release_live() is state
|
||||
empty = ArbiterState(current_mode="x", live_resume_index=2)
|
||||
assert empty.release_live() is empty # no rotation to resume into
|
||||
|
||||
|
||||
# -- Vegas and Rotation (stage 3) -----------------------------------------
|
||||
|
||||
class TestVegasAndRotation:
|
||||
|
||||
# (state, inputs) -> (source, mode, ends_live). LEGACY now means Vegas only.
|
||||
TABLE = [
|
||||
(_rot("weather", 1), _below(live=()), ROTATION, "weather", False),
|
||||
(_rot("weather", 1), _below(live=None), ROTATION, "weather", False),
|
||||
(_rot("weather", 1), _below(live=(), vegas=True), LEGACY, None, False),
|
||||
(_rot("weather", 1), _below(live=None, vegas=True, keeps=True), LEGACY, None, False),
|
||||
# The iteration yielded: the screen it fell through to.
|
||||
(_rot("weather", 1), _below(live=(), vegas=True, yielded=True),
|
||||
ROTATION, "weather", False),
|
||||
(_rot("weather", 1), _below(live=("nfl_live",), vegas=True, yielded=True),
|
||||
LIVE, "nfl_live", False),
|
||||
# Live priority just ended: the rotation resumes where it was cut.
|
||||
(_rot("nhl_live", 3, resume=1), _below(live=()), ROTATION, "weather", True),
|
||||
# ... and Vegas carries the resume through to its own pass.
|
||||
(_rot("nhl_live", 3, resume=1), _below(live=(), vegas=True), LEGACY, None, True),
|
||||
# A rotation that something moved off its list carries on from there.
|
||||
(ArbiterState(current_mode=None, rotation=ROT), _below(live=()), ROTATION, None, False),
|
||||
]
|
||||
|
||||
@pytest.mark.parametrize("state,inputs,source,mode,ends_live", TABLE)
|
||||
def test_decide(self, state, inputs, source, mode, ends_live):
|
||||
plan = Arbiter.decide(state, inputs, 0.0)
|
||||
assert (plan.source, plan.mode, plan.ends_live) == (source, mode, ends_live)
|
||||
|
||||
def test_a_rotation_plan_may_be_preempted_by_everything_a_screen_watches(self):
|
||||
plan = Arbiter.decide(_rot(), _below(live=()), 0.0)
|
||||
assert plan.preemptible_by == SCREEN_PREEMPTERS
|
||||
assert {OFF, ONDEM, WIFI, LIVE, ROTATION, Source.RELOAD} == SCREEN_PREEMPTERS
|
||||
|
||||
|
||||
class _End:
|
||||
def __init__(self, on_demand_active=False, still_live=False):
|
||||
self.on_demand_active = on_demand_active
|
||||
self.still_live = still_live
|
||||
|
||||
|
||||
class TestAfter:
|
||||
"""ArbiterState.after: _advance_after_screen's step."""
|
||||
|
||||
def test_the_rotation_advances(self):
|
||||
nxt = _rot("weather", 1).after(_End())
|
||||
assert (nxt.current_mode, nxt.rotation_index) == ("nfl_live", 2)
|
||||
|
||||
def test_it_wraps(self):
|
||||
nxt = _rot("nhl_live", 3).after(_End())
|
||||
assert (nxt.current_mode, nxt.rotation_index) == ("clock", 0)
|
||||
|
||||
def test_a_live_mode_still_live_holds(self):
|
||||
state = _rot("nfl_live", 2)
|
||||
assert state.after(_End(still_live=True)) is state
|
||||
|
||||
def test_an_on_demand_session_moves_to_its_next_mode(self):
|
||||
state = replace(_session(index=1, current="b"), rotation=ROT, rotation_index=1)
|
||||
nxt = state.after(_End(on_demand_active=True))
|
||||
assert (nxt.current_mode, nxt.on_demand_index, nxt.rotation_index) == ("c", 2, 1)
|
||||
|
||||
def test_a_session_with_no_modes_is_left_to_the_controller(self):
|
||||
state = ArbiterState(current_mode="x", rotation=ROT)
|
||||
assert state.after(_End(on_demand_active=True)) is state
|
||||
|
||||
def test_no_rotation_no_step(self):
|
||||
state = ArbiterState(current_mode="x")
|
||||
assert state.after(_End()) is state
|
||||
|
||||
|
||||
# -- Mid-screen: decide(..., running=plan) (stage 3) ----------------------
|
||||
#
|
||||
# What the ScreenRunner asks at each service point. These rows are what
|
||||
# _check_live_takeover, _screen_preempted and _wifi_notice_pending answered
|
||||
# between frames before stage 3, written out.
|
||||
|
||||
CLOCK = Arbiter.decide(ArbiterState(current_mode="clock"), _below(live=()), 0.0)
|
||||
NFL = Arbiter.decide(ArbiterState(current_mode="clock"), _below(live=("nfl_live",)), 0.0)
|
||||
OD = Arbiter.decide(_session(modes=("x", "y")), ON, 0.0)
|
||||
FRESH = WifiNotice(message="AP mode", expires_at=1_000.0)
|
||||
|
||||
HELD = "held"
|
||||
|
||||
|
||||
def _mid(on_demand=False, schedule_on=True, live=None, notice=None, reload=False):
|
||||
return ArbiterInputs(schedule_on=schedule_on, on_demand_active=on_demand,
|
||||
follower_active=False, live_modes=live, wifi_notice=notice,
|
||||
reload_pending=reload)
|
||||
|
||||
|
||||
# (running, current_mode, inputs, now) -> HELD or (source, mode)
|
||||
MID_TABLE = [
|
||||
# Nothing changed.
|
||||
(CLOCK, "clock", _mid(), 500.0, HELD),
|
||||
(CLOCK, "clock", _mid(live=()), 500.0, HELD),
|
||||
# A game went live: it takes the panel (the first live mode).
|
||||
(CLOCK, "clock", _mid(live=("nfl_live", "nhl_live")), 500.0, (LIVE, "nfl_live")),
|
||||
# ... even with a notice pending: the claim is made now, and the next
|
||||
# pass shows the notice first (top-of-pass order), then the game.
|
||||
(CLOCK, "clock", _mid(live=("nfl_live",), notice=FRESH), 500.0, (LIVE, "nfl_live")),
|
||||
# ... but not over the schedule or an on-demand session.
|
||||
(CLOCK, "clock", _mid(live=("nfl_live",), schedule_on=False), 500.0, (OFF, None)),
|
||||
(CLOCK, "x", _mid(live=("nfl_live",), on_demand=True), 500.0, (ONDEM, "x")),
|
||||
# A screen already on a live mode is not taken over by another.
|
||||
(CLOCK, "nfl_live", _mid(live=("nfl_live", "nhl_live")), 500.0, (ROTATION, "nfl_live")),
|
||||
(NFL, "nfl_live", _mid(live=("nhl_live",)), 500.0, HELD),
|
||||
# The mode moved under the screen: on-demand started, or ended, or the
|
||||
# rotation was rebuilt.
|
||||
(CLOCK, "x", _mid(on_demand=True), 500.0, (ONDEM, "x")),
|
||||
(OD, "weather", _mid(), 500.0, (ROTATION, "weather")),
|
||||
(CLOCK, "weather", _mid(), 500.0, (ROTATION, "weather")),
|
||||
# An on-demand session that ends on the same mode keeps the screen.
|
||||
(OD, "x", _mid(), 500.0, HELD),
|
||||
# The schedule: off ends it; an on-demand override holds.
|
||||
(CLOCK, "clock", _mid(schedule_on=False), 500.0, (OFF, None)),
|
||||
(OD, "x", _mid(on_demand=True, schedule_on=False), 500.0, HELD),
|
||||
# A WiFi notice, compared with its expiry; on-demand outranks it.
|
||||
(CLOCK, "clock", _mid(notice=FRESH), 999.9, (WIFI, None)),
|
||||
(CLOCK, "clock", _mid(notice=FRESH), 1_000.0, HELD),
|
||||
(NFL, "nfl_live", _mid(notice=FRESH), 500.0, (WIFI, None)),
|
||||
(OD, "x", _mid(on_demand=True, notice=FRESH), 500.0, HELD),
|
||||
# A plugin reload waits at the top of the loop.
|
||||
(CLOCK, "clock", _mid(reload=True), 500.0, (Source.RELOAD, None)),
|
||||
(OD, "x", _mid(on_demand=True, reload=True), 500.0, (Source.RELOAD, None)),
|
||||
# The order between them: a moved mode before the schedule, the
|
||||
# schedule before a notice, a notice before a reload.
|
||||
(CLOCK, "weather", _mid(schedule_on=False), 500.0, (ROTATION, "weather")),
|
||||
(CLOCK, "clock", _mid(schedule_on=False, notice=FRESH), 500.0, (OFF, None)),
|
||||
(CLOCK, "clock", _mid(notice=FRESH, reload=True), 500.0, (WIFI, None)),
|
||||
]
|
||||
|
||||
|
||||
class TestMidScreen:
|
||||
|
||||
@pytest.mark.parametrize("running,current,inputs,now,expected", MID_TABLE)
|
||||
def test_decide(self, running, current, inputs, now, expected):
|
||||
state = ArbiterState(current_mode=current)
|
||||
plan = Arbiter.decide(state, inputs, now, running=running)
|
||||
if expected == HELD:
|
||||
assert plan is running
|
||||
else:
|
||||
assert plan is not running
|
||||
assert (plan.source, plan.mode) == expected
|
||||
|
||||
def test_the_running_plans(self):
|
||||
assert (CLOCK.source, CLOCK.mode) == (ROTATION, "clock")
|
||||
assert (NFL.source, NFL.mode) == (LIVE, "nfl_live")
|
||||
assert LIVE not in NFL.preemptible_by
|
||||
assert (OD.source, OD.mode) == (ONDEM, "x")
|
||||
|
||||
def test_a_follower_and_vegas_never_preempt(self):
|
||||
for plan in (CLOCK, NFL, OD):
|
||||
assert FOLLOW not in plan.preemptible_by
|
||||
assert LEGACY not in plan.preemptible_by
|
||||
|
||||
def test_nothing_in_preemptible_by_means_nothing_preempts(self):
|
||||
bare = replace(CLOCK, preemptible_by=frozenset())
|
||||
inputs = _mid(live=("nfl_live",), schedule_on=False, notice=FRESH, reload=True)
|
||||
assert Arbiter.decide(ArbiterState(current_mode="weather"), inputs, 0.0,
|
||||
running=bare) is bare
|
||||
|
||||
def test_mid_screen_decide_reads_no_clock(self):
|
||||
boom = MagicMock(side_effect=AssertionError("decide read the clock"))
|
||||
with patch("time.time", boom), patch("time.monotonic", boom):
|
||||
for running, current, inputs, now, _ in MID_TABLE:
|
||||
Arbiter.decide(ArbiterState(current_mode=current), inputs, now,
|
||||
running=running)
|
||||
|
||||
|
||||
# (current, inputs) -> live_takeover. The mode a mid-screen check claims.
|
||||
TAKEOVER_TABLE = [
|
||||
("clock", _mid(live=("nfl_live",)), "nfl_live"),
|
||||
("clock", _mid(live=()), None),
|
||||
("clock", _mid(live=None), None), # no scan was due
|
||||
("nfl_live", _mid(live=("nfl_live",)), None),
|
||||
("clock", _mid(live=("nfl_live",), on_demand=True), None),
|
||||
("clock", _mid(live=("nfl_live",), schedule_on=False), None),
|
||||
("clock", replace(_mid(live=("nfl_live",)), vegas_enabled=True,
|
||||
vegas_live_in_ticker=True), None),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("current,inputs,expected", TAKEOVER_TABLE)
|
||||
def test_live_takeover(current, inputs, expected):
|
||||
assert live_takeover(ArbiterState(current_mode=current), inputs) == expected
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
"""The report of a scrolling screen held by its plugin's update().
|
||||
|
||||
While a plugin's update() runs it holds the plugin's lock, and its screen's
|
||||
frames are skipped -- on a scroller, a frozen strip -- with nothing logged.
|
||||
_note_display_hold times each such run and reports one of
|
||||
DISPLAY_HOLD_REPORT_SECONDS or more.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import types
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class _Clock:
|
||||
"""display_controller's clock: moves only when run() sleeps or a test says."""
|
||||
|
||||
def __init__(self, start=10_000.0):
|
||||
self.t = start
|
||||
|
||||
def now(self):
|
||||
return self.t
|
||||
|
||||
def sleep(self, seconds):
|
||||
self.t += max(seconds, 0.0005)
|
||||
|
||||
def module(self):
|
||||
return types.SimpleNamespace(time=self.now, monotonic=self.now,
|
||||
perf_counter=self.now, sleep=self.sleep)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clock(monkeypatch):
|
||||
c = _Clock()
|
||||
monkeypatch.setattr("src.display_controller.time", c.module())
|
||||
return c
|
||||
|
||||
|
||||
class _Locks:
|
||||
"""get_plugin_lock for one plugin, whose lock the test can hold."""
|
||||
|
||||
def __init__(self):
|
||||
self.lock = threading.Lock()
|
||||
|
||||
def __call__(self, plugin_id):
|
||||
return self.lock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def held(test_display_controller):
|
||||
c = test_display_controller
|
||||
locks = _Locks()
|
||||
c.plugin_manager.get_plugin_lock = locks
|
||||
c.plugin_manager._warn_rate_limited = MagicMock()
|
||||
c.plugin_manager.health_tracker = MagicMock()
|
||||
c._display_hold = None
|
||||
return c, locks.lock
|
||||
|
||||
|
||||
def _plugin(plugin_id):
|
||||
p = MagicMock()
|
||||
p.plugin_id = plugin_id
|
||||
p.display.return_value = True
|
||||
return p
|
||||
|
||||
|
||||
class TestTheDisplayHoldReport:
|
||||
def test_a_long_hold_is_reported_when_it_ends(self, held, clock):
|
||||
c, lock = held
|
||||
ticker = _plugin("ticker")
|
||||
lock.acquire() # update() running
|
||||
assert c._display_once(ticker, "ticker", False, report_hold=True) is True
|
||||
clock.t += 0.2
|
||||
assert c._display_once(ticker, "ticker", False, report_hold=True) is True
|
||||
assert ticker.display.call_count == 0
|
||||
c.plugin_manager._warn_rate_limited.assert_not_called()
|
||||
clock.t += 0.2
|
||||
lock.release() # update() done
|
||||
c._display_once(ticker, "ticker", False, report_hold=True)
|
||||
assert ticker.display.call_count == 1
|
||||
key, message, plugin_id, ms = c.plugin_manager._warn_rate_limited.call_args[0]
|
||||
assert key == "display-hold:ticker" and plugin_id == "ticker"
|
||||
assert "held" in message and ms == pytest.approx(400.0)
|
||||
c.plugin_manager.health_tracker.record_busy_skip.assert_called_once_with(
|
||||
"ticker", "display hold", pytest.approx(0.4))
|
||||
|
||||
def test_a_short_hold_is_not(self, held, clock):
|
||||
c, lock = held
|
||||
ticker = _plugin("ticker")
|
||||
lock.acquire()
|
||||
c._display_once(ticker, "ticker", False, report_hold=True)
|
||||
clock.t += 0.1
|
||||
lock.release()
|
||||
c._display_once(ticker, "ticker", False, report_hold=True)
|
||||
c.plugin_manager._warn_rate_limited.assert_not_called()
|
||||
c.plugin_manager.health_tracker.record_busy_skip.assert_not_called()
|
||||
|
||||
def test_a_hold_that_ends_on_another_plugins_screen_is_not_blamed_on_it(
|
||||
self, held, clock):
|
||||
c, lock = held
|
||||
lock.acquire()
|
||||
c._display_once(_plugin("ticker"), "ticker", False, report_hold=True)
|
||||
clock.t += 1.0
|
||||
lock.release()
|
||||
c._display_once(_plugin("clock"), "clock", False, report_hold=True)
|
||||
c.plugin_manager._warn_rate_limited.assert_not_called()
|
||||
assert c._display_hold is None
|
||||
|
||||
def test_frames_that_draw_report_nothing(self, held, clock):
|
||||
c, _lock = held
|
||||
ticker = _plugin("ticker")
|
||||
for _ in range(5):
|
||||
c._display_once(ticker, "ticker", False, report_hold=True)
|
||||
clock.t += 0.5
|
||||
c.plugin_manager._warn_rate_limited.assert_not_called()
|
||||
|
||||
def test_the_1hz_loop_reports_no_holds(self, held, clock):
|
||||
# A static screen's frames are a second apart: one skipped frame is
|
||||
# not a measured hold, and nothing on the panel froze. (On ledpi the
|
||||
# first version reported every such skip as "held 1000 ms".)
|
||||
c, lock = held
|
||||
board = _plugin("board")
|
||||
lock.acquire()
|
||||
c._display_once(board, "board", False)
|
||||
clock.t += 1.0
|
||||
lock.release()
|
||||
c._display_once(board, "board", False)
|
||||
c.plugin_manager._warn_rate_limited.assert_not_called()
|
||||
c.plugin_manager.health_tracker.record_busy_skip.assert_not_called()
|
||||
|
||||
def test_a_run_left_open_is_dropped_by_a_1hz_frame(self, held, clock):
|
||||
c, lock = held
|
||||
ticker = _plugin("ticker")
|
||||
lock.acquire()
|
||||
c._display_once(ticker, "ticker", False, report_hold=True)
|
||||
lock.release()
|
||||
c._display_once(ticker, "ticker", False) # the 1 Hz loop draws
|
||||
assert c._display_hold is None
|
||||
clock.t += 5.0
|
||||
c._display_once(ticker, "ticker", False, report_hold=True)
|
||||
c.plugin_manager._warn_rate_limited.assert_not_called()
|
||||
@@ -6,7 +6,7 @@ a Vegas iteration runs for vegas_scroll.max_cycle_duration (240s here). On a
|
||||
real Pi on 2026-09-23:
|
||||
|
||||
* an on-demand request posted at 10:54:27 was activated at 10:57:24, when the
|
||||
Vegas iteration it arrived during finally ended -- nothing read the mailbox
|
||||
Vegas iteration it arrived during finally ended -- nothing read the request
|
||||
in between, because _check_vegas_interrupt only looked at a flag that the
|
||||
main-loop read sets;
|
||||
* two brightness saves 12s apart inside one 30s screen never showed at all.
|
||||
@@ -15,6 +15,11 @@ _service_pending_changes is the fix: a throttled pass the dwell sleep, the
|
||||
render loops and the Vegas interrupt check all call. These tests drive those
|
||||
long stretches on a fake clock and check a change lands within one throttle
|
||||
interval -- and that the throttle holds, since the callers run at frame rate.
|
||||
|
||||
The on-demand requests here come from a plugin in the display process
|
||||
(submit_plugin_on_demand), the way in that needs no socket; the file mailbox
|
||||
these tests used to write is gone (stage 5). A queued request skips the
|
||||
throttle, so it lands at the next pass's call, not the next interval.
|
||||
"""
|
||||
|
||||
import threading
|
||||
@@ -28,7 +33,6 @@ from src.vegas_mode.config import VegasModeConfig
|
||||
from src.vegas_mode.coordinator import VegasModeCoordinator
|
||||
|
||||
VEGAS_ITERATION_SECONDS = 240
|
||||
REQUEST_KEY = 'display_on_demand_request'
|
||||
|
||||
|
||||
class FakeClock:
|
||||
@@ -75,26 +79,15 @@ def clock(monkeypatch):
|
||||
|
||||
@pytest.fixture
|
||||
def controller(test_display_controller, clock):
|
||||
"""A controller at rest: no schedule, full brightness, empty mailbox."""
|
||||
"""A controller at rest: no schedule, full brightness, nothing queued."""
|
||||
c = test_display_controller
|
||||
c._refresh_config_cache({'display': {'hardware': {'brightness': 90}}})
|
||||
c.current_brightness = 90
|
||||
c.is_display_active = True
|
||||
c._check_wifi_status_message = MagicMock(return_value=None)
|
||||
|
||||
c.mailbox = {} # what the web process has written
|
||||
|
||||
def cache_get(key, *args, **kwargs):
|
||||
if key == REQUEST_KEY:
|
||||
return c.mailbox.get('request')
|
||||
return None
|
||||
|
||||
def cache_delete(key):
|
||||
if key == REQUEST_KEY:
|
||||
c.mailbox.pop('request', None)
|
||||
|
||||
c.cache_manager.get = MagicMock(side_effect=cache_get)
|
||||
c.cache_manager.delete = MagicMock(side_effect=cache_delete)
|
||||
c.cache_manager.get = MagicMock(return_value=None)
|
||||
c.cache_manager.delete = MagicMock()
|
||||
c.cache_manager.set = MagicMock()
|
||||
c.display_manager.set_brightness = MagicMock(return_value=True)
|
||||
c.display_manager.update_display = MagicMock()
|
||||
@@ -110,11 +103,12 @@ def controller(test_display_controller, clock):
|
||||
return c
|
||||
|
||||
|
||||
def post_request(controller, request_id='r1', mode='clock'):
|
||||
controller.mailbox['request'] = {
|
||||
'request_id': request_id, 'action': 'start',
|
||||
'plugin_id': mode, 'mode': mode,
|
||||
}
|
||||
def post_request(controller, request_id='r1', mode='clock', action='start'):
|
||||
"""A plugin asking for the screen (or giving it back) from its thread."""
|
||||
assert controller.submit_plugin_on_demand({
|
||||
'request_id': request_id, 'action': action,
|
||||
'plugin_id': mode, 'mode': mode, 'source': 'plugin',
|
||||
})
|
||||
|
||||
|
||||
def save_brightness(controller, brightness):
|
||||
@@ -124,9 +118,11 @@ def save_brightness(controller, brightness):
|
||||
{'display': {'hardware': {'brightness': brightness}}})
|
||||
|
||||
|
||||
def mailbox_reads(controller):
|
||||
return sum(1 for call in controller.cache_manager.get.call_args_list
|
||||
if call.args and call.args[0] == REQUEST_KEY)
|
||||
def count_passes(controller):
|
||||
"""Count the pending-changes passes that got past the throttle."""
|
||||
controller._poll_on_demand_requests = MagicMock(
|
||||
wraps=controller._poll_on_demand_requests)
|
||||
return controller._poll_on_demand_requests
|
||||
|
||||
|
||||
def vegas_coordinator(controller):
|
||||
@@ -207,15 +203,16 @@ class TestOnDemandDuringVegas:
|
||||
"interrupt checker never saw the request")
|
||||
assert controller.on_demand_active
|
||||
|
||||
def test_an_empty_mailbox_lets_the_iteration_run_out(self, controller, clock):
|
||||
def test_a_quiet_iteration_runs_out(self, controller, clock):
|
||||
coord = vegas_coordinator(controller)
|
||||
passes = count_passes(controller)
|
||||
assert coord.run_iteration() is True
|
||||
assert not controller.on_demand_active
|
||||
# And the read is throttled: at most one per interval, not per check.
|
||||
max_reads = VEGAS_ITERATION_SECONDS / controller.PENDING_CHANGES_INTERVAL + 1
|
||||
assert 0 < mailbox_reads(controller) <= max_reads
|
||||
# And the pass is throttled: at most one per interval, not per check.
|
||||
max_passes = VEGAS_ITERATION_SECONDS / controller.PENDING_CHANGES_INTERVAL + 1
|
||||
assert 0 < passes.call_count <= max_passes
|
||||
checks = coord.frames // coord._interrupt_check_interval
|
||||
assert mailbox_reads(controller) < checks / 2
|
||||
assert passes.call_count < checks / 2
|
||||
|
||||
|
||||
class TestBrightnessIsAppliedMidScreen:
|
||||
@@ -295,23 +292,25 @@ class TestBrightnessIsAppliedMidScreen:
|
||||
|
||||
|
||||
class TestThrottle:
|
||||
def test_no_reads_or_brightness_calls_between_passes(self, controller, clock):
|
||||
def test_no_passes_or_brightness_calls_between_passes(self, controller, clock):
|
||||
controller._service_pending_changes()
|
||||
reads = mailbox_reads(controller)
|
||||
passes = count_passes(controller)
|
||||
save_brightness(controller, 40)
|
||||
post_request(controller)
|
||||
for _ in range(500): # a few seconds of frames, all inside one interval
|
||||
controller._service_pending_changes()
|
||||
clock.t += controller.PENDING_CHANGES_INTERVAL / 1000
|
||||
assert mailbox_reads(controller) == reads
|
||||
assert passes.call_count == 0
|
||||
controller.display_manager.set_brightness.assert_not_called()
|
||||
assert not controller.on_demand_active
|
||||
|
||||
clock.t += controller.PENDING_CHANGES_INTERVAL
|
||||
controller._service_pending_changes()
|
||||
# The poll, plus the consume step's re-read of the request it acted on.
|
||||
assert mailbox_reads(controller) > reads
|
||||
assert passes.call_count == 1
|
||||
controller.display_manager.set_brightness.assert_called_once_with(40)
|
||||
|
||||
def test_a_queued_request_skips_the_throttle(self, controller, clock):
|
||||
controller._service_pending_changes()
|
||||
post_request(controller)
|
||||
controller._service_pending_changes() # inside the interval
|
||||
assert controller.on_demand_active
|
||||
|
||||
def test_between_passes_it_does_no_work_at_all(self, controller, clock):
|
||||
@@ -398,7 +397,7 @@ class TestScheduleAndDwells:
|
||||
controller._service_pending_changes()
|
||||
assert controller.on_demand_active
|
||||
clock.t += controller.PENDING_CHANGES_INTERVAL
|
||||
controller.mailbox['request'] = {'request_id': 'r2', 'action': 'stop'}
|
||||
post_request(controller, request_id='r2', action='stop')
|
||||
start = clock.t
|
||||
controller._sleep_with_plugin_updates(30)
|
||||
assert not controller.on_demand_active
|
||||
|
||||
@@ -24,7 +24,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from src.cache_manager import CacheManager # noqa: E402
|
||||
from src import error_aggregator as errors # noqa: E402
|
||||
from src.error_aggregator import ( # noqa: E402
|
||||
ERROR_CLEAR_REQUEST_KEY, ERROR_SNAPSHOT_KEY, ErrorAggregator,
|
||||
ERROR_SNAPSHOT_KEY, ErrorAggregator,
|
||||
ErrorSnapshotPublisher,
|
||||
)
|
||||
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
||||
@@ -314,69 +314,76 @@ class TestRoutes:
|
||||
assert secret not in published, secret
|
||||
|
||||
|
||||
CLIENT = "web_interface.blueprints.api_v3.control_client"
|
||||
RETIRED_CLEAR_KEY = "plugin_error_clear_request"
|
||||
|
||||
|
||||
def _mailbox_file(shared_cache):
|
||||
_, _, directory = shared_cache
|
||||
return directory / f"{RETIRED_CLEAR_KEY}.json"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def socket_up(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
|
||||
|
||||
|
||||
class TestClear:
|
||||
def test_clear_is_applied_by_the_display_and_republished(self, web, display):
|
||||
"""``errors.clear``: the display applies the clear before it answers."""
|
||||
|
||||
def test_socket_clear_is_applied_before_the_answer(self, web, display, socket_up,
|
||||
shared_cache):
|
||||
aggregator, publisher, _ = display
|
||||
_fail(aggregator)
|
||||
_fail(aggregator)
|
||||
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"]
|
||||
assert body["clear_requested"] is True
|
||||
assert body["cleared_count"] == 2
|
||||
# The display applies it on its next tick, throttle or not...
|
||||
assert publisher.tick() is True
|
||||
body = response.get_json()
|
||||
data = body["data"]
|
||||
assert data["transport"] == "socket" and data["applied"] is True
|
||||
assert data["clear_requested"] 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
|
||||
data = _summary(web)
|
||||
assert data["total_errors"] == 0 and data["clear_pending"] is False
|
||||
# ...and only once.
|
||||
summary = _summary(web)
|
||||
assert summary["total_errors"] == 0 and summary["clear_pending"] is False
|
||||
assert not _mailbox_file(shared_cache).exists()
|
||||
# Nothing left for a tick to do.
|
||||
assert publisher.tick() is False
|
||||
|
||||
def test_summary_hides_cleared_errors_before_the_display_applies_it(self, web, display):
|
||||
def test_errors_after_the_clear_are_kept(self, web, display, socket_up):
|
||||
aggregator, publisher, _ = display
|
||||
for _ in range(5):
|
||||
_fail(aggregator)
|
||||
publisher.tick()
|
||||
web.post("/api/v3/errors/clear", json={"all": True})
|
||||
# No display tick yet.
|
||||
data = _summary(web)
|
||||
assert data["clear_pending"] is True
|
||||
assert data["total_errors"] == 0
|
||||
assert data["recent_errors"] == [] and data["active_patterns"] == {}
|
||||
assert data["plugin_error_counts"] == {}
|
||||
plugin = web.get("/api/v3/errors/plugin/p1").get_json()["data"]
|
||||
assert plugin["status"] == "healthy" and plugin["total_errors"] == 0
|
||||
|
||||
def test_a_snapshot_written_just_before_the_clear_cannot_bring_errors_back(
|
||||
self, web, display, shared_cache):
|
||||
# The race: the display builds a snapshot, the user clicks Clear, and
|
||||
# the display's write lands after the request.
|
||||
aggregator, publisher, _ = display
|
||||
display_cache, _, _ = shared_cache
|
||||
for _ in range(3):
|
||||
_fail(aggregator)
|
||||
stale = aggregator.build_snapshot()
|
||||
web.post("/api/v3/errors/clear", json={"all": True})
|
||||
stale["applied_clear_id"] = None
|
||||
display_cache.set(ERROR_SNAPSHOT_KEY, stale)
|
||||
assert _summary(web)["total_errors"] == 0
|
||||
|
||||
def test_errors_after_the_clear_are_kept(self, web, display):
|
||||
aggregator, publisher, clock = display
|
||||
_fail(aggregator, plugin_id="before")
|
||||
publisher.tick()
|
||||
web.post("/api/v3/errors/clear", json={"all": True})
|
||||
# An error lands after the request but before the display applies it.
|
||||
for record in aggregator._records:
|
||||
record.timestamp -= timedelta(seconds=5)
|
||||
publisher.tick()
|
||||
web.post("/api/v3/errors/clear", json={"all": True})
|
||||
_fail(aggregator, plugin_id="after")
|
||||
publisher.min_interval = 0
|
||||
publisher.tick()
|
||||
data = _summary(web)
|
||||
assert data["plugin_error_counts"] == {"after": {"ValueError": 1}}
|
||||
assert data["clear_pending"] is False
|
||||
|
||||
def test_age_based_clear(self, web, display):
|
||||
def test_age_based_clear(self, web, display, socket_up):
|
||||
aggregator, publisher, _ = display
|
||||
_fail(aggregator, plugin_id="old")
|
||||
_fail(aggregator, plugin_id="old")
|
||||
@@ -386,58 +393,129 @@ class TestClear:
|
||||
publisher.tick()
|
||||
body = web.post("/api/v3/errors/clear", json={"max_age_hours": 1}).get_json()["data"]
|
||||
assert body["cleared_count"] == 2
|
||||
pending = _summary(web)
|
||||
assert pending["clear_pending"] is True
|
||||
assert [r["plugin_id"] for r in pending["recent_errors"]] == ["new"]
|
||||
publisher.tick()
|
||||
data = _summary(web)
|
||||
assert data["plugin_error_counts"] == {"new": {"ValueError": 1}}
|
||||
assert data["total_errors"] == 1
|
||||
|
||||
def test_a_narrower_clear_does_not_undo_a_pending_wider_one(self, web, display):
|
||||
aggregator, publisher, _ = display
|
||||
for _ in range(3):
|
||||
_fail(aggregator)
|
||||
publisher.tick()
|
||||
web.post("/api/v3/errors/clear", json={"all": True})
|
||||
web.post("/api/v3/errors/clear", json={"max_age_hours": 24})
|
||||
assert _summary(web)["total_errors"] == 0
|
||||
publisher.tick()
|
||||
assert aggregator.get_error_summary()["total_errors"] == 0
|
||||
|
||||
def test_default_body_still_means_older_than_24_hours(self, web, display):
|
||||
def test_default_body_still_means_older_than_24_hours(self, web, display, socket_up):
|
||||
aggregator, publisher, _ = display
|
||||
_fail(aggregator)
|
||||
publisher.tick()
|
||||
response = web.post("/api/v3/errors/clear")
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()["data"]["cleared_count"] == 0
|
||||
publisher.tick()
|
||||
assert _summary(web)["total_errors"] == 1
|
||||
|
||||
def test_validation_is_unchanged_but_all_skips_it(self, web):
|
||||
def test_validation_is_unchanged_but_all_skips_it(self, web, socket_up):
|
||||
assert web.post("/api/v3/errors/clear", json={"max_age_hours": 0}).status_code == 400
|
||||
assert web.post("/api/v3/errors/clear", json={"max_age_hours": 9000}).status_code == 400
|
||||
assert web.post("/api/v3/errors/clear",
|
||||
json={"all": True, "max_age_hours": "junk"}).status_code == 200
|
||||
|
||||
def test_a_request_that_did_not_reach_the_cache_is_an_error(self, web, api_v3_module): # noqa: F811
|
||||
cache = MagicMock()
|
||||
cache.get.return_value = None
|
||||
api_v3_module.api_v3.cache_manager = cache
|
||||
|
||||
class TestClearWithoutTheSocket:
|
||||
"""Stage 5: no mailbox to fall back to. The route says why it failed and
|
||||
writes nothing; the errors stay as the display last reported them."""
|
||||
|
||||
def _post(self, web, display, monkeypatch, error):
|
||||
monkeypatch.setattr(f"{CLIENT}.errors_clear", MagicMock(side_effect=error))
|
||||
aggregator, publisher, _ = display
|
||||
_fail(aggregator)
|
||||
publisher.tick()
|
||||
return web.post("/api/v3/errors/clear", json={"all": True})
|
||||
|
||||
@pytest.mark.parametrize("reason", ["no_socket", "refused"])
|
||||
def test_a_stopped_display_is_an_error(self, web, display, shared_cache, monkeypatch,
|
||||
reason):
|
||||
from src.ipc import client as control_client
|
||||
response = self._post(web, display, monkeypatch,
|
||||
control_client.ControlError(reason, sent=False))
|
||||
assert response.status_code == 503
|
||||
body = response.get_json()
|
||||
assert body["context"]["socket_error"] == reason
|
||||
assert "not running" in body["message"]
|
||||
assert not _mailbox_file(shared_cache).exists()
|
||||
# Nothing hides them: they are still the display's last report.
|
||||
summary = _summary(web)
|
||||
assert summary["total_errors"] == 1 and summary["clear_pending"] is False
|
||||
|
||||
@pytest.mark.parametrize("reason", ["disabled", "unsupported"])
|
||||
def test_no_socket_here_is_an_error(self, web, display, shared_cache, monkeypatch, reason):
|
||||
from src.ipc import client as control_client
|
||||
response = self._post(web, display, monkeypatch,
|
||||
control_client.ControlError(reason, sent=False))
|
||||
assert response.status_code == 503
|
||||
assert "not available" in response.get_json()["message"]
|
||||
assert not _mailbox_file(shared_cache).exists()
|
||||
|
||||
def test_an_older_display_is_told_to_restart(self, web, display, shared_cache,
|
||||
monkeypatch):
|
||||
from src.ipc import client as control_client
|
||||
response = self._post(web, display, monkeypatch,
|
||||
control_client.ControlError("unknown_command", sent=True))
|
||||
assert response.status_code == 503
|
||||
assert "restart" in response.get_json()["message"]
|
||||
assert not _mailbox_file(shared_cache).exists()
|
||||
|
||||
@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
|
||||
response = self._post(web, display, monkeypatch,
|
||||
control_client.ControlError(reason, sent=True))
|
||||
assert response.status_code == 503
|
||||
body = response.get_json()
|
||||
assert body["context"]["socket_error"] == reason
|
||||
assert body["message"] == "The display service did not apply the clear"
|
||||
assert not _mailbox_file(shared_cache).exists()
|
||||
|
||||
def test_the_default_test_setup_has_no_socket(self, web, display, shared_cache):
|
||||
# conftest turns the socket off: the real client answers "disabled".
|
||||
aggregator, publisher, _ = display
|
||||
_fail(aggregator)
|
||||
publisher.tick()
|
||||
response = web.post("/api/v3/errors/clear", json={"all": True})
|
||||
assert response.status_code == 500
|
||||
assert "clear request" in response.get_json()["message"]
|
||||
assert response.status_code == 503
|
||||
assert response.get_json()["context"]["socket_error"] in ("disabled", "unsupported")
|
||||
|
||||
|
||||
class TestPublisher:
|
||||
def test_a_tick_reads_no_clear_request(self, display, shared_cache):
|
||||
_, publisher, clock = display
|
||||
_, web_cache, directory = shared_cache
|
||||
# An old web interface's leftover request file is ignored.
|
||||
(directory / f"{RETIRED_CLEAR_KEY}.json").write_text(
|
||||
'{"timestamp": 1, "data": {"request_id": "old", "cutoff": 9e9}}')
|
||||
publisher.cache_manager = MagicMock(wraps=publisher.cache_manager)
|
||||
for _ in range(5):
|
||||
clock.now += 60
|
||||
publisher.tick()
|
||||
publisher.cache_manager.get.assert_not_called()
|
||||
snapshot = web_cache.get(ERROR_SNAPSHOT_KEY, max_age=None, memory_ttl=0)
|
||||
assert snapshot["applied_clear_id"] is None
|
||||
|
||||
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
|
||||
|
||||
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):
|
||||
def test_the_snapshot_is_group_readable(web, display, shared_cache):
|
||||
aggregator, publisher, _ = display
|
||||
_, _, directory = shared_cache
|
||||
_fail(aggregator)
|
||||
publisher.tick()
|
||||
web.post("/api/v3/errors/clear", json={"all": True})
|
||||
for key in (ERROR_SNAPSHOT_KEY, ERROR_CLEAR_REQUEST_KEY):
|
||||
mode = stat.S_IMODE(os.stat(directory / f"{key}.json").st_mode)
|
||||
assert mode == 0o660, (key, oct(mode))
|
||||
mode = stat.S_IMODE(os.stat(directory / f"{ERROR_SNAPSHOT_KEY}.json").st_mode)
|
||||
assert mode == 0o660, oct(mode)
|
||||
|
||||
@@ -576,6 +576,42 @@ class TestCounters:
|
||||
assert snap["hosts"]["site.api.espn.com"]["requests"] == 1
|
||||
assert snap["totals"]["bytes"] == 3 * len(b'{"ok": 1}')
|
||||
|
||||
def test_wire_bytes_are_the_compressed_size(self, service):
|
||||
# Built the way requests builds a real response: a urllib3
|
||||
# HTTPResponse carrying a gzip body, decoded when .content is read.
|
||||
import gzip
|
||||
import io
|
||||
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.response import HTTPResponse
|
||||
|
||||
decoded = json.dumps({"events": [{"id": str(i), "name": "x" * 200}
|
||||
for i in range(50)]}).encode()
|
||||
wire = gzip.compress(decoded)
|
||||
|
||||
def handler(url, kwargs):
|
||||
raw = HTTPResponse(body=io.BytesIO(wire), status=200,
|
||||
headers={"Content-Encoding": "gzip",
|
||||
"Content-Type": "application/json"},
|
||||
preload_content=False, decode_content=True)
|
||||
request = requests.Request("GET", url).prepare()
|
||||
response = HTTPAdapter().build_response(request, raw)
|
||||
response.content # what Session.get does for a non-streamed call
|
||||
return response
|
||||
|
||||
response = service.get(FakeSession(handler), "https://site.api.espn.com/x")
|
||||
assert response.content == decoded
|
||||
totals = _counters(service)
|
||||
assert totals["bytes"] == len(decoded)
|
||||
assert totals["wire_bytes"] == len(wire) < len(decoded)
|
||||
|
||||
def test_wire_bytes_fall_back_to_the_decoded_size(self, service):
|
||||
# No urllib3 response behind it (a test double, another adapter):
|
||||
# count what is known rather than nothing.
|
||||
service.get(FakeSession(), "https://api.test/x")
|
||||
totals = _counters(service)
|
||||
assert totals["wire_bytes"] == totals["bytes"] == len(b'{"ok": 1}')
|
||||
|
||||
def test_errors_and_http_errors(self, service):
|
||||
def handler(url, kwargs):
|
||||
if url.endswith("/down"):
|
||||
|
||||
@@ -185,6 +185,149 @@ class TestPluginFonts:
|
||||
assert fm.font_catalog["my-plugin::bundled"] == str(plugin_dir / "fonts" / "Bundled.ttf")
|
||||
|
||||
|
||||
class TestForgetPluginFonts:
|
||||
"""forget_plugin_fonts drops what a plugin's manifest registered. Before
|
||||
it, unloading a plugin left its fonts resolvable and its cached font
|
||||
objects alive until a restart."""
|
||||
|
||||
@staticmethod
|
||||
def _register(fm, root, plugin_id, family="bundled"):
|
||||
plugin_dir = root / plugin_id
|
||||
(plugin_dir / "fonts").mkdir(parents=True, exist_ok=True)
|
||||
font_file = plugin_dir / "fonts" / f"{family}.ttf"
|
||||
if not font_file.exists(): # a loaded font may hold it open (Windows)
|
||||
shutil.copy(resolve_asset_path("assets/fonts/PressStart2P-Regular.ttf"), font_file)
|
||||
manifest = {"fonts": [{"family": family, "source": f"plugin://fonts/{family}.ttf"}]}
|
||||
assert fm.register_plugin_fonts(plugin_id, manifest, plugin_dir=plugin_dir)
|
||||
return plugin_dir
|
||||
|
||||
@staticmethod
|
||||
def _entries_of(fm, plugin_id):
|
||||
prefix = f"{plugin_id}::"
|
||||
return {
|
||||
"plugin_fonts": plugin_id in fm.plugin_fonts,
|
||||
"plugin_font_catalogs": plugin_id in fm.plugin_font_catalogs,
|
||||
"font_catalog": [k for k in fm.font_catalog if k.startswith(prefix)],
|
||||
"font_cache": [k for k in fm.font_cache if k.startswith(prefix)],
|
||||
}
|
||||
|
||||
NONE = {"plugin_fonts": False, "plugin_font_catalogs": False,
|
||||
"font_catalog": [], "font_cache": []}
|
||||
|
||||
def test_unload_leaves_no_plugin_entries(self, fm, tmp_path):
|
||||
self._register(fm, tmp_path, "alpha")
|
||||
fm.resolve_font("alpha.title", "bundled", 8, plugin_id="alpha")
|
||||
fm.get_font("alpha::bundled", 10)
|
||||
assert self._entries_of(fm, "alpha")["font_cache"] # cached before
|
||||
gen = fm.cache_generation
|
||||
|
||||
assert fm.forget_plugin_fonts("alpha") is True
|
||||
|
||||
assert self._entries_of(fm, "alpha") == self.NONE
|
||||
assert fm.cache_generation == gen + 1
|
||||
# The family no longer resolves to the plugin's file.
|
||||
assert fm.font_catalog.get("alpha::bundled") is None
|
||||
|
||||
def test_other_plugins_and_core_fonts_are_untouched(self, fm, tmp_path):
|
||||
self._register(fm, tmp_path, "alpha")
|
||||
self._register(fm, tmp_path, "beta")
|
||||
# A plugin whose id is a prefix of another's must not take it along.
|
||||
self._register(fm, tmp_path, "alpha-two")
|
||||
for pid in ("alpha", "beta", "alpha-two"):
|
||||
fm.get_font(f"{pid}::bundled", 8)
|
||||
core_font = fm.get_font("press_start", 8)
|
||||
beta_before = self._entries_of(fm, "beta")
|
||||
alpha_two_before = self._entries_of(fm, "alpha-two")
|
||||
|
||||
fm.forget_plugin_fonts("alpha")
|
||||
|
||||
assert self._entries_of(fm, "beta") == beta_before
|
||||
assert self._entries_of(fm, "alpha-two") == alpha_two_before
|
||||
assert fm.get_font("press_start", 8) is core_font
|
||||
|
||||
def test_reload_re_registers_cleanly(self, fm, tmp_path):
|
||||
plugin_dir = self._register(fm, tmp_path, "alpha")
|
||||
old = fm.get_font("alpha::bundled", 8)
|
||||
fm.forget_plugin_fonts("alpha")
|
||||
|
||||
self._register(fm, tmp_path, "alpha")
|
||||
|
||||
assert fm.font_catalog["alpha::bundled"] == str(plugin_dir / "fonts" / "bundled.ttf")
|
||||
font = fm.resolve_font("alpha.title", "bundled", 8, plugin_id="alpha")
|
||||
assert isinstance(font, ImageFont.FreeTypeFont)
|
||||
assert font is not old # loaded fresh, not the dropped cache entry
|
||||
|
||||
def test_a_family_the_new_manifest_drops_stops_resolving(self, fm, tmp_path):
|
||||
self._register(fm, tmp_path, "alpha", family="old_face")
|
||||
fm.forget_plugin_fonts("alpha")
|
||||
self._register(fm, tmp_path, "alpha", family="new_face")
|
||||
|
||||
assert "alpha::old_face" not in fm.font_catalog
|
||||
assert "alpha::new_face" in fm.font_catalog
|
||||
|
||||
def test_unknown_plugin_is_a_no_op(self, fm):
|
||||
catalog = dict(fm.font_catalog)
|
||||
gen = fm.cache_generation
|
||||
|
||||
assert fm.forget_plugin_fonts("never-registered") is False
|
||||
|
||||
assert fm.font_catalog == catalog
|
||||
assert fm.cache_generation == gen
|
||||
|
||||
|
||||
class TestPluginManagerReloadFonts:
|
||||
"""Through PluginManager: unloading a plugin forgets its manifest fonts,
|
||||
and reload_plugin (unload + load) registers them again so they resolve."""
|
||||
|
||||
PLUGIN_ID = "font-reload-demo"
|
||||
MODULE = "plugin_font_reload_demo"
|
||||
|
||||
def test_unload_forgets_and_reload_resolves(self, tmp_path):
|
||||
import sys
|
||||
from src.plugin_system.plugin_manager import PluginManager
|
||||
|
||||
plugins_dir = tmp_path / "plugins"
|
||||
plugin_dir = plugins_dir / self.PLUGIN_ID
|
||||
(plugin_dir / "fonts").mkdir(parents=True)
|
||||
shutil.copy(resolve_asset_path("assets/fonts/PressStart2P-Regular.ttf"),
|
||||
plugin_dir / "fonts" / "Bundled.ttf")
|
||||
manifest = {"id": self.PLUGIN_ID, "name": "Demo", "class_name": "Demo",
|
||||
"entry_point": "manager.py",
|
||||
"fonts": {"fonts": [{"family": "bundled",
|
||||
"source": "plugin://fonts/Bundled.ttf"}]}}
|
||||
(plugin_dir / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
|
||||
(plugin_dir / "manager.py").write_text(
|
||||
"class Demo:\n"
|
||||
" def __init__(self, plugin_id, config, display_manager, cache_manager, plugin_manager):\n"
|
||||
" self.enabled = True\n", encoding="utf-8")
|
||||
|
||||
pm = PluginManager(plugins_dir=str(plugins_dir))
|
||||
fm = FontManager({})
|
||||
pm.font_manager = fm
|
||||
pm.plugin_manifests[self.PLUGIN_ID] = manifest
|
||||
key = f"{self.PLUGIN_ID}::bundled"
|
||||
try:
|
||||
assert pm.load_plugin(self.PLUGIN_ID) is True
|
||||
assert key in fm.font_catalog
|
||||
fm.register_manager_font(self.PLUGIN_ID, "demo.title", "bundled", 8)
|
||||
old = fm.resolve_font("demo.title", "bundled", 8, plugin_id=self.PLUGIN_ID)
|
||||
|
||||
assert pm.unload_plugin(self.PLUGIN_ID) is True
|
||||
assert self.PLUGIN_ID not in fm.plugin_fonts
|
||||
assert self.PLUGIN_ID not in fm.plugin_font_catalogs
|
||||
assert key not in fm.font_catalog
|
||||
assert not [k for k in fm.font_cache if k.startswith(f"{self.PLUGIN_ID}::")]
|
||||
assert self.PLUGIN_ID not in fm.manager_fonts
|
||||
|
||||
assert pm.reload_plugin(self.PLUGIN_ID) is True
|
||||
assert fm.font_catalog[key] == str(plugin_dir / "fonts" / "Bundled.ttf")
|
||||
font = fm.resolve_font("demo.title", "bundled", 8, plugin_id=self.PLUGIN_ID)
|
||||
assert isinstance(font, ImageFont.FreeTypeFont)
|
||||
assert font is not old
|
||||
finally:
|
||||
sys.modules.pop(self.MODULE, None)
|
||||
|
||||
|
||||
class TestDownloadFont:
|
||||
"""_download_font: plugin fonts declared by URL, cached in temp_font_dir."""
|
||||
|
||||
|
||||
@@ -807,54 +807,3 @@ def test_a_process_with_the_gc_monitor_exits_cleanly():
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
assert "Exception ignored" not in proc.stderr
|
||||
assert "installed at exit: False" in proc.stdout
|
||||
|
||||
|
||||
SLOW = 1 / 110.0 # a panel that cannot reach a 120 Hz cap
|
||||
|
||||
|
||||
def _windows(recorder, n, interval, start=0.0):
|
||||
for i in range(n):
|
||||
_feed(recorder, [interval] * 200, start=start + 50.0 * i)
|
||||
_aggregate(recorder)
|
||||
|
||||
|
||||
def _shortfall_warnings(caplog):
|
||||
return [r for r in caplog.records
|
||||
if r.name == "src.common.frame_timing" and "Limit Refresh Rate" in r.getMessage()]
|
||||
|
||||
|
||||
def test_a_panel_slower_than_its_cap_is_reported_once(tmp_path, caplog):
|
||||
r = _recorder(tmp_path)
|
||||
r.plan_refresh(120.0)
|
||||
caplog.set_level("WARNING")
|
||||
_windows(r, 3, SLOW) # adopted on the 2nd window, checked on the 4th
|
||||
assert _shortfall_warnings(caplog) == []
|
||||
_windows(r, 3, SLOW, start=1000.0)
|
||||
warnings = _shortfall_warnings(caplog)
|
||||
assert len(warnings) == 1
|
||||
assert "about 110 Hz" in warnings[0].getMessage()
|
||||
assert "to 100 Hz" in warnings[0].getMessage()
|
||||
|
||||
|
||||
def test_a_panel_that_reaches_its_cap_is_not_reported(tmp_path, caplog):
|
||||
r = _recorder(tmp_path)
|
||||
r.plan_refresh(100.0)
|
||||
caplog.set_level("WARNING")
|
||||
_windows(r, 6, PERIOD)
|
||||
assert _shortfall_warnings(caplog) == []
|
||||
|
||||
|
||||
def test_without_a_planned_rate_nothing_is_checked(tmp_path, caplog):
|
||||
# The emulator and the fallback canvas: DisplayManager never calls
|
||||
# plan_refresh(), since their frames are not paced by a panel.
|
||||
r = _recorder(tmp_path)
|
||||
caplog.set_level("WARNING")
|
||||
_windows(r, 6, SLOW)
|
||||
assert _shortfall_warnings(caplog) == []
|
||||
|
||||
|
||||
def test_the_snapshot_records_the_planned_rate(tmp_path):
|
||||
r = _recorder(tmp_path)
|
||||
assert r.snapshot()["planned_refresh_hz"] is None
|
||||
r.plan_refresh(120.0)
|
||||
assert r.snapshot()["planned_refresh_hz"] == 120.0
|
||||
|
||||
@@ -709,6 +709,6 @@ class TestTheControllersOwnScreens:
|
||||
c._check_wifi_status_message.return_value = None
|
||||
inputs = DisplayController._arbiter_inputs(c)
|
||||
assert inputs.wifi_notice is None
|
||||
assert Arbiter.decide(ArbiterState(), inputs, 0.0).source is Source.LEGACY
|
||||
assert Arbiter.decide(ArbiterState(), inputs, 0.0).source is Source.ROTATION
|
||||
assert dm.is_currently_scrolling()
|
||||
assert dm._frame_hold == 2
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
"""GET /api/v3/plugins/installed never waits on the network for registry data.
|
||||
|
||||
The route used `get_registry_info`, which goes through `fetch_registry`: on a
|
||||
cold (or expired) cache that downloads plugins.json from GitHub with a 10s
|
||||
timeout and three attempts -- and, with no registry to fall back on, every
|
||||
plugin's lookup repeated the whole cycle. The first plugin-list load after a
|
||||
restart waited on GitHub, and offline it waited out every timeout.
|
||||
|
||||
Now the route reads the registry copy already in memory, however old, and a
|
||||
missing or expired copy only starts a background refresh. These tests block
|
||||
the network at the socket layer (DNS lookups hang, then fail) and assert the
|
||||
request returns quickly without a single network attempt on the request path.
|
||||
"""
|
||||
|
||||
import functools
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from test._api_v3_test_helpers import ( # noqa: F401 - fixtures
|
||||
api_v3_client, api_v3_module,
|
||||
)
|
||||
from src.plugin_system.store_manager import PluginStoreManager
|
||||
|
||||
# How long a blocked lookup hangs before failing: long enough that a single
|
||||
# one on the request path blows the response budget below.
|
||||
HANG_SECONDS = 1.5
|
||||
FAST_SECONDS = 1.0
|
||||
|
||||
REGISTRY = {'plugins': [
|
||||
{'id': 'weather', 'name': 'Weather', 'verified': True, 'latest_version': '1.2.0'},
|
||||
]}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def blocked_network(monkeypatch):
|
||||
"""Every DNS lookup hangs, then fails; records the thread it came from."""
|
||||
attempts = []
|
||||
|
||||
def hang_then_fail(host, *args, **kwargs):
|
||||
attempts.append((host, threading.current_thread().name))
|
||||
time.sleep(HANG_SECONDS)
|
||||
raise socket.gaierror(-3, 'Temporary failure in name resolution (blocked by test)')
|
||||
|
||||
monkeypatch.setattr(socket, 'getaddrinfo', hang_then_fail)
|
||||
return attempts
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_path, monkeypatch):
|
||||
store = PluginStoreManager(plugins_dir=str(tmp_path / 'plugins'))
|
||||
# One attempt, no pause between attempts, so a background refresh against
|
||||
# the blocked network ends within the test (teardown joins it).
|
||||
monkeypatch.setattr(store, '_http_get_with_retries', functools.partial(
|
||||
PluginStoreManager._http_get_with_retries, store, max_retries=1))
|
||||
yield store
|
||||
thread = getattr(store, '_registry_refresh_thread', None)
|
||||
if thread is not None:
|
||||
thread.join(timeout=30)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def get_installed(api_v3_module, api_v3_client, store, tmp_path):
|
||||
api = api_v3_module.api_v3
|
||||
api.plugin_store_manager = store
|
||||
api.plugin_catalog.plugins_dir = str(tmp_path / 'plugins')
|
||||
api.plugin_catalog.get_all_plugin_info = MagicMock(return_value=[
|
||||
{'id': 'weather', 'name': 'Weather', 'version': '1.0.0'},
|
||||
{'id': 'clock', 'name': 'Clock', 'version': '2.0.0'},
|
||||
])
|
||||
api.plugin_catalog.get_plugin_display_modes = MagicMock(return_value=[])
|
||||
api.config_manager.load_config = MagicMock(return_value={})
|
||||
|
||||
def _get():
|
||||
start = time.perf_counter()
|
||||
response = api_v3_client.get('/api/v3/plugins/installed')
|
||||
elapsed = time.perf_counter() - start
|
||||
assert response.status_code == 200
|
||||
plugins = {p['id']: p for p in response.get_json()['data']['plugins']}
|
||||
return plugins, elapsed
|
||||
return _get
|
||||
|
||||
|
||||
def _request_path_attempts(attempts):
|
||||
return [a for a in attempts if a[1] != 'registry-refresh']
|
||||
|
||||
|
||||
def test_a_cold_cache_offline_returns_fast_without_registry_info(get_installed, blocked_network):
|
||||
plugins, elapsed = get_installed()
|
||||
|
||||
assert _request_path_attempts(blocked_network) == []
|
||||
assert elapsed < FAST_SECONDS, f"installed list took {elapsed:.2f}s with the network blocked"
|
||||
weather = plugins['weather']
|
||||
assert weather['latest_version'] == ''
|
||||
assert weather['update_available'] is False
|
||||
assert weather['verified'] is False
|
||||
|
||||
|
||||
def test_a_stale_cache_is_used_as_is_without_a_fetch(get_installed, store, blocked_network):
|
||||
store.registry_cache = REGISTRY
|
||||
store.registry_cache_time = time.time() - store.registry_cache_timeout - 3600
|
||||
|
||||
plugins, elapsed = get_installed()
|
||||
|
||||
assert _request_path_attempts(blocked_network) == []
|
||||
assert elapsed < FAST_SECONDS
|
||||
weather = plugins['weather']
|
||||
assert weather['latest_version'] == '1.2.0'
|
||||
assert weather['update_available'] is True
|
||||
assert weather['verified'] is True
|
||||
assert plugins['clock']['latest_version'] == ''
|
||||
|
||||
|
||||
def test_a_fresh_cache_starts_no_refresh(get_installed, store, blocked_network):
|
||||
store.registry_cache = REGISTRY
|
||||
store.registry_cache_time = time.time()
|
||||
|
||||
plugins, _ = get_installed()
|
||||
|
||||
assert blocked_network == []
|
||||
assert store._registry_refresh_thread is None
|
||||
assert plugins['weather']['update_available'] is True
|
||||
|
||||
|
||||
def test_a_cold_cache_is_filled_in_the_background_for_the_next_load(get_installed, store, monkeypatch):
|
||||
response = MagicMock()
|
||||
response.json.return_value = REGISTRY
|
||||
fetched_on = []
|
||||
|
||||
def fake_get(url, **kwargs):
|
||||
fetched_on.append(threading.current_thread().name)
|
||||
return response
|
||||
|
||||
monkeypatch.setattr(store, '_http_get_with_retries', fake_get)
|
||||
|
||||
first, _ = get_installed()
|
||||
assert first['weather']['update_available'] is False
|
||||
store._registry_refresh_thread.join(timeout=10)
|
||||
|
||||
second, _ = get_installed()
|
||||
assert second['weather']['latest_version'] == '1.2.0'
|
||||
assert second['weather']['update_available'] is True
|
||||
# One background fetch for the whole listing, none on the request path.
|
||||
assert fetched_on == ['registry-refresh']
|
||||
|
||||
|
||||
def test_an_offline_background_refresh_backs_off(store, monkeypatch):
|
||||
def offline(url, **kwargs):
|
||||
raise requests.ConnectionError('blocked by test')
|
||||
|
||||
monkeypatch.setattr(store, '_http_get_with_retries', offline)
|
||||
|
||||
assert store.refresh_registry_in_background() is True
|
||||
store._registry_refresh_thread.join(timeout=10)
|
||||
assert store.registry_cache is None
|
||||
# Offline: the next page load does not start another attempt straight away.
|
||||
assert store.refresh_registry_in_background() is False
|
||||
store._registry_refresh_retry_after = 0.0
|
||||
assert store.refresh_registry_in_background() is True
|
||||
|
||||
|
||||
def test_only_one_background_refresh_runs_at_a_time(store, monkeypatch):
|
||||
release = threading.Event()
|
||||
|
||||
def slow(url, **kwargs):
|
||||
release.wait(10)
|
||||
raise requests.ConnectionError('blocked by test')
|
||||
|
||||
monkeypatch.setattr(store, '_http_get_with_retries', slow)
|
||||
try:
|
||||
assert store.refresh_registry_in_background() is True
|
||||
assert store.refresh_registry_in_background() is False
|
||||
finally:
|
||||
release.set()
|
||||
|
||||
|
||||
def test_get_registry_info_still_fetches_for_the_store(store, monkeypatch):
|
||||
"""The store, install and update paths keep fetching a cold registry."""
|
||||
response = MagicMock()
|
||||
response.json.return_value = REGISTRY
|
||||
monkeypatch.setattr(store, '_http_get_with_retries', MagicMock(return_value=response))
|
||||
|
||||
assert store.get_registry_info('weather')['latest_version'] == '1.2.0'
|
||||
store._http_get_with_retries.assert_called_once()
|
||||
@@ -3,7 +3,7 @@
|
||||
Pure data, so every test here runs on every platform. What they pin:
|
||||
|
||||
* a request and a response survive encode -> decode -> parse unchanged, and
|
||||
the on-demand arguments carry exactly what the file mailbox carries;
|
||||
the on-demand arguments carry exactly what the REST route sends;
|
||||
* the envelope and the arguments refuse what the display could not act on
|
||||
(missing ids, wrong types, a non-finite duration) with a stable error code;
|
||||
* framing never holds more than one message's worth of bytes, however the
|
||||
@@ -167,7 +167,8 @@ class TestOnDemandArgs:
|
||||
def test_every_command_has_an_argument_type(self, cmd):
|
||||
args = {Command.ON_DEMAND_START: {'plugin_id': 'p'},
|
||||
Command.PLUGIN_RELOAD: {'plugin_id': 'p'},
|
||||
Command.BRIGHTNESS_SET: {'brightness': 50}}.get(cmd, {})
|
||||
Command.BRIGHTNESS_SET: {'brightness': 50},
|
||||
Command.ERRORS_CLEAR: {'cutoff': 1790000000.0}}.get(cmd, {})
|
||||
c.parse_args(cmd, args)
|
||||
|
||||
def test_hello_versions(self):
|
||||
@@ -183,8 +184,8 @@ class TestOnDemandArgs:
|
||||
|
||||
|
||||
class TestMailboxShape:
|
||||
"""Socket commands are handed to the mailbox's own handler, so they must
|
||||
look exactly like what the web route writes to the mailbox."""
|
||||
"""Socket commands are handed to the display's on-demand handler, so they
|
||||
must look exactly like the request dict it takes."""
|
||||
|
||||
def test_start(self):
|
||||
args = OnDemandStartArgs(plugin_id='clock', mode='clock_main', duration=60.0,
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
"""DisplayController's side of the control socket.
|
||||
|
||||
The server's handlers only queue; the render thread drains the queue where
|
||||
it reads the file mailbox (_poll_on_demand_requests) and hands each command
|
||||
to the mailbox's own handler (_handle_on_demand_request). These tests pin
|
||||
that hook:
|
||||
The server's handlers only queue; the render thread drains the queue
|
||||
(_poll_on_demand_requests) and hands each on-demand command to
|
||||
_handle_on_demand_request, which plugins' own requests use too. These tests
|
||||
pin that hook:
|
||||
|
||||
* a socket command is applied by the same code as a mailbox request, with
|
||||
its request id, and without waiting for the mailbox's 0.25 s read floor;
|
||||
* a request that arrives both ways (a client that timed out after the
|
||||
command was queued, then wrote the mailbox) is activated once;
|
||||
* a socket command is applied with its request id, at once, and touches no
|
||||
cache key (the file mailbox and the persisted processed id are gone);
|
||||
* a start sent twice with one request id is activated once;
|
||||
* a command that fails is contained, and the ones after it still run;
|
||||
* cleanup closes the socket; a disabled socket changes nothing.
|
||||
"""
|
||||
@@ -58,28 +57,15 @@ 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
|
||||
|
||||
def fake_delete(key):
|
||||
if key == 'display_on_demand_request':
|
||||
mailbox['value'] = None
|
||||
|
||||
c_.cache_manager.get = MagicMock(side_effect=fake_get)
|
||||
c_.cache_manager.get = MagicMock(return_value=None)
|
||||
c_.cache_manager.set = MagicMock()
|
||||
c_.cache_manager.delete = MagicMock(side_effect=fake_delete)
|
||||
c_.cache_manager.delete = MagicMock()
|
||||
c_._activate_on_demand = MagicMock()
|
||||
c_.mailbox = mailbox
|
||||
return c_
|
||||
|
||||
|
||||
class TestDrain:
|
||||
def test_a_socket_start_goes_through_the_mailbox_handler(self, controller):
|
||||
def test_a_socket_start_is_activated_with_its_request_id(self, controller):
|
||||
controller._control_server = FakeServer(_start('sock-1', duration=30.0, pinned=True))
|
||||
controller._poll_on_demand_requests()
|
||||
controller._activate_on_demand.assert_called_once()
|
||||
@@ -89,47 +75,28 @@ class TestDrain:
|
||||
assert request['plugin_id'] == 'clock'
|
||||
assert request['duration'] == 30.0 and request['pinned'] is True
|
||||
assert controller.on_demand_request_id == 'sock-1'
|
||||
# The same restart-replay guard as a mailbox request.
|
||||
controller.cache_manager.set.assert_any_call(
|
||||
'display_on_demand_processed_id', 'sock-1', ttl=3600)
|
||||
# No mailbox, and no persisted processed id.
|
||||
keys = {call.args[0] for m in (controller.cache_manager.get,
|
||||
controller.cache_manager.set,
|
||||
controller.cache_manager.delete)
|
||||
for call in m.call_args_list}
|
||||
assert not keys & {'display_on_demand_request', 'display_on_demand_processed_id'}
|
||||
|
||||
def test_socket_commands_skip_the_mailbox_floor(self, controller):
|
||||
def test_socket_commands_land_at_once(self, controller):
|
||||
server = FakeServer()
|
||||
controller._control_server = server
|
||||
controller._poll_on_demand_requests() # reads the mailbox, sets the floor
|
||||
reads = controller.cache_manager.get.call_count
|
||||
controller._poll_on_demand_requests()
|
||||
server.commands.append(_start('quick'))
|
||||
controller._poll_on_demand_requests() # within the floor
|
||||
controller._activate_on_demand.assert_called_once()
|
||||
mailbox_reads = [call for call in controller.cache_manager.get.call_args_list[reads:]
|
||||
if call.args[0] == 'display_on_demand_request']
|
||||
# Only _consume_on_demand_request's compare-before-delete re-read.
|
||||
assert len(mailbox_reads) <= 1
|
||||
|
||||
def test_a_request_that_came_both_ways_is_activated_once(self, controller):
|
||||
controller._control_server = FakeServer(_start('dup'))
|
||||
controller.mailbox['value'] = {'request_id': 'dup', 'action': 'start',
|
||||
'plugin_id': 'clock'}
|
||||
controller._poll_on_demand_requests()
|
||||
controller._last_on_demand_poll = None
|
||||
controller._poll_on_demand_requests()
|
||||
controller._activate_on_demand.assert_called_once()
|
||||
assert controller.mailbox['value'] is None, "the duplicate was left in the mailbox"
|
||||
|
||||
def test_a_fallback_write_landing_later_is_ignored(self, controller):
|
||||
controller._control_server = FakeServer(_start('late'))
|
||||
controller._poll_on_demand_requests()
|
||||
controller.mailbox['value'] = {'request_id': 'late', 'action': 'start',
|
||||
'plugin_id': 'clock'}
|
||||
controller._last_on_demand_poll = None
|
||||
controller._poll_on_demand_requests()
|
||||
controller._activate_on_demand.assert_called_once()
|
||||
|
||||
def test_the_mailbox_still_works_alongside(self, controller):
|
||||
controller._control_server = FakeServer()
|
||||
controller.mailbox['value'] = {'request_id': 'mb', 'action': 'start', 'plugin_id': 'p'}
|
||||
def test_a_start_sent_twice_is_activated_once(self, controller):
|
||||
server = FakeServer(_start('dup'))
|
||||
controller._control_server = server
|
||||
controller._poll_on_demand_requests()
|
||||
assert controller._activate_on_demand.call_args.args[0]['request_id'] == 'mb'
|
||||
server.commands.append(_start('dup'))
|
||||
controller._poll_on_demand_requests()
|
||||
controller._activate_on_demand.assert_called_once()
|
||||
|
||||
def test_a_socket_stop_ends_on_demand(self, controller):
|
||||
controller.on_demand_active = True
|
||||
@@ -159,10 +126,11 @@ class TestDrain:
|
||||
controller._poll_on_demand_requests()
|
||||
assert calls == ['bad', 'good']
|
||||
|
||||
def test_no_server_means_mailbox_only(self, controller):
|
||||
def test_no_server_means_nothing_to_apply(self, controller):
|
||||
controller._control_server = None
|
||||
controller._poll_on_demand_requests()
|
||||
controller._activate_on_demand.assert_not_called()
|
||||
controller.cache_manager.get.assert_not_called()
|
||||
|
||||
|
||||
class TestPendingChangesFloor:
|
||||
|
||||
@@ -41,6 +41,15 @@ def _reload(plugin_id):
|
||||
return _command(Command.PLUGIN_RELOAD, PluginReloadArgs(plugin_id))
|
||||
|
||||
|
||||
def _screen(mode):
|
||||
"""A rotation screen of ``mode``, as the ScreenRunner hands it to the
|
||||
1 Hz loop's frame wait."""
|
||||
from src.display_arbiter import ArbiterState, rotation_plan
|
||||
from src.screen_runner import Screen
|
||||
plan = rotation_plan(ArbiterState(current_mode=mode))
|
||||
return Screen(plan, plugin=None, accepts_display_mode=False, start=0.0)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dc(test_display_controller):
|
||||
c = test_display_controller
|
||||
@@ -253,7 +262,8 @@ class TestRealTimeWake:
|
||||
dc.on_demand_active = False
|
||||
dc._activate_on_demand = MagicMock(
|
||||
side_effect=lambda request: setattr(dc, 'current_display_mode', 'weather'))
|
||||
dc._wifi_notice_pending = MagicMock(return_value=False)
|
||||
dc._wifi_notice_pending = MagicMock(return_value=False) # the dwell's check
|
||||
dc._read_wifi_notice = MagicMock(return_value=None) # the frame wait's
|
||||
dc._tick_plugin_updates = MagicMock()
|
||||
dc._check_live_takeover = MagicMock()
|
||||
dc.cache_manager.get = MagicMock(return_value=None)
|
||||
@@ -282,10 +292,10 @@ class TestRealTimeWake:
|
||||
dc.current_display_mode = 'clock'
|
||||
stamps = []
|
||||
t = self._post_later(server, self._start_line(f's{i}'), 0.05, stamps)
|
||||
ended = dc._wait_frame_interval(1.0, 'clock')
|
||||
ended = dc._wait_frame_interval(1.0, _screen('clock'))
|
||||
woke = time.monotonic()
|
||||
t.join()
|
||||
assert ended is True
|
||||
assert ended is not None
|
||||
latencies.append(woke - stamps[0])
|
||||
latencies.sort()
|
||||
print(f"static-screen wake latency: median {latencies[5] * 1000:.2f} ms, "
|
||||
@@ -315,7 +325,7 @@ class TestRealTimeWake:
|
||||
'args': {'brightness': 33}}).encode()
|
||||
started = time.monotonic()
|
||||
t = self._post_later(server, line, 0.1, stamps)
|
||||
assert dc._wait_frame_interval(0.5, 'clock') is False
|
||||
assert dc._wait_frame_interval(0.5, _screen('clock')) is None
|
||||
assert time.monotonic() - started >= 0.49
|
||||
t.join()
|
||||
dc.display_manager.set_brightness.assert_called_once_with(33)
|
||||
@@ -323,7 +333,7 @@ class TestRealTimeWake:
|
||||
def test_without_a_socket_it_is_a_plain_sleep(self, dc):
|
||||
dc._control_server = None
|
||||
started = time.monotonic()
|
||||
assert dc._wait_frame_interval(0.2, 'clock') is False
|
||||
assert dc._wait_frame_interval(0.2, _screen('clock')) is None
|
||||
assert time.monotonic() - started >= 0.19
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
"""Stages 4 and 5 of the control socket: the socket is the only way in.
|
||||
|
||||
* The client knows whether the display had the request (``ControlError.sent``)
|
||||
and ``display_not_listening`` says when no display was there to take it
|
||||
(stopped or still starting), the one case a later retry can fix.
|
||||
* ``errors.clear`` is answered on the connection thread by a handler the
|
||||
display registers; a display without one answers like an older display.
|
||||
* Stage 5: the display no longer reads the ``display_on_demand_request``
|
||||
mailbox at all, and the cache refuses writes to the retired mailbox keys,
|
||||
warning once per writer.
|
||||
|
||||
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 import cache_manager as cache_module
|
||||
from src.cache_manager import CacheManager
|
||||
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,listening', [('no_socket', False), ('refused', False),
|
||||
('timeout', True), ('busy', True)])
|
||||
def test_a_failed_connect_was_not_sent(self, reason, listening):
|
||||
e = _error(connect_error=client.ControlError(reason))
|
||||
assert e.reason == reason and e.sent is False
|
||||
# Only "nothing there" is worth waiting for: a timeout or a full
|
||||
# backlog is a display that is there and stuck.
|
||||
assert client.display_not_listening(e) is not listening
|
||||
|
||||
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 not client.display_not_listening(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.display_not_listening(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.display_not_listening(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.display_not_listening(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.display_not_listening(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 not client.display_not_listening(e)
|
||||
|
||||
@pytest.mark.parametrize('code', ['unknown_command', 'unsupported_version'])
|
||||
def test_an_older_display_is_listening(self, code):
|
||||
sock = FakeSock([_reply('rid-1', ok=False, error={'code': code, 'message': 'x'})])
|
||||
e = _error(sock=sock)
|
||||
assert e.sent is True
|
||||
assert not client.display_not_listening(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 not client.display_not_listening(e.value)
|
||||
|
||||
@pytest.mark.parametrize('reason', ['disabled', 'unsupported'])
|
||||
def test_a_client_without_the_socket_is_not_waiting_for_a_display(self, reason):
|
||||
assert not client.display_not_listening(client.ControlError(reason))
|
||||
|
||||
@pytest.mark.parametrize('reason', sorted(client.NOT_LISTENING_REASONS))
|
||||
def test_a_request_that_was_sent_had_a_display(self, reason):
|
||||
# Only a request that never left can be waited on and sent again: one
|
||||
# that was sent may have been applied.
|
||||
assert not client.display_not_listening(client.ControlError(reason, sent=True))
|
||||
|
||||
def test_a_client_bug_is_not_a_missing_display(self):
|
||||
assert not client.display_not_listening(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']
|
||||
|
||||
|
||||
# -- stage 5: the display reads no mailbox --------------------------------------------
|
||||
|
||||
class CountingCache:
|
||||
"""The slice of CacheManager the on-demand path uses, counting every call."""
|
||||
|
||||
def __init__(self):
|
||||
self.data = {}
|
||||
self.calls = []
|
||||
|
||||
def __getattr__(self, name):
|
||||
# Any other method (file_signature, delete, ...) is recorded too.
|
||||
def call(*a, **kw):
|
||||
self.calls.append((name, a[0] if a else None))
|
||||
return call
|
||||
|
||||
def get(self, key, *a, **kw):
|
||||
self.calls.append(('get', key))
|
||||
return self.data.get(key)
|
||||
|
||||
def set(self, key, value, *a, **kw):
|
||||
self.calls.append(('set', key))
|
||||
self.data[key] = value
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def controller(test_display_controller):
|
||||
dc = test_display_controller
|
||||
dc.cache_manager = CountingCache()
|
||||
dc._activate_on_demand = MagicMock()
|
||||
dc.on_demand_active = False
|
||||
dc.on_demand_request_id = None
|
||||
return dc
|
||||
|
||||
|
||||
class TestNoMailbox:
|
||||
@pytest.mark.parametrize('with_socket', [False, True])
|
||||
def test_polling_touches_no_cache_key(self, controller, with_socket):
|
||||
controller._control_server = FakeServer() if with_socket else None
|
||||
controller.cache_manager.data[MAILBOX] = {'request_id': 'left', 'action': 'start',
|
||||
'plugin_id': 'clock'}
|
||||
for _ in range(200):
|
||||
controller._poll_on_demand_requests()
|
||||
assert controller.cache_manager.calls == []
|
||||
controller._activate_on_demand.assert_not_called()
|
||||
|
||||
def test_socket_commands_land_at_once(self, controller):
|
||||
server = controller._control_server = FakeServer()
|
||||
server.commands.append(QueuedCommand('sock', Command.ON_DEMAND_START,
|
||||
OnDemandStartArgs(plugin_id='clock'), time.time()))
|
||||
controller._poll_on_demand_requests()
|
||||
controller._activate_on_demand.assert_called_once()
|
||||
|
||||
def test_a_start_is_applied_once_and_writes_no_processed_id(self, controller):
|
||||
server = controller._control_server = FakeServer()
|
||||
for _ in range(2):
|
||||
server.commands.append(QueuedCommand('same', Command.ON_DEMAND_START,
|
||||
OnDemandStartArgs(plugin_id='clock'), time.time()))
|
||||
controller._poll_on_demand_requests()
|
||||
controller._activate_on_demand.assert_called_once()
|
||||
assert controller.cache_manager.calls == []
|
||||
|
||||
def test_a_socket_stop_touches_no_cache_key(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._drain_control_commands()
|
||||
controller._clear_on_demand.assert_called_once()
|
||||
assert controller.cache_manager.calls == []
|
||||
|
||||
def test_the_mailbox_helpers_are_gone(self):
|
||||
from src import display_controller as dcm
|
||||
from src import error_aggregator as ea
|
||||
for name in ('ON_DEMAND_MAILBOX_KEY', 'MailboxWatch'):
|
||||
assert not hasattr(dcm, name)
|
||||
assert not hasattr(ea, 'ERROR_CLEAR_REQUEST_KEY')
|
||||
assert not hasattr(cache_module, 'MailboxWatch')
|
||||
assert not hasattr(CacheManager, 'file_signature')
|
||||
assert not hasattr(client, 'should_fall_back')
|
||||
for name in ('_consume_on_demand_request', '_note_mailbox_request',
|
||||
'_mailbox_poll_interval', 'MAILBOX_POLL_INTERVAL_WITH_SOCKET'):
|
||||
assert not hasattr(dcm.DisplayController, name)
|
||||
|
||||
|
||||
# -- stage 5: writes to the retired keys are refused, with one warning per writer -----
|
||||
|
||||
@pytest.fixture
|
||||
def real_cache(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(CacheManager, '_get_writable_cache_dir', lambda self: str(tmp_path))
|
||||
monkeypatch.setattr(cache_module, '_retired_writers_warned', set())
|
||||
cache = CacheManager()
|
||||
yield cache
|
||||
cache.stop_cleanup_thread()
|
||||
|
||||
|
||||
class OldPlugin:
|
||||
"""What an old plugin looks like on the stack: BasePlugin gives every
|
||||
plugin ``plugin_id`` and ``cache_manager``."""
|
||||
|
||||
def __init__(self, plugin_id, cache):
|
||||
self.plugin_id = plugin_id
|
||||
self.cache_manager = cache
|
||||
|
||||
def trigger(self, target=None):
|
||||
self.cache_manager.set(MAILBOX, {'request_id': 'r', 'action': 'start',
|
||||
'plugin_id': target or self.plugin_id})
|
||||
|
||||
|
||||
def _warnings(caplog):
|
||||
return [r.getMessage() for r in caplog.records
|
||||
if r.levelno == logging.WARNING and 'retired' in r.getMessage()]
|
||||
|
||||
|
||||
class TestRetiredKeys:
|
||||
@pytest.mark.parametrize('key', sorted(cache_module.RETIRED_MAILBOX_KEYS))
|
||||
def test_a_write_stores_nothing(self, real_cache, key):
|
||||
real_cache.set(key, {'request_id': 'r'})
|
||||
real_cache.save_cache(key, {'request_id': 'r2'})
|
||||
assert real_cache.get(key, max_age=None, memory_ttl=0) is None
|
||||
assert not [f for f in os.listdir(real_cache.cache_dir) if key in f]
|
||||
|
||||
def test_other_keys_are_unaffected(self, real_cache):
|
||||
real_cache.set('display_on_demand_state', {'active': True})
|
||||
assert real_cache.get('display_on_demand_state', max_age=None,
|
||||
memory_ttl=0) == {'active': True}
|
||||
|
||||
def test_the_writing_plugin_is_named_once(self, real_cache, caplog):
|
||||
caplog.set_level(logging.WARNING)
|
||||
on_air = OldPlugin('on-air', real_cache)
|
||||
for _ in range(3):
|
||||
on_air.trigger()
|
||||
OldPlugin('pomodoro-timer', real_cache).trigger()
|
||||
lines = _warnings(caplog)
|
||||
assert len(lines) == 2
|
||||
assert "plugin 'on-air'" in lines[0] and 'display_on_demand_request' in lines[0]
|
||||
assert 'request_on_demand' in lines[0]
|
||||
assert "plugin 'pomodoro-timer'" in lines[1]
|
||||
|
||||
def test_the_writer_is_the_caller_not_the_target(self, real_cache, caplog):
|
||||
caplog.set_level(logging.WARNING)
|
||||
OldPlugin('mqtt-notifications', real_cache).trigger(target='clock')
|
||||
(line,) = _warnings(caplog)
|
||||
assert "plugin 'mqtt-notifications'" in line and 'clock' not in line
|
||||
|
||||
def test_without_a_plugin_on_the_stack_the_request_names_it(self, real_cache, caplog):
|
||||
caplog.set_level(logging.WARNING)
|
||||
real_cache.set(MAILBOX, {'request_id': 'r', 'action': 'start', 'plugin_id': 'gif-player'})
|
||||
(line,) = _warnings(caplog)
|
||||
assert "plugin 'gif-player' (named in the request)" in line
|
||||
|
||||
def test_otherwise_unknown(self, real_cache, caplog):
|
||||
caplog.set_level(logging.WARNING)
|
||||
real_cache.set('plugin_error_clear_request', {'request_id': 'r', 'cutoff': 1.0})
|
||||
real_cache.set('plugin_error_clear_request', {'request_id': 'r2', 'cutoff': 2.0})
|
||||
(line,) = _warnings(caplog)
|
||||
assert 'by unknown' in line and '/api/v3/errors/clear' in line
|
||||
|
||||
|
||||
# -- 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_listening(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 not client.display_not_listening(e.value)
|
||||
finally:
|
||||
server.close()
|
||||
|
||||
def test_no_display_is_not_listening(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.display_not_listening(e.value)
|
||||
|
||||
def test_a_full_queue_is_listening(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.display_not_listening(e.value)
|
||||
finally:
|
||||
server.close()
|
||||
@@ -327,3 +327,27 @@ class TestCleartextIsCalledOut:
|
||||
def test_tls_on_does_not_warn(self, bridge_module):
|
||||
assert bridge_module.warn_if_cleartext(
|
||||
{"mqtt_tls": True, "mqtt_password": "hunter2"}) is False
|
||||
|
||||
|
||||
class TestTheApiClientReadsStartingAsTaken:
|
||||
"""A cold start answers 202 with ``status: "starting"``: the request is
|
||||
taken and the web process delivers it once the display listens. The
|
||||
bridge must report that as success, not as a failure."""
|
||||
|
||||
def _client(self, bridge_module, status_code, body):
|
||||
response = MagicMock(status_code=status_code)
|
||||
response.json.return_value = body
|
||||
session = MagicMock()
|
||||
session.request.return_value = response
|
||||
return bridge_module.LEDMatrixClient("http://pi:5000", session=session)
|
||||
|
||||
def test_202_starting_is_returned_not_raised(self, bridge_module):
|
||||
api = self._client(bridge_module, 202, {
|
||||
"status": "starting", "message": "starting",
|
||||
"data": {"request_id": "r1", "pending": True}})
|
||||
assert api.start_on_demand(mode="clock") == {"request_id": "r1", "pending": True}
|
||||
|
||||
def test_an_error_is_still_raised(self, bridge_module):
|
||||
api = self._client(bridge_module, 503, {"status": "error", "message": "no display"})
|
||||
with pytest.raises(RuntimeError, match="no display"):
|
||||
api.start_on_demand(mode="clock")
|
||||
|
||||
@@ -337,13 +337,8 @@ class TestResumingAfterTheSession:
|
||||
|
||||
class TestStopClearsAnError:
|
||||
def _post_stop(self, c):
|
||||
stop = {'request_id': 'S1', 'action': 'stop'}
|
||||
c._last_on_demand_poll = None
|
||||
c.cache_manager.get = MagicMock(
|
||||
side_effect=lambda key, *a, **kw:
|
||||
stop if key == 'display_on_demand_request' else None)
|
||||
c.cache_manager.delete = MagicMock()
|
||||
c._poll_on_demand_requests()
|
||||
c._handle_on_demand_request({'request_id': 'S1', 'action': 'stop',
|
||||
'source': 'socket'})
|
||||
|
||||
def test_a_stop_after_a_failed_request_clears_the_error(self, controller):
|
||||
_start(controller, plugin_id='uninstalled')
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
"""The web process's on-demand dispatcher (web_interface/on_demand_dispatch.py).
|
||||
|
||||
A start that finds no display listening -- the service was just started, or
|
||||
is still loading its plugins -- is answered at once (202), and the
|
||||
dispatcher's one worker thread sends it again until the display
|
||||
acknowledges it or the wait runs out. These tests drive the worker with a
|
||||
fake ``send`` and short waits:
|
||||
|
||||
* acknowledged: delivered once, and reported as such;
|
||||
* nothing listening for the whole wait: ``start-timeout``;
|
||||
* any other failure: reported at once, not retried;
|
||||
* a newer start supersedes the pending one; a stop cancels it;
|
||||
* the outcome is reported for a while, then forgotten.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from src.ipc import client as control_client
|
||||
from web_interface import on_demand_dispatch
|
||||
from web_interface.on_demand_dispatch import OnDemandDispatcher
|
||||
|
||||
|
||||
def _not_listening():
|
||||
return control_client.ControlError("no_socket", "x", sent=False)
|
||||
|
||||
|
||||
def _payload(rid, plugin_id="weather"):
|
||||
return {"request_id": rid, "action": "start", "plugin_id": plugin_id,
|
||||
"mode": plugin_id, "duration": 30, "pinned": False}
|
||||
|
||||
|
||||
class FakeSend:
|
||||
"""Answers with ``outcomes`` in turn (an exception is raised, anything
|
||||
else acks); the last one repeats. Records the request ids it was sent."""
|
||||
|
||||
def __init__(self, *outcomes):
|
||||
self.outcomes = list(outcomes) or ["ack"]
|
||||
self.sent = []
|
||||
self.lock = threading.Lock()
|
||||
|
||||
def __call__(self, payload):
|
||||
with self.lock:
|
||||
self.sent.append(payload["request_id"])
|
||||
outcome = self.outcomes.pop(0) if len(self.outcomes) > 1 else self.outcomes[0]
|
||||
if isinstance(outcome, BaseException):
|
||||
raise outcome
|
||||
if callable(outcome):
|
||||
return outcome(payload)
|
||||
return {"accepted": True}
|
||||
|
||||
|
||||
def _until(predicate, timeout=5.0):
|
||||
end = time.monotonic() + timeout
|
||||
while time.monotonic() < end:
|
||||
if predicate():
|
||||
return True
|
||||
time.sleep(0.005)
|
||||
return False
|
||||
|
||||
|
||||
def _settled(d):
|
||||
return _until(lambda: not d.pending() and d._thread is None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make():
|
||||
made = []
|
||||
|
||||
def build(send, wait_seconds=2.0, retry_interval=0.01):
|
||||
d = OnDemandDispatcher(send, wait_seconds=wait_seconds, retry_interval=retry_interval)
|
||||
made.append(d)
|
||||
return d
|
||||
yield build
|
||||
for d in made:
|
||||
d.cancel("teardown")
|
||||
|
||||
|
||||
class TestDelivery:
|
||||
def test_an_acknowledged_start_is_delivered_once(self, make):
|
||||
send = FakeSend("ack")
|
||||
d = make(send)
|
||||
d.submit(_payload("r1"))
|
||||
assert _settled(d)
|
||||
assert send.sent == ["r1"]
|
||||
status = d.status()
|
||||
assert status["status"] == "delivered" and status["request_id"] == "r1"
|
||||
assert status["source"] == "web"
|
||||
|
||||
def test_it_is_sent_again_until_the_display_listens(self, make):
|
||||
send = FakeSend(_not_listening(), _not_listening(), "ack")
|
||||
d = make(send)
|
||||
d.submit(_payload("r1"))
|
||||
assert _settled(d)
|
||||
assert send.sent == ["r1", "r1", "r1"]
|
||||
assert d.status()["status"] == "delivered"
|
||||
|
||||
def test_while_it_waits_it_reports_starting(self, make):
|
||||
gate = threading.Event()
|
||||
|
||||
def blocked(payload):
|
||||
gate.wait(5)
|
||||
raise _not_listening()
|
||||
|
||||
send = FakeSend(blocked, "ack")
|
||||
d = make(send)
|
||||
d.submit(_payload("r1"))
|
||||
status = d.status()
|
||||
assert status["status"] == "starting" and status["active"] is False
|
||||
assert (status["plugin_id"], status["mode"], status["duration"]) == ("weather", "weather", 30)
|
||||
gate.set()
|
||||
assert _settled(d)
|
||||
assert d.status()["status"] == "delivered"
|
||||
|
||||
|
||||
class TestGivingUp:
|
||||
def test_nothing_listening_for_the_whole_wait_is_a_start_timeout(self, make):
|
||||
send = FakeSend(_not_listening())
|
||||
d = make(send, wait_seconds=0.2)
|
||||
started = time.monotonic()
|
||||
d.submit(_payload("r1"))
|
||||
assert _settled(d)
|
||||
waited = time.monotonic() - started
|
||||
status = d.status()
|
||||
assert status["status"] == "error" and status["error"] == "start-timeout"
|
||||
assert 0.15 <= waited < 2.0
|
||||
assert len(send.sent) > 3 # it kept trying in between
|
||||
|
||||
def test_a_start_can_carry_its_own_wait(self, make):
|
||||
# The route passes a shorter wait for a service that was already
|
||||
# running; the dispatcher's default must not override it.
|
||||
d = make(FakeSend(_not_listening()), wait_seconds=30.0)
|
||||
d.submit(_payload("r1"), wait_seconds=0.1)
|
||||
assert _until(lambda: not d.pending(), timeout=3.0), "it waited the default"
|
||||
assert d.status()["error"] == "start-timeout"
|
||||
|
||||
@pytest.mark.parametrize("reason,sent", [("busy", True), ("unknown_command", True),
|
||||
("timeout", True), ("forbidden", False)])
|
||||
def test_any_other_failure_is_reported_at_once(self, make, reason, sent):
|
||||
send = FakeSend(control_client.ControlError(reason, "x", sent=sent))
|
||||
d = make(send)
|
||||
d.submit(_payload("r1"))
|
||||
assert _settled(d)
|
||||
assert send.sent == ["r1"]
|
||||
status = d.status()
|
||||
assert status["status"] == "error" and status["error"] == reason
|
||||
|
||||
def test_a_client_bug_is_internal(self, make):
|
||||
d = make(FakeSend(RuntimeError("boom")))
|
||||
d.submit(_payload("r1"))
|
||||
assert _settled(d)
|
||||
assert d.status()["error"] == "internal"
|
||||
|
||||
|
||||
class TestOneAtATime:
|
||||
def test_a_newer_start_supersedes_the_pending_one(self, make):
|
||||
send = FakeSend(_not_listening())
|
||||
d = make(send)
|
||||
d.submit(_payload("old"))
|
||||
assert _until(lambda: "old" in send.sent)
|
||||
d.submit(_payload("new", plugin_id="clock"))
|
||||
assert d.status()["request_id"] == "new"
|
||||
n = len(send.sent)
|
||||
send.outcomes = ["ack"]
|
||||
assert _settled(d)
|
||||
assert send.sent[n:] and set(send.sent[n + 1:]) <= {"new"}
|
||||
assert send.sent[-1] == "new"
|
||||
status = d.status()
|
||||
assert status["status"] == "delivered" and status["plugin_id"] == "clock"
|
||||
|
||||
def test_an_answer_for_a_superseded_start_is_not_reported(self, make):
|
||||
# The old start's send is in flight when the new one arrives; its
|
||||
# ack must not mark the new one delivered.
|
||||
in_flight, release = threading.Event(), threading.Event()
|
||||
|
||||
def slow(payload):
|
||||
in_flight.set()
|
||||
release.wait(5)
|
||||
return {"accepted": True}
|
||||
|
||||
send = FakeSend(slow, _not_listening())
|
||||
d = make(send, wait_seconds=0.3)
|
||||
d.submit(_payload("old"))
|
||||
assert in_flight.wait(5)
|
||||
d.submit(_payload("new"))
|
||||
release.set()
|
||||
assert _settled(d)
|
||||
status = d.status()
|
||||
assert status["request_id"] == "new"
|
||||
assert status["status"] == "error" and status["error"] == "start-timeout"
|
||||
|
||||
def test_a_stop_cancels_the_pending_start(self, make):
|
||||
send = FakeSend(_not_listening())
|
||||
d = make(send)
|
||||
d.submit(_payload("r1"))
|
||||
assert _until(lambda: send.sent)
|
||||
assert d.cancel("requested-stop") == "r1"
|
||||
assert _settled(d)
|
||||
n = len(send.sent)
|
||||
time.sleep(0.05)
|
||||
assert len(send.sent) == n, "it kept sending a cancelled start"
|
||||
status = d.status()
|
||||
assert status["status"] == "idle" and status["last_event"] == "requested-stop"
|
||||
|
||||
def test_a_cancel_with_nothing_pending_does_nothing(self, make):
|
||||
d = make(FakeSend("ack"))
|
||||
assert d.cancel() is None
|
||||
assert d.status() is None
|
||||
|
||||
def test_a_cancel_after_delivery_leaves_the_outcome(self, make):
|
||||
d = make(FakeSend("ack"))
|
||||
d.submit(_payload("r1"))
|
||||
assert _settled(d)
|
||||
assert d.cancel() is None
|
||||
assert d.status()["status"] == "delivered"
|
||||
|
||||
|
||||
class TestOutcomeLifetime:
|
||||
def test_an_outcome_is_forgotten_after_a_while(self, make, monkeypatch):
|
||||
d = make(FakeSend("ack"))
|
||||
d.submit(_payload("r1"))
|
||||
assert _settled(d)
|
||||
assert d.status() is not None
|
||||
monkeypatch.setattr(on_demand_dispatch, "OUTCOME_SECONDS", 0.0)
|
||||
time.sleep(0.01)
|
||||
assert d.status() is None
|
||||
|
||||
def test_a_pending_start_never_expires(self, make, monkeypatch):
|
||||
monkeypatch.setattr(on_demand_dispatch, "OUTCOME_SECONDS", 0.0)
|
||||
gate = threading.Event()
|
||||
d = make(FakeSend(lambda p: gate.wait(5) and {"accepted": True}))
|
||||
d.submit(_payload("r1"))
|
||||
time.sleep(0.01)
|
||||
assert d.status()["status"] == "starting"
|
||||
gate.set()
|
||||
assert _settled(d)
|
||||
|
||||
|
||||
def test_the_process_has_one_dispatcher():
|
||||
on_demand_dispatch.reset_for_tests()
|
||||
try:
|
||||
assert on_demand_dispatch.current() is None
|
||||
first = on_demand_dispatch.get_dispatcher(FakeSend())
|
||||
assert on_demand_dispatch.get_dispatcher(FakeSend()) is first
|
||||
assert on_demand_dispatch.current() is first
|
||||
finally:
|
||||
on_demand_dispatch.reset_for_tests()
|
||||
@@ -87,8 +87,10 @@ class TestANamedLiveModeIsShown:
|
||||
def test_the_named_mode_survives_a_restart(self, football):
|
||||
football._activate_on_demand({'plugin_id': 'football-scoreboard',
|
||||
'mode': 'ncaa_fb_live'})
|
||||
saved = football.cache_manager.set.call_args_list[-1]
|
||||
assert saved.args[0] == 'display_on_demand_config'
|
||||
# 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'
|
||||
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
"""The on-demand request mailbox: how often it is read, and how it is consumed.
|
||||
|
||||
The mailbox is a cache key the web process writes and the display process
|
||||
reads. Two properties matter and neither is obvious from the call site:
|
||||
|
||||
* it is polled after every rendered frame, so an uncached read here is a
|
||||
disk read at frame rate;
|
||||
* consuming it must not throw away a request that arrived while the previous
|
||||
one was being processed.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestPollingIsBounded:
|
||||
"""_poll_on_demand_requests runs ~125x/second on a scrolling mode.
|
||||
|
||||
The read is deliberately uncached (memory_ttl=0) because a cached one
|
||||
pinned the first request for an hour. That makes the call a real disk read,
|
||||
so it needs a floor -- without one it was ~125 reads per second to find
|
||||
nothing at all.
|
||||
"""
|
||||
|
||||
def test_first_call_always_reads(self, test_display_controller):
|
||||
c = test_display_controller
|
||||
c.cache_manager.get = MagicMock(return_value=None)
|
||||
c._poll_on_demand_requests()
|
||||
assert c.cache_manager.get.call_count == 1
|
||||
|
||||
def test_immediate_second_call_does_not_read(self, test_display_controller):
|
||||
c = test_display_controller
|
||||
c.cache_manager.get = MagicMock(return_value=None)
|
||||
c._poll_on_demand_requests()
|
||||
for _ in range(50):
|
||||
c._poll_on_demand_requests()
|
||||
assert c.cache_manager.get.call_count == 1, "polling was not bounded"
|
||||
|
||||
def test_reads_again_once_the_interval_has_passed(self, test_display_controller, monkeypatch):
|
||||
c = test_display_controller
|
||||
c.cache_manager.get = MagicMock(return_value=None)
|
||||
clock = {"t": 1000.0}
|
||||
monkeypatch.setattr("src.display_controller.time.monotonic", lambda: clock["t"])
|
||||
|
||||
c._poll_on_demand_requests()
|
||||
clock["t"] += c.ON_DEMAND_POLL_INTERVAL / 2
|
||||
c._poll_on_demand_requests()
|
||||
assert c.cache_manager.get.call_count == 1, "read before the interval elapsed"
|
||||
|
||||
clock["t"] += c.ON_DEMAND_POLL_INTERVAL
|
||||
c._poll_on_demand_requests()
|
||||
assert c.cache_manager.get.call_count == 2
|
||||
|
||||
def test_the_interval_is_short_enough_to_feel_instant(self, test_display_controller):
|
||||
# A person clicking in the web UI must not notice the floor.
|
||||
assert test_display_controller.ON_DEMAND_POLL_INTERVAL <= 0.5
|
||||
|
||||
|
||||
class TestMailboxIsConsumedByIdentity:
|
||||
"""Deleting whatever is in the mailbox loses a request that raced in."""
|
||||
|
||||
def _arrange(self, controller, first, later):
|
||||
"""Mailbox returns `first`, then `later` on the pre-delete re-read."""
|
||||
controller.on_demand_active = False
|
||||
controller.on_demand_request_id = None
|
||||
controller._last_on_demand_poll = None
|
||||
reads = iter([first, later])
|
||||
|
||||
def fake_get(key, *a, **kw):
|
||||
if key == 'display_on_demand_request':
|
||||
return next(reads, later)
|
||||
return None # processed-id lookup
|
||||
|
||||
controller.cache_manager.get = MagicMock(side_effect=fake_get)
|
||||
controller.cache_manager.set = MagicMock()
|
||||
controller.cache_manager.delete = MagicMock()
|
||||
controller._activate_on_demand = MagicMock()
|
||||
|
||||
REQ_A = {'request_id': 'A', 'action': 'start', 'plugin_id': 'p', 'mode': 'm'}
|
||||
REQ_B = {'request_id': 'B', 'action': 'start', 'plugin_id': 'p', 'mode': 'm'}
|
||||
|
||||
def test_own_request_is_deleted(self, test_display_controller):
|
||||
c = test_display_controller
|
||||
self._arrange(c, self.REQ_A, self.REQ_A)
|
||||
c._poll_on_demand_requests()
|
||||
c.cache_manager.delete.assert_called_once_with('display_on_demand_request')
|
||||
|
||||
def test_a_newer_request_is_left_for_the_next_poll(self, test_display_controller):
|
||||
c = test_display_controller
|
||||
self._arrange(c, self.REQ_A, self.REQ_B)
|
||||
c._poll_on_demand_requests()
|
||||
assert c.cache_manager.delete.call_count == 0, \
|
||||
"request B was deleted without ever being processed"
|
||||
|
||||
def test_an_already_empty_mailbox_is_still_cleared(self, test_display_controller):
|
||||
c = test_display_controller
|
||||
self._arrange(c, self.REQ_A, None)
|
||||
c._poll_on_demand_requests()
|
||||
c.cache_manager.delete.assert_called_once_with('display_on_demand_request')
|
||||
|
||||
def test_the_request_is_still_processed(self, test_display_controller):
|
||||
c = test_display_controller
|
||||
self._arrange(c, self.REQ_A, self.REQ_B)
|
||||
c._poll_on_demand_requests()
|
||||
c._activate_on_demand.assert_called_once()
|
||||
@@ -8,8 +8,9 @@ Three separate gaps, all reachable from the web UI's force-display dialog:
|
||||
* restarting while on-demand was active loaded *only* the on-demand plugin,
|
||||
so normal rotation had nothing to return to for the life of the process;
|
||||
* a stop request was exempt from the duplicate guards on purpose and was
|
||||
never removed from the mailbox, so it was re-processed on every poll
|
||||
forever.
|
||||
never removed from the file mailbox, so it was re-processed on every poll
|
||||
forever. The mailbox is gone (stage 5); a stop still skips the guards,
|
||||
so a second click stops a session a race left running.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
@@ -185,51 +186,25 @@ class TestRestartDoesNotStarveTheOtherPlugins:
|
||||
assert controller.on_demand_active is False
|
||||
|
||||
|
||||
class TestStopRequestsAreConsumed:
|
||||
"""A stop request is exempt from the duplicate guards, so the mailbox
|
||||
delete is the only thing that ends it."""
|
||||
|
||||
STOP = {'request_id': 'S1', 'action': 'stop'}
|
||||
class TestStopRequestsSkipTheDuplicateGuard:
|
||||
"""A stop is exempt from the request-id guard: every one is acted on."""
|
||||
|
||||
def _arrange(self, controller, active):
|
||||
controller.on_demand_active = active
|
||||
controller.on_demand_status = 'active' if active else 'idle'
|
||||
controller._last_on_demand_poll = None
|
||||
controller.cache_manager.get = MagicMock(
|
||||
side_effect=lambda key, *a, **kw:
|
||||
self.STOP if key == 'display_on_demand_request' else None)
|
||||
controller.cache_manager.set = MagicMock()
|
||||
controller.cache_manager.delete = MagicMock()
|
||||
controller._clear_on_demand = MagicMock()
|
||||
|
||||
def test_a_handled_stop_is_removed_from_the_mailbox(self, test_display_controller):
|
||||
def test_the_stop_is_acted_on(self, test_display_controller):
|
||||
c = test_display_controller
|
||||
self._arrange(c, active=True)
|
||||
c._poll_on_demand_requests()
|
||||
c.cache_manager.delete.assert_called_once_with('display_on_demand_request')
|
||||
|
||||
def test_a_stop_arriving_while_idle_is_also_removed(self, test_display_controller):
|
||||
"""Otherwise a stop sent to an idle display re-fires forever."""
|
||||
c = test_display_controller
|
||||
self._arrange(c, active=False)
|
||||
c._poll_on_demand_requests()
|
||||
c.cache_manager.delete.assert_called_once_with('display_on_demand_request')
|
||||
|
||||
def test_the_stop_is_still_acted_on(self, test_display_controller):
|
||||
c = test_display_controller
|
||||
self._arrange(c, active=True)
|
||||
c._poll_on_demand_requests()
|
||||
c._handle_on_demand_request({'request_id': 'S1', 'action': 'stop',
|
||||
'source': 'socket'})
|
||||
c._clear_on_demand.assert_called_once_with(reason='requested-stop')
|
||||
|
||||
def test_a_start_racing_in_behind_a_stop_is_not_discarded(self, test_display_controller):
|
||||
"""The compare-before-delete applies to stops too."""
|
||||
def test_the_same_stop_twice_is_acted_on_twice(self, test_display_controller):
|
||||
c = test_display_controller
|
||||
self._arrange(c, active=True)
|
||||
newer = {'request_id': 'S2', 'action': 'start', 'plugin_id': 'p', 'mode': 'm'}
|
||||
reads = iter([self.STOP, newer])
|
||||
c.cache_manager.get = MagicMock(
|
||||
side_effect=lambda key, *a, **kw:
|
||||
next(reads, newer) if key == 'display_on_demand_request' else None)
|
||||
|
||||
c._poll_on_demand_requests()
|
||||
assert c.cache_manager.delete.call_count == 0
|
||||
for _ in range(2):
|
||||
c._handle_on_demand_request({'request_id': 'S1', 'action': 'stop',
|
||||
'source': 'socket'})
|
||||
assert c._clear_on_demand.call_count == 2
|
||||
|
||||
@@ -69,6 +69,7 @@ def test_fixed_plugin_loads_new_code_after_failed_load(plugin_env, first_source)
|
||||
assert MODULE_NAME not in sys.modules
|
||||
assert PLUGIN_ID not in pm.plugin_loader._loaded_modules
|
||||
pm.font_manager.forget_manager_fonts.assert_called_with(PLUGIN_ID)
|
||||
pm.font_manager.forget_plugin_fonts.assert_called_with(PLUGIN_ID)
|
||||
|
||||
(plugin_dir / "manager.py").write_text(_FIXED, encoding="utf-8")
|
||||
assert pm.load_plugin(PLUGIN_ID) is True
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
"""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 no longer reads
|
||||
(stage 5). These tests pin the way in that replaced 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 _handle_on_demand_request, at once, 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.
|
||||
"""
|
||||
|
||||
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_.cache_manager.get = MagicMock(return_value=None)
|
||||
c_.cache_manager.set = MagicMock()
|
||||
c_.cache_manager.delete = MagicMock()
|
||||
c_._activate_on_demand = MagicMock()
|
||||
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_on_demand_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_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_lands_on_the_next_poll(self, wired):
|
||||
controller, manager = wired
|
||||
controller._control_server = _WakeServer()
|
||||
controller._poll_on_demand_requests()
|
||||
_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_socket_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._handle_on_demand_request({'request_id': 's', 'action': 'stop',
|
||||
'source': 'socket'})
|
||||
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: no display in this process took the request."""
|
||||
|
||||
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_a_mailbox_write_after_none_is_dropped_with_a_warning(self, tmp_path,
|
||||
monkeypatch, caplog):
|
||||
"""What a plugin written for older cores does on None now: its
|
||||
fallback write to the retired key stores nothing, and the log names
|
||||
it once."""
|
||||
from src import cache_manager as cache_module
|
||||
from src.cache_manager import CacheManager
|
||||
monkeypatch.setattr(CacheManager, '_get_writable_cache_dir',
|
||||
lambda self: str(tmp_path))
|
||||
monkeypatch.setattr(cache_module, '_retired_writers_warned', set())
|
||||
cache = CacheManager()
|
||||
try:
|
||||
plugin = _plugin('birdnet-go', _manager())
|
||||
plugin.cache_manager = cache
|
||||
caplog.set_level(logging.WARNING)
|
||||
for _ in range(2):
|
||||
if plugin.request_on_demand(mode='m') is None:
|
||||
plugin.cache_manager.set('display_on_demand_request', {
|
||||
'request_id': 'r', 'action': 'start', 'plugin_id': 'birdnet-go'})
|
||||
assert cache.get('display_on_demand_request', max_age=None, memory_ttl=0) is None
|
||||
lines = [r.getMessage() for r in caplog.records if 'retired' in r.getMessage()]
|
||||
assert len(lines) == 1 and "plugin 'birdnet-go'" in lines[0]
|
||||
finally:
|
||||
cache.stop_cleanup_thread()
|
||||
|
||||
|
||||
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 reads as "not taken"."""
|
||||
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'
|
||||
@@ -423,7 +423,7 @@ def web_listing(api_v3_module, api_v3_client, shared_cache, tmp_path): # noqa:
|
||||
{"id": "clock", "name": "Clock", "version": "1.1.0"},
|
||||
{"id": "weather", "name": "Weather", "version": "3.0.0"},
|
||||
])
|
||||
api.plugin_store_manager.get_registry_info = MagicMock(return_value=None)
|
||||
api.plugin_store_manager.get_cached_registry_info = MagicMock(return_value=None)
|
||||
api.plugin_store_manager._get_local_git_info = MagicMock(return_value=None)
|
||||
api.config_manager.load_config = MagicMock(return_value={
|
||||
"clock": {"enabled": True}, "weather": {"enabled": True}})
|
||||
|
||||
@@ -12,8 +12,8 @@ frame, so a command is applied:
|
||||
* at the next frame in Vegas (8 ms here, at 125 fps).
|
||||
|
||||
Each test runs the real run() loop on the fake clock of
|
||||
test/_run_loop_harness.py and compares the socket with the mailbox for the
|
||||
same request. The latencies are the fake clock's, so they are exact.
|
||||
test/_run_loop_harness.py. The latencies are the fake clock's, so they are
|
||||
exact. (The file mailbox these were once compared with is gone: stage 5.)
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -32,13 +32,10 @@ def _event_time(trace, kind):
|
||||
return next(e[0] for e in trace["events"] if e[1] == kind)
|
||||
|
||||
|
||||
def _on_demand_latency(tmp_path, build, via, horizon=40):
|
||||
def _on_demand_latency(tmp_path, build, horizon=40):
|
||||
h = RunLoopHarness(tmp_path, horizon=horizon)
|
||||
build(h)
|
||||
if via == "socket":
|
||||
h.control_socket().post(POSTED, Command.ON_DEMAND_START, {"plugin_id": "weather"})
|
||||
else:
|
||||
h.on_demand_request(POSTED, "mb1", plugin_id="weather")
|
||||
h.control_socket().post(POSTED, Command.ON_DEMAND_START, {"plugin_id": "weather"})
|
||||
trace = h.run()
|
||||
return round(_event_time(trace, "on-demand-start") - POSTED, 3), trace
|
||||
|
||||
@@ -60,18 +57,12 @@ def _vegas(h):
|
||||
h.enable_vegas(cycle=30)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("build, mailbox_latency", [
|
||||
(_static, 0.7), # the next 1 s frame, at t=11
|
||||
(_dwell, 0.2), # the next 0.25 s tick, at t=10.5
|
||||
(_vegas, 0.02), # the next 10-frame check: 80 ms at 125 fps, ~0.4 s on a Pi 4
|
||||
], ids=["static-screen", "dwell", "vegas"])
|
||||
def test_a_socket_command_lands_at_once(tmp_path, build, mailbox_latency):
|
||||
(tmp_path / "s").mkdir()
|
||||
(tmp_path / "m").mkdir()
|
||||
socket_latency, trace = _on_demand_latency(tmp_path / "s", build, "socket")
|
||||
via_mailbox, _ = _on_demand_latency(tmp_path / "m", build, "mailbox")
|
||||
|
||||
assert via_mailbox == pytest.approx(mailbox_latency, abs=0.002)
|
||||
@pytest.mark.parametrize("build", [_static, _dwell, _vegas],
|
||||
ids=["static-screen", "dwell", "vegas"])
|
||||
def test_a_socket_command_lands_at_once(tmp_path, build):
|
||||
# Before stage 2 these waited for the next 1 s frame, the next 0.25 s
|
||||
# dwell tick, or the next 10-frame Vegas check.
|
||||
socket_latency, trace = _on_demand_latency(tmp_path, build)
|
||||
if build is _vegas:
|
||||
# One 8 ms frame: Vegas checks the queue every frame now.
|
||||
assert socket_latency <= 0.008
|
||||
|
||||
@@ -0,0 +1,455 @@
|
||||
"""ScreenRunner (src/screen_runner.py) on a scripted host and a fake clock.
|
||||
|
||||
The golden traces (test_run_loop_golden.py) run it inside the real
|
||||
DisplayController; these pin down its own contract: which ExitReason each
|
||||
way of ending gives, the order it calls its host in, how the 125 Hz loop
|
||||
paces, and which service point asks what.
|
||||
"""
|
||||
|
||||
from typing import Any, List, Optional, Tuple
|
||||
|
||||
import pytest
|
||||
|
||||
from src.display_arbiter import (
|
||||
RELOAD_PLAN, SCREEN_PREEMPTERS, FramePolicy, ScreenPlan, Source,
|
||||
)
|
||||
from src.screen_runner import (
|
||||
AFTER_COMPLETED_LOOP, AFTER_LOOP, DYNAMIC_GRACE, FINAL, FRAME, HIGH_FPS_INTERVAL,
|
||||
Checkpoint, ExitReason, FirstFrame, NoticeRead, Screen, ScreenRunner,
|
||||
)
|
||||
|
||||
WIFI_PLAN = ScreenPlan(Source.WIFI)
|
||||
|
||||
|
||||
class Clock:
|
||||
"""time/perf_counter read ``now``; sleep advances it."""
|
||||
|
||||
def __init__(self):
|
||||
self.now = 0.0
|
||||
self.sleeps: List[float] = []
|
||||
|
||||
def time(self) -> float:
|
||||
return self.now
|
||||
|
||||
def perf_counter(self) -> float:
|
||||
return self.now
|
||||
|
||||
def sleep(self, seconds: float) -> None:
|
||||
self.sleeps.append(round(seconds, 6))
|
||||
self.now += seconds
|
||||
|
||||
|
||||
class Host:
|
||||
"""A ScreenHost whose answers are scripted; records every call."""
|
||||
|
||||
def __init__(self, clock: Clock, *, shown=True, raised=False, minimum=10.0,
|
||||
maximum=10.0, dynamic=False, policy=FramePolicy.STATIC,
|
||||
completed=True, draws=None, checks=None, work=0.0,
|
||||
cycle_complete_at=None, dwell_to=None):
|
||||
self.clock = clock
|
||||
self.calls: List[Tuple[Any, ...]] = []
|
||||
self.shown, self.raised = shown, raised
|
||||
self.minimum, self.maximum = minimum, maximum
|
||||
self.dynamic, self.policy, self.completed = dynamic, policy, completed
|
||||
#: display() results for the frames after the first, in order.
|
||||
self.draws = list(draws or [])
|
||||
#: (checkpoint name, frame number) -> plan, or a callable(screen, cp).
|
||||
self.checks = checks or {}
|
||||
self.work = work
|
||||
self.cycle_complete_at = cycle_complete_at
|
||||
self.dwell_to = dwell_to
|
||||
self.frames = 0
|
||||
|
||||
def first_frame(self, plan, plugin):
|
||||
self.calls.append(("first", plan.mode))
|
||||
return FirstFrame(self.shown, self.raised, False)
|
||||
|
||||
def complete_plan(self, plan, plugin):
|
||||
self.calls.append(("complete",))
|
||||
if not self.completed:
|
||||
return None
|
||||
return ScreenPlan(plan.source, mode=plan.mode, min_duration=self.minimum,
|
||||
max_duration=self.maximum, dynamic=self.dynamic,
|
||||
frame_policy=self.policy, preemptible_by=plan.preemptible_by)
|
||||
|
||||
def draw(self, screen):
|
||||
self.frames += 1
|
||||
self.calls.append(("draw", self.frames))
|
||||
self.clock.now += self.work
|
||||
return self.draws.pop(0) if self.draws else True
|
||||
|
||||
def after_frame(self, screen):
|
||||
self.calls.append(("after_frame",))
|
||||
|
||||
def tick(self):
|
||||
self.calls.append(("tick",))
|
||||
|
||||
def service(self, screen):
|
||||
self.calls.append(("service",))
|
||||
return ("scan", self.frames)
|
||||
|
||||
def wait_frame(self, interval, screen):
|
||||
self.calls.append(("wait", interval))
|
||||
self.clock.sleep(interval)
|
||||
return self._scripted("wait", screen)
|
||||
|
||||
def check(self, screen, checkpoint, live_scan=None):
|
||||
self.calls.append(("check", checkpoint.name, live_scan))
|
||||
return self._scripted(checkpoint.name, screen, checkpoint)
|
||||
|
||||
def _scripted(self, name, screen, checkpoint=None):
|
||||
answer = self.checks.get((name, self.frames))
|
||||
if callable(answer):
|
||||
return answer(screen, checkpoint)
|
||||
return answer
|
||||
|
||||
def dwell(self, seconds):
|
||||
self.calls.append(("dwell", round(seconds, 6)))
|
||||
self.clock.now = self.dwell_to if self.dwell_to is not None else self.clock.now + seconds
|
||||
|
||||
def cycle_complete(self, screen):
|
||||
self.calls.append(("cycle?",))
|
||||
return self.cycle_complete_at is not None and self.clock.now >= self.cycle_complete_at
|
||||
|
||||
|
||||
PLAN = ScreenPlan(Source.ROTATION, mode="clock", preemptible_by=SCREEN_PREEMPTERS)
|
||||
|
||||
|
||||
def _run(**kwargs) -> Tuple[Any, Host, Clock]:
|
||||
clock = Clock()
|
||||
host = Host(clock, **kwargs)
|
||||
outcome = ScreenRunner(clock, host).run(PLAN, plugin=object())
|
||||
return outcome, host, clock
|
||||
|
||||
|
||||
def _names(host, *kinds):
|
||||
return [c for c in host.calls if c[0] in kinds]
|
||||
|
||||
|
||||
class TestFirstFrame:
|
||||
|
||||
def test_no_plugin_is_empty_without_a_dispatch(self):
|
||||
clock = Clock()
|
||||
host = Host(clock)
|
||||
outcome = ScreenRunner(clock, host).run(PLAN, plugin=None)
|
||||
assert outcome.exit_reason is ExitReason.EMPTY
|
||||
assert host.calls == []
|
||||
|
||||
@pytest.mark.parametrize("raised,reason", [(False, ExitReason.EMPTY),
|
||||
(True, ExitReason.ERROR)])
|
||||
def test_nothing_shown(self, raised, reason):
|
||||
outcome, host, _ = _run(shown=False, raised=raised)
|
||||
assert outcome.exit_reason is reason
|
||||
assert host.calls == [("first", "clock")]
|
||||
|
||||
def test_an_on_demand_session_with_no_time_left(self):
|
||||
outcome, host, _ = _run(completed=False)
|
||||
assert outcome.exit_reason is ExitReason.PREEMPTED
|
||||
assert outcome.preempted_by is None
|
||||
assert host.calls == [("first", "clock"), ("complete",)]
|
||||
|
||||
|
||||
class TestStaticLoop:
|
||||
|
||||
def test_runs_its_duration_one_frame_a_second(self):
|
||||
outcome, host, clock = _run(maximum=5.0)
|
||||
assert outcome.exit_reason is ExitReason.DURATION
|
||||
assert outcome.elapsed == 5.0
|
||||
# Frames at 1..4 s; the wait that reaches 5 s ends it before a fifth.
|
||||
assert host.frames == 4
|
||||
assert clock.sleeps == [1.0] * 5
|
||||
# Each frame: wait, tick, draw, follower frame, service, the check.
|
||||
assert host.calls[2:9] == [("wait", 1.0), ("tick",), ("draw", 1), ("after_frame",),
|
||||
("service",), ("check", "frame", ("scan", 1)), ("wait", 1.0)]
|
||||
# A completed loop: the after-loop look reads no notice, then FINAL.
|
||||
assert host.calls[-2:] == [("check", "after-completed-loop", None), ("check", "final", None)]
|
||||
|
||||
def test_preempted_between_frames(self):
|
||||
by = ScreenPlan(Source.ON_DEMAND, mode="x")
|
||||
outcome, host, _ = _run(maximum=30.0, checks={("frame", 3): by})
|
||||
assert outcome.exit_reason is ExitReason.PREEMPTED
|
||||
assert outcome.preempted_by is by
|
||||
assert outcome.elapsed == 3.0
|
||||
# Decided at the service point: no second look, no dwell.
|
||||
assert host.calls[-1] == ("check", "frame", ("scan", 3))
|
||||
|
||||
def test_preempted_by_a_socket_command_in_the_frame_wait(self):
|
||||
outcome, host, _ = _run(maximum=30.0, checks={("wait", 2): WIFI_PLAN})
|
||||
assert outcome.exit_reason is ExitReason.PREEMPTED
|
||||
assert host.calls[-1] == ("wait", 1.0)
|
||||
|
||||
def test_display_false_makes_up_the_minimum(self):
|
||||
outcome, host, _ = _run(maximum=12.0, draws=[True, False])
|
||||
assert outcome.exit_reason is ExitReason.DISPLAY_FALSE
|
||||
# The loop ended early: the after-loop look may read a notice, then
|
||||
# the dwell makes up the rest of the 12 s.
|
||||
assert _names(host, "check", "dwell")[-4:] == [
|
||||
("check", "after-loop", None), ("dwell", 10.0),
|
||||
("check", "after-dwell", None), ("check", "final", None)]
|
||||
assert outcome.elapsed == 12.0
|
||||
|
||||
def test_a_notice_that_cuts_the_dwell_short_ends_the_screen(self):
|
||||
def notice(screen, checkpoint):
|
||||
assert checkpoint.notice is NoticeRead.ALWAYS
|
||||
return WIFI_PLAN if checkpoint.notice_counts else None
|
||||
outcome, _, _ = _run(maximum=12.0, draws=[False], dwell_to=6.0,
|
||||
checks={("after-dwell", 1): notice})
|
||||
assert outcome.exit_reason is ExitReason.PREEMPTED
|
||||
|
||||
def test_a_notice_after_a_full_dwell_does_not(self):
|
||||
def notice(screen, checkpoint):
|
||||
return WIFI_PLAN if checkpoint.notice_counts else None
|
||||
outcome, _, _ = _run(maximum=12.0, draws=[False],
|
||||
checks={("after-dwell", 1): notice})
|
||||
assert outcome.exit_reason is ExitReason.DISPLAY_FALSE
|
||||
|
||||
def test_a_reload_ends_the_loop_but_the_screen_counts(self):
|
||||
outcome, host, _ = _run(maximum=30.0, checks={("frame", 2): RELOAD_PLAN})
|
||||
assert outcome.exit_reason is ExitReason.RELOAD
|
||||
# Not decided at the service point: the after-loop look, the dwell
|
||||
# (which returns at once while a reload waits) and FINAL follow.
|
||||
assert ("check", "after-loop", None) in host.calls
|
||||
assert ("check", "final", None) in host.calls
|
||||
|
||||
def test_a_change_after_the_loop_preempts(self):
|
||||
by = ScreenPlan(Source.ROTATION, mode="weather")
|
||||
outcome, _, _ = _run(maximum=3.0, checks={("final", 2): by})
|
||||
assert outcome.exit_reason is ExitReason.PREEMPTED
|
||||
assert outcome.preempted_by is by
|
||||
|
||||
def test_a_dynamic_screen_keeps_going_on_false(self):
|
||||
outcome, host, _ = _run(minimum=3.0, maximum=8.0, dynamic=True,
|
||||
draws=[False, False, False])
|
||||
assert outcome.exit_reason is ExitReason.DURATION
|
||||
assert host.frames == 7
|
||||
|
||||
|
||||
class TestHighFpsLoop:
|
||||
|
||||
def test_paces_to_the_deadline(self):
|
||||
outcome, host, clock = _run(maximum=0.05, policy=FramePolicy.HIGH_FPS, work=0.003)
|
||||
assert outcome.exit_reason is ExitReason.DURATION
|
||||
# 3 ms of drawing leaves 5 ms of an 8 ms frame to sleep.
|
||||
assert set(clock.sleeps) == {round(HIGH_FPS_INTERVAL - 0.003, 6)}
|
||||
|
||||
def test_an_overrun_frame_still_yields(self):
|
||||
_, _, clock = _run(maximum=0.05, policy=FramePolicy.HIGH_FPS, work=0.02)
|
||||
assert set(clock.sleeps) == {0.001}
|
||||
|
||||
def test_service_before_the_sleep_check_after(self):
|
||||
_, host, clock = _run(maximum=0.016, policy=FramePolicy.HIGH_FPS)
|
||||
first = host.calls[2:7]
|
||||
assert first == [("draw", 1), ("after_frame",), ("tick",), ("service",),
|
||||
("check", "frame", ("scan", 1))]
|
||||
assert clock.sleeps[0] == HIGH_FPS_INTERVAL
|
||||
|
||||
def test_preempted_after_the_frames_sleep(self):
|
||||
outcome, _, clock = _run(maximum=30.0, policy=FramePolicy.HIGH_FPS,
|
||||
checks={("frame", 3): WIFI_PLAN})
|
||||
assert outcome.exit_reason is ExitReason.PREEMPTED
|
||||
assert clock.now == pytest.approx(3 * HIGH_FPS_INTERVAL)
|
||||
|
||||
def test_display_false_has_no_make_up_dwell(self):
|
||||
outcome, host, _ = _run(maximum=30.0, policy=FramePolicy.HIGH_FPS,
|
||||
draws=[True, False])
|
||||
assert outcome.exit_reason is ExitReason.DISPLAY_FALSE
|
||||
assert not _names(host, "dwell")
|
||||
|
||||
|
||||
class TestDynamicDuration:
|
||||
|
||||
def test_cycle_complete_after_the_minimum_and_grace(self):
|
||||
outcome, host, _ = _run(minimum=3.0, maximum=20.0, dynamic=True,
|
||||
cycle_complete_at=1.0)
|
||||
assert outcome.exit_reason is ExitReason.CYCLE_COMPLETE
|
||||
# Not asked before minimum + grace (3.5 s): the 1 Hz loop's first
|
||||
# frame at or past it is the one at 4 s.
|
||||
assert outcome.elapsed == 4.0
|
||||
assert DYNAMIC_GRACE == 0.5
|
||||
|
||||
def test_capped_at_the_maximum(self):
|
||||
outcome, host, _ = _run(minimum=3.0, maximum=6.0, dynamic=True)
|
||||
assert outcome.exit_reason is ExitReason.DURATION
|
||||
# The end-of-screen log asks the plugin once more, before FINAL.
|
||||
assert host.calls[-3:] == [("check", "after-completed-loop", None), ("cycle?",),
|
||||
("check", "final", None)]
|
||||
|
||||
def test_high_fps_cycle_complete(self):
|
||||
outcome, _, _ = _run(minimum=0.02, maximum=1.0, dynamic=True,
|
||||
policy=FramePolicy.HIGH_FPS, cycle_complete_at=0.0)
|
||||
assert outcome.exit_reason is ExitReason.CYCLE_COMPLETE
|
||||
assert outcome.elapsed >= 0.02 + DYNAMIC_GRACE
|
||||
|
||||
|
||||
class TestCheckpoints:
|
||||
|
||||
@pytest.mark.parametrize("checkpoint,notice,reload", [
|
||||
(FRAME, NoticeRead.IF_UNDECIDED, True),
|
||||
(AFTER_LOOP, NoticeRead.IF_UNDECIDED, False),
|
||||
(AFTER_COMPLETED_LOOP, NoticeRead.NEVER, False),
|
||||
(FINAL, NoticeRead.NEVER, False),
|
||||
])
|
||||
def test_what_each_service_point_considers(self, checkpoint, notice, reload):
|
||||
"""A reload counts only between frames; the notice file is read
|
||||
where the loop read it before stage 3."""
|
||||
assert isinstance(checkpoint, Checkpoint)
|
||||
assert (checkpoint.notice, checkpoint.reload) == (notice, reload)
|
||||
|
||||
def test_the_screen_carries_the_completed_plan(self):
|
||||
seen: List[Optional[Screen]] = []
|
||||
|
||||
def grab(screen, checkpoint):
|
||||
seen.append(screen)
|
||||
_run(maximum=2.0, checks={("frame", 1): grab})
|
||||
assert seen[0].plan.frame_policy is FramePolicy.STATIC
|
||||
assert seen[0].mode == "clock"
|
||||
|
||||
|
||||
class TestControllerServicePoint:
|
||||
"""DisplayController._screen_check: the reads it makes and what it claims,
|
||||
on a controller built by the run-loop harness."""
|
||||
|
||||
@pytest.fixture
|
||||
def dc(self, tmp_path):
|
||||
import os
|
||||
os.environ.setdefault("EMULATOR", "true")
|
||||
from test._run_loop_harness import FakePlugin, RunLoopHarness
|
||||
h = RunLoopHarness(tmp_path, horizon=10)
|
||||
h.add_plugin(FakePlugin("clock", ["clock"], duration=20))
|
||||
h.add_plugin(FakePlugin("sports", ["sports_live"], duration=20,
|
||||
live=(0, 100), live_priority=True))
|
||||
dc = h.controller
|
||||
dc.current_display_mode = "clock"
|
||||
dc.current_mode_index = 0
|
||||
dc.reads = []
|
||||
|
||||
def read():
|
||||
dc.reads.append(1)
|
||||
return {"message": "AP mode", "expires_at": 1e12}
|
||||
dc._check_wifi_status_message = read
|
||||
return dc
|
||||
|
||||
@staticmethod
|
||||
def _screen(mode="clock"):
|
||||
from src.display_arbiter import ArbiterState, rotation_plan
|
||||
return Screen(rotation_plan(ArbiterState(current_mode=mode)), plugin=None,
|
||||
accepts_display_mode=False, start=0.0)
|
||||
|
||||
def test_a_pending_notice_is_read_and_ends_the_screen(self, dc):
|
||||
by = dc._screen_check(self._screen(), FRAME)
|
||||
assert by.source is Source.WIFI and len(dc.reads) == 1
|
||||
|
||||
def test_not_read_once_the_mode_has_moved(self, dc):
|
||||
dc.current_display_mode = "sports_live"
|
||||
by = dc._screen_check(self._screen(), FRAME)
|
||||
assert by.source is Source.ROTATION and dc.reads == []
|
||||
|
||||
def test_not_read_while_scheduled_off(self, dc):
|
||||
dc.is_display_active = False
|
||||
by = dc._screen_check(self._screen(), FRAME)
|
||||
assert by.source is Source.SCHEDULED_OFF and dc.reads == []
|
||||
|
||||
def test_not_read_during_on_demand(self, dc):
|
||||
dc.on_demand_active = True
|
||||
dc.on_demand_schedule_override = True
|
||||
assert dc._screen_check(self._screen(), FRAME) is None
|
||||
assert dc.reads == []
|
||||
|
||||
def test_a_live_takeover_is_claimed_before_the_notice(self, dc):
|
||||
by = dc._screen_check(self._screen(), FRAME, live_scan=("sports_live",))
|
||||
assert by.source is Source.LIVE and dc.reads == []
|
||||
assert dc.current_display_mode == "sports_live"
|
||||
assert dc._live_takeover_unshown is True and dc._live_resume_index == 0
|
||||
|
||||
def test_after_a_completed_loop_the_notice_is_not_read(self, dc):
|
||||
assert dc._screen_check(self._screen(), AFTER_COMPLETED_LOOP) is None
|
||||
assert dc.reads == []
|
||||
|
||||
def test_after_a_full_dwell_it_is_read_but_does_not_count(self, dc):
|
||||
from src.screen_runner import after_dwell
|
||||
assert dc._screen_check(self._screen(), after_dwell(False)) is None
|
||||
assert len(dc.reads) == 1
|
||||
|
||||
def test_a_reload_counts_only_between_frames(self, dc):
|
||||
dc._check_wifi_status_message = lambda: None
|
||||
dc._pending_plugin_reloads = ("pending",)
|
||||
assert dc._screen_check(self._screen(), FRAME) is RELOAD_PLAN
|
||||
assert dc._screen_check(self._screen(), AFTER_LOOP) is None
|
||||
|
||||
def test_an_on_demand_index_past_a_shortened_list_starts_again(self, dc):
|
||||
"""_take_plan writes the shown index back, so the session's next
|
||||
step goes on from the mode actually shown."""
|
||||
from src.display_arbiter import Arbiter, ArbiterInputs
|
||||
dc.on_demand_active = True
|
||||
dc.on_demand_modes = ["clock", "sports_live"]
|
||||
dc.on_demand_mode_index = 5 # the list shrank under it
|
||||
inputs = ArbiterInputs(schedule_on=True, on_demand_active=True,
|
||||
follower_active=False)
|
||||
plan = dc._take_plan(Arbiter.decide(dc._arbiter_state(), inputs, 0.0))
|
||||
assert plan.mode == "clock" and dc.on_demand_mode_index == 0
|
||||
dc._advance_on_demand()
|
||||
assert dc.current_display_mode == "sports_live"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("policy,report_hold", [(FramePolicy.HIGH_FPS, True),
|
||||
(FramePolicy.STATIC, False)])
|
||||
def test_only_the_high_fps_loop_reports_a_held_frame(policy, report_hold):
|
||||
"""#758's report_hold: the 125 Hz loop times frames held by update();
|
||||
the 1 Hz loop's frames are a second apart and must not."""
|
||||
from unittest.mock import MagicMock
|
||||
from src.display_controller import _ScreenHost
|
||||
controller = MagicMock()
|
||||
plan = ScreenPlan(Source.ROTATION, mode="ticker", frame_policy=policy)
|
||||
plugin = object()
|
||||
_ScreenHost(controller).draw(Screen(plan, plugin, True, 0.0))
|
||||
controller._display_once.assert_called_once_with(plugin, "ticker", True,
|
||||
report_hold=report_hold)
|
||||
|
||||
|
||||
def _rows(tmp_path, horizon, build):
|
||||
import os
|
||||
os.environ.setdefault("EMULATOR", "true")
|
||||
from test._run_loop_harness import RunLoopHarness
|
||||
h = RunLoopHarness(tmp_path, horizon=horizon)
|
||||
build(h)
|
||||
return h.run()["screens"]
|
||||
|
||||
|
||||
class TestThroughRun:
|
||||
"""Service points that only the full loop reaches, on the harness."""
|
||||
|
||||
def test_a_notice_pending_when_a_later_frame_is_empty_ends_the_screen(self, tmp_path):
|
||||
"""The after-loop look reads the notice when the loop ended early: a
|
||||
1 Hz screen whose second frame has nothing to show must not sit in
|
||||
its make-up dwell (which only notices a notice that arrives during
|
||||
it) while the notice expires."""
|
||||
from test._run_loop_harness import FakePlugin
|
||||
|
||||
def build(h):
|
||||
h.add_plugin(FakePlugin("flaky", ["flaky"], duration=12, first_frame_only=True))
|
||||
h.add_plugin(FakePlugin("clock", ["clock"], duration=12))
|
||||
h.wifi_message(0.5, "Connected to HomeNet", duration=5)
|
||||
rows = _rows(tmp_path, 30, build)
|
||||
assert rows[0][1] == "flaky" and rows[0][2] == 1.0
|
||||
assert rows[1][1] == "<wifi>"
|
||||
# ... and the mode it cut short comes back, not the next one.
|
||||
after = next(row for row in rows[1:] if row[1] != "<wifi>")
|
||||
assert after[1] == "flaky"
|
||||
|
||||
def test_vegas_yielding_to_a_follower_shows_a_rotation_screen_first(self, tmp_path):
|
||||
"""Pins today's behaviour (docs/RUN_LOOP_REDESIGN.md, "may be
|
||||
wrong"): the interrupt check stops the ticker for a follower, but
|
||||
the yield path never looks at one, so a rotation screen runs its
|
||||
full duration before the next pass hands the panel to the leader."""
|
||||
from test._run_loop_harness import FakePlugin
|
||||
|
||||
def build(h):
|
||||
h.add_plugin(FakePlugin("clock", ["clock"], duration=20))
|
||||
h.add_plugin(FakePlugin("weather", ["weather"], duration=20))
|
||||
h.enable_vegas(cycle=30)
|
||||
h.sync.follower_windows = [(10, 60)]
|
||||
rows = _rows(tmp_path, 50, build)
|
||||
assert rows[0][1] == "<vegas>" and rows[0][3] == "vegas-interrupt"
|
||||
assert 10.0 <= rows[0][0] + rows[0][2] <= 10.2
|
||||
assert rows[1][1] == "clock" and rows[1][2] == 20.0
|
||||
assert rows[2][1] == "<follower>"
|
||||
@@ -21,7 +21,6 @@ from src.common.scroll_config import ( # noqa: E402
|
||||
refresh_hz_from_config,
|
||||
resolve,
|
||||
)
|
||||
from src.common import scroll_config # noqa: E402
|
||||
|
||||
|
||||
class FakeHelper:
|
||||
@@ -505,46 +504,3 @@ class TestSpeedAdvice:
|
||||
got = solve_crisp(50, 125.74)
|
||||
assert got.steppiness == "smooth"
|
||||
assert got.pixels_per_frame == 1
|
||||
|
||||
|
||||
class TestRefreshShortfall:
|
||||
"""A panel that cannot reach its cap runs every scroll slow."""
|
||||
|
||||
def test_the_ledmatrix_rig_is_told_to_cap_at_100(self):
|
||||
# Pi 4, 2x128x64 on adafruit-hat-pwm under a 120 Hz cap: measured
|
||||
# 107.6-113.1 Hz, and frame_timing reports the fast end.
|
||||
s = scroll_config.refresh_shortfall(113.1, 120)
|
||||
assert s == {"measured_hz": 113.1, "planned_hz": 120.0,
|
||||
"suggested_cap_hz": 100, "slow_percent": 6}
|
||||
|
||||
def test_a_panel_that_holds_its_cap_is_fine(self):
|
||||
assert scroll_config.refresh_shortfall(99.95, 100) is None
|
||||
assert scroll_config.refresh_shortfall(97.5, 100) is None
|
||||
|
||||
def test_a_panel_that_beats_its_cap_is_fine(self):
|
||||
assert scroll_config.refresh_shortfall(125.7, 120) is None
|
||||
|
||||
def test_nothing_measured_says_nothing(self):
|
||||
assert scroll_config.refresh_shortfall(None, 120) is None
|
||||
assert scroll_config.refresh_shortfall(0, 120) is None
|
||||
assert scroll_config.refresh_shortfall("fast", 120) is None
|
||||
|
||||
def test_the_suggestion_leaves_headroom_under_the_measurement(self):
|
||||
assert scroll_config.holdable_cap(113.1) == 100
|
||||
assert scroll_config.holdable_cap(95.0) == 90
|
||||
# 5% under 105 is 99.75: 100 would sit inside the panel's drift.
|
||||
assert scroll_config.holdable_cap(105.0) == 90
|
||||
assert scroll_config.holdable_cap(9.0) is None
|
||||
assert scroll_config.holdable_cap(None) is None
|
||||
|
||||
def test_the_log_line_names_the_cap_to_use(self):
|
||||
text = scroll_config.describe_refresh_shortfall(
|
||||
scroll_config.refresh_shortfall(113.1, 120))
|
||||
assert "about 113 Hz" in text and "120 Hz" in text
|
||||
assert "6% slower" in text
|
||||
assert "Set Limit Refresh Rate to 100 Hz" in text
|
||||
|
||||
def test_no_suggestion_for_a_panel_too_slow_for_any_cap(self):
|
||||
text = scroll_config.describe_refresh_shortfall(
|
||||
scroll_config.refresh_shortfall(9.0, 100))
|
||||
assert "Set Limit Refresh Rate" not in text
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
"""src.common.sports_game_over: behaviour, host contract and base order.
|
||||
|
||||
The matrix is ledmatrix-plugins' ``scripts/test_game_over_check.py`` (the
|
||||
table the family 5 reconcile was checked against) folded to the three
|
||||
``FINAL_PERIOD`` values the nine scoreboards declare: None (afl, baseball,
|
||||
nrl, soccer, ufc), 4 (basketball, football, lacrosse) and 3 (hockey).
|
||||
Baseball's postponed/suspended override stays in its plugin and is not here.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.common import sports_game_over
|
||||
from src.common.sports_game_over import SportsGameOverMixin
|
||||
from src.common.sports_shared import SportsLiveSharedMixin
|
||||
|
||||
LOG = logging.getLogger("test_sports_game_over")
|
||||
|
||||
|
||||
def host(final_period):
|
||||
"""A live manager stand-in declaring ``FINAL_PERIOD`` as a plugin does."""
|
||||
cls = type("Live", (SportsGameOverMixin,), {"FINAL_PERIOD": final_period})
|
||||
h = cls()
|
||||
h.logger = LOG
|
||||
return h
|
||||
|
||||
|
||||
MISSING = object() # the key is absent from the game dict
|
||||
|
||||
|
||||
def game(period_text="", period=MISSING, clock=MISSING, away="1", home="2"):
|
||||
g = {"away_abbr": "AWY", "home_abbr": "HOM", "away_score": away,
|
||||
"home_score": home, "period_text": period_text}
|
||||
if period is not MISSING:
|
||||
g["period"] = period
|
||||
if clock is not MISSING:
|
||||
g["clock"] = clock
|
||||
return g
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The matrix: clock x period, for each FINAL_PERIOD. Scores 1-2.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
FINAL_PERIODS = (None, 4, 3)
|
||||
PERIODS = (MISSING, 1, 2, 3, 4, 5, 6)
|
||||
CLOCKS = {"12:00": "12:00", "0:00": "0:00", ":00": ":00", "0.0": "0.0",
|
||||
"-": "-", "''": "", "None": None, "missing": MISSING}
|
||||
|
||||
#: The period text each ESPN status carries. Only "Final" contains "final";
|
||||
#: the method reads no status, so every other text answers the same row.
|
||||
LIVE_TEXTS = {
|
||||
"in progress": lambda p: "" if p is MISSING else f"P{p}",
|
||||
"end of period": lambda p: "" if p is MISSING else f"End P{p}",
|
||||
"halftime": lambda p: "Halftime",
|
||||
"end of round": lambda p: "" if p is MISSING else f"End R{p}",
|
||||
"postponed": lambda p: "Postponed",
|
||||
}
|
||||
|
||||
#: clock -> one cell per period (missing, 1..6) for FINAL_PERIOD None, 4, 3.
|
||||
EXPECTED_LIVE = {
|
||||
"12:00": "....... ....... .......",
|
||||
"0:00": "....... ....YYY ...YYYY",
|
||||
":00": "....... ....YYY ...YYYY",
|
||||
"0.0": "....... ....... .......",
|
||||
"-": "....... ....... .......",
|
||||
"''": "....... ....... .......",
|
||||
"None": "....... ....... .......",
|
||||
"missing": "....... ....... .......",
|
||||
}
|
||||
|
||||
|
||||
def row(text_for, clock):
|
||||
return " ".join(
|
||||
"".join("Y" if host(fp)._is_game_really_over(game(text_for(p), p, clock)) else "."
|
||||
for p in PERIODS)
|
||||
for fp in FINAL_PERIODS)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", sorted(LIVE_TEXTS))
|
||||
@pytest.mark.parametrize("clock_label", sorted(CLOCKS))
|
||||
def test_a_live_period_text(status, clock_label):
|
||||
assert row(LIVE_TEXTS[status], CLOCKS[clock_label]) == EXPECTED_LIVE[clock_label]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("clock_label", sorted(CLOCKS))
|
||||
def test_a_final_period_text_is_always_over(clock_label):
|
||||
assert row(lambda p: "Final", CLOCKS[clock_label]) == "YYYYYYY YYYYYYY YYYYYYY"
|
||||
|
||||
|
||||
#: label -> (game, one cell per FINAL_PERIOD None, 4, 3)
|
||||
EDGES = {
|
||||
"period_text None, P4 0:00": (game(None, 4, "0:00"), ".YY"),
|
||||
"period None, 0:00": (game("", None, "0:00"), "..."),
|
||||
"period 'OT', 0:00": (game("OT", "OT", "0:00"), "..."),
|
||||
"period '4' (str), 0:00": (game("P4", "4", "0:00"), ".YY"),
|
||||
"clock int 0, P4": (game("P4", 4, 0), "..."),
|
||||
"clock float 0.0, P4": (game("P4", 4, 0.0), "..."),
|
||||
"clock ' 0:00 ', P4": (game("P4", 4, " 0:00 "), ".YY"),
|
||||
"clock '00:00', P4": (game("P4", 4, "00:00"), "..."),
|
||||
"period_text 'Final/OT', P5 0:00": (game("Final/OT", 5, "0:00"), "YYY"),
|
||||
"period_text 'FINAL', P1 12:00": (game("FINAL", 1, "12:00"), "YYY"),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("label", sorted(EDGES))
|
||||
def test_edge_shapes(label):
|
||||
g, want = EDGES[label]
|
||||
got = "".join("Y" if host(fp)._is_game_really_over(dict(g)) else "." for fp in FINAL_PERIODS)
|
||||
assert got == want
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The tie guard: level at 0:00 is overtime, not the end.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTieGuard:
|
||||
@pytest.mark.parametrize("fp,period", [(4, 4), (4, 5), (3, 3), (3, 4), (3, 5)])
|
||||
def test_level_at_zero_is_not_over(self, fp, period):
|
||||
assert host(fp)._is_game_really_over(game("", period, "0:00", "2", "2")) is False
|
||||
|
||||
def test_level_scores_compare_as_numbers(self):
|
||||
assert host(4)._is_game_really_over(game("", 4, "0:00", 2, "2")) is False
|
||||
assert host(4)._is_game_really_over(game("", 4, "0:00", " 2 ", "2")) is False
|
||||
|
||||
def test_a_game_that_ends_level_ends_on_final(self):
|
||||
assert host(4)._is_game_really_over(game("Final/OT", 5, "0:00", "2", "2")) is True
|
||||
|
||||
def test_level_before_the_final_period_was_never_over(self):
|
||||
assert host(4)._is_game_really_over(game("", 3, "0:00", "2", "2")) is False
|
||||
|
||||
@pytest.mark.parametrize("away,home", [
|
||||
(MISSING, MISSING), (None, None), ("", ""), ("2", None),
|
||||
("2.0", "2.0"), ({"value": 2}, {"value": 2}), ("inf", "inf"),
|
||||
])
|
||||
def test_an_unreadable_score_leaves_it_to_the_clock(self, away, home):
|
||||
g = game("", 4, "0:00")
|
||||
for key, value in (("away_score", away), ("home_score", home)):
|
||||
if value is MISSING:
|
||||
del g[key]
|
||||
else:
|
||||
g[key] = value
|
||||
assert host(4)._is_game_really_over(g) is True
|
||||
|
||||
def test_float_infinity_does_not_raise(self):
|
||||
assert host(4)._is_game_really_over(
|
||||
game("", 4, "0:00", float("inf"), float("inf"))) is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ufc: ESPN MMA payloads, as ufc's _extract_game_details stores them
|
||||
# (ledmatrix-plugins plugins/ufc-scoreboard/test/fixtures/espn_mma_round_states.json).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
UFC_RECORDED = {
|
||||
"in_round_3_of_3": ("R3", 3, "1:21"),
|
||||
"break_after_round_1": ("R1", 1, "-"),
|
||||
"end_of_round_after_stoppage": ("R2", 2, "0:51"),
|
||||
"walkouts_five_rounder": ("", 0, "-"),
|
||||
"final_five_round_decision": ("R5", 5, "5:00"),
|
||||
"final_five_round_stoppage": ("R5", 5, "1:38"),
|
||||
"final_three_round_decision": ("R3", 3, "5:00"),
|
||||
"final_three_round_stoppage": ("R2", 2, "4:07"),
|
||||
"break_after_round_4_of_5": ("R4", 4, "-"),
|
||||
"end_of_round_5_awaiting_decision": ("R5", 5, "-"),
|
||||
}
|
||||
|
||||
|
||||
class TestUfc:
|
||||
@pytest.mark.parametrize("name", sorted(UFC_RECORDED))
|
||||
def test_no_recorded_state_is_over_here(self, name):
|
||||
"""A finished bout leaves the live list on is_final, before this is asked."""
|
||||
text, period, clock = UFC_RECORDED[name]
|
||||
assert host(None)._is_game_really_over(game(text, period, clock, "0", "0")) is False
|
||||
|
||||
@pytest.mark.parametrize("fp", FINAL_PERIODS)
|
||||
def test_a_round_break_dash_is_never_a_zero_clock(self, fp):
|
||||
assert host(fp)._is_game_really_over(game("R4", 4, "-", "1", "2")) is False
|
||||
|
||||
@pytest.mark.parametrize("clock", ["0:00", None, MISSING])
|
||||
def test_the_horn_does_not_end_a_bout(self, clock):
|
||||
assert host(None)._is_game_really_over(game("R5", 5, clock, "1", "2")) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Wiring: the default, overrides, and the live mixin's caller.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_the_default_is_no_clock_rule():
|
||||
assert SportsGameOverMixin.FINAL_PERIOD is None
|
||||
|
||||
|
||||
def test_an_override_defers_through_super():
|
||||
"""baseball's BaseballLive: its own check first, then the shared one."""
|
||||
|
||||
class Baseballish(SportsGameOverMixin):
|
||||
logger = LOG
|
||||
|
||||
def _is_game_really_over(self, game):
|
||||
if game.get("status") == "status_postponed":
|
||||
return True
|
||||
return super()._is_game_really_over(game)
|
||||
|
||||
b = Baseballish()
|
||||
assert b._is_game_really_over(dict(game("", 6, "0:00"), status="status_postponed")) is True
|
||||
assert b._is_game_really_over(game("", 6, "0:00")) is False
|
||||
assert b._is_game_really_over(game("Final", 9, None)) is True
|
||||
|
||||
|
||||
class _Live(SportsGameOverMixin, SportsLiveSharedMixin):
|
||||
"""A SportsLive stand-in in the documented base order."""
|
||||
|
||||
FINAL_PERIOD = 4
|
||||
|
||||
def __init__(self):
|
||||
self.logger = LOG
|
||||
self.stale_game_timeout = 600
|
||||
self.game_update_timestamps = {}
|
||||
|
||||
|
||||
class TestBaseOrder:
|
||||
def test_the_documented_order_resolves_this_method(self):
|
||||
assert _Live._is_game_really_over is SportsGameOverMixin._is_game_really_over
|
||||
mro = _Live.__mro__
|
||||
assert mro.index(SportsGameOverMixin) < mro.index(SportsLiveSharedMixin)
|
||||
|
||||
def test_neither_shared_mixin_defines_it(self):
|
||||
"""So the base order cannot change which body runs."""
|
||||
from src.common.sports_shared import SportsCoreSharedMixin
|
||||
for mixin in (SportsLiveSharedMixin, SportsCoreSharedMixin):
|
||||
assert "_is_game_really_over" not in vars(mixin)
|
||||
|
||||
def test_detect_stale_games_drops_an_over_game_through_it(self):
|
||||
live = _Live()
|
||||
live.game_update_timestamps = {"over": {"last_seen": time.time()},
|
||||
"on": {"last_seen": time.time()}}
|
||||
games = [dict(game("", 4, "0:00"), id="over"),
|
||||
dict(game("", 4, "0:00", "2", "2"), id="on")]
|
||||
live._detect_stale_games(games)
|
||||
assert [g["id"] for g in games] == ["on"]
|
||||
assert "over" not in live.game_update_timestamps
|
||||
|
||||
def test_the_class_value_wins_over_the_default(self):
|
||||
assert _Live().FINAL_PERIOD == 4
|
||||
assert _Live()._is_game_really_over(game("", 4, "0:00")) is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Host contract
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _self_reads():
|
||||
tree = ast.parse(Path(sports_game_over.__file__).read_text(encoding="utf-8"))
|
||||
cls = next(n for n in tree.body
|
||||
if isinstance(n, ast.ClassDef) and n.name == "SportsGameOverMixin")
|
||||
return {node.attr for node in ast.walk(cls)
|
||||
if isinstance(node, ast.Attribute) and isinstance(node.ctx, ast.Load)
|
||||
and isinstance(node.value, ast.Name) and node.value.id == "self"}
|
||||
|
||||
|
||||
class TestHostContract:
|
||||
def test_every_host_read_is_documented(self):
|
||||
undocumented = sorted(n for n in _self_reads()
|
||||
if f"``{n}``" not in sports_game_over.__doc__)
|
||||
assert undocumented == [], f"read but not in the host contract: {undocumented}"
|
||||
|
||||
def test_the_mixin_creates_no_state(self):
|
||||
assert "__init__" not in vars(SportsGameOverMixin)
|
||||
assert not hasattr(SportsGameOverMixin, "logger")
|
||||
assert sorted(n for n in vars(SportsGameOverMixin) if not n.startswith("__")) == [
|
||||
"FINAL_PERIOD", "_is_game_really_over"]
|
||||
@@ -0,0 +1,123 @@
|
||||
"""sports_game_over still matches every plugin copy, and each plugin's FINAL_PERIOD.
|
||||
|
||||
``SportsGameOverMixin._is_game_really_over`` was copied from the scoreboards'
|
||||
``SportsLive._is_game_really_over`` once family 5 had made the nine copies one
|
||||
body. The plugins delete their copies once they floor on the release that
|
||||
ships this module. Until each has, a copy that changes on its own is a fix one
|
||||
side has and the other lacks.
|
||||
|
||||
Point LEDMATRIX_PLUGINS at a ledmatrix-plugins checkout and the method is
|
||||
compared with every plugin copy using ``scripts/sports_drift_report.py``'s own
|
||||
normalisation (the AST with docstrings and annotations dropped), plus the
|
||||
decorators. A copy that is gone counts as adopted when the plugin's
|
||||
``sports.py`` names the module. Each plugin's ``SportsLive.FINAL_PERIOD`` is
|
||||
compared with the value the owner decided for its sport, which stays in the
|
||||
plugin after adoption. Without the variable this skips: core CI has no plugins
|
||||
checkout.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import importlib.util
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.common import sports_game_over
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
|
||||
#: The owner's decision (docs/SPORTS_UNIFICATION.md, family 5): the period
|
||||
#: from which a 0:00 clock ends a game, None where the clock never does.
|
||||
FINAL_PERIOD = {
|
||||
"afl": None, "baseball": None, "basketball": 4, "football": 4,
|
||||
"hockey": 3, "lacrosse": 4, "nrl": None, "soccer": None, "ufc": None,
|
||||
}
|
||||
NAME = "_is_game_really_over"
|
||||
|
||||
|
||||
def _drift_report():
|
||||
"""scripts/sports_drift_report.py, loaded by path (scripts/ is no package)."""
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"sports_drift_report", REPO / "scripts" / "sports_drift_report.py")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
DRIFT = _drift_report()
|
||||
|
||||
|
||||
def _plugins_root():
|
||||
root = DRIFT.resolve_plugins_dir(os.environ.get("LEDMATRIX_PLUGINS"))
|
||||
if root is None:
|
||||
pytest.skip("set LEDMATRIX_PLUGINS to a ledmatrix-plugins checkout to "
|
||||
"compare this module against the plugin copies")
|
||||
return root
|
||||
|
||||
|
||||
def _class(tree, name):
|
||||
return next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == name)
|
||||
|
||||
|
||||
def _method(cls):
|
||||
return next((n for n in cls.body
|
||||
if isinstance(n, ast.FunctionDef) and n.name == NAME), None)
|
||||
|
||||
|
||||
def _fingerprint(node):
|
||||
return (DRIFT._digest(node, DRIFT._Canonical()),
|
||||
tuple(ast.unparse(d) for d in node.decorator_list))
|
||||
|
||||
|
||||
def _final_period(cls):
|
||||
for node in cls.body:
|
||||
if (isinstance(node, (ast.Assign, ast.AnnAssign)) and node.value is not None):
|
||||
target = node.targets[0] if isinstance(node, ast.Assign) else node.target
|
||||
if isinstance(target, ast.Name) and target.id == "FINAL_PERIOD":
|
||||
return ast.literal_eval(node.value)
|
||||
raise AssertionError("SportsLive declares no FINAL_PERIOD")
|
||||
|
||||
|
||||
def _ours():
|
||||
tree = ast.parse(Path(sports_game_over.__file__).read_text(encoding="utf-8"))
|
||||
return _class(tree, "SportsGameOverMixin")
|
||||
|
||||
|
||||
def test_the_mixin_holds_one_method_and_the_default():
|
||||
names = sorted(n.name if isinstance(n, ast.FunctionDef) else n.target.id
|
||||
for n in _ours().body if isinstance(n, (ast.FunctionDef, ast.AnnAssign))
|
||||
and (isinstance(n, ast.FunctionDef) or n.value is not None))
|
||||
assert names == ["FINAL_PERIOD", NAME]
|
||||
assert _final_period(_ours()) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sport", sorted(FINAL_PERIOD))
|
||||
def test_every_remaining_plugin_copy_matches(sport):
|
||||
root = _plugins_root()
|
||||
source = (root / f"{sport}-scoreboard" / "sports.py").read_text(encoding="utf-8")
|
||||
live = _class(ast.parse(source), "SportsLive")
|
||||
copy = _method(live)
|
||||
if copy is None:
|
||||
assert sports_game_over.__name__ in source, (
|
||||
f"{sport}: no {NAME} on SportsLive and no {sports_game_over.__name__} import")
|
||||
else:
|
||||
assert _fingerprint(copy) == _fingerprint(_method(_ours())), (
|
||||
f"{NAME} in {sport} differs from sports_game_over. "
|
||||
f"Port the change to both, or stop treating it as shared.")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sport", sorted(FINAL_PERIOD))
|
||||
def test_every_plugin_declares_its_final_period(sport):
|
||||
root = _plugins_root()
|
||||
source = (root / f"{sport}-scoreboard" / "sports.py").read_text(encoding="utf-8")
|
||||
assert _final_period(_class(ast.parse(source), "SportsLive")) == FINAL_PERIOD[sport]
|
||||
|
||||
|
||||
def test_the_drift_report_still_calls_it_identical():
|
||||
root = _plugins_root()
|
||||
families = DRIFT.build(root, ("sports.py",))
|
||||
rows = {(r["file"], r["family"]): r
|
||||
for r in (DRIFT.summarise(k, v) for k, v in families.items())}
|
||||
row = rows.get(("sports.py", NAME))
|
||||
assert row is None or row["worst_class_variants"] == 1
|
||||
@@ -465,6 +465,136 @@ class TestLiveMixin:
|
||||
# The safety property that makes it correct: 30s beats 600s.
|
||||
assert h._idle_live_interval() == h.update_interval
|
||||
|
||||
# ---- every pending kickoff is honoured, not just the first ------------
|
||||
#
|
||||
# One stored kickoff held the slot through its grace and refused every
|
||||
# later one; a later one that had passed by the time the grace ended was
|
||||
# refused again as "already past". So of two favourites kicking off ten
|
||||
# minutes apart, the second lost its grace: if ESPN had not flipped it live
|
||||
# by the end of the FIRST game's grace, the back-off returned to its
|
||||
# ceiling and the game was noticed up to that late. That bites whenever the
|
||||
# first game is not live by then -- a rain delay, a postponement, ESPN slow
|
||||
# to flip it -- since a live first game keeps the live cadence anyway.
|
||||
|
||||
@staticmethod
|
||||
def _replay(monkeypatch, kickoffs, flips, until, poll=30, ceiling=900):
|
||||
"""Drive a live manager's idle loop over a schedule on a fake clock.
|
||||
|
||||
``kickoffs`` are start offsets in seconds from t=0 (the first look),
|
||||
``flips`` how long after its start ESPN reports each game live (None:
|
||||
postponed, never live). Every
|
||||
look offers each not-yet-live game, as the live loop does, then sleeps
|
||||
for whatever the back-off returns. Returns, per game, how long after it
|
||||
went live it was first seen live -- None if never.
|
||||
"""
|
||||
clock = [1_800_000_000.0]
|
||||
monkeypatch.setattr(sports_shared.time, "time", lambda: clock[0])
|
||||
h = _LiveHost(no_data_interval=300)
|
||||
h.live_idle_max_interval = ceiling
|
||||
h.update_interval = poll
|
||||
h._empty_live_streak = 30 # idle all morning: at the ceiling
|
||||
t0 = clock[0]
|
||||
seen = [None] * len(kickoffs)
|
||||
while clock[0] - t0 < until:
|
||||
now = clock[0] - t0
|
||||
live = [f is not None and k + f <= now for k, f in zip(kickoffs, flips)]
|
||||
for i, is_live in enumerate(live):
|
||||
if is_live and seen[i] is None:
|
||||
seen[i] = now - (kickoffs[i] + flips[i])
|
||||
start = datetime.fromtimestamp(t0 + kickoffs[i], tz=timezone.utc)
|
||||
h._note_scheduled_start_candidate(
|
||||
{"is_live": is_live, "is_halftime": False,
|
||||
"start_time_utc": start})
|
||||
# A real board keeps polling at the live cadence while anything is
|
||||
# live; a game here stays live for an hour after it flips.
|
||||
on = any(f is not None and k + f <= now < k + f + 3600
|
||||
for k, f in zip(kickoffs, flips))
|
||||
h._note_live_fetch(on)
|
||||
clock[0] += poll if on else h._idle_live_interval()
|
||||
assert len(getattr(h, "_later_scheduled_starts", None) or ()) <= sports_shared._KICKOFF_QUEUE_MAX
|
||||
return seen
|
||||
|
||||
@pytest.mark.parametrize("kickoffs,flips", [
|
||||
# (start offsets, ESPN's flip delay per game), both in seconds.
|
||||
pytest.param([1800], [120], id="one kickoff, flipped 2 min late"),
|
||||
pytest.param([1800, 2400], [0, 840],
|
||||
id="first live on time, the second flipped 14 min late"),
|
||||
pytest.param([1800, 2400], [None, 840],
|
||||
id="first postponed, the second 10 min later flipped 14 min late"),
|
||||
pytest.param([1800, 2400], [1200, 840],
|
||||
id="first in a 20 min delay, the second flipped 14 min late"),
|
||||
pytest.param([1800, 2100, 2520], [None, None, 600],
|
||||
id="three inside one grace, the last flipped 10 min late"),
|
||||
pytest.param([1800, 1800, 2400], [None, None, 700],
|
||||
id="two at the same time, then one 10 min later"),
|
||||
pytest.param([1800, 2700], [None, 840],
|
||||
id="second kickoff 15 min later, flipped 14 min late"),
|
||||
pytest.param([1800 + 60 * i for i in range(20)],
|
||||
[None] * 19 + [840],
|
||||
id="twenty kickoffs a minute apart overflow the queue"),
|
||||
])
|
||||
def test_every_pending_kickoff_gets_its_grace(self, monkeypatch, kickoffs, flips):
|
||||
seen = self._replay(monkeypatch, kickoffs, flips,
|
||||
until=max(kickoffs) + 3 * 3600)
|
||||
late = [s for s, f in zip(seen, flips)
|
||||
if f is not None and (s is None or s > 30)]
|
||||
assert not late, "games noticed late (s after going live): %r" % (seen,)
|
||||
|
||||
def test_a_kickoff_that_never_flips_costs_one_grace_then_backs_off(self, monkeypatch):
|
||||
# A postponed game keeps the live cadence for its grace and no longer:
|
||||
# remembering more kickoffs must not pin the poll to dead ones.
|
||||
clock = [1_800_000_000.0]
|
||||
monkeypatch.setattr(sports_shared.time, "time", lambda: clock[0])
|
||||
h = self._idle_host()
|
||||
t0 = clock[0]
|
||||
for offset in (600, 900):
|
||||
h._note_scheduled_start_candidate(
|
||||
{"start_time_utc": datetime.fromtimestamp(t0 + offset, tz=timezone.utc)})
|
||||
clock[0] = t0 + 900 + sports_shared._KICKOFF_GRACE_SECONDS - 1
|
||||
assert h._idle_live_interval() == h.update_interval
|
||||
clock[0] = t0 + 900 + sports_shared._KICKOFF_GRACE_SECONDS + 1
|
||||
assert h._idle_live_interval() == 900
|
||||
assert not getattr(h, "_later_scheduled_starts", None)
|
||||
|
||||
def test_a_queued_kickoff_past_its_own_grace_is_skipped(self, monkeypatch):
|
||||
# After a long sleep (or a run of looks that never woke the manager)
|
||||
# several queued kickoffs may have gone stale at once. The one still
|
||||
# inside its grace must win, not the first stale one in the queue.
|
||||
clock = [1_800_000_000.0]
|
||||
monkeypatch.setattr(sports_shared.time, "time", lambda: clock[0])
|
||||
h = self._idle_host()
|
||||
t0 = clock[0]
|
||||
for offset in (600, 700, 1500):
|
||||
h._note_scheduled_start_candidate(
|
||||
{"start_time_utc": datetime.fromtimestamp(t0 + offset, tz=timezone.utc)})
|
||||
clock[0] = t0 + 1500 + 150 # 600 and 700 are past their grace
|
||||
assert h._idle_live_interval() == h.update_interval
|
||||
assert h._next_scheduled_start_ts == t0 + 1500
|
||||
|
||||
def test_the_queue_keeps_the_earliest_kickoffs(self, monkeypatch):
|
||||
clock = [1_800_000_000.0]
|
||||
monkeypatch.setattr(sports_shared.time, "time", lambda: clock[0])
|
||||
h = self._idle_host()
|
||||
t0 = clock[0]
|
||||
cap = sports_shared._KICKOFF_QUEUE_MAX
|
||||
for offset in reversed(range(1, cap + 6)): # latest first
|
||||
h._note_scheduled_start_candidate(
|
||||
{"start_time_utc": datetime.fromtimestamp(t0 + 600 * offset, tz=timezone.utc)})
|
||||
assert h._next_scheduled_start_ts == t0 + 600
|
||||
assert h._later_scheduled_starts == [t0 + 600 * i for i in range(2, cap + 2)]
|
||||
|
||||
def test_a_kickoff_offered_twice_is_kept_once(self, monkeypatch):
|
||||
clock = [1_800_000_000.0]
|
||||
monkeypatch.setattr(sports_shared.time, "time", lambda: clock[0])
|
||||
h = self._idle_host()
|
||||
t0 = clock[0]
|
||||
for _ in range(3):
|
||||
for offset in (600, 1200):
|
||||
h._note_scheduled_start_candidate(
|
||||
{"start_time_utc": datetime.fromtimestamp(t0 + offset, tz=timezone.utc)})
|
||||
assert h._next_scheduled_start_ts == t0 + 600
|
||||
assert h._later_scheduled_starts == [t0 + 1200]
|
||||
|
||||
def test_finding_a_live_game_resets_the_streak(self):
|
||||
h = _LiveHost()
|
||||
h._note_live_fetch(False)
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
"""A plugin update must keep the files the plugin wrote beside itself.
|
||||
|
||||
Field incident, 2026-10-04: updating calendar 1.2.9 -> 1.2.12 from the web UI
|
||||
replaced plugin-repos/calendar/ with the fresh download and deleted the old
|
||||
copy -- and with it token.pickle and credentials.json, the plugin's Google
|
||||
OAuth files. No release contains them (the repo gitignores them), so the hot
|
||||
reload logged "Credentials file not found" and the calendar stayed broken
|
||||
until the files were restored by hand.
|
||||
|
||||
Both update routes are covered: a monorepo plugin (registry ``plugin_path``),
|
||||
which is reinstalled into a fresh directory, and a plugin installed from its
|
||||
own git repository, which is updated with ``git pull`` after an auto-stash.
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from src.plugin_system.plugin_local_files import (
|
||||
is_known_state_file, local_files_to_keep,
|
||||
)
|
||||
from src.plugin_system.store_manager import PluginStoreManager
|
||||
|
||||
PLUGIN_ID = "calendar"
|
||||
|
||||
|
||||
def _manifest(version):
|
||||
return {"id": PLUGIN_ID, "name": "Calendar", "class_name": "CalendarPlugin",
|
||||
"display_modes": ["calendar"], "version": version}
|
||||
|
||||
|
||||
def _write_release(target, version):
|
||||
"""What a download of ``version`` puts on disk."""
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
(target / "manifest.json").write_text(json.dumps(_manifest(version)))
|
||||
(target / "manager.py").write_text(f"VERSION = {version!r}\n")
|
||||
(target / ".gitignore").write_text("credentials.json\ntoken.pickle\ncache/\n")
|
||||
|
||||
|
||||
def _drop_local_files(plugin_dir):
|
||||
"""What the plugin writes at runtime: OAuth files plus cached state."""
|
||||
(plugin_dir / "token.pickle").write_bytes(b"\x80\x04oauth-token")
|
||||
(plugin_dir / "credentials.json").write_text('{"installed": {}}')
|
||||
(plugin_dir / "cache").mkdir()
|
||||
(plugin_dir / "cache" / "events.json").write_text("[]")
|
||||
|
||||
|
||||
def _assert_local_files_kept(plugin_dir):
|
||||
assert (plugin_dir / "token.pickle").read_bytes() == b"\x80\x04oauth-token"
|
||||
assert (plugin_dir / "credentials.json").read_text() == '{"installed": {}}'
|
||||
assert (plugin_dir / "cache" / "events.json").read_text() == "[]"
|
||||
|
||||
|
||||
def _leftover_backups(plugins_dir):
|
||||
return [p.name for p in plugins_dir.iterdir() if "standalone-backup" in p.name]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_path, monkeypatch):
|
||||
mgr = PluginStoreManager(
|
||||
plugins_dir=str(tmp_path / "plugin-repos"),
|
||||
uninstalled_registry_path=str(tmp_path / "uninstalled.json"))
|
||||
mgr.plugins_dir.mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setattr(mgr, "_install_dependencies", lambda *a, **k: True)
|
||||
monkeypatch.setattr(mgr, "fetch_registry", lambda *a, **k: {"plugins": []})
|
||||
return mgr
|
||||
|
||||
|
||||
class TestMonorepoUpdate:
|
||||
@pytest.fixture
|
||||
def installed(self, store, monkeypatch):
|
||||
registry_entry = {
|
||||
"id": PLUGIN_ID, "repo": "https://github.com/ChuckBuilds/ledmatrix-plugins",
|
||||
"plugin_path": "plugins/calendar", "branch": "main",
|
||||
"latest_version": "1.2.9",
|
||||
}
|
||||
monkeypatch.setattr(store, "get_plugin_info", lambda *a, **k: registry_entry)
|
||||
release = {"version": "1.2.9"}
|
||||
|
||||
def fake_monorepo_download(download_url, plugin_subpath, target):
|
||||
assert plugin_subpath == "plugins/calendar"
|
||||
_write_release(target, release["version"])
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(store, "_install_from_monorepo", fake_monorepo_download)
|
||||
assert store.install_plugin(PLUGIN_ID) is True
|
||||
|
||||
def publish(version):
|
||||
registry_entry["latest_version"] = release["version"] = version
|
||||
return store, store.plugins_dir / PLUGIN_ID, publish
|
||||
|
||||
def test_update_keeps_token_and_gitignored_files(self, installed):
|
||||
store, plugin_dir, publish = installed
|
||||
_drop_local_files(plugin_dir)
|
||||
|
||||
publish("1.2.12")
|
||||
assert store.update_plugin(PLUGIN_ID) is True
|
||||
|
||||
assert json.loads((plugin_dir / "manifest.json").read_text())["version"] == "1.2.12"
|
||||
_assert_local_files_kept(plugin_dir)
|
||||
assert _leftover_backups(store.plugins_dir) == []
|
||||
|
||||
def test_token_is_kept_even_when_the_release_does_not_gitignore_it(self, installed):
|
||||
store, plugin_dir, publish = installed
|
||||
(plugin_dir / ".gitignore").unlink()
|
||||
(plugin_dir / "token.pickle").write_bytes(b"tok")
|
||||
(plugin_dir / "config_secrets.json").write_text("{}")
|
||||
|
||||
publish("1.2.12")
|
||||
assert store.update_plugin(PLUGIN_ID) is True
|
||||
|
||||
assert (plugin_dir / "token.pickle").read_bytes() == b"tok"
|
||||
assert (plugin_dir / "config_secrets.json").read_text() == "{}"
|
||||
|
||||
def test_release_content_wins_and_old_code_is_not_carried(self, installed):
|
||||
store, plugin_dir, publish = installed
|
||||
# A file the old copy had that the new release dropped, byte code, and
|
||||
# an old copy of a file the new release also ships.
|
||||
(plugin_dir / "removed_module.py").write_text("OLD = True\n")
|
||||
(plugin_dir / "__pycache__").mkdir()
|
||||
(plugin_dir / "__pycache__" / "manager.cpython-313.pyc").write_bytes(b"pyc")
|
||||
|
||||
publish("1.2.12")
|
||||
assert store.update_plugin(PLUGIN_ID) is True
|
||||
|
||||
assert not (plugin_dir / "removed_module.py").exists()
|
||||
assert not (plugin_dir / "__pycache__").exists()
|
||||
assert "1.2.12" in (plugin_dir / "manager.py").read_text()
|
||||
|
||||
def test_reinstall_over_an_existing_copy_keeps_them_too(self, installed):
|
||||
store, plugin_dir, publish = installed
|
||||
_drop_local_files(plugin_dir)
|
||||
|
||||
assert store.install_plugin(PLUGIN_ID) is True
|
||||
|
||||
_assert_local_files_kept(plugin_dir)
|
||||
assert _leftover_backups(store.plugins_dir) == []
|
||||
|
||||
|
||||
class TestInstallFromUrlReplace:
|
||||
def test_replacing_an_installed_copy_keeps_the_token(self, store, monkeypatch):
|
||||
plugin_dir = store.plugins_dir / PLUGIN_ID
|
||||
_write_release(plugin_dir, "1.0.0")
|
||||
_drop_local_files(plugin_dir)
|
||||
|
||||
def fake_clone(repo_url, target, branches):
|
||||
_write_release(target, "2.0.0")
|
||||
return "main"
|
||||
|
||||
monkeypatch.setattr(store, "_install_via_git", fake_clone)
|
||||
result = store.install_from_url(
|
||||
"https://github.com/example/ledmatrix-calendar", plugin_id=PLUGIN_ID)
|
||||
|
||||
assert result["success"] is True
|
||||
assert json.loads((plugin_dir / "manifest.json").read_text())["version"] == "2.0.0"
|
||||
_assert_local_files_kept(plugin_dir)
|
||||
|
||||
|
||||
def _git(*args, cwd):
|
||||
subprocess.run(["git", "-c", "user.email=t@example.com", "-c", "user.name=t",
|
||||
"-c", "core.autocrlf=false", *args],
|
||||
cwd=cwd, check=True, capture_output=True)
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("git") is None, reason="git not installed")
|
||||
class TestGitRepoUpdate:
|
||||
@pytest.fixture
|
||||
def cloned(self, store, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(store, "get_plugin_info", lambda *a, **k: None)
|
||||
upstream = tmp_path / "upstream"
|
||||
_write_release(upstream, "1.0.0")
|
||||
# This repo does NOT gitignore the token: an untracked, non-ignored
|
||||
# file is exactly what `git stash push -u` used to sweep away.
|
||||
(upstream / ".gitignore").write_text("cache/\n")
|
||||
_git("init", "-q", "-b", "main", cwd=upstream)
|
||||
_git("add", ".", cwd=upstream)
|
||||
_git("commit", "-qm", "1.0.0", cwd=upstream)
|
||||
|
||||
plugin_dir = store.plugins_dir / PLUGIN_ID
|
||||
_git("clone", "-q", str(upstream), str(plugin_dir), cwd=tmp_path)
|
||||
|
||||
def publish(version):
|
||||
(upstream / "manifest.json").write_text(json.dumps(_manifest(version)))
|
||||
_git("commit", "-qam", version, cwd=upstream)
|
||||
return store, plugin_dir, publish
|
||||
|
||||
def test_pull_update_keeps_untracked_token(self, cloned):
|
||||
store, plugin_dir, publish = cloned
|
||||
_drop_local_files(plugin_dir)
|
||||
# An unrelated untracked file, so the update really does stash.
|
||||
(plugin_dir / "notes.txt").write_text("scratch")
|
||||
|
||||
publish("1.1.0")
|
||||
assert store.update_plugin(PLUGIN_ID) is True
|
||||
|
||||
assert json.loads((plugin_dir / "manifest.json").read_text())["version"] == "1.1.0"
|
||||
_assert_local_files_kept(plugin_dir)
|
||||
|
||||
def test_token_alone_does_not_trigger_a_stash(self, cloned):
|
||||
store, plugin_dir, publish = cloned
|
||||
(plugin_dir / "token.pickle").write_bytes(b"tok")
|
||||
|
||||
publish("1.1.0")
|
||||
assert store.update_plugin(PLUGIN_ID) is True
|
||||
|
||||
assert (plugin_dir / "token.pickle").read_bytes() == b"tok"
|
||||
stashes = subprocess.run(["git", "-C", str(plugin_dir), "stash", "list"],
|
||||
capture_output=True, text=True, check=True)
|
||||
assert stashes.stdout.strip() == ""
|
||||
|
||||
|
||||
class TestWhatIsKept:
|
||||
@pytest.mark.parametrize("path,expected", [
|
||||
("token.pickle", True),
|
||||
("data/session.pickle", True),
|
||||
("credentials.json", True),
|
||||
("token.json", True),
|
||||
("config_secrets.json", True),
|
||||
(".pkce_code_verifier", True),
|
||||
("manager.py", False),
|
||||
("config.json", False),
|
||||
])
|
||||
def test_known_state_files(self, path, expected):
|
||||
assert is_known_state_file(path) is expected
|
||||
|
||||
def test_gitignore_rules(self, tmp_path):
|
||||
old, new = tmp_path / "old", tmp_path / "new"
|
||||
new.mkdir()
|
||||
for rel in ["a.log", "logs/x.txt", "sub/deep/b.log", "keep.log",
|
||||
"anchored.txt", "sub/anchored.txt", "assets/x/y_backup/z.png",
|
||||
"manager.py", "shipped.log"]:
|
||||
(old / rel).parent.mkdir(parents=True, exist_ok=True)
|
||||
(old / rel).write_text("x")
|
||||
(new / "shipped.log").write_text("new")
|
||||
(old / ".gitignore").write_text(
|
||||
"# comment\n*.log\n!keep.log\nlogs/\n/anchored.txt\n"
|
||||
"assets/**/*_backup/\n")
|
||||
|
||||
assert local_files_to_keep(old, new) == [
|
||||
"a.log", "anchored.txt", "assets/x/y_backup/z.png",
|
||||
"logs/x.txt", "sub/deep/b.log",
|
||||
]
|
||||
@@ -446,7 +446,7 @@ class TestInstalledPluginsApi:
|
||||
**(manifest_extra or {})}
|
||||
api.plugin_catalog.get_all_plugin_info = MagicMock(return_value=[info])
|
||||
api.plugin_catalog.get_plugin_directory = MagicMock(return_value=None)
|
||||
api.plugin_store_manager.get_registry_info = MagicMock(return_value=None)
|
||||
api.plugin_store_manager.get_cached_registry_info = MagicMock(return_value=None)
|
||||
api.config_manager.load_config = MagicMock(return_value={'demo': config})
|
||||
response = api_v3_client.get('/api/v3/plugins/installed')
|
||||
assert response.status_code == 200
|
||||
|
||||
+14
-11
@@ -528,14 +528,17 @@ class TestDisplayAPI:
|
||||
from web_interface.blueprints.api_v3 import api_v3
|
||||
|
||||
mock_cache_manager = api_v3.cache_manager = MagicMock()
|
||||
|
||||
response = client.post('/api/v3/display/on-demand/stop')
|
||||
|
||||
# May return 200 if successful or 500 on error
|
||||
assert response.status_code in [200, 500]
|
||||
# Verify stop request was set in cache if successful
|
||||
if response.status_code == 200:
|
||||
assert mock_cache_manager.set.called
|
||||
|
||||
with patch('web_interface.blueprints.api_v3.display.control_client.on_demand_stop',
|
||||
side_effect=lambda request_id, **kw: {'accepted': True}) as stop:
|
||||
response = client.post('/api/v3/display/on-demand/stop')
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()['data']['transport'] == 'socket'
|
||||
stop.assert_called_once()
|
||||
# The request goes over the control socket; nothing is written to
|
||||
# the cache (the file mailbox is gone).
|
||||
mock_cache_manager.set.assert_not_called()
|
||||
|
||||
|
||||
class TestPluginsAPI:
|
||||
@@ -551,7 +554,7 @@ class TestPluginsAPI:
|
||||
mock_plugin_catalog.get_all_plugin_info.return_value = [
|
||||
{'id': 'weather', 'name': 'Weather Plugin'}
|
||||
]
|
||||
api_v3.plugin_store_manager.get_registry_info.return_value = None
|
||||
api_v3.plugin_store_manager.get_cached_registry_info.return_value = None
|
||||
|
||||
response = client.get('/api/v3/plugins/installed')
|
||||
|
||||
@@ -571,7 +574,7 @@ class TestPluginsAPI:
|
||||
{'id': 'weather', 'name': 'Weather', 'version': '1.0.0'}
|
||||
]
|
||||
# Registry advertises a newer version than the installed one.
|
||||
api_v3.plugin_store_manager.get_registry_info.return_value = {
|
||||
api_v3.plugin_store_manager.get_cached_registry_info.return_value = {
|
||||
'verified': True, 'latest_version': '1.2.0'
|
||||
}
|
||||
|
||||
@@ -592,7 +595,7 @@ class TestPluginsAPI:
|
||||
mock_plugin_catalog.get_all_plugin_info.return_value = [
|
||||
{'id': 'weather', 'name': 'Weather', 'version': '1.2.0'}
|
||||
]
|
||||
api_v3.plugin_store_manager.get_registry_info.return_value = {
|
||||
api_v3.plugin_store_manager.get_cached_registry_info.return_value = {
|
||||
'verified': True, 'latest_version': '1.2.0'
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ class TestInstalledList:
|
||||
info = {"id": "demo", "name": "Demo", "version": "1.0.0",
|
||||
"description": "stale cached copy", "loaded": False}
|
||||
api.plugin_catalog.get_all_plugin_info = MagicMock(return_value=[info])
|
||||
api.plugin_store_manager.get_registry_info = MagicMock(return_value=None)
|
||||
api.plugin_store_manager.get_cached_registry_info = MagicMock(return_value=None)
|
||||
api.plugin_store_manager._get_local_git_info = MagicMock(return_value=None)
|
||||
api.config_manager.load_config = MagicMock(return_value={})
|
||||
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
"""GET /api/v3/config/refresh-rate: the cap, the measured rate, a cap to hold."""
|
||||
import json
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
from web_interface.blueprints.api_v3 import api_v3
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch, tmp_path):
|
||||
stats = tmp_path / "stats.json"
|
||||
monkeypatch.setattr("src.common.frame_timing.default_stats_path", lambda: str(stats))
|
||||
manager = MagicMock()
|
||||
manager.load_config.return_value = {
|
||||
"display": {"hardware": {"limit_refresh_rate_hz": 120}}}
|
||||
monkeypatch.setattr(api_v3, "config_manager", manager, raising=False)
|
||||
app = Flask(__name__)
|
||||
app.register_blueprint(api_v3, url_prefix="/api/v3")
|
||||
c = app.test_client()
|
||||
c.stats_path = stats
|
||||
return c
|
||||
|
||||
|
||||
def _get(client):
|
||||
body = client.get("/api/v3/config/refresh-rate").get_json()
|
||||
assert body["status"] == "success"
|
||||
return body["data"]
|
||||
|
||||
|
||||
def test_nothing_measured_yet(client):
|
||||
data = _get(client)
|
||||
assert data == {"planned_hz": 120.0, "measured_hz": None, "shortfall": None}
|
||||
|
||||
|
||||
def test_a_panel_short_of_its_cap_gets_a_cap_it_can_hold(client):
|
||||
client.stats_path.write_text(json.dumps(
|
||||
{"measured_refresh_hz": 110.4, "planned_refresh_hz": 120.0}))
|
||||
data = _get(client)
|
||||
assert data["measured_hz"] == 110.4
|
||||
assert data["shortfall"]["suggested_cap_hz"] == 100
|
||||
assert data["shortfall"]["slow_percent"] == 8
|
||||
|
||||
|
||||
def test_a_panel_at_its_cap_has_no_shortfall(client):
|
||||
client.stats_path.write_text(json.dumps(
|
||||
{"measured_refresh_hz": 121.3, "planned_refresh_hz": 120.0}))
|
||||
assert _get(client)["shortfall"] is None
|
||||
|
||||
|
||||
def test_a_file_written_under_another_cap_is_stale(client):
|
||||
# The cap was changed to 120 but the display still runs under 100 Hz.
|
||||
client.stats_path.write_text(json.dumps(
|
||||
{"measured_refresh_hz": 99.9, "planned_refresh_hz": 100.0}))
|
||||
data = _get(client)
|
||||
assert data["measured_hz"] is None
|
||||
assert data["shortfall"] is None
|
||||
|
||||
|
||||
def test_a_file_from_a_display_too_old_to_record_its_cap_still_counts(client):
|
||||
client.stats_path.write_text(json.dumps({"measured_refresh_hz": 110.4}))
|
||||
assert _get(client)["shortfall"]["suggested_cap_hz"] == 100
|
||||
@@ -81,7 +81,6 @@ REMOVED_CATCH_ALLS = [
|
||||
("GET", "/api/v3/config/main", None),
|
||||
("GET", "/api/v3/config/secrets", None),
|
||||
("GET", "/api/v3/display/modes", None),
|
||||
("POST", "/api/v3/display/on-demand/stop", {}),
|
||||
("GET", "/api/v3/cache/list", None),
|
||||
("GET", "/api/v3/plugins/installed", None),
|
||||
("GET", "/api/v3/plugins/health", None),
|
||||
|
||||
@@ -43,7 +43,8 @@ def _url(path):
|
||||
def test_the_module_directories_hold_modules():
|
||||
assert {p.name for p in MODULES} >= {"boot.js", "registry.js", "api.js", "facade.js", "cache.js",
|
||||
"durations.js", "operation-history.js", "raw-json.js",
|
||||
"backup-restore.js"}
|
||||
"backup-restore.js", "schedule.js", "general.js",
|
||||
"visibility.js", "display.js"}
|
||||
for directory in MODULE_DIRS:
|
||||
# node needs this to import them in the JS tests; browsers ignore it.
|
||||
assert '"type": "module"' in (directory / "package.json").read_text(encoding="utf-8")
|
||||
@@ -104,6 +105,9 @@ CONVERTED = {
|
||||
"operation-history": ("operation_history.html", "/partials/operation-history"),
|
||||
"raw-json": ("raw_json.html", "/partials/raw-json"),
|
||||
"backup-restore": ("backup_restore.html", "/partials/backup-restore"),
|
||||
"schedule": ("schedule.html", "/partials/schedule"),
|
||||
"general": ("general.html", "/partials/general"),
|
||||
"display": ("display.html", "/partials/display"),
|
||||
}
|
||||
|
||||
# Old window.* names that moved into a page module. Each stays as a
|
||||
@@ -114,6 +118,9 @@ ALIASES = {
|
||||
"saveMainConfig", "saveSecretsConfig"],
|
||||
"backup-restore": ["exportBackup", "loadBackupList", "validateRestoreFile",
|
||||
"clearRestore", "runRestore"],
|
||||
"schedule": ["handleScheduleResponse", "handleDimScheduleResponse"],
|
||||
"general": ["webLogin"],
|
||||
"display": ["updateSyncUI"],
|
||||
}
|
||||
|
||||
|
||||
@@ -155,12 +162,15 @@ def test_moved_globals_stay_as_aliases(name):
|
||||
assert f"'{global_name}'" in boot, f"boot.js does not alias {global_name}"
|
||||
assert re.search(rf"^export (?:function|const) {global_name}\b", module, re.M), (
|
||||
f"pages/{name}.js does not export {global_name}")
|
||||
# No template defines them any more.
|
||||
# No template defines them any more, or calls them (an inline handler
|
||||
# would reach the page only through the deprecated alias).
|
||||
for partial in PARTIALS.glob("*.html"):
|
||||
text = partial.read_text(encoding="utf-8")
|
||||
for global_name in ALIASES[name]:
|
||||
assert f"window.{global_name} =" not in text, partial.name
|
||||
assert f"function {global_name}(" not in text, partial.name
|
||||
assert not re.search(rf"\b{global_name}\b", text), (
|
||||
f"{partial.name} still names {global_name}")
|
||||
|
||||
|
||||
def test_every_registered_page_has_its_module_and_partial():
|
||||
@@ -180,3 +190,32 @@ def test_converted_partials_carry_no_inline_script():
|
||||
if "data-page=" in text:
|
||||
assert "<script" not in text.lower(), (
|
||||
f"{partial.name} is a page module now; its code belongs in js/pages/")
|
||||
|
||||
|
||||
def test_the_schedule_page_reads_its_config_back_intact():
|
||||
"""schedule.html hands both saved schedules to pages/schedule.js as JSON in
|
||||
single-quoted data attributes; a value with quotes or markup must neither
|
||||
end the attribute nor change on the way."""
|
||||
from html.parser import HTMLParser
|
||||
import json
|
||||
import web_interface.app as web_app
|
||||
from flask import render_template
|
||||
|
||||
hostile = {"mode": "per-day", "start_time": "07:00", "note": "it's a \"<b>\" & '</div>"}
|
||||
|
||||
class Root(HTMLParser):
|
||||
attrs = None
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if dict(attrs).get("data-page") == "schedule":
|
||||
self.attrs = dict(attrs)
|
||||
|
||||
with web_app.app.test_request_context():
|
||||
html = render_template("v3/partials/schedule.html", schedule_config=hostile,
|
||||
dim_schedule_config=None, normal_brightness=90)
|
||||
parser = Root()
|
||||
parser.feed(html)
|
||||
assert parser.attrs is not None
|
||||
assert json.loads(parser.attrs["data-schedule-config"]) == hostile
|
||||
# A missing dim schedule arrives as null; the page treats that as {}.
|
||||
assert json.loads(parser.attrs["data-dim-schedule-config"]) is None
|
||||
|
||||
@@ -26,13 +26,44 @@ import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
def client(monkeypatch):
|
||||
from web_interface import app as web_app
|
||||
from web_interface.app import app
|
||||
# The captive-portal before_request hook shells out to systemctl/nmcli
|
||||
# whenever its 30s cache is cold, so on a Linux host whether a request
|
||||
# here runs subprocess depended on how long ago the previous one was --
|
||||
# and several tests below stub subprocess. Pin it: no test in this file
|
||||
# is about AP mode.
|
||||
monkeypatch.setattr(web_app, 'is_ap_mode_active', lambda: False)
|
||||
# GET /plugins/installed looks up each plugin's registry entry, and on a
|
||||
# cold cache that fetches plugins.json from GitHub -- so without a
|
||||
# connection those tests sat in the HTTP retry loop. No test in this
|
||||
# file is about the registry.
|
||||
monkeypatch.setattr(web_app.plugin_store_manager, 'fetch_registry',
|
||||
lambda *args, **kwargs: {'plugins': []})
|
||||
app.config['TESTING'] = True
|
||||
with app.test_client() as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def starlark_apps_dir(tmp_path, monkeypatch):
|
||||
"""Point every Starlark storage path at tmp_path for every test.
|
||||
|
||||
The manifest, its directory and the lock file are three separate module
|
||||
constants. Fixtures that redirected the first two but not the lock left
|
||||
the lock pointing at the repo, and on Linux (where the lock is taken)
|
||||
each test created starlark-apps/manifest.json.lock in the checkout. The
|
||||
directory is not created here; tests that need it make it.
|
||||
"""
|
||||
from web_interface.blueprints import api_v3 as module
|
||||
apps_dir = tmp_path / "starlark-apps"
|
||||
monkeypatch.setattr(module, '_STARLARK_APPS_DIR', apps_dir)
|
||||
monkeypatch.setattr(module, '_STARLARK_MANIFEST_FILE', apps_dir / 'manifest.json')
|
||||
monkeypatch.setattr(module, '_STARLARK_MANIFEST_LOCK_FILE', apps_dir / 'manifest.json.lock')
|
||||
return apps_dir
|
||||
|
||||
|
||||
class TestRoutesAreRegistered:
|
||||
"""The failure was a missing route, so check the URL map directly.
|
||||
|
||||
@@ -391,13 +422,9 @@ class TestTheManifestSurvivesConcurrentWriters:
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def starlark_dir(self, tmp_path, monkeypatch):
|
||||
from web_interface.blueprints import api_v3 as module
|
||||
apps_dir = tmp_path / "starlark-apps"
|
||||
apps_dir.mkdir()
|
||||
monkeypatch.setattr(module, '_STARLARK_APPS_DIR', apps_dir)
|
||||
monkeypatch.setattr(module, '_STARLARK_MANIFEST_FILE', apps_dir / 'manifest.json')
|
||||
return apps_dir
|
||||
def starlark_dir(self, starlark_apps_dir):
|
||||
starlark_apps_dir.mkdir()
|
||||
return starlark_apps_dir
|
||||
|
||||
def test_each_writer_gets_its_own_temp_file(self, starlark_dir):
|
||||
from web_interface.blueprints import api_v3 as module
|
||||
@@ -553,13 +580,9 @@ class TestTheManifestStaysRelocatable:
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def starlark_dir(self, tmp_path, monkeypatch):
|
||||
from web_interface.blueprints import api_v3 as module
|
||||
apps_dir = tmp_path / "starlark-apps"
|
||||
apps_dir.mkdir()
|
||||
monkeypatch.setattr(module, '_STARLARK_APPS_DIR', apps_dir)
|
||||
monkeypatch.setattr(module, '_STARLARK_MANIFEST_FILE', apps_dir / 'manifest.json')
|
||||
return apps_dir
|
||||
def starlark_dir(self, starlark_apps_dir):
|
||||
starlark_apps_dir.mkdir()
|
||||
return starlark_apps_dir
|
||||
|
||||
def _install(self, tmp_path):
|
||||
from web_interface.blueprints import api_v3 as module
|
||||
@@ -599,13 +622,10 @@ class TestManifestLockPreventsLostUpdates:
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def starlark_dir(self, tmp_path, monkeypatch):
|
||||
def starlark_dir(self, starlark_apps_dir):
|
||||
from web_interface.blueprints import api_v3 as module
|
||||
apps_dir = tmp_path / "starlark-apps"
|
||||
apps_dir = starlark_apps_dir
|
||||
apps_dir.mkdir()
|
||||
monkeypatch.setattr(module, '_STARLARK_APPS_DIR', apps_dir)
|
||||
monkeypatch.setattr(module, '_STARLARK_MANIFEST_FILE', apps_dir / 'manifest.json')
|
||||
monkeypatch.setattr(module, '_STARLARK_MANIFEST_LOCK_FILE', apps_dir / 'manifest.json.lock')
|
||||
module._write_starlark_manifest({'apps': {}})
|
||||
return apps_dir
|
||||
|
||||
@@ -693,12 +713,10 @@ class TestConfigAndManifestStayInSync:
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def app_dir(self, tmp_path, monkeypatch):
|
||||
def app_dir(self, starlark_apps_dir):
|
||||
from web_interface.blueprints import api_v3 as module
|
||||
apps_dir = tmp_path / "starlark-apps"
|
||||
apps_dir = starlark_apps_dir
|
||||
apps_dir.mkdir()
|
||||
monkeypatch.setattr(module, '_STARLARK_APPS_DIR', apps_dir)
|
||||
monkeypatch.setattr(module, '_STARLARK_MANIFEST_FILE', apps_dir / 'manifest.json')
|
||||
one_app_dir = apps_dir / 'demo'
|
||||
one_app_dir.mkdir()
|
||||
module._write_starlark_manifest({'apps': {'demo': {'name': 'Demo', 'enabled': True}}})
|
||||
@@ -1042,13 +1060,18 @@ class TestTheStoreUsesTheTokenTheUserConfigured:
|
||||
/plugins/store/github-status reported `authenticated: true` with a
|
||||
rate_limit of 5000 while /starlark/repository/browse reported a limit of
|
||||
60 -- the store going blank was that 60 running out.
|
||||
|
||||
The managers are attributes web_interface/app.py hangs on the blueprint
|
||||
when it is imported, so they exist only once some earlier test has
|
||||
imported the app. Every patch here passes create=True: these tests must
|
||||
not depend on which test ran before them.
|
||||
"""
|
||||
|
||||
def test_the_store_managers_token_is_used(self):
|
||||
from web_interface.blueprints import api_v3 as mod
|
||||
|
||||
with patch.object(mod.api_v3, 'plugin_store_manager',
|
||||
MagicMock(github_token='ghp_configured')):
|
||||
MagicMock(github_token='ghp_configured'), create=True):
|
||||
assert mod._starlark_github_token() == 'ghp_configured'
|
||||
|
||||
def test_a_hand_edited_config_key_still_works(self):
|
||||
@@ -1057,8 +1080,8 @@ class TestTheStoreUsesTheTokenTheUserConfigured:
|
||||
cfg = MagicMock()
|
||||
cfg.load_config.return_value = {'github_token': 'ghp_by_hand'}
|
||||
with patch.object(mod.api_v3, 'plugin_store_manager',
|
||||
MagicMock(github_token=None)), \
|
||||
patch.object(mod.api_v3, 'config_manager', cfg):
|
||||
MagicMock(github_token=None), create=True), \
|
||||
patch.object(mod.api_v3, 'config_manager', cfg, create=True):
|
||||
assert mod._starlark_github_token() == 'ghp_by_hand'
|
||||
|
||||
def test_no_token_anywhere_is_not_an_error(self):
|
||||
@@ -1067,8 +1090,8 @@ class TestTheStoreUsesTheTokenTheUserConfigured:
|
||||
cfg = MagicMock()
|
||||
cfg.load_config.return_value = {}
|
||||
with patch.object(mod.api_v3, 'plugin_store_manager',
|
||||
MagicMock(github_token=None)), \
|
||||
patch.object(mod.api_v3, 'config_manager', cfg):
|
||||
MagicMock(github_token=None), create=True), \
|
||||
patch.object(mod.api_v3, 'config_manager', cfg, create=True):
|
||||
assert mod._starlark_github_token() is None
|
||||
|
||||
def test_an_unreadable_config_does_not_take_the_store_down(self):
|
||||
@@ -1077,8 +1100,8 @@ class TestTheStoreUsesTheTokenTheUserConfigured:
|
||||
cfg = MagicMock()
|
||||
cfg.load_config.side_effect = OSError("config.json is unreadable")
|
||||
with patch.object(mod.api_v3, 'plugin_store_manager',
|
||||
MagicMock(github_token=None)), \
|
||||
patch.object(mod.api_v3, 'config_manager', cfg):
|
||||
MagicMock(github_token=None), create=True), \
|
||||
patch.object(mod.api_v3, 'config_manager', cfg, create=True):
|
||||
assert mod._starlark_github_token() is None
|
||||
|
||||
def test_browse_hands_the_token_to_the_repository(self, client):
|
||||
@@ -1092,7 +1115,7 @@ class TestTheStoreUsesTheTokenTheUserConfigured:
|
||||
repo.return_value.get_rate_limit_info.return_value = {'remaining': 4999}
|
||||
|
||||
with patch.object(mod.api_v3, 'plugin_store_manager',
|
||||
MagicMock(github_token='ghp_configured')), \
|
||||
MagicMock(github_token='ghp_configured'), create=True), \
|
||||
patch('web_interface.blueprints.api_v3._get_tronbyte_repository_class',
|
||||
return_value=repo):
|
||||
client.get('/api/v3/starlark/repository/browse')
|
||||
@@ -1136,12 +1159,23 @@ class TestPixletEditorHostDefaultsButDoesNotOverride:
|
||||
captured['env'] = env
|
||||
return FakeProcess()
|
||||
|
||||
# Swap the route module's own ``subprocess`` binding, not the shared
|
||||
# ``subprocess.Popen``: patching the attribute on the real module is
|
||||
# process-wide, and the app's before_request hook (the captive-portal
|
||||
# check) runs ``subprocess.run`` -- ``with Popen(...)`` -- whenever its
|
||||
# 30s AP-mode cache is cold on a host with systemctl. On the Linux CI
|
||||
# runner that handed it this FakeProcess and 500'd the request, but
|
||||
# only when the previous request was more than 30s earlier.
|
||||
fake_subprocess = types.ModuleType('subprocess')
|
||||
fake_subprocess.__dict__.update(mod.subprocess.__dict__)
|
||||
fake_subprocess.Popen = fake_popen
|
||||
|
||||
with patch.object(mod, '_validate_starlark_app_path',
|
||||
return_value=(app_dir, None)), \
|
||||
patch.object(mod, '_PIXLET_EDITOR_SCRIPT', script), \
|
||||
patch.object(mod, '_PIXLET_EDITOR_STATE', state_file), \
|
||||
patch.object(mod, '_find_pixlet_binary', return_value='/usr/bin/pixlet'), \
|
||||
patch.object(mod.subprocess, 'Popen', side_effect=fake_popen), \
|
||||
patch.object(mod, 'subprocess', fake_subprocess), \
|
||||
patch.dict(os.environ):
|
||||
if operator_host is None:
|
||||
os.environ.pop('PIXLET_EDITOR_HOST', None)
|
||||
@@ -1171,15 +1205,13 @@ class TestStandaloneRenderUsesTheDeviceLocation:
|
||||
SCHEMA = {"schema": [{"typeOf": "location", "id": "location"}]}
|
||||
|
||||
@pytest.fixture
|
||||
def app_dir(self, tmp_path, monkeypatch):
|
||||
def app_dir(self, starlark_apps_dir, monkeypatch):
|
||||
from web_interface.blueprints import api_v3 as module
|
||||
apps_dir = tmp_path / "starlark-apps"
|
||||
apps_dir = starlark_apps_dir
|
||||
app_dir = apps_dir / "weather"
|
||||
app_dir.mkdir(parents=True)
|
||||
(app_dir / "weather.star").write_text("# app")
|
||||
(app_dir / "schema.json").write_text(json.dumps(self.SCHEMA))
|
||||
monkeypatch.setattr(module, '_STARLARK_APPS_DIR', apps_dir)
|
||||
monkeypatch.setattr(module, '_STARLARK_MANIFEST_FILE', apps_dir / 'manifest.json')
|
||||
(apps_dir / 'manifest.json').write_text(json.dumps(
|
||||
{'apps': {'weather': {'star_file': 'weather.star'}}}))
|
||||
config_manager = MagicMock()
|
||||
|
||||
@@ -128,6 +128,7 @@ class Web:
|
||||
store = api.plugin_store_manager
|
||||
store.plugins_dir = str(self.plugins_dir)
|
||||
store.get_registry_info.return_value = None
|
||||
store.get_cached_registry_info.return_value = None
|
||||
store.get_plugin_info.return_value = None
|
||||
store._get_local_git_info.return_value = None
|
||||
store.install_plugin.return_value = True
|
||||
|
||||
@@ -158,16 +158,11 @@ def _panel_refresh_hz(config):
|
||||
cap = scroll_config.refresh_hz_from_config(config)
|
||||
try:
|
||||
with open(frame_timing.default_stats_path(), encoding='utf-8') as fh:
|
||||
stats = json.load(fh)
|
||||
measured = float(stats.get('measured_refresh_hz') or 0)
|
||||
planned = float(stats.get('planned_refresh_hz') or 0)
|
||||
measured = float(json.load(fh).get('measured_refresh_hz') or 0)
|
||||
except (OSError, ValueError, TypeError, AttributeError):
|
||||
measured = planned = 0.0
|
||||
# Reject a stale file from a previous hardware config: one written under
|
||||
# another cap (the display has not restarted since it changed), or, from
|
||||
# a display too old to record its cap, a measurement far off this one.
|
||||
if planned and abs(planned - cap) > 0.5:
|
||||
measured = 0.0
|
||||
# Reject a stale file from a previous hardware config: a measurement far
|
||||
# off the cap says the config changed since it was written.
|
||||
if measured > 0 and 0.5 * cap <= measured <= 1.5 * cap:
|
||||
return measured, 'measured'
|
||||
return cap, 'configured'
|
||||
@@ -194,29 +189,6 @@ def get_scroll_speed_advice():
|
||||
return jsonify({'status': 'success', 'data': advice})
|
||||
|
||||
|
||||
@api_v3.route('/config/refresh-rate', methods=['GET'])
|
||||
def get_refresh_rate():
|
||||
"""The refresh cap, what the panel measured, and a cap it can hold.
|
||||
|
||||
Backs the hint under the Display tab's Limit Refresh Rate field. Scroll
|
||||
speeds are solved against the cap, so a panel that cannot reach it runs
|
||||
every scroll slow; ``shortfall`` (None when the panel keeps up, or nothing
|
||||
has been measured yet) says by how much and suggests a cap.
|
||||
"""
|
||||
from src.common import scroll_config
|
||||
if not api_v3.config_manager:
|
||||
return jsonify({'status': 'error', 'message': 'Config manager not initialized'}), 500
|
||||
config = api_v3.config_manager.load_config()
|
||||
planned = scroll_config.refresh_hz_from_config(config)
|
||||
hz, source = _panel_refresh_hz(config)
|
||||
measured = hz if source == 'measured' else None
|
||||
return jsonify({'status': 'success', 'data': {
|
||||
'planned_hz': planned,
|
||||
'measured_hz': round(measured, 1) if measured else None,
|
||||
'shortfall': scroll_config.refresh_shortfall(measured, planned),
|
||||
}})
|
||||
|
||||
|
||||
@api_v3.route('/config/schedule', methods=['GET'])
|
||||
def get_schedule_config():
|
||||
"""Get current schedule configuration"""
|
||||
|
||||
@@ -9,7 +9,7 @@ from web_interface.blueprints.api_v3 import (
|
||||
_get_display_service_status, _socket_reason_code, _stop_display_service, api_v3,
|
||||
jsonify, logger, request, uuid,
|
||||
)
|
||||
from web_interface import display_preview, display_state
|
||||
from web_interface import display_preview, display_state, on_demand_dispatch
|
||||
import web_interface.blueprints.api_v3 as _pkg
|
||||
from src.ipc import client as control_client
|
||||
# Read through the module rather than bound by value: tests patch these
|
||||
@@ -33,60 +33,71 @@ def _cache_manager():
|
||||
|
||||
|
||||
|
||||
def _deliver_on_demand(payload):
|
||||
"""Hand an on-demand request to the display: control socket, else mailbox.
|
||||
#: How long a start is sent again to a service that systemd reports running
|
||||
#: but that has no socket yet (a display still loading its plugins, or one
|
||||
#: someone else just restarted). A cold start gets the dispatcher's own
|
||||
#: START_WAIT_SECONDS. Either way the route answers at once (202) and the
|
||||
#: web process's dispatcher does the waiting.
|
||||
ON_DEMAND_SOCKET_WAIT_RUNNING_SECONDS = 10.0
|
||||
|
||||
The socket (src/ipc) answers with an acknowledgement as soon as the
|
||||
display has the command queued for its render thread. Any failure -- no
|
||||
socket (the display is stopped or predates it), a timeout, a refusal --
|
||||
writes the file mailbox instead, exactly as before the socket existed;
|
||||
the display reads it within ON_DEMAND_POLL_INTERVAL. Both carry the same
|
||||
request_id, so a request that reached the display both ways (a reply
|
||||
that timed out after the command was queued) is still processed once.
|
||||
|
||||
Returns ``(transport, socket_error)``: ``'socket'`` and None, or
|
||||
``'mailbox'`` and the socket failure's reason code.
|
||||
def _dispatcher():
|
||||
"""The web process's on-demand dispatcher (web_interface/on_demand_dispatch.py)."""
|
||||
return on_demand_dispatch.get_dispatcher(_send_on_demand)
|
||||
|
||||
|
||||
def _pending_start_state():
|
||||
"""A start the dispatcher is still delivering, or one it gave up on:
|
||||
the state the status routes report instead of the display's. None when
|
||||
there is none (or it was delivered, after which the display's own
|
||||
state is the truth)."""
|
||||
dispatcher = on_demand_dispatch.current()
|
||||
status = dispatcher.status() if dispatcher is not None else None
|
||||
if status is None or status.get('status') not in ('starting', 'error'):
|
||||
return None
|
||||
return status
|
||||
|
||||
|
||||
def _send_on_demand(payload):
|
||||
"""Hand an on-demand request to the display over the control socket.
|
||||
|
||||
Returns the display's acknowledgement: it has the command queued for
|
||||
its render thread. Raises ``control_client.ControlError`` when it did
|
||||
not take it; there is no other way to reach the display (the file
|
||||
mailbox ``display_on_demand_request`` is gone).
|
||||
"""
|
||||
try:
|
||||
if payload['action'] == 'start':
|
||||
control_client.on_demand_start(
|
||||
payload['request_id'], payload.get('plugin_id'), payload.get('mode'),
|
||||
payload.get('duration'), bool(payload.get('pinned', False)))
|
||||
else:
|
||||
control_client.on_demand_stop(payload['request_id'])
|
||||
return 'socket', None
|
||||
except control_client.ControlError as e:
|
||||
reason = _socket_reason_code(e.reason)
|
||||
if payload['action'] == 'start':
|
||||
return control_client.on_demand_start(
|
||||
payload['request_id'], payload.get('plugin_id'), payload.get('mode'),
|
||||
payload.get('duration'), bool(payload.get('pinned', False)))
|
||||
return control_client.on_demand_stop(payload['request_id'])
|
||||
|
||||
|
||||
def _socket_error_response(request_id, action, reason, message=None, **extra):
|
||||
"""The answer when the display did not take an on-demand request: ``400``
|
||||
for arguments it refused, else ``503``."""
|
||||
status = 400 if reason == 'invalid_args' else 503
|
||||
data = {'request_id': request_id, 'transport': 'socket', 'socket_error': reason}
|
||||
data.update(extra)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': message or (f'The display service did not accept the on-demand {action} '
|
||||
f'request ({reason})'),
|
||||
'data': data,
|
||||
}), status
|
||||
|
||||
|
||||
def _socket_failure_reason(error):
|
||||
"""The reportable reason code for an exception from _send_on_demand, logged."""
|
||||
if isinstance(error, control_client.ControlError):
|
||||
reason = _socket_reason_code(error.reason)
|
||||
if reason in _QUIET_SOCKET_REASONS:
|
||||
logger.debug("On-demand %s via the mailbox: %s", payload['action'], e)
|
||||
logger.debug("On-demand request not taken over the control socket: %s", error)
|
||||
else:
|
||||
logger.warning("Control socket did not take on-demand %s (%s); "
|
||||
"using the mailbox", payload['action'], e)
|
||||
except Exception: # never let the socket path break the route
|
||||
logger.exception("Control socket client failed; using the mailbox")
|
||||
reason = 'internal'
|
||||
_cache_manager().set('display_on_demand_request', payload)
|
||||
return 'mailbox', reason
|
||||
|
||||
|
||||
def _withdraw_on_demand(request_id):
|
||||
"""Take a start request the route has refused back out of the mailbox.
|
||||
|
||||
The display reads the mailbox for an hour without looking at a
|
||||
request's age, so one left there after an error answer ran whenever the
|
||||
display next started. Only this request is removed: the mailbox is
|
||||
re-read and cleared only while it still holds this request_id, as the
|
||||
display's _consume_on_demand_request does, so a newer request posted in
|
||||
the meantime stays for the display to take.
|
||||
"""
|
||||
cache = _cache_manager()
|
||||
try:
|
||||
current = cache.get('display_on_demand_request', max_age=3600, memory_ttl=0)
|
||||
if isinstance(current, dict) and current.get('request_id') == request_id:
|
||||
cache.delete('display_on_demand_request')
|
||||
except Exception: # the route is answering an error already
|
||||
logger.warning("Could not withdraw on-demand request %s from the mailbox",
|
||||
request_id, exc_info=True)
|
||||
logger.warning("On-demand request not taken over the control socket: %s", error)
|
||||
return reason
|
||||
logger.error("Control socket client failed", exc_info=error)
|
||||
return 'internal'
|
||||
|
||||
|
||||
@api_v3.route('/display/current', methods=['GET'])
|
||||
@@ -191,6 +202,7 @@ def get_on_demand_status():
|
||||
"""
|
||||
state = display_state.on_demand_state(display_state.read_state())
|
||||
source = 'socket'
|
||||
pending = _pending_start_state()
|
||||
if state is None:
|
||||
source = 'cache'
|
||||
cache = _cache_manager()
|
||||
@@ -199,6 +211,10 @@ def get_on_demand_status():
|
||||
# copy it read for the full max_age -- "active" for two minutes after
|
||||
# the display had already stopped.
|
||||
state = cache.get('display_on_demand_state', max_age=120, memory_ttl=0)
|
||||
if pending is not None and _shadows(pending, state):
|
||||
# A start the web process is still delivering, or gave up on
|
||||
# (start-timeout): newer than anything the display has said.
|
||||
state, source = pending, 'web'
|
||||
if state is None:
|
||||
state = {
|
||||
'active': False,
|
||||
@@ -214,6 +230,18 @@ def get_on_demand_status():
|
||||
'source': source,
|
||||
}
|
||||
})
|
||||
def _shadows(pending, state):
|
||||
"""Whether the web process's pending start (or its failure) is newer
|
||||
than the display's on-demand ``state``. While it is still being sent it
|
||||
always is; a failure is, until the display publishes something later."""
|
||||
if pending.get('status') == 'starting' or not isinstance(state, dict):
|
||||
return True
|
||||
shown = state.get('last_updated')
|
||||
if not isinstance(shown, (int, float)) or isinstance(shown, bool):
|
||||
return True
|
||||
return shown < (pending.get('last_updated') or 0)
|
||||
|
||||
|
||||
@api_v3.route('/display/on-demand/start', methods=['POST'])
|
||||
def start_on_demand_display():
|
||||
"""Request the display controller to run a specific plugin on-demand."""
|
||||
@@ -262,11 +290,10 @@ def start_on_demand_display():
|
||||
resolved_plugin,
|
||||
)
|
||||
|
||||
# Deliver the request over the control socket, or post it to the
|
||||
# mailbox the display process polls (DisplayController.
|
||||
# _poll_on_demand_requests). Done before any service start: a stopped
|
||||
# display has no socket, so the request lands in the mailbox, where a
|
||||
# freshly started display finds it on its first poll.
|
||||
# The request goes over the control socket, the only way to reach the
|
||||
# display. A display that is not listening (stopped, or still starting)
|
||||
# is started when start_service asks for it, and the request is sent
|
||||
# again once its socket is up.
|
||||
request_id = data.get('request_id') or str(uuid.uuid4())
|
||||
request_payload = {
|
||||
'request_id': request_id,
|
||||
@@ -277,65 +304,85 @@ def start_on_demand_display():
|
||||
'pinned': pinned,
|
||||
'timestamp': _pkg.time.time()
|
||||
}
|
||||
transport, socket_error = _deliver_on_demand(request_payload)
|
||||
|
||||
# A socket acknowledgement is the display itself answering: it is
|
||||
# running and has the request queued, whatever systemd says (a display
|
||||
# run by hand or in the emulator has no active unit). So nothing is
|
||||
# checked or started for it -- that answered "not running" for a request
|
||||
# that had already taken effect. The service is still reported the way
|
||||
# _ensure_display_service_running reports a running one.
|
||||
if transport == 'socket':
|
||||
# This start supersedes one the dispatcher is still delivering,
|
||||
# whatever becomes of it: an older request must not land after it.
|
||||
dispatcher = on_demand_dispatch.current()
|
||||
if dispatcher is not None:
|
||||
dispatcher.cancel('superseded')
|
||||
try:
|
||||
_send_on_demand(request_payload)
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
error = e
|
||||
else:
|
||||
# A socket acknowledgement is the display itself answering: it is
|
||||
# running and has the request queued, whatever systemd says (a
|
||||
# display run by hand or in the emulator has no active unit). So
|
||||
# nothing is checked or started for it. The service is still
|
||||
# reported the way _ensure_display_service_running reports a
|
||||
# running one.
|
||||
service_result = (dict(_get_display_service_status(), started=False)
|
||||
if start_service else None)
|
||||
return _on_demand_started(request_id, resolved_plugin, resolved_mode,
|
||||
duration, pinned, service_result, transport,
|
||||
socket_error)
|
||||
duration, pinned, service_result)
|
||||
|
||||
reason = _socket_failure_reason(error)
|
||||
if not control_client.display_not_listening(error):
|
||||
# The display had it and refused it, is too old for the command, or
|
||||
# this web process cannot use the socket at all (switched off, no
|
||||
# Unix sockets): starting a service would not change that.
|
||||
return _socket_error_response(request_id, 'start', reason)
|
||||
|
||||
service_status = _get_display_service_status()
|
||||
|
||||
if not service_status.get('active') and not start_service:
|
||||
# The request is in the mailbox, and the display reads it whenever
|
||||
# it next starts: taken back out, or a request answered with this
|
||||
# error ran later anyway.
|
||||
_withdraw_on_demand(request_id)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Display service is not running. Please start the display service or enable "Start Service" option.',
|
||||
'service_status': service_status
|
||||
'service_status': service_status,
|
||||
'data': {'request_id': request_id, 'transport': 'socket', 'socket_error': reason},
|
||||
}), 400
|
||||
|
||||
# start_service means "start it if it is not running", as the UI's
|
||||
# checkbox says; _ensure_display_service_running leaves a running service
|
||||
# alone. This used to stop a running service, sleep 1.5s and start it
|
||||
# again, so every on-demand or "Preview on display" click -- and every
|
||||
# MQTT on-demand command, which posts here with the default -- cold-
|
||||
# restarted the display process: every plugin reloaded and the panel was
|
||||
# blank for seconds. The restart bought nothing. The running process
|
||||
# reads this mailbox every ON_DEMAND_POLL_INTERVAL (0.25s), from its
|
||||
# dwell sleep, its render loops and Vegas's interrupt check as well as
|
||||
# the main loop, and a restarted one got the request the same way: the
|
||||
# startup path only restores a session the display itself saved
|
||||
# (display_on_demand_config), so it loaded nothing it would not have had.
|
||||
# checkbox says; _ensure_display_service_running leaves a running
|
||||
# service alone (restarting it cost seconds of blank panel for nothing).
|
||||
# Either way the display has no socket yet. The route does not wait for
|
||||
# it -- a cold start can outlast a client's timeout (the MQTT bridge's is
|
||||
# 15 s) -- but answers 202 and leaves the sending to the dispatcher,
|
||||
# whose outcome the status routes report.
|
||||
wait = ON_DEMAND_SOCKET_WAIT_RUNNING_SECONDS
|
||||
service_result = None
|
||||
if start_service:
|
||||
if not service_status.get('active'):
|
||||
service_result = _ensure_display_service_running()
|
||||
# Check if service actually started
|
||||
if service_result and not service_result.get('active'):
|
||||
_withdraw_on_demand(request_id)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Failed to start display service. Please check service logs or start it manually.',
|
||||
'service_result': service_result
|
||||
}), 500
|
||||
wait = on_demand_dispatch.START_WAIT_SECONDS
|
||||
elif start_service:
|
||||
service_result = dict(service_status, started=False)
|
||||
|
||||
return _on_demand_started(request_id, resolved_plugin, resolved_mode,
|
||||
duration, pinned, service_result, transport,
|
||||
socket_error)
|
||||
_dispatcher().submit(request_payload, wait_seconds=wait)
|
||||
return jsonify({
|
||||
'status': 'starting',
|
||||
'message': ('The display service is starting; the request is sent to it as soon '
|
||||
'as it is listening. Check the on-demand status for the outcome.'),
|
||||
'data': {
|
||||
'request_id': request_id,
|
||||
'plugin_id': resolved_plugin,
|
||||
'mode': resolved_mode,
|
||||
'duration': duration,
|
||||
'pinned': pinned,
|
||||
'service': service_result,
|
||||
'transport': 'socket',
|
||||
'socket_error': reason,
|
||||
'pending': True,
|
||||
'wait_seconds': wait,
|
||||
},
|
||||
}), 202
|
||||
|
||||
|
||||
def _on_demand_started(request_id, plugin_id, mode, duration, pinned,
|
||||
service_result, transport, socket_error):
|
||||
def _on_demand_started(request_id, plugin_id, mode, duration, pinned, service_result):
|
||||
"""The success answer of /display/on-demand/start."""
|
||||
response_data = {
|
||||
'request_id': request_id,
|
||||
@@ -344,10 +391,8 @@ def _on_demand_started(request_id, plugin_id, mode, duration, pinned,
|
||||
'duration': duration,
|
||||
'pinned': pinned,
|
||||
'service': service_result,
|
||||
'transport': transport,
|
||||
'transport': 'socket',
|
||||
}
|
||||
if socket_error:
|
||||
response_data['socket_error'] = socket_error
|
||||
return jsonify({'status': 'success', 'data': response_data})
|
||||
@api_v3.route('/display/on-demand/stop', methods=['POST'])
|
||||
def stop_on_demand_display():
|
||||
@@ -356,16 +401,38 @@ def stop_on_demand_display():
|
||||
# _coerce_to_bool: bool("false") is True, which stopped the service.
|
||||
stop_service = _coerce_to_bool(data.get('stop_service', False))
|
||||
|
||||
# The running display takes the stop over the control socket, or reads
|
||||
# it from the mailbox within ON_DEMAND_POLL_INTERVAL, and resumes normal
|
||||
# rotation in place (_clear_on_demand); nothing is restarted.
|
||||
# The running display takes the stop over the control socket and
|
||||
# resumes normal rotation in place (_clear_on_demand); nothing is
|
||||
# restarted.
|
||||
request_id = data.get('request_id') or str(uuid.uuid4())
|
||||
request_payload = {
|
||||
'request_id': request_id,
|
||||
'action': 'stop',
|
||||
'timestamp': _pkg.time.time()
|
||||
}
|
||||
transport, socket_error = _deliver_on_demand(request_payload)
|
||||
socket_error = None
|
||||
# A start the web process is still delivering is dropped first: the
|
||||
# stop is newer, whatever happens to it below.
|
||||
dispatcher = on_demand_dispatch.current()
|
||||
cancelled = dispatcher.cancel('requested-stop') if dispatcher is not None else None
|
||||
try:
|
||||
_send_on_demand(request_payload)
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
socket_error = _socket_failure_reason(e)
|
||||
if not stop_service and not (cancelled and control_client.display_not_listening(e)):
|
||||
if control_client.display_not_listening(e):
|
||||
service_status = _get_display_service_status()
|
||||
message = ('Display service is not running, so the stop could not be '
|
||||
'delivered. If it resumes an on-demand session when it starts, '
|
||||
'stop it then.' if not service_status.get('active') else
|
||||
f'The display service is running but its control socket did '
|
||||
f'not answer ({socket_error}). It may still be starting; '
|
||||
f'try again shortly.')
|
||||
return _socket_error_response(request_id, 'stop', socket_error, message,
|
||||
service=service_status)
|
||||
return _socket_error_response(request_id, 'stop', socket_error)
|
||||
# Stopping the service ends on-demand too, whatever the display did
|
||||
# with the request.
|
||||
|
||||
service_result = None
|
||||
if stop_service:
|
||||
@@ -374,11 +441,16 @@ def stop_on_demand_display():
|
||||
response_data = {
|
||||
'request_id': request_id,
|
||||
'service': service_result,
|
||||
'transport': transport,
|
||||
'transport': 'socket',
|
||||
}
|
||||
if cancelled:
|
||||
# The start it ended never reached the display.
|
||||
response_data['cancelled_request_id'] = cancelled
|
||||
if socket_error:
|
||||
response_data['socket_error'] = socket_error
|
||||
return jsonify({'status': 'success', 'data': response_data})
|
||||
|
||||
|
||||
@api_v3.route('/display/current-status', methods=['GET'])
|
||||
def get_current_display_status():
|
||||
"""Return the display mode/plugin currently intended to be shown.
|
||||
@@ -408,4 +480,10 @@ def get_current_display_status():
|
||||
'plugin_id': None,
|
||||
'last_updated': None,
|
||||
}
|
||||
return jsonify({'status': 'success', 'data': dict(state, source=source)})
|
||||
data = dict(state, source=source)
|
||||
pending = _pending_start_state()
|
||||
if pending is not None:
|
||||
# An on-demand start the web process is still delivering (or gave
|
||||
# up on): what the panel is about to show, or why it will not.
|
||||
data['on_demand_pending'] = pending
|
||||
return jsonify({'status': 'success', 'data': data})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user