mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-06 07:15:09 +00:00
Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4521acdf57 | ||
|
|
e4f5e49ff7 | ||
|
|
b440a27cdb | ||
|
|
6dbec2e0b1 | ||
|
|
e40bc47d28 | ||
|
|
3bdb5bff3b | ||
|
|
e745ae8060 | ||
|
|
a669d781f5 | ||
|
|
e74447c65e | ||
|
|
c20c0beac2 | ||
|
|
6fb2dc3595 | ||
|
|
b638b91169 | ||
|
|
bb475a79ea | ||
|
|
554426e023 | ||
|
|
3f920f2899 | ||
|
|
0577c807eb | ||
|
|
5a7893b11a | ||
|
|
3866aa4519 | ||
|
|
26cae3e5d6 | ||
|
|
294bd522bd | ||
|
|
80048fe78e | ||
|
|
378478124c | ||
|
|
d447cdd965 | ||
|
|
ee7c3389f9 | ||
|
|
caec9f5bf5 | ||
|
|
a74b5a2f0f | ||
|
|
57d7df6705 | ||
|
|
e09e251553 | ||
|
|
7026eeb156 | ||
|
|
2236ff3081 | ||
|
|
5ad5e9aa59 | ||
|
|
8a0cce1aaf | ||
|
|
0b039c875f | ||
|
|
07abd87d5e | ||
|
|
e32d177cbd | ||
|
|
d18e4d3c9d | ||
|
|
04f0d8d134 | ||
|
|
064b9c9912 | ||
|
|
a6e9e3ef1c | ||
|
|
41192b9588 |
+719
-12
@@ -19,6 +19,347 @@ accepts both, but the store flags the old spelling as deprecated
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
### Tooling
|
||||||
|
|
||||||
|
- `test/test_sports_helpers.py`'s parity tests pass again with
|
||||||
|
`LEDMATRIX_PLUGINS` set. The scoreboards deleted their copies of the
|
||||||
|
`sports_helpers` bodies and constants when they adopted `SportsHelpersMixin`
|
||||||
|
(ledmatrix-plugins #563/#564), and the 19 tests still expected them. A copy
|
||||||
|
that is gone now counts as adopted when the plugin imports
|
||||||
|
`src.common.sports_helpers`, as the stage 3/4 and game-over parity tests
|
||||||
|
already do; a copy that remains must still match.
|
||||||
|
|
||||||
|
## 3.8.2
|
||||||
|
|
||||||
|
The display hands freed memory back to the OS (#774), and sports consolidation
|
||||||
|
family 6: `src.common.sports_favorites`, which the scoreboards adopt by
|
||||||
|
flooring on 3.8.2 (#775).
|
||||||
|
|
||||||
|
### The display hands freed memory back to the OS
|
||||||
|
|
||||||
|
The display process's resident memory climbed in steps for hours while the
|
||||||
|
data it held stayed flat: glibc keeps what Python frees in per-thread malloc
|
||||||
|
arenas and returns little of it. `src/malloc_tuning.py` (new, standard library
|
||||||
|
only, a no-op off Linux/glibc) does two things in-process, so it reaches
|
||||||
|
devices without re-running the installer:
|
||||||
|
|
||||||
|
- **Arena cap at start-up.** `run.py` calls `mallopt(M_ARENA_MAX, 2)` before any
|
||||||
|
thread exists, the same cap as the unit's `Environment=MALLOC_ARENA_MAX=2`.
|
||||||
|
Units installed before that line never got it (systemd runs the copy in
|
||||||
|
`/etc/systemd/system`); a `MALLOC_ARENA_MAX` in the environment still wins.
|
||||||
|
- **`malloc_trim(0)` between screens**, at most every 5 minutes, from the top of
|
||||||
|
the render loop where no frame is being drawn. Measured on a Pi 4: 2-11 ms
|
||||||
|
per call.
|
||||||
|
|
||||||
|
On ledpi (Pi 4, 192x48, Vegas on, nine plugins, a unit without
|
||||||
|
`MALLOC_ARENA_MAX`), alternated main / branch / branch / main arms of 2.5 h:
|
||||||
|
two hours in, resident memory was 551 MB on main (the second main arm was
|
||||||
|
already at 651 MB after 1 h 44 min) against 412 and 386 MB with this change,
|
||||||
|
and the 20-minute frame soaks came out at 0.147-0.165% late against main's
|
||||||
|
0.151-0.188%.
|
||||||
|
|
||||||
|
### New modules
|
||||||
|
|
||||||
|
- `src/common/sports_favorites.py` -- sports consolidation family 6, once the
|
||||||
|
plugins made `_is_favorite_game` (seven bodies), `_select_games_for_display`
|
||||||
|
(two) and `_select_recent_games_for_display` (three) one each:
|
||||||
|
`SportsFavoritesMixin` (`SportsCore`: `_is_favorite_game`, `_favorite_code`),
|
||||||
|
`SportsUpcomingFavoritesMixin` and `SportsRecentFavoritesMixin` (the
|
||||||
|
favourites-only picks). Each side of a game is named by the 3.5.0
|
||||||
|
`_favorite_key` seam and compared with `favorite_teams` stripped and
|
||||||
|
upper-cased; nrl overrides the key with the ESPN team id. Only a game with an
|
||||||
|
id can be a duplicate. A plugin may inherit the mixins once it floors on
|
||||||
|
3.8.2, and deletes its copies then. (#775)
|
||||||
|
|
||||||
|
## 3.8.1
|
||||||
|
|
||||||
|
Smooth scrolling at the slower speeds, and the fixes and performance work
|
||||||
|
since 3.8.0. Highlights: the default 50 px/s and every other held-frame speed
|
||||||
|
now scroll cleanly (below), Raspberry Pi OS Bookworm is supported alongside
|
||||||
|
Trixie, updates refresh the systemd units, the display control socket gains
|
||||||
|
stages 2 and 3, the shared fetch service lands (stages 1 and 2), and a run of
|
||||||
|
web UI and Plugin Manager fixes. One new module is for plugins:
|
||||||
|
`src.common.sports_game_over` (sports family 5), which the scoreboards adopt
|
||||||
|
by flooring on 3.8.1; the other new modules are core-internal and set no
|
||||||
|
`ledmatrix_min_version` floor.
|
||||||
|
|
||||||
|
### Scroll speed
|
||||||
|
|
||||||
|
These two entries were the reason for this release: on 3.8.0 a slow scroll
|
||||||
|
either stepped or showed a half-pixel tear across the middle of the panel,
|
||||||
|
so only speeds of one pixel per refresh looked right.
|
||||||
|
|
||||||
|
- The Vegas Scroll Speed slider now says what the panel will do with the speed
|
||||||
|
it is on, and offers the nearest smooth ones to click. Only speeds that advance
|
||||||
|
a whole number of pixels per refresh look smooth, and which those are depends
|
||||||
|
on the panel (`GET /api/v3/config/scroll-speed-advice`, built on
|
||||||
|
`scroll_config.speed_advice()`; it uses the refresh the display measured, not
|
||||||
|
the `limit_refresh_rate_hz` cap). The slider steps by 1 px/s instead of 5.
|
||||||
|
- The default 50 px/s no longer snaps to a stepped 48 px/s (2 px every 5
|
||||||
|
refreshes, 24 fps) on a 120 Hz panel: `solve_crisp()` now prefers 60 or 40 px/s,
|
||||||
|
which move one pixel at a time. 100 Hz panels are unaffected. (#710)
|
||||||
|
- A held-frame scroll (one pixel every two or more refreshes, such as 50 or
|
||||||
|
60 px/s on a 100-120 Hz panel) no longer shows a half-pixel step across the
|
||||||
|
middle of the panel. Scan-order compensation ran only at one frame per
|
||||||
|
refresh; a held frame is now presented as a sequence of swaps
|
||||||
|
(`scan_order.refresh_plan()`), so the half of the panel that scans later
|
||||||
|
steps one refresh after the rest. It is skipped when a blit takes more than
|
||||||
|
half a refresh, since the second blit has to land before the next vsync.
|
||||||
|
(#711)
|
||||||
|
|
||||||
|
### Web UI: the Display tab is an ES-module page, with a page-visibility service (stage 4)
|
||||||
|
|
||||||
|
- New `static/v3/js/core/visibility.js`: each page module gets
|
||||||
|
`ctx.visibility` with `whileVisible(start, stop)`, `every(ms, fn)` and
|
||||||
|
`isVisible()`. Work registered there runs only while the page's tab is the
|
||||||
|
active tab and the browser tab is visible, and ends when the page is
|
||||||
|
swapped out, with no teardown code in the page. It reads the active tab
|
||||||
|
from `window.LEDVisibility`, so it agrees with the classic partials that
|
||||||
|
still use that directly. The page registry gained a `mountContext` option
|
||||||
|
for services bound to one mounted page.
|
||||||
|
- The Display tab's inline scripts are now `static/v3/js/pages/display.js`.
|
||||||
|
The partial has no inline script, `onclick` or `onchange` any more. The
|
||||||
|
multi-display sync status poll (every 5 s) runs through
|
||||||
|
`ctx.visibility.every`; the status and scroll-speed hint requests go
|
||||||
|
through `core/api.js` with the page's abort signal, and so does the Vegas
|
||||||
|
order widget's plugin-list request.
|
||||||
|
- Behaviour differences: with sync on, opening the tab asks for the status
|
||||||
|
once instead of twice, and a Display tab loaded while not on screen waits
|
||||||
|
until it is. A login redirect during a poll no longer flashes "Sync status
|
||||||
|
unavailable". The `window.syncStatusInterval` timer id is gone (nothing
|
||||||
|
read it). A pending scroll-hint request or widget retry is dropped when
|
||||||
|
the partial is swapped out.
|
||||||
|
- `window.updateSyncUI` keeps working as a deprecated alias through
|
||||||
|
`window.LEDMatrix` (one console warning).
|
||||||
|
- New suites `test/js/dom/test_visibility_service.js` and
|
||||||
|
`dom/test_display_page.js`; `unit/test_display_partial_ids.js` imports the
|
||||||
|
module instead of slicing the template.
|
||||||
|
|
||||||
|
### Plugins ask for the screen in-process: `request_on_demand()` / `end_on_demand()`
|
||||||
|
|
||||||
|
The in-process way in that stage 5 of the control socket needed
|
||||||
|
(`docs/IPC_CONTROL_SOCKET.md`, "Plugins in the display process").
|
||||||
|
|
||||||
|
- **`BasePlugin.request_on_demand(mode=None, duration=None, pinned=False)`**
|
||||||
|
shows the plugin now, and **`BasePlugin.end_on_demand()`** gives the
|
||||||
|
screen back. Both are safe from any thread (an MQTT callback, a timer
|
||||||
|
thread): `PluginManager.request_on_demand()` / `end_on_demand()` hand the
|
||||||
|
request to `DisplayController.submit_plugin_on_demand()`, which only
|
||||||
|
queues it (at most 32) and wakes the render thread through the control
|
||||||
|
socket's flag (`ControlServer.wake()`). The render thread applies it with
|
||||||
|
the socket's commands, through the same handler as a web on-demand
|
||||||
|
request, so it lands within a frame rather than on the mailbox's
|
||||||
|
once-a-second look. Both return the request id, or `None` when no display
|
||||||
|
runs in the process (the web interface, `scripts/check_plugin.py`) or the
|
||||||
|
queue is full.
|
||||||
|
- **A plugin's stop ends only its own session.** A mailbox stop still ends
|
||||||
|
any session, whoever started it.
|
||||||
|
- **Older cores.** Plugins detect the methods with `hasattr` and write the
|
||||||
|
`display_on_demand_request` mailbox when they are missing or answer
|
||||||
|
`None`; the pattern is in `docs/PLUGIN_API_REFERENCE.md` ("On-demand
|
||||||
|
display"). The display still reads the mailbox for plugins that write it.
|
||||||
|
|
||||||
|
### Web UI: Schedule and General are ES-module pages (stage 3)
|
||||||
|
|
||||||
|
- The Schedule and General tabs follow stage 2 (#727): their inline
|
||||||
|
`<script>` blocks are now `static/v3/js/pages/schedule.js` and
|
||||||
|
`pages/general.js`, started once per swap-in by the page registry and
|
||||||
|
stopped on swap-out. Neither partial has an inline script, `onclick`,
|
||||||
|
`onsubmit` or `oninput` any more.
|
||||||
|
- Schedule: both pickers are drawn from the saved config the partial
|
||||||
|
carries as JSON in `data-schedule-config` / `data-dim-schedule-config`.
|
||||||
|
The forms' `hx-on` save handlers became one `htmx:afterRequest` listener
|
||||||
|
on the page; the forms are marked `data-reports-result`, which `app.js`
|
||||||
|
now honours like an `hx-on` after-request handler, so a save still shows
|
||||||
|
one notification.
|
||||||
|
- General: the timezone picker reads the saved zone from `data-timezone`.
|
||||||
|
The Security section's forms and buttons carry `data-action` and use one
|
||||||
|
delegated submit and one delegated click listener, so a token row added
|
||||||
|
after a create needs no listener of its own. Requests go through
|
||||||
|
`core/api.js`: the optional login's "sign in again" answer no longer
|
||||||
|
flashes an error while the page navigates to the login form. A login
|
||||||
|
change made just before a swap is still reported.
|
||||||
|
- Old globals keep working as deprecated aliases through `window.LEDMatrix`
|
||||||
|
(one console warning each): `handleScheduleResponse`,
|
||||||
|
`handleDimScheduleResponse`, and `webLogin` (its five methods).
|
||||||
|
- New DOM suites `test/js/dom/test_{schedule,general}_page.js`;
|
||||||
|
`unit/test_general_web_login_token.js` imports the module instead of
|
||||||
|
slicing the template, and `unit/test_restart_banner.js` covers
|
||||||
|
`data-reports-result`.
|
||||||
|
|
||||||
|
### The control socket carries every web command; the mailboxes are a fallback
|
||||||
|
|
||||||
|
Stage 4 of the web → display control socket (`docs/IPC_CONTROL_SOCKET.md`).
|
||||||
|
|
||||||
|
- **Mailbox only when the socket cannot carry it.** The on-demand routes
|
||||||
|
(`POST /api/v3/display/on-demand/start` and `/stop`) write the
|
||||||
|
`display_on_demand_request` mailbox only when the display never had the
|
||||||
|
request: no socket (a stopped display, one older than the socket), a
|
||||||
|
refused or timed-out connect, or a display too old to know the command.
|
||||||
|
A display that had it and refused or did not answer (a full queue, bad
|
||||||
|
arguments, silence after the send) is answered `503` (`400` for bad
|
||||||
|
arguments) with `socket_error`, and no mailbox copy is written: the
|
||||||
|
display may have applied it, or would refuse the copy too. A stop with
|
||||||
|
`stop_service` still stops the service. `src.ipc.client.should_fall_back()`
|
||||||
|
holds the rule; `ControlError.sent` says whether the display had the
|
||||||
|
request.
|
||||||
|
- **`errors.clear`.** `POST /api/v3/errors/clear` goes over the socket: the
|
||||||
|
display clears its error records and republishes its error snapshot
|
||||||
|
before it answers, so the response says `applied: true` with the
|
||||||
|
display's own `cleared_count`. The `plugin_error_clear_request` mailbox
|
||||||
|
is written only on the same fallback rule (a display from before this
|
||||||
|
release answers `unknown_command`, and gets the mailbox). A display that
|
||||||
|
had it and failed answers `503`. The error snapshot gains
|
||||||
|
`applied_clear_cutoff`, so an older mailbox request is not shown as
|
||||||
|
pending once a wider clear has been applied.
|
||||||
|
- **The display looks at the mailboxes less, and more cheaply.** While the
|
||||||
|
control socket is up, the on-demand mailbox is looked at once a second
|
||||||
|
instead of every 0.25 s (`MAILBOX_POLL_INTERVAL_WITH_SOCKET`), and both
|
||||||
|
mailboxes are read only when their file changed since the last look:
|
||||||
|
otherwise a look is one `stat()` (`CacheManager.file_signature`,
|
||||||
|
`MailboxWatch`). A socket command no longer reads or deletes the mailbox
|
||||||
|
file. A duplicate already processed is taken out of the mailbox, rather
|
||||||
|
than re-read for an hour. Without a socket (Windows,
|
||||||
|
`LEDMATRIX_CONTROL_SOCKET=off`) the mailbox is read every 0.25 s as before.
|
||||||
|
- **Kept for one release.** The display still reads both mailboxes, so an
|
||||||
|
older web interface (or a web user not yet in the socket's group) keeps
|
||||||
|
working during an upgrade, and still writes `display_current_state`,
|
||||||
|
`display_on_demand_state` and `plugin_runtime_snapshot` for the readers'
|
||||||
|
fallback. A request that comes through the on-demand mailbox while the
|
||||||
|
socket is up is logged once per writer: plugins that write
|
||||||
|
`display_on_demand_request` themselves (birdnet-go, mqtt-notifications,
|
||||||
|
on-air, pomodoro-timer) now get the screen within a second rather than a
|
||||||
|
quarter second, and need an in-process way in before the mailbox goes.
|
||||||
|
|
||||||
|
### Display loop stage 3: a ScreenRunner, and the Arbiter decides every screen
|
||||||
|
|
||||||
|
Internal; no behaviour change. Stage 3 of `docs/RUN_LOOP_REDESIGN.md`.
|
||||||
|
|
||||||
|
- Each screen runs in `ScreenRunner` (`src/screen_runner.py`): the first
|
||||||
|
frame, the 125 Hz or 1 Hz frame loop, the make-up dwell and the
|
||||||
|
dynamic-duration exit, moved out of `DisplayController.run()` with their
|
||||||
|
pacing unchanged. It paces with an injected clock and returns an
|
||||||
|
`Outcome` whose `ExitReason` is `DURATION`, `CYCLE_COMPLETE`, `EMPTY`,
|
||||||
|
`ERROR`, `DISPLAY_FALSE`, `RELOAD` or `PREEMPTED`. `PREEMPTED` replaces
|
||||||
|
the five "did the mode change under this screen?" re-checks.
|
||||||
|
- `Arbiter.decide()` now answers for on-demand, live priority and the
|
||||||
|
rotation too (Sources `ON_DEMAND`, `LIVE`, `ROTATION`); `LEGACY` means
|
||||||
|
only Vegas, whose iteration moves to stage 4. The on-demand session, the
|
||||||
|
rotation's position and the live resume point are snapshotted into
|
||||||
|
`ArbiterState`, whose pure transitions (`next_on_demand`, `claim_live`,
|
||||||
|
`release_live`, `after`) replace the bookkeeping in `_resolve_active_mode`,
|
||||||
|
`_apply_live_priority` and `_advance_after_screen`.
|
||||||
|
- Between frames, the runner's service points make one
|
||||||
|
`decide(..., running=plan)` call instead of `_check_live_takeover`,
|
||||||
|
`_screen_preempted` and `_wifi_notice_pending` one after another. The
|
||||||
|
WiFi notice file is still read exactly where it was (the read is
|
||||||
|
throttled and deletes an expired file).
|
||||||
|
- A Vegas pass scans the live-priority plugins once instead of twice at the
|
||||||
|
same instant.
|
||||||
|
- The golden traces are byte-identical, and a capture of all 67 harness
|
||||||
|
runs in the suite (every sleep, frame, read and scan) matches `main`
|
||||||
|
apart from the duplicate scan above and one moment: in the 125 Hz loop a
|
||||||
|
live takeover's state change is made after the frame's 8 ms sleep rather
|
||||||
|
than before it, ending the screen at the same frame as before.
|
||||||
|
- New module: `src/screen_runner.py`. Core-internal: plugins have no reason
|
||||||
|
to import it, so it sets no `ledmatrix_min_version` floor.
|
||||||
|
|
||||||
|
### A scrolling screen held by its plugin's update() is reported
|
||||||
|
|
||||||
|
- While a plugin's `update()` runs it holds the plugin's lock, and that
|
||||||
|
plugin's frames are skipped: on a scroller, a frozen strip, with nothing
|
||||||
|
logged (and a freeze of 5 s or more is a gap, not a freeze, to the frame
|
||||||
|
stats). The high-FPS loop now times each run of skipped frames; one of
|
||||||
|
250 ms or more logs `Display of <plugin> held N ms by its update()`
|
||||||
|
(rate-limited per plugin) when it ends, and is recorded on the plugin's
|
||||||
|
health as a `display hold` busy skip, which never counts toward the
|
||||||
|
circuit breaker. The 1 Hz loop is left out: its frames are a second apart,
|
||||||
|
so one skipped frame there measures nothing and freezes nothing visible.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Unloading a plugin now forgets the fonts its manifest registered, not only
|
||||||
|
the fonts it reported using. Its `plugin_id::family` entries kept resolving
|
||||||
|
and their cached font objects stayed alive until a restart, and a family a
|
||||||
|
reinstalled plugin's manifest dropped stayed registered. The new
|
||||||
|
`FontManager.forget_plugin_fonts(plugin_id)` does the cleanup;
|
||||||
|
`PluginManager.unload_plugin()` and a failed load call it alongside
|
||||||
|
`forget_manager_fonts()`, and a reload registers the manifest's fonts again.
|
||||||
|
|
||||||
|
- The web preview and `/api/v3/display/current` no longer stay black for a
|
||||||
|
whole screen that draws its card once and then holds it. The snapshot is
|
||||||
|
written from `update_display()` at most once per write interval, so a frame
|
||||||
|
pushed inside that interval was skipped and left for the next
|
||||||
|
`update_display()` -- which such a screen never makes. Soccer's
|
||||||
|
recent/upcoming cards skip redundant redraws, and the first one after an
|
||||||
|
on-demand start lands a few milliseconds after the start's clear wrote a
|
||||||
|
black frame: on ledpi the preview showed 0 lit pixels for the whole 15 s
|
||||||
|
while the panel showed the card. `DisplayManager` now remembers a skipped
|
||||||
|
changed frame, and the render loop writes it (`write_owed_snapshot()`)
|
||||||
|
once the interval has passed. The cadence is unchanged, and nothing extra
|
||||||
|
runs when no frame is owed.
|
||||||
|
- The installed-plugins list (`GET /api/v3/plugins/installed`) no longer
|
||||||
|
waits on GitHub. Its comment said the registry lookup made no network call,
|
||||||
|
but on a cold or expired cache `get_registry_info()` downloads plugins.json
|
||||||
|
(10 s timeout, three attempts), and with nothing cached to fall back on
|
||||||
|
every plugin's lookup repeated that: offline, 5 plugins took 11 s with DNS
|
||||||
|
failing and 2 plugins 65 s with the route black-holed, on every load. The
|
||||||
|
list now reads the registry copy already in memory, however old
|
||||||
|
(`get_cached_registry_info()`); with none yet it returns without update or
|
||||||
|
verified badges and starts one background refresh
|
||||||
|
(`refresh_registry_in_background()`, backing off for a minute after an
|
||||||
|
offline failure), so a later load has them. The store, install and update
|
||||||
|
paths still fetch as before.
|
||||||
|
- A sports live manager's idle back-off now honours every pending kickoff,
|
||||||
|
not just the first. `_note_scheduled_start_candidate()` kept one kickoff
|
||||||
|
and, while it was inside its 15-minute grace, refused every later one; by
|
||||||
|
the time the grace ended the later one had passed and was refused again.
|
||||||
|
So of two favourites kicking off within 15 minutes of each other, the
|
||||||
|
second lost its own grace: if the first game was not live by then (a rain
|
||||||
|
delay, a postponement, ESPN slow to flip it) and ESPN had not flipped the
|
||||||
|
second either, the back-off went back to its ceiling and the second game
|
||||||
|
was noticed up to the ceiling (15 minutes by default) late. Later
|
||||||
|
kickoffs now wait in a short queue (`_later_scheduled_starts`, the
|
||||||
|
earliest 8) and each takes over with a grace of its own when the one
|
||||||
|
before it expires. A kickoff still
|
||||||
|
holds the live cadence for at most its own grace, so a postponed game
|
||||||
|
costs the same quarter of an hour as before.
|
||||||
|
|
||||||
|
### ESPN date-range fetches: fewer requests, fewer at once
|
||||||
|
|
||||||
|
A soccer board (8 leagues, ESPN rejecting `dates=` ranges) logged ~90
|
||||||
|
`NameResolutionError` lines and an `update() timed out` at every start on a
|
||||||
|
Pi: each league's fortnight-either-side window was 29 day requests, fetched
|
||||||
|
by several managers at once, ~40 in flight. Measured against live ESPN with
|
||||||
|
soccer-scoreboard 2.39.2, alternating runs: **~450 requests per start, peak
|
||||||
|
~45 in flight, ~75 DNS lookups -> 46 requests, peak 13, ~30 lookups**.
|
||||||
|
|
||||||
|
- `fetch_espn_date_chunks()` asks for a window's partial edge month whole
|
||||||
|
when the window covers `ESPN_MONTH_COVER_MIN_DAYS` (7) or more of its days,
|
||||||
|
and trims the answer to the window's days by each event's US Eastern start
|
||||||
|
date -- the day ESPN's `dates=YYYYMMDD` means (417 of 417 live soccer
|
||||||
|
events matched). A 29-day window spanning two months is 2 requests instead
|
||||||
|
of 29. Short windows (a live poll's 1-2 days) stay day by day. A trimmed
|
||||||
|
month that comes back at the 500-event cap re-asks only the window's days.
|
||||||
|
An event with no readable date is kept. New: `espn_request_chunks()`.
|
||||||
|
- Chunk requests share one process-wide cap of `ESPN_CHUNK_WORKERS` (6) in
|
||||||
|
flight, across every window being fetched, instead of six per window.
|
||||||
|
- A new process starts as if a range had just been rejected, so it no longer
|
||||||
|
spends one doomed 400 per window at every start (eleven at once from a
|
||||||
|
soccer board); the range is still retried `RANGE_RETRY_SECONDS` in.
|
||||||
|
|
||||||
|
### Fetch stats: bytes on the wire, not just decoded
|
||||||
|
|
||||||
|
`GET /api/v3/plugins/fetch-stats` reported only `bytes`, the decoded body
|
||||||
|
size, and that read as the download volume. ESPN gzips every scoreboard, so
|
||||||
|
it overstated what crossed the network about 14x: a college football
|
||||||
|
Saturday's scoreboard is 865 KB decoded and 63 KB on the wire, and ledpi's
|
||||||
|
"643 MB in 6 hours" of football was ~47 MB of actual traffic. Every counter
|
||||||
|
set (totals, per plugin, per host) now has `wire_bytes` too, read from
|
||||||
|
urllib3's count of the raw bytes it took off the socket. A response with no
|
||||||
|
urllib3 response behind it is counted at its decoded size. `bytes` keeps its
|
||||||
|
meaning.
|
||||||
|
|
||||||
### Cheap per-frame and per-fetch savings
|
### Cheap per-frame and per-fetch savings
|
||||||
|
|
||||||
- `BaseOddsManager.get_odds()` no longer pretty-prints every odds response
|
- `BaseOddsManager.get_odds()` no longer pretty-prints every odds response
|
||||||
@@ -457,6 +798,16 @@ policies are unchanged.
|
|||||||
- `src/display_arbiter.py` -- the display loop's Arbiter (see Tooling).
|
- `src/display_arbiter.py` -- the display loop's Arbiter (see Tooling).
|
||||||
Core-internal: plugins have no reason to import it, so it sets no
|
Core-internal: plugins have no reason to import it, so it sets no
|
||||||
`ledmatrix_min_version` floor.
|
`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
|
### Tooling
|
||||||
|
|
||||||
@@ -488,6 +839,71 @@ policies are unchanged.
|
|||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|
||||||
|
- A cache key too long to be a filename is now cached. The calendar
|
||||||
|
plugin's key joins every calendar id the user picked; on a real install
|
||||||
|
it passed 300 bytes, ext4 refuses names over 255, and every write failed
|
||||||
|
with `File name too long` — logged as "(permission denied)", so it read
|
||||||
|
like a cache-directory ownership problem. `DiskCache.get_cache_path` now
|
||||||
|
keeps a key of up to 200 UTF-8 bytes as its filename, as before, and
|
||||||
|
turns a longer one into its first bytes plus a hash of the whole key. The
|
||||||
|
web UI's cache list and delete keep working, because the shortened name
|
||||||
|
maps back to the same file. A failed write now names the real error.
|
||||||
|
- The cache's memory tier no longer serves data older than the reader asked
|
||||||
|
for. A record loaded from disk was timed in memory from the load, not
|
||||||
|
from when it was written, so `get(key, max_age=300)` could return data
|
||||||
|
close to 600 s old (after a restart, after the hourly memory sweep, or in
|
||||||
|
the other process, which only ever loads the record from disk), and a
|
||||||
|
stored `ttl` was stretched the same way. A memory hit is now also checked against
|
||||||
|
the record's own timestamp, and a stale one falls through to disk, which
|
||||||
|
returns a newer write if there is one.
|
||||||
|
- An on-demand request that names a `*_live` mode now shows that mode. On
|
||||||
|
ledpi, `{"plugin_id": "football-scoreboard", "mode": "ncaa_fb_live"}` with
|
||||||
|
15 college games on answered 200 and showed `nfl_recent`. The session's
|
||||||
|
mode list kept a live mode only when the plugin's `has_live_content()`
|
||||||
|
said so. That method answers the live-priority question, and the sports
|
||||||
|
plugins answer it for favourite teams only. A mode the request names
|
||||||
|
(not one resolved from a bare plugin id) now leads the session, with the
|
||||||
|
plugin's other modes after it. If it has nothing to draw, the session
|
||||||
|
moves on to the next of those modes, like any empty on-demand mode. The
|
||||||
|
name is saved with the session (`named_mode` in
|
||||||
|
`display_on_demand_config`), so a restart resumes on it.
|
||||||
|
- A restart during an on-demand session whose plugin then fails to load no
|
||||||
|
longer leaves a session with no modes. On ledpi, `clock-simple` failed
|
||||||
|
config validation after a crash. The display logged `No valid display
|
||||||
|
modes found for on-demand plugin 'clock-simple' after restoration` and
|
||||||
|
kept reporting the session as active until its first pass ended it as
|
||||||
|
`idle`. The cached request stayed behind for the next restart. The session
|
||||||
|
now ends at startup with status `error` and error `restore-failed`, which
|
||||||
|
`/display/on-demand/status` reports, and the cached request is dropped. The
|
||||||
|
same applies when the plugin system itself fails to start.
|
||||||
|
- `POST /api/v3/config/schedule` and `/config/dim-schedule` accept a
|
||||||
|
disabled per-day schedule with every day off. That is the shape
|
||||||
|
`config.template.json` ships, so posting back what GET returned on a fresh
|
||||||
|
install answered 400 "At least one day must be enabled". An enabled per-day
|
||||||
|
schedule still needs a day on. A day that is off now keeps the times it
|
||||||
|
was posted with (the schedule picker sends them). Before, saving dropped
|
||||||
|
them, so turning the day back on showed the defaults.
|
||||||
|
- `POST /api/v3/config/main` answers `restart_required: true` only when the
|
||||||
|
save changed a setting the running display does not apply by itself.
|
||||||
|
Brightness (`brightness.set` and the config watcher), the per-mode
|
||||||
|
durations and plugin sections are applied live. A brightness-only save,
|
||||||
|
such as the MQTT bridge's slider, or a save that changed nothing, no longer
|
||||||
|
shows the restart banner. Hardware, rotation order, timezone and every
|
||||||
|
other setting still ask for the restart.
|
||||||
|
- `GET /api/v3/health` reports `degraded` when the display service is
|
||||||
|
stopped. Before, only the sub-checks changed, and the overall status stayed
|
||||||
|
`healthy` for as long as the last preview frame was under 60 s old.
|
||||||
|
`checks.display_loop.status` is now `stopped` when three things agree:
|
||||||
|
systemd says the service is not active, the control socket does not
|
||||||
|
answer, and there is no live heartbeat. Where the platform has no socket
|
||||||
|
(Windows) or it is switched off, nothing changes.
|
||||||
|
- `GET /api/v3/display/current-status` no longer reports the stopped
|
||||||
|
display's last state (`is_display_active: true`) from the cache for up to
|
||||||
|
120 s. When the control socket does not answer and the render loop's
|
||||||
|
heartbeat is absent, stale, or from a process that is gone (#726's rules),
|
||||||
|
the answer is unknown, with every field `null`. A display that still beats
|
||||||
|
without a socket, Windows and a socket switched off read the cache as
|
||||||
|
before. New `web_interface.display_state.display_gone()`.
|
||||||
- The garbage-collection timer (`GcMonitor`, above) no longer prints
|
- The garbage-collection timer (`GcMonitor`, above) no longer prints
|
||||||
`Exception ignored while calling GC callback ... 'NoneType' object has no
|
`Exception ignored while calling GC callback ... 'NoneType' object has no
|
||||||
attribute 'perf_counter'` when the display service or a test run exits.
|
attribute 'perf_counter'` when the display service or a test run exits.
|
||||||
@@ -498,6 +914,16 @@ policies are unchanged.
|
|||||||
`DisplayManager.cleanup()` (reached from SIGTERM through `run()`'s
|
`DisplayManager.cleanup()` (reached from SIGTERM through `run()`'s
|
||||||
`finally`) unregisters it with the frame recorder. New
|
`finally`) unregisters it with the frame recorder. New
|
||||||
`frame_timing.uninstall_gc_monitor()`.
|
`frame_timing.uninstall_gc_monitor()`.
|
||||||
|
- The web interface's state subscription (`StateSubscription`,
|
||||||
|
`src/ipc/client.py`) resubscribes about 1 s after a display restart, every
|
||||||
|
time. Its reconnect wait went back to the minimum only when the
|
||||||
|
subscription was stopped. A disconnect after a working connection kept
|
||||||
|
doubling the wait, so successive display restarts were followed by waits
|
||||||
|
of 1, 2, 4, 8, 16 and then 30 s for good.
|
||||||
|
During each wait the web answered from one-shot `state.get` connections
|
||||||
|
instead of its copy. The wait now resets once a connection has stored a
|
||||||
|
snapshot. A display that does not offer the stream is still retried
|
||||||
|
slowly.
|
||||||
- A plugin reload after a store update (`plugin.reload`, #720) no longer
|
- A plugin reload after a store update (`plugin.reload`, #720) no longer
|
||||||
freezes the panel during Vegas. On ledpi a football reload froze it for
|
freezes the panel during Vegas. On ledpi a football reload froze it for
|
||||||
3.0 s (`Render stall over: no frame for 3043ms`). The reload ran on the
|
3.0 s (`Render stall over: no frame for 3043ms`). The reload ran on the
|
||||||
@@ -514,6 +940,25 @@ policies are unchanged.
|
|||||||
for the plugin is refused (`plugin-reloading`), and a config reconcile
|
for the plugin is refused (`plugin-reloading`), and a config reconcile
|
||||||
neither loads it twice nor unloads it mid-load. A Vegas fetch that waited
|
neither loads it twice nor unloads it mid-load. A Vegas fetch that waited
|
||||||
out a reload for the lock skips the old instance.
|
out a reload for the lock skips the old instance.
|
||||||
|
- A plugin display duration that is not a number no longer stops the
|
||||||
|
display. Several plugins (clock-simple, calendar, countdown) return their
|
||||||
|
`display_duration` setting as it is in config.json, so a value saved as
|
||||||
|
`"20"` or `null` (the raw config editor, a hand edit) reached the run loop
|
||||||
|
as a string or None. Comparing it with 0 raised a TypeError that no
|
||||||
|
handler in the loop caught: the display service exited when that plugin's
|
||||||
|
screen came up, and systemd restarted it into the same crash. The
|
||||||
|
controller now reads the plugin's answer as a number: a numeric string
|
||||||
|
counts, and anything else (or a `get_display_duration()` that raises)
|
||||||
|
shows the mode for 30 s, with one warning per plugin.
|
||||||
|
- A scroll strip narrower than the panel scrolls instead of raising on every
|
||||||
|
frame. When a frame ran off the end of the strip, `ScrollHelper` copied
|
||||||
|
the strip's tail and then the rest of the frame from its head, which
|
||||||
|
assumed the head was that wide; for a narrower strip that raised
|
||||||
|
`ValueError: could not broadcast` at every position, so nothing was drawn
|
||||||
|
and each frame logged a traceback. Vegas builds such a strip, with no
|
||||||
|
lead-in, when its content is narrower than the chain. A frame that runs
|
||||||
|
off the strip now continues from its head column by column, so a narrow
|
||||||
|
strip repeats across the panel; a wide strip wraps exactly as before.
|
||||||
- The schedule-off blank and the WiFi notice no longer start with a
|
- The schedule-off blank and the WiFi notice no longer start with a
|
||||||
scroller's leftovers. Both are drawn by the display controller rather than
|
scroller's leftovers. Both are drawn by the display controller rather than
|
||||||
dispatched to a plugin, so #716's handover never reached them: drawn while
|
dispatched to a plugin, so #716's handover never reached them: drawn while
|
||||||
@@ -523,6 +968,46 @@ policies are unchanged.
|
|||||||
scroller or Vegas) were counted as 0.5-1 s freezes and logged as a
|
scroller or Vegas) were counted as 0.5-1 s freezes and logged as a
|
||||||
`Render stall ... mid-scroll`. The controller now ends the scroll state
|
`Render stall ... mid-scroll`. The controller now ends the scroll state
|
||||||
before drawing either.
|
before drawing either.
|
||||||
|
- A plugin that keeps helpers in a package (elections' `providers/`,
|
||||||
|
flights' `enrichment/`, olympics' `data/` and `renderers/`) now runs its
|
||||||
|
updated helpers after a reload. Unloading dropped the package itself but
|
||||||
|
left its modules (`providers.feed`) in `sys.modules`, so the reload after a
|
||||||
|
store update imported the new `manager.py` and got the old helpers back from
|
||||||
|
the cache until the display restarted. `PluginLoader` now drops a plugin's
|
||||||
|
package modules when it unloads, and when a load fails part-way.
|
||||||
|
- Uninstalling a dev plugin that `scripts/dev/dev_plugin_setup.sh` linked
|
||||||
|
into the plugins directory now removes the link and leaves the checkout
|
||||||
|
alone. The store's removal passed the link to `shutil.rmtree`, which
|
||||||
|
refuses a symlink; its fallback then walked through the link and chmodded
|
||||||
|
every directory and file of the linked checkout to 0700, and the sudo stage
|
||||||
|
refused a path outside the plugins directory, so the uninstall failed with
|
||||||
|
the link still in place. The same removal discards the set-aside copy after
|
||||||
|
an install or update. A symlink, dangling or not, is now unlinked.
|
||||||
|
- A dev plugin linked in under a name its checkout does not share now loads.
|
||||||
|
`dev_plugin_setup.sh link-github foo <url>` clones `ledmatrix-foo` (the
|
||||||
|
repository naming convention) and links it as `plugins/foo`. The loader's
|
||||||
|
containment check for dependency installs resolved the link and looked for
|
||||||
|
`ledmatrix-foo` among the plugins directory's entries, found none, and
|
||||||
|
refused the plugin, so the load failed with "Dependency installation
|
||||||
|
failed" even when it had no `requirements.txt`. The check now looks for the
|
||||||
|
entry the path itself names in the plugins directory, the link, and still
|
||||||
|
only ever answers with an entry it found there.
|
||||||
|
- A plugin whose `update()` raises `asyncio.CancelledError` or `SystemExit`
|
||||||
|
no longer goes dark until a restart. Both derive from `BaseException`, not
|
||||||
|
`Exception`, and the update worker's bookkeeping caught only `Exception`:
|
||||||
|
the plugin kept its lock and stayed RUNNING, so it was never updated again
|
||||||
|
and every `display()` was skipped as busy. It is now recorded as that
|
||||||
|
update's failure, the same as any other raise. The plugin executor
|
||||||
|
reported such a call as a timeout; it now reports it as a failure.
|
||||||
|
- Saving a config change no longer freezes the panel while a plugin is busy.
|
||||||
|
`ConfigService` told its subscribers about a change while holding its lock,
|
||||||
|
and the display's per-plugin subscriber waits up to 5 s for a plugin in the
|
||||||
|
middle of an update. A save that enables or disables a plugin also queues a
|
||||||
|
reconcile, which the render thread runs, and its `get_config()` and
|
||||||
|
`unsubscribe()` waited behind every one of those callbacks. Subscribers now
|
||||||
|
run after the lock is released. One reload's notifications still finish
|
||||||
|
before the next one's start, and a callback `unsubscribe()` removed is not
|
||||||
|
running, and will not run, once it returns.
|
||||||
- A plugin whose `display()` raises now opens its circuit breaker. The first
|
- A plugin whose `display()` raises now opens its circuit breaker. The first
|
||||||
frame of each screen goes through the plugin executor, which caught the
|
frame of each screen goes through the plugin executor, which caught the
|
||||||
exception and returned False. The display read that as "no content" and
|
exception and returned False. The display read that as "no content" and
|
||||||
@@ -532,6 +1017,57 @@ policies are unchanged.
|
|||||||
the plugin leaves rotation until the cooldown ends, the same as a raising
|
the plugin leaves rotation until the cooldown ends, the same as a raising
|
||||||
`update()`. The display still moves straight on to the next mode. A hung
|
`update()`. The display still moves straight on to the next mode. A hung
|
||||||
`display()` is still recorded once, as a hang.
|
`display()` is still recorded once, as a hang.
|
||||||
|
- A plugin settings save that failed validation no longer leaks into the next
|
||||||
|
save. `ConfigManager.load_config()` returned its cached config itself (the
|
||||||
|
fast path from #410), so the form save's edits went into the cache before
|
||||||
|
validation ran, and a refused save left them there. The next save of any
|
||||||
|
other setting (another plugin's, a plugin toggle, the schedule) wrote them
|
||||||
|
to config.json: the refused value, and a nested secret typed into the same
|
||||||
|
form (`mqtt.password`, `league.espn_s2`, `flightaware.api_key`) in plain
|
||||||
|
text, because it had never reached config_secrets.json to be stripped.
|
||||||
|
The form also reloaded showing the refused values. `load_config()` now
|
||||||
|
returns a private copy, and the saves keep one, so nothing a caller edits
|
||||||
|
reaches the cache unless it is saved. The copy duplicates only the dicts
|
||||||
|
and lists (every other JSON value is immutable): 2.1 ms for a real 60 KiB
|
||||||
|
config on a Pi 4, against 6.8 ms for `copy.deepcopy`.
|
||||||
|
- `GET /api/v3/plugins/config` no longer returns secrets. It sent back the
|
||||||
|
plugin's section with config_secrets.json merged in, API keys and tokens
|
||||||
|
in plain text: the masking #276 added was dropped in #330. It also took
|
||||||
|
any id, so `?plugin_id=web_auth` returned the login's cookie-signing key
|
||||||
|
and password hash and `?plugin_id=github` the Plugin Store token. Secret
|
||||||
|
fields now come back blank, as the settings page renders them, and a
|
||||||
|
plugin with no schema has its credential-named fields blanked, as
|
||||||
|
`GET /config/main` does. Blank rather than the `••••••••` of
|
||||||
|
`GET /config/secrets`, because the save reads a blank secret as
|
||||||
|
"unchanged", so a client can post the response back without erasing
|
||||||
|
one. Core sections and malformed ids get a 400, as they already did from
|
||||||
|
reset and uninstall.
|
||||||
|
- Plugin settings with a table (a list of rows, such as geochron's cities
|
||||||
|
or the countdowns) save again when a text cell is blank or holds only
|
||||||
|
digits. A row posts its cells as `cities.0.timezone`, and the schema
|
||||||
|
lookup stopped at the list, so each cell was parsed with no schema: a
|
||||||
|
blank optional text cell became null, and a name like "2027" became a
|
||||||
|
number. Either failed validation, and every save of the page failed for
|
||||||
|
as long as the row existed. A plugin with a secret in its rows could not
|
||||||
|
be saved from the page at all, since the secret cell is drawn blank. The
|
||||||
|
lookup now steps from the index into the list's item schema.
|
||||||
|
- A plugin whose API key is required and has no default (youtube-stats)
|
||||||
|
can be saved from its settings page without typing the key in again. The
|
||||||
|
page draws a stored secret blank and posts the blank back; for a required
|
||||||
|
secret the save read that blank as null, failed validation, and refused
|
||||||
|
every save of the page. A blank secret field now means "unchanged", as it
|
||||||
|
already did for an optional one.
|
||||||
|
- `POST /api/v3/plugins/config` refuses a core section or a malformed
|
||||||
|
plugin id with a 400, as reset and uninstall already did.
|
||||||
|
`{"plugin_id": "display", ...}` merged unvalidated values into the core
|
||||||
|
display section (and added `"enabled": true` to it), and an id that was
|
||||||
|
not a string answered with a 500.
|
||||||
|
- A plugin text setting saves what was typed when that looks like a
|
||||||
|
boolean or JSON. The form save tried `true`/`false` and `[...]`/`{...}`
|
||||||
|
before it looked at the schema, so a text field holding "true", "False",
|
||||||
|
"[1, 2]" or "{}" was stored as a boolean, list or object, and the save
|
||||||
|
failed validation. Text fields, nullable ones included, are now taken as
|
||||||
|
typed; other types convert as before.
|
||||||
- A WiFi notice (such as "Connected to HomeNet" or "AP mode on") now shows
|
- A WiFi notice (such as "Connected to HomeNet" or "AP mode on") now shows
|
||||||
within about a second of being posted. It was only checked between
|
within about a second of being posted. It was only checked between
|
||||||
screens, so a 5 s notice posted during a 20 s screen expired before that
|
screens, so a 5 s notice posted during a 20 s screen expired before that
|
||||||
@@ -540,6 +1076,42 @@ policies are unchanged.
|
|||||||
notice is what shows next, and Vegas resumes after it; before, a rotation
|
notice is what shows next, and Vegas resumes after it; before, a rotation
|
||||||
screen showed instead and the notice expired behind it. An active
|
screen showed instead and the notice expired behind it. An active
|
||||||
on-demand session still holds the panel until it ends.
|
on-demand session still holds the panel until it ends.
|
||||||
|
- The Config Editor tab no longer shows API keys and tokens in plain
|
||||||
|
text. Its `config_secrets.json` editor (`/partials/raw-json`) was filled
|
||||||
|
with the file as it is on disk, so while the web login is off (the
|
||||||
|
default) anyone who could reach the port could read every credential,
|
||||||
|
although `GET /api/v3/config/secrets` masks them. The editor now shows the
|
||||||
|
same masked values. Saving it unchanged changes nothing, because the save
|
||||||
|
drops the masks and merges onto the stored file; to change a secret,
|
||||||
|
replace its mask. A list of secrets still needs every entry's real value
|
||||||
|
to be changed. The `config.json` editor is unchanged: its save writes the
|
||||||
|
file as given, so a mask there would be stored.
|
||||||
|
- A disabled plugin keeps its place in the rotation order and its Vegas
|
||||||
|
exclusion when the Display or Rotation & Durations tab is saved. The order
|
||||||
|
lists show enabled plugins only and rewrite their hidden inputs from those
|
||||||
|
rows as soon as they are drawn, so any save of either tab stored the lists
|
||||||
|
without the disabled plugin. Once re-enabled, it came back at the end of
|
||||||
|
the rotation and scrolling in Vegas again. A disabled plugin's saved id
|
||||||
|
now stays in its saved place (`widgets/plugin-order-list.js`); the id of
|
||||||
|
a plugin that is no longer installed is still dropped.
|
||||||
|
- Restoring a backup with "Reinstall missing plugins" installs only the
|
||||||
|
plugins that are missing. Every plugin the backup listed was sent to the
|
||||||
|
store's install, which replaces an installed copy with a fresh download,
|
||||||
|
so a restore onto the same device re-downloaded all of them in one
|
||||||
|
request. A plugin installed from its own URL is not in the registry, so
|
||||||
|
its "reinstall" failed and the restore answered "Restore failed" while
|
||||||
|
the plugin sat there installed. An installed plugin, found by the store's
|
||||||
|
own lookup (registry aliases included), is now listed under Skipped as
|
||||||
|
`plugin:<id> (installed)`.
|
||||||
|
- `POST /api/v3/config/main` answers a JSON body that does not parse with
|
||||||
|
400 `Invalid JSON in request body`, as `/config/raw/main` does, and an
|
||||||
|
empty JSON body with 400 `No data provided`. Both were a 500
|
||||||
|
`CONFIG_SAVE_FAILED` suggesting file permissions and disk space, with a
|
||||||
|
traceback logged at ERROR: `get_json()` raised inside the handler's
|
||||||
|
catch-all.
|
||||||
|
- Fonts restored from a backup show up in the Fonts tab and the font
|
||||||
|
pickers straight away. The font catalog is cached for five minutes, and
|
||||||
|
upload and delete cleared it but a restore did not.
|
||||||
- A game that goes live now takes over the panel within about a second.
|
- A game that goes live now takes over the panel within about a second.
|
||||||
Live priority was only checked between screens, so a game that went live
|
Live priority was only checked between screens, so a game that went live
|
||||||
during a 30 s screen waited for that screen to end. The frame loops and the
|
during a 30 s screen waited for that screen to end. The frame loops and the
|
||||||
@@ -549,6 +1121,45 @@ policies are unchanged.
|
|||||||
screen showed first and the game came after it. Each check also asks each
|
screen showed first and the game came after it. Each check also asks each
|
||||||
plugin `has_live_content()` once, where a plugin registered under several
|
plugin `has_live_content()` once, where a plugin registered under several
|
||||||
modes used to be asked once per mode.
|
modes used to be asked once per mode.
|
||||||
|
- A plugin action whose params hold `true`, `false` or `null` runs again.
|
||||||
|
`/api/v3/plugins/action` wrote the params into the source of the wrapper
|
||||||
|
that runs the plugin's script, and those JSON words are not Python, so the
|
||||||
|
wrapper stopped with a NameError and the action answered "Action failed".
|
||||||
|
The plugin file manager's category toggle sends `"enabled": true`, so
|
||||||
|
turning a category on or off in of-the-day always failed. The params now
|
||||||
|
reach the wrapper on its stdin; the script still receives them as JSON on
|
||||||
|
its own stdin, as before.
|
||||||
|
- An on-demand request that `/api/v3/display/on-demand/start` refuses no
|
||||||
|
longer runs later. With the display stopped the request goes to the
|
||||||
|
display's mailbox, and the display reads that mailbox for an hour without
|
||||||
|
looking at a request's age. So with "Start display service" unticked, the
|
||||||
|
answer was "Display service is not running", yet the next time the
|
||||||
|
display was started it ran that plugin, pinned if the request said so.
|
||||||
|
The same happened after "Failed to start display service". On either
|
||||||
|
refusal the route now takes its request back out of the mailbox, unless a
|
||||||
|
newer one has replaced it. A request the display acknowledges over the
|
||||||
|
control socket is now a success whatever systemd reports: a display run
|
||||||
|
by hand or in the emulator was told "not running" for a request it had
|
||||||
|
already taken, and with "Start display service" ticked the route tried to
|
||||||
|
start the service beside it.
|
||||||
|
- `/api/v3/plugins/operation/<id>` reports a queued operation as `pending`
|
||||||
|
instead of answering 500. The queue keeps an operation's callback among
|
||||||
|
its parameters until it runs, and the status route tried to send that
|
||||||
|
function as JSON. An install queued behind another plugin's install
|
||||||
|
failed every status poll until the first one finished. Parameters whose
|
||||||
|
name starts with `_` are internal and are no longer in the answer.
|
||||||
|
- A second click on Install while that plugin is still installing, or an
|
||||||
|
Uninstall during its install, now answers 409 "already has an install,
|
||||||
|
update or uninstall in progress" instead of 500 "An error occurred". The
|
||||||
|
first operation carried on either way. The uninstall route also stopped
|
||||||
|
recording a failed uninstall in the operation history for an uninstall
|
||||||
|
that never started.
|
||||||
|
- `/api/v3/plugins/<plugin_id>/static/<path>` serves images and other
|
||||||
|
binary files. It opened every file as UTF-8 text, so a plugin's icon or
|
||||||
|
preview image answered 500 `UnicodeDecodeError`. Files are now sent as
|
||||||
|
they are on disk, an image with its own content type; HTML, JavaScript,
|
||||||
|
CSS, JSON and other text keep the types they had. The path checks are
|
||||||
|
unchanged.
|
||||||
- The display schedule turns the panel off at exactly the end time. A window
|
- The display schedule turns the panel off at exactly the end time. A window
|
||||||
now runs from its start time up to, but not including, its end time: with
|
now runs from its start time up to, but not including, its end time: with
|
||||||
07:00-23:00 the panel is on at 07:00 and off at 23:00. Before, the end
|
07:00-23:00 the panel is on at 07:00 and off at 23:00. Before, the end
|
||||||
@@ -556,10 +1167,77 @@ policies are unchanged.
|
|||||||
the panel went off at 23:00 or at 23:01 depending on when in the minute
|
the panel went off at 23:00 or at 23:01 depending on when in the minute
|
||||||
that check ran. Windows that cross midnight and per-day schedules follow
|
that check ran. Windows that cross midnight and per-day schedules follow
|
||||||
the same rule, and so does the dim schedule.
|
the same rule, and so does the dim schedule.
|
||||||
|
- The MQTT bridge settings on the Tools tab can save a broker password with
|
||||||
|
TLS off. The server refuses that unless `allow_insecure_mqtt` is set, and
|
||||||
|
the form had no way to set it, so a password-protected broker on a home
|
||||||
|
network without TLS could not be saved from the web UI, and once such a
|
||||||
|
password was stored every later save failed too. While "Use TLS" is
|
||||||
|
unchecked the form now shows "Allow without TLS (trusted network)",
|
||||||
|
prefilled from the saved settings. It is off until ticked, so the server
|
||||||
|
still refuses a cleartext password by default.
|
||||||
|
- The Overview's plugin-config warning check stops polling. It asked
|
||||||
|
`/api/v3/plugins/reconciliation-status` every 2 s until startup
|
||||||
|
reconciliation reported done, and the route reports not done whenever its
|
||||||
|
status file is missing: reconciliation raised before writing it, or /tmp
|
||||||
|
was cleaned under a long-running web service. The page then sent that
|
||||||
|
request every 2 s for as long as it stayed open, whichever tab was showing.
|
||||||
|
It now gives up after a minute and only polls while the Overview is on
|
||||||
|
screen.
|
||||||
|
- Moving the Brightness slider on the Display tab no longer throws an error
|
||||||
|
in the browser console on every step. Its handler also updated a "LED
|
||||||
|
brightness" line that was removed from the page in #387; the lookup is
|
||||||
|
gone.
|
||||||
|
- Creating an API token on the General tab no longer leaves the page asking
|
||||||
|
"Leave site?" on reload. The unsaved-changes guard marks a form when you
|
||||||
|
type in it and clears the mark only after an htmx save, and the token form
|
||||||
|
saves with a plain request, so it stayed marked after the token was
|
||||||
|
created. It is cleared once the token is saved.
|
||||||
- An on-demand session that ends during scheduled-off hours, by expiring or
|
- An on-demand session that ends during scheduled-off hours, by expiring or
|
||||||
being stopped, blanks the panel within about a second. It used to stay on
|
being stopped, blanks the panel within about a second. It used to stay on
|
||||||
until the next minute, because the once-a-minute schedule check had
|
until the next minute, because the once-a-minute schedule check had
|
||||||
already run that minute and the session had overridden its answer.
|
already run that minute and the session had overridden its answer.
|
||||||
|
- Check & Update All updates what is installed now. A second run in the
|
||||||
|
same page sent the plugins the first run had seen, so a plugin uninstalled
|
||||||
|
since then failed with "plugin not found" and one installed since was
|
||||||
|
skipped. After a run the installed cards and the Updates badge show the
|
||||||
|
new versions; they kept offering "Update to vX" for what had just been
|
||||||
|
updated until the page was reloaded.
|
||||||
|
- The Run On-Demand dialog lists a plugin's display modes, so a mode other
|
||||||
|
than the first can be started, and pinned. `/api/v3/plugins/installed`
|
||||||
|
never sent `display_modes`, which the dialog reads, so every plugin
|
||||||
|
offered only its own id under "This plugin exposes a single display
|
||||||
|
mode", and the display started its first mode. Each entry now carries
|
||||||
|
`display_modes`, the modes its manifest declares.
|
||||||
|
- Installing Weather, Music, Stocks or Leaderboard from the Plugin Store
|
||||||
|
enables it, as installing any other plugin does. Each installs under the
|
||||||
|
id its manifest declares (`ledmatrix-weather` for the store's `weather`),
|
||||||
|
but the store enabled the store id, which `/api/v3/plugins/toggle`
|
||||||
|
answered with "Plugin not found": the plugin stayed disabled behind
|
||||||
|
"installed, but enabling it failed". `POST /api/v3/plugins/install` now
|
||||||
|
answers with the installed `plugin_id` (in the operation's result when it
|
||||||
|
is queued), and the store enables that.
|
||||||
|
- Reinstalling a plugin from the Plugin Store leaves it enabled or disabled
|
||||||
|
as it was. Reinstall enabled it as a fresh install does, so a plugin the
|
||||||
|
user had switched off came back on.
|
||||||
|
- A Plugin Store install that takes more than a minute is no longer
|
||||||
|
reported as failed. The store stopped waiting after 60 s and showed
|
||||||
|
"Install operation timed out" while the server, which allows the
|
||||||
|
plugin's dependency install 300 s on its own, carried on and usually
|
||||||
|
succeeded; the plugin was then neither enabled nor listed until the page
|
||||||
|
was reloaded. The store now waits up to 10 minutes, and if it still has
|
||||||
|
no answer it reloads the installed list and says the install may still
|
||||||
|
be running.
|
||||||
|
- The Plugin Store's category filter lists every category its plugins
|
||||||
|
have. It offered a fixed seven while the registry uses about twenty, so
|
||||||
|
plugins filed under productivity, utility, transit and the rest could not
|
||||||
|
be filtered to, and "Financial" missed the plugin filed under "finance".
|
||||||
|
The choices are now built from the store's plugins, as the Starlark
|
||||||
|
section's are.
|
||||||
|
- The Install button under Install Single Plugin (Plugin Manager > Install
|
||||||
|
from GitHub) runs one handler per click. It also had an inline `onclick`
|
||||||
|
whose handler threw a `ReferenceError` on every click; only the other
|
||||||
|
handler's request went out, and making the inline one work would have
|
||||||
|
sent every install twice. The inline handler is gone.
|
||||||
- `/api/v3/plugins/installed` no longer reports the display's plugins as
|
- `/api/v3/plugins/installed` no longer reports the display's plugins as
|
||||||
`live` while `/api/v3/health` says `display_loop: stalled`. The runtime
|
`live` while `/api/v3/health` says `display_loop: stalled`. The runtime
|
||||||
snapshot is written from its own thread, which kept going while the render
|
snapshot is written from its own thread, which kept going while the render
|
||||||
@@ -571,11 +1249,41 @@ policies are unchanged.
|
|||||||
heartbeat when the service stops), is `stale` at once instead of `live`
|
heartbeat when the service stops), is `stale` at once instead of `live`
|
||||||
for up to 180 s. No new files or writes: both checks are on the reading
|
for up to 180 s. No new files or writes: both checks are on the reading
|
||||||
side.
|
side.
|
||||||
|
- A scoreboard's scroll and Vegas cards with `scroll_card.date_format:
|
||||||
|
"weekday"` now show the printed date's own weekday. A Friday 8 PM ET game
|
||||||
|
read "Sat Oct 2". The card took the weekday in the plugin's own
|
||||||
|
`timezone` setting, which ships blank, so it fell back to UTC, while the
|
||||||
|
"Oct 2" beside it came from the zone the plugin actually resolves (its
|
||||||
|
setting, then the global one, then the system zone). Every zone is within a
|
||||||
|
day of UTC, so the card now finds which day near the start's UTC date has
|
||||||
|
the printed month and day and names that one. Games east of UTC (Auckland,
|
||||||
|
Kiritimati) were off by a day the other way and are fixed the same way.
|
||||||
|
The switch-mode scorebug, which already used the plugin's resolved zone,
|
||||||
|
shares the same formatter and draws what it drew before.
|
||||||
- `/api/v3/display/current-status` reflects a wake from scheduled-off, a
|
- `/api/v3/display/current-status` reflects a wake from scheduled-off, a
|
||||||
schedule-off blank, or an on-demand session starting or ending at once,
|
schedule-off blank, or an on-demand session starting or ending at once,
|
||||||
even when the mode name stays the same. The display republished its
|
even when the mode name stays the same. The display republished its
|
||||||
current state only on a mode change or every 30 s, so `is_display_active`
|
current state only on a mode change or every 30 s, so `is_display_active`
|
||||||
and `on_demand_active` could be up to 30 s out of date.
|
and `on_demand_active` could be up to 30 s out of date.
|
||||||
|
- `/api/v3/display/current-status` no longer answers `mode: null` over the
|
||||||
|
control socket (#735) once the same mode has been on screen for more than
|
||||||
|
two minutes: a live game under live priority, Vegas, or a single plugin.
|
||||||
|
On ledpi it returned nulls in every sample for 90 minutes while the
|
||||||
|
display was live. The state stream's version leaves out the timestamps
|
||||||
|
that move on every publish, and a subscriber's keepalive tick carried only
|
||||||
|
the loop's heartbeat. So the web interface's copy kept the
|
||||||
|
`display.last_updated` of the last real change, and the reader's 120 s
|
||||||
|
rule called it unknown. The plugin runtime section had the same problem:
|
||||||
|
with no plugin changing state, `/plugins/state` and the `runtime` in
|
||||||
|
`/plugins/installed` read `stale` after 180 s. A tick (and a `state.get`
|
||||||
|
answer with `since`) now carries `volatile`: the current values of those
|
||||||
|
timestamps (`display.last_updated`, `on_demand.last_updated` and
|
||||||
|
`remaining`, `plugins.published_at`), and the subscription merges them
|
||||||
|
into its copy. The verdicts are unchanged. A render thread that stops
|
||||||
|
publishing still reads as `stalled` after 60 s and as unknown after 120 s,
|
||||||
|
a runtime publisher that stops still goes `stale`, and a subscription that
|
||||||
|
goes quiet still falls back to the cache. The cache path's 120 s rule is
|
||||||
|
unchanged.
|
||||||
|
|
||||||
### Scrolling
|
### Scrolling
|
||||||
|
|
||||||
@@ -753,18 +1461,6 @@ guard the import, since the loader's version check is advisory).
|
|||||||
processes, or turns the socket off with `off`. A non-root dev run uses a
|
processes, or turns the socket off with `off`. A non-root dev run uses a
|
||||||
private per-user path under the temp directory.
|
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
|
### Update channels
|
||||||
|
|
||||||
- Devices no longer pick up every merge to `main`. A new setting,
|
- Devices no longer pick up every merge to `main`. A new setting,
|
||||||
@@ -1022,6 +1718,17 @@ read any of them:
|
|||||||
|
|
||||||
### Fixes
|
### 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
|
- Quieter routine logging. Every rotation logged each mode twice
|
||||||
("Switching to mode", then "Processing mode"), and a mode with nothing to
|
("Switching to mode", then "Processing mode"), and a mode with nothing to
|
||||||
show added "display() returned False" and "No content to display". Those
|
show added "display() returned False" and "No content to display". Those
|
||||||
|
|||||||
@@ -649,7 +649,9 @@ When nothing is running on demand, `data.state` is
|
|||||||
> on-demand machinery is internal — drive it through the REST endpoints
|
> on-demand machinery is internal — drive it through the REST endpoints
|
||||||
> above (or the web UI buttons). The API handlers
|
> above (or the web UI buttons). The API handlers
|
||||||
> (`start_on_demand_display()` / `stop_on_demand_display()` in
|
> (`start_on_demand_display()` / `stop_on_demand_display()` in
|
||||||
> `web_interface/blueprints/api_v3/display.py`) write a request into the cache
|
> `web_interface/blueprints/api_v3/display.py`) send the request over the
|
||||||
|
> display's control socket ([IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md)).
|
||||||
|
> Only when the socket cannot carry it do they write it into the cache
|
||||||
> manager under the `display_on_demand_request` key, which
|
> manager under the `display_on_demand_request` key, which
|
||||||
> `DisplayController._poll_on_demand_requests()`
|
> `DisplayController._poll_on_demand_requests()`
|
||||||
> (`src/display_controller.py`) picks up. A separate
|
> (`src/display_controller.py`) picks up. A separate
|
||||||
@@ -747,8 +749,14 @@ keys helps troubleshoot stuck states.
|
|||||||
"timestamp": 1234567890.123
|
"timestamp": 1234567890.123
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
**Purpose:** Communication from web interface to display controller
|
**Purpose:** Communication from web interface to display controller, as the
|
||||||
**When Set:** API endpoint receives request
|
fallback when the control socket cannot carry the request (deprecated; it
|
||||||
|
will be removed in a later release)
|
||||||
|
**When Set:** API endpoint receives a request and the display's control
|
||||||
|
socket is unavailable (display stopped, or older than the socket or the
|
||||||
|
command); some plugins also write it directly
|
||||||
|
**Read:** once a second while the display serves the control socket (0.25 s
|
||||||
|
without it), and only when the file changed since the last look
|
||||||
**Auto-Cleared:** After processing or 1 hour TTL
|
**Auto-Cleared:** After processing or 1 hour TTL
|
||||||
|
|
||||||
**2. display_on_demand_config** (No TTL)
|
**2. display_on_demand_config** (No TTL)
|
||||||
|
|||||||
+18
-8
@@ -42,11 +42,11 @@ each other. They share three things:
|
|||||||
| State | Where | Written by | Read by |
|
| 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 command | control socket `/run/ledmatrix/control.sock` ([IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md)) | web: `start_on_demand_display()` / `stop_on_demand_display()` in [`api_v3/display.py`](../web_interface/blueprints/api_v3/display.py), via [`src/ipc/client.py`](../src/ipc/client.py) | display: [`src/ipc/server.py`](../src/ipc/server.py) acks; the render thread applies it in `_poll_on_demand_requests()` |
|
||||||
| On-demand request (fallback) | cache `display_on_demand_request` | web, when the socket fails; four plugins write it directly | display: `_poll_on_demand_requests()` |
|
| On-demand request (fallback) | cache `display_on_demand_request` | web, only when the socket could not carry the request (`should_fall_back`); four plugins write it directly | display: `_poll_on_demand_requests()`, a `stat()` every 1 s while the socket is up (0.25 s without), read only when the file changed |
|
||||||
| On-demand state | cache `display_on_demand_state` | display: `_publish_on_demand_state()` | web: `/api/v3/display/on-demand/status` |
|
| 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` |
|
| 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/*` |
|
| Plugin errors | cache `plugin_error_snapshot` | display: `ErrorSnapshotPublisher` ([`src/error_aggregator.py`](../src/error_aggregator.py)) | web: `read_error_report()` for `/api/v3/errors/*` |
|
||||||
| Error clear | cache `plugin_error_clear_request` | web | display |
|
| Error clear | control socket `errors.clear`; cache `plugin_error_clear_request` as the fallback | web: `POST /api/v3/errors/clear` | display: applied before the socket answers; the mailbox on the error publisher's 5 s tick, read only when the file changed |
|
||||||
| Font usage | cache `font_usage_snapshot` | display: `FontUsagePublisher` ([`src/font_usage.py`](../src/font_usage.py)) | web: Fonts tab |
|
| 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` |
|
| 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` |
|
| Plugin health | cache `plugin_health:<id>` | display (web writes on reset) | web: `/api/v3/plugins/health` |
|
||||||
@@ -58,11 +58,16 @@ each other. They share three things:
|
|||||||
|
|
||||||
The on-demand start route starts `ledmatrix.service` when it is not running
|
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
|
(`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
|
send the command over the display's control socket and get an ack; only when
|
||||||
fails (a stopped display, one older than the socket) they write the mailbox
|
the socket could not carry it (a stopped display, one older than the socket
|
||||||
instead, which the display reads every `ON_DEMAND_POLL_INTERVAL` (0.25s), from
|
or the command) do they write the mailbox instead. A display that had the
|
||||||
its dwell sleep, its render loops and Vegas's interrupt check as well as the
|
request and refused it is answered with the error, not posted a mailbox
|
||||||
main loop. Both ways end in the same handler, `_handle_on_demand_request()`.
|
copy. The display looks at the mailbox every
|
||||||
|
`MAILBOX_POLL_INTERVAL_WITH_SOCKET` (1 s) while it serves the socket, and
|
||||||
|
every `ON_DEMAND_POLL_INTERVAL` (0.25 s) without one, from its dwell sleep,
|
||||||
|
its render loops and Vegas's interrupt check as well as the main loop; a
|
||||||
|
look is one `stat()` unless the file changed. Both ways end in the same
|
||||||
|
handler, `_handle_on_demand_request()`.
|
||||||
The socket's handlers only queue; see [IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md)
|
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, the permission model and the plan to retire the mailboxes.
|
||||||
|
|
||||||
@@ -138,7 +143,9 @@ loaded and when. Nothing else keeps plugin state:
|
|||||||
`DisplayController` right after it creates the `PluginManager`, writes the
|
`DisplayController` right after it creates the `PluginManager`, writes the
|
||||||
cache key `plugin_runtime_snapshot`: per plugin `loaded`, `state`, `error`
|
cache key `plugin_runtime_snapshot`: per plugin `loaded`, `state`, `error`
|
||||||
(type, a redacted message of at most 200 characters, when, recoverable),
|
(type, a redacted message of at most 200 characters, when, recoverable),
|
||||||
`version` and `loaded_at`, plus `published_at`, `stale_after` and `running`.
|
`version`, `loaded_at` and `modes` (the display modes `DisplayController`
|
||||||
|
registered -- `plugin.modes` when the plugin computes them, else the
|
||||||
|
manifest's), plus `published_at`, `stale_after` and `running`.
|
||||||
The cache is on disk, usually the SD card, so it writes when something a
|
The cache is on disk, usually the SD card, so it writes when something a
|
||||||
reader sees changes -- throttled to once per 10 s -- and otherwise once a
|
reader sees changes -- throttled to once per 10 s -- and otherwise once a
|
||||||
minute as a heartbeat. RUNNING, which every `update()` passes through, is
|
minute as a heartbeat. RUNNING, which every `update()` passes through, is
|
||||||
@@ -154,6 +161,9 @@ truth cannot leak into a response. `/api/v3/plugins/installed` returns
|
|||||||
`loaded`, `state`, `error_info`, `loaded_version` and `loaded_at` per
|
`loaded`, `state`, `error_info`, `loaded_version` and `loaded_at` per
|
||||||
plugin and `data.runtime` (`status`, `published_at`, `age_seconds`);
|
plugin and `data.runtime` (`status`, `published_at`, `age_seconds`);
|
||||||
`/api/v3/plugins/state` returns the same beside the desired state.
|
`/api/v3/plugins/state` returns the same beside the desired state.
|
||||||
|
`PluginCatalog.get_plugin_display_modes` and `find_plugin_for_mode` prefer a
|
||||||
|
live view's `modes` to the manifest's `display_modes`, so `/display/modes`
|
||||||
|
and on-demand see modes a plugin generates from its config (#668).
|
||||||
|
|
||||||
**Reconciliation**
|
**Reconciliation**
|
||||||
([`state_reconciliation.py`](../src/plugin_system/state_reconciliation.py))
|
([`state_reconciliation.py`](../src/plugin_system/state_reconciliation.py))
|
||||||
|
|||||||
@@ -203,6 +203,7 @@ Current methods:
|
|||||||
| `measure_text(text, font)` | `(width, height, baseline)` |
|
| `measure_text(text, font)` | `(width, height, baseline)` |
|
||||||
| `get_font_height(font)` | Line height |
|
| `get_font_height(font)` | Line height |
|
||||||
| `register_plugin_fonts(plugin_id, font_manifest)` | Register a plugin's fonts (core calls it at load) |
|
| `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 |
|
| `clear_cache()` | Drop cached fonts and metrics |
|
||||||
| `font_catalog` (attribute) | Family name → file path |
|
| `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()` | — |
|
| `get_performance_stats()` | — |
|
||||||
| `set_override()`, `remove_override()`, `get_overrides()` | a font field in your plugin's config schema |
|
| `set_override()`, `remove_override()`, `get_overrides()` | a font field in your plugin's config schema |
|
||||||
| `get_manager_fonts()`, `get_detected_fonts()` | — |
|
| `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 |
|
| `add_font()`, `remove_font()`, `validate_font()` | the web UI's Fonts tab |
|
||||||
|
|||||||
+146
-29
@@ -7,14 +7,18 @@ status. Stage 2 makes those commands land within a frame on every kind of
|
|||||||
screen, and adds `brightness.set` and `plugin.reload`. Stage 3 adds a state
|
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
|
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
|
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
|
wrote to the SD card. Stage 4 makes the socket the only way a command goes
|
||||||
for one release.
|
while it works: the web interface writes a mailbox only when the socket
|
||||||
|
cannot carry the request, `errors.clear` replaces the last command that
|
||||||
|
always went through a mailbox, and the display looks at the mailboxes once
|
||||||
|
a second, with a `stat()`. The file mailboxes and the cache keys stay as a
|
||||||
|
fallback for one release.
|
||||||
|
|
||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Socket | `/run/ledmatrix/control.sock` (tmpfs) |
|
| Socket | `/run/ledmatrix/control.sock` (tmpfs) |
|
||||||
| Served by | the display process ([`src/ipc/server.py`](../src/ipc/server.py)), started by `DisplayController.run()` |
|
| 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 |
|
| 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 |
|
| 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) |
|
| `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.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 |
|
| `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 `""`
|
`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
|
mean "until stopped". `pinned` must be a real boolean: the REST route has
|
||||||
@@ -146,7 +161,7 @@ connection until either side hangs up:
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{"v": 1, "id": "<the subscribe id>", "event": "state", "result": {...a state snapshot...}}
|
{"v": 1, "id": "<the subscribe id>", "event": "state", "result": {...a state snapshot...}}
|
||||||
{"v": 1, "id": "<the subscribe id>", "event": "tick", "result": {"version": 7, "epoch": "…", "pid": 812, "served_at": 1790000000.1, "changed": false, "loop": {...}}}
|
{"v": 1, "id": "<the subscribe id>", "event": "tick", "result": {"version": 7, "epoch": "…", "pid": 812, "served_at": 1790000000.1, "changed": false, "loop": {...}, "volatile": {"display": {"last_updated": 1790000000.0}, "...": "..."}}}
|
||||||
```
|
```
|
||||||
|
|
||||||
An event has `event` where a response has `ok`, which is how a reader tells
|
An event has `event` where a response has `ok`, which is how a reader tells
|
||||||
@@ -216,8 +231,14 @@ socket.
|
|||||||
counts within an `epoch`, one run of the display process, so a reader that
|
counts within an `epoch`, one run of the display process, so a reader that
|
||||||
sees a new `epoch` has a restarted display.
|
sees a new `epoch` has a restarted display.
|
||||||
- `state.get` with `since` and `epoch` from an earlier answer gets just
|
- `state.get` with `since` and `epoch` from an earlier answer gets just
|
||||||
`{changed: false, version, epoch, pid, served_at, loop}` while nothing has
|
`{changed: false, version, epoch, pid, served_at, loop, volatile}` while
|
||||||
changed.
|
nothing has changed. `volatile` is `{section: {key: value}}`: the current
|
||||||
|
values of those ignored timestamps, which the reader merges into the copy
|
||||||
|
it has. They don't make a new version, but they are still news:
|
||||||
|
`display.last_updated` is how a reader knows the render thread is still
|
||||||
|
publishing, and `plugins.published_at` the runtime publisher. Without
|
||||||
|
them a reader's copy kept the timestamps of the last real change, so a
|
||||||
|
mode on screen for over 120 s read as unknown.
|
||||||
- A snapshot that would not fit in a message (hundreds of plugins) is sent
|
- A snapshot that would not fit in a message (hundreds of plugins) is sent
|
||||||
without `plugins`, and `truncated: ["plugins"]` says so. Readers then use
|
without `plugins`, and `truncated: ["plugins"]` says so. Readers then use
|
||||||
the cache for that section only.
|
the cache for that section only.
|
||||||
@@ -226,8 +247,10 @@ socket.
|
|||||||
|
|
||||||
- a `state` event (a full snapshot) whenever the version changes, and
|
- a `state` event (a full snapshot) whenever the version changes, and
|
||||||
- a `tick` at least every 5 s (`SUBSCRIBE_KEEPALIVE_SECONDS`) when nothing
|
- a `tick` at least every 5 s (`SUBSCRIBE_KEEPALIVE_SECONDS`) when nothing
|
||||||
changed. It carries `loop`, so a stalled render loop shows up within one
|
changed. It is the short `changed: false` answer, so it carries `loop`
|
||||||
tick, and it tells the reader the connection is alive.
|
(a stalled render loop shows up within one tick) and `volatile` (the
|
||||||
|
timestamps stay as fresh as the writers keep them), and it tells the
|
||||||
|
reader the connection is alive.
|
||||||
|
|
||||||
A slow reader is never sent a backlog: each event is the latest version, so
|
A slow reader is never sent a backlog: each event is the latest version, so
|
||||||
one that falls behind skips the versions in between. A reader that has heard
|
one that falls behind skips the versions in between. A reader that has heard
|
||||||
@@ -359,7 +382,9 @@ place it reads the mailbox:
|
|||||||
Vegas iteration is on the stack (`_apply_pending_plugin_reloads`). Until
|
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
|
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
|
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
|
- Only the quick half of the reload runs on the render thread
|
||||||
(`_start_plugin_reload`): the plugin's modes leave the rotation, its
|
(`_start_plugin_reload`): the plugin's modes leave the rotation, its
|
||||||
config subscription is dropped, and `PluginManager.detach_plugin` takes
|
config subscription is dropped, and `PluginManager.detach_plugin` takes
|
||||||
@@ -382,7 +407,7 @@ place it reads the mailbox:
|
|||||||
unloads it mid-load; a disable saved meanwhile is applied once the
|
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.
|
reload is done. A second reload of the same plugin runs after the first.
|
||||||
|
|
||||||
The 0.25 s floor on the mailbox read does not apply to the queue, because
|
The floor on the mailbox read (0.25 s, 1 s since stage 4) does not apply to the queue, because
|
||||||
draining it costs no disk read. A queued command also lets
|
draining it costs no disk read. A queued command also lets
|
||||||
`_service_pending_changes()` skip its own floor.
|
`_service_pending_changes()` skip its own floor.
|
||||||
|
|
||||||
@@ -410,7 +435,7 @@ Now the queue wakes the render thread:
|
|||||||
|
|
||||||
So a command lands within a millisecond or so on a static screen and in a
|
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
|
dwell, and within one frame in Vegas and on a scrolling screen. The mailbox
|
||||||
keeps its old delays. Commands still run only on the render thread: the
|
is slower on purpose (see "The mailboxes now"). Commands still run only on the render thread: the
|
||||||
connection threads only queue them and set the event. The one exception is
|
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),
|
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
|
which runs on its own thread. Every change to the display's state still
|
||||||
@@ -428,11 +453,74 @@ bookkeeping. A client's send to the render thread waking took 0.72 ms median
|
|||||||
Without a socket (Windows, `LEDMATRIX_CONTROL_SOCKET=off`) the waits are the
|
Without a socket (Windows, `LEDMATRIX_CONTROL_SOCKET=off`) the waits are the
|
||||||
plain sleeps they were.
|
plain sleeps they were.
|
||||||
|
|
||||||
**Exactly once.** A command and a mailbox write for the same request share
|
**Exactly once.** Since stage 4 the web interface writes the mailbox only
|
||||||
one `request_id`. If the client times out after the display queued the
|
when the display never had the request (see "When the web interface falls
|
||||||
command and then also writes the mailbox, the display processes the request
|
back"), so a request goes one way or the other, never both. A command and a
|
||||||
once. The existing `on_demand_request_id` and processed-id checks drop the
|
mailbox write for the same request still share one `request_id`, and the
|
||||||
second copy.
|
`on_demand_request_id` and processed-id checks still drop a second copy: an
|
||||||
|
older web interface (before stage 4) wrote the mailbox after a reply timed
|
||||||
|
out, too. The display takes such a copy out of the mailbox when it drops it.
|
||||||
|
|
||||||
|
## When the web interface falls back (stage 4)
|
||||||
|
|
||||||
|
The client tells a request the display never had from one it had and then
|
||||||
|
failed. `ControlError.sent` is True once the whole request was written to a
|
||||||
|
connected display; a refusal the display sends before reading anything
|
||||||
|
(`forbidden`, too many connections) carries no request id, and leaves it
|
||||||
|
False. `src.ipc.client.should_fall_back()` is the one rule every route uses:
|
||||||
|
|
||||||
|
| What happened | Example reasons | Mailbox? | The route answers |
|
||||||
|
|---|---|---|---|
|
||||||
|
| The display never had it | `no_socket`, `refused`, `disabled`, `unsupported`, a connect or send that timed out, `forbidden` / `busy` at the door, `invalid_request` (refused by the client itself) | yes | success, `transport: "mailbox"`, `socket_error` |
|
||||||
|
| A display too old to know it (the upgrade case) | `unknown_command`, `unsupported_version` | yes | as above |
|
||||||
|
| The display had it and failed | `busy` (queue full), `invalid_args`, `internal`, a timeout or hang-up after the send, `bad_response` | no | `503` (`400` for `invalid_args`), `socket_error` |
|
||||||
|
|
||||||
|
A display that had the request may have applied it (a reply that timed out),
|
||||||
|
or would refuse the mailbox copy as well (bad arguments), or is stuck and
|
||||||
|
would not read the mailbox either (a full queue). Writing the copy anyway
|
||||||
|
only turned that into a "success". An on-demand stop with `stop_service`
|
||||||
|
still stops the service, which ends on-demand whatever happened.
|
||||||
|
|
||||||
|
Brightness and plugin reload never had a mailbox: without the socket, the
|
||||||
|
config watcher applies the saved brightness and a reload becomes the
|
||||||
|
restart banner, as before.
|
||||||
|
|
||||||
|
### The mailboxes now
|
||||||
|
|
||||||
|
| Mailbox | Written by | Read by the display | While the socket is up |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `display_on_demand_request` | the web interface, only on fallback; plugins that predate `BasePlugin.request_on_demand()`, or run on a core without it | the render thread, `_poll_on_demand_requests()` | looked at every 1 s (`MAILBOX_POLL_INTERVAL_WITH_SOCKET`), 0.25 s without a socket |
|
||||||
|
| `plugin_error_clear_request` | the web interface, only on fallback | the error publisher's thread, every 5 s tick | unchanged rate |
|
||||||
|
|
||||||
|
A look is one `stat()` of the mailbox file (`CacheManager.file_signature`):
|
||||||
|
`(inode, mtime, size)`, and every write renames a new file into place, so a
|
||||||
|
new write always looks different. `MailboxWatch` reads the file only when
|
||||||
|
that changed since the last look, so a mailbox that holds nothing new, or
|
||||||
|
nothing at all, costs no open and no parse. A socket command never reads or
|
||||||
|
deletes the on-demand mailbox. A start already processed is taken out of
|
||||||
|
the mailbox instead of being re-read until it expires.
|
||||||
|
|
||||||
|
A request that comes through the on-demand mailbox while the socket is up
|
||||||
|
is logged once per writer (`came through the file mailbox although the
|
||||||
|
control socket is up`), which names the plugins that still write it.
|
||||||
|
|
||||||
|
### Plugins in the display process
|
||||||
|
|
||||||
|
A plugin asks for the screen with `BasePlugin.request_on_demand()` and gives
|
||||||
|
it back with `end_on_demand()` (see "On-demand display" in
|
||||||
|
[PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md)). Neither goes through
|
||||||
|
the socket or a file: `PluginManager` hands the mailbox-shaped request,
|
||||||
|
marked `source: 'plugin'`, to `DisplayController.submit_plugin_on_demand`,
|
||||||
|
which queues it in memory (at most `PLUGIN_ON_DEMAND_QUEUE_SIZE`, 32) from
|
||||||
|
whatever thread the plugin called on, and wakes the render thread through
|
||||||
|
the socket's queue flag (`ControlServer.wake()`). The render thread applies
|
||||||
|
it in `_drain_control_commands`, after the socket's commands, through the
|
||||||
|
same `_handle_on_demand_request`, so it lands within a frame like a socket
|
||||||
|
command. Without a socket it lands on the next pending-changes pass (typically
|
||||||
|
within 0.25 s). A plugin's stop ends only a session that plugin owns. The four
|
||||||
|
plugins that wrote the mailbox (birdnet-go, mqtt-notifications, on-air,
|
||||||
|
pomodoro-timer) use it where the core has it and write the mailbox
|
||||||
|
otherwise.
|
||||||
|
|
||||||
## Robustness
|
## Robustness
|
||||||
|
|
||||||
@@ -449,9 +537,10 @@ block the render loop or crash it:
|
|||||||
and the connection is closed, because the next message boundary cannot be
|
and the connection is closed, because the next message boundary cannot be
|
||||||
found. A client that disconnects mid-message is dropped silently. No
|
found. A client that disconnects mid-message is dropped silently. No
|
||||||
exception from a handler leaves the connection thread.
|
exception from a handler leaves the connection thread.
|
||||||
- **Full queue.** When the queue is full, the client gets `busy` and falls
|
- **Full queue.** When the queue is full, the client gets `busy`, and the web
|
||||||
back to the mailbox. A full queue means the render thread is stuck, and the
|
interface answers `503` rather than write the mailbox, which the stuck
|
||||||
systemd watchdog deals with that.
|
render thread would not read either. A full queue means the render thread
|
||||||
|
is stuck, and the systemd watchdog deals with that.
|
||||||
- **Awaited commands.** The wait for an awaited command's outcome happens on
|
- **Awaited commands.** The wait for an awaited command's outcome happens on
|
||||||
its connection thread and is bounded (`AWAIT_SECONDS`), so a stuck render
|
its connection thread and is bounded (`AWAIT_SECONDS`), so a stuck render
|
||||||
thread costs that client `pending` and one connection slot for at most
|
thread costs that client `pending` and one connection slot for at most
|
||||||
@@ -568,15 +657,32 @@ device never touches the live display.
|
|||||||
an uninstall that keeps its config, still answer `restart_required`.
|
an uninstall that keeps its config, still answer `restart_required`.
|
||||||
They can now use a load/unload command and report the result the same
|
They can now use a load/unload command and report the result the same
|
||||||
way the update route does.
|
way the update route does.
|
||||||
4. **Retire the mailboxes.** After a release in which every device has had the
|
4. **The mailboxes become a fallback (done).**
|
||||||
socket, the web interface stops writing `display_on_demand_request`, and
|
- The web interface writes a mailbox only when the socket could not carry
|
||||||
the display stops polling it, logging the plugins that still write it so
|
the request (`should_fall_back`); a display that had it and failed is
|
||||||
they can move to an in-process `request_display()`. The other cache keys
|
answered as that (see "When the web interface falls back").
|
||||||
used as messages (`plugin_error_clear_request` and the remaining
|
- `errors.clear` replaces `plugin_error_clear_request` as the way a clear
|
||||||
`display_*` keys) move to the socket or to tmpfs. The display also stops
|
reaches the display.
|
||||||
writing `display_current_state`, `display_on_demand_state` and
|
- The display looks at the on-demand mailbox once a second while the
|
||||||
`plugin_runtime_snapshot` once the web interface no longer falls back to
|
socket is up, reads either mailbox only when its file changed, and logs
|
||||||
them.
|
who still writes the on-demand one (see "The mailboxes now").
|
||||||
|
- Not changed, deliberately: config saves (the schedule, the dim
|
||||||
|
schedule, plugin settings) still reach the display through
|
||||||
|
`config.json` and its watcher, which is the setting itself rather than
|
||||||
|
a message; see `config.reload` under stage 2. The preview viewer marker
|
||||||
|
(`/tmp/led_matrix_preview_viewer`) is a presence signal the display
|
||||||
|
already stats at most once a second. Plugin health and metrics resets
|
||||||
|
write the persisted record the display publishes and do not reach the
|
||||||
|
running display (their routes say so); they are not mailboxes.
|
||||||
|
5. **Remove the mailboxes (next release).** Once every device has run a
|
||||||
|
display with stage 4, the web interface stops writing both mailboxes and
|
||||||
|
the display stops reading them. The four plugins that wrote
|
||||||
|
`display_on_demand_request` now have an in-process way to ask for the
|
||||||
|
screen (`BasePlugin.request_on_demand()` / `end_on_demand()`, see
|
||||||
|
"Plugins in the display process"); they keep the mailbox write only as
|
||||||
|
their fallback on older cores. The display also stops writing `display_current_state`,
|
||||||
|
`display_on_demand_state` and `plugin_runtime_snapshot` once the web
|
||||||
|
interface no longer falls back to them.
|
||||||
|
|
||||||
## Checking it on a device
|
## Checking it on a device
|
||||||
|
|
||||||
@@ -591,7 +697,18 @@ curl -s -X POST localhost:5000/api/v3/display/on-demand/start \
|
|||||||
If the response says `"transport": "mailbox"`, `socket_error` gives the
|
If the response says `"transport": "mailbox"`, `socket_error` gives the
|
||||||
reason. `no_socket` means the display is stopped or predates the socket.
|
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
|
`refused` usually means the web user is not in the socket's group, which
|
||||||
takes effect when the web service restarts after the user is added.
|
takes effect when the web service restarts after the user is added. A `503`
|
||||||
|
with `"transport": "socket"` means the display had the request and did not
|
||||||
|
take it (`busy`, `timeout`, ...): nothing was written to the mailbox.
|
||||||
|
|
||||||
|
An error clear:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST localhost:5000/api/v3/errors/clear \
|
||||||
|
-H 'Content-Type: application/json' -d '{"all":true}'
|
||||||
|
# ... "applied": true, "transport": "socket"
|
||||||
|
sudo journalctl -u ledmatrix | grep -E "Cleared .* plugin error|file mailbox"
|
||||||
|
```
|
||||||
|
|
||||||
Brightness and a plugin reload:
|
Brightness and a plugin reload:
|
||||||
|
|
||||||
|
|||||||
@@ -488,6 +488,78 @@ working for the plugin itself. `get_vegas_segment_width()` read the
|
|||||||
`vegas_panel_count` config value, which has never affected Vegas — a card's
|
`vegas_panel_count` config value, which has never affected Vegas — a card's
|
||||||
width comes from `get_vegas_content()` and `vegas_width_pct`.
|
width comes from `get_vegas_content()` and `vegas_width_pct`.
|
||||||
|
|
||||||
|
### On-demand display
|
||||||
|
|
||||||
|
A plugin that reacts to something outside the rotation (an MQTT message, a
|
||||||
|
timer, a detection) can take the screen for it, and give it back. Both
|
||||||
|
methods are safe from any thread, including an MQTT callback: they only
|
||||||
|
queue the request, and the display applies it on its render thread within a
|
||||||
|
frame or so, exactly like an on-demand start or stop from the web interface.
|
||||||
|
|
||||||
|
#### `request_on_demand(mode=None, duration=None, pinned=False) -> Optional[str]`
|
||||||
|
|
||||||
|
Show this plugin now.
|
||||||
|
|
||||||
|
- `mode`: one of the plugin's display modes; `None` for its first.
|
||||||
|
- `duration`: seconds before the rotation resumes; `None` (or `0`) for no
|
||||||
|
limit, until `end_on_demand()` or the user stops it.
|
||||||
|
- `pinned`: stay on `mode` instead of cycling through the plugin's other
|
||||||
|
modes.
|
||||||
|
|
||||||
|
Returns the request id once the display has queued it, or `None` when
|
||||||
|
there is no display in this process to ask (the web interface's plugin
|
||||||
|
manager, `scripts/check_plugin.py`) or its queue is full. A bad argument
|
||||||
|
(a `mode` that is not a string, a `duration` that is not a number) raises
|
||||||
|
`ValueError`.
|
||||||
|
|
||||||
|
#### `end_on_demand() -> Optional[str]`
|
||||||
|
|
||||||
|
Give the screen back. Ends only a session this plugin owns: a session the
|
||||||
|
user started for another plugin, or one that already ended, is left alone.
|
||||||
|
Returns the request id once queued, or `None` as above.
|
||||||
|
|
||||||
|
#### Older cores: feature detection
|
||||||
|
|
||||||
|
These methods are new after core 3.8.0 (see `CHANGELOG.md`). Before them,
|
||||||
|
plugins wrote the `display_on_demand_request` cache key (the "mailbox")
|
||||||
|
themselves. The display reads it only once a second while the control
|
||||||
|
socket is up, and it will be removed in a future release (see
|
||||||
|
[IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md), stage 5). A plugin that
|
||||||
|
must keep working on older cores checks for the method, and writes the
|
||||||
|
mailbox only when the method is missing or answers `None`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import time, uuid
|
||||||
|
|
||||||
|
def _show_alert(self):
|
||||||
|
if hasattr(self, "request_on_demand") and self.request_on_demand(
|
||||||
|
mode="my_alert", duration=15):
|
||||||
|
return
|
||||||
|
# Older core, or no display in this process: the mailbox, as before.
|
||||||
|
self.cache_manager.set("display_on_demand_request", {
|
||||||
|
"request_id": str(uuid.uuid4()), "action": "start",
|
||||||
|
"plugin_id": self.plugin_id, "mode": "my_alert",
|
||||||
|
"duration": 15, "pinned": False, "timestamp": time.time(),
|
||||||
|
})
|
||||||
|
|
||||||
|
def _release(self):
|
||||||
|
if hasattr(self, "end_on_demand") and self.end_on_demand():
|
||||||
|
return
|
||||||
|
self.cache_manager.set("display_on_demand_request", {
|
||||||
|
"request_id": str(uuid.uuid4()), "action": "stop",
|
||||||
|
"plugin_id": self.plugin_id, "timestamp": time.time(),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep `ledmatrix_min_version` where it is: the fallback is what keeps the
|
||||||
|
plugin working on older cores. A mailbox stop ends any on-demand session,
|
||||||
|
whoever started it; `end_on_demand()` ends only the plugin's own.
|
||||||
|
|
||||||
|
Both methods answer a request id only when the plugin manager returned a
|
||||||
|
string, so a test that gives the plugin a `MagicMock()` plugin manager gets
|
||||||
|
`None` and exercises the mailbox path. To test the new path, set
|
||||||
|
`plugin_manager.request_on_demand.return_value = "some-id"`.
|
||||||
|
|
||||||
> The full source for `BasePlugin` lives in
|
> The full source for `BasePlugin` lives in
|
||||||
> `src/plugin_system/base_plugin.py`. If a method here disagrees with the
|
> `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.
|
> source, the source wins — please open an issue or PR to fix the doc.
|
||||||
@@ -966,6 +1038,14 @@ if info:
|
|||||||
self.logger.info(f"Plugin: {info['name']}, Version: {info.get('version')}")
|
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_all_plugin_info() -> List[Dict[str, Any]]`
|
||||||
|
|
||||||
Get information for all plugins.
|
Get information for all plugins.
|
||||||
|
|||||||
+67
-31
@@ -159,14 +159,16 @@ there an unchecked checkbox — which the browser omits — is saved as
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`restart_required` is always true here: display hardware, rotation,
|
`restart_required` is true when the save changed a setting that takes
|
||||||
durations and general settings take effect when the display restarts, and
|
effect when the display restarts: display hardware, rotation order,
|
||||||
the web UI shows its restart banner on the flag. (Plugin sections saved
|
timezone, general settings and the rest. The web UI shows its restart banner
|
||||||
through this route reach the running plugin live, like
|
on the flag. It is false when the save changed only what the running display
|
||||||
`POST /plugins/config`.)
|
applies by itself, or nothing: `brightness`, the per-mode durations
|
||||||
|
(`duration__<mode>`, `display.display_durations`) and plugin sections, which
|
||||||
|
reach the running plugin live, like `POST /plugins/config`.
|
||||||
|
|
||||||
A saved `brightness` is the exception: it reaches the panel without a
|
A saved `brightness` reaches the panel without a restart. The route also
|
||||||
restart. The route also sends it to the running display over the control
|
sends it to the running display over the control
|
||||||
socket (`brightness.set`), which puts it on the panel at once, and the
|
socket (`brightness.set`), which puts it on the panel at once, and the
|
||||||
response adds `"brightness_transport": "socket"`. Otherwise it is
|
response adds `"brightness_transport": "socket"`. Otherwise it is
|
||||||
`"config"`, with `brightness_socket_error` giving the reason, and the
|
`"config"`, with `brightness_socket_error` giving the reason, and the
|
||||||
@@ -246,7 +248,10 @@ Replace the schedule configuration.
|
|||||||
```
|
```
|
||||||
|
|
||||||
A day whose `<day>_enabled` key is absent counts as enabled, with default
|
A day whose `<day>_enabled` key is absent counts as enabled, with default
|
||||||
times `07:00`-`23:00`. At least one day must be enabled.
|
times `07:00`-`23:00`. An enabled schedule needs at least one day enabled; a
|
||||||
|
disabled one (`"enabled": false`) may have every day off, as
|
||||||
|
`config.template.json` ships it. A day that is off keeps the times sent for
|
||||||
|
it, when they are valid `HH:MM`.
|
||||||
|
|
||||||
**Response**:
|
**Response**:
|
||||||
```json
|
```json
|
||||||
@@ -343,7 +348,11 @@ control socket ([IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md)), and `cache`
|
|||||||
when it came from the `display_current_state` cache key (no socket: the
|
when it came from the `display_current_state` cache key (no socket: the
|
||||||
display is stopped or older, or this is Windows). A display whose render
|
display is stopped or older, or this is Windows). A display whose render
|
||||||
loop has not refreshed its state for 120 seconds is reported with every
|
loop has not refreshed its state for 120 seconds is reported with every
|
||||||
field `null`, either way.
|
field `null`, either way. So is a stopped display: when the socket does not
|
||||||
|
answer and the render loop's heartbeat
|
||||||
|
(`/run/ledmatrix/display-heartbeat.json`) is absent, stale or from a process
|
||||||
|
that is gone, the cache's last entry is not used. A display still beating
|
||||||
|
without a socket, Windows, or a socket switched off reads the cache.
|
||||||
|
|
||||||
### List Display Modes
|
### List Display Modes
|
||||||
|
|
||||||
@@ -354,9 +363,11 @@ it. This is the list the force-display dialog offers.
|
|||||||
|
|
||||||
Send the reported `plugin_id` alongside `mode` when starting an on-demand
|
Send the reported `plugin_id` alongside `mode` when starting an on-demand
|
||||||
display: `/display/on-demand/start` falls back to `find_plugin_for_mode` when
|
display: `/display/on-demand/start` falls back to `find_plugin_for_mode` when
|
||||||
`plugin_id` is omitted, and that lookup only sees modes declared in a static
|
`plugin_id` is omitted. While the display is running, this list and that
|
||||||
manifest — a plugin whose modes are generated (each installed Starlark app is
|
lookup use the modes the display registered, including ones a plugin generates
|
||||||
one) returns 404 there.
|
from its config (each installed Starlark app, each soccer `custom_leagues`
|
||||||
|
entry). With the display stopped, or for a plugin it has not loaded, both see
|
||||||
|
only the modes its manifest declares.
|
||||||
|
|
||||||
Triggers plugin discovery, which is otherwise lazy — so a caller that never
|
Triggers plugin discovery, which is otherwise lazy — so a caller that never
|
||||||
opens the dashboard still gets the full list.
|
opens the dashboard still gets the full list.
|
||||||
@@ -455,7 +466,7 @@ Request a specific plugin to display on-demand.
|
|||||||
- `mode` (string, optional): Display mode name (plugin_id inferred if not provided)
|
- `mode` (string, optional): Display mode name (plugin_id inferred if not provided)
|
||||||
- `duration` (number, optional): Duration in seconds (0 = until stopped)
|
- `duration` (number, optional): Duration in seconds (0 = until stopped)
|
||||||
- `pinned` (boolean, optional): Pin display (pause rotation)
|
- `pinned` (boolean, optional): Pin display (pause rotation)
|
||||||
- `start_service` (boolean, optional): Start the display service if it is not running (default: true). A running service is never restarted: it picks the request up within about a quarter of a second. When false and the service is stopped, the route returns 400.
|
- `start_service` (boolean, optional): Start the display service if it is not running (default: true). A running service is never restarted: it picks the request up within a frame over its control socket (within about a second through the mailbox fallback). When false and the service is stopped, the route returns 400.
|
||||||
|
|
||||||
**Response**:
|
**Response**:
|
||||||
```json
|
```json
|
||||||
@@ -480,10 +491,19 @@ display's control socket acknowledged it (it is queued for the render thread,
|
|||||||
which wakes for it and applies it within a frame; see
|
which wakes for it and applies it within a frame; see
|
||||||
[IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md)), `"mailbox"` means it was
|
[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.
|
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
|
The mailbox is used only when the socket could not carry the request. With
|
||||||
(`no_socket` when the display is stopped or predates the socket, `timeout`,
|
`"mailbox"`, `socket_error` gives the reason (`no_socket` when the display is
|
||||||
`refused`, `busy`, ...). Either way the request is applied the same way;
|
stopped or predates the socket, `refused`, a connect `timeout`,
|
||||||
`request_id` is the same id in both.
|
`unknown_command` from a display too old for the command, ...). Either way
|
||||||
|
the request is applied the same way; `request_id` is the same id in both.
|
||||||
|
|
||||||
|
When the display had the request and did not take it -- a full queue
|
||||||
|
(`busy`), bad arguments (`invalid_args`), no answer after the request was
|
||||||
|
sent (`timeout`, `closed`) -- the route answers `503` (`400` for
|
||||||
|
`invalid_args`) with `status: "error"` and `data: {request_id, transport:
|
||||||
|
"socket", socket_error}`, and writes nothing to the mailbox. The stop route
|
||||||
|
does the same, except that with `stop_service: true` it still stops the
|
||||||
|
service and answers success.
|
||||||
|
|
||||||
### Stop On-Demand Display
|
### Stop On-Demand Display
|
||||||
|
|
||||||
@@ -2239,13 +2259,11 @@ error with `"all": true` (`max_age_hours` is then ignored).
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The clear is asynchronous. The web interface records a request
|
The clear goes to the display service over its control socket
|
||||||
(`plugin_error_clear_request` in the shared cache), and the display service
|
(`errors.clear`, see [IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md)), which
|
||||||
applies it within about 5 seconds, rebuilding its counts from the errors it
|
applies it, rebuilding its counts from the errors it keeps, and republishes
|
||||||
keeps and republishing. Reads hide the cleared errors from the moment the
|
before it answers: `applied` is `true`, `transport` is `"socket"`, and
|
||||||
request is recorded. Until the display service applies an age-based clear,
|
`cleared_count` is the display's own count.
|
||||||
`recent_errors` and `active_patterns` are already filtered but the counts
|
|
||||||
are the old ones, and `clear_pending` is `true`.
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -2253,17 +2271,30 @@ are the old ones, and `clear_pending` is `true`.
|
|||||||
"data": {
|
"data": {
|
||||||
"cleared_count": 13,
|
"cleared_count": 13,
|
||||||
"clear_requested": true,
|
"clear_requested": true,
|
||||||
|
"applied": true,
|
||||||
|
"transport": "socket",
|
||||||
"request_id": "5f0c1e...",
|
"request_id": "5f0c1e...",
|
||||||
"cutoff": "2026-09-23T09:59:02.310000"
|
"cutoff": "2026-09-23T09:59:02.310000"
|
||||||
},
|
},
|
||||||
"message": "Clear of all errors requested; the display service applies it within about 5 seconds"
|
"message": "Cleared all errors"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`cleared_count` is how many of the reported errors the clear hides. It is
|
When the socket cannot carry it (the display is stopped, or older than
|
||||||
|
`errors.clear`) the clear is asynchronous, as before: the web interface
|
||||||
|
records a request (`plugin_error_clear_request` in the shared cache),
|
||||||
|
`applied` is `false` and `transport` is `"mailbox"`, and the display service
|
||||||
|
applies it within about 5 seconds. Reads hide the cleared errors from the
|
||||||
|
moment the request is recorded. Until the display service applies an
|
||||||
|
age-based clear, `recent_errors` and `active_patterns` are already filtered
|
||||||
|
but the counts are the old ones, and `clear_pending` is `true`. Then
|
||||||
|
`cleared_count` is how many of the reported errors the clear hides, and
|
||||||
`null` when that cannot be known before the display service applies it (an
|
`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
|
age-based clear over more errors than the report lists).
|
||||||
could not be written to the shared cache answers `500`.
|
|
||||||
|
A request that could not be written to the shared cache answers `500`. A
|
||||||
|
display that had the request and failed it (`internal`, a timeout after the
|
||||||
|
request was sent) answers `503`, with `context.socket_error`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -2281,7 +2312,11 @@ display snapshot. `data.status` is `healthy` or `degraded`, with
|
|||||||
(with `heartbeat_age_seconds`), `stalled` (no heartbeat for 60s: the panel is
|
(with `heartbeat_age_seconds`), `stalled` (no heartbeat for 60s: the panel is
|
||||||
frozen even if the service is active; the status turns `degraded`), or
|
frozen even if the service is active; the status turns `degraded`), or
|
||||||
`not_reported` when the display writes none (not started yet, the dev server,
|
`not_reported` when the display writes none (not started yet, the dev server,
|
||||||
Windows), which does not affect the status. Its `source` is `socket` when the
|
Windows), which does not affect the status, or `stopped` (with `source:
|
||||||
|
"service"`) when the display service is not active, the control socket does
|
||||||
|
not answer and there is no live heartbeat; the status then turns
|
||||||
|
`degraded`. A platform with no control socket (Windows) or a socket switched
|
||||||
|
off never reports `stopped`. Its `source` is `socket` when the
|
||||||
age came from the display's state stream over the control socket (measured
|
age came from the display's state stream over the control socket (measured
|
||||||
in memory by the display) and `heartbeat_file` when it came from
|
in memory by the display) and `heartbeat_file` when it came from
|
||||||
`/run/ledmatrix/display-heartbeat.json`.
|
`/run/ledmatrix/display-heartbeat.json`.
|
||||||
@@ -2344,8 +2379,9 @@ Replace the dim schedule. `dim_brightness` is 0-100 (default 30). In
|
|||||||
`per-day` mode the days can be sent either as the `days` object that GET
|
`per-day` mode the days can be sent either as the `days` object that GET
|
||||||
returns, or as the web form's flat fields (`monday_enabled`,
|
returns, or as the web form's flat fields (`monday_enabled`,
|
||||||
`monday_start`, `monday_end`, ...). A day that is not sent counts as
|
`monday_start`, `monday_end`, ...). A day that is not sent counts as
|
||||||
enabled with default times `20:00`-`07:00`; at least one day must be
|
enabled with default times `20:00`-`07:00`. As for the schedule above, an
|
||||||
enabled.
|
enabled dim schedule needs at least one day enabled and a disabled one may
|
||||||
|
have every day off.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+208
-83
@@ -36,122 +36,189 @@ Each pass, in order:
|
|||||||
1. `loop_pass()` (watchdog). Apply a pending plugin enable/disable, then
|
1. `loop_pass()` (watchdog). Apply a pending plugin enable/disable, then
|
||||||
any plugin reloads the control socket asked for
|
any plugin reloads the control socket asked for
|
||||||
(`_apply_pending_plugin_reloads`; a pending reload ends the screen
|
(`_apply_pending_plugin_reloads`; a pending reload ends the screen
|
||||||
before it, like a WiFi notice, through `_screen_preempted`). The static
|
before it, like a WiFi notice, as `Source.RELOAD` at the runner's
|
||||||
screen's frame sleep and the dwell wait on the socket's queue instead of
|
service points). The static screen's frame sleep and the dwell wait on
|
||||||
sleeping (`_wait_frame_interval`, `_sleep_with_plugin_updates`); without
|
the socket's queue instead of sleeping (`_wait_frame_interval`,
|
||||||
a socket, as in the golden traces, they are the plain sleeps.
|
`_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.
|
2. With no modes: dwell 1 s, next pass.
|
||||||
3. Poll on-demand requests and expiry, release plugins loaded only for
|
3. Poll on-demand requests and expiry, release plugins loaded only for
|
||||||
on-demand, tick plugin updates, drop an expired WiFi notice, evaluate
|
on-demand, tick plugin updates, drop an expired WiFi notice, evaluate
|
||||||
the schedule (an on-demand session overrides scheduled-off), apply the
|
the schedule (an on-demand session overrides scheduled-off), apply the
|
||||||
brightness target. Then gather the Arbiter's inputs
|
brightness target. Then gather the Arbiter's inputs
|
||||||
(`_arbiter_inputs`) and call `Arbiter.decide()`, which picks one of
|
(`_arbiter_inputs`) and call `Arbiter.decide()`.
|
||||||
steps 4-6 or returns `LEGACY` for steps 7-9 (stage 2).
|
|
||||||
4. **Scheduled off:** blank, dwell up to 60 s. `_blank_while_scheduled_off`
|
4. **Scheduled off:** blank, dwell up to 60 s. `_blank_while_scheduled_off`
|
||||||
5. **Follower:** render one frame from the leader. `_run_follower_frame`
|
5. **Follower:** render one frame from the leader. `_run_follower_frame`
|
||||||
6. **WiFi notice** (unless on-demand): draw it, dwell 0.5 s. `_show_wifi_notice`.
|
6. **WiFi notice** (unless on-demand): draw it, dwell 0.5 s. `_show_wifi_notice`.
|
||||||
It is also polled mid-screen (`_wifi_notice_pending`): the frame loops,
|
It also ends a running screen within about a second (the runner's
|
||||||
the dwell sleep and an interrupted Vegas iteration end within about a
|
service points), and a screen cut short resumes after it.
|
||||||
second when one arrives, and a screen cut short resumes after it.
|
7. **The Sources below the notice:** read whether Vegas is on and make the
|
||||||
7. **Live priority** (unless on-demand, or Vegas keeps live content in the
|
live-priority scan (`_arbiter_inputs_below_wifi`, where run() always
|
||||||
ticker): switch to the next live mode, or resume the rotation. A game
|
read them), and call `decide()` again. It answers OnDemand (the
|
||||||
that goes live during a screen is caught sooner, by
|
session's current mode), Live (the next live mode, round-robin; a game
|
||||||
`_check_live_takeover` in the frame loops and the dwell sleep (at most
|
that goes live during a screen takes over at the next service point, at
|
||||||
once a second, and not while a live mode is showing).
|
most once a second), Vegas (`LEGACY`) or Rotation. `_take_plan` applies
|
||||||
8. **Vegas** (unless on-demand, or live content preempts it): run one
|
the answer: a live claim or the resume when live priority ends, the
|
||||||
iteration of up to `max_cycle_duration`. A completed iteration ends the
|
on-demand index.
|
||||||
pass, and so does one that yielded for a WiFi notice or the schedule.
|
8. **Vegas** (`_run_vegas_iteration`): one iteration of up to
|
||||||
Any other interrupted one falls through to step 9 in the same pass.
|
`max_cycle_duration`. A completed iteration ends the pass, and so does
|
||||||
9. **One screen:** pick the mode (`_resolve_active_mode`), the plugin
|
one that yielded for the schedule, a reload or a WiFi notice. Any other
|
||||||
(`_plugin_for_mode`), draw the first frame through the executor
|
interrupted one asks `decide()` once more (`vegas_yielded`): a game that
|
||||||
(`_dispatch_first_frame`). On no content, rotate at once
|
stopped the ticker, or an on-demand session that started, shows next.
|
||||||
(`_note_empty_pass`, `_skip_failed_plugin_modes`). Otherwise work out the
|
9. **One screen:** pick the plugin (`_plugin_for_mode`) and hand the plan
|
||||||
bounds (`_track_dynamic_cycle`, `_resolve_durations`,
|
to the `ScreenRunner` (`src/screen_runner.py`). It draws the first frame
|
||||||
`_clamp_to_on_demand`) and the frame rate (`_needs_high_fps`), run the
|
through the executor (`_dispatch_first_frame`), has the controller fill
|
||||||
125 Hz or 1 Hz frame loop, make up the minimum duration, then pick the
|
in the plugin's durations, dynamic flag and frame policy
|
||||||
next mode (`_advance_after_screen`).
|
(`_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
|
## Design
|
||||||
behaviour. Since stage 2 the choice between steps 4, 5, 6 and the rest is
|
|
||||||
made by `Arbiter.decide()` in `src/display_arbiter.py`. The frame loops, the
|
|
||||||
Vegas branch and every early exit are still inline in `run()`.
|
|
||||||
|
|
||||||
## Target design
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def run(self):
|
def run(self):
|
||||||
while True:
|
while True:
|
||||||
inputs = self._drain_inputs() # requests, schedule, config, sync
|
inputs = self._arbiter_inputs() # schedule, follower, notice
|
||||||
plan = self.arbiter.decide(self.state, inputs, clock.now())
|
plan = Arbiter.decide(self._arbiter_state(), inputs, now)
|
||||||
outcome = self.runner.run(plan) # ExitReason + elapsed
|
... # off / follower / notice
|
||||||
self.state = self.state.after(plan, outcome) # rotation, on-demand index, live resume
|
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
|
### Sources
|
||||||
|
|
||||||
Each kind of content is a Source. A Source looks at the state and the
|
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
|
inputs and either offers a screen or passes. The Arbiter asks them in this
|
||||||
order:
|
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 |
|
| gate | ScheduledOff | the schedule is off and no on-demand session overrides it | `decide` |
|
||||||
| 1 | Follower | a sync leader is driving this panel | step 5 |
|
| 1 | Follower | a sync leader is driving this panel | `decide` |
|
||||||
| 2 | OnDemand | a session is active (its mode list, index, expiry and pin) | `_resolve_active_mode` |
|
| 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 | step 6 |
|
| 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) | step 7 |
|
| 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 | step 8 |
|
| 5 | Vegas | Vegas is enabled and nothing above wants the panel | `LEGACY`, run by `_run_vegas_iteration` |
|
||||||
| 6 | Rotation | always: `available_modes[current_mode_index]` | step 9 |
|
| 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
|
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
|
today: a scheduled-off panel stays blank even for a follower, and only an
|
||||||
on-demand session overrides it.
|
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
|
### Arbiter
|
||||||
|
|
||||||
```python
|
```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
|
`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
|
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 |
|
| Field | Meaning |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `source` | which Source won |
|
| `source` | which Source won |
|
||||||
| `mode`, `plugin` | what to draw (None for a blank or follower plan) |
|
| `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 `_clamp_to_on_demand` |
|
| `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 |
|
| `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 |
|
| `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
|
### ScreenRunner
|
||||||
|
|
||||||
```python
|
```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
|
The ScreenRunner draws the first frame (`_dispatch_first_frame`), runs the
|
||||||
frame loop that the plan's frame policy selects, services pending changes
|
frame loop that the plan's frame policy selects, services pending changes
|
||||||
between frames, and returns one `ExitReason`:
|
between frames, and returns one `ExitReason`:
|
||||||
|
|
||||||
| ExitReason | Today's equivalent (golden-trace exit) |
|
| ExitReason | Golden-trace exit |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `DURATION` | target duration reached (`duration`) |
|
| `DURATION` | target duration reached (`duration`) |
|
||||||
| `CYCLE_COMPLETE` | dynamic plugin finished after its minimum (`cycle-complete`) |
|
| `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`) |
|
| `ERROR` | the dispatch itself raised (`error`) |
|
||||||
| `DISPLAY_FALSE` | a later frame returned False (`display-false`) |
|
| `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.
|
`PREEMPTED` replaces the five `current_display_mode != active_mode` checks.
|
||||||
The runner asks the Arbiter, at the throttled service points it already has,
|
The runner asks its host at named service points (`Checkpoint`): `FRAME`
|
||||||
whether a Source in `plan.preemptible_by` now wants the panel.
|
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
|
In the 125 Hz loop the live-priority scan is made before the frame's sleep
|
||||||
`time.monotonic`/`time.sleep`. In the golden traces it is the fake clock
|
(`_screen_service`), at the moments it always was, and weighed by the
|
||||||
that the harness patches in today.
|
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
|
## Stages
|
||||||
|
|
||||||
@@ -181,13 +248,14 @@ that the harness patches in today.
|
|||||||
- dynamic duration (cycle complete, plugin cap, global cap)
|
- dynamic duration (cycle complete, plugin cap, global cap)
|
||||||
- live priority taking over and handing back; live round-robin
|
- live priority taking over and handing back; live round-robin
|
||||||
- on-demand start/stop/expiry; pinned on-demand; a session resumed after
|
- on-demand start/stop/expiry; pinned on-demand; a session resumed after
|
||||||
a restart
|
a restart, and one that cannot resume (its plugin did not load); a
|
||||||
|
request naming a live mode the plugin's live check would drop
|
||||||
- schedule off and dim, with an on-demand override during downtime
|
- schedule off and dim, with an on-demand override during downtime
|
||||||
- WiFi notice; sync follower
|
- WiFi notice; sync follower
|
||||||
- Vegas, with and without `live_in_ticker`
|
- Vegas, with and without `live_in_ticker`
|
||||||
- Each trace row is `[start, mode, duration, exit_reason, frames,
|
- Each trace row is `[start, mode, duration, exit_reason, frames,
|
||||||
force_clear]`. The exit reason is the event that decided what came next.
|
force_clear]`. The exit reason is the event that decided what came next.
|
||||||
- All 16 tests run in under a second. The goldens were generated from
|
- All 18 tests run in under a second. The goldens were generated from
|
||||||
main's `run()` before any code moved.
|
main's `run()` before any code moved.
|
||||||
- Vegas uses `FakeVegas`, which implements only the contract the controller
|
- Vegas uses `FakeVegas`, which implements only the contract the controller
|
||||||
depends on: `run_iteration()` returns True after its duration and False
|
depends on: `run_iteration()` returns True after its duration and False
|
||||||
@@ -249,37 +317,84 @@ What shipped:
|
|||||||
Source, the dwells, the expiry comparison, the snapshot's reads, each
|
Source, the dwells, the expiry comparison, the snapshot's reads, each
|
||||||
dispatch in `run()`); every one failed a test.
|
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
|
The plan, from where stage 2 left off:
|
||||||
into `ScreenRunner.run(plan)` with an injected `FrameClock`. Replace the
|
|
||||||
five re-checks with `PREEMPTED`. Add the OnDemand, Live and Rotation Sources
|
|
||||||
so `LEGACY` is left meaning only Vegas.
|
|
||||||
|
|
||||||
Concretely, from where stage 2 left off:
|
|
||||||
|
|
||||||
1. `ArbiterState` gains the rotation index, the on-demand mode list, index,
|
1. `ArbiterState` gains the rotation index, the on-demand mode list, index,
|
||||||
expiry and pin, and the live resume point (today `current_mode_index`,
|
expiry and pin, and the live resume point. `ArbiterInputs` gains the
|
||||||
`on_demand_*` and the live-priority stash). `ArbiterInputs` gains the
|
live modes and whether Vegas is enabled and keeps live content in the
|
||||||
live modes (`_collect_live_modes`) and whether Vegas is enabled and keeps
|
ticker.
|
||||||
live content in the ticker.
|
2. OnDemand returns its current mode with the session's bound, reading
|
||||||
2. OnDemand returns its current mode with `_clamp_to_on_demand`'s bound,
|
`now` for the expiry. Live returns the next live mode (round-robin).
|
||||||
reading `now` for the expiry. Live returns the next live mode
|
Rotation returns the rotation's mode. `ScreenPlan` gains `mode`,
|
||||||
(round-robin). Rotation returns `available_modes[current_mode_index]`.
|
`plugin`, `min_duration`, `max_duration`, `dynamic`, `frame_policy` and
|
||||||
`ScreenPlan` gains `mode`, `plugin`, `min_duration`, `max_duration`,
|
`preemptible_by`.
|
||||||
`dynamic`, `frame_policy` and `preemptible_by`.
|
3. `ScreenRunner.run(plan)` returns an `ExitReason`; `state.after(outcome)`
|
||||||
3. `ScreenRunner.run(plan)` returns an `ExitReason`; `state.after(plan,
|
replaces `_advance_after_screen`'s step and the live-resume bookkeeping.
|
||||||
outcome)` replaces `_advance_after_screen` and the live-resume
|
Each mid-screen check asks `decide()` whether a Source in
|
||||||
bookkeeping. Each mid-screen check asks `decide()` whether a Source in
|
`plan.preemptible_by` now wins.
|
||||||
`plan.preemptible_by` now wins, so `_screen_preempted`,
|
|
||||||
`_check_live_takeover` and `_wifi_notice_pending` become one call.
|
|
||||||
4. The control socket (`_drain_control_commands`, `_wait_for_control`) and
|
4. The control socket (`_drain_control_commands`, `_wait_for_control`) and
|
||||||
state publishing stay where they are; the runner calls them at its
|
state publishing stay where they are; the runner calls them at its
|
||||||
service points.
|
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),
|
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
|
so it needs a frame soak on ledpi, A/B against main, before it merges.
|
||||||
whoever owns scroll performance (`docs/SCROLL_PERFORMANCE.md`).
|
Coordinate with whoever owns scroll performance (`docs/SCROLL_PERFORMANCE.md`).
|
||||||
|
|
||||||
### Stage 4: Vegas as a Source
|
### Stage 4: Vegas as a Source
|
||||||
|
|
||||||
@@ -344,3 +459,13 @@ PR that updates the affected trace and explains why. All six are fixed:
|
|||||||
|
|
||||||
A new one found later goes the same way: record it here with the trace that
|
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.
|
shows it, then fix it in its own PR, not inside a restructure stage.
|
||||||
|
|
||||||
|
Open:
|
||||||
|
|
||||||
|
- Vegas stops for a sync follower (its interrupt check includes
|
||||||
|
`is_follower_active`), but the yield path never looks at a follower, so a
|
||||||
|
full rotation screen (20 s in the test) runs before the next pass hands
|
||||||
|
the panel to the leader. Found by stage 3's mutation run;
|
||||||
|
`test_screen_runner.py::TestThroughRun::test_vegas_yielding_to_a_follower_shows_a_rotation_screen_first`
|
||||||
|
pins it. Stage 4, which drops the interrupt callback, is the natural
|
||||||
|
place to fix it.
|
||||||
|
|||||||
+85
-30
@@ -87,10 +87,12 @@ more. Shared sports code lives in `src/common`:
|
|||||||
| `sports_celebration.py` | 3.7.0 | `SportsCelebrationMixin` — draws the score/win takeover; colour helpers |
|
| `sports_celebration.py` | 3.7.0 | `SportsCelebrationMixin` — draws the score/win takeover; colour helpers |
|
||||||
| `sports_fetch.py` | 3.7.0 | `SportsFetchMixin` — season fetch, live lookback and live-odds decisions |
|
| `sports_fetch.py` | 3.7.0 | `SportsFetchMixin` — season fetch, live lookback and live-odds decisions |
|
||||||
| `sports_card_wrappers.py` | 3.7.0 | `SportsCardWrappersMixin` — the game renderer's `sports_card` delegations |
|
| `sports_card_wrappers.py` | 3.7.0 | `SportsCardWrappersMixin` — the game renderer's `sports_card` delegations |
|
||||||
| `sports_plugin_host.py` | next release | `SportsPluginHostMixin` — the plugin class's (`manager.py`) identical helpers: Vegas weight, off-thread switch refresh |
|
| `sports_plugin_host.py` | 3.8.0 | `SportsPluginHostMixin` — the plugin class's (`manager.py`) identical helpers: Vegas weight, off-thread switch refresh |
|
||||||
| `sports_live_scroll.py` | next release | `SportsLiveScrollMixin` — rebuild a live scroll strip mid-cycle, keeping the marquee's place |
|
| `sports_live_scroll.py` | 3.8.0 | `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_display_rules.py` | 3.8.0 | `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_font_path.py` | 3.8.0 | `resolve_font_path` — what the plugins' `_resolve_font_path` copies return |
|
||||||
|
| `sports_game_over.py` | 3.8.1 | `SportsGameOverMixin` — `_is_game_really_over`, with the `FINAL_PERIOD` seam (family 5) |
|
||||||
|
| `sports_favorites.py` | 3.8.2 | `SportsFavoritesMixin`, `SportsUpcomingFavoritesMixin`, `SportsRecentFavoritesMixin` — `_is_favorite_game` and the favourites-only picks, on the `_favorite_key` seam (family 6) |
|
||||||
|
|
||||||
Each is described in [src/common/README.md](../src/common/README.md).
|
Each is described in [src/common/README.md](../src/common/README.md).
|
||||||
|
|
||||||
@@ -102,7 +104,8 @@ modules taken from the plugin copies, each a **new module** rather than growth
|
|||||||
on an existing one: a plugin that deletes a method copy and relies on an older
|
on an existing one: a plugin that deletes a method copy and relies on an older
|
||||||
module having gained it fails at runtime with an `AttributeError`, while a
|
module having gained it fails at runtime with an `AttributeError`, while a
|
||||||
missing module fails at load, where the version checks can see it.
|
missing module fails at load, where the version checks can see it.
|
||||||
`sports_helpers.py` holds `_favorite_key`, the override point listed below.
|
`sports_helpers.py` holds `_favorite_key`, the override point listed below;
|
||||||
|
`sports_favorites.py` is what calls it.
|
||||||
Each promoted module has a parity test that compares its bodies against the
|
Each promoted module has a parity test that compares its bodies against the
|
||||||
plugin copies when `LEDMATRIX_PLUGINS` points at a checkout
|
plugin copies when `LEDMATRIX_PLUGINS` points at a checkout
|
||||||
(`test_sports_helpers.py`, `test_sports_stage3_parity.py`), and
|
(`test_sports_helpers.py`, `test_sports_stage3_parity.py`), and
|
||||||
@@ -125,7 +128,7 @@ deprecation cycle.
|
|||||||
| `_custom_scorebug_layout(game, draw)` | Per-sport overlay on the base layout | no-op |
|
| `_custom_scorebug_layout(game, draw)` | Per-sport overlay on the base layout | no-op |
|
||||||
| `score_phrase(points, team_abbr)` | Celebration wording (`"GOOOOAAALLL!"` vs `"TOUCHDOWN!"`). `points` is the score delta, which sports with variable-value scores use to name the play | `"<abbr> SCORES!"` — only consulted when `CelebrationMixin` is present |
|
| `score_phrase(points, team_abbr)` | Celebration wording (`"GOOOOAAALLL!"` vs `"TOUCHDOWN!"`). `points` is the score delta, which sports with variable-value scores use to name the play | `"<abbr> SCORES!"` — only consulted when `CelebrationMixin` is present |
|
||||||
| `win_phrase(team_abbr)` | Win-celebration wording | `"<abbr> WINS!"` — mixin only |
|
| `win_phrase(team_abbr)` | Win-celebration wording | `"<abbr> WINS!"` — mixin only |
|
||||||
| `_favorite_key(game, side)` | Which view-model field identifies a team for favorites matching | `game["<side>_abbr"]` |
|
| `_favorite_key(game, side)` | Which view-model field identifies a team for favorites matching. `sports_favorites` compares it, and each `favorite_teams` entry, stripped and upper-cased; a `None` matches nothing | `game["<side>_abbr"]`. nrl returns the ESPN team id, `None` when it is missing |
|
||||||
| `_config_schema_path()` | Plugin's `config_schema.json` — returning it routes `_get_layout_offset` through the `src.element_style` resolver (and gives it the defaults to compare against) | `None`, i.e. the classic inline `customization.layout` read |
|
| `_config_schema_path()` | Plugin's `config_schema.json` — returning it routes `_get_layout_offset` through the `src.element_style` resolver (and gives it the defaults to compare against) | `None`, i.e. the classic inline `customization.layout` read |
|
||||||
| `_font_root()` | Directory to resolve `assets/fonts` against | core install root |
|
| `_font_root()` | Directory to resolve `assets/fonts` against | core install root |
|
||||||
|
|
||||||
@@ -134,8 +137,7 @@ constants rather than behavior:
|
|||||||
|
|
||||||
| Attribute | Meaning | Default |
|
| Attribute | Meaning | Default |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `FINAL_PERIOD` | Period at/after which a zero clock can mean "over" | `4` (hockey overrides to `3`) |
|
| `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` |
|
||||||
| `CLOCK_COUNTS_DOWN` | Whether `0:00` means "expired" | `True` (soccer/afl/nrl override to `False` — their clocks count up, so `0:00` is kickoff) |
|
|
||||||
| `COALESCE_SCORING_SEQUENCE` | Fold score increments arriving during an active celebration into that one celebration | `False` (football overrides to `True` — a touchdown lands as +6, then +1 for the extra point) |
|
| `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
|
### Why these are seams and not branches
|
||||||
@@ -146,11 +148,14 @@ so NRL matches favorites on team ID. Flattening every plugin to abbreviations
|
|||||||
would silently select the wrong club for NRL users. The base declares the seam,
|
would silently select the wrong club for NRL users. The base declares the seam,
|
||||||
NRL fills it, and core never learns the string `"nrl"`.
|
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
|
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
|
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
|
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
|
goals, and folding them would swallow one. Neither default is "right" — which is
|
||||||
@@ -298,6 +303,49 @@ Left in the plugins, though identical:
|
|||||||
renderers) is already core's, in `SportsHelpersMixin`; a renderer that
|
renderers) is already core's, in `SportsHelpersMixin`; a renderer that
|
||||||
wants it can inherit that.
|
wants it can inherit that.
|
||||||
|
|
||||||
|
### Family 5: the game-over check (core done; adoption waits for a release)
|
||||||
|
|
||||||
|
The pilot of the method below. ledmatrix-plugins `scripts/test_game_over_check.py`
|
||||||
|
(#621) pinned 3,115 answers across the nine plugins first; the reconcile
|
||||||
|
(ledmatrix-plugins #625) made the five bodies one and
|
||||||
|
changed only the cells the owner's decisions under
|
||||||
|
[Product decisions](#product-decisions-each-family-needs) explain: ufc's
|
||||||
|
clock rule (65 cells), baseball's dormant one (53, every one a game with a
|
||||||
|
`period` baseball's games never carry), and a level score at 0:00 (five
|
||||||
|
cells in hockey, basketball, football and lacrosse). The harness renders
|
||||||
|
were pixel-identical. `src/common/sports_game_over.py` holds the body;
|
||||||
|
`test/test_sports_game_over_parity.py` compares it, and each plugin's
|
||||||
|
`FINAL_PERIOD`, with the plugin copies.
|
||||||
|
|
||||||
|
### Family 6: favourite matching (core done; adoption waits for a release)
|
||||||
|
|
||||||
|
ledmatrix-plugins `scripts/test_favourite_matching.py` (#634) pinned 204 rows
|
||||||
|
across the nine plugins first: `_is_favorite_game` on each manager role, the
|
||||||
|
two selection methods, the real `update()` with favourites-only on and off,
|
||||||
|
and the INFO summary; the reconcile extends it to 217 (a lower-case and a
|
||||||
|
padded favourite through `update()`, and the live favourite boost). The reconcile (ledmatrix-plugins
|
||||||
|
#635) made `_is_favorite_game` one body on `SportsCore`
|
||||||
|
(afl and soccer's `SportsUpcoming` copies and five `SportsLive` copies, all
|
||||||
|
redundant, are gone), added `_favorite_code` beside it, and gave nrl a
|
||||||
|
`_favorite_key` override instead of its own copies. So that a lower-case
|
||||||
|
favourite works on a favourites-only Upcoming board, the Upcoming `update()`'s
|
||||||
|
favourites-only pre-filter and the basketball, hockey and lacrosse live boost
|
||||||
|
now ask `_is_favorite_game` too (a one-line change each; `update()` itself is
|
||||||
|
family 13). Of 3,897 cells only those the decisions above explain changed:
|
||||||
|
case and spaces in eight plugins (30-40 each), the id-less duplicate fix (6-8
|
||||||
|
each), nrl's key (6) and its "None" match (6), and the INFO line in baseball,
|
||||||
|
football and ufc. The harness renders were byte-identical. `src/common/sports_favorites.py` holds the
|
||||||
|
bodies, one mixin per carrying class; `test/test_sports_favorites_parity.py`
|
||||||
|
compares them with the plugin copies and checks that only nrl overrides
|
||||||
|
`_favorite_key`.
|
||||||
|
|
||||||
|
Left for later families: the live screens' favourites-only filter
|
||||||
|
(`_classify_live_game` and its inline copies) and favourites-first sort still
|
||||||
|
compare abbreviations exactly, and
|
||||||
|
`SportsCoreSharedMixin._round_robin_favorites` groups favourites by raw
|
||||||
|
abbreviation (or by `_team_in` where a plugin has one) instead of through
|
||||||
|
`_favorite_key`. The result-colour helpers also wait (decision above).
|
||||||
|
|
||||||
### Why the method changes
|
### Why the method changes
|
||||||
|
|
||||||
Byte-identical promotion has nearly run dry. Measured on ledmatrix-plugins
|
Byte-identical promotion has nearly run dry. Measured on ledmatrix-plugins
|
||||||
@@ -333,8 +381,8 @@ game-over check); the report measures each method in it. The procedure:
|
|||||||
line in each plugin.
|
line in each plugin.
|
||||||
- *A per-sport fact* (hockey ends in period 3; a soccer clock counts up).
|
- *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
|
Make it a declared class constant or override point with a default, as
|
||||||
`FINAL_PERIOD`, `CLOCK_COUNTS_DOWN`, `COALESCE_SCORING_SEQUENCE` and
|
`FINAL_PERIOD`, `COALESCE_SCORING_SEQUENCE` and `_favorite_key` are,
|
||||||
`_favorite_key` are, and add it to the tables above. Never a sport-name
|
and add it to the tables above. Never a sport-name
|
||||||
branch: core must not learn sport names.
|
branch: core must not learn sport names.
|
||||||
- *A product difference*: anything a user can see (which games show, a
|
- *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
|
colour, a date, a badge, how long a screen stays). The owner picks the
|
||||||
@@ -382,8 +430,8 @@ release.
|
|||||||
| # | Family | Methods (variants) | Why here |
|
| # | 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) |
|
| 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 |
|
| 6 | Favourite matching | `_is_favorite_game` (7 across three classes), `_select_games_for_display` (2: nrl), `_select_recent_games_for_display` (3) | Everything that asks "is this a favourite" goes through the 3.5.0 `_favorite_key` seam. Reconciled to one body each and promoted as `sports_favorites`; adoption waits for the release that ships it. See [Family 6](#family-6-favourite-matching-core-done-adoption-waits-for-a-release) |
|
||||||
| 7 | Other-games rotation | `_by_importance`, `_other_games_window`, `_advance_other_games_if_due` (2 each: football), `_rotate_other_games_on_display` (2: ufc) | One outlier each; football carries two fixes the other eight lack |
|
| 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 |
|
| 8 | Rankings | `_fetch_team_rankings` (3), `_choose_poll` (3), `_load_division_team_ids`, `_passes_other_filters`, `_best_rank`, `_is_ranked_game` (2 each: football) | Needs 7; the rank badge and the "ranked only" filter read it |
|
||||||
| 9 | Live fetch and odds | `_fetch_todays_games` (5), `_fetch_odds` (3), `_attach_odds_to_rotated_games` (3) | The prerequisite for one shared ESPN poller across plugins |
|
| 9 | Live fetch and odds | `_fetch_todays_games` (5), `_fetch_odds` (3), `_attach_odds_to_rotated_games` (3) | The prerequisite for one shared ESPN poller across plugins |
|
||||||
@@ -416,21 +464,28 @@ family 9 prepares.
|
|||||||
Owner calls to make before (or while) reconciling. Items marked *verify* are
|
Owner calls to make before (or while) reconciling. Items marked *verify* are
|
||||||
suspected behaviour that needs a payload or a rig to confirm first.
|
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
|
- **5, game-over check. Decided 2026-10-05, done:** one seam,
|
||||||
game in afl, nrl and soccer (`CLOCK_COUNTS_DOWN = False`); hockey ends at
|
`FINAL_PERIOD`: hockey 3; basketball, football and lacrosse 4; `None` (the
|
||||||
0:00 from period 3, basketball, football and lacrosse from period 4.
|
clock never ends a game) for afl, nrl and soccer (clocks that count up),
|
||||||
baseball and ufc share a copy that reads a missing clock as "0:00": dormant
|
baseball (its games carry no `period`, so the old rule was dormant) and
|
||||||
in baseball (its games carry no `period`), and not triggered by ufc's round
|
ufc (a bout ends only on ESPN's final status, which also closes the ~1 s
|
||||||
breaks either. ESPN sends a break as `STATUS_END_OF_ROUND` with displayClock
|
window at the horn when the ticking clock reads `0:00`; ESPN's round-break
|
||||||
`-`, not `0:00` (verified against recorded payloads; ledmatrix-plugins#580
|
displayClock `-` was never a zero clock, ledmatrix-plugins#580). Only a
|
||||||
pins it). Whatever rule ufc gets must not read `-` as `0:00`. Decide ufc's
|
non-empty clock string counts (the baseball/ufc copy read a missing clock
|
||||||
rule: no clock rule (ESPN's `STATUS_FINAL` is the only end signal it needs;
|
as `0:00`). A score level at 0:00 is not over: the game stays live through
|
||||||
this also closes a ~1 s window at the horn when the ticking clock reads
|
the break before overtime, and one that really ends tied ends on its final
|
||||||
`0:00`), or its own final period.
|
status. Baseball keeps its postponed/suspended override in `BaseballLive`.
|
||||||
- **6, favourite matching.** NRL keeps matching favourites by team id
|
- **6, favourite matching. Decided 2026-10-05, done:** each side of a game is
|
||||||
(abbreviations collide: NEW, CAN), through `_favorite_key` rather than its
|
named by `_favorite_key` (the abbreviation; NRL overrides it with the ESPN
|
||||||
own copies of the selection methods. Six plugins log the recent-games
|
team id, and `None` for a missing id, which fixes a favourite typed "None"
|
||||||
selection at INFO; baseball, football and ufc do not.
|
matching every game without one) and compared with `favorite_teams`
|
||||||
|
stripped and upper-cased, so " bos" matches BOS. NRL's ambiguous "NEW"
|
||||||
|
still matches nothing and is logged; routing the result-colour helpers
|
||||||
|
(`side_is_favorite`, which tint both NEW clubs) through `_favorite_key` is
|
||||||
|
left for a later family. The recent-games selection logs at INFO in all
|
||||||
|
nine. ufc stays on the shared body, dormant: its favourites are fighters,
|
||||||
|
which its MMA managers match themselves (a follow-up). Fix ported: only a
|
||||||
|
game with an id can be a duplicate in the selection methods.
|
||||||
- **7, other-games rotation.** football advances the rotation window under
|
- **7, other-games rotation.** football advances the rotation window under
|
||||||
`_games_lock` (update() and display() both advance it; interleaved, a
|
`_games_lock` (update() and display() both advance it; interleaved, a
|
||||||
window of games is skipped) and fixes a favourites-only pool that recomposed
|
window of games is skipped) and fixes a favourites-only pool that recomposed
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ static/v3/js/
|
|||||||
registry.js page lifecycle: init/destroy on htmx swaps
|
registry.js page lifecycle: init/destroy on htmx swaps
|
||||||
api.js fetch wrapper for /api/v3 (JSON envelope, login redirect)
|
api.js fetch wrapper for /api/v3 (JSON envelope, login redirect)
|
||||||
facade.js window.LEDMatrix and deprecated aliases
|
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
|
store.js (the one installed-plugin store), form/renderer.js
|
||||||
pages/ one module per tab partial
|
pages/ one module per tab partial
|
||||||
cache.js export init(root, ctx), destroy(root, ctx)
|
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.
|
to the module's export of the same name and warns once.
|
||||||
- **Timers are cleared in `destroy()`**, the one thing `ctx.signal` cannot
|
- **Timers are cleared in `destroy()`**, the one thing `ctx.signal` cannot
|
||||||
undo by itself.
|
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:
|
`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.state` | A per-mount object for the page's own state |
|
||||||
| `ctx.api` | Shared service from `boot.js` |
|
| `ctx.api` | Shared service from `boot.js` |
|
||||||
| `ctx.notify` | 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
|
A page that passes `{ signal: ctx.signal }` to `addEventListener` and
|
||||||
`fetch` needs no teardown code. Its listeners and in-flight requests go
|
`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
|
`textContent` rather than markup strings, and a newer load supersedes an
|
||||||
older one.
|
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
|
### One facade
|
||||||
|
|
||||||
`window.LEDMatrix` is the only global the module code adds:
|
`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 |
|
| 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` |
|
| 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) |
|
| 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 |
|
| 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 | 147 | `webLogin` global and the security section. The first page that touches login |
|
| 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 | 231 | First page with `LEDVisibility` timers: those move to a `ctx.visibility` service that stops on destroy |
|
| 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 |
|
| 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 |
|
| 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) |
|
| 11 | Fonts | 681 | Large, but self-contained (6 globals) |
|
||||||
@@ -258,7 +298,7 @@ the order:
|
|||||||
| `showNotification` | 4 versions | `core/notify.js` |
|
| `showNotification` | 4 versions | `core/notify.js` |
|
||||||
| The modal helper | `utils/dialog.js` | `core/dialog.js` |
|
| The modal helper | `utils/dialog.js` | `core/dialog.js` |
|
||||||
| SSE streams | `app-shell.js` | `core/streams.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
|
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
|
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 |
|
| 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) |
|
| `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_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_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_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_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 |
|
| `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/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 |
|
| `test/test_field_model_parity.py` | pytest | The model against the macro for every available schema |
|
||||||
|
|||||||
@@ -36,8 +36,10 @@ src/common/sports_card.py
|
|||||||
src/common/sports_card_wrappers.py
|
src/common/sports_card_wrappers.py
|
||||||
src/common/sports_celebration.py
|
src/common/sports_celebration.py
|
||||||
src/common/sports_display_rules.py
|
src/common/sports_display_rules.py
|
||||||
|
src/common/sports_favorites.py
|
||||||
src/common/sports_fetch.py
|
src/common/sports_fetch.py
|
||||||
src/common/sports_font_path.py
|
src/common/sports_font_path.py
|
||||||
|
src/common/sports_game_over.py
|
||||||
src/common/sports_live_scroll.py
|
src/common/sports_live_scroll.py
|
||||||
src/common/sports_plugin_host.py
|
src/common/sports_plugin_host.py
|
||||||
src/common/sports_scroll.py
|
src/common/sports_scroll.py
|
||||||
@@ -58,6 +60,7 @@ src/ipc/contract.py
|
|||||||
src/ipc/server.py
|
src/ipc/server.py
|
||||||
src/logging_config.py
|
src/logging_config.py
|
||||||
src/logo_downloader.py
|
src/logo_downloader.py
|
||||||
|
src/malloc_tuning.py
|
||||||
src/matrix_support.py
|
src/matrix_support.py
|
||||||
src/pi5_matrix_support.py
|
src/pi5_matrix_support.py
|
||||||
src/plugin_system/__init__.py
|
src/plugin_system/__init__.py
|
||||||
@@ -88,6 +91,7 @@ src/plugin_system/testing/vegas.py
|
|||||||
src/plugin_system/vegas_elements.py
|
src/plugin_system/vegas_elements.py
|
||||||
src/redaction.py
|
src/redaction.py
|
||||||
src/scan_order.py
|
src/scan_order.py
|
||||||
|
src/screen_runner.py
|
||||||
src/startup_validator.py
|
src/startup_validator.py
|
||||||
src/vegas_mode/__init__.py
|
src/vegas_mode/__init__.py
|
||||||
src/vegas_mode/config.py
|
src/vegas_mode/config.py
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ project_dir = os.path.dirname(os.path.abspath(__file__))
|
|||||||
if project_dir not in sys.path:
|
if project_dir not in sys.path:
|
||||||
sys.path.insert(0, project_dir)
|
sys.path.insert(0, project_dir)
|
||||||
|
|
||||||
|
# Cap glibc's malloc arenas before any thread exists (arenas already made
|
||||||
|
# stay): the in-process twin of the unit's MALLOC_ARENA_MAX=2, for units
|
||||||
|
# installed before that line. A no-op off glibc. See src/malloc_tuning.py.
|
||||||
|
from src import malloc_tuning
|
||||||
|
malloc_tuning.cap_arenas()
|
||||||
|
|
||||||
# Under systemd the watchdog clock is already running, and start-up (plugin
|
# Under systemd the watchdog clock is already running, and start-up (plugin
|
||||||
# loads, initial updates) takes far longer than the render loop's limit. Widen
|
# loads, initial updates) takes far longer than the render loop's limit. Widen
|
||||||
# it before anything slow is imported; the render loop narrows it again once
|
# it before anything slow is imported; the render loop narrows it again once
|
||||||
|
|||||||
+1
-1
@@ -4,5 +4,5 @@ LEDMatrix Display System
|
|||||||
Core source package for the LED Matrix Display project.
|
Core source package for the LED Matrix Display project.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "3.8.0"
|
__version__ = "3.8.2"
|
||||||
|
|
||||||
|
|||||||
@@ -213,8 +213,9 @@ def list_installed_plugins(project_root: Path) -> List[Dict[str, Any]]:
|
|||||||
The plugins are the ``manifest.json`` files in the configured plugin
|
The plugins are the ``manifest.json`` files in the configured plugin
|
||||||
directory (see :func:`_plugins_directory`), with the manifest's version;
|
directory (see :func:`_plugins_directory`), with the manifest's version;
|
||||||
``enabled`` is config.json's flag by the display's rule (a missing flag
|
``enabled`` is config.json's flag by the display's rule (a missing flag
|
||||||
is disabled). A restore reinstalls every listed plugin and takes enabled
|
is disabled). A restore installs each listed plugin that is missing and
|
||||||
state from the restored config.json, so ``enabled`` is informational.
|
takes enabled state from the restored config.json, so ``enabled`` is
|
||||||
|
informational.
|
||||||
|
|
||||||
``data/plugin_state.json`` is not read: it only ever repeated config's
|
``data/plugin_state.json`` is not read: it only ever repeated config's
|
||||||
enabled flags and the manifests' versions, and is retired (nothing
|
enabled flags and the manifests' versions, and is retired (nothing
|
||||||
|
|||||||
Vendored
+40
-5
@@ -4,6 +4,7 @@ Disk Cache
|
|||||||
Handles persistent disk-based caching with atomic writes and error recovery.
|
Handles persistent disk-based caching with atomic writes and error recovery.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
@@ -31,6 +32,35 @@ except ImportError: # pragma: no cover - exercised on hosts without the wheel
|
|||||||
# useful, and a half-written file was never useful.
|
# useful, and a half-written file was never useful.
|
||||||
_ORPHAN_TEMP_MAX_AGE_SECONDS = 3600
|
_ORPHAN_TEMP_MAX_AGE_SECONDS = 3600
|
||||||
|
|
||||||
|
# Longest key, in UTF-8 bytes, used verbatim as a filename stem. ext4 caps a
|
||||||
|
# name at 255 bytes and set()'s temp file is ".<stem>.json.<8 random>", 15
|
||||||
|
# bytes longer than the stem, so anything near the cap could never be written:
|
||||||
|
# the calendar plugin's key joins every calendar id and passed 300 bytes on a
|
||||||
|
# real install, failing every write with ENAMETOOLONG. Longer keys keep this
|
||||||
|
# many bytes as a readable prefix and end in a hash of the whole key.
|
||||||
|
_MAX_KEY_FILENAME_BYTES = 200
|
||||||
|
_KEY_HASH_CHARS = 16
|
||||||
|
|
||||||
|
|
||||||
|
def _filename_stem(key: str) -> str:
|
||||||
|
"""The filename stem for a key that is already a safe path component.
|
||||||
|
|
||||||
|
Short keys are used as they are, so every file already on disk keeps its
|
||||||
|
name. A long one becomes its first bytes plus a hash of the full key: the
|
||||||
|
prefix keeps the stem recognisable (and keeps the data-type words that
|
||||||
|
cleanup's retention lookup reads from it), the hash keeps two keys that
|
||||||
|
share a long prefix apart. The result is itself short, so a stem read back
|
||||||
|
from a filename -- which is how the web UI names a key it deletes -- maps to
|
||||||
|
the same file.
|
||||||
|
"""
|
||||||
|
encoded = key.encode('utf-8')
|
||||||
|
if len(encoded) <= _MAX_KEY_FILENAME_BYTES:
|
||||||
|
return key
|
||||||
|
digest = hashlib.sha256(encoded).hexdigest()[:_KEY_HASH_CHARS]
|
||||||
|
keep = _MAX_KEY_FILENAME_BYTES - _KEY_HASH_CHARS - 1
|
||||||
|
prefix = encoded[:keep].decode('utf-8', errors='ignore')
|
||||||
|
return f"{prefix}-{digest}"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class CacheStrategyProtocol(Protocol):
|
class CacheStrategyProtocol(Protocol):
|
||||||
@@ -343,6 +373,8 @@ class DiskCache:
|
|||||||
derives them), so rejecting anything with a path component turns
|
derives them), so rejecting anything with a path component turns
|
||||||
away only inputs that could never have been written here.
|
away only inputs that could never have been written here.
|
||||||
|
|
||||||
|
A key too long to be a filename is shortened by _filename_stem.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
key: Cache key
|
key: Cache key
|
||||||
|
|
||||||
@@ -356,7 +388,7 @@ class DiskCache:
|
|||||||
if safe_key is None:
|
if safe_key is None:
|
||||||
self.logger.warning("Rejected unsafe cache key %r", key)
|
self.logger.warning("Rejected unsafe cache key %r", key)
|
||||||
return None
|
return None
|
||||||
return os.path.join(self.cache_dir, f"{safe_key}.json")
|
return os.path.join(self.cache_dir, f"{_filename_stem(safe_key)}.json")
|
||||||
|
|
||||||
def get(self, key: str, max_age: Optional[int] = 300) -> Optional[Dict[str, Any]]:
|
def get(self, key: str, max_age: Optional[int] = 300) -> Optional[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
@@ -561,7 +593,7 @@ class DiskCache:
|
|||||||
# If direct write also fails, try fallback location
|
# If direct write also fails, try fallback location
|
||||||
self.logger.warning("Direct write failed for key '%s' to %s: %s", key, cache_path, write_error)
|
self.logger.warning("Direct write failed for key '%s' to %s: %s", key, cache_path, write_error)
|
||||||
raise # Re-raise to trigger fallback logic
|
raise # Re-raise to trigger fallback logic
|
||||||
except (IOError, OSError, PermissionError):
|
except (IOError, OSError, PermissionError) as primary_error:
|
||||||
# Attempt one-time fallback write to user's home cache directory
|
# Attempt one-time fallback write to user's home cache directory
|
||||||
try:
|
try:
|
||||||
# Try user's home cache directory as fallback
|
# Try user's home cache directory as fallback
|
||||||
@@ -587,11 +619,14 @@ class DiskCache:
|
|||||||
self.logger.debug("Fallback cache write also failed for key '%s': %s", key, e2)
|
self.logger.debug("Fallback cache write also failed for key '%s': %s", key, e2)
|
||||||
|
|
||||||
# If all write attempts failed, log warning but don't raise exception
|
# If all write attempts failed, log warning but don't raise exception
|
||||||
# Cache is a performance optimization, not critical for operation
|
# Cache is a performance optimization, not critical for operation.
|
||||||
|
# Name the real error: this used to say "permission denied"
|
||||||
|
# whatever happened, which sent a too-long filename off to
|
||||||
|
# be debugged as a directory-ownership problem.
|
||||||
self.logger.warning(
|
self.logger.warning(
|
||||||
"Could not write cache for key '%s' to %s (permission denied). "
|
"Could not write cache for key '%s' to %s (%s). "
|
||||||
"Cache will be unavailable for this key, but application will continue.",
|
"Cache will be unavailable for this key, but application will continue.",
|
||||||
key, cache_path
|
key, cache_path, primary_error.strerror or primary_error
|
||||||
)
|
)
|
||||||
return # Exit gracefully without raising exception
|
return # Exit gracefully without raising exception
|
||||||
|
|
||||||
|
|||||||
+90
-3
@@ -28,7 +28,7 @@ import os
|
|||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import pytz
|
import pytz
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -46,6 +46,69 @@ from src.cache.disk_cache import DateTimeEncoder # noqa: F401 - deliberate re-e
|
|||||||
# CacheManager.config_manager not built yet (None means "not available").
|
# CacheManager.config_manager not built yet (None means "not available").
|
||||||
_UNSET: Any = object()
|
_UNSET: Any = object()
|
||||||
|
|
||||||
|
|
||||||
|
def _outlived(record: Any, max_age: Optional[float], now: float) -> bool:
|
||||||
|
"""Whether a record's own timestamp puts it past max_age.
|
||||||
|
|
||||||
|
The memory tier times an entry from when it was put there, and a record
|
||||||
|
loaded from disk is put there when it is read, not when it was written: a
|
||||||
|
record 290 s old, read after a restart, could be served for another
|
||||||
|
max_age from memory. This is the age check DiskCache.get makes, with the
|
||||||
|
same rule that a stored ttl wins over the caller's max_age. A record that
|
||||||
|
carries no timestamp is left to the memory tier's own clock.
|
||||||
|
"""
|
||||||
|
if not isinstance(record, dict):
|
||||||
|
return False
|
||||||
|
stored_ttl = record.get('ttl')
|
||||||
|
if isinstance(stored_ttl, (int, float)) and not isinstance(stored_ttl, bool) \
|
||||||
|
and stored_ttl >= 0:
|
||||||
|
max_age = stored_ttl
|
||||||
|
stamp = record.get('timestamp')
|
||||||
|
if max_age is None or stamp is None or isinstance(stamp, bool):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return now - float(stamp) > max_age
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
_NOT_SEEN: Any = object()
|
||||||
|
|
||||||
|
|
||||||
|
class MailboxWatch:
|
||||||
|
"""Tells the poller of a mailbox key whether its file changed since the
|
||||||
|
last look, from one stat() (:meth:`CacheManager.file_signature`).
|
||||||
|
|
||||||
|
The display polls the mailboxes the web interface falls back to. Reading
|
||||||
|
one is an open and a JSON parse; with this a poll that finds the same file
|
||||||
|
(or none) costs a stat, and the file is read only after a new write. A
|
||||||
|
cache without ``file_signature`` (a test double) is read every time.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, key: str):
|
||||||
|
self.key = key
|
||||||
|
self._seen: Any = _NOT_SEEN
|
||||||
|
|
||||||
|
def changed(self, cache_manager: Any) -> bool:
|
||||||
|
"""True when the poller should read the key now."""
|
||||||
|
signature = getattr(cache_manager, 'file_signature', None)
|
||||||
|
sig = signature(self.key) if callable(signature) else _NOT_SEEN
|
||||||
|
if sig is not None and not isinstance(sig, tuple):
|
||||||
|
return True # cannot tell: read it
|
||||||
|
if sig is None:
|
||||||
|
self._seen = None
|
||||||
|
return False # no file, nothing to read
|
||||||
|
if sig == self._seen:
|
||||||
|
return False
|
||||||
|
self._seen = sig
|
||||||
|
return True
|
||||||
|
|
||||||
|
def forget(self) -> None:
|
||||||
|
"""Read the key on the next poll even if its file has not changed
|
||||||
|
(the last read failed)."""
|
||||||
|
self._seen = _NOT_SEEN
|
||||||
|
|
||||||
|
|
||||||
class CacheManager:
|
class CacheManager:
|
||||||
"""Manages caching of API responses to reduce API calls."""
|
"""Manages caching of API responses to reduce API calls."""
|
||||||
|
|
||||||
@@ -270,6 +333,24 @@ class CacheManager:
|
|||||||
"""Get the path for a cache file."""
|
"""Get the path for a cache file."""
|
||||||
return self._disk_cache_component.get_cache_path(key)
|
return self._disk_cache_component.get_cache_path(key)
|
||||||
|
|
||||||
|
def file_signature(self, key: str) -> Optional[Tuple[int, int, int]]:
|
||||||
|
"""``(st_ino, st_mtime_ns, st_size)`` of ``key``'s file, or None when
|
||||||
|
there is no file (the key is absent, or this cache has no disk tier).
|
||||||
|
|
||||||
|
One stat(), no read: a poller of a mailbox another process writes
|
||||||
|
compares it with the last one it saw and reads the file only when it
|
||||||
|
changed. Every write replaces the file (a temp file renamed into
|
||||||
|
place), so a new write always has a new inode, however fast it came.
|
||||||
|
"""
|
||||||
|
path = self._get_cache_path(key)
|
||||||
|
if not path:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
st = os.stat(path)
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
return (st.st_ino, st.st_mtime_ns, st.st_size)
|
||||||
|
|
||||||
def get_cached_data(self, key: str, max_age: int = 300, memory_ttl: Optional[int] = None) -> Optional[Dict[str, Any]]:
|
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.
|
"""Get data from cache (memory first, then disk) honoring TTLs.
|
||||||
|
|
||||||
@@ -284,7 +365,11 @@ class CacheManager:
|
|||||||
# 1) Memory cache
|
# 1) Memory cache
|
||||||
cached = self._memory_cache_component.get(key, max_age=in_memory_ttl)
|
cached = self._memory_cache_component.get(key, max_age=in_memory_ttl)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
return cached
|
if not _outlived(cached, max_age, time.time()):
|
||||||
|
return cached
|
||||||
|
# Too old for this reader. Disk may hold a newer write (from the
|
||||||
|
# other process), and if it does not, the miss is the right answer.
|
||||||
|
self._memory_cache_component.clear(key)
|
||||||
|
|
||||||
# 2) Disk cache
|
# 2) Disk cache
|
||||||
record = self._disk_cache_component.get(key, max_age=max_age)
|
record = self._disk_cache_component.get(key, max_age=max_age)
|
||||||
@@ -318,7 +403,9 @@ class CacheManager:
|
|||||||
# Check memory cache first (1 minute TTL)
|
# Check memory cache first (1 minute TTL)
|
||||||
cached = self._memory_cache_component.get(key, max_age=60)
|
cached = self._memory_cache_component.get(key, max_age=60)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
return cached
|
if not _outlived(cached, 3600, time.time()):
|
||||||
|
return cached
|
||||||
|
self._memory_cache_component.clear(key)
|
||||||
|
|
||||||
# Check disk cache
|
# Check disk cache
|
||||||
data = self._disk_cache_component.get(key, max_age=3600) # 1 hour for load_cache
|
data = self._disk_cache_component.get(key, max_age=3600) # 1 hour for load_cache
|
||||||
|
|||||||
+31
-3
@@ -44,8 +44,10 @@ Rules for the package:
|
|||||||
| [`sports_card_wrappers`](#sports_card_wrappers) | The game renderer's `sports_card` delegations | Yes (scoreboards) | 3.7.0 |
|
| [`sports_card_wrappers`](#sports_card_wrappers) | The game renderer's `sports_card` delegations | Yes (scoreboards) | 3.7.0 |
|
||||||
| [`sports_celebration`](#sports_celebration) | Draw a scoreboard's score/win celebration | Yes (scoreboards) | 3.7.0 |
|
| [`sports_celebration`](#sports_celebration) | Draw a scoreboard's score/win celebration | Yes (scoreboards) | 3.7.0 |
|
||||||
| [`sports_display_rules`](#sports_display_rules) | Which games a scoreboard shows, for how long, and its scorebug date line | Yes (scoreboards) | 3.8.0 |
|
| [`sports_display_rules`](#sports_display_rules) | Which games a scoreboard shows, for how long, and its scorebug date line | Yes (scoreboards) | 3.8.0 |
|
||||||
|
| [`sports_favorites`](#sports_favorites) | Which games involve a favourite team, and the favourites-only picks | Yes (scoreboards) | 3.8.2 |
|
||||||
| [`sports_fetch`](#sports_fetch) | Scoreboard season fetch, lookback and live-odds decisions | Yes (scoreboards) | 3.7.0 |
|
| [`sports_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_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_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_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 |
|
| [`sports_live_scroll`](#sports_live_scroll) | Rebuild a live scroll strip mid-cycle without moving it | Yes (scoreboards) | 3.8.0 |
|
||||||
@@ -109,8 +111,11 @@ and the plugin test harness all use it. Most plugins get BDF text through
|
|||||||
[`espn_dates.py`](espn_dates.py). ESPN's site API rejects `dates=` ranges
|
[`espn_dates.py`](espn_dates.py). ESPN's site API rejects `dates=` ranges
|
||||||
and truncates results when `limit` is above 500. `fetch_espn_scoreboard()`
|
and truncates results when `limit` is above 500. `fetch_espn_scoreboard()`
|
||||||
splits a range into month and day requests ESPN accepts and merges the
|
splits a range into month and day requests ESPN accepts and merges the
|
||||||
results; `espn_date_chunks()`, `fetch_espn_date_chunks()`,
|
results; `espn_date_chunks()`, `espn_request_chunks()`,
|
||||||
`clamp_espn_limit()` and `merge_scoreboard_payloads()` are the pieces.
|
`fetch_espn_date_chunks()`, `clamp_espn_limit()` and
|
||||||
|
`merge_scoreboard_payloads()` are the pieces. A window's partial edge months
|
||||||
|
are asked whole and trimmed to its days (US Eastern), and chunk requests share
|
||||||
|
one process-wide cap of `ESPN_CHUNK_WORKERS` in flight.
|
||||||
Every request goes through [`fetch_service`](#fetch_service), the chunks
|
Every request goes through [`fetch_service`](#fetch_service), the chunks
|
||||||
counted against the plugin that asked. Scoreboard plugins also bundle a copy
|
counted against the plugin that asked. Scoreboard plugins also bundle a copy
|
||||||
for older cores.
|
for older cores.
|
||||||
@@ -276,6 +281,19 @@ list it before `SportsCoreSharedMixin`) and `SportsGameRulesMixin`
|
|||||||
`_effective_live_duration()`, the shorter dwell for a non-favourite live
|
`_effective_live_duration()`, the shorter dwell for a non-favourite live
|
||||||
game).
|
game).
|
||||||
|
|
||||||
|
### sports_favorites
|
||||||
|
|
||||||
|
[`sports_favorites.py`](sports_favorites.py). Sports family 6, one mixin per
|
||||||
|
class that carried the methods: `SportsFavoritesMixin` (`SportsCore`:
|
||||||
|
`_is_favorite_game(game)` and `_favorite_code(value)`),
|
||||||
|
`SportsUpcomingFavoritesMixin` (`_select_games_for_display`) and
|
||||||
|
`SportsRecentFavoritesMixin` (`_select_recent_games_for_display`). Each side
|
||||||
|
of a game is named by `_favorite_key` (from `SportsHelpersMixin`; NRL
|
||||||
|
overrides it with the team id) and compared with `favorite_teams` stripped and
|
||||||
|
upper-cased. The selection methods give each favourite up to the per-team
|
||||||
|
limit, count a game between two favourites for both, and treat only games
|
||||||
|
with an id as possible duplicates.
|
||||||
|
|
||||||
### sports_fetch
|
### sports_fetch
|
||||||
|
|
||||||
[`sports_fetch.py`](sports_fetch.py). `SportsFetchMixin`: the `SportsCore`
|
[`sports_fetch.py`](sports_fetch.py). `SportsFetchMixin`: the `SportsCore`
|
||||||
@@ -291,6 +309,16 @@ path as given when it exists (relative to the cwd), else
|
|||||||
`font_layout.resolve_asset_path(path)`. What the scoreboards'
|
`font_layout.resolve_asset_path(path)`. What the scoreboards'
|
||||||
`_resolve_font_path` copies return on a core that ships it.
|
`_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
|
||||||
|
|
||||||
[`sports_game_renderer.py`](sports_game_renderer.py).
|
[`sports_game_renderer.py`](sports_game_renderer.py).
|
||||||
@@ -388,7 +416,7 @@ Created by `DisplayController`; works with any plugin.
|
|||||||
`draw_multiline_text()`, `create_text_image()`.
|
`draw_multiline_text()`, `create_text_image()`.
|
||||||
|
|
||||||
`draw_text_outlined(draw, xy, text, font, fill, outline_color=(0, 0, 0),
|
`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
|
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
|
offset, but the string is rasterized once. `OUTLINE_SQUARE` is the
|
||||||
eight-sided one-pixel outline the scoreboards draw, `OUTLINE_CROSS` the
|
eight-sided one-pixel outline the scoreboards draw, `OUTLINE_CROSS` the
|
||||||
|
|||||||
+189
-37
@@ -26,10 +26,30 @@ A month can hold more than 500 events (college baseball's March does), and
|
|||||||
ESPN answers that with exactly ``limit`` events and no hint that more exist. A
|
ESPN answers that with exactly ``limit`` events and no hint that more exist. A
|
||||||
month chunk that comes back full is therefore re-asked day by day.
|
month chunk that comes back full is therefore re-asked day by day.
|
||||||
|
|
||||||
|
A window's *partial* edge months are asked for whole, too, once the window
|
||||||
|
covers ``ESPN_MONTH_COVER_MIN_DAYS`` or more of their days, and the answer is
|
||||||
|
trimmed back to the window's days. A scoreboard's default fortnight either side
|
||||||
|
of today (29 days, two partial months) was 29 day requests per league; it is
|
||||||
|
now 2. Trimming needs ESPN's "game day", which is the event's start in US
|
||||||
|
Eastern time -- checked against the live API on 2026-10-03: 417 of 417 soccer
|
||||||
|
events across five leagues and three months (one of them spanning the end of
|
||||||
|
daylight saving) came back from exactly the day query their Eastern date
|
||||||
|
names. A short window (a live poll's one or two days) stays day by day, so it
|
||||||
|
never downloads a whole month to read a day of it.
|
||||||
|
|
||||||
|
Chunk requests share one process-wide budget of ``ESPN_CHUNK_WORKERS`` in
|
||||||
|
flight, however many windows are being fetched at once. Each window used to get
|
||||||
|
its own six, so a scoreboard starting eight leagues -- each with a recent and
|
||||||
|
an upcoming manager -- had ~40 requests in flight, every one beyond a session's
|
||||||
|
pool a new connection and a new DNS lookup. On a Pi whose resolver could not
|
||||||
|
keep up, that was ~90 ``NameResolutionError`` lines within a minute of every
|
||||||
|
start.
|
||||||
|
|
||||||
Once a range has been rejected, later ranges skip straight to chunks for
|
Once a range has been rejected, later ranges skip straight to chunks for
|
||||||
``RANGE_RETRY_SECONDS`` instead of spending a doomed request first -- live
|
``RANGE_RETRY_SECONDS`` instead of spending a doomed request first -- live
|
||||||
scoreboards ask every 30 seconds. After that the range is tried again, so the
|
scoreboards ask every 30 seconds. After that the range is tried again, so the
|
||||||
workaround retires itself if ESPN reverts.
|
workaround retires itself if ESPN reverts. A process starts inside that
|
||||||
|
period, as if a range had just been rejected.
|
||||||
|
|
||||||
ONE CACHE KEY PER SCOREBOARD
|
ONE CACHE KEY PER SCOREBOARD
|
||||||
----------------------------
|
----------------------------
|
||||||
@@ -55,7 +75,7 @@ import re
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta, tzinfo
|
||||||
from functools import partial
|
from functools import partial
|
||||||
from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple, cast
|
from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple, cast
|
||||||
|
|
||||||
@@ -100,16 +120,57 @@ RANGE_RETRY_SECONDS = 6 * 60 * 60
|
|||||||
# pool_maxsize of 10 so the shared Session never has to discard connections.
|
# pool_maxsize of 10 so the shared Session never has to discard connections.
|
||||||
ESPN_CHUNK_WORKERS = 6
|
ESPN_CHUNK_WORKERS = 6
|
||||||
|
|
||||||
|
#: An edge month the window covers at least this many days of is asked for
|
||||||
|
#: whole and trimmed, instead of one request per day (see module docstring).
|
||||||
|
#: Below it the days are cheaper than the month: a whole month is two to
|
||||||
|
#: three times the bytes of the half of it a fortnight window holds.
|
||||||
|
ESPN_MONTH_COVER_MIN_DAYS = 7
|
||||||
|
|
||||||
|
# Every chunk request in the process holds one of these while it is in flight
|
||||||
|
# -- the cap is per process, not per window (see module docstring).
|
||||||
|
_chunk_slots = threading.BoundedSemaphore(ESPN_CHUNK_WORKERS)
|
||||||
|
|
||||||
|
|
||||||
|
def _eastern_zone() -> Optional[tzinfo]:
|
||||||
|
"""US Eastern, the zone ESPN's ``dates=YYYYMMDD`` means, or None when
|
||||||
|
this Python has no time zone data (no edge month is trimmed then)."""
|
||||||
|
zone: Optional[tzinfo] = None
|
||||||
|
try:
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
zone = ZoneInfo("America/New_York")
|
||||||
|
except Exception: # noqa: BLE001 - no zoneinfo module or no tz database
|
||||||
|
zone = None
|
||||||
|
if zone is not None:
|
||||||
|
return zone
|
||||||
|
try:
|
||||||
|
import pytz
|
||||||
|
return cast(tzinfo, pytz.timezone("America/New_York"))
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
_EASTERN = _eastern_zone()
|
||||||
|
|
||||||
|
# What _fetch_one_chunk returns for a month that came back at the cap.
|
||||||
|
_CAPPED: Any = object()
|
||||||
|
|
||||||
_range_lock = threading.Lock()
|
_range_lock = threading.Lock()
|
||||||
_ranges_rejected_until = 0.0
|
# A process starts out assuming ranges are still rejected, as they have been
|
||||||
|
# since 2026-09-15, and tries one again RANGE_RETRY_SECONDS in. Starting
|
||||||
|
# from "unknown" cost one doomed range request per window at every start --
|
||||||
|
# eleven 400s at once from a soccer board, each fetching before any had
|
||||||
|
# answered -- to learn what every start learns.
|
||||||
|
_ranges_rejected_until = time.monotonic() + RANGE_RETRY_SECONDS
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ESPN_MAX_LIMIT",
|
"ESPN_MAX_LIMIT",
|
||||||
"ESPN_CHUNK_WORKERS",
|
"ESPN_CHUNK_WORKERS",
|
||||||
|
"ESPN_MONTH_COVER_MIN_DAYS",
|
||||||
"RANGE_RETRY_SECONDS",
|
"RANGE_RETRY_SECONDS",
|
||||||
"clamp_espn_limit",
|
"clamp_espn_limit",
|
||||||
"parse_espn_date_range",
|
"parse_espn_date_range",
|
||||||
"espn_date_chunks",
|
"espn_date_chunks",
|
||||||
|
"espn_request_chunks",
|
||||||
"merge_scoreboard_payloads",
|
"merge_scoreboard_payloads",
|
||||||
"fetch_espn_date_chunks",
|
"fetch_espn_date_chunks",
|
||||||
"fetch_espn_scoreboard",
|
"fetch_espn_scoreboard",
|
||||||
@@ -220,6 +281,79 @@ def espn_date_chunks(start: date, end: date) -> List[str]:
|
|||||||
return chunks
|
return chunks
|
||||||
|
|
||||||
|
|
||||||
|
def espn_request_chunks(
|
||||||
|
start: date,
|
||||||
|
end: date,
|
||||||
|
month_cover_min_days: Optional[int] = None,
|
||||||
|
) -> List[Tuple[str, Optional[Tuple[date, date]]]]:
|
||||||
|
"""The requests that fetch ``[start, end]``, as ``(dates, trim)`` pairs.
|
||||||
|
|
||||||
|
:func:`espn_date_chunks`, except that a partial edge month with
|
||||||
|
``month_cover_min_days`` (default ``ESPN_MONTH_COVER_MIN_DAYS``) or more
|
||||||
|
of its days in the window becomes one ``YYYYMM`` request whose ``trim``
|
||||||
|
is the first and last of those days: its events that start outside them
|
||||||
|
(US Eastern) are dropped. ``trim`` is None for every other request.
|
||||||
|
Without time zone data nothing can be trimmed, so the edge days stay day
|
||||||
|
requests.
|
||||||
|
"""
|
||||||
|
if month_cover_min_days is None:
|
||||||
|
month_cover_min_days = ESPN_MONTH_COVER_MIN_DAYS
|
||||||
|
planned: List[Tuple[str, Optional[Tuple[date, date]]]] = []
|
||||||
|
run: List[str] = []
|
||||||
|
|
||||||
|
def flush() -> None:
|
||||||
|
if (_EASTERN is not None and month_cover_min_days > 0
|
||||||
|
and len(run) >= month_cover_min_days):
|
||||||
|
planned.append((run[0][:6], (_parse_day(run[0]), _parse_day(run[-1]))))
|
||||||
|
else:
|
||||||
|
planned.extend((day, None) for day in run)
|
||||||
|
run.clear()
|
||||||
|
|
||||||
|
for chunk in espn_date_chunks(start, end):
|
||||||
|
if run and (len(chunk) != 8 or chunk[:6] != run[0][:6]):
|
||||||
|
flush()
|
||||||
|
if len(chunk) == 8:
|
||||||
|
run.append(chunk)
|
||||||
|
else:
|
||||||
|
planned.append((chunk, None))
|
||||||
|
flush()
|
||||||
|
return planned
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_day(text: str) -> date:
|
||||||
|
return date(int(text[:4]), int(text[4:6]), int(text[6:8]))
|
||||||
|
|
||||||
|
|
||||||
|
def _eastern_day(stamp: Any) -> Optional[date]:
|
||||||
|
"""The US Eastern date of an ESPN event ``date`` ("2026-10-10T11:30Z"),
|
||||||
|
or None when it cannot be read."""
|
||||||
|
if not isinstance(stamp, str) or _EASTERN is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
moment = datetime.fromisoformat(stamp.strip().replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if moment.tzinfo is None:
|
||||||
|
return None
|
||||||
|
return moment.astimezone(_EASTERN).date()
|
||||||
|
|
||||||
|
|
||||||
|
def _trim_to_days(payload: Any, first: date, last: date) -> Any:
|
||||||
|
"""Drop the events of a month payload that start outside ``[first, last]``
|
||||||
|
(US Eastern). An event whose date cannot be read is kept: its day query
|
||||||
|
might well have returned it, and a game is never dropped on a guess.
|
||||||
|
"""
|
||||||
|
if not isinstance(payload, dict) or not isinstance(payload.get("events"), list):
|
||||||
|
return payload
|
||||||
|
kept = []
|
||||||
|
for event in payload["events"]:
|
||||||
|
day = _eastern_day(event.get("date")) if isinstance(event, dict) else None
|
||||||
|
if day is None or first <= day <= last:
|
||||||
|
kept.append(event)
|
||||||
|
payload["events"] = kept
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def merge_scoreboard_payloads(payloads: List[Any]) -> Dict[str, Any]:
|
def merge_scoreboard_payloads(payloads: List[Any]) -> Dict[str, Any]:
|
||||||
"""Fold chunk responses into one scoreboard payload.
|
"""Fold chunk responses into one scoreboard payload.
|
||||||
|
|
||||||
@@ -250,37 +384,54 @@ def merge_scoreboard_payloads(payloads: List[Any]) -> Dict[str, Any]:
|
|||||||
def _fetch_one_chunk(
|
def _fetch_one_chunk(
|
||||||
session, url: str, params: Dict[str, Any], headers, timeout, logger, chunk: str,
|
session, url: str, params: Dict[str, Any], headers, timeout, logger, chunk: str,
|
||||||
cache_max_age: Optional[float] = None,
|
cache_max_age: Optional[float] = None,
|
||||||
) -> Optional[Dict[str, Any]]:
|
trims: Optional[Dict[str, Tuple[date, date]]] = None,
|
||||||
|
) -> Any:
|
||||||
"""GET a single ``dates=`` chunk, or None when it failed.
|
"""GET a single ``dates=`` chunk, or None when it failed.
|
||||||
|
|
||||||
One bad chunk must not sink the rest of the season, so every error is
|
One bad chunk must not sink the rest of the season, so every error is
|
||||||
logged and swallowed here rather than raised to the gather below.
|
logged and swallowed here rather than raised to the gather below.
|
||||||
|
|
||||||
|
A month that comes back at the cap is truncated: it returns ``_CAPPED``,
|
||||||
|
its payload dropped here before it is ever held beside the others. A
|
||||||
|
month in ``trims`` loses its events outside the days given there.
|
||||||
|
|
||||||
|
The request holds one of the process-wide ``_chunk_slots`` while it runs.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
response = fetch_get(
|
with _chunk_slots:
|
||||||
session,
|
response = fetch_get(
|
||||||
url,
|
session,
|
||||||
params=dict(params, dates=chunk, limit=ESPN_MAX_LIMIT),
|
url,
|
||||||
headers=headers,
|
params=dict(params, dates=chunk, limit=ESPN_MAX_LIMIT),
|
||||||
timeout=timeout,
|
headers=headers,
|
||||||
**_memo_kwargs(cache_max_age),
|
timeout=timeout,
|
||||||
)
|
**_memo_kwargs(cache_max_age),
|
||||||
response.raise_for_status()
|
)
|
||||||
return cast(Optional[Dict[str, Any]], response_json(response))
|
response.raise_for_status()
|
||||||
|
payload = response_json(response)
|
||||||
except Exception as exc: # noqa: BLE001 - see docstring
|
except Exception as exc: # noqa: BLE001 - see docstring
|
||||||
if logger:
|
if logger:
|
||||||
logger.warning("ESPN chunk %s failed, skipping it: %s", chunk, exc)
|
logger.warning("ESPN chunk %s failed, skipping it: %s", chunk, exc)
|
||||||
return None
|
return None
|
||||||
|
if len(chunk) == 6 and isinstance(payload, dict):
|
||||||
|
if len(payload.get("events") or []) >= ESPN_MAX_LIMIT:
|
||||||
|
return _CAPPED
|
||||||
|
trim = (trims or {}).get(chunk)
|
||||||
|
if trim is not None:
|
||||||
|
payload = _trim_to_days(payload, *trim)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def _fetch_chunks(
|
def _fetch_chunks(
|
||||||
session, url: str, params: Dict[str, Any], headers, timeout, logger,
|
session, url: str, params: Dict[str, Any], headers, timeout, logger,
|
||||||
chunks: List[str], cache_max_age: Optional[float] = None,
|
chunks: List[str], cache_max_age: Optional[float] = None,
|
||||||
) -> List[Optional[Dict[str, Any]]]:
|
trims: Optional[Dict[str, Tuple[date, date]]] = None,
|
||||||
|
) -> List[Any]:
|
||||||
"""Fetch every chunk, returning payloads positionally aligned with ``chunks``.
|
"""Fetch every chunk, returning payloads positionally aligned with ``chunks``.
|
||||||
|
|
||||||
Requests go out ``ESPN_CHUNK_WORKERS`` at a time because a cold season is
|
Requests go out ``ESPN_CHUNK_WORKERS`` at a time because a cold season is
|
||||||
over a hundred of them. The order they come back in is not significant --
|
over a hundred of them -- and no more than that across every window the
|
||||||
|
process is fetching, which ``_fetch_one_chunk``'s slot enforces. The order they come back in is not significant --
|
||||||
callers keep ``chunks`` order from the returned list -- but it does mean
|
callers keep ``chunks`` order from the returned list -- but it does mean
|
||||||
the session is shared across threads, which is why this only ever issues
|
the session is shared across threads, which is why this only ever issues
|
||||||
GETs and never touches session state.
|
GETs and never touches session state.
|
||||||
@@ -293,7 +444,7 @@ def _fetch_chunks(
|
|||||||
return []
|
return []
|
||||||
fetch = partial(
|
fetch = partial(
|
||||||
_fetch_one_chunk, session, url, params, headers, timeout, logger,
|
_fetch_one_chunk, session, url, params, headers, timeout, logger,
|
||||||
cache_max_age=cache_max_age,
|
cache_max_age=cache_max_age, trims=trims,
|
||||||
)
|
)
|
||||||
if len(chunks) == 1:
|
if len(chunks) == 1:
|
||||||
return [fetch(chunks[0])]
|
return [fetch(chunks[0])]
|
||||||
@@ -340,7 +491,9 @@ def fetch_espn_date_chunks(
|
|||||||
if span is None:
|
if span is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
chunks = espn_date_chunks(*span)
|
planned = espn_request_chunks(*span)
|
||||||
|
chunks = [chunk for chunk, _ in planned]
|
||||||
|
trims = {chunk: trim for chunk, trim in planned if trim is not None}
|
||||||
if logger:
|
if logger:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Fetching ESPN date range %s as %d month/day chunks",
|
"Fetching ESPN date range %s as %d month/day chunks",
|
||||||
@@ -349,32 +502,31 @@ def fetch_espn_date_chunks(
|
|||||||
|
|
||||||
results = _fetch_chunks(
|
results = _fetch_chunks(
|
||||||
session, url, params, headers, timeout, logger, chunks, cache_max_age,
|
session, url, params, headers, timeout, logger, chunks, cache_max_age,
|
||||||
|
trims,
|
||||||
)
|
)
|
||||||
attempted = len(chunks)
|
attempted = len(chunks)
|
||||||
|
|
||||||
# A month that came back at the cap is truncated; its days replace it in
|
# A month that came back at the cap is truncated; its days (only the
|
||||||
# place, so merged events stay in chunk order however the requests raced.
|
# window's, for a trimmed edge month) replace it in place, so merged
|
||||||
|
# events stay in chunk order however the requests raced. Its payload was
|
||||||
|
# already dropped in the worker: a capped college-baseball month is ~2MB
|
||||||
|
# of parsed JSON, and holding four of them through ~120 day requests added
|
||||||
|
# ~25MB to the peak -- more than the concurrency itself. Low-memory boards
|
||||||
|
# (docs/LOW_MEMORY_BOARDS.md) have under 200MB of headroom.
|
||||||
slots: List[Any] = results
|
slots: List[Any] = results
|
||||||
capped: Dict[int, List[str]] = {}
|
capped: Dict[int, List[str]] = {}
|
||||||
for index, chunk in enumerate(chunks):
|
for index, chunk in enumerate(chunks):
|
||||||
payload = slots[index]
|
if slots[index] is not _CAPPED:
|
||||||
if payload is None or len(chunk) != 6:
|
|
||||||
continue
|
continue
|
||||||
events = payload.get("events") if isinstance(payload, dict) else None
|
if logger:
|
||||||
if len(events or []) >= ESPN_MAX_LIMIT:
|
logger.info(
|
||||||
if logger:
|
"ESPN month %s hit the %d-event cap; re-asking it day by day",
|
||||||
logger.info(
|
chunk, ESPN_MAX_LIMIT,
|
||||||
"ESPN month %s hit the %d-event cap; re-asking it day by day",
|
)
|
||||||
chunk, ESPN_MAX_LIMIT,
|
trim = trims.get(chunk)
|
||||||
)
|
capped[index] = (_days_of_month(chunk) if trim is None
|
||||||
capped[index] = _days_of_month(chunk)
|
else espn_date_chunks(*trim))
|
||||||
# Drop the truncated month now rather than after its days arrive:
|
slots[index] = None
|
||||||
# a capped college-baseball month is ~2MB of parsed JSON, and
|
|
||||||
# holding four of them through ~120 day requests added ~25MB to
|
|
||||||
# the peak -- more than the concurrency itself. Low-memory boards
|
|
||||||
# (docs/LOW_MEMORY_BOARDS.md) have under 200MB of headroom.
|
|
||||||
slots[index] = None
|
|
||||||
payload = events = None
|
|
||||||
|
|
||||||
if capped:
|
if capped:
|
||||||
days = [day for index in sorted(capped) for day in capped[index]]
|
days = [day for index in sorted(capped) for day in capped[index]]
|
||||||
|
|||||||
@@ -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
|
Identical means what the validator store keys on: URL, query, effective
|
||||||
headers and, for a session with cookies or auth, the session.
|
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
|
adapter retries, throttled requests and seconds waited, plus requests
|
||||||
answered without the network: ``memo_hits`` (the response cache) and
|
answered without the network: ``memo_hits`` (the response cache) and
|
||||||
``cache_hits`` / ``legacy_cache_hits`` (a shared ESPN scoreboard cache entry,
|
``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
|
"throttled", # requests that waited for a host budget
|
||||||
"overruns", # requests that went after max_wait_seconds anyway
|
"overruns", # requests that went after max_wait_seconds anyway
|
||||||
"bytes", # decoded response body bytes received
|
"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
|
"wait_seconds", # time spent waiting for host budgets
|
||||||
"memo_hits", # answered from the response cache (max-age); nothing sent
|
"memo_hits", # answered from the response cache (max-age); nothing sent
|
||||||
"cache_hits", # scoreboard fetches answered from a shared ESPN cache entry
|
"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
|
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:
|
def _retries_of(response: Any) -> int:
|
||||||
raw = getattr(response, "raw", None)
|
raw = getattr(response, "raw", None)
|
||||||
retries = getattr(raw, "retries", None)
|
retries = getattr(raw, "retries", None)
|
||||||
@@ -1117,6 +1145,7 @@ class FetchService:
|
|||||||
http_errors=int(status is not None and status >= 400),
|
http_errors=int(status is not None and status >= 400),
|
||||||
retries=_retries_of(response),
|
retries=_retries_of(response),
|
||||||
bytes=len(body) if body is not None else 0,
|
bytes=len(body) if body is not None else 0,
|
||||||
|
wire_bytes=_wire_bytes_of(response, body),
|
||||||
throttled=int(waited > 0), overruns=int(overrun),
|
throttled=int(waited > 0), overruns=int(overrun),
|
||||||
wait_seconds=waited)
|
wait_seconds=waited)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
+24
-24
@@ -561,7 +561,7 @@ class ScrollHelper:
|
|||||||
width = self.display_width
|
width = self.display_width
|
||||||
strip_width = self.cached_array.shape[1]
|
strip_width = self.cached_array.shape[1]
|
||||||
|
|
||||||
if start_x + width + 1 <= strip_width:
|
if 0 <= start_x and start_x + width + 1 <= strip_width:
|
||||||
# Slice the backing array directly. Going via
|
# Slice the backing array directly. Going via
|
||||||
# _get_visible_portion_integer would build two PIL images only for
|
# _get_visible_portion_integer would build two PIL images only for
|
||||||
# them to be converted straight back to arrays, which measured 15x
|
# them to be converted straight back to arrays, which measured 15x
|
||||||
@@ -569,9 +569,10 @@ class ScrollHelper:
|
|||||||
near = self.cached_array[:, start_x:start_x + width]
|
near = self.cached_array[:, start_x:start_x + width]
|
||||||
far = self.cached_array[:, start_x + 1:start_x + 1 + width]
|
far = self.cached_array[:, start_x + 1:start_x + 1 + width]
|
||||||
else:
|
else:
|
||||||
# Close enough to the end that one of the slices wraps; let the
|
# One of the slices wraps (close to the end, or a strip narrower
|
||||||
# integer path handle that and pay the conversion. Continuous mode
|
# than the panel); let the integer path handle that and pay the
|
||||||
# extends the strip before reaching here, so this is the rare case.
|
# conversion. Continuous mode extends the strip before reaching
|
||||||
|
# here, so this is the rare case.
|
||||||
near = np.asarray(
|
near = np.asarray(
|
||||||
self._get_visible_portion_integer(start_x, start_x + width))
|
self._get_visible_portion_integer(start_x, start_x + width))
|
||||||
far = np.asarray(
|
far = np.asarray(
|
||||||
@@ -601,34 +602,33 @@ class ScrollHelper:
|
|||||||
_size = (self.display_width, self.display_height)
|
_size = (self.display_width, self.display_height)
|
||||||
img_w = self.cached_array.shape[1]
|
img_w = self.cached_array.shape[1]
|
||||||
|
|
||||||
if end_x <= img_w:
|
if 0 <= start_x and end_x <= img_w:
|
||||||
# Normal case: single contiguous slice (fastest path). tobytes()
|
# Normal case: single contiguous slice (fastest path). tobytes()
|
||||||
# on the column-slice view already returns C-order bytes, so
|
# on the column-slice view already returns C-order bytes, so
|
||||||
# ascontiguousarray() first only added a second full-frame copy.
|
# ascontiguousarray() first only added a second full-frame copy.
|
||||||
return Image.frombytes(
|
return Image.frombytes(
|
||||||
'RGB', _size,
|
'RGB', _size,
|
||||||
self.cached_array[:, start_x:end_x].tobytes())
|
self.cached_array[:, start_x:end_x].tobytes())
|
||||||
|
|
||||||
|
# Ensure frame buffer is allocated for all non-simple paths
|
||||||
|
if self._frame_buffer is None or self._frame_buffer.shape != (self.display_height, self.display_width, 3):
|
||||||
|
self._frame_buffer = np.zeros((self.display_height, self.display_width, 3), dtype=np.uint8)
|
||||||
|
|
||||||
|
if img_w == 0:
|
||||||
|
self._frame_buffer[:] = 0
|
||||||
else:
|
else:
|
||||||
# Ensure frame buffer is allocated for all non-simple paths
|
# The frame runs off the strip, so it carries on from the head:
|
||||||
if self._frame_buffer is None or self._frame_buffer.shape != (self.display_height, self.display_width, 3):
|
# frame column j is strip column (start_x + j) modulo the strip's
|
||||||
self._frame_buffer = np.zeros((self.display_height, self.display_width, 3), dtype=np.uint8)
|
# width -- the tail and then the head, and a strip narrower than
|
||||||
|
# the panel repeated across it. Copying the tail and then the rest
|
||||||
|
# of the frame from the head assumed the head was that wide, and
|
||||||
|
# raised at every position for a strip narrower than the panel
|
||||||
|
# (Vegas composes one, with no lead-in, when its content is
|
||||||
|
# narrower than the chain).
|
||||||
|
np.take(self.cached_array, np.arange(start_x, end_x), axis=1,
|
||||||
|
mode='wrap', out=self._frame_buffer)
|
||||||
|
|
||||||
width1 = img_w - start_x
|
return Image.frombytes('RGB', _size, self._frame_buffer.tobytes())
|
||||||
if width1 > 0:
|
|
||||||
# Wrap-around: tail of image + head of image
|
|
||||||
self._frame_buffer[:, :width1] = self.cached_array[:, start_x:]
|
|
||||||
remaining_width = self.display_width - width1
|
|
||||||
self._frame_buffer[:, width1:] = self.cached_array[:, :remaining_width]
|
|
||||||
else:
|
|
||||||
# Edge case: start_x at or past image end — show from beginning,
|
|
||||||
# clamped to available width (scroll_position should wrap before
|
|
||||||
# reaching this state in normal operation).
|
|
||||||
available = min(self.display_width, img_w)
|
|
||||||
self._frame_buffer[:, :available] = self.cached_array[:, :available]
|
|
||||||
if available < self.display_width:
|
|
||||||
self._frame_buffer[:, available:] = 0
|
|
||||||
|
|
||||||
return Image.frombytes('RGB', _size, self._frame_buffer.tobytes())
|
|
||||||
|
|
||||||
def calculate_dynamic_duration(self) -> int:
|
def calculate_dynamic_duration(self) -> int:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ the extra guard only stops a None size raising TypeError.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any, Dict, Optional, Tuple
|
from typing import Any, Dict, Optional, Tuple
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
@@ -338,10 +338,46 @@ def format_game_date(config: Optional[Dict[str, Any]], logger, date_text: str,
|
|||||||
if not raw:
|
if not raw:
|
||||||
return ""
|
return ""
|
||||||
fmt = str(scroll_card_option(config, "date_format", "abbrev") or "abbrev")
|
fmt = str(scroll_card_option(config, "date_format", "abbrev") or "abbrev")
|
||||||
return _format_date_as(fmt, raw, lambda: weekday_for(config, logger, game))
|
return _format_date_as(fmt, raw, lambda: weekday_for(config, logger, game),
|
||||||
|
game=game)
|
||||||
|
|
||||||
|
|
||||||
def _format_date_as(fmt: str, raw: str, weekday, months=MONTH_ABBR) -> str:
|
def _printed_weekday(game: Optional[Dict], month: int, day: int) -> str:
|
||||||
|
"""The weekday of the date a card prints as month/day, or '' if unknown.
|
||||||
|
|
||||||
|
The extractor prints "M/D" in the plugin's resolved zone (its own setting,
|
||||||
|
else the global one, else the system zone). The card cannot see that zone:
|
||||||
|
it is handed the plugin's config, whose ``timezone`` ships as "", so
|
||||||
|
card_tzinfo answers UTC and an evening kickoff in the Americas got the
|
||||||
|
next day's weekday ("Sat Oct 2" for a Friday game). Every zone is within
|
||||||
|
a day of UTC, so the printed date is the start's UTC date or a neighbour
|
||||||
|
of it; the one with that month and day is the date on the card.
|
||||||
|
"""
|
||||||
|
if not isinstance(game, dict):
|
||||||
|
return ""
|
||||||
|
raw = game.get("start_time_utc") or game.get("start_time")
|
||||||
|
if not raw:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
start = raw if isinstance(raw, datetime) else datetime.fromisoformat(
|
||||||
|
str(raw).replace("Z", "+00:00"))
|
||||||
|
if start.utcoffset() is None:
|
||||||
|
return "" # naive: no instant to place the date against
|
||||||
|
utc_day = start.astimezone(timezone.utc).date()
|
||||||
|
except (ValueError, TypeError, OverflowError):
|
||||||
|
return ""
|
||||||
|
for offset in (0, -1, 1):
|
||||||
|
try:
|
||||||
|
candidate = utc_day + timedelta(days=offset)
|
||||||
|
except OverflowError:
|
||||||
|
continue
|
||||||
|
if (candidate.month, candidate.day) == (month, day):
|
||||||
|
return WEEKDAY_ABBR[candidate.weekday()]
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _format_date_as(fmt: str, raw: str, weekday, months=MONTH_ABBR,
|
||||||
|
game: Optional[Dict] = None) -> str:
|
||||||
"""Render a stripped, non-empty "M/D" *raw* in style *fmt*.
|
"""Render a stripped, non-empty "M/D" *raw* in style *fmt*.
|
||||||
|
|
||||||
The body both date formatters share. They differ in which setting names the
|
The body both date formatters share. They differ in which setting names the
|
||||||
@@ -349,6 +385,9 @@ def _format_date_as(fmt: str, raw: str, weekday, months=MONTH_ABBR) -> str:
|
|||||||
``SportsCoreSharedMixin._format_game_date``), so those arrive as arguments:
|
``SportsCoreSharedMixin._format_game_date``), so those arrive as arguments:
|
||||||
*weekday* is a zero-argument callable, only called for the "weekday" style.
|
*weekday* is a zero-argument callable, only called for the "weekday" style.
|
||||||
*months* lets the mixin keep reading its (overridable) ``_MONTH_ABBR``.
|
*months* lets the mixin keep reading its (overridable) ``_MONTH_ABBR``.
|
||||||
|
With *game*, the "weekday" style names the printed date's own weekday
|
||||||
|
(:func:`_printed_weekday`), and *weekday* is only the fallback for a
|
||||||
|
date its start time cannot place.
|
||||||
"""
|
"""
|
||||||
if fmt == "numeric":
|
if fmt == "numeric":
|
||||||
return raw
|
return raw
|
||||||
@@ -364,7 +403,7 @@ def _format_date_as(fmt: str, raw: str, weekday, months=MONTH_ABBR) -> str:
|
|||||||
if fmt == "day_first":
|
if fmt == "day_first":
|
||||||
return f"{day} {name}"
|
return f"{day} {name}"
|
||||||
if fmt == "weekday":
|
if fmt == "weekday":
|
||||||
day_name = weekday()
|
day_name = _printed_weekday(game, month, day) or weekday()
|
||||||
return f"{day_name} {name} {day}" if day_name else f"{name} {day}"
|
return f"{day_name} {name} {day}" if day_name else f"{name} {day}"
|
||||||
return f"{name} {day}"
|
return f"{name} {day}"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,263 @@
|
|||||||
|
"""Which games involve a favourite team, and which of them to show (sports family 6).
|
||||||
|
|
||||||
|
The scoreboards' favourite matching, reconciled in ledmatrix-plugins
|
||||||
|
(family 6) from seven ``_is_favorite_game`` bodies, two
|
||||||
|
``_select_games_for_display`` and three ``_select_recent_games_for_display``
|
||||||
|
into one each, and copied here under their existing names:
|
||||||
|
|
||||||
|
- ``SportsFavoritesMixin`` (``SportsCore``): ``_is_favorite_game(game)``,
|
||||||
|
asked by ``SportsCoreSharedMixin._favorites_first``, the switch-mode
|
||||||
|
favourite boost (``SportsHelpersMixin._next_switch_index``), the
|
||||||
|
non-favourite live dwell (``SportsGameRulesMixin._effective_live_duration``)
|
||||||
|
and the plugins' live rotation; and ``_favorite_code(value)``, the
|
||||||
|
normalisation both sides of every comparison go through.
|
||||||
|
- ``SportsUpcomingFavoritesMixin`` (``SportsUpcoming``):
|
||||||
|
``_select_games_for_display``, the favourites-only pick of upcoming games.
|
||||||
|
- ``SportsRecentFavoritesMixin`` (``SportsRecent``):
|
||||||
|
``_select_recent_games_for_display``, the same for finished games, most
|
||||||
|
recent first.
|
||||||
|
|
||||||
|
Each mixin carries only what its class already had, so no manager gains a
|
||||||
|
method it did not have.
|
||||||
|
|
||||||
|
THE RULE
|
||||||
|
--------
|
||||||
|
Each side of a game is named by ``_favorite_key(game, side)``, the override
|
||||||
|
point ``SportsHelpersMixin`` (``src.common.sports_helpers``) has carried since
|
||||||
|
3.5.0: the team abbreviation by default. A sport whose abbreviations are not
|
||||||
|
unique overrides it -- NRL returns the ESPN team id (and None when the id is
|
||||||
|
missing), because "NEW" is both Newcastle and New Zealand. That value and every
|
||||||
|
entry of ``favorite_teams`` are compared as ``_favorite_code`` leaves them:
|
||||||
|
stripped and upper-cased, a blank or missing value matching nothing. So
|
||||||
|
" bos" in the config matches BOS.
|
||||||
|
|
||||||
|
The selection methods give each favourite team up to the per-team limit
|
||||||
|
(``upcoming_games_to_show`` / ``recent_games_to_show``); a game between two
|
||||||
|
favourites counts for both. Only a game with an id can be a duplicate: two
|
||||||
|
games without one are two games.
|
||||||
|
|
||||||
|
A new module rather than more methods on ``sports_shared`` or
|
||||||
|
``sports_helpers``, for the reason ``sports_helpers`` gives: a missing module
|
||||||
|
fails at load, where the version checks see it; a missing method fails
|
||||||
|
mid-update.
|
||||||
|
|
||||||
|
WHAT A HOST MUST PROVIDE
|
||||||
|
------------------------
|
||||||
|
Derived by walking every ``self.<attr>`` the mixins read; the host-contract
|
||||||
|
test in ``test/test_sports_favorites.py`` fails if a read is added without
|
||||||
|
being listed here.
|
||||||
|
|
||||||
|
- ``favorite_teams`` -- the resolved favourites list (``_is_favorite_game``).
|
||||||
|
The selection methods are handed the list instead.
|
||||||
|
- ``_favorite_key`` -- ``SportsHelpersMixin`` supplies the default.
|
||||||
|
- ``_favorite_code`` -- from ``SportsFavoritesMixin``, which the Upcoming and
|
||||||
|
Recent classes inherit through their ``SportsCore``.
|
||||||
|
- ``logger`` -- the selection methods log each pick at DEBUG and a summary at
|
||||||
|
INFO.
|
||||||
|
- ``upcoming_games_to_show`` (Upcoming) and ``recent_games_to_show`` (Recent)
|
||||||
|
-- the per-team limits.
|
||||||
|
|
||||||
|
The methods read the game dict's ``id`` and ``start_time_utc`` (selection),
|
||||||
|
whatever ``_favorite_key`` reads (``home_abbr`` / ``away_abbr`` by default),
|
||||||
|
and ``home_abbr`` / ``away_abbr`` again for the DEBUG line; any may be missing.
|
||||||
|
|
||||||
|
BASE ORDER
|
||||||
|
----------
|
||||||
|
No other mixin defines these methods, so the position in the bases does not
|
||||||
|
change which body runs; a method on the plugin's own class still wins. The
|
||||||
|
mixins have no ``__init__`` and no state.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Callable, Dict, List, Optional
|
||||||
|
|
||||||
|
|
||||||
|
class SportsFavoritesMixin:
|
||||||
|
"""``SportsCore``'s favourite check. See module docstring."""
|
||||||
|
|
||||||
|
# The host contract, declared for type checking only.
|
||||||
|
favorite_teams: List[str]
|
||||||
|
_favorite_key: Callable[[Dict, str], Optional[str]]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _favorite_code(value) -> Optional[str]:
|
||||||
|
"""``value`` as favourites are compared: stripped and upper-cased.
|
||||||
|
|
||||||
|
None for a missing or blank value, which matches nothing.
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return str(value).strip().upper() or None
|
||||||
|
|
||||||
|
def _is_favorite_game(self, game: Dict) -> bool:
|
||||||
|
"""Does either side of this game belong to a favourite team?
|
||||||
|
|
||||||
|
``_favorite_key`` names each side (the abbreviation; nrl overrides it
|
||||||
|
with the ESPN team id), and both it and ``favorite_teams`` are compared
|
||||||
|
as ``_favorite_code`` normalises them, so " bos" matches BOS.
|
||||||
|
"""
|
||||||
|
favorites = {self._favorite_code(team) for team in self.favorite_teams or ()}
|
||||||
|
favorites.discard(None)
|
||||||
|
return any(
|
||||||
|
self._favorite_code(self._favorite_key(game, side)) in favorites
|
||||||
|
for side in ("home", "away")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SportsUpcomingFavoritesMixin:
|
||||||
|
"""``SportsUpcoming``'s favourites-only pick. See module docstring."""
|
||||||
|
|
||||||
|
# The host contract, declared for type checking only.
|
||||||
|
logger: logging.Logger
|
||||||
|
upcoming_games_to_show: int
|
||||||
|
_favorite_key: Callable[[Dict, str], Optional[str]]
|
||||||
|
_favorite_code: Callable[[object], Optional[str]]
|
||||||
|
|
||||||
|
def _select_games_for_display(
|
||||||
|
self, processed_games: List[Dict], favorite_teams: List[str]
|
||||||
|
) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Single-pass game selection with proper deduplication and counting.
|
||||||
|
|
||||||
|
When a game involves two favorite teams, it counts toward BOTH teams' limits.
|
||||||
|
This prevents unexpected game counts from the multi-pass algorithm.
|
||||||
|
Teams are matched as _is_favorite_game matches them. Only a game with
|
||||||
|
an id can be a duplicate: two games without one are two games.
|
||||||
|
"""
|
||||||
|
sorted_games = sorted(
|
||||||
|
processed_games,
|
||||||
|
key=lambda g: g.get("start_time_utc")
|
||||||
|
or datetime.max.replace(tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not favorite_teams:
|
||||||
|
return sorted_games
|
||||||
|
|
||||||
|
selected_games = []
|
||||||
|
selected_ids = set()
|
||||||
|
team_counts: Dict[Optional[str], int] = {
|
||||||
|
code: 0 for code in map(self._favorite_code, favorite_teams) if code
|
||||||
|
}
|
||||||
|
|
||||||
|
for game in sorted_games:
|
||||||
|
game_id = game.get("id")
|
||||||
|
if game_id is not None and game_id in selected_ids:
|
||||||
|
continue
|
||||||
|
|
||||||
|
home = self._favorite_code(self._favorite_key(game, "home"))
|
||||||
|
away = self._favorite_code(self._favorite_key(game, "away"))
|
||||||
|
|
||||||
|
home_fav = home in team_counts
|
||||||
|
away_fav = away in team_counts
|
||||||
|
|
||||||
|
if not home_fav and not away_fav:
|
||||||
|
continue
|
||||||
|
|
||||||
|
home_needs = home_fav and team_counts[home] < self.upcoming_games_to_show
|
||||||
|
away_needs = away_fav and team_counts[away] < self.upcoming_games_to_show
|
||||||
|
|
||||||
|
if home_needs or away_needs:
|
||||||
|
selected_games.append(game)
|
||||||
|
if game_id is not None:
|
||||||
|
selected_ids.add(game_id)
|
||||||
|
if home_fav:
|
||||||
|
team_counts[home] += 1
|
||||||
|
if away_fav:
|
||||||
|
team_counts[away] += 1
|
||||||
|
|
||||||
|
self.logger.debug(
|
||||||
|
f"Selected game {game.get('away_abbr')}@{game.get('home_abbr')}: "
|
||||||
|
f"team_counts={team_counts}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if all(c >= self.upcoming_games_to_show for c in team_counts.values()):
|
||||||
|
self.logger.debug("All favorite teams satisfied, stopping selection")
|
||||||
|
break
|
||||||
|
|
||||||
|
self.logger.info(
|
||||||
|
f"Selected {len(selected_games)} games for {len(favorite_teams)} "
|
||||||
|
f"favorite teams: {team_counts}"
|
||||||
|
)
|
||||||
|
return selected_games
|
||||||
|
|
||||||
|
|
||||||
|
class SportsRecentFavoritesMixin:
|
||||||
|
"""``SportsRecent``'s favourites-only pick. See module docstring."""
|
||||||
|
|
||||||
|
# The host contract, declared for type checking only.
|
||||||
|
logger: logging.Logger
|
||||||
|
recent_games_to_show: int
|
||||||
|
_favorite_key: Callable[[Dict, str], Optional[str]]
|
||||||
|
_favorite_code: Callable[[object], Optional[str]]
|
||||||
|
|
||||||
|
def _select_recent_games_for_display(
|
||||||
|
self, processed_games: List[Dict], favorite_teams: List[str]
|
||||||
|
) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Single-pass game selection for recent games with proper deduplication.
|
||||||
|
|
||||||
|
When a game involves two favorite teams, it counts toward BOTH teams' limits.
|
||||||
|
Games are sorted by most recent first.
|
||||||
|
Teams are matched as _is_favorite_game matches them. Only a game with
|
||||||
|
an id can be a duplicate: two games without one are two games.
|
||||||
|
"""
|
||||||
|
sorted_games = sorted(
|
||||||
|
processed_games,
|
||||||
|
key=lambda g: g.get("start_time_utc")
|
||||||
|
or datetime.min.replace(tzinfo=timezone.utc),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not favorite_teams:
|
||||||
|
return sorted_games
|
||||||
|
|
||||||
|
selected_games = []
|
||||||
|
selected_ids = set()
|
||||||
|
team_counts: Dict[Optional[str], int] = {
|
||||||
|
code: 0 for code in map(self._favorite_code, favorite_teams) if code
|
||||||
|
}
|
||||||
|
|
||||||
|
for game in sorted_games:
|
||||||
|
game_id = game.get("id")
|
||||||
|
if game_id is not None and game_id in selected_ids:
|
||||||
|
continue
|
||||||
|
|
||||||
|
home = self._favorite_code(self._favorite_key(game, "home"))
|
||||||
|
away = self._favorite_code(self._favorite_key(game, "away"))
|
||||||
|
|
||||||
|
home_fav = home in team_counts
|
||||||
|
away_fav = away in team_counts
|
||||||
|
|
||||||
|
if not home_fav and not away_fav:
|
||||||
|
continue
|
||||||
|
|
||||||
|
home_needs = home_fav and team_counts[home] < self.recent_games_to_show
|
||||||
|
away_needs = away_fav and team_counts[away] < self.recent_games_to_show
|
||||||
|
|
||||||
|
if home_needs or away_needs:
|
||||||
|
selected_games.append(game)
|
||||||
|
if game_id is not None:
|
||||||
|
selected_ids.add(game_id)
|
||||||
|
if home_fav:
|
||||||
|
team_counts[home] += 1
|
||||||
|
if away_fav:
|
||||||
|
team_counts[away] += 1
|
||||||
|
|
||||||
|
self.logger.debug(
|
||||||
|
f"Selected recent game {game.get('away_abbr')}@{game.get('home_abbr')}: "
|
||||||
|
f"team_counts={team_counts}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if all(c >= self.recent_games_to_show for c in team_counts.values()):
|
||||||
|
self.logger.debug("All favorite teams satisfied, stopping selection")
|
||||||
|
break
|
||||||
|
|
||||||
|
self.logger.info(
|
||||||
|
f"Selected {len(selected_games)} recent games for {len(favorite_teams)} "
|
||||||
|
f"favorite teams: {team_counts}"
|
||||||
|
)
|
||||||
|
return selected_games
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["SportsFavoritesMixin", "SportsUpcomingFavoritesMixin", "SportsRecentFavoritesMixin"]
|
||||||
@@ -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"]
|
||||||
@@ -34,8 +34,9 @@ first core release that ships it (see ``CHANGELOG.md``).
|
|||||||
|
|
||||||
``_favorite_key`` is the one method not taken from the plugins: it is the
|
``_favorite_key`` is the one method not taken from the plugins: it is the
|
||||||
override point from the since-removed ``src/base_classes`` sports core,
|
override point from the since-removed ``src/base_classes`` sports core,
|
||||||
carried here so later phases (shared celebrations and game selection) have a
|
carried here as the hardware-free home for the seam. ``sports_favorites``
|
||||||
hardware-free home for the seam. No plugin defines it today and nothing in this module calls it.
|
calls it; nrl overrides it with the ESPN team id. Nothing in this module
|
||||||
|
calls it.
|
||||||
|
|
||||||
WHAT A HOST MUST PROVIDE
|
WHAT A HOST MUST PROVIDE
|
||||||
------------------------
|
------------------------
|
||||||
@@ -47,8 +48,8 @@ listed here.
|
|||||||
- ``mode_config`` (dict) and ``logger`` -- ``_setting_int``. ``league`` is
|
- ``mode_config`` (dict) and ``logger`` -- ``_setting_int``. ``league`` is
|
||||||
read with ``getattr`` for the warning text only.
|
read with ``getattr`` for the warning text only.
|
||||||
- ``games_list`` and ``current_game_index`` -- ``_next_switch_index``; plus
|
- ``games_list`` and ``current_game_index`` -- ``_next_switch_index``; plus
|
||||||
``_is_favorite_game`` (called with a game), which stays per-plugin and is
|
``_is_favorite_game`` (called with a game; ``sports_favorites`` has the
|
||||||
only called when
|
shared body), only called when
|
||||||
``favorite_rotation_boost`` is above 1. ``favorite_rotation_boost`` itself
|
``favorite_rotation_boost`` is above 1. ``favorite_rotation_boost`` itself
|
||||||
defaults to 1 on the mixin.
|
defaults to 1 on the mixin.
|
||||||
- ``last_game_switch`` -- ``_reset_dwell_on_reentry``, read with ``getattr``
|
- ``last_game_switch`` -- ``_reset_dwell_on_reentry``, read with ``getattr``
|
||||||
@@ -216,15 +217,12 @@ class SportsHelpersMixin:
|
|||||||
rather than a branch so core never has to learn the string "nrl"::
|
rather than a branch so core never has to learn the string "nrl"::
|
||||||
|
|
||||||
def _favorite_key(self, game, side):
|
def _favorite_key(self, game, side):
|
||||||
return str(game.get(f"{side}_id"))
|
team_id = game.get(f"{side}_id")
|
||||||
|
return None if team_id is None else str(team_id)
|
||||||
|
|
||||||
An override that stringifies should note that a missing id becomes the
|
It returns ``None`` for a missing id rather than ``str(None)``, which
|
||||||
literal ``"None"``, which would spuriously match a favorites list
|
would match a favourite typed "None". A ``None`` never matches.
|
||||||
containing that string. The default returns ``None`` for a missing
|
``sports_favorites`` compares the value stripped and upper-cased.
|
||||||
abbreviation, which never matches.
|
|
||||||
|
|
||||||
Carried from the since-removed ``src/base_classes`` sports core for
|
|
||||||
later phases; nothing in this module calls it yet.
|
|
||||||
"""
|
"""
|
||||||
return game.get(f"{side}_abbr")
|
return game.get(f"{side}_abbr")
|
||||||
|
|
||||||
|
|||||||
@@ -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``,
|
``_get_layout_offset``, ``_by_importance``, ``_other_games_window``,
|
||||||
``_upcoming_date_and_time_text``, ``_extract_game_details_common``,
|
``_upcoming_date_and_time_text``, ``_extract_game_details_common``,
|
||||||
``_load_division_team_ids``, ``_get_timezone``, ``_is_favorite_game``,
|
``_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
|
Of the fourteen shared class constants, thirteen are identical everywhere and
|
||||||
live here. Only ``_SCORE_PROBE_TEXT`` varies -- afl and basketball reach three digits
|
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
|
_KICKOFF_GRACE_SECONDS = 900
|
||||||
#: Fallback cadence around a kickoff when the manager has no update_interval.
|
#: Fallback cadence around a kickoff when the manager has no update_interval.
|
||||||
_KICKOFF_POLL_FLOOR = 30
|
_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:
|
def _resolve_font_path(path: str) -> str:
|
||||||
@@ -360,14 +388,16 @@ class SportsCoreSharedMixin:
|
|||||||
The formatting is sports_card's. What differs from the card's
|
The formatting is sports_card's. What differs from the card's
|
||||||
``format_game_date`` is passed in: the setting (``switch_date_format``,
|
``format_game_date`` is passed in: the setting (``switch_date_format``,
|
||||||
see :meth:`_switch_date_format`) and the weekday, which comes from
|
see :meth:`_switch_date_format`) and the weekday, which comes from
|
||||||
:meth:`_weekday_for` and so from this plugin's resolved timezone.
|
:meth:`_weekday_for` and so from this plugin's resolved timezone
|
||||||
|
when the game's start cannot place the printed date. The game goes
|
||||||
|
in too, so both formatters name the printed date's own weekday.
|
||||||
"""
|
"""
|
||||||
raw = str(date_text or "").strip()
|
raw = str(date_text or "").strip()
|
||||||
if not raw:
|
if not raw:
|
||||||
return raw
|
return raw
|
||||||
return _card._format_date_as(self._switch_date_format(), raw,
|
return _card._format_date_as(self._switch_date_format(), raw,
|
||||||
lambda: self._weekday_for(game),
|
lambda: self._weekday_for(game),
|
||||||
self._MONTH_ABBR)
|
self._MONTH_ABBR, game=game)
|
||||||
|
|
||||||
def _weekday_for(self, game: Optional[Dict]) -> str:
|
def _weekday_for(self, game: Optional[Dict]) -> str:
|
||||||
"""Weekday abbreviation from the game's start time, or ''."""
|
"""Weekday abbreviation from the game's start time, or ''."""
|
||||||
@@ -1292,11 +1322,11 @@ class SportsLiveSharedMixin:
|
|||||||
otherwise look like another empty check and escalate the back-off
|
otherwise look like another empty check and escalate the back-off
|
||||||
again, right when the game is actually starting.
|
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:
|
if not start:
|
||||||
return interval
|
return interval
|
||||||
live = getattr(self, "update_interval", None) or _KICKOFF_POLL_FLOOR
|
live = getattr(self, "update_interval", None) or _KICKOFF_POLL_FLOOR
|
||||||
now = time.time()
|
|
||||||
if now < start:
|
if now < start:
|
||||||
return max(live, min(interval, int(start - now)))
|
return max(live, min(interval, int(start - now)))
|
||||||
if now - start <= _KICKOFF_GRACE_SECONDS:
|
if now - start <= _KICKOFF_GRACE_SECONDS:
|
||||||
@@ -1311,6 +1341,16 @@ class SportsLiveSharedMixin:
|
|||||||
already has. Self-correcting: a stored start that has passed is
|
already has. Self-correcting: a stored start that has passed is
|
||||||
replaced by the next one offered, so a postponed game cannot pin the
|
replaced by the next one offered, so a postponed game cannot pin the
|
||||||
cadence to a kickoff that never happens.
|
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):
|
if not isinstance(details, dict):
|
||||||
return
|
return
|
||||||
@@ -1327,7 +1367,7 @@ class SportsLiveSharedMixin:
|
|||||||
now = time.time()
|
now = time.time()
|
||||||
if candidate <= now:
|
if candidate <= now:
|
||||||
return
|
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
|
# 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
|
# 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
|
# window in _clamp_to_scheduled_start dead code: the moment 13:00 came
|
||||||
@@ -1337,10 +1377,20 @@ class SportsLiveSharedMixin:
|
|||||||
# polled at 13:00:45, found nothing live because ESPN had not flipped
|
# 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,
|
# the status yet, and then went quiet for the next quarter of an hour,
|
||||||
# which is the behaviour this whole clamp exists to prevent.
|
# which is the behaviour this whole clamp exists to prevent.
|
||||||
if (current is None
|
#
|
||||||
or current <= now - _KICKOFF_GRACE_SECONDS
|
# Nor is it forgotten: whichever kickoff loses is queued behind the
|
||||||
or candidate < current):
|
# one honoured now, so it gets its own grace when that one's ends.
|
||||||
|
if current is None:
|
||||||
self._next_scheduled_start_ts = candidate
|
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
|
#: How long a game that finished live is still reported by
|
||||||
#: finished_games_snapshot(): long enough for the recent-games list, which
|
#: finished_games_snapshot(): long enough for the recent-games list, which
|
||||||
|
|||||||
+42
-9
@@ -46,6 +46,35 @@ from src.common.permission_utils import (
|
|||||||
get_config_dir_mode
|
get_config_dir_mode
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _private_copy(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""A deep copy of ``config`` that shares nothing with it.
|
||||||
|
|
||||||
|
load_config() hands one out per call, and the saves keep one, so the
|
||||||
|
cached config is never an object a caller holds. A web handler edits what
|
||||||
|
it loaded, validates, and may refuse the save; when the cache was that
|
||||||
|
same object, the refused edit stayed in it, and the next save of any
|
||||||
|
other setting wrote it to config.json -- a nested secret included, in
|
||||||
|
plain text, since it had never reached config_secrets.json to be
|
||||||
|
stripped.
|
||||||
|
|
||||||
|
The config is JSON data, so only its dicts and lists need copying; every
|
||||||
|
other value in it is immutable. On a Pi 4 with a real 60 KiB config this
|
||||||
|
takes 2.1 ms against copy.deepcopy's 6.8 ms, on a path ~30 handlers call
|
||||||
|
(a pickle round trip is no faster, 1.9 ms, and brings pickle into the
|
||||||
|
config path for nothing).
|
||||||
|
"""
|
||||||
|
return _copy_containers(config)
|
||||||
|
|
||||||
|
|
||||||
|
def _copy_containers(value: Any) -> Any:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {key: _copy_containers(item) for key, item in value.items()}
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [_copy_containers(item) for item in value]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
class ConfigManager:
|
class ConfigManager:
|
||||||
"""
|
"""
|
||||||
Reads and writes the main application configuration files.
|
Reads and writes the main application configuration files.
|
||||||
@@ -126,9 +155,10 @@ class ConfigManager:
|
|||||||
validate_after_write=validate_after_write
|
validate_after_write=validate_after_write
|
||||||
)
|
)
|
||||||
|
|
||||||
# Update in-memory config if save was successful
|
# Update in-memory config if save was successful. A copy: the caller
|
||||||
|
# still holds new_config_data (see _private_copy).
|
||||||
if result.status == SaveResultStatus.SUCCESS:
|
if result.status == SaveResultStatus.SUCCESS:
|
||||||
self.config = new_config_data
|
self.config = _private_copy(new_config_data)
|
||||||
# In-memory config now matches what was just written, so the
|
# In-memory config now matches what was just written, so the
|
||||||
# load_config fast path may return it. It still carries the
|
# load_config fast path may return it. It still carries the
|
||||||
# merged secrets that were stripped on disk; that matches a full
|
# merged secrets that were stripped on disk; that matches a full
|
||||||
@@ -208,14 +238,16 @@ class ConfigManager:
|
|||||||
|
|
||||||
Fast path: when config.json, config_secrets.json and the template
|
Fast path: when config.json, config_secrets.json and the template
|
||||||
are all unchanged since the last successful load (mtime_ns + size),
|
are all unchanged since the last successful load (mtime_ns + size),
|
||||||
the already-parsed self.config is returned without touching the
|
a copy of the already-parsed self.config is returned without
|
||||||
files — same aliasing semantics as the full path, which also
|
touching the files.
|
||||||
returns self.config.
|
|
||||||
|
Either way the caller gets its own copy (see _private_copy): editing
|
||||||
|
it changes nothing here until it is saved.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
current_sig = self._files_signature()
|
current_sig = self._files_signature()
|
||||||
if self.config and self._loaded_sig == current_sig:
|
if self.config and self._loaded_sig == current_sig:
|
||||||
return self.config
|
return _private_copy(self.config)
|
||||||
|
|
||||||
# Check if config file exists, if not create from template
|
# Check if config file exists, if not create from template
|
||||||
if not os.path.exists(self.config_path):
|
if not os.path.exists(self.config_path):
|
||||||
@@ -249,7 +281,7 @@ class ConfigManager:
|
|||||||
# Signature taken AFTER load + migration (migration may write the
|
# Signature taken AFTER load + migration (migration may write the
|
||||||
# config back), so it reflects exactly what was read/written.
|
# config back), so it reflects exactly what was read/written.
|
||||||
self._loaded_sig = self._files_signature()
|
self._loaded_sig = self._files_signature()
|
||||||
return self.config
|
return _private_copy(self.config)
|
||||||
|
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
# Only config.json can get here: a missing or unreadable secrets
|
# Only config.json can get here: a missing or unreadable secrets
|
||||||
@@ -355,8 +387,9 @@ class ConfigManager:
|
|||||||
try:
|
try:
|
||||||
atomic_write_json(self.config_path, config_to_write)
|
atomic_write_json(self.config_path, config_to_write)
|
||||||
|
|
||||||
# Update the in-memory config to the new state (which includes secrets for runtime)
|
# Update the in-memory config to the new state (which includes
|
||||||
self.config = new_config_data
|
# secrets for runtime), as a copy -- see _private_copy
|
||||||
|
self.config = _private_copy(new_config_data)
|
||||||
self._loaded_sig = self._files_signature()
|
self._loaded_sig = self._files_signature()
|
||||||
self.logger.info(f"Configuration successfully saved to {os.path.abspath(self.config_path)}")
|
self.logger.info(f"Configuration successfully saved to {os.path.abspath(self.config_path)}")
|
||||||
if secrets_content:
|
if secrets_content:
|
||||||
|
|||||||
+80
-30
@@ -14,7 +14,7 @@ import json
|
|||||||
import time
|
import time
|
||||||
import threading
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Any, Optional, List, Callable
|
from typing import Dict, Any, Optional, List, Callable, Tuple
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
import logging
|
import logging
|
||||||
import hashlib
|
import hashlib
|
||||||
@@ -52,6 +52,17 @@ class ConfigService:
|
|||||||
|
|
||||||
# Thread safety
|
# Thread safety
|
||||||
self._lock: threading.RLock = threading.RLock()
|
self._lock: threading.RLock = threading.RLock()
|
||||||
|
# Held across a whole reload -- read, swap, notify -- so one reload's
|
||||||
|
# notifications finish before the next one's start. Subscribers run
|
||||||
|
# under this lock and never under _lock: the display's per-plugin
|
||||||
|
# subscriber can wait seconds for a busy plugin, and get_config(),
|
||||||
|
# subscribe() and unsubscribe() -- called from the render thread --
|
||||||
|
# must not wait behind it.
|
||||||
|
self._notify_lock: threading.RLock = threading.RLock()
|
||||||
|
# (key, callback, thread id) of the callback a notification is running,
|
||||||
|
# so unsubscribe() can wait for that one call; signalled on its return.
|
||||||
|
self._running_callback: Optional[Tuple[str, Callable[..., None], int]] = None
|
||||||
|
self._callback_done = threading.Condition(self._lock)
|
||||||
|
|
||||||
# Current configuration
|
# Current configuration
|
||||||
self._current_config: Dict[str, Any] = {}
|
self._current_config: Dict[str, Any] = {}
|
||||||
@@ -87,23 +98,24 @@ class ConfigService:
|
|||||||
True if config changed, False otherwise
|
True if config changed, False otherwise
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
new_config = self.config_manager.load_config()
|
with self._notify_lock:
|
||||||
new_checksum = self._calculate_checksum(new_config)
|
new_config = self.config_manager.load_config()
|
||||||
|
new_checksum = self._calculate_checksum(new_config)
|
||||||
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
# Check if config actually changed
|
# Check if config actually changed
|
||||||
if new_checksum == self._current_checksum:
|
if new_checksum == self._current_checksum:
|
||||||
self.logger.debug("Configuration unchanged, skipping reload")
|
self.logger.debug("Configuration unchanged, skipping reload")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Store old config for change detection
|
# Store old config for change detection
|
||||||
old_config = self._current_config.copy()
|
old_config = self._current_config.copy()
|
||||||
|
|
||||||
# Update current config
|
# Update current config
|
||||||
self._current_config = new_config
|
self._current_config = new_config
|
||||||
self._current_checksum = new_checksum
|
self._current_checksum = new_checksum
|
||||||
|
|
||||||
# Notify subscribers
|
# Notify subscribers, outside _lock (see _notify_lock)
|
||||||
self._notify_subscribers(old_config, new_config)
|
self._notify_subscribers(old_config, new_config)
|
||||||
|
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
@@ -127,16 +139,19 @@ class ConfigService:
|
|||||||
Args:
|
Args:
|
||||||
old_config: Previous configuration
|
old_config: Previous configuration
|
||||||
new_config: New configuration
|
new_config: New configuration
|
||||||
|
|
||||||
|
Called without _lock held. The subscriber lists are copied under it,
|
||||||
|
and each callback is checked against them again just before it runs.
|
||||||
"""
|
"""
|
||||||
|
with self._lock:
|
||||||
|
subscribers = {key: list(callbacks) for key, callbacks in self._subscribers.items()}
|
||||||
|
|
||||||
# Notify global subscribers (key: '*')
|
# Notify global subscribers (key: '*')
|
||||||
for callback in self._subscribers.get('*', []):
|
for callback in subscribers.get('*', []):
|
||||||
try:
|
self._call_subscriber('*', callback, old_config, new_config)
|
||||||
callback(old_config, new_config)
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error("Error in global config change callback: %s", e, exc_info=True)
|
|
||||||
|
|
||||||
# Notify plugin-specific subscribers
|
# Notify plugin-specific subscribers
|
||||||
for plugin_id in self._subscribers.keys():
|
for plugin_id, callbacks in subscribers.items():
|
||||||
if plugin_id == '*':
|
if plugin_id == '*':
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -145,16 +160,42 @@ class ConfigService:
|
|||||||
|
|
||||||
# Only notify if plugin config actually changed
|
# Only notify if plugin config actually changed
|
||||||
if old_plugin_config != new_plugin_config:
|
if old_plugin_config != new_plugin_config:
|
||||||
for callback in self._subscribers[plugin_id]:
|
for callback in callbacks:
|
||||||
try:
|
self._call_subscriber(plugin_id, callback,
|
||||||
callback(old_plugin_config, new_plugin_config)
|
old_plugin_config, new_plugin_config)
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(
|
def _call_subscriber(
|
||||||
"Error in config change callback for %s: %s",
|
self,
|
||||||
plugin_id,
|
key: str,
|
||||||
e,
|
callback: Callable[[Dict[str, Any], Dict[str, Any]], None],
|
||||||
exc_info=True
|
old_config: Dict[str, Any],
|
||||||
)
|
new_config: Dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""Run one callback, unless it was unsubscribed since the snapshot.
|
||||||
|
|
||||||
|
unsubscribe() promises that once it returns the callback is neither
|
||||||
|
running nor will run: the display unloads the plugin straight after.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
if callback not in self._subscribers.get(key, ()):
|
||||||
|
return
|
||||||
|
self._running_callback = (key, callback, threading.get_ident())
|
||||||
|
try:
|
||||||
|
callback(old_config, new_config)
|
||||||
|
except Exception as e:
|
||||||
|
if key == '*':
|
||||||
|
self.logger.error("Error in global config change callback: %s", e, exc_info=True)
|
||||||
|
else:
|
||||||
|
self.logger.error(
|
||||||
|
"Error in config change callback for %s: %s",
|
||||||
|
key,
|
||||||
|
e,
|
||||||
|
exc_info=True
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
with self._lock:
|
||||||
|
self._running_callback = None
|
||||||
|
self._callback_done.notify_all()
|
||||||
|
|
||||||
def _check_file_changes(self) -> bool:
|
def _check_file_changes(self) -> bool:
|
||||||
"""
|
"""
|
||||||
@@ -276,6 +317,11 @@ class ConfigService:
|
|||||||
"""
|
"""
|
||||||
Unsubscribe from configuration changes.
|
Unsubscribe from configuration changes.
|
||||||
|
|
||||||
|
Once this returns the callback is not running and will not be called
|
||||||
|
again. A notification that is running this very callback is waited
|
||||||
|
for (unless the callback is the caller); one running any other
|
||||||
|
callback is not.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
callback: Callback function to remove
|
callback: Callback function to remove
|
||||||
plugin_id: Optional plugin ID (must match subscription)
|
plugin_id: Optional plugin ID (must match subscription)
|
||||||
@@ -285,6 +331,10 @@ class ConfigService:
|
|||||||
if callback in self._subscribers[key]:
|
if callback in self._subscribers[key]:
|
||||||
self._subscribers[key].remove(callback)
|
self._subscribers[key].remove(callback)
|
||||||
self.logger.debug("Unsubscribed from config changes for %s", key)
|
self.logger.debug("Unsubscribed from config changes for %s", key)
|
||||||
|
while (self._running_callback is not None
|
||||||
|
and self._running_callback[:2] == (key, callback)
|
||||||
|
and self._running_callback[2] != threading.get_ident()):
|
||||||
|
self._callback_done.wait()
|
||||||
|
|
||||||
def shutdown(self) -> None:
|
def shutdown(self) -> None:
|
||||||
"""Shutdown the configuration service."""
|
"""Shutdown the configuration service."""
|
||||||
|
|||||||
+404
-39
@@ -1,41 +1,56 @@
|
|||||||
"""What the panel shows next: the Arbiter of docs/RUN_LOOP_REDESIGN.md.
|
"""What the panel shows next: the Arbiter of docs/RUN_LOOP_REDESIGN.md.
|
||||||
|
|
||||||
``Arbiter.decide(state, inputs, now)`` takes a snapshot that
|
``Arbiter.decide(state, inputs, now)`` takes a snapshot that
|
||||||
``DisplayController.run()`` gathers once per pass and returns a
|
``DisplayController.run()`` gathers and returns a :class:`ScreenPlan` naming
|
||||||
:class:`ScreenPlan` naming the Source that gets the panel. It is a pure
|
the Source that gets the panel. It is a pure function: no I/O, no clock
|
||||||
function: no I/O, no clock reads (``now`` is passed in), no locks, and it
|
reads (``now`` is passed in), no locks, and it changes nothing it is given.
|
||||||
changes nothing it is given. That is what lets a plain table of cases test
|
That is what lets a plain table of cases test the priority order, which
|
||||||
the priority order, which used to exist only as the order of ``if`` blocks
|
used to exist only as the order of ``if`` blocks in ``run()``.
|
||||||
in ``run()``.
|
|
||||||
|
|
||||||
The full order is
|
The order is
|
||||||
|
|
||||||
ScheduledOff (a gate), Follower, OnDemand, Wifi, Live, Vegas, Rotation
|
ScheduledOff (a gate), Follower, OnDemand, Wifi, Live, Vegas, Rotation
|
||||||
|
|
||||||
Stage 2 decides the gate, Follower and Wifi. Every other case returns a
|
Every Source but Vegas is decided here (stage 3). Vegas is the ``LEGACY``
|
||||||
``LEGACY`` plan, meaning "carry on with run()'s existing code" (live
|
plan: the Arbiter picks it, but its iteration is still run()'s own code
|
||||||
priority, Vegas, then one rotation screen). OnDemand is in the order already
|
until stage 4.
|
||||||
because it outranks the WiFi notice: an active session is a ``LEGACY`` plan
|
|
||||||
even when a notice is pending.
|
|
||||||
|
|
||||||
The Wifi Source's mid-screen rule, :func:`wifi_notice_preempts`, lives here
|
A pass asks twice: once with the inputs every pass reads (the gate,
|
||||||
too, so both of its answers -- at the top of a pass and between frames --
|
Follower, OnDemand, Wifi), and once more, only when nothing above the
|
||||||
come from one module.
|
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 enum import Enum
|
||||||
from typing import Optional
|
from typing import FrozenSet, Optional, Protocol, Tuple
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Arbiter",
|
"Arbiter",
|
||||||
"ArbiterInputs",
|
"ArbiterInputs",
|
||||||
"ArbiterState",
|
"ArbiterState",
|
||||||
|
"FramePolicy",
|
||||||
|
"LIVE_PREEMPTERS",
|
||||||
"SCHEDULED_OFF_DWELL",
|
"SCHEDULED_OFF_DWELL",
|
||||||
|
"SCREEN_PREEMPTERS",
|
||||||
|
"ScreenEnd",
|
||||||
"ScreenPlan",
|
"ScreenPlan",
|
||||||
"Source",
|
"Source",
|
||||||
"WIFI_NOTICE_DWELL",
|
"WIFI_NOTICE_DWELL",
|
||||||
"WifiNotice",
|
"WifiNotice",
|
||||||
|
"live_pick",
|
||||||
|
"live_takeover",
|
||||||
|
"on_demand_bound",
|
||||||
|
"rotation_plan",
|
||||||
"wifi_notice_preempts",
|
"wifi_notice_preempts",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -53,11 +68,41 @@ class Source(Enum):
|
|||||||
|
|
||||||
SCHEDULED_OFF = "scheduled-off"
|
SCHEDULED_OFF = "scheduled-off"
|
||||||
FOLLOWER = "follower"
|
FOLLOWER = "follower"
|
||||||
|
ON_DEMAND = "on-demand"
|
||||||
WIFI = "wifi"
|
WIFI = "wifi"
|
||||||
# Not decided by the Arbiter yet: on-demand, live priority, Vegas and the
|
LIVE = "live"
|
||||||
# rotation are still chosen by run()'s own code. Stage 3 adds the
|
# Vegas: the Arbiter picks it, but its iteration is still run()'s own
|
||||||
# OnDemand, Live and Rotation Sources; stage 4 adds Vegas.
|
# code (and its interrupt callback a second copy of this order) until
|
||||||
|
# stage 4 makes it a Source driven frame by frame.
|
||||||
LEGACY = "legacy"
|
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)
|
@dataclass(frozen=True)
|
||||||
@@ -76,11 +121,122 @@ class WifiNotice:
|
|||||||
class ArbiterState:
|
class ArbiterState:
|
||||||
"""What the Arbiter remembers between passes.
|
"""What the Arbiter remembers between passes.
|
||||||
|
|
||||||
Nothing yet: the stage-2 Sources decide from the inputs alone. The
|
A snapshot of the controller's own fields, taken when decide() is
|
||||||
on-demand index, the rotation index and the live resume point move here
|
called (DisplayController._arbiter_state); the transitions below return
|
||||||
with their Sources in stage 3.
|
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)
|
@dataclass(frozen=True)
|
||||||
class ArbiterInputs:
|
class ArbiterInputs:
|
||||||
@@ -95,57 +251,122 @@ class ArbiterInputs:
|
|||||||
when it could win (the panel is on, and neither a follower nor
|
when it could win (the panel is on, and neither a follower nor
|
||||||
on-demand outranks it), because reading it has side effects: a
|
on-demand outranks it), because reading it has side effects: a
|
||||||
1 Hz throttle and deleting an expired file.
|
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
|
schedule_on: bool
|
||||||
on_demand_active: bool
|
on_demand_active: bool
|
||||||
follower_active: bool
|
follower_active: bool
|
||||||
wifi_notice: Optional[WifiNotice] = None
|
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)
|
@dataclass(frozen=True)
|
||||||
class ScreenPlan:
|
class ScreenPlan:
|
||||||
"""The Arbiter's answer for one pass.
|
"""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:
|
Attributes:
|
||||||
source: The Source that gets the panel.
|
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
|
max_duration: How long the plan holds the panel, in seconds, at most
|
||||||
(its dwell ends early when what the panel should show changes).
|
(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.
|
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
|
source: Source
|
||||||
|
mode: Optional[str] = None
|
||||||
|
plugin: Optional[str] = None
|
||||||
|
min_duration: Optional[float] = None
|
||||||
max_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
|
notice: Optional[WifiNotice] = None
|
||||||
|
deadline: Optional[float] = None
|
||||||
|
ends_live: bool = False
|
||||||
|
|
||||||
|
|
||||||
SCHEDULED_OFF_PLAN = ScreenPlan(Source.SCHEDULED_OFF, max_duration=SCHEDULED_OFF_DWELL)
|
SCHEDULED_OFF_PLAN = ScreenPlan(Source.SCHEDULED_OFF, max_duration=SCHEDULED_OFF_DWELL)
|
||||||
FOLLOWER_PLAN = ScreenPlan(Source.FOLLOWER)
|
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:
|
class Arbiter:
|
||||||
"""Decides which Source gets the panel. Stateless; see the module docstring."""
|
"""Decides which Source gets the panel. Stateless; see the module docstring."""
|
||||||
|
|
||||||
@staticmethod
|
@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.
|
"""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:
|
Args:
|
||||||
state: What the Arbiter remembers between passes (nothing yet).
|
state: What the Arbiter remembers between passes.
|
||||||
inputs: This pass's snapshot.
|
inputs: This pass's snapshot.
|
||||||
now: Wall-clock time of the snapshot. No stage-2 Source reads it:
|
now: Wall-clock time of the snapshot. The OnDemand Source reads
|
||||||
the top-of-pass WiFi check takes the notice as read, and only
|
it for what is left of a timed session, and the mid-screen
|
||||||
the mid-screen check (:func:`wifi_notice_preempts`) compares
|
WiFi rule (:func:`wifi_notice_preempts`) to compare with the
|
||||||
it with the expiry. It is in the signature for the Sources
|
notice's expiry. The top-of-pass WiFi check does not: it
|
||||||
stage 3 adds (on-demand expiry, durations).
|
takes the notice as read.
|
||||||
|
running: The screen on the panel, for a mid-screen check.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The winning Source's plan, or LEGACY_PLAN when the winner is one
|
The winning Source's plan (LEGACY for Vegas), or ``running``.
|
||||||
run() still decides itself.
|
|
||||||
"""
|
"""
|
||||||
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
|
# ScheduledOff is a gate, not a Source: a scheduled-off panel stays
|
||||||
# blank even for a follower, and only an on-demand session overrides
|
# blank even for a follower, and only an on-demand session overrides
|
||||||
@@ -157,17 +378,161 @@ class Arbiter:
|
|||||||
if inputs.follower_active:
|
if inputs.follower_active:
|
||||||
return FOLLOWER_PLAN
|
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:
|
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.
|
# 3. Wifi: a pending notice, held for one short dwell per pass.
|
||||||
if inputs.wifi_notice is not None:
|
if inputs.wifi_notice is not None:
|
||||||
return ScreenPlan(Source.WIFI, max_duration=WIFI_NOTICE_DWELL,
|
return ScreenPlan(Source.WIFI, max_duration=WIFI_NOTICE_DWELL,
|
||||||
notice=inputs.wifi_notice)
|
notice=inputs.wifi_notice)
|
||||||
|
|
||||||
# 4-6. Live, Vegas, Rotation: still run()'s own code.
|
# 4. Live: the next live game, round-robin across several. With
|
||||||
return LEGACY_PLAN
|
# 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,
|
def wifi_notice_preempts(notice: Optional[WifiNotice], on_demand_active: bool,
|
||||||
|
|||||||
+881
-538
File diff suppressed because it is too large
Load Diff
+52
-2
@@ -317,6 +317,11 @@ class DisplayManager:
|
|||||||
# is handed to the writer; this only once it has been saved, so an
|
# is handed to the writer; this only once it has been saved, so an
|
||||||
# mtime touch never vouches for a frame still waiting to be written.
|
# mtime touch never vouches for a frame still waiting to be written.
|
||||||
self._saved_snapshot_digest: Optional[int] = None
|
self._saved_snapshot_digest: Optional[int] = None
|
||||||
|
# A changed frame reached _write_snapshot_if_due() inside the write
|
||||||
|
# interval and was skipped. Nothing writes it unless update_display()
|
||||||
|
# runs again, and a screen that draws once and holds never calls it
|
||||||
|
# again -- see write_owed_snapshot().
|
||||||
|
self._snapshot_owed = False
|
||||||
self._snapshot_dir_prepared = False
|
self._snapshot_dir_prepared = False
|
||||||
# Background writer used mid-scroll; see _write_snapshot_if_due.
|
# Background writer used mid-scroll; see _write_snapshot_if_due.
|
||||||
self._snapshot_cond = threading.Condition()
|
self._snapshot_cond = threading.Condition()
|
||||||
@@ -1788,9 +1793,10 @@ class DisplayManager:
|
|||||||
|
|
||||||
if frame_checksum is not None:
|
if frame_checksum is not None:
|
||||||
digest = frame_checksum
|
digest = frame_checksum
|
||||||
|
frame_changed = digest != self._last_snapshot_digest
|
||||||
action = snapshot_policy.decide(
|
action = snapshot_policy.decide(
|
||||||
now, self._last_snapshot_ts, self._last_snapshot_touch_ts,
|
now, self._last_snapshot_ts, self._last_snapshot_touch_ts,
|
||||||
viewer_fresh, digest != self._last_snapshot_digest)
|
viewer_fresh, frame_changed)
|
||||||
else:
|
else:
|
||||||
# Ask as if the frame had changed before paying to find out.
|
# Ask as if the frame had changed before paying to find out.
|
||||||
# decide() is monotone in frame_changed -- a SKIP for a
|
# decide() is monotone in frame_changed -- a SKIP for a
|
||||||
@@ -1802,22 +1808,36 @@ class DisplayManager:
|
|||||||
now, self._last_snapshot_ts, self._last_snapshot_touch_ts,
|
now, self._last_snapshot_ts, self._last_snapshot_touch_ts,
|
||||||
viewer_fresh, True)
|
viewer_fresh, True)
|
||||||
if action is snapshot_policy.SnapshotAction.SKIP:
|
if action is snapshot_policy.SnapshotAction.SKIP:
|
||||||
|
# Not hashed, so not known to be unchanged: owed until a
|
||||||
|
# later look finds it written or unchanged.
|
||||||
|
self._snapshot_owed = True
|
||||||
return
|
return
|
||||||
digest = zlib.adler32(self.image.tobytes())
|
digest = zlib.adler32(self.image.tobytes())
|
||||||
if digest == self._last_snapshot_digest:
|
frame_changed = digest != self._last_snapshot_digest
|
||||||
|
if not frame_changed:
|
||||||
# Unchanged after all: the decision an unchanged frame gets.
|
# Unchanged after all: the decision an unchanged frame gets.
|
||||||
action = snapshot_policy.decide(
|
action = snapshot_policy.decide(
|
||||||
now, self._last_snapshot_ts,
|
now, self._last_snapshot_ts,
|
||||||
self._last_snapshot_touch_ts, viewer_fresh, False)
|
self._last_snapshot_touch_ts, viewer_fresh, False)
|
||||||
if action is snapshot_policy.SnapshotAction.SKIP:
|
if action is snapshot_policy.SnapshotAction.SKIP:
|
||||||
|
# A changed frame inside the write interval stays owed: the
|
||||||
|
# next update_display() would write it, but a static screen
|
||||||
|
# may not make one -- write_owed_snapshot() covers that.
|
||||||
|
self._snapshot_owed = frame_changed
|
||||||
return
|
return
|
||||||
if (action is snapshot_policy.SnapshotAction.TOUCH
|
if (action is snapshot_policy.SnapshotAction.TOUCH
|
||||||
and self._saved_snapshot_digest == digest):
|
and self._saved_snapshot_digest == digest):
|
||||||
# mtime bump only: keeps the health check (snapshot age)
|
# mtime bump only: keeps the health check (snapshot age)
|
||||||
# green without paying for a PNG encode of an unchanged frame
|
# green without paying for a PNG encode of an unchanged frame
|
||||||
|
# (this frame is already on disk, so nothing is owed).
|
||||||
|
self._snapshot_owed = False
|
||||||
os.utime(self._snapshot_path, None)
|
os.utime(self._snapshot_path, None)
|
||||||
self._last_snapshot_touch_ts = now
|
self._last_snapshot_touch_ts = now
|
||||||
return
|
return
|
||||||
|
# Owed until the write below succeeds: if it raises, the frame
|
||||||
|
# stays owed and write_owed_snapshot() retries it, rather than a
|
||||||
|
# held screen leaving the preview stale after one failed write.
|
||||||
|
self._snapshot_owed = True
|
||||||
# (A TOUCH for a frame that isn't on disk yet -- still queued, or
|
# (A TOUCH for a frame that isn't on disk yet -- still queued, or
|
||||||
# its write failed -- is written instead: touching would make the
|
# its write failed -- is written instead: touching would make the
|
||||||
# older file on disk look current.)
|
# older file on disk look current.)
|
||||||
@@ -1842,9 +1862,39 @@ class DisplayManager:
|
|||||||
self._last_snapshot_ts = now
|
self._last_snapshot_ts = now
|
||||||
self._last_snapshot_touch_ts = now
|
self._last_snapshot_touch_ts = now
|
||||||
self._last_snapshot_digest = digest
|
self._last_snapshot_digest = digest
|
||||||
|
self._snapshot_owed = False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self._log_snapshot_failure(e)
|
self._log_snapshot_failure(e)
|
||||||
|
|
||||||
|
def write_owed_snapshot(self) -> None:
|
||||||
|
"""Write a frame the snapshot throttle skipped, once it is due.
|
||||||
|
|
||||||
|
The preview snapshot is only ever written from update_display(), and
|
||||||
|
at most once per write interval (snapshot_policy). A frame pushed
|
||||||
|
inside that interval is skipped, and is written by the next
|
||||||
|
update_display() that comes after it -- but a screen that draws its
|
||||||
|
card once and then holds it makes no further call. Its frame was on
|
||||||
|
the panel and never in the preview: soccer's recent/upcoming cards
|
||||||
|
skip redundant redraws, and the first one after an on-demand start
|
||||||
|
(pushed a few milliseconds after the controller's clear) left
|
||||||
|
/api/v3/display/current and the web preview black for the whole
|
||||||
|
screen while the panel showed the card.
|
||||||
|
|
||||||
|
The render loop calls this after each frame. Cheap when nothing is
|
||||||
|
owed (one attribute read); otherwise the usual policy decides, so
|
||||||
|
the write still waits out the interval and an unchanged frame is
|
||||||
|
never re-encoded.
|
||||||
|
"""
|
||||||
|
if not self._snapshot_owed:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
if self._writes_suppressed():
|
||||||
|
return
|
||||||
|
with self._update_lock:
|
||||||
|
self._write_snapshot_if_due()
|
||||||
|
except Exception as e: # pylint: disable=broad-except
|
||||||
|
self._log_snapshot_failure(e)
|
||||||
|
|
||||||
def _log_snapshot_failure(self, error: Exception) -> None:
|
def _log_snapshot_failure(self, error: Exception) -> None:
|
||||||
# Snapshot failures must never break display — but they must not
|
# Snapshot failures must never break display — but they must not
|
||||||
# be silent either: the snapshot's mtime is the web UI's display
|
# be silent either: the snapshot's mtime is the web UI's display
|
||||||
|
|||||||
+125
-31
@@ -490,10 +490,13 @@ def record_error(
|
|||||||
# user reads, and the other way round for the clear request.
|
# user reads, and the other way round for the clear request.
|
||||||
#
|
#
|
||||||
# ERROR_SNAPSHOT_KEY written by the display service only
|
# ERROR_SNAPSHOT_KEY written by the display service only
|
||||||
# ERROR_CLEAR_REQUEST_KEY written by the web interface only
|
# ERROR_CLEAR_REQUEST_KEY written by the web interface only, as a fallback
|
||||||
#
|
#
|
||||||
# A clear is asynchronous: the web interface records a request, and the
|
# A clear goes over the control socket (``errors.clear``): the display applies
|
||||||
# display service applies it (clear_before) on its next tick and republishes.
|
# it (clear_before) and republishes the snapshot before it answers. Only when
|
||||||
|
# the socket cannot carry it (no socket, or a display older than the command)
|
||||||
|
# does the web interface record a request in the mailbox, which the display
|
||||||
|
# applies on its next tick; its tick reads that file only when it changed.
|
||||||
# Until it has, the web interface hides whatever the snapshot shows from
|
# 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
|
# before the cutoff, so a clear takes effect for readers immediately and a
|
||||||
# snapshot published just before the request cannot bring old errors back.
|
# snapshot published just before the request cannot bring old errors back.
|
||||||
@@ -598,13 +601,43 @@ class ErrorSnapshotPublisher:
|
|||||||
self._published_version: Optional[int] = None
|
self._published_version: Optional[int] = None
|
||||||
self._last_attempt: Optional[float] = None
|
self._last_attempt: Optional[float] = None
|
||||||
self._applied_clear_id: Optional[str] = None
|
self._applied_clear_id: Optional[str] = None
|
||||||
|
# The widest cutoff applied in this process: a clear request at or
|
||||||
|
# before it has nothing left to clear (see _pending_cutoff).
|
||||||
|
self._applied_clear_cutoff: Optional[float] = None
|
||||||
|
from src.cache_manager import MailboxWatch # the display's cache, loaded already
|
||||||
|
self._mailbox = MailboxWatch(ERROR_CLEAR_REQUEST_KEY)
|
||||||
self._tick_lock = threading.Lock()
|
self._tick_lock = threading.Lock()
|
||||||
self._stop = threading.Event()
|
self._stop = threading.Event()
|
||||||
self._thread: Optional[threading.Thread] = None
|
self._thread: Optional[threading.Thread] = None
|
||||||
|
|
||||||
|
def _clear(self, request_id: str, cutoff: float) -> int:
|
||||||
|
"""Apply one clear and remember it. Caller holds _tick_lock."""
|
||||||
|
cleared = 0
|
||||||
|
if math.isfinite(cutoff):
|
||||||
|
cleared = self.aggregator.clear_before(datetime.fromtimestamp(cutoff))
|
||||||
|
_snapshot_logger.info("Cleared %d plugin error record(s) as requested (%s)",
|
||||||
|
cleared, request_id)
|
||||||
|
if self._applied_clear_cutoff is None or cutoff > self._applied_clear_cutoff:
|
||||||
|
self._applied_clear_cutoff = cutoff
|
||||||
|
# A malformed request is acknowledged too, so it is not retried forever.
|
||||||
|
self._applied_clear_id = request_id
|
||||||
|
return cleared
|
||||||
|
|
||||||
def _apply_clear_request(self) -> bool:
|
def _apply_clear_request(self) -> bool:
|
||||||
"""Honour a clear request we have not applied yet. True if one was."""
|
"""Honour a mailbox 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)
|
|
||||||
|
The mailbox is the fallback for a web interface that could not use
|
||||||
|
the control socket (``errors.clear``, :meth:`clear_now`). It is read
|
||||||
|
only when its file changed since the last tick; otherwise a tick
|
||||||
|
costs one stat().
|
||||||
|
"""
|
||||||
|
if not self._mailbox.changed(self.cache_manager):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
request = self.cache_manager.get(ERROR_CLEAR_REQUEST_KEY, max_age=None, memory_ttl=0)
|
||||||
|
except Exception:
|
||||||
|
self._mailbox.forget()
|
||||||
|
raise
|
||||||
if not isinstance(request, dict):
|
if not isinstance(request, dict):
|
||||||
return False
|
return False
|
||||||
request_id = request.get("request_id")
|
request_id = request.get("request_id")
|
||||||
@@ -614,14 +647,30 @@ class ErrorSnapshotPublisher:
|
|||||||
cutoff = float(request.get("cutoff"))
|
cutoff = float(request.get("cutoff"))
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
cutoff = float("nan")
|
cutoff = float("nan")
|
||||||
if math.isfinite(cutoff):
|
self._clear(request_id, 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 True
|
||||||
|
|
||||||
|
def clear_now(self, request_id: str, cutoff: float) -> int:
|
||||||
|
"""``errors.clear`` over the control socket: apply a clear at once and
|
||||||
|
republish the snapshot, so the web interface's next read has it.
|
||||||
|
Returns how many records were cleared. Raises when the snapshot
|
||||||
|
could not be written, so the caller is not told it worked."""
|
||||||
|
with self._tick_lock:
|
||||||
|
cleared = self._clear(request_id, float(cutoff))
|
||||||
|
self._publish(self.aggregator.version, self._clock())
|
||||||
|
return cleared
|
||||||
|
|
||||||
|
def _publish(self, version: int, now: float) -> None:
|
||||||
|
"""Write the snapshot. Caller holds _tick_lock."""
|
||||||
|
# Stamp the attempt before writing: a cache that keeps failing
|
||||||
|
# is retried at the throttled rate, not on every tick.
|
||||||
|
self._last_attempt = now
|
||||||
|
snapshot = self.aggregator.build_snapshot()
|
||||||
|
snapshot["applied_clear_id"] = self._applied_clear_id
|
||||||
|
snapshot["applied_clear_cutoff"] = self._applied_clear_cutoff
|
||||||
|
self.cache_manager.set(ERROR_SNAPSHOT_KEY, snapshot)
|
||||||
|
self._published_version = version
|
||||||
|
|
||||||
def tick(self) -> bool:
|
def tick(self) -> bool:
|
||||||
"""Apply a pending clear and publish if due. True if a snapshot was written."""
|
"""Apply a pending clear and publish if due. True if a snapshot was written."""
|
||||||
with self._tick_lock:
|
with self._tick_lock:
|
||||||
@@ -635,13 +684,7 @@ class ErrorSnapshotPublisher:
|
|||||||
if (self._last_attempt is not None
|
if (self._last_attempt is not None
|
||||||
and now - self._last_attempt < self.min_interval):
|
and now - self._last_attempt < self.min_interval):
|
||||||
return False
|
return False
|
||||||
# Stamp the attempt before writing: a cache that keeps failing
|
self._publish(version, now)
|
||||||
# is retried at the throttled rate, not on every tick.
|
|
||||||
self._last_attempt = now
|
|
||||||
snapshot = self.aggregator.build_snapshot()
|
|
||||||
snapshot["applied_clear_id"] = self._applied_clear_id
|
|
||||||
self.cache_manager.set(ERROR_SNAPSHOT_KEY, snapshot)
|
|
||||||
self._published_version = version
|
|
||||||
return True
|
return True
|
||||||
except Exception as err: # never let reporting break the display
|
except Exception as err: # never let reporting break the display
|
||||||
_snapshot_logger.debug("Could not publish the plugin error snapshot: %s",
|
_snapshot_logger.debug("Could not publish the plugin error snapshot: %s",
|
||||||
@@ -693,6 +736,22 @@ def start_error_snapshot_publisher(cache_manager: Any) -> Optional[ErrorSnapshot
|
|||||||
return None
|
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) -------------------------------------------
|
# --- Reading side (web interface) -------------------------------------------
|
||||||
|
|
||||||
def read_error_report(cache_manager: Any) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
def read_error_report(cache_manager: Any) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
||||||
@@ -731,7 +790,15 @@ def _pending_cutoff(snapshot: Optional[Dict[str, Any]],
|
|||||||
cutoff = float(clear_request.get("cutoff"))
|
cutoff = float(clear_request.get("cutoff"))
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return None
|
return None
|
||||||
return cutoff if math.isfinite(cutoff) else None
|
if not math.isfinite(cutoff):
|
||||||
|
return None
|
||||||
|
# A wider clear has been applied since (over the control socket): this
|
||||||
|
# older request has nothing left to hide.
|
||||||
|
applied = snapshot.get("applied_clear_cutoff") if snapshot is not None else None
|
||||||
|
if (isinstance(applied, (int, float)) and not isinstance(applied, bool)
|
||||||
|
and applied >= cutoff):
|
||||||
|
return None
|
||||||
|
return cutoff
|
||||||
|
|
||||||
|
|
||||||
def _is_after(item: Any, field_name: str, cutoff: float) -> bool:
|
def _is_after(item: Any, field_name: str, cutoff: float) -> bool:
|
||||||
@@ -831,13 +898,31 @@ def _count_cleared(summary: Dict[str, Any], cutoff: float) -> Optional[int]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def request_error_clear(cache_manager: Any, cutoff: float) -> Dict[str, Any]:
|
#: ``send(request_id, cutoff)`` hands a clear to the display over the control
|
||||||
|
#: socket and returns its ErrorsClearResult, or None when the socket could
|
||||||
|
#: not carry it and the mailbox should be written instead. Any exception it
|
||||||
|
#: raises reaches the caller: the display had the request and failed it.
|
||||||
|
ClearSender = Callable[[str, float], Optional[Dict[str, Any]]]
|
||||||
|
|
||||||
|
|
||||||
|
def request_error_clear(cache_manager: Any, cutoff: float,
|
||||||
|
send: Optional[ClearSender] = None) -> Dict[str, Any]:
|
||||||
"""Ask the display service to forget errors recorded at or before ``cutoff``.
|
"""Ask the display service to forget errors recorded at or before ``cutoff``.
|
||||||
|
|
||||||
Returns ``request_id``, ``cutoff`` (ISO, local time), ``cleared_count``
|
Over the control socket when ``send`` is given and carries it: the
|
||||||
(see _count_cleared) and ``clear_requested``. Raises OSError when the
|
display applies the clear and republishes its snapshot before it
|
||||||
request did not reach the shared cache, since a cache without a usable
|
answers, so nothing is written here. Otherwise (no socket, or a display
|
||||||
directory accepts set() and keeps nothing.
|
older than ``errors.clear``) a request is written to the
|
||||||
|
``plugin_error_clear_request`` mailbox, which the display applies on
|
||||||
|
its next tick, and readers hide the cleared errors until then.
|
||||||
|
|
||||||
|
Returns ``request_id``, ``cutoff`` (ISO, local time), ``cleared_count``,
|
||||||
|
``clear_requested``, ``applied`` (the display has already cleared them)
|
||||||
|
and ``transport`` (``socket`` or ``mailbox``). ``cleared_count`` is the
|
||||||
|
display's own count over the socket, else an estimate from the snapshot
|
||||||
|
(see _count_cleared). Raises OSError when a mailbox request did not reach
|
||||||
|
the shared cache, since a cache without a usable directory accepts set()
|
||||||
|
and keeps nothing.
|
||||||
|
|
||||||
A request the display has not applied yet is only ever widened: a later,
|
A request the display has not applied yet is only ever widened: a later,
|
||||||
narrower one ("older than 24 hours" after "everything") overwriting it
|
narrower one ("older than 24 hours" after "everything") overwriting it
|
||||||
@@ -848,8 +933,21 @@ def request_error_clear(cache_manager: Any, cutoff: float) -> Dict[str, Any]:
|
|||||||
if pending is not None:
|
if pending is not None:
|
||||||
cutoff = max(cutoff, pending)
|
cutoff = max(cutoff, pending)
|
||||||
before = error_summary_from_report(snapshot, clear_request)
|
before = error_summary_from_report(snapshot, clear_request)
|
||||||
|
request_id = uuid.uuid4().hex
|
||||||
|
answer = {
|
||||||
|
"clear_requested": True,
|
||||||
|
"request_id": request_id,
|
||||||
|
"cutoff": datetime.fromtimestamp(cutoff).isoformat(),
|
||||||
|
}
|
||||||
|
if send is not None:
|
||||||
|
result = send(request_id, cutoff)
|
||||||
|
if result is not None:
|
||||||
|
count = result.get("cleared")
|
||||||
|
return dict(answer, applied=True, transport="socket",
|
||||||
|
cleared_count=count if isinstance(count, int) and not isinstance(count, bool)
|
||||||
|
else _count_cleared(before, cutoff))
|
||||||
request = {
|
request = {
|
||||||
"request_id": uuid.uuid4().hex,
|
"request_id": request_id,
|
||||||
"cutoff": cutoff,
|
"cutoff": cutoff,
|
||||||
"requested_at": time.time(),
|
"requested_at": time.time(),
|
||||||
}
|
}
|
||||||
@@ -857,9 +955,5 @@ def request_error_clear(cache_manager: Any, cutoff: float) -> Dict[str, Any]:
|
|||||||
stored = cache_manager.get(ERROR_CLEAR_REQUEST_KEY, max_age=None, memory_ttl=0)
|
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"]:
|
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")
|
raise OSError("the clear request was not stored in the shared cache")
|
||||||
return {
|
return dict(answer, applied=False, transport="mailbox",
|
||||||
"cleared_count": _count_cleared(before, cutoff),
|
cleared_count=_count_cleared(before, cutoff))
|
||||||
"clear_requested": True,
|
|
||||||
"request_id": request["request_id"],
|
|
||||||
"cutoff": datetime.fromtimestamp(cutoff).isoformat(),
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -222,6 +222,39 @@ class FontManager:
|
|||||||
logger.error(f"Error registering fonts for plugin {plugin_id}: {e}", exc_info=True)
|
logger.error(f"Error registering fonts for plugin {plugin_id}: {e}", exc_info=True)
|
||||||
return False
|
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:
|
def _validate_font_manifest(self, font_manifest: Dict[str, Any]) -> bool:
|
||||||
"""Validate the structure of a plugin's font manifest."""
|
"""Validate the structure of a plugin's font manifest."""
|
||||||
required_fields = ["fonts"]
|
required_fields = ["fonts"]
|
||||||
|
|||||||
+107
-13
@@ -3,8 +3,13 @@
|
|||||||
Every failure -- no socket (the display is stopped, or predates the socket),
|
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
|
a refused or timed-out connection, a reply that breaks the contract, or an
|
||||||
error the display returned -- raises :class:`ControlError` with a short
|
error the display returned -- raises :class:`ControlError` with a short
|
||||||
``reason``, and the caller falls back to the file mailbox. Nothing here
|
``reason``. Nothing here blocks for longer than ``timeout`` in total.
|
||||||
blocks for longer than ``timeout`` in total.
|
|
||||||
|
Whether the caller may then write the file mailbox instead is
|
||||||
|
:func:`should_fall_back`: only when the display never took the request (it
|
||||||
|
could not be reached, or it is too old to know the command). A display that
|
||||||
|
took the request and then failed, refused or went quiet is answered as
|
||||||
|
that, not posted a second time through the mailbox.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -22,6 +27,7 @@ from src.ipc.contract import (
|
|||||||
SUBSCRIBE_KEEPALIVE_SECONDS,
|
SUBSCRIBE_KEEPALIVE_SECONDS,
|
||||||
SUPPORTED_VERSIONS,
|
SUPPORTED_VERSIONS,
|
||||||
Command,
|
Command,
|
||||||
|
ErrorCode,
|
||||||
FrameReader,
|
FrameReader,
|
||||||
ProtocolError,
|
ProtocolError,
|
||||||
Request,
|
Request,
|
||||||
@@ -49,17 +55,49 @@ class ControlError(Exception):
|
|||||||
``refused``, ``timeout``, ``closed``, ``bad_response``, ``invalid_request``.
|
``refused``, ``timeout``, ``closed``, ``bad_response``, ``invalid_request``.
|
||||||
When the display answered with an error, ``reason`` is that error's
|
When the display answered with an error, ``reason`` is that error's
|
||||||
:class:`~src.ipc.contract.ErrorCode` (``busy``, ``unknown_command``, ...).
|
: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)
|
super().__init__(reason, message)
|
||||||
self.reason = reason
|
self.reason = reason
|
||||||
self.message = message
|
self.message = message
|
||||||
|
self.sent = sent
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return f'{self.reason}: {self.message}' if self.message else self.reason
|
return f'{self.reason}: {self.message}' if self.message else self.reason
|
||||||
|
|
||||||
|
|
||||||
|
#: Answers from a display that read the request but does not speak it: one
|
||||||
|
#: older than the command (an upgrade in progress) or the protocol version.
|
||||||
|
#: It did nothing, so the mailbox is the way to reach it.
|
||||||
|
UPGRADE_REASONS = frozenset({ErrorCode.UNKNOWN_COMMAND, ErrorCode.UNSUPPORTED_VERSION})
|
||||||
|
|
||||||
|
|
||||||
|
def should_fall_back(error: BaseException) -> bool:
|
||||||
|
"""May the caller write the file mailbox after ``error``?
|
||||||
|
|
||||||
|
Yes when the display never took the request: there is no socket (the
|
||||||
|
display is stopped, predates the socket, or it is switched off), the
|
||||||
|
connection was refused or timed out, the display turned the connection
|
||||||
|
away before reading it, or it is too old to know the command
|
||||||
|
(:data:`UPGRADE_REASONS`). Also for an error that is not a
|
||||||
|
:class:`ControlError` (a bug in the client), as before.
|
||||||
|
|
||||||
|
No once the display had the request: a ``busy`` queue, ``invalid_args``,
|
||||||
|
an ``internal`` error, or a timeout or hang-up after the request was
|
||||||
|
sent. The display may have applied it, or would refuse it from the
|
||||||
|
mailbox too, so a second copy there only hides the failure.
|
||||||
|
"""
|
||||||
|
if not isinstance(error, ControlError):
|
||||||
|
return True
|
||||||
|
return not error.sent or error.reason in UPGRADE_REASONS
|
||||||
|
|
||||||
|
|
||||||
def request(cmd: str, args: Optional[Mapping[str, Any]] = None, *,
|
def request(cmd: str, args: Optional[Mapping[str, Any]] = None, *,
|
||||||
request_id: Optional[str] = None,
|
request_id: Optional[str] = None,
|
||||||
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
||||||
@@ -93,11 +131,14 @@ def request(cmd: str, args: Optional[Mapping[str, Any]] = None, *,
|
|||||||
# A refusal before the request was read (forbidden, too many
|
# A refusal before the request was read (forbidden, too many
|
||||||
# connections) carries no id.
|
# connections) carries no id.
|
||||||
if response.id != request_id and not (response.id is None and not response.ok):
|
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:
|
if not response.ok:
|
||||||
error = response.error
|
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',
|
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 {})
|
return dict(response.result or {})
|
||||||
|
|
||||||
|
|
||||||
@@ -142,26 +183,31 @@ def _connect(paths: Sequence[str], deadline: float) -> socket.socket:
|
|||||||
|
|
||||||
|
|
||||||
def _exchange(sock: socket.socket, payload: bytes, deadline: float) -> Response:
|
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:
|
try:
|
||||||
sock.settimeout(_remaining(deadline))
|
sock.settimeout(_remaining(deadline))
|
||||||
sock.sendall(payload)
|
sock.sendall(payload)
|
||||||
|
sent = True
|
||||||
reader = FrameReader(MAX_MESSAGE_BYTES)
|
reader = FrameReader(MAX_MESSAGE_BYTES)
|
||||||
while True:
|
while True:
|
||||||
sock.settimeout(_remaining(deadline))
|
sock.settimeout(_remaining(deadline))
|
||||||
data = sock.recv(4096)
|
data = sock.recv(4096)
|
||||||
if not data:
|
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)
|
lines = reader.feed(data)
|
||||||
if lines:
|
if lines:
|
||||||
return Response.from_dict(decode_message(lines[0]))
|
return Response.from_dict(decode_message(lines[0]))
|
||||||
except socket.timeout:
|
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:
|
except ProtocolError as e:
|
||||||
raise ControlError('bad_response', e.message) from None
|
raise ControlError('bad_response', e.message, sent=sent) from None
|
||||||
except ControlError:
|
except ControlError as e:
|
||||||
|
e.sent = e.sent or sent
|
||||||
raise
|
raise
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
raise ControlError('closed', str(e)) from None
|
raise ControlError('closed', str(e), sent=sent) from None
|
||||||
|
|
||||||
|
|
||||||
# -- commands ---------------------------------------------------------------------------
|
# -- commands ---------------------------------------------------------------------------
|
||||||
@@ -227,6 +273,21 @@ def plugin_reload(plugin_id: str, *, timeout: Optional[float] = None,
|
|||||||
else timeout, paths=paths)
|
else timeout, paths=paths)
|
||||||
|
|
||||||
|
|
||||||
|
def errors_clear(request_id: str, cutoff: float, *,
|
||||||
|
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
||||||
|
paths: Optional[Sequence[str]] = None) -> Dict[str, Any]:
|
||||||
|
"""Have the display forget the plugin errors recorded at or before
|
||||||
|
``cutoff`` (epoch seconds) and publish its error snapshot again.
|
||||||
|
|
||||||
|
Returns :class:`~src.ipc.contract.ErrorsClearResult` once it is done.
|
||||||
|
Raises :class:`ControlError`: ``unknown_command`` from a display older
|
||||||
|
than the command, which still reads the ``plugin_error_clear_request``
|
||||||
|
mailbox.
|
||||||
|
"""
|
||||||
|
return request(Command.ERRORS_CLEAR, {'cutoff': cutoff}, request_id=request_id,
|
||||||
|
timeout=timeout, paths=paths)
|
||||||
|
|
||||||
|
|
||||||
def ping(*, timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
def ping(*, timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
||||||
paths: Optional[Sequence[str]] = None) -> Dict[str, Any]:
|
paths: Optional[Sequence[str]] = None) -> Dict[str, Any]:
|
||||||
return request(Command.PING, {}, timeout=timeout, paths=paths)
|
return request(Command.PING, {}, timeout=timeout, paths=paths)
|
||||||
@@ -283,6 +344,29 @@ def snapshot_loop_age(snapshot: Mapping[str, Any],
|
|||||||
return max(float(age), 0.0) + snapshot_age(snapshot, now_mono)
|
return max(float(age), 0.0) + snapshot_age(snapshot, now_mono)
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_volatile(state: Dict[str, Any], volatile: Any) -> None:
|
||||||
|
"""Fold a tick's ``volatile`` values (``{section: {key: value}}``) into
|
||||||
|
``state``, copying each section it touches.
|
||||||
|
|
||||||
|
These are the timestamps the hub leaves out of its version --
|
||||||
|
``display.last_updated``, ``on_demand.last_updated``/``remaining``,
|
||||||
|
``plugins.published_at`` -- and the readers judge freshness by them, so
|
||||||
|
a copy that only full ``state`` events updated would go stale while the
|
||||||
|
same mode stayed on screen. Only keys the section already has are taken:
|
||||||
|
a tick never adds a section or a key the last snapshot did not carry
|
||||||
|
(a section left out of a truncated snapshot stays out).
|
||||||
|
"""
|
||||||
|
if not isinstance(volatile, dict):
|
||||||
|
return # a display from before ticks carried them
|
||||||
|
for name, values in volatile.items():
|
||||||
|
section = state.get(name)
|
||||||
|
if not isinstance(section, dict) or not isinstance(values, dict):
|
||||||
|
continue
|
||||||
|
fresh = {k: v for k, v in values.items() if k in section}
|
||||||
|
if fresh:
|
||||||
|
state[name] = dict(section, **fresh)
|
||||||
|
|
||||||
|
|
||||||
#: A subscription that has heard nothing for this long is not trusted: the
|
#: A subscription that has heard nothing for this long is not trusted: the
|
||||||
#: display sends a tick at least every SUBSCRIBE_KEEPALIVE_SECONDS.
|
#: display sends a tick at least every SUBSCRIBE_KEEPALIVE_SECONDS.
|
||||||
SUBSCRIPTION_SILENCE_SECONDS = 3 * SUBSCRIBE_KEEPALIVE_SECONDS
|
SUBSCRIPTION_SILENCE_SECONDS = 3 * SUBSCRIBE_KEEPALIVE_SECONDS
|
||||||
@@ -373,9 +457,9 @@ class StateSubscription:
|
|||||||
def _run(self) -> None:
|
def _run(self) -> None:
|
||||||
backoff = _RECONNECT_MIN_SECONDS
|
backoff = _RECONNECT_MIN_SECONDS
|
||||||
while not self._stop.is_set():
|
while not self._stop.is_set():
|
||||||
|
snapshots = self.snapshots
|
||||||
try:
|
try:
|
||||||
self._follow()
|
self._follow()
|
||||||
backoff = _RECONNECT_MIN_SECONDS
|
|
||||||
except ControlError as e:
|
except ControlError as e:
|
||||||
self.last_error = e.reason
|
self.last_error = e.reason
|
||||||
if e.reason in _SLOW_RETRY_REASONS:
|
if e.reason in _SLOW_RETRY_REASONS:
|
||||||
@@ -391,6 +475,11 @@ class StateSubscription:
|
|||||||
sock.close()
|
sock.close()
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
if self.snapshots != snapshots:
|
||||||
|
# This connection got as far as the display's state: whatever
|
||||||
|
# ended it (a restart, most often), it was working, so the
|
||||||
|
# next try starts from the shortest wait again.
|
||||||
|
backoff = _RECONNECT_MIN_SECONDS
|
||||||
if self._stop.wait(backoff):
|
if self._stop.wait(backoff):
|
||||||
return
|
return
|
||||||
backoff = min(backoff * 2, _RECONNECT_MAX_SECONDS)
|
backoff = min(backoff * 2, _RECONNECT_MAX_SECONDS)
|
||||||
@@ -449,12 +538,17 @@ class StateSubscription:
|
|||||||
self.snapshots += 1
|
self.snapshots += 1
|
||||||
elif (self._snapshot is not None
|
elif (self._snapshot is not None
|
||||||
and result.get('epoch') == self._snapshot.get('epoch')):
|
and result.get('epoch') == self._snapshot.get('epoch')):
|
||||||
# A tick: nothing changed but the render loop's liveness.
|
# A tick: nothing changed but the render loop's liveness and
|
||||||
|
# the volatile keys (timestamps) the writers keep refreshing.
|
||||||
snap = dict(self._snapshot)
|
snap = dict(self._snapshot)
|
||||||
|
state = dict(snap.get('state') or {})
|
||||||
|
if result.get('version') == snap.get('version'):
|
||||||
|
_merge_volatile(state, result.get('volatile'))
|
||||||
loop = result.get('loop')
|
loop = result.get('loop')
|
||||||
if isinstance(loop, dict):
|
if isinstance(loop, dict):
|
||||||
snap['state'] = dict(snap.get('state') or {}, loop=loop)
|
state['loop'] = loop
|
||||||
snap['loop'] = loop
|
snap['loop'] = loop
|
||||||
|
snap['state'] = state
|
||||||
snap['served_at'] = result.get('served_at', snap.get('served_at'))
|
snap['served_at'] = result.get('served_at', snap.get('served_at'))
|
||||||
self._snapshot = snap
|
self._snapshot = snap
|
||||||
else:
|
else:
|
||||||
|
|||||||
+58
-11
@@ -149,12 +149,13 @@ class Command:
|
|||||||
PLUGIN_RELOAD = 'plugin.reload'
|
PLUGIN_RELOAD = 'plugin.reload'
|
||||||
STATE_GET = 'state.get'
|
STATE_GET = 'state.get'
|
||||||
STATE_SUBSCRIBE = 'state.subscribe'
|
STATE_SUBSCRIBE = 'state.subscribe'
|
||||||
|
ERRORS_CLEAR = 'errors.clear'
|
||||||
|
|
||||||
|
|
||||||
#: Every command version 1 defines, in the order ``hello`` reports them.
|
#: Every command version 1 defines, in the order ``hello`` reports them.
|
||||||
#: ``brightness.set`` and ``plugin.reload`` came in stage 2, and ``state.get``
|
#: ``brightness.set`` and ``plugin.reload`` came in stage 2, ``state.get``
|
||||||
#: and ``state.subscribe`` in stage 3, all within version 1 (see the module
|
#: and ``state.subscribe`` in stage 3, and ``errors.clear`` in stage 4, all
|
||||||
#: docstring on adding commands).
|
#: within version 1 (see the module docstring on adding commands).
|
||||||
COMMANDS: Tuple[str, ...] = (
|
COMMANDS: Tuple[str, ...] = (
|
||||||
Command.HELLO,
|
Command.HELLO,
|
||||||
Command.PING,
|
Command.PING,
|
||||||
@@ -165,8 +166,15 @@ COMMANDS: Tuple[str, ...] = (
|
|||||||
Command.PLUGIN_RELOAD,
|
Command.PLUGIN_RELOAD,
|
||||||
Command.STATE_GET,
|
Command.STATE_GET,
|
||||||
Command.STATE_SUBSCRIBE,
|
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.
|
#: Commands that are queued for the render thread.
|
||||||
QUEUED_COMMANDS = frozenset({Command.ON_DEMAND_START, Command.ON_DEMAND_STOP,
|
QUEUED_COMMANDS = frozenset({Command.ON_DEMAND_START, Command.ON_DEMAND_STOP,
|
||||||
Command.BRIGHTNESS_SET, Command.PLUGIN_RELOAD})
|
Command.BRIGHTNESS_SET, Command.PLUGIN_RELOAD})
|
||||||
@@ -191,7 +199,7 @@ MAX_SUBSCRIBERS = 4
|
|||||||
|
|
||||||
#: A subscriber hears from the display at least this often: a ``state``
|
#: A subscriber hears from the display at least this often: a ``state``
|
||||||
#: event when something changed, else a ``tick`` carrying the render loop's
|
#: event when something changed, else a ``tick`` carrying the render loop's
|
||||||
#: liveness. A client that has heard nothing for a few of these treats its
|
#: liveness and the latest volatile timestamps. A client that has heard nothing for a few of these treats its
|
||||||
#: copy as unknown.
|
#: copy as unknown.
|
||||||
SUBSCRIBE_KEEPALIVE_SECONDS = 5.0
|
SUBSCRIBE_KEEPALIVE_SECONDS = 5.0
|
||||||
|
|
||||||
@@ -530,8 +538,10 @@ class StateGetArgs:
|
|||||||
"""``state.get``: the display's state, as a versioned snapshot.
|
"""``state.get``: the display's state, as a versioned snapshot.
|
||||||
|
|
||||||
With ``since`` and the ``epoch`` it came from, the answer is only
|
With ``since`` and the ``epoch`` it came from, the answer is only
|
||||||
``{changed: false, version, epoch, served_at, loop}`` while the state is
|
``{changed: false, version, epoch, served_at, loop, volatile}`` while the
|
||||||
still at that version, so a poller that already has it is sent no state.
|
state is still at that version, so a poller that already has it is sent
|
||||||
|
no state -- only the latest values of the keys that do not count as a
|
||||||
|
change (``volatile``, see :class:`StateSnapshot`).
|
||||||
"""
|
"""
|
||||||
since: Optional[int] = None
|
since: Optional[int] = None
|
||||||
epoch: Optional[str] = None
|
epoch: Optional[str] = None
|
||||||
@@ -563,8 +573,32 @@ class StateSubscribeArgs:
|
|||||||
return cls()
|
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,
|
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.
|
#: The arguments of a command that goes on the render thread's queue.
|
||||||
QueuedArgs = Union[OnDemandStartArgs, OnDemandStopArgs, BrightnessSetArgs, PluginReloadArgs]
|
QueuedArgs = Union[OnDemandStartArgs, OnDemandStopArgs, BrightnessSetArgs, PluginReloadArgs]
|
||||||
@@ -579,6 +613,7 @@ _ARG_TYPES: Dict[str, Any] = {
|
|||||||
Command.PLUGIN_RELOAD: PluginReloadArgs,
|
Command.PLUGIN_RELOAD: PluginReloadArgs,
|
||||||
Command.STATE_GET: StateGetArgs,
|
Command.STATE_GET: StateGetArgs,
|
||||||
Command.STATE_SUBSCRIBE: StateSubscribeArgs,
|
Command.STATE_SUBSCRIBE: StateSubscribeArgs,
|
||||||
|
Command.ERRORS_CLEAR: ErrorsClearArgs,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -651,6 +686,13 @@ class PluginReloadResult(TypedDict):
|
|||||||
modes: List[str]
|
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):
|
class LoopState(TypedDict):
|
||||||
"""``loop``: is the render loop still going round?
|
"""``loop``: is the render loop still going round?
|
||||||
|
|
||||||
@@ -672,9 +714,13 @@ class StateSnapshot(TypedDict, total=False):
|
|||||||
``version`` counts changes to the state within one ``epoch`` (one run of
|
``version`` counts changes to the state within one ``epoch`` (one run of
|
||||||
the display process): a reader that sees a new epoch starts over.
|
the display process): a reader that sees a new epoch starts over.
|
||||||
``changed`` is False only for a ``state.get`` whose ``since`` is still
|
``changed`` is False only for a ``state.get`` whose ``since`` is still
|
||||||
current, and then ``state`` is absent. ``served_at`` is the display's
|
current, and then ``state`` is absent and ``volatile`` is there instead:
|
||||||
wall clock when it answered. ``loop`` is measured at that moment, so it
|
``{section: {key: value}}``, the current values of the keys the version
|
||||||
is also inside ``state``.
|
ignores (``display.last_updated``, ``on_demand.last_updated`` and
|
||||||
|
``remaining``, ``plugins.published_at``). A reader merges them into the
|
||||||
|
copy it has; they are how it can tell the writers are still publishing.
|
||||||
|
``served_at`` is the display's wall clock when it answered. ``loop`` is
|
||||||
|
measured at that moment, so it is also inside ``state``.
|
||||||
|
|
||||||
``state`` holds the sections in :data:`STATE_SECTIONS`:
|
``state`` holds the sections in :data:`STATE_SECTIONS`:
|
||||||
|
|
||||||
@@ -696,12 +742,13 @@ class StateSnapshot(TypedDict, total=False):
|
|||||||
served_at: float
|
served_at: float
|
||||||
changed: bool
|
changed: bool
|
||||||
state: Dict[str, Any]
|
state: Dict[str, Any]
|
||||||
|
volatile: Dict[str, Dict[str, Any]]
|
||||||
loop: LoopState
|
loop: LoopState
|
||||||
|
|
||||||
|
|
||||||
class StateEventKind:
|
class StateEventKind:
|
||||||
STATE = 'state' # result: a full StateSnapshot, the latest version
|
STATE = 'state' # result: a full StateSnapshot, the latest version
|
||||||
TICK = 'tick' # result: {version, epoch, pid, served_at, loop}; nothing changed
|
TICK = 'tick' # result: {version, epoch, pid, served_at, loop, volatile}; nothing changed
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|||||||
+87
-9
@@ -6,7 +6,9 @@ rendering: a command that changes the panel is validated, put on a bounded
|
|||||||
queue and acknowledged, and the render thread drains that queue at the point
|
queue and acknowledged, and the render thread drains that queue at the point
|
||||||
where it reads the file mailbox (``DisplayController._poll_on_demand_requests``),
|
where it reads the file mailbox (``DisplayController._poll_on_demand_requests``),
|
||||||
handing each command to the same code. Queries (``on_demand.status``) are
|
handing each command to the same code. Queries (``on_demand.status``) are
|
||||||
answered from a snapshot callable the display provides.
|
answered from a snapshot callable the display provides, and the few commands
|
||||||
|
that touch nothing the render thread owns (``errors.clear``) by a handler the
|
||||||
|
display registers, on the connection thread.
|
||||||
|
|
||||||
The queue also wakes the render thread: :meth:`ControlServer.wait_for_command`
|
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
|
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,
|
AWAITED_COMMANDS,
|
||||||
COMMANDS,
|
COMMANDS,
|
||||||
DEFAULT_SOCKET_DIR,
|
DEFAULT_SOCKET_DIR,
|
||||||
|
DIRECT_COMMANDS,
|
||||||
DEFAULT_SOCKET_PATH,
|
DEFAULT_SOCKET_PATH,
|
||||||
MAX_MESSAGE_BYTES,
|
MAX_MESSAGE_BYTES,
|
||||||
MAX_SUBSCRIBERS,
|
MAX_SUBSCRIBERS,
|
||||||
@@ -107,7 +110,8 @@ MAX_CLIENTS = 8
|
|||||||
|
|
||||||
#: Commands waiting for the render thread. It drains them at least every
|
#: 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
|
#: 0.25 s, so a full queue means the render thread is stuck, and the client
|
||||||
#: is told ``busy`` (and falls back to the mailbox) instead of piling up work.
|
#: is told ``busy`` instead of piling up work. The mailbox would not be read
|
||||||
|
#: either, so the web interface reports the failure rather than fall back.
|
||||||
QUEUE_SIZE = 16
|
QUEUE_SIZE = 16
|
||||||
|
|
||||||
#: Timeout for one recv()/send() on a connection.
|
#: Timeout for one recv()/send() on a connection.
|
||||||
@@ -222,6 +226,22 @@ def _fingerprint(value: Optional[Mapping[str, Any]], volatile: Iterable[str]) ->
|
|||||||
return {k: v for k, v in value.items() if k not in skip} if skip else dict(value)
|
return {k: v for k, v in value.items() if k not in skip} if skip else dict(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _volatile_values(sections: Mapping[str, Optional[Dict[str, Any]]],
|
||||||
|
volatile: Mapping[str, FrozenSet[str]]) -> Dict[str, Dict[str, Any]]:
|
||||||
|
"""``{section: {key: value}}``: the volatile keys each published section
|
||||||
|
has now. The sections are never mutated after publish (a publish swaps
|
||||||
|
in a new dict), so reading them outside the lock is safe."""
|
||||||
|
values: Dict[str, Dict[str, Any]] = {}
|
||||||
|
for name, keys in volatile.items():
|
||||||
|
value = sections.get(name)
|
||||||
|
if not keys or not isinstance(value, dict):
|
||||||
|
continue
|
||||||
|
present = {k: value[k] for k in keys if k in value}
|
||||||
|
if present:
|
||||||
|
values[name] = present
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
def _unknown_loop() -> Dict[str, Any]:
|
def _unknown_loop() -> Dict[str, Any]:
|
||||||
return {'heartbeat_age_seconds': None, 'armed': False, 'stale_after': None}
|
return {'heartbeat_age_seconds': None, 'armed': False, 'stale_after': None}
|
||||||
|
|
||||||
@@ -259,7 +279,11 @@ class StateHub:
|
|||||||
(``loop_probe``), so it keeps ageing while the render thread is stuck.
|
(``loop_probe``), so it keeps ageing while the render thread is stuck.
|
||||||
|
|
||||||
The version goes up when a section's value changes, ignoring the keys
|
The version goes up when a section's value changes, ignoring the keys
|
||||||
the publisher names as volatile (timestamps). Publishing never blocks on
|
the publisher names as volatile (timestamps). Those keys still carry
|
||||||
|
news -- ``display.last_updated`` is the render thread's proof of life --
|
||||||
|
so the short ``changed: false`` answer, which is what a subscriber's
|
||||||
|
tick carries, has their current values in ``volatile``; a reader merges
|
||||||
|
them into its copy. Publishing never blocks on
|
||||||
a reader: the lock is held only to swap a dict reference and compare it,
|
a reader: the lock is held only to swap a dict reference and compare it,
|
||||||
and every socket write happens on the reader's own thread, outside it.
|
and every socket write happens on the reader's own thread, outside it.
|
||||||
A reader that is slow gets the latest version when it next asks, not
|
A reader that is slow gets the latest version when it next asks, not
|
||||||
@@ -274,6 +298,7 @@ class StateHub:
|
|||||||
self._cond = threading.Condition(threading.Lock())
|
self._cond = threading.Condition(threading.Lock())
|
||||||
self._sections: Dict[str, Optional[Dict[str, Any]]] = {}
|
self._sections: Dict[str, Optional[Dict[str, Any]]] = {}
|
||||||
self._fingerprints: Dict[str, Any] = {}
|
self._fingerprints: Dict[str, Any] = {}
|
||||||
|
self._volatile: Dict[str, FrozenSet[str]] = {}
|
||||||
self._version = 0
|
self._version = 0
|
||||||
self.epoch = epoch or uuid.uuid4().hex[:16]
|
self.epoch = epoch or uuid.uuid4().hex[:16]
|
||||||
self.pid = os.getpid() if pid is None else pid
|
self.pid = os.getpid() if pid is None else pid
|
||||||
@@ -301,9 +326,11 @@ class StateHub:
|
|||||||
The value is copied (one level), so the caller may reuse its dict.
|
The value is copied (one level), so the caller may reuse its dict.
|
||||||
"""
|
"""
|
||||||
stored = None if value is None else dict(value)
|
stored = None if value is None else dict(value)
|
||||||
fingerprint = _fingerprint(stored, volatile)
|
skip = frozenset(volatile)
|
||||||
|
fingerprint = _fingerprint(stored, skip)
|
||||||
with self._cond:
|
with self._cond:
|
||||||
self._sections[section] = stored
|
self._sections[section] = stored
|
||||||
|
self._volatile[section] = skip
|
||||||
if self._fingerprints.get(section, _MISSING) == fingerprint:
|
if self._fingerprints.get(section, _MISSING) == fingerprint:
|
||||||
return False
|
return False
|
||||||
self._fingerprints[section] = fingerprint
|
self._fingerprints[section] = fingerprint
|
||||||
@@ -333,11 +360,14 @@ class StateHub:
|
|||||||
"""The :class:`~src.ipc.contract.StateSnapshot` now.
|
"""The :class:`~src.ipc.contract.StateSnapshot` now.
|
||||||
|
|
||||||
``since`` with this hub's ``epoch``, still the current version, gives
|
``since`` with this hub's ``epoch``, still the current version, gives
|
||||||
the short ``changed: false`` form.
|
the short ``changed: false`` form, with ``volatile``: each section's
|
||||||
|
volatile keys at their latest values (the rest of the section is
|
||||||
|
what the reader already has).
|
||||||
"""
|
"""
|
||||||
with self._cond:
|
with self._cond:
|
||||||
version = self._version
|
version = self._version
|
||||||
sections = dict(self._sections)
|
sections = dict(self._sections)
|
||||||
|
volatile = dict(self._volatile)
|
||||||
loop = self.loop()
|
loop = self.loop()
|
||||||
result: Dict[str, Any] = {
|
result: Dict[str, Any] = {
|
||||||
'schema': STATE_SCHEMA,
|
'schema': STATE_SCHEMA,
|
||||||
@@ -349,6 +379,7 @@ class StateHub:
|
|||||||
}
|
}
|
||||||
if since is not None and epoch == self.epoch and since == version:
|
if since is not None and epoch == self.epoch and since == version:
|
||||||
result['changed'] = False
|
result['changed'] = False
|
||||||
|
result['volatile'] = _volatile_values(sections, volatile)
|
||||||
return result
|
return result
|
||||||
state: Dict[str, Any] = {name: sections.get(name) for name in STATE_SECTIONS
|
state: Dict[str, Any] = {name: sections.get(name) for name in STATE_SECTIONS
|
||||||
if name != 'loop'}
|
if name != 'loop'}
|
||||||
@@ -525,6 +556,12 @@ def server_socket_path(environ: Optional[Mapping[str, str]] = None) -> Optional[
|
|||||||
|
|
||||||
StatusProvider = Callable[[], Dict[str, Any]]
|
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:
|
class ControlServer:
|
||||||
"""Serves the control socket on background threads.
|
"""Serves the control socket on background threads.
|
||||||
@@ -542,9 +579,12 @@ class ControlServer:
|
|||||||
await_seconds: Optional[Mapping[str, float]] = None,
|
await_seconds: Optional[Mapping[str, float]] = None,
|
||||||
state_hub: Optional[StateHub] = None,
|
state_hub: Optional[StateHub] = None,
|
||||||
max_subscribers: int = MAX_SUBSCRIBERS,
|
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.path = path
|
||||||
self.state_hub = state_hub
|
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._subscriber_slots = threading.BoundedSemaphore(max_subscribers)
|
||||||
self._keepalive = keepalive
|
self._keepalive = keepalive
|
||||||
self._await_seconds: Dict[str, float] = dict(AWAIT_SECONDS)
|
self._await_seconds: Dict[str, float] = dict(AWAIT_SECONDS)
|
||||||
@@ -732,8 +772,22 @@ class ControlServer:
|
|||||||
"""
|
"""
|
||||||
return self._pending.wait(timeout)
|
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]:
|
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] = []
|
commands: List[QueuedCommand] = []
|
||||||
self._pending.clear()
|
self._pending.clear()
|
||||||
while True:
|
while True:
|
||||||
@@ -1001,6 +1055,9 @@ class ControlServer:
|
|||||||
snap = hub.snapshot()
|
snap = hub.snapshot()
|
||||||
return Response.success(request.id, fit_snapshot(snap), v=request.v)
|
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, (
|
if request.cmd in QUEUED_COMMANDS and isinstance(args, (
|
||||||
OnDemandStartArgs, OnDemandStopArgs, BrightnessSetArgs, PluginReloadArgs)):
|
OnDemandStartArgs, OnDemandStopArgs, BrightnessSetArgs, PluginReloadArgs)):
|
||||||
awaited = request.cmd in AWAITED_COMMANDS
|
awaited = request.cmd in AWAITED_COMMANDS
|
||||||
@@ -1028,6 +1085,25 @@ class ControlServer:
|
|||||||
return Response.failure(request.id, ErrorCode.INTERNAL,
|
return Response.failure(request.id, ErrorCode.INTERNAL,
|
||||||
f'{request.cmd} is not implemented', v=request.v)
|
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:
|
def _await_outcome(self, request: Request, outcome: CommandOutcome) -> Response:
|
||||||
"""Answer an awaited command once the render thread has applied it.
|
"""Answer an awaited command once the render thread has applied it.
|
||||||
|
|
||||||
@@ -1052,7 +1128,9 @@ class ControlServer:
|
|||||||
def start_control_server(status_provider: Optional[StatusProvider] = None,
|
def start_control_server(status_provider: Optional[StatusProvider] = None,
|
||||||
cache_dir: Optional[str] = None,
|
cache_dir: Optional[str] = None,
|
||||||
environ: Optional[Mapping[str, 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.
|
"""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
|
None covers Windows, ``LEDMATRIX_CONTROL_SOCKET=off`` and any failure to
|
||||||
@@ -1064,7 +1142,7 @@ def start_control_server(status_provider: Optional[StatusProvider] = None,
|
|||||||
logger.debug("Control socket disabled or unsupported here; using the file mailbox only")
|
logger.debug("Control socket disabled or unsupported here; using the file mailbox only")
|
||||||
return None
|
return None
|
||||||
server = ControlServer(path, status_provider, resolve_socket_group(cache_dir),
|
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
|
return server if server.start() else None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"""Keep glibc's malloc from holding on to memory the display has freed.
|
||||||
|
|
||||||
|
The display process allocates and frees PIL images and numpy buffers all day
|
||||||
|
from a dozen threads. glibc gives each allocating thread its own malloc arena
|
||||||
|
(up to 8 x CPU count) and returns little of what is freed inside them to the
|
||||||
|
OS, so resident memory climbs for hours while the live data stays flat. Two
|
||||||
|
in-process remedies, both standard library only (ctypes) and both no-ops off
|
||||||
|
Linux/glibc:
|
||||||
|
|
||||||
|
* :func:`cap_arenas` -- ``mallopt(M_ARENA_MAX, 2)``, the in-process twin of the
|
||||||
|
unit's ``Environment=MALLOC_ARENA_MAX=2``. Units installed before that line
|
||||||
|
existed never got it (systemd runs the copy in /etc/systemd/system), so the
|
||||||
|
process applies it itself. Call it before any other thread starts: arenas
|
||||||
|
already created stay. A ``MALLOC_ARENA_MAX`` set in the environment wins.
|
||||||
|
* :class:`MallocTrimmer` -- ``malloc_trim(0)`` at most every few minutes,
|
||||||
|
called from the render loop between screens, where no frame is being drawn.
|
||||||
|
glibc 2.8+ releases free pages from the middle of every arena, not only the
|
||||||
|
top of the main heap.
|
||||||
|
|
||||||
|
Without glibc (macOS, Windows, musl, the dev server on any of them) nothing is
|
||||||
|
loaded and every call returns False.
|
||||||
|
"""
|
||||||
|
import ctypes
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from typing import Any, Callable, Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
#: glibc's mallopt() parameter number for the arena cap (malloc.h).
|
||||||
|
M_ARENA_MAX = -8
|
||||||
|
|
||||||
|
#: The arena cap applied when the environment does not set one; the same value
|
||||||
|
#: as the unit's ``MALLOC_ARENA_MAX``.
|
||||||
|
DEFAULT_ARENA_MAX = 2
|
||||||
|
|
||||||
|
#: Seconds between malloc_trim() calls. A trim takes about 1-20 ms on a Pi 4,
|
||||||
|
#: so this keeps it far from frame timing while still returning memory long
|
||||||
|
#: before it piles up.
|
||||||
|
TRIM_INTERVAL_SECONDS = 300.0
|
||||||
|
|
||||||
|
_UNLOADED = object()
|
||||||
|
_libc: Any = _UNLOADED
|
||||||
|
|
||||||
|
|
||||||
|
def _load_libc() -> Optional[Any]:
|
||||||
|
"""The process's C library if it is glibc with malloc_trim, else None."""
|
||||||
|
global _libc
|
||||||
|
if _libc is _UNLOADED:
|
||||||
|
_libc = None
|
||||||
|
if sys.platform.startswith('linux'):
|
||||||
|
try:
|
||||||
|
libc = ctypes.CDLL(None)
|
||||||
|
# gnu_get_libc_version is glibc-only, so musl (which has
|
||||||
|
# mallopt but no malloc_trim) is left alone as a whole.
|
||||||
|
if all(hasattr(libc, name) for name in
|
||||||
|
('gnu_get_libc_version', 'malloc_trim', 'mallopt')):
|
||||||
|
libc.malloc_trim.argtypes = [ctypes.c_size_t]
|
||||||
|
libc.malloc_trim.restype = ctypes.c_int
|
||||||
|
libc.mallopt.argtypes = [ctypes.c_int, ctypes.c_int]
|
||||||
|
libc.mallopt.restype = ctypes.c_int
|
||||||
|
_libc = libc
|
||||||
|
except (OSError, AttributeError, TypeError):
|
||||||
|
logger.debug("glibc malloc controls unavailable", exc_info=True)
|
||||||
|
return _libc
|
||||||
|
|
||||||
|
|
||||||
|
def cap_arenas(max_arenas: int = DEFAULT_ARENA_MAX) -> bool:
|
||||||
|
"""Cap glibc's malloc arenas at ``max_arenas``. True when the cap was set.
|
||||||
|
|
||||||
|
Skipped when ``MALLOC_ARENA_MAX`` is in the environment: glibc has read it
|
||||||
|
already, and an operator who set it chose that value.
|
||||||
|
"""
|
||||||
|
if os.environ.get('MALLOC_ARENA_MAX'):
|
||||||
|
return False
|
||||||
|
libc = _load_libc()
|
||||||
|
if libc is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return bool(libc.mallopt(M_ARENA_MAX, int(max_arenas)))
|
||||||
|
except Exception: # pylint: disable=broad-except
|
||||||
|
logger.debug("mallopt(M_ARENA_MAX) failed", exc_info=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class MallocTrimmer:
|
||||||
|
"""Calls ``malloc_trim(0)`` at most once per ``interval`` seconds.
|
||||||
|
|
||||||
|
:meth:`maybe_trim` is meant for an idle point of the render loop; it costs
|
||||||
|
one clock read when no trim is due. The first trim comes one interval
|
||||||
|
after construction, so start-up's allocations have settled.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, interval: float = TRIM_INTERVAL_SECONDS,
|
||||||
|
clock: Callable[[], float] = time.monotonic) -> None:
|
||||||
|
self._interval = interval
|
||||||
|
self._clock = clock
|
||||||
|
self._libc = _load_libc()
|
||||||
|
self._next = clock() + interval
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available(self) -> bool:
|
||||||
|
return self._libc is not None
|
||||||
|
|
||||||
|
def maybe_trim(self) -> bool:
|
||||||
|
"""Trim if one is due. True when malloc_trim ran and released memory."""
|
||||||
|
if self._libc is None:
|
||||||
|
return False
|
||||||
|
now = self._clock()
|
||||||
|
if now < self._next:
|
||||||
|
return False
|
||||||
|
self._next = now + self._interval
|
||||||
|
try:
|
||||||
|
released = bool(self._libc.malloc_trim(0))
|
||||||
|
except Exception: # pylint: disable=broad-except
|
||||||
|
logger.debug("malloc_trim failed; not trying again", exc_info=True)
|
||||||
|
self._libc = None
|
||||||
|
return False
|
||||||
|
logger.debug("malloc_trim(0) took %.1f ms, released=%s",
|
||||||
|
(self._clock() - now) * 1000.0, released)
|
||||||
|
return released
|
||||||
@@ -1070,6 +1070,65 @@ class BasePlugin(ABC):
|
|||||||
if callable(notify):
|
if callable(notify):
|
||||||
notify(self.plugin_id)
|
notify(self.plugin_id)
|
||||||
|
|
||||||
|
def request_on_demand(self, mode: Optional[str] = None,
|
||||||
|
duration: Optional[float] = None,
|
||||||
|
pinned: bool = False) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Take the screen now: show this plugin on demand. Safe from any thread.
|
||||||
|
|
||||||
|
For a plugin that reacts to something outside the rotation -- an MQTT
|
||||||
|
message, a timer, a detection -- and wants the panel for it. The
|
||||||
|
request goes straight to the display in this process and is applied
|
||||||
|
on its render thread within a frame or so, exactly like an on-demand
|
||||||
|
start from the web interface.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mode: One of this plugin's display modes; None for its first.
|
||||||
|
duration: Seconds to show it before the rotation resumes; None
|
||||||
|
(or zero) for no limit, until end_on_demand() or the user
|
||||||
|
stops it.
|
||||||
|
pinned: Stay on ``mode`` instead of cycling through the
|
||||||
|
plugin's other modes.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The request id once the display has queued it, or None when
|
||||||
|
there is no display in this process to ask (the web interface,
|
||||||
|
scripts/check_plugin.py) or its queue is full. A plugin that
|
||||||
|
also runs on cores without this method writes the
|
||||||
|
``display_on_demand_request`` mailbox on None, as before; see
|
||||||
|
"On-demand display" in docs/PLUGIN_API_REFERENCE.md.
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
if not (hasattr(self, 'request_on_demand')
|
||||||
|
and self.request_on_demand(mode='my_alert', duration=15)):
|
||||||
|
self._write_on_demand_mailbox(...) # older cores
|
||||||
|
"""
|
||||||
|
request = getattr(getattr(self, 'plugin_manager', None), 'request_on_demand', None)
|
||||||
|
if not callable(request):
|
||||||
|
return None
|
||||||
|
request_id = request(self.plugin_id, mode=mode, duration=duration, pinned=pinned)
|
||||||
|
# Only a real id counts: a test's MagicMock manager answers a mock,
|
||||||
|
# which must read as "not taken" so the plugin's fallback runs.
|
||||||
|
return request_id if isinstance(request_id, str) else None
|
||||||
|
|
||||||
|
def end_on_demand(self) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Give the screen back: end this plugin's on-demand session. Any thread.
|
||||||
|
|
||||||
|
Ends only a session this plugin owns. One the user started for
|
||||||
|
another plugin, or a session that already ended, is left alone. The
|
||||||
|
rotation resumes where it left off.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The request id once queued, or None as request_on_demand() does.
|
||||||
|
"""
|
||||||
|
end = getattr(getattr(self, 'plugin_manager', None), 'end_on_demand', None)
|
||||||
|
if not callable(end):
|
||||||
|
return None
|
||||||
|
request_id = end(self.plugin_id)
|
||||||
|
return request_id if isinstance(request_id, str) else None
|
||||||
|
|
||||||
def get_vegas_participation(self) -> str:
|
def get_vegas_participation(self) -> str:
|
||||||
"""
|
"""
|
||||||
How this plugin takes part in Vegas mode: ``'scroll'``, ``'pause'`` or
|
How this plugin takes part in Vegas mode: ``'scroll'``, ``'pause'`` or
|
||||||
|
|||||||
@@ -48,12 +48,19 @@ class PluginOperation:
|
|||||||
completed_at: Optional[datetime] = None
|
completed_at: Optional[datetime] = None
|
||||||
|
|
||||||
def to_dict(self) -> Dict[str, Any]:
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
"""Convert operation to dictionary for serialization."""
|
"""Convert operation to dictionary for serialization.
|
||||||
|
|
||||||
|
Parameters whose name starts with ``_`` are internal and left out:
|
||||||
|
PluginOperationQueue keeps the operation's callback there as
|
||||||
|
``_callback`` until its worker runs it, and a pending operation's
|
||||||
|
status answered 500 because that function cannot be serialized.
|
||||||
|
"""
|
||||||
return {
|
return {
|
||||||
'operation_id': self.operation_id,
|
'operation_id': self.operation_id,
|
||||||
'operation_type': self.operation_type.value,
|
'operation_type': self.operation_type.value,
|
||||||
'plugin_id': self.plugin_id,
|
'plugin_id': self.plugin_id,
|
||||||
'parameters': self.parameters,
|
'parameters': {key: value for key, value in self.parameters.items()
|
||||||
|
if not str(key).startswith('_')},
|
||||||
'status': self.status.value,
|
'status': self.status.value,
|
||||||
'progress': self.progress,
|
'progress': self.progress,
|
||||||
'message': self.message,
|
'message': self.message,
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ reads through a catalog unchanged. It has nothing that runs a plugin: no
|
|||||||
``load_plugin``, ``get_plugin`` or ``plugins``.
|
``load_plugin``, ``get_plugin`` or ``plugins``.
|
||||||
|
|
||||||
Runtime state -- whether the display has a plugin loaded, its health, its
|
Runtime state -- whether the display has a plugin loaded, its health, its
|
||||||
errors -- is not here either. The display process publishes what it knows to
|
errors -- is not here either, with one exception: given a ``runtime_source``,
|
||||||
|
the mode lookups prefer the modes the running display registered. The display process publishes what it knows to
|
||||||
the shared cache (health and resource metrics, the current mode, the error
|
the shared cache (health and resource metrics, the current mode, the error
|
||||||
aggregator snapshot), and the web routes read those publications. What the
|
aggregator snapshot), and the web routes read those publications. What the
|
||||||
display does not publish (which plugins it has loaded, its plugin state
|
display does not publish (which plugins it has loaded, its plugin state
|
||||||
@@ -26,8 +27,9 @@ See docs/ARCHITECTURE.md ("Web and display processes").
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional, Union, cast
|
from typing import Any, Callable, Dict, List, Optional, Union, cast
|
||||||
|
|
||||||
from src.common.permission_utils import (
|
from src.common.permission_utils import (
|
||||||
ensure_directory_permissions, get_plugin_dir_mode,
|
ensure_directory_permissions, get_plugin_dir_mode,
|
||||||
@@ -39,6 +41,10 @@ from src.plugin_system.plugin_dirs import (
|
|||||||
|
|
||||||
PathLike = Union[str, Path]
|
PathLike = Union[str, Path]
|
||||||
|
|
||||||
|
#: How long one read of the display's runtime view answers mode lookups. A
|
||||||
|
#: listing asks once per plugin; the cache copy is a file read each time.
|
||||||
|
_RUNTIME_VIEW_TTL_SECONDS = 1.0
|
||||||
|
|
||||||
|
|
||||||
class PluginCatalog:
|
class PluginCatalog:
|
||||||
"""Manifests, schemas, config and versions of the installed plugins.
|
"""Manifests, schemas, config and versions of the installed plugins.
|
||||||
@@ -49,10 +55,17 @@ class PluginCatalog:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, plugins_dir: PathLike, config_manager: Optional[Any] = None,
|
def __init__(self, plugins_dir: PathLike, config_manager: Optional[Any] = None,
|
||||||
schema_manager: Optional[Any] = None) -> None:
|
schema_manager: Optional[Any] = None,
|
||||||
|
runtime_source: Optional[Callable[[], Any]] = None) -> None:
|
||||||
self.plugins_dir: Path = Path(plugins_dir)
|
self.plugins_dir: Path = Path(plugins_dir)
|
||||||
self.config_manager = config_manager
|
self.config_manager = config_manager
|
||||||
self.schema_manager = schema_manager
|
self.schema_manager = schema_manager
|
||||||
|
# Returns the display's PluginRuntimeView
|
||||||
|
# (src/plugin_system/plugin_runtime.py). Its live view carries the
|
||||||
|
# modes the display registered, which the mode lookups below prefer
|
||||||
|
# to the manifest's. None: manifests only.
|
||||||
|
self.runtime_source = runtime_source
|
||||||
|
self._runtime_view_memo: Optional[tuple] = None
|
||||||
self.logger = get_logger(__name__)
|
self.logger = get_logger(__name__)
|
||||||
|
|
||||||
# Guards plugin_manifests/plugin_directories: request threads read
|
# Guards plugin_manifests/plugin_directories: request threads read
|
||||||
@@ -172,23 +185,67 @@ class PluginCatalog:
|
|||||||
by_manifest=False)
|
by_manifest=False)
|
||||||
return str(plugin_dir) if plugin_dir is not None else None
|
return str(plugin_dir) if plugin_dir is not None else None
|
||||||
|
|
||||||
def get_plugin_display_modes(self, plugin_id: str) -> List[str]:
|
def _runtime_view(self) -> Any:
|
||||||
"""The manifest's ``display_modes``, or [].
|
"""The display's runtime view, read at most once a second; None
|
||||||
|
without a source or when reading it fails."""
|
||||||
|
if self.runtime_source is None:
|
||||||
|
return None
|
||||||
|
now = time.monotonic()
|
||||||
|
memo = self._runtime_view_memo
|
||||||
|
if memo is not None and now - memo[0] < _RUNTIME_VIEW_TTL_SECONDS:
|
||||||
|
return memo[1]
|
||||||
|
try:
|
||||||
|
view = self.runtime_source()
|
||||||
|
except Exception as exc: # a lookup must still answer from manifests
|
||||||
|
self.logger.debug("Could not read the display's runtime view: %s", exc)
|
||||||
|
view = None
|
||||||
|
self._runtime_view_memo = (now, view)
|
||||||
|
return view
|
||||||
|
|
||||||
What the display actually rotates can differ: a plugin may compute
|
def _live_display_modes(self, plugin_id: str) -> Optional[List[str]]:
|
||||||
its modes at run time (``plugin.modes``). This is the declared list.
|
"""The modes the running display registered for ``plugin_id``, or None."""
|
||||||
|
view = self._runtime_view()
|
||||||
|
if view is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
modes = view.display_modes(plugin_id)
|
||||||
|
except Exception as exc: # includes a source returning something else
|
||||||
|
self.logger.debug("Could not read display modes for %s: %s", plugin_id, exc)
|
||||||
|
return None
|
||||||
|
return list(modes) if isinstance(modes, list) and modes else None
|
||||||
|
|
||||||
|
def get_plugin_display_modes(self, plugin_id: str) -> List[str]:
|
||||||
|
"""The modes the display registered for the plugin, else the
|
||||||
|
manifest's ``display_modes``, else [].
|
||||||
|
|
||||||
|
A plugin may compute its modes at run time (``plugin.modes``): each
|
||||||
|
league soccer-scoreboard's ``custom_leagues`` adds is a mode no
|
||||||
|
manifest can list ahead of time (#668). The running display
|
||||||
|
publishes what it registered, and that wins while the display is
|
||||||
|
live and has the plugin loaded. Otherwise -- display stopped, plugin
|
||||||
|
disabled -- the declared list is the best answer there is.
|
||||||
"""
|
"""
|
||||||
|
live = self._live_display_modes(plugin_id)
|
||||||
|
if live is not None:
|
||||||
|
return live
|
||||||
with self._lock:
|
with self._lock:
|
||||||
manifest = self.plugin_manifests.get(plugin_id)
|
manifest = self.plugin_manifests.get(plugin_id)
|
||||||
modes = (manifest or {}).get('display_modes', [])
|
modes = (manifest or {}).get('display_modes', [])
|
||||||
return list(modes) if isinstance(modes, list) else []
|
return list(modes) if isinstance(modes, list) else []
|
||||||
|
|
||||||
def find_plugin_for_mode(self, mode: str) -> Optional[str]:
|
def find_plugin_for_mode(self, mode: str) -> Optional[str]:
|
||||||
"""The plugin whose manifest declares ``mode`` (case-insensitive)."""
|
"""The plugin that registered ``mode`` on the running display, else
|
||||||
|
the one whose manifest declares it (case-insensitive both ways)."""
|
||||||
wanted = mode.strip().lower()
|
wanted = mode.strip().lower()
|
||||||
with self._lock:
|
with self._lock:
|
||||||
manifests = dict(self.plugin_manifests)
|
manifests = dict(self.plugin_manifests)
|
||||||
|
for plugin_id in manifests:
|
||||||
|
live = self._live_display_modes(plugin_id)
|
||||||
|
if live and any(m.lower() == wanted for m in live):
|
||||||
|
return plugin_id
|
||||||
for plugin_id, manifest in manifests.items():
|
for plugin_id, manifest in manifests.items():
|
||||||
|
if self._live_display_modes(plugin_id):
|
||||||
|
continue # the display's list is the truth for this plugin
|
||||||
modes = manifest.get('display_modes')
|
modes = manifest.get('display_modes')
|
||||||
if isinstance(modes, list) and any(
|
if isinstance(modes, list) and any(
|
||||||
isinstance(m, str) and m.lower() == wanted for m in modes):
|
isinstance(m, str) and m.lower() == wanted for m in modes):
|
||||||
|
|||||||
@@ -92,7 +92,10 @@ class PluginExecutor:
|
|||||||
with plugin_scope(plugin_id):
|
with plugin_scope(plugin_id):
|
||||||
result_container['value'] = operation()
|
result_container['value'] = operation()
|
||||||
result_container['completed'] = True
|
result_container['completed'] = True
|
||||||
except Exception as e:
|
except BaseException as e: # pylint: disable=broad-except
|
||||||
|
# asyncio.CancelledError and SystemExit too: uncaught, one
|
||||||
|
# ended this thread with 'completed' unset, and an operation
|
||||||
|
# that failed at once was reported as timing out.
|
||||||
result_container['exception'] = e
|
result_container['exception'] = e
|
||||||
result_container['completed'] = True
|
result_container['completed'] = True
|
||||||
|
|
||||||
|
|||||||
@@ -199,9 +199,22 @@ def contained_plugin_dir(plugin_dir: Path, plugins_dir: Path) -> Optional[str]:
|
|||||||
name that came out of ``os.scandir()`` on the trusted root carries no
|
name that came out of ``os.scandir()`` on the trusted root carries no
|
||||||
taint, which is a real containment guarantee (and one CodeQL's
|
taint, which is a real containment guarantee (and one CodeQL's
|
||||||
path-injection query can follow), not a string sanitiser.
|
path-injection query can follow), not a string sanitiser.
|
||||||
|
|
||||||
|
The entry looked for is the one ``plugin_dir`` itself names when it sits
|
||||||
|
directly in ``plugins_dir``: for a dev plugin symlinked in under its id,
|
||||||
|
the link's name. Resolving the link first and looking for the target's
|
||||||
|
folder name refused ``plugins/foo -> ~/.ledmatrix-dev-plugins/ledmatrix-foo``
|
||||||
|
(what ``dev_plugin_setup.sh link-github foo <url>`` makes), so the plugin
|
||||||
|
never loaded. Any other path is resolved and matched by its final name,
|
||||||
|
as before.
|
||||||
"""
|
"""
|
||||||
plugin_dir_real = os.path.realpath(str(plugin_dir))
|
|
||||||
plugins_dir_real = os.path.realpath(str(plugins_dir))
|
plugins_dir_real = os.path.realpath(str(plugins_dir))
|
||||||
|
plugin_dir_abs = os.path.abspath(str(plugin_dir))
|
||||||
|
if os.path.realpath(os.path.dirname(plugin_dir_abs)) == plugins_dir_real:
|
||||||
|
matched_name = find_trusted_subdir(plugins_dir_real, os.path.basename(plugin_dir_abs))
|
||||||
|
if matched_name is not None:
|
||||||
|
return os.path.join(plugins_dir_real, matched_name)
|
||||||
|
plugin_dir_real = os.path.realpath(str(plugin_dir))
|
||||||
matched_name = find_trusted_subdir(plugins_dir_real, os.path.basename(plugin_dir_real))
|
matched_name = find_trusted_subdir(plugins_dir_real, os.path.basename(plugin_dir_real))
|
||||||
if matched_name is None:
|
if matched_name is None:
|
||||||
return None
|
return None
|
||||||
@@ -243,6 +256,10 @@ class PluginLoader:
|
|||||||
self.logger = logger or get_logger(__name__)
|
self.logger = logger or get_logger(__name__)
|
||||||
self._loaded_modules: Dict[str, Any] = {}
|
self._loaded_modules: Dict[str, Any] = {}
|
||||||
self._plugin_module_registry: Dict[str, set] = {} # Maps plugin_id to set of module names
|
self._plugin_module_registry: Dict[str, set] = {} # Maps plugin_id to set of module names
|
||||||
|
# plugin_id -> {dotted name: module} for the modules of the plugin's
|
||||||
|
# own packages (``providers.feed``). They keep their names while the
|
||||||
|
# plugin runs and are dropped with it; see _iter_plugin_submodules.
|
||||||
|
self._plugin_submodules: Dict[str, Dict[str, Any]] = {}
|
||||||
# Lock to serialize module loading when plugins share module names
|
# Lock to serialize module loading when plugins share module names
|
||||||
# (e.g., scroll_display.py, game_renderer.py across sport plugins).
|
# (e.g., scroll_display.py, game_renderer.py across sport plugins).
|
||||||
# During exec_module, bare-name sub-modules temporarily appear in
|
# During exec_module, bare-name sub-modules temporarily appear in
|
||||||
@@ -449,6 +466,45 @@ class PluginLoader:
|
|||||||
continue
|
continue
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _iter_plugin_submodules(
|
||||||
|
plugin_dir: Path, before_keys: set
|
||||||
|
) -> list:
|
||||||
|
"""Return dotted-name modules from plugin_dir added after before_keys.
|
||||||
|
|
||||||
|
The modules of a package the plugin ships (``providers.feed`` from
|
||||||
|
``providers/feed.py``). _iter_plugin_bare_modules skips them, so the
|
||||||
|
bare ``providers`` was namespaced and dropped on unload while
|
||||||
|
``providers.feed`` stayed in sys.modules: a reload after a store update
|
||||||
|
imported a fresh ``providers`` and then got the old ``feed`` back from
|
||||||
|
the cache, running the new manager.py against the old helpers until the
|
||||||
|
display restarted.
|
||||||
|
|
||||||
|
A module counts when its ``__file__`` -- or, for a namespace package,
|
||||||
|
which has none, every ``__path__`` entry -- is inside plugin_dir, so a
|
||||||
|
library the plugin imports (``requests.adapters``) never does.
|
||||||
|
|
||||||
|
Returns a list of (mod_name, module) tuples.
|
||||||
|
"""
|
||||||
|
resolved_dir = plugin_dir.resolve()
|
||||||
|
result = []
|
||||||
|
for key in set(sys.modules.keys()) - before_keys:
|
||||||
|
if "." not in key:
|
||||||
|
continue
|
||||||
|
mod = sys.modules.get(key)
|
||||||
|
if mod is None:
|
||||||
|
continue
|
||||||
|
mod_file = getattr(mod, "__file__", None)
|
||||||
|
locations = [mod_file] if mod_file else list(getattr(mod, "__path__", None) or [])
|
||||||
|
if not locations:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if all(Path(loc).resolve().is_relative_to(resolved_dir) for loc in locations):
|
||||||
|
result.append((key, mod))
|
||||||
|
except (ValueError, TypeError, OSError):
|
||||||
|
continue
|
||||||
|
return result
|
||||||
|
|
||||||
def _evict_stale_bare_modules(self, plugin_dir: Path) -> dict:
|
def _evict_stale_bare_modules(self, plugin_dir: Path) -> dict:
|
||||||
"""Temporarily remove bare-name sys.modules entries from other plugins.
|
"""Temporarily remove bare-name sys.modules entries from other plugins.
|
||||||
|
|
||||||
@@ -527,6 +583,13 @@ class PluginLoader:
|
|||||||
# Track for cleanup during unload
|
# Track for cleanup during unload
|
||||||
self._plugin_module_registry[plugin_id] = namespaced_names
|
self._plugin_module_registry[plugin_id] = namespaced_names
|
||||||
|
|
||||||
|
# The modules of the plugin's own packages keep their dotted names
|
||||||
|
# while it runs -- as they always have, so the package and its
|
||||||
|
# children stay a matching set in sys.modules -- and are dropped
|
||||||
|
# with the plugin by unregister_plugin_modules().
|
||||||
|
self._plugin_submodules[plugin_id] = dict(
|
||||||
|
self._iter_plugin_submodules(plugin_dir, before_keys))
|
||||||
|
|
||||||
if namespaced_names:
|
if namespaced_names:
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
"Namespace-isolated %d module(s) for plugin %s",
|
"Namespace-isolated %d module(s) for plugin %s",
|
||||||
@@ -537,10 +600,16 @@ class PluginLoader:
|
|||||||
"""Remove namespaced sub-modules and cached module for a plugin from sys.modules.
|
"""Remove namespaced sub-modules and cached module for a plugin from sys.modules.
|
||||||
|
|
||||||
Called by PluginManager during unload to clean up all module entries
|
Called by PluginManager during unload to clean up all module entries
|
||||||
that were created when the plugin was loaded.
|
that were created when the plugin was loaded, including the dotted
|
||||||
|
modules of its packages. A dotted name is dropped only while it still
|
||||||
|
holds this plugin's module: the name is not namespaced, so another
|
||||||
|
plugin may have put its own there since.
|
||||||
"""
|
"""
|
||||||
for ns_name in self._plugin_module_registry.pop(plugin_id, set()):
|
for ns_name in self._plugin_module_registry.pop(plugin_id, set()):
|
||||||
sys.modules.pop(ns_name, None)
|
sys.modules.pop(ns_name, None)
|
||||||
|
for name, mod in self._plugin_submodules.pop(plugin_id, {}).items():
|
||||||
|
if sys.modules.get(name) is mod:
|
||||||
|
sys.modules.pop(name, None)
|
||||||
self._loaded_modules.pop(plugin_id, None)
|
self._loaded_modules.pop(plugin_id, None)
|
||||||
|
|
||||||
def load_module(
|
def load_module(
|
||||||
@@ -646,11 +715,13 @@ class PluginLoader:
|
|||||||
if evicted_name not in sys.modules:
|
if evicted_name not in sys.modules:
|
||||||
sys.modules[evicted_name] = evicted_mod
|
sys.modules[evicted_name] = evicted_mod
|
||||||
# Clean up the partially-initialized main module and any
|
# Clean up the partially-initialized main module and any
|
||||||
# bare-name sub-modules that were added during exec_module
|
# bare-name or package sub-modules that were added during
|
||||||
# so they don't leak into subsequent plugin loads.
|
# exec_module so they don't leak into subsequent plugin loads.
|
||||||
sys.modules.pop(module_name, None)
|
sys.modules.pop(module_name, None)
|
||||||
for key, _ in self._iter_plugin_bare_modules(plugin_dir, before_keys):
|
for key, _ in self._iter_plugin_bare_modules(plugin_dir, before_keys):
|
||||||
sys.modules.pop(key, None)
|
sys.modules.pop(key, None)
|
||||||
|
for key, _ in self._iter_plugin_submodules(plugin_dir, before_keys):
|
||||||
|
sys.modules.pop(key, None)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
self._loaded_modules[plugin_id] = module
|
self._loaded_modules[plugin_id] = module
|
||||||
|
|||||||
@@ -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 time
|
||||||
import threading
|
import threading
|
||||||
import types
|
import types
|
||||||
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, Dict, List, NamedTuple, Optional, Any, Tuple, Union
|
from typing import Callable, Dict, List, NamedTuple, Optional, Any, Tuple, Union
|
||||||
import logging
|
import logging
|
||||||
@@ -214,6 +215,9 @@ class PluginManager:
|
|||||||
# add_update_listener(). A tuple, replaced rather than mutated, so the
|
# add_update_listener(). A tuple, replaced rather than mutated, so the
|
||||||
# worker can iterate it without a lock.
|
# worker can iterate it without a lock.
|
||||||
self._update_listeners: Tuple[Callable[[str], None], ...] = ()
|
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,
|
# Config changes that found the plugin's lock busy, latest per plugin,
|
||||||
# with the instance they were meant for. See apply_config_change().
|
# with the instance they were meant for. See apply_config_change().
|
||||||
self._deferred_config_changes: Dict[str, Tuple[Any, Dict[str, Any]]] = {}
|
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)
|
self.plugin_loader.unregister_plugin_modules(plugin_id)
|
||||||
except Exception as e: # pragma: no cover - defensive
|
except Exception as e: # pragma: no cover - defensive
|
||||||
self.logger.debug("Could not drop modules of %s: %s", plugin_id, e)
|
self.logger.debug("Could not drop modules of %s: %s", plugin_id, e)
|
||||||
try:
|
self._forget_plugin_fonts(plugin_id)
|
||||||
if self.font_manager is not None and hasattr(self.font_manager, 'forget_manager_fonts'):
|
|
||||||
self.font_manager.forget_manager_fonts(plugin_id)
|
def _forget_plugin_fonts(self, plugin_id: str) -> None:
|
||||||
except Exception as e:
|
"""Drop what the FontManager holds for a plugin: the fonts its
|
||||||
self.logger.debug("Could not forget fonts of %s: %s", plugin_id, e)
|
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
|
#: Config keys the **core** reads out of a plugin's own config block. The
|
||||||
#: plugin never declares them, so a schema with
|
#: plugin never declares them, so a schema with
|
||||||
@@ -846,12 +860,9 @@ class PluginManager:
|
|||||||
# Delegate sub-module and cached-module cleanup to the loader
|
# Delegate sub-module and cached-module cleanup to the loader
|
||||||
self.plugin_loader.unregister_plugin_modules(plugin_id)
|
self.plugin_loader.unregister_plugin_modules(plugin_id)
|
||||||
|
|
||||||
# Its font registrations go with it (the Fonts tab's "Used by").
|
# Its font registrations go with it: the fonts it reported using
|
||||||
try:
|
# and the ones its manifest registered.
|
||||||
if self.font_manager is not None and hasattr(self.font_manager, 'forget_manager_fonts'):
|
self._forget_plugin_fonts(plugin_id)
|
||||||
self.font_manager.forget_manager_fonts(plugin_id)
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.debug("Could not forget fonts of %s: %s", plugin_id, e)
|
|
||||||
|
|
||||||
# Update state
|
# Update state
|
||||||
self.state_manager.set_state(plugin_id, PluginState.UNLOADED)
|
self.state_manager.set_state(plugin_id, PluginState.UNLOADED)
|
||||||
@@ -1163,7 +1174,7 @@ class PluginManager:
|
|||||||
def _record_update_failure(
|
def _record_update_failure(
|
||||||
self,
|
self,
|
||||||
plugin_id: str,
|
plugin_id: str,
|
||||||
exc: Optional[Exception] = None,
|
exc: Optional[BaseException] = None,
|
||||||
log: bool = True,
|
log: bool = True,
|
||||||
count_failure: bool = True,
|
count_failure: bool = True,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -1187,7 +1198,7 @@ class PluginManager:
|
|||||||
"""
|
"""
|
||||||
failure_time = time.time()
|
failure_time = time.time()
|
||||||
if exc is not None:
|
if exc is not None:
|
||||||
err: Exception = exc
|
err: BaseException = exc
|
||||||
error_type = type(exc).__name__
|
error_type = type(exc).__name__
|
||||||
else:
|
else:
|
||||||
err = Exception(f"Plugin {plugin_id} execution failed (timeout or executor error)")
|
err = Exception(f"Plugin {plugin_id} execution failed (timeout or executor error)")
|
||||||
@@ -1653,7 +1664,7 @@ class PluginManager:
|
|||||||
finish_guard = threading.Lock()
|
finish_guard = threading.Lock()
|
||||||
finished = {'done': False}
|
finished = {'done': False}
|
||||||
|
|
||||||
def _finish(success: bool, exc: Optional[Exception] = None) -> None:
|
def _finish(success: bool, exc: Optional[BaseException] = None) -> None:
|
||||||
with finish_guard:
|
with finish_guard:
|
||||||
if finished['done']:
|
if finished['done']:
|
||||||
return
|
return
|
||||||
@@ -1727,7 +1738,13 @@ class PluginManager:
|
|||||||
self.resource_monitor.monitor_call(plugin_id, plugin_instance.update)
|
self.resource_monitor.monitor_call(plugin_id, plugin_instance.update)
|
||||||
else:
|
else:
|
||||||
plugin_instance.update()
|
plugin_instance.update()
|
||||||
except Exception as exc:
|
except BaseException as exc: # pylint: disable=broad-except
|
||||||
|
# BaseException, not just Exception: asyncio.CancelledError
|
||||||
|
# and SystemExit derive from it. Either one skipped _finish,
|
||||||
|
# so the plugin kept its lock and stayed RUNNING for good --
|
||||||
|
# never rescheduled, and every display() skipped as busy.
|
||||||
|
# Re-raised for the executor, which reports it as this
|
||||||
|
# update's failure.
|
||||||
_finish(False, exc=exc)
|
_finish(False, exc=exc)
|
||||||
raise
|
raise
|
||||||
else:
|
else:
|
||||||
@@ -1838,3 +1855,73 @@ class PluginManager:
|
|||||||
done = sorted(self._completed_updates)
|
done = sorted(self._completed_updates)
|
||||||
self._completed_updates.clear()
|
self._completed_updates.clear()
|
||||||
return done
|
return done
|
||||||
|
|
||||||
|
# -- on-demand requests from plugins -------------------------------------
|
||||||
|
|
||||||
|
def set_on_demand_handler(
|
||||||
|
self, handler: Optional[Callable[[Dict[str, Any]], bool]]) -> None:
|
||||||
|
"""Route plugins' on-demand requests to ``handler`` (None: nowhere).
|
||||||
|
|
||||||
|
The display controller sets its ``submit_plugin_on_demand`` here
|
||||||
|
before any plugin loads. The handler takes a mailbox-shaped request
|
||||||
|
from any thread, queues it for the render thread and returns True,
|
||||||
|
or False when it could not. A plugin manager with no handler (the
|
||||||
|
web interface's, a test's, scripts/check_plugin.py's) has no screen
|
||||||
|
to give, so request_on_demand() there answers None.
|
||||||
|
"""
|
||||||
|
self._on_demand_handler = handler
|
||||||
|
|
||||||
|
def request_on_demand(self, plugin_id: str, mode: Optional[str] = None,
|
||||||
|
duration: Optional[float] = None,
|
||||||
|
pinned: bool = False) -> Optional[str]:
|
||||||
|
"""Ask the display to show ``plugin_id`` now. Safe from any thread.
|
||||||
|
|
||||||
|
BasePlugin.request_on_demand() lands here; see it for the arguments.
|
||||||
|
Returns the request id once the display has queued the request (it
|
||||||
|
is applied on the render thread within a frame or so), or None when
|
||||||
|
this process has no display to ask or its queue is full.
|
||||||
|
"""
|
||||||
|
if not isinstance(plugin_id, str) or not plugin_id:
|
||||||
|
raise ValueError('plugin_id is required')
|
||||||
|
if mode is not None and (not isinstance(mode, str) or not mode):
|
||||||
|
raise ValueError('mode must be a non-empty string or None')
|
||||||
|
if duration is not None:
|
||||||
|
if isinstance(duration, bool) or not isinstance(duration, (int, float)):
|
||||||
|
raise ValueError('duration must be a number of seconds or None')
|
||||||
|
if not math.isfinite(duration) or duration <= 0:
|
||||||
|
duration = None # the display reads these as "no limit" too
|
||||||
|
else:
|
||||||
|
duration = float(duration)
|
||||||
|
return self._submit_on_demand({
|
||||||
|
'action': 'start', 'plugin_id': plugin_id, 'mode': mode,
|
||||||
|
'duration': duration, 'pinned': bool(pinned)})
|
||||||
|
|
||||||
|
def end_on_demand(self, plugin_id: str) -> Optional[str]:
|
||||||
|
"""Give the screen back, if ``plugin_id``'s on-demand session has it.
|
||||||
|
|
||||||
|
BasePlugin.end_on_demand() lands here. A session the plugin does not
|
||||||
|
own (the user started another plugin from the web interface, say) is
|
||||||
|
left alone. Returns the request id once queued, or None as
|
||||||
|
request_on_demand() does.
|
||||||
|
"""
|
||||||
|
if not isinstance(plugin_id, str) or not plugin_id:
|
||||||
|
raise ValueError('plugin_id is required')
|
||||||
|
return self._submit_on_demand({'action': 'stop', 'plugin_id': plugin_id})
|
||||||
|
|
||||||
|
def _submit_on_demand(self, request: Dict[str, Any]) -> Optional[str]:
|
||||||
|
# __dict__.get: tests build bare managers with PluginManager.__new__.
|
||||||
|
handler = self.__dict__.get('_on_demand_handler')
|
||||||
|
if handler is None:
|
||||||
|
return None
|
||||||
|
request_id = str(uuid.uuid4())
|
||||||
|
request.update({'request_id': request_id, 'timestamp': time.time(),
|
||||||
|
'source': 'plugin'})
|
||||||
|
try:
|
||||||
|
accepted = handler(request)
|
||||||
|
except Exception as exc: # pylint: disable=broad-except
|
||||||
|
self._warn_rate_limited(
|
||||||
|
"on-demand-handler",
|
||||||
|
"The on-demand request from plugin %s failed: %r",
|
||||||
|
request.get('plugin_id'), exc)
|
||||||
|
return None
|
||||||
|
return request_id if accepted else None
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ import os
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, field, replace
|
from dataclasses import dataclass, field, replace
|
||||||
from typing import Any, Callable, Dict, Optional
|
from typing import Any, Callable, Dict, List, Optional
|
||||||
|
|
||||||
from src import display_watchdog
|
from src import display_watchdog
|
||||||
from src.logging_config import get_logger
|
from src.logging_config import get_logger
|
||||||
@@ -100,6 +100,9 @@ _ERROR_MESSAGE_CHARS = 200
|
|||||||
_ERROR_TYPE_CHARS = 80
|
_ERROR_TYPE_CHARS = 80
|
||||||
_ID_CHARS = 100
|
_ID_CHARS = 100
|
||||||
_VERSION_CHARS = 40
|
_VERSION_CHARS = 40
|
||||||
|
#: Bounds on a plugin's published ``modes``: a plugin computes them, so a
|
||||||
|
#: runaway list must not bloat a file written to the SD card.
|
||||||
|
_MAX_MODES = 200
|
||||||
|
|
||||||
#: Reader statuses. Only LIVE carries runtime facts.
|
#: Reader statuses. Only LIVE carries runtime facts.
|
||||||
LIVE = "live"
|
LIVE = "live"
|
||||||
@@ -154,6 +157,15 @@ def summarize_error(error_info: Optional[Dict[str, Any]]) -> Optional[Dict[str,
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _published_modes(modes: Any) -> Optional[List[str]]:
|
||||||
|
"""The registered display modes as a snapshot carries them, or None."""
|
||||||
|
if not isinstance(modes, list):
|
||||||
|
return None
|
||||||
|
# A name is a key the display matches exactly: drop one too long to
|
||||||
|
# carry whole rather than clip it into a different name.
|
||||||
|
return [m for m in modes if isinstance(m, str) and len(m) <= _ID_CHARS][:_MAX_MODES]
|
||||||
|
|
||||||
|
|
||||||
def build_runtime_snapshot(state_manager: Any, *, started_at: float,
|
def build_runtime_snapshot(state_manager: Any, *, started_at: float,
|
||||||
now: Optional[float] = None,
|
now: Optional[float] = None,
|
||||||
running: bool = True,
|
running: bool = True,
|
||||||
@@ -173,6 +185,7 @@ def build_runtime_snapshot(state_manager: Any, *, started_at: float,
|
|||||||
"error": summarize_error(record.get("error_info")),
|
"error": summarize_error(record.get("error_info")),
|
||||||
"version": _clip(version, _VERSION_CHARS) if version else None,
|
"version": _clip(version, _VERSION_CHARS) if version else None,
|
||||||
"loaded_at": _epoch(record.get("loaded_at")),
|
"loaded_at": _epoch(record.get("loaded_at")),
|
||||||
|
"modes": _published_modes(record.get("modes")),
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
"schema": SNAPSHOT_SCHEMA,
|
"schema": SNAPSHOT_SCHEMA,
|
||||||
@@ -416,6 +429,21 @@ class PluginRuntimeView:
|
|||||||
"loaded_at": record.get("loaded_at"),
|
"loaded_at": record.get("loaded_at"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def display_modes(self, plugin_id: str) -> Optional[List[str]]:
|
||||||
|
"""The display modes the display registered for ``plugin_id``: what
|
||||||
|
it rotates and accepts on-demand, including modes a plugin computes
|
||||||
|
from its config. None unless the view is live and the plugin is
|
||||||
|
loaded with its modes registered -- the caller then falls back to
|
||||||
|
the manifest's ``display_modes``."""
|
||||||
|
if not self.live:
|
||||||
|
return None
|
||||||
|
record = self.plugins.get(plugin_id)
|
||||||
|
modes = record.get("modes") if isinstance(record, dict) else None
|
||||||
|
if not isinstance(modes, list):
|
||||||
|
return None
|
||||||
|
modes = [m for m in modes if isinstance(m, str)]
|
||||||
|
return modes or None
|
||||||
|
|
||||||
def describe(self) -> Dict[str, Any]:
|
def describe(self) -> Dict[str, Any]:
|
||||||
"""The view's own status, for a response to carry beside the facts."""
|
"""The view's own status, for a response to carry beside the facts."""
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ snapshot ``plugin_runtime.PluginRuntimePublisher`` publishes from it.
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Optional, Dict, Any
|
from typing import Any, Dict, List, Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
@@ -231,6 +231,26 @@ class PluginStateManager:
|
|||||||
}
|
}
|
||||||
self._note_change()
|
self._note_change()
|
||||||
|
|
||||||
|
def record_modes(self, plugin_id: str, modes: List[str]) -> None:
|
||||||
|
"""Record the display modes the display registered for ``plugin_id``.
|
||||||
|
|
||||||
|
Called by the DisplayController each time it registers the plugin.
|
||||||
|
These are the modes it actually rotates and accepts on-demand --
|
||||||
|
``plugin.modes`` when the plugin computes them (a soccer league the
|
||||||
|
user added under ``custom_leagues``), else the manifest's list -- and
|
||||||
|
the web interface has no other way to learn them (#668). Kept on the
|
||||||
|
loaded record, so an unload or a reload's fresh record_loaded()
|
||||||
|
forgets them until the plugin is registered again.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
loaded = self._loaded.get(plugin_id)
|
||||||
|
if loaded is None:
|
||||||
|
return
|
||||||
|
modes = [str(m) for m in modes]
|
||||||
|
if loaded.get('modes') != modes:
|
||||||
|
loaded['modes'] = modes
|
||||||
|
self._note_change()
|
||||||
|
|
||||||
def record_unloaded(self, plugin_id: str) -> None:
|
def record_unloaded(self, plugin_id: str) -> None:
|
||||||
"""Forget the loaded record alone, keeping state and error info: for
|
"""Forget the loaded record alone, keeping state and error info: for
|
||||||
an unload that failed after the instance was already dropped."""
|
an unload that failed after the instance was already dropped."""
|
||||||
@@ -243,7 +263,8 @@ class PluginStateManager:
|
|||||||
section so a concurrent load or unload is seen whole or not at all.
|
section so a concurrent load or unload is seen whole or not at all.
|
||||||
|
|
||||||
Per plugin: ``state`` (published_state()'s value), ``loaded``,
|
Per plugin: ``state`` (published_state()'s value), ``loaded``,
|
||||||
``version`` and ``loaded_at`` (None unless loaded) and ``error_info``
|
``version``, ``loaded_at`` and ``modes`` (None unless loaded; ``modes``
|
||||||
|
also None until the display registers it) and ``error_info``
|
||||||
(a copy, or None).
|
(a copy, or None).
|
||||||
"""
|
"""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@@ -257,6 +278,7 @@ class PluginStateManager:
|
|||||||
'loaded': loaded is not None,
|
'loaded': loaded is not None,
|
||||||
'version': loaded['version'] if loaded else None,
|
'version': loaded['version'] if loaded else None,
|
||||||
'loaded_at': loaded['loaded_at'] if loaded else None,
|
'loaded_at': loaded['loaded_at'] if loaded else None,
|
||||||
|
'modes': list(loaded['modes']) if loaded and 'modes' in loaded else None,
|
||||||
'error_info': dict(info) if info is not None else None,
|
'error_info': dict(info) if info is not None else None,
|
||||||
}
|
}
|
||||||
return records
|
return records
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from src.plugin_system.plugin_loader import (
|
|||||||
contained_plugin_dir, requirements_to_install,
|
contained_plugin_dir, requirements_to_install,
|
||||||
)
|
)
|
||||||
from src.plugin_system.plugin_dirs import BACKUP_MARKER
|
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 (
|
from src.plugin_system.repo_urls import (
|
||||||
USER_AGENT, github_api_headers, github_owner_repo, normalize_repo_url,
|
USER_AGENT, github_api_headers, github_owner_repo, normalize_repo_url,
|
||||||
)
|
)
|
||||||
@@ -92,7 +93,9 @@ class _InstallMixin:
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
if installed:
|
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
|
return True
|
||||||
|
|
||||||
self._restore_backup(plugin_id, plugin_path, backup_path, "Install")
|
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 f"could not set aside {plugin_path}: {e}"
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _discard_backup(self, plugin_id: str, backup_path: Path, action: str) -> None:
|
def _discard_backup(
|
||||||
"""Remove the set-aside copy after a successful (re)install."""
|
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):
|
if not self._safe_remove_directory(backup_path):
|
||||||
self.logger.warning(
|
self.logger.warning(
|
||||||
"%s of %s succeeded but the previous copy at %s could not be "
|
"%s of %s succeeded but the previous copy at %s could not be "
|
||||||
@@ -542,7 +570,8 @@ class _InstallMixin:
|
|||||||
raise
|
raise
|
||||||
temp_dir = None # Prevent cleanup since we moved it
|
temp_dir = None # Prevent cleanup since we moved it
|
||||||
if backup_path is not None:
|
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
|
# Install dependencies
|
||||||
self._install_dependencies(final_path)
|
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
|
# the registry cache expires. Only one thread fetches; others wait and
|
||||||
# then get the result from the warm cache (double-checked locking).
|
# then get the result from the warm cache (double-checked locking).
|
||||||
self._registry_fetch_lock = threading.Lock()
|
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
|
# Per-plugin locks for _reinstall_with_rollback: the web UI runs
|
||||||
# Flask with threaded=True, so two overlapping requests for the
|
# Flask with threaded=True, so two overlapping requests for the
|
||||||
@@ -344,12 +349,26 @@ class PluginStoreManager(_RegistryMixin, _InstallMixin, _UpdateMixin):
|
|||||||
2. Fix permissions via os.chmod() then retry (works for same-owner files)
|
2. Fix permissions via os.chmod() then retry (works for same-owner files)
|
||||||
3. Use sudo rm -rf as last resort (works for root-owned __pycache__, etc.)
|
3. Use sudo rm -rf as last resort (works for root-owned __pycache__, etc.)
|
||||||
|
|
||||||
|
A symlink -- a dev plugin linked in by scripts/dev/dev_plugin_setup.sh
|
||||||
|
-- is removed as a link, before any of that: rmtree refuses one, and
|
||||||
|
stage 2 would walk through it and chmod the developer's checkout.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
path: Path to directory to remove
|
path: Path to directory to remove
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if directory was removed successfully, False otherwise
|
True if directory was removed successfully, False otherwise
|
||||||
"""
|
"""
|
||||||
|
if path.is_symlink():
|
||||||
|
# Checked before exists(), which follows the link: a dangling one
|
||||||
|
# would read as already removed and be left behind.
|
||||||
|
try:
|
||||||
|
path.unlink()
|
||||||
|
return True
|
||||||
|
except OSError as e:
|
||||||
|
self.logger.error(f"Could not remove the symlink {path}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
return True # Already removed
|
return True # Already removed
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ methods reach shared state and helpers through ``self``.
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import requests
|
import requests
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -985,10 +986,14 @@ class _RegistryMixin:
|
|||||||
|
|
||||||
def get_registry_info(self, plugin_id: str) -> Optional[Dict]:
|
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
|
Makes no GitHub API calls, but it does go through `fetch_registry`:
|
||||||
(e.g., verified status, latest_version).
|
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:
|
Args:
|
||||||
plugin_id: Plugin identifier
|
plugin_id: Plugin identifier
|
||||||
@@ -999,3 +1004,52 @@ class _RegistryMixin:
|
|||||||
registry = self.fetch_registry()
|
registry = self.fetch_registry()
|
||||||
plugins = registry.get('plugins', []) or []
|
plugins = registry.get('plugins', []) or []
|
||||||
return self._match_registry_entry(plugins, plugin_id)
|
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 pathlib import Path
|
||||||
from typing import Dict, Optional, Tuple
|
from typing import Dict, Optional, Tuple
|
||||||
from src.plugin_system.plugin_dirs import BACKUP_MARKER
|
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
|
from src.plugin_system.repo_urls import same_repo
|
||||||
|
|
||||||
|
|
||||||
@@ -302,7 +305,11 @@ class _UpdateMixin:
|
|||||||
installed = False
|
installed = False
|
||||||
|
|
||||||
if installed:
|
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
|
return True
|
||||||
|
|
||||||
# Bad network, registry error...: the user keeps a working plugin.
|
# 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'):
|
for line in untracked_result.stdout.strip().split('\n'):
|
||||||
if line.startswith('??'):
|
if line.startswith('??'):
|
||||||
# Untracked file
|
# Untracked file
|
||||||
file_path = line[3:].strip()
|
file_path = line[3:].strip().strip('"')
|
||||||
untracked_files.append(file_path)
|
# 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
|
# Check for tracked file changes
|
||||||
status_result = subprocess.run(
|
status_result = subprocess.run(
|
||||||
@@ -537,9 +548,17 @@ class _UpdateMixin:
|
|||||||
if has_changes:
|
if has_changes:
|
||||||
self.logger.info(f"Stashing local changes in {plugin_id} before update")
|
self.logger.info(f"Stashing local changes in {plugin_id} before update")
|
||||||
try:
|
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(
|
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,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=30,
|
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,24 @@ class FakeCache:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.data: Dict[str, Any] = {}
|
self.data: Dict[str, Any] = {}
|
||||||
self.cache_dir = "/nonexistent/run-loop-harness"
|
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):
|
def get(self, key, max_age=None, memory_ttl=None):
|
||||||
return self.data.get(key)
|
return self.data.get(key)
|
||||||
|
|
||||||
def set(self, key, data, ttl=None):
|
def set(self, key, data, ttl=None):
|
||||||
self.data[key] = data
|
self.data[key] = data
|
||||||
|
# Every write is a new file, as DiskCache's rename makes it.
|
||||||
|
self._writes += 1
|
||||||
|
self._written[key] = self._writes
|
||||||
|
|
||||||
|
def file_signature(self, key):
|
||||||
|
"""CacheManager.file_signature: None without a file, else a value
|
||||||
|
that changes with every write."""
|
||||||
|
if key not in self.data:
|
||||||
|
return None
|
||||||
|
return (self._written.get(key, 0), 0, 0)
|
||||||
|
|
||||||
def delete(self, key):
|
def delete(self, key):
|
||||||
self.data.pop(key, None)
|
self.data.pop(key, None)
|
||||||
@@ -724,11 +736,14 @@ class RunLoopHarness:
|
|||||||
self.clock.at(t, post)
|
self.clock.at(t, post)
|
||||||
|
|
||||||
def restore_on_demand(self, plugin_id: str, mode: Optional[str] = None,
|
def restore_on_demand(self, plugin_id: str, mode: Optional[str] = None,
|
||||||
duration: Optional[float] = None, pinned: bool = False):
|
duration: Optional[float] = None, pinned: bool = False,
|
||||||
|
named_mode: Optional[str] = None):
|
||||||
"""Start with an on-demand session resumed from the cache, as after
|
"""Start with an on-demand session resumed from the cache, as after
|
||||||
a restart: the state _select_startup_plugins restores, then
|
a restart: the state _select_startup_plugins restores, then
|
||||||
_populate_on_demand_modes_from_plugin, as __init__ calls it."""
|
_populate_on_demand_modes_from_plugin, as __init__ calls it. A
|
||||||
|
session that cannot resume is logged as ``on-demand-error``."""
|
||||||
dc = self.controller
|
dc = self.controller
|
||||||
|
dc._on_demand_named_mode = named_mode
|
||||||
dc.on_demand_active = True
|
dc.on_demand_active = True
|
||||||
dc.on_demand_plugin_id = plugin_id
|
dc.on_demand_plugin_id = plugin_id
|
||||||
dc.on_demand_mode = mode
|
dc.on_demand_mode = mode
|
||||||
@@ -739,6 +754,8 @@ class RunLoopHarness:
|
|||||||
dc.on_demand_status = 'active'
|
dc.on_demand_status = 'active'
|
||||||
dc.on_demand_schedule_override = True
|
dc.on_demand_schedule_override = True
|
||||||
dc._populate_on_demand_modes_from_plugin()
|
dc._populate_on_demand_modes_from_plugin()
|
||||||
|
if dc.on_demand_status == 'error':
|
||||||
|
self.log("on-demand-error", dc.on_demand_last_error)
|
||||||
|
|
||||||
def wifi_message(self, t: float, message: str, duration: float = 5):
|
def wifi_message(self, t: float, message: str, duration: float = 5):
|
||||||
def write():
|
def write():
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"screens": [
|
||||||
|
[0.0, "clock", 5.0, "on-demand-start", 6, false],
|
||||||
|
[5.0, "sports_live", 15.0, "duration", 15, true],
|
||||||
|
[20.0, "sports_recent", 15.0, "duration", 15, true],
|
||||||
|
[35.0, "sports_upcoming", 5.0, "on-demand-requested-stop", 6, true],
|
||||||
|
[40.0, "clock", 20.0, "duration", 20, true],
|
||||||
|
[60.0, "sports_live", 15.0, "display-false", 11, true],
|
||||||
|
[75.0, "sports_recent", 15.0, "duration", 15, true],
|
||||||
|
[90.0, "sports_upcoming", 10.0, "on-demand-start", 11, true],
|
||||||
|
[100.0, "sports_live", 0.0, "empty", 1, true],
|
||||||
|
[100.0, "sports_recent", 15.0, "duration", 15, true],
|
||||||
|
[115.0, "sports_upcoming", 15.0, "duration", 15, true],
|
||||||
|
[130.0, "sports_live", 0.0, "empty", 1, true],
|
||||||
|
[130.0, "sports_recent", 10.0, "on-demand-requested-stop", 11, true],
|
||||||
|
[140.0, "sports_upcoming", 15.0, "duration", 15, true],
|
||||||
|
[155.0, "clock", 5.0, "horizon", 5, true]
|
||||||
|
],
|
||||||
|
"events": [
|
||||||
|
[5.0, "request", "start:n1"],
|
||||||
|
[5.0, "on-demand-start", "sports"],
|
||||||
|
[40.0, "request", "stop:n2"],
|
||||||
|
[40.0, "on-demand-requested-stop"],
|
||||||
|
[100.0, "request", "start:n3"],
|
||||||
|
[100.0, "on-demand-start", "sports"],
|
||||||
|
[140.0, "request", "stop:n4"],
|
||||||
|
[140.0, "on-demand-requested-stop"]
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"screens": [
|
||||||
|
[0.0, "clock", 20.0, "duration", 20, false],
|
||||||
|
[20.0, "weather", 20.0, "duration", 20, true],
|
||||||
|
[40.0, "clock", 20.0, "horizon", 20, true]
|
||||||
|
],
|
||||||
|
"events": [
|
||||||
|
[0.0, "on-demand-error", "restore-failed"]
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -45,7 +45,12 @@ server has none.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `unit/test_list_filter.js` | no | `ListFilter` search/filter/sort/count/sticky, and the installed-plugins config **extracted verbatim** from `plugins_manager.js` so the test can't drift from it |
|
| `unit/test_list_filter.js` | no | `ListFilter` search/filter/sort/count/sticky, and the installed-plugins config **extracted verbatim** from `plugins_manager.js` so the test can't drift from it |
|
||||||
| `unit/test_update_all.js` | no | `PluginInstallManager.updateAll` from `plugins/install_manager.js`: Check & Update All sends only plugin ids (never `starlark:` app entries), re-sends a request that got no HTTP answer (web service restarting) instead of skipping that plugin, never re-sends one that got any HTTP answer (the real `api_client.js` classifies a proxy 502 or a JSON error without `error_code` as `API_ERROR`), and counts a no-op update as already up to date in the summary. Also run by `test/web_interface/test_update_all_plugins.py` so CI covers it |
|
| `unit/test_update_all.js` | no | `PluginInstallManager.updateAll` from `plugins/install_manager.js`: Check & Update All sends only plugin ids (never `starlark:` app entries), re-sends a request that got no HTTP answer (web service restarting) instead of skipping that plugin, never re-sends one that got any HTTP answer (the real `api_client.js` classifies a proxy 502 or a JSON error without `error_code` as `API_ERROR`), and counts a no-op update as already up to date in the summary. Also run by `test/web_interface/test_update_all_plugins.py` so CI covers it |
|
||||||
|
| `unit/test_store_install.js` | no | The store's Install button, with the whole of `plugins_manager.js` run by `plugins_manager_sandbox.js` (a vm context, fake DOM and API): a fresh install reloads the list, then enables the id the plugin was installed as -- the answer's `plugin_id`, else the installed entry the store entry matches (Weather installs as `ledmatrix-weather`); a Reinstall leaves the enabled state alone |
|
||||||
|
| `unit/test_install_polling.js` | no | How long Install waits for a queued install (sandbox): at least the server's 300 s dependency-install timeout; when it stops waiting it reloads the installed list and warns, rather than reporting a failure or enabling anything |
|
||||||
|
| `unit/test_store_categories.js` | no | The store's category filter (sandbox): the template ships only All Categories, the rest come from the store's plugins (one per category whatever its case), choosing one filters to it, and a swapped-in select is refilled from the cache keeping the choice |
|
||||||
|
| `unit/test_github_url_install.js` | no | Install Single Plugin (sandbox, the button as `plugins.html` ships it): no inline `onclick`, so a click or Enter sends exactly one `install-from-url` request and raises no error |
|
||||||
| `unit/test_render_cards.js` | no | `renderInstalledCards` markup, both empty states, and HTML-escaping of hostile plugin metadata |
|
| `unit/test_render_cards.js` | no | `renderInstalledCards` markup, both empty states, and HTML-escaping of hostile plugin metadata |
|
||||||
|
| `unit/test_plugin_order_list.js` | no | `widgets/plugin-order-list.js` (the Vegas and rotation order lists): a disabled plugin, which gets no row, keeps its slot in the saved order and its Vegas exclusion when the list rewrites its hidden inputs, around reordering and include/exclude; an uninstalled plugin's id is dropped, a failed plugin list leaves the inputs as saved, and only string ids are carried over, once each |
|
||||||
| `unit/test_style_editor_element_keys.js` | no | `elementKeys()`/`styleRows()`/`positionRows()` from `widgets/style-editor.js`: every `customization.layout` entry gets exactly one row -- paired with its style element through core's `x-layout-key` (so `score` belongs to `score_text`, not a second row), or a position row of its own, leaves included -- since the widget claims the whole `layout` block from the generic fallback renderer |
|
| `unit/test_style_editor_element_keys.js` | no | `elementKeys()`/`styleRows()`/`positionRows()` from `widgets/style-editor.js`: every `customization.layout` entry gets exactly one row -- paired with its style element through core's `x-layout-key` (so `score` belongs to `score_text`, not a second row), or a position row of its own, leaves included -- since the widget claims the whole `layout` block from the generic fallback renderer |
|
||||||
| `unit/test_style_editor_layout_leaf_columns.js` | no | `columnsFor()` from `widgets/style-editor.js`: a layout-only key whose own value is a leaf (no x/y sub-object, e.g. a `show_logo` toggle) gets a self-keyed column instead of a blank, uneditable row |
|
| `unit/test_style_editor_layout_leaf_columns.js` | no | `columnsFor()` from `widgets/style-editor.js`: a layout-only key whose own value is a leaf (no x/y sub-object, e.g. a `show_logo` toggle) gets a self-keyed column instead of a blank, uneditable row |
|
||||||
| `unit/test_style_editor_layout_leaf_collision.js` | no | `columnsFor()` from `widgets/style-editor.js`: a layout-only leaf key still gets its own column even when its name collides with an unrelated element's style sub-field or another layout axis's sub-field |
|
| `unit/test_style_editor_layout_leaf_collision.js` | no | `columnsFor()` from `widgets/style-editor.js`: a layout-only leaf key still gets its own column even when its name collides with an unrelated element's style sub-field or another layout axis's sub-field |
|
||||||
@@ -53,6 +58,9 @@ server has none.
|
|||||||
| `unit/test_store_registry_fields.js` | no | The store card's registry fields from `plugins_manager.js`: the commit that introduced the listed version (a hex SHA only, linked to that tree), the "Needs LEDMatrix X+" warning, a card from an older registry without either, and `isStorePluginInstalled` answering to `aliases` |
|
| `unit/test_store_registry_fields.js` | no | The store card's registry fields from `plugins_manager.js`: the commit that introduced the listed version (a hex SHA only, linked to that tree), the "Needs LEDMatrix X+" warning, a card from an older registry without either, and `isStorePluginInstalled` answering to `aliases` |
|
||||||
| `unit/test_page_registry.js` | no | The page lifecycle in `js/core/registry.js` (a minimal DOM shim): one `init` per `data-page` root, `destroy` and an aborted `ctx.signal` when htmx swaps it away, a vetoed swap keeps it, lazy page modules, a root removed without htmx swept on the next swap |
|
| `unit/test_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_core_modules.js` | no | `js/core/api.js` (JSON envelope, HTTP/`status: error`/network errors, abort passthrough, the #683 login redirect, same-server paths only) and `js/core/facade.js` (`window.LEDMatrix`, deprecated aliases) |
|
||||||
|
| `unit/test_overview_reconciliation_poll.js` | no | The Overview's reconciliation-banner poll from `partials/overview.html`, run in a vm: it gives up after a bounded number of requests when the status never says done, runs only while the Overview is on screen (`LEDVisibility`, its own key), and stops once the banner is shown |
|
||||||
|
| `unit/test_display_partial_ids.js` | no | `js/pages/display.js` started on a fake root that answers only for the ids `partials/display.html` renders: every id it looks up (with every listener and timer it set fired) exists, and moving the brightness slider updates its label without throwing |
|
||||||
|
| `unit/test_general_web_login_token.js` | no | `createToken` from `js/pages/general.js`, imported with a fake DOM and fetch: a created API token clears the form's `data-dirty` mark (so a reload does not ask "Leave site?"), a refused one keeps it |
|
||||||
| `unit/test_plugin_action_delegation.js` | no | The document-level card-action delegation and `handlePluginAction` from `plugins_manager.js`, run with the handler inside an IIFE as in the real file: each action is handled once, a Starlark app uninstall goes to `DELETE /starlark/apps/<id>`, and an uninstall is confirmed once |
|
| `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_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 |
|
| `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 |
|
||||||
@@ -61,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_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_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_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_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 |
|
| `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); });
|
||||||
@@ -98,7 +98,11 @@ const ok = (l, c, x) => c ? (pass++, console.log(' ok ' + l))
|
|||||||
|
|
||||||
const lists = () => requests.filter(r => r.url === '/api/v3/plugins/installed').length;
|
const lists = () => requests.filter(r => r.url === '/api/v3/plugins/installed').length;
|
||||||
const $ = id => doc.getElementById(id);
|
const $ = id => doc.getElementById(id);
|
||||||
const order = () => JSON.parse($('rotation_plugin_order_value').value || '[]');
|
// The rows' ids, in order. The input also keeps saved ids that have no row
|
||||||
|
// (a disabled plugin's place, see test/js/unit/test_plugin_order_list.js),
|
||||||
|
// and the saved order comes from whatever config the server has.
|
||||||
|
const SHOWN = plugins.filter(p => p.enabled).map(p => p.id);
|
||||||
|
const order = () => JSON.parse($('rotation_plugin_order_value').value || '[]').filter(id => SHOWN.includes(id));
|
||||||
async function swap() {
|
async function swap() {
|
||||||
panel.dispatchEvent(new window.CustomEvent('htmx:beforeSwap', { bubbles: true, detail: { target: panel, shouldSwap: true } }));
|
panel.dispatchEvent(new window.CustomEvent('htmx:beforeSwap', { bubbles: true, detail: { target: panel, shouldSwap: true } }));
|
||||||
panel.innerHTML = partial;
|
panel.innerHTML = partial;
|
||||||
|
|||||||
@@ -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); });
|
||||||
@@ -98,8 +98,50 @@ const get = p => new Promise((res, rej) =>
|
|||||||
window.saveMqttBridge();
|
window.saveMqttBridge();
|
||||||
await tick(150);
|
await tick(150);
|
||||||
ok('save includes password once typed', sent && sent.mqtt_password === 'typed-secret');
|
ok('save includes password once typed', sent && sent.mqtt_password === 'typed-secret');
|
||||||
|
|
||||||
|
// A password with TLS off is refused unless allow_insecure_mqtt is set
|
||||||
|
// (CWE-319, api_v3/misc.py). The form has to be able to send it, or a
|
||||||
|
// plain-LAN broker with a password can never be saved from here.
|
||||||
|
const allowRow = () => $('mqtt-allow-insecure-row');
|
||||||
|
const shown = el => !!el && !el.classList.contains('hidden');
|
||||||
|
ok('allow-without-TLS control rendered', !!$('mqtt-allow-insecure'));
|
||||||
|
ok('allow-without-TLS starts as saved',
|
||||||
|
!!$('mqtt-allow-insecure') && $('mqtt-allow-insecure').checked === !!bridge.data.config.allow_insecure_mqtt);
|
||||||
|
ok('allow-without-TLS shown only while TLS is off',
|
||||||
|
shown(allowRow()) === !$('mqtt-tls').checked);
|
||||||
|
$('mqtt-tls').checked = true;
|
||||||
|
$('mqtt-tls').dispatchEvent(new window.Event('change', { bubbles: true }));
|
||||||
|
ok('ticking TLS hides it', !shown(allowRow()));
|
||||||
|
$('mqtt-tls').checked = false;
|
||||||
|
$('mqtt-tls').dispatchEvent(new window.Event('change', { bubbles: true }));
|
||||||
|
ok('unticking TLS shows it again', shown(allowRow()));
|
||||||
|
|
||||||
|
const setAllow = v => { if ($('mqtt-allow-insecure')) $('mqtt-allow-insecure').checked = v; };
|
||||||
|
setAllow(false);
|
||||||
|
window.saveMqttBridge();
|
||||||
|
await tick(150);
|
||||||
|
ok('save sends allow_insecure_mqtt false when unticked', !!sent && sent.allow_insecure_mqtt === false, sent);
|
||||||
|
setAllow(true);
|
||||||
|
window.saveMqttBridge();
|
||||||
|
await tick(150);
|
||||||
|
ok('save sends allow_insecure_mqtt true when ticked', !!sent && sent.allow_insecure_mqtt === true, sent);
|
||||||
onPut = null;
|
onPut = null;
|
||||||
|
|
||||||
|
// Prefilled from the saved settings, and hidden while TLS is saved on.
|
||||||
|
bridgePayload = JSON.parse(JSON.stringify(bridge));
|
||||||
|
bridgePayload.data.config.allow_insecure_mqtt = true;
|
||||||
|
bridgePayload.data.config.mqtt_tls = false;
|
||||||
|
window.loadMqttBridge();
|
||||||
|
await tick(150);
|
||||||
|
ok('a saved opt-in is prefilled', !!$('mqtt-allow-insecure') && $('mqtt-allow-insecure').checked === true);
|
||||||
|
bridgePayload.data.config.mqtt_tls = true;
|
||||||
|
window.loadMqttBridge();
|
||||||
|
await tick(150);
|
||||||
|
ok('hidden on load when TLS is saved on', !shown(allowRow()));
|
||||||
|
bridgePayload = bridge;
|
||||||
|
window.loadMqttBridge();
|
||||||
|
await tick(150);
|
||||||
|
|
||||||
// ── Pixlet editor, idle ────────────────────────────────────────────────
|
// ── Pixlet editor, idle ────────────────────────────────────────────────
|
||||||
const appIds = (apps.data.apps || []).map(a => a.id);
|
const appIds = (apps.data.apps || []).map(a => a.id);
|
||||||
ok('editor lists the apps on disk',
|
ok('editor lists the apps on disk',
|
||||||
|
|||||||
@@ -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); });
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
// The whole of plugins_manager.js (and list_filter.js before it, as the page
|
||||||
|
// loads them), evaluated in a node vm context against a small fake DOM.
|
||||||
|
//
|
||||||
|
// For suites that drive the plugin manager's real flows -- install, polling,
|
||||||
|
// store filters, the GitHub-URL button -- rather than one function sliced
|
||||||
|
// out of the file. Nothing is mocked inside the script: only what the page
|
||||||
|
// gives it (document, fetch, timers, showNotification, LEDEscape).
|
||||||
|
//
|
||||||
|
// const sb = create({ route: (method, url, body) => ({ status, json }) });
|
||||||
|
// sb.el('plugin-store-grid'); // make an element exist by id
|
||||||
|
// sb.window.installPlugin('weather');
|
||||||
|
// await sb.until(() => sb.requests.some(r => r.url.includes('/toggle')));
|
||||||
|
//
|
||||||
|
// Timers ignore their delays and run on the next turn, so a poll loop that
|
||||||
|
// would take minutes in a browser finishes in milliseconds. The page is in
|
||||||
|
// readyState "loading" with no #installed-plugins-grid, so the script's own
|
||||||
|
// start-up does nothing until a suite asks for it (window.initPluginsPage()).
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const vm = require('vm');
|
||||||
|
const ledEscape = require('./led_escape');
|
||||||
|
|
||||||
|
const V3 = path.resolve(__dirname, '../../web_interface/static/v3');
|
||||||
|
const PLUGINS_HTML = path.resolve(__dirname, '../../web_interface/templates/v3/partials/plugins.html');
|
||||||
|
|
||||||
|
class FakeClassList {
|
||||||
|
constructor() { this.set = new Set(); }
|
||||||
|
add(...c) { c.forEach(x => this.set.add(x)); }
|
||||||
|
remove(...c) { c.forEach(x => this.set.delete(x)); }
|
||||||
|
contains(c) { return this.set.has(c); }
|
||||||
|
toggle(c, force) {
|
||||||
|
const on = force === undefined ? !this.set.has(c) : !!force;
|
||||||
|
if (on) this.set.add(c); else this.set.delete(c);
|
||||||
|
return on;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function create({ route } = {}) {
|
||||||
|
const elements = new Map();
|
||||||
|
const requests = [];
|
||||||
|
const toasts = [];
|
||||||
|
const errors = [];
|
||||||
|
const restartNotes = [];
|
||||||
|
|
||||||
|
class FakeElement {
|
||||||
|
constructor(id, tag = 'div', attributes = {}) {
|
||||||
|
this.id = id;
|
||||||
|
this.tagName = tag.toUpperCase();
|
||||||
|
this.attributes = { ...attributes };
|
||||||
|
this.listeners = {};
|
||||||
|
this.children = [];
|
||||||
|
this.classList = new FakeClassList();
|
||||||
|
this.style = { removeProperty() {} };
|
||||||
|
this.dataset = {};
|
||||||
|
this.value = '';
|
||||||
|
this.textContent = '';
|
||||||
|
this.disabled = false;
|
||||||
|
this.parentNode = null;
|
||||||
|
this._html = '';
|
||||||
|
}
|
||||||
|
get innerHTML() { return this._html; }
|
||||||
|
set innerHTML(v) { this._html = String(v); this.children = []; }
|
||||||
|
getAttribute(n) { return n in this.attributes ? this.attributes[n] : null; }
|
||||||
|
setAttribute(n, v) { this.attributes[n] = String(v); }
|
||||||
|
hasAttribute(n) { return n in this.attributes; }
|
||||||
|
removeAttribute(n) { delete this.attributes[n]; }
|
||||||
|
addEventListener(type, fn) { (this.listeners[type] = this.listeners[type] || []).push(fn); }
|
||||||
|
removeEventListener(type, fn) {
|
||||||
|
this.listeners[type] = (this.listeners[type] || []).filter(f => f !== fn);
|
||||||
|
}
|
||||||
|
appendChild(child) { this.children.push(child); child.parentNode = this; return child; }
|
||||||
|
querySelector() { return null; }
|
||||||
|
querySelectorAll() { return []; }
|
||||||
|
closest() { return null; }
|
||||||
|
cloneNode() {
|
||||||
|
const copy = new FakeElement(this.id, this.tagName, this.attributes);
|
||||||
|
copy._html = this._html;
|
||||||
|
copy.value = this.value;
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
replaceChild(next, prev) {
|
||||||
|
next.parentNode = this;
|
||||||
|
prev.parentNode = null;
|
||||||
|
if (next.id) elements.set(next.id, next);
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
replaceWith(next) { if (this.parentNode) this.parentNode.replaceChild(next, this); }
|
||||||
|
// A browser runs an inline on<type> attribute first (it was set before
|
||||||
|
// any listener was added), then the listeners, and an exception in one
|
||||||
|
// does not stop the next: it is reported, which is what `errors` holds.
|
||||||
|
dispatch(type, init = {}) {
|
||||||
|
const event = {
|
||||||
|
type, target: this, currentTarget: this, key: init.key,
|
||||||
|
defaultPrevented: false,
|
||||||
|
preventDefault() { this.defaultPrevented = true; },
|
||||||
|
stopPropagation() {}, stopImmediatePropagation() {},
|
||||||
|
};
|
||||||
|
const inline = this.getAttribute('on' + type);
|
||||||
|
const handlers = [];
|
||||||
|
if (inline !== null) {
|
||||||
|
handlers.push(vm.runInContext(`(function(event) {\n${inline}\n})`, ctx));
|
||||||
|
}
|
||||||
|
handlers.push(...(this.listeners[type] || []));
|
||||||
|
for (const h of handlers) {
|
||||||
|
try { h.call(this, event); } catch (e) { errors.push(e); }
|
||||||
|
}
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
click() { return this.dispatch('click'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function el(id, tag, attributes) {
|
||||||
|
if (!elements.has(id)) {
|
||||||
|
const parent = new FakeElement(null);
|
||||||
|
parent.appendChild(new FakeElement(id, tag, attributes));
|
||||||
|
elements.set(id, parent.children[0]);
|
||||||
|
}
|
||||||
|
return elements.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timers = [];
|
||||||
|
const ctx = {
|
||||||
|
// Warnings are the script noting elements this fake page doesn't have.
|
||||||
|
console: { log: console.log.bind(console), error: console.error.bind(console),
|
||||||
|
warn: () => {}, info: () => {}, debug: () => {} },
|
||||||
|
debugLog: () => {},
|
||||||
|
addEventListener() {},
|
||||||
|
URL,
|
||||||
|
document: {
|
||||||
|
readyState: 'loading',
|
||||||
|
body: { addEventListener() {} },
|
||||||
|
getElementById: id => elements.get(id) || null,
|
||||||
|
querySelector: () => null,
|
||||||
|
querySelectorAll: () => [],
|
||||||
|
addEventListener() {},
|
||||||
|
dispatchEvent() { return true; },
|
||||||
|
createElement: tag => new FakeElement(null, tag),
|
||||||
|
},
|
||||||
|
CustomEvent: class { constructor(type, init) { this.type = type; this.detail = init && init.detail; } },
|
||||||
|
setTimeout: (fn, _ms, ...args) => { timers.push(setImmediate(() => fn(...args))); return timers.length; },
|
||||||
|
clearTimeout: () => {},
|
||||||
|
setInterval: () => 0,
|
||||||
|
clearInterval: () => {},
|
||||||
|
requestAnimationFrame: fn => setImmediate(fn),
|
||||||
|
getComputedStyle: () => ({ display: 'block' }),
|
||||||
|
scrollTo() {},
|
||||||
|
sessionStorage: { getItem: () => null, setItem() {}, removeItem() {} },
|
||||||
|
localStorage: { getItem: () => null, setItem() {}, removeItem() {} },
|
||||||
|
confirm: () => true,
|
||||||
|
alert: () => {},
|
||||||
|
showNotification: (message, type) => {
|
||||||
|
toasts.push({ message: String(message),
|
||||||
|
type: type && typeof type === 'object' ? type.type : type });
|
||||||
|
},
|
||||||
|
noteRestartRequired: (body) => { restartNotes.push(body); },
|
||||||
|
fetch: async (url, opts = {}) => {
|
||||||
|
const method = (opts.method || 'GET').toUpperCase();
|
||||||
|
let body = null;
|
||||||
|
try { body = opts.body ? JSON.parse(opts.body) : null; } catch (e) { body = opts.body; }
|
||||||
|
requests.push({ method, url: String(url), body });
|
||||||
|
const answer = (route && route(method, String(url), body)) || { status: 200, json: { status: 'success' } };
|
||||||
|
const status = answer.status || 200;
|
||||||
|
return { ok: status < 400, status, json: async () => answer.json };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
ctx.window = ctx;
|
||||||
|
vm.createContext(ctx);
|
||||||
|
ledEscape.install(ctx);
|
||||||
|
for (const file of ['js/plugins/list_filter.js', 'plugins_manager.js']) {
|
||||||
|
vm.runInContext(fs.readFileSync(path.join(V3, file), 'utf8'), ctx, { filename: file });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolves once cond() is true, letting timers and promises run between
|
||||||
|
// checks; rejects if it never is.
|
||||||
|
async function until(cond, label = 'condition', turns = 20000) {
|
||||||
|
for (let i = 0; i < turns; i++) {
|
||||||
|
if (cond()) return;
|
||||||
|
await new Promise(r => setImmediate(r));
|
||||||
|
}
|
||||||
|
throw new Error('timed out waiting for ' + label);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lets every pending timer and promise run.
|
||||||
|
async function settle(turns = 50) {
|
||||||
|
for (let i = 0; i < turns; i++) await new Promise(r => setImmediate(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { window: ctx, el, FakeElement, requests, toasts, errors, restartNotes, until, settle };
|
||||||
|
}
|
||||||
|
|
||||||
|
// The attributes of the element with this id in partials/plugins.html, as
|
||||||
|
// the template ships them (no Jinja on the tags these suites read).
|
||||||
|
function templateAttributes(id) {
|
||||||
|
const html = fs.readFileSync(PLUGINS_HTML, 'utf8');
|
||||||
|
const at = html.indexOf(`id="${id}"`);
|
||||||
|
if (at < 0) throw new Error(`no element with id ${id} in plugins.html`);
|
||||||
|
const start = html.lastIndexOf('<', at);
|
||||||
|
let end = start, quote = null;
|
||||||
|
for (; end < html.length; end++) {
|
||||||
|
const ch = html[end];
|
||||||
|
if (quote) { if (ch === quote) quote = null; } else if (ch === '"' || ch === "'") quote = ch;
|
||||||
|
else if (ch === '>') break;
|
||||||
|
}
|
||||||
|
const tag = html.slice(start, end + 1);
|
||||||
|
const attrs = {};
|
||||||
|
const re = /([\w:-]+)\s*=\s*("([^"]*)"|'([^']*)')/g;
|
||||||
|
let m;
|
||||||
|
while ((m = re.exec(tag))) attrs[m[1]] = m[3] !== undefined ? m[3] : m[4];
|
||||||
|
return { tag: tag.match(/^<(\w+)/)[1], attrs, source: tag };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { create, templateAttributes };
|
||||||
+14
-3
@@ -17,17 +17,28 @@ const fs = require('fs');
|
|||||||
|
|
||||||
const BASE = process.env.BASE || 'http://localhost:5000';
|
const BASE = process.env.BASE || 'http://localhost:5000';
|
||||||
const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.js',
|
const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.js',
|
||||||
|
'unit/test_plugin_order_list.js',
|
||||||
'unit/test_html_escaping.js', 'unit/test_style_editor_element_keys.js',
|
'unit/test_html_escaping.js', 'unit/test_style_editor_element_keys.js',
|
||||||
'unit/test_style_editor_layout_leaf_columns.js',
|
'unit/test_style_editor_layout_leaf_columns.js',
|
||||||
'unit/test_style_editor_layout_leaf_collision.js',
|
'unit/test_style_editor_layout_leaf_collision.js',
|
||||||
'unit/test_update_all.js', 'unit/test_inline_handler_escaping.js',
|
'unit/test_update_all.js',
|
||||||
|
'unit/test_store_install.js',
|
||||||
|
'unit/test_install_polling.js',
|
||||||
|
'unit/test_store_categories.js',
|
||||||
|
'unit/test_github_url_install.js',
|
||||||
|
'unit/test_inline_handler_escaping.js',
|
||||||
'unit/test_plugin_action_delegation.js', 'unit/test_file_upload_widget.js',
|
'unit/test_plugin_action_delegation.js', 'unit/test_file_upload_widget.js',
|
||||||
'unit/test_store_registry_fields.js', 'unit/test_restart_banner.js',
|
'unit/test_store_registry_fields.js', 'unit/test_restart_banner.js',
|
||||||
'unit/test_page_registry.js', 'unit/test_core_modules.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'];
|
||||||
const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.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_tools_sections.js', 'dom/test_cache_page.js',
|
||||||
'dom/test_durations_page.js', 'dom/test_operation_history_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) {
|
function reachable(url) {
|
||||||
return new Promise(res => {
|
return new Promise(res => {
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
// The Display tab's page module must only look up elements the partial
|
||||||
|
// renders.
|
||||||
|
//
|
||||||
|
// Its brightness slider handler once also wrote to #brightness-display, a
|
||||||
|
// "LED brightness: N%" line that #387 removed from partials/display.html. The
|
||||||
|
// lookup returned null, so every movement of the slider threw a TypeError.
|
||||||
|
// This imports the shipped module (static/v3/js/pages/display.js), starts it
|
||||||
|
// on a fake root that answers only for the ids the partial's markup renders
|
||||||
|
// (null for any other, as in a browser), fires every listener it registered,
|
||||||
|
// and checks that every id it asked for exists. Then it moves the slider.
|
||||||
|
//
|
||||||
|
// No jsdom and no server needed.
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { pathToFileURL } = require('url');
|
||||||
|
|
||||||
|
const PARTIAL = path.resolve(__dirname, '../../../web_interface/templates/v3/partials/display.html');
|
||||||
|
const JS = path.resolve(__dirname, '../../../web_interface/static/v3/js');
|
||||||
|
|
||||||
|
let pass = 0, fail = 0;
|
||||||
|
const ok = (label, cond, extra) => cond
|
||||||
|
? (pass++, console.log(' ok ' + label))
|
||||||
|
: (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' ' + JSON.stringify(extra) : '')));
|
||||||
|
|
||||||
|
const html = fs.readFileSync(PARTIAL, 'utf8');
|
||||||
|
const rendered = new Set([...html.matchAll(/\bid="([^"{}]+)"/g)].map(m => m[1]));
|
||||||
|
|
||||||
|
function fakeElement(id) {
|
||||||
|
const listeners = {};
|
||||||
|
const classes = new Set();
|
||||||
|
return {
|
||||||
|
id, value: '1', textContent: '', min: '', max: '', checked: false,
|
||||||
|
style: {}, dataset: {}, className: '',
|
||||||
|
classList: {
|
||||||
|
add: c => classes.add(c), remove: c => classes.delete(c),
|
||||||
|
contains: c => classes.has(c),
|
||||||
|
},
|
||||||
|
addEventListener: (type, fn) => { (listeners[type] ||= []).push(fn); },
|
||||||
|
dispatchEvent() { return true; },
|
||||||
|
appendChild() {},
|
||||||
|
getAttribute: () => null,
|
||||||
|
listeners,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
console.log('\n── Display page module: element lookups ──');
|
||||||
|
const display = await import(pathToFileURL(path.join(JS, 'pages/display.js')).href);
|
||||||
|
|
||||||
|
const asked = new Set();
|
||||||
|
const elements = new Map();
|
||||||
|
const timers = [];
|
||||||
|
const win = {
|
||||||
|
setTimeout: fn => { timers.push(fn); return timers.length; },
|
||||||
|
clearTimeout() {},
|
||||||
|
URLSearchParams,
|
||||||
|
Event: class { constructor(type) { this.type = type; } },
|
||||||
|
PluginOrderList: { init() {} },
|
||||||
|
};
|
||||||
|
const doc = { defaultView: win, createElement: () => fakeElement(''), createTextNode: () => ({}) };
|
||||||
|
const rootListeners = {};
|
||||||
|
const root = {
|
||||||
|
ownerDocument: doc,
|
||||||
|
querySelector(sel) {
|
||||||
|
const m = /^#([\w-]+)$/.exec(sel);
|
||||||
|
if (!m) throw new Error('unexpected selector ' + sel);
|
||||||
|
asked.add(m[1]);
|
||||||
|
if (!rendered.has(m[1])) return null;
|
||||||
|
if (!elements.has(m[1])) elements.set(m[1], fakeElement(m[1]));
|
||||||
|
return elements.get(m[1]);
|
||||||
|
},
|
||||||
|
addEventListener: (type, fn) => { (rootListeners[type] ||= []).push(fn); },
|
||||||
|
contains: () => true,
|
||||||
|
};
|
||||||
|
const never = () => new Promise(() => {});
|
||||||
|
const polls = [];
|
||||||
|
const ctx = {
|
||||||
|
root, name: 'display', state: {}, signal: { aborted: false },
|
||||||
|
api: { get: never },
|
||||||
|
visibility: { every: (ms, fn) => { polls.push(ms); fn(); return () => {}; } },
|
||||||
|
};
|
||||||
|
|
||||||
|
let loadError = null;
|
||||||
|
try { display.init(root, ctx); } catch (e) { loadError = e; }
|
||||||
|
ok('init() runs', !loadError, loadError && String(loadError));
|
||||||
|
ok('the sync status is polled through ctx.visibility', polls.length === 1 && polls[0] === 5000, polls);
|
||||||
|
|
||||||
|
// Fire everything it wired, so every lookup it can make is made.
|
||||||
|
let thrown = null;
|
||||||
|
try {
|
||||||
|
for (const el of elements.values()) {
|
||||||
|
for (const fns of Object.values(el.listeners)) fns.forEach(fn => fn.call(el, { target: el }));
|
||||||
|
}
|
||||||
|
while (timers.length) timers.shift()();
|
||||||
|
} catch (e) { thrown = e; }
|
||||||
|
ok('its listeners and timers run without throwing', !thrown, thrown && String(thrown));
|
||||||
|
|
||||||
|
const missing = [...asked].filter(id => !rendered.has(id));
|
||||||
|
ok('it looks elements up', asked.size > 10, asked.size);
|
||||||
|
ok('every looked-up id is rendered by the partial', missing.length === 0, missing);
|
||||||
|
|
||||||
|
const slider = elements.get('brightness') || fakeElement('brightness');
|
||||||
|
const handlers = slider.listeners.input || [];
|
||||||
|
ok('the slider has an input handler', handlers.length > 0);
|
||||||
|
slider.value = '42';
|
||||||
|
thrown = null;
|
||||||
|
try { handlers.forEach(fn => fn.call(slider, { target: slider })); } catch (e) { thrown = e; }
|
||||||
|
ok('moving the slider throws nothing', !thrown, thrown && String(thrown));
|
||||||
|
const label = elements.get('brightness-value');
|
||||||
|
ok('...and shows the new value', !!label && label.textContent === '42', label && label.textContent);
|
||||||
|
|
||||||
|
display.destroy(root, ctx);
|
||||||
|
console.log(`\n${pass} passed, ${fail} failed\n`);
|
||||||
|
process.exit(fail ? 1 : 0);
|
||||||
|
})().catch(e => { console.error(e); process.exit(1); });
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// Creating an API token on the General tab must leave its form clean.
|
||||||
|
//
|
||||||
|
// app.js marks a form data-dirty on any input in it and clears the mark only
|
||||||
|
// after a successful htmx request; its beforeunload handler then asks "Leave
|
||||||
|
// site?" while any visible form is still dirty. The token form posts with
|
||||||
|
// fetch (createToken in static/v3/js/pages/general.js), so after a token was
|
||||||
|
// created the form stayed dirty and reloading the page while the General tab
|
||||||
|
// was open prompted about changes that had been saved.
|
||||||
|
//
|
||||||
|
// Imports the shipped page module and runs createToken with a fake fetch and
|
||||||
|
// DOM -- no jsdom and no server needed.
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const { pathToFileURL } = require('url');
|
||||||
|
|
||||||
|
const JS = path.resolve(__dirname, '../../../web_interface/static/v3/js');
|
||||||
|
const load = f => import(pathToFileURL(path.join(JS, f)).href);
|
||||||
|
|
||||||
|
let pass = 0, fail = 0;
|
||||||
|
const ok = (label, cond, extra) => cond
|
||||||
|
? (pass++, console.log(' ok ' + label))
|
||||||
|
: (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' ' + JSON.stringify(extra) : '')));
|
||||||
|
|
||||||
|
function el() {
|
||||||
|
const classes = new Set(['hidden']);
|
||||||
|
return {
|
||||||
|
textContent: '', dataset: {}, style: {}, className: '',
|
||||||
|
classList: { add: c => classes.add(c), remove: c => classes.delete(c), contains: c => classes.has(c) },
|
||||||
|
appendChild() {}, addEventListener() {}, querySelector: () => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setup(answer) {
|
||||||
|
const { createApi } = await load('core/api.js');
|
||||||
|
const general = await load('pages/general.js');
|
||||||
|
const elements = {
|
||||||
|
'#web-login-tokens': el(),
|
||||||
|
'#web-login-new-token-value': el(),
|
||||||
|
'#web-login-new-token': el(),
|
||||||
|
};
|
||||||
|
const notes = [];
|
||||||
|
const doc = { createElement: () => el(), defaultView: { confirm: () => true } };
|
||||||
|
const root = { ownerDocument: doc, querySelector: sel => elements[sel] || null, querySelectorAll: () => [] };
|
||||||
|
const fetch = () => Promise.resolve({
|
||||||
|
ok: answer.ok, status: answer.ok ? 200 : 400,
|
||||||
|
headers: { get: () => null },
|
||||||
|
text: () => Promise.resolve(JSON.stringify(answer.body)),
|
||||||
|
});
|
||||||
|
const ctx = {
|
||||||
|
root, state: {}, signal: { aborted: false },
|
||||||
|
api: createApi({ fetch }),
|
||||||
|
notify: (m, t) => notes.push([m, t]),
|
||||||
|
};
|
||||||
|
return { general, ctx, elements, notes };
|
||||||
|
}
|
||||||
|
|
||||||
|
function dirtyForm() {
|
||||||
|
const attrs = new Map([['data-dirty', '']]);
|
||||||
|
return {
|
||||||
|
querySelector: sel => (sel === '[name="name"]' ? { value: 'Home Assistant' } : null),
|
||||||
|
reset() {},
|
||||||
|
hasAttribute: name => attrs.has(name),
|
||||||
|
setAttribute: (name, value) => attrs.set(name, String(value)),
|
||||||
|
removeAttribute: name => attrs.delete(name),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
console.log('\n── General tab: API token form ──');
|
||||||
|
|
||||||
|
{
|
||||||
|
const t = await setup({ ok: true, body: {
|
||||||
|
status: 'success', message: 'Token created',
|
||||||
|
data: { token: 'lmx_secret', record: { id: 't1', name: 'Home Assistant', prefix: 'lmx_sec' } },
|
||||||
|
} });
|
||||||
|
const form = dirtyForm();
|
||||||
|
await t.general.createToken(t.ctx, form);
|
||||||
|
ok('the new token is shown', t.elements['#web-login-new-token-value'].textContent === 'lmx_secret');
|
||||||
|
ok('a created token leaves the form clean (no "Leave site?" on reload)',
|
||||||
|
!form.hasAttribute('data-dirty'));
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const t = await setup({ ok: false, body: { status: 'error', message: 'Name is required' } });
|
||||||
|
const form = dirtyForm();
|
||||||
|
await t.general.createToken(t.ctx, form);
|
||||||
|
ok('a refused request reports the error', t.notes.some(([m, type]) => type === 'error' && /Name is required/.test(m)),
|
||||||
|
t.notes);
|
||||||
|
ok('...and keeps the form dirty: nothing was saved', form.hasAttribute('data-dirty'));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n${pass} passed, ${fail} failed\n`);
|
||||||
|
process.exit(fail ? 1 : 0);
|
||||||
|
})().catch(e => { console.log('HARNESS ERROR: ' + e.stack); process.exit(1); });
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// Plugin Manager > Install from GitHub > Install Single Plugin: one click,
|
||||||
|
// one request, no errors.
|
||||||
|
//
|
||||||
|
// The Install button carried an inline onclick calling
|
||||||
|
// window.handleGitHubPluginInstall, and attachInstallButtonHandler also gave
|
||||||
|
// it a click listener that installs. Both ran on every click. The inline one
|
||||||
|
// threw a ReferenceError (it called isGithubUrl, which lives inside the
|
||||||
|
// plugin-manager IIFE, from outside it), so only the listener's request went
|
||||||
|
// out -- and fixing that scope alone would have sent every install twice.
|
||||||
|
// The button now has the listener only.
|
||||||
|
//
|
||||||
|
// Runs the whole of plugins_manager.js in the sandbox, with the button as
|
||||||
|
// partials/plugins.html ships it.
|
||||||
|
|
||||||
|
const { create, templateAttributes } = require('../plugins_manager_sandbox');
|
||||||
|
|
||||||
|
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).slice(0, 400) : '')));
|
||||||
|
|
||||||
|
const URL = 'https://github.com/someone/ledmatrix-demo';
|
||||||
|
|
||||||
|
function route(method, url) {
|
||||||
|
if (method === 'POST' && url === '/api/v3/plugins/install-from-url') {
|
||||||
|
return { json: { status: 'success', message: 'Plugin demo installed successfully', plugin_id: 'demo' } };
|
||||||
|
}
|
||||||
|
if (url.startsWith('/api/v3/plugins/installed')) return { json: { status: 'success', data: { plugins: [] } } };
|
||||||
|
return { json: { status: 'success' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function page() {
|
||||||
|
const sb = create({ route });
|
||||||
|
const button = templateAttributes('install-plugin-from-url');
|
||||||
|
sb.el('install-plugin-from-url', button.tag, button.attrs);
|
||||||
|
sb.el('github-plugin-url', 'input');
|
||||||
|
sb.el('github-plugin-status');
|
||||||
|
sb.el('plugin-branch-input', 'input');
|
||||||
|
return sb;
|
||||||
|
}
|
||||||
|
|
||||||
|
const installs = sb => sb.requests.filter(r => r.url === '/api/v3/plugins/install-from-url');
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
console.log('\nthe template');
|
||||||
|
{
|
||||||
|
const { attrs } = templateAttributes('install-plugin-from-url');
|
||||||
|
ok('the Install button has no inline onclick', !('onclick' in attrs), attrs.onclick);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\na click');
|
||||||
|
{
|
||||||
|
const sb = page();
|
||||||
|
sb.window.attachInstallButtonHandler();
|
||||||
|
// htmx:afterSettle runs it again on every swap; that must not add a handler.
|
||||||
|
sb.window.attachInstallButtonHandler();
|
||||||
|
sb.el('github-plugin-url').value = URL;
|
||||||
|
sb.window.document.getElementById('install-plugin-from-url').click();
|
||||||
|
await sb.settle();
|
||||||
|
ok('raises no error', sb.errors.length === 0, sb.errors.map(String));
|
||||||
|
ok('sends exactly one install request', installs(sb).length === 1, installs(sb));
|
||||||
|
ok('for the URL typed', installs(sb)[0] && installs(sb)[0].body.repo_url === URL, installs(sb));
|
||||||
|
ok('and reports the result', /Successfully installed: demo/.test(sb.el('github-plugin-status').innerHTML),
|
||||||
|
sb.el('github-plugin-status').innerHTML);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nEnter in the URL field');
|
||||||
|
{
|
||||||
|
const sb = page();
|
||||||
|
sb.window.attachInstallButtonHandler();
|
||||||
|
const input = sb.el('github-plugin-url');
|
||||||
|
input.value = URL;
|
||||||
|
input.dispatch('keypress', { key: 'Enter' });
|
||||||
|
await sb.settle();
|
||||||
|
ok('raises no error', sb.errors.length === 0, sb.errors.map(String));
|
||||||
|
ok('sends exactly one install request', installs(sb).length === 1, installs(sb));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\na URL that is not GitHub');
|
||||||
|
{
|
||||||
|
const sb = page();
|
||||||
|
sb.window.attachInstallButtonHandler();
|
||||||
|
sb.el('github-plugin-url').value = 'https://example.com/x';
|
||||||
|
sb.window.document.getElementById('install-plugin-from-url').click();
|
||||||
|
await sb.settle();
|
||||||
|
ok('is refused without a request or an error',
|
||||||
|
installs(sb).length === 0 && sb.errors.length === 0 && /valid GitHub URL/.test(sb.el('github-plugin-status').innerHTML),
|
||||||
|
{ errors: sb.errors.map(String), status: sb.el('github-plugin-status').innerHTML });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n${pass} passed, ${fail} failed`);
|
||||||
|
process.exit(fail ? 1 : 0);
|
||||||
|
})().catch(e => { console.error(e); process.exit(1); });
|
||||||
@@ -103,17 +103,16 @@ const ESCAPERS = [
|
|||||||
'static/v3/js/widgets/text-input.js', 'function escapeHtml(text) {', 'escapeHtml', false],
|
'static/v3/js/widgets/text-input.js', 'function escapeHtml(text) {', 'escapeHtml', false],
|
||||||
['slider.js (escapeAttr)',
|
['slider.js (escapeAttr)',
|
||||||
'static/v3/js/widgets/slider.js', 'function escapeAttr(text) {', 'escapeAttr', false],
|
'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)',
|
['tools.html (escHtml)',
|
||||||
'templates/v3/partials/tools.html', 'function escHtml(s) {', 'escHtml', false],
|
'templates/v3/partials/tools.html', 'function escHtml(s) {', 'escHtml', false],
|
||||||
['tools.html (phEscape)',
|
['tools.html (phEscape)',
|
||||||
'templates/v3/partials/tools.html', 'function phEscape(s) {', 'phEscape', false],
|
'templates/v3/partials/tools.html', 'function phEscape(s) {', 'phEscape', false],
|
||||||
['logs.html (escapeHtml)',
|
['logs.html (escapeHtml)',
|
||||||
'templates/v3/partials/logs.html', 'function escapeHtml(text) {', 'escapeHtml', false],
|
'templates/v3/partials/logs.html', 'function escapeHtml(text) {', 'escapeHtml', false],
|
||||||
// cache.html, backup_restore.html and operation_history.html have no
|
// cache.html, backup_restore.html, operation_history.html and display.html
|
||||||
// script any more: their js/pages/ modules draw server data with
|
// have no script any more (display.html's two escapers were never called):
|
||||||
// textContent, and each page's suite in test/js/dom/ checks a hostile value.
|
// 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
|
// The breakout payload: closes a double-quoted attribute and opens an event
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// How long the store's Install waits for a queued install, and what it says
|
||||||
|
// when it stops waiting.
|
||||||
|
//
|
||||||
|
// It polled the operation 60 times, a second apart, then reported "Install
|
||||||
|
// operation timed out" as an error and did nothing else. The server is
|
||||||
|
// allowed far longer: the plugin's dependency install alone may take 300 s
|
||||||
|
// (install_requirements_file in src/plugin_system/store_install.py), after
|
||||||
|
// a download that fetches the plugin one file at a time. So an install that
|
||||||
|
// went on to succeed was reported as failed, never enabled, and missing from
|
||||||
|
// the installed list until the page was reloaded.
|
||||||
|
//
|
||||||
|
// Runs the whole of plugins_manager.js in the sandbox; its timers ignore
|
||||||
|
// their delays, so each poll here stands for one second on a real page.
|
||||||
|
|
||||||
|
const { create } = require('../plugins_manager_sandbox');
|
||||||
|
|
||||||
|
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).slice(0, 400) : '')));
|
||||||
|
|
||||||
|
// The server's dependency-install timeout, in polls (one a second).
|
||||||
|
const DEPENDENCY_INSTALL_TIMEOUT_POLLS = 300;
|
||||||
|
|
||||||
|
function server(completesAfterPolls) {
|
||||||
|
const state = { polls: 0, installed: [] };
|
||||||
|
state.route = (method, url, body) => {
|
||||||
|
if (url.startsWith('/api/v3/plugins/installed')) {
|
||||||
|
return { json: { status: 'success', data: { plugins: state.installed.map(p => ({ ...p })) } } };
|
||||||
|
}
|
||||||
|
if (method === 'POST' && url === '/api/v3/plugins/install') {
|
||||||
|
return { json: { status: 'success', message: 'queued', data: { operation_id: 'op-1' } } };
|
||||||
|
}
|
||||||
|
if (url === '/api/v3/plugins/operation/op-1') {
|
||||||
|
state.polls++;
|
||||||
|
if (completesAfterPolls === null || state.polls < completesAfterPolls) {
|
||||||
|
return { json: { status: 'success', data: { status: 'running' } } };
|
||||||
|
}
|
||||||
|
state.installed = [{ id: 'clock-simple', name: 'Clock', enabled: false }];
|
||||||
|
return { json: { status: 'success', data: { status: 'completed',
|
||||||
|
result: { success: true, message: 'installed', plugin_id: 'clock-simple' } } } };
|
||||||
|
}
|
||||||
|
if (method === 'POST' && url === '/api/v3/plugins/toggle') {
|
||||||
|
return { json: { status: 'success', message: 'enabled' } };
|
||||||
|
}
|
||||||
|
return { json: { status: 'success' } };
|
||||||
|
};
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
console.log('\nan install that takes longer than a minute');
|
||||||
|
{
|
||||||
|
// 200 s: well inside what the server allows.
|
||||||
|
const srv = server(200);
|
||||||
|
const sb = create({ route: srv.route });
|
||||||
|
sb.window.installPlugin('clock-simple');
|
||||||
|
await sb.until(() => sb.toasts.some(t => /installed and enabled|enabling it failed|timed out|still/i.test(t.message)),
|
||||||
|
'the install to finish');
|
||||||
|
await sb.settle();
|
||||||
|
ok('is waited for until it completes', srv.polls === 200, srv.polls);
|
||||||
|
ok('is not reported as an error', !sb.toasts.some(t => t.type === 'error'), sb.toasts);
|
||||||
|
ok('and is enabled', sb.requests.some(r => r.url === '/api/v3/plugins/toggle' && r.body.plugin_id === 'clock-simple'),
|
||||||
|
sb.requests.filter(r => r.method === 'POST'));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nan install that never reports back');
|
||||||
|
{
|
||||||
|
const srv = server(null);
|
||||||
|
const sb = create({ route: srv.route });
|
||||||
|
sb.window.installPlugin('clock-simple');
|
||||||
|
await sb.until(() => sb.toasts.length >= 3, 'the poller to give up');
|
||||||
|
await sb.settle();
|
||||||
|
ok(`is polled for at least the ${DEPENDENCY_INSTALL_TIMEOUT_POLLS} s dependency-install timeout`,
|
||||||
|
srv.polls >= DEPENDENCY_INSTALL_TIMEOUT_POLLS, srv.polls);
|
||||||
|
ok('...but not forever', srv.polls <= 1200, srv.polls);
|
||||||
|
const lastPoll = sb.requests.map(r => r.url).lastIndexOf('/api/v3/plugins/operation/op-1');
|
||||||
|
ok('then the installed list is reloaded, to show what actually happened',
|
||||||
|
sb.requests.slice(lastPoll + 1).some(r => r.url === '/api/v3/plugins/installed'),
|
||||||
|
sb.requests.slice(lastPoll + 1).map(r => r.url));
|
||||||
|
const last = sb.toasts[sb.toasts.length - 1];
|
||||||
|
ok('it says the install may still be running, as a warning, not a failure',
|
||||||
|
last && last.type === 'warning' && !/fail|timed out/i.test(last.message), sb.toasts);
|
||||||
|
ok('nothing is enabled on a guess', !sb.requests.some(r => r.url === '/api/v3/plugins/toggle'));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n${pass} passed, ${fail} failed`);
|
||||||
|
process.exit(fail ? 1 : 0);
|
||||||
|
})().catch(e => { console.error(e); process.exit(1); });
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
// The Overview's "Plugin Config Warning" poll must end.
|
||||||
|
//
|
||||||
|
// The banner script in partials/overview.html asks
|
||||||
|
// /api/v3/plugins/reconciliation-status every 2 s until startup reconciliation
|
||||||
|
// says it is done. The route answers done: false whenever its status file is
|
||||||
|
// missing -- reconciliation raised before writing it, or /tmp was cleaned
|
||||||
|
// under a long-running web service -- so the poll used to run every 2 s for
|
||||||
|
// as long as the page stayed open, on every tab. It now gives up after a
|
||||||
|
// bounded number of tries and runs only while the Overview is on screen
|
||||||
|
// (LEDVisibility, like the other partials' pollers).
|
||||||
|
//
|
||||||
|
// Runs the shipped inline script in a vm with fake timers, fetch and DOM --
|
||||||
|
// no jsdom and no server needed.
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const vm = require('vm');
|
||||||
|
|
||||||
|
const PARTIAL = path.resolve(__dirname, '../../../web_interface/templates/v3/partials/overview.html');
|
||||||
|
|
||||||
|
let pass = 0, fail = 0;
|
||||||
|
const ok = (label, cond, extra) => cond
|
||||||
|
? (pass++, console.log(' ok ' + label))
|
||||||
|
: (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' ' + JSON.stringify(extra) : '')));
|
||||||
|
|
||||||
|
function bannerScript() {
|
||||||
|
const html = fs.readFileSync(PARTIAL, 'utf8');
|
||||||
|
const scripts = [...html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/gi)].map(m => m[1]);
|
||||||
|
const found = scripts.find(s => s.includes('ledmatrix-recon-dismissed'));
|
||||||
|
if (!found) throw new Error('reconciliation banner script not found in overview.html');
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
const flush = async () => { for (let i = 0; i < 10; i++) await new Promise(r => setImmediate(r)); };
|
||||||
|
|
||||||
|
function load({ payload, visibility = true }) {
|
||||||
|
const timers = new Map();
|
||||||
|
let nextId = 1;
|
||||||
|
const calls = [];
|
||||||
|
const banner = { style: { setProperty() {} }, dataset: {} };
|
||||||
|
const text = { textContent: '' };
|
||||||
|
const registrations = [];
|
||||||
|
const window = {};
|
||||||
|
if (visibility) {
|
||||||
|
window.LEDVisibility = {
|
||||||
|
onActive(tab, start, stop, key) { registrations.push({ tab, start, stop, key }); start(); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const context = {
|
||||||
|
window,
|
||||||
|
document: {
|
||||||
|
getElementById: id => ({ 'reconciliation-banner': banner, 'reconciliation-banner-text': text })[id] || null,
|
||||||
|
},
|
||||||
|
sessionStorage: { getItem: () => null, setItem() {} },
|
||||||
|
fetch: (url) => {
|
||||||
|
calls.push(url);
|
||||||
|
return Promise.resolve({ json: () => Promise.resolve(payload()) });
|
||||||
|
},
|
||||||
|
setTimeout: (fn) => { const id = nextId++; timers.set(id, fn); return id; },
|
||||||
|
clearTimeout: (id) => { timers.delete(id); },
|
||||||
|
};
|
||||||
|
vm.createContext(context);
|
||||||
|
vm.runInContext(bannerScript(), context);
|
||||||
|
const fireTimers = async () => {
|
||||||
|
const due = [...timers.entries()];
|
||||||
|
timers.clear();
|
||||||
|
due.forEach(([, fn]) => fn());
|
||||||
|
await flush();
|
||||||
|
};
|
||||||
|
return { calls, timers, registrations, banner, text, window, fireTimers };
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
console.log('\n── Overview reconciliation poll ──');
|
||||||
|
|
||||||
|
// 1. A status file that never says done: the poll stops on its own.
|
||||||
|
{
|
||||||
|
const t = load({ payload: () => ({ status: 'success', data: { done: false, unresolved: [] } }) });
|
||||||
|
await flush();
|
||||||
|
for (let i = 0; i < 200; i++) await t.fireTimers();
|
||||||
|
ok('a status that never turns done stops being polled', t.timers.size === 0,
|
||||||
|
{ pending: t.timers.size, requests: t.calls.length });
|
||||||
|
ok('...after a bounded number of requests (at most 30, a minute at 2 s)',
|
||||||
|
t.calls.length > 1 && t.calls.length <= 30, t.calls.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Runs only while the Overview is on screen.
|
||||||
|
{
|
||||||
|
const t = load({ payload: () => ({ status: 'success', data: { done: false, unresolved: [] } }) });
|
||||||
|
await flush();
|
||||||
|
const reg = t.registrations[0];
|
||||||
|
ok('registers with LEDVisibility for the overview tab', !!reg && reg.tab === 'overview', reg && reg.tab);
|
||||||
|
ok('under its own key, so it does not replace another overview poller',
|
||||||
|
!!reg && !!reg.key && reg.key !== 'overview', reg && reg.key);
|
||||||
|
ok('first request goes out at once', t.calls.length === 1, t.calls.length);
|
||||||
|
if (reg) {
|
||||||
|
reg.stop();
|
||||||
|
ok('leaving the tab cancels the pending retry', t.timers.size === 0, t.timers.size);
|
||||||
|
for (let i = 0; i < 5; i++) await t.fireTimers();
|
||||||
|
ok('no requests while another tab is active', t.calls.length === 1, t.calls.length);
|
||||||
|
reg.start();
|
||||||
|
await flush();
|
||||||
|
ok('coming back asks again at once', t.calls.length === 2, t.calls.length);
|
||||||
|
ok('...and keeps polling', t.timers.size === 1, t.timers.size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. A finished reconciliation with findings shows the banner and stops.
|
||||||
|
{
|
||||||
|
let done = false;
|
||||||
|
const t = load({ payload: () => (done
|
||||||
|
? { status: 'success', data: { done: true, unresolved: [{ plugin_id: 'clock', type: 'plugin_missing_on_disk' }] } }
|
||||||
|
: { status: 'success', data: { done: false, unresolved: [] } }) });
|
||||||
|
await flush();
|
||||||
|
await t.fireTimers();
|
||||||
|
done = true;
|
||||||
|
await t.fireTimers();
|
||||||
|
ok('the banner names the finding once reconciliation is done',
|
||||||
|
t.text.textContent.includes('clock'), t.text.textContent);
|
||||||
|
const before = t.calls.length;
|
||||||
|
for (let i = 0; i < 5; i++) await t.fireTimers();
|
||||||
|
ok('no more requests once it is done', t.calls.length === before && t.timers.size === 0,
|
||||||
|
{ before, after: t.calls.length, pending: t.timers.size });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Without LEDVisibility (base.html always has it) it still runs, bounded.
|
||||||
|
{
|
||||||
|
const t = load({ visibility: false, payload: () => ({ status: 'success', data: { done: false } }) });
|
||||||
|
await flush();
|
||||||
|
ok('runs without LEDVisibility', t.calls.length === 1, t.calls.length);
|
||||||
|
for (let i = 0; i < 200; i++) await t.fireTimers();
|
||||||
|
ok('...and is still bounded', t.timers.size === 0 && t.calls.length <= 30, t.calls.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n${pass} passed, ${fail} failed\n`);
|
||||||
|
process.exit(fail ? 1 : 0);
|
||||||
|
})().catch(e => { console.log('HARNESS ERROR: ' + e.stack); process.exit(1); });
|
||||||
@@ -242,6 +242,46 @@ function recorder(log) {
|
|||||||
ok('has()', reg.has('dup') && !reg.has('nope'));
|
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`);
|
console.log(`\n${pass} passed, ${fail} failed`);
|
||||||
process.exit(fail ? 1 : 0);
|
process.exit(fail ? 1 : 0);
|
||||||
})().catch(e => { console.error(e); process.exit(1); });
|
})().catch(e => { console.error(e); process.exit(1); });
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
// The shared plugin order list (widgets/plugin-order-list.js) keeps what it
|
||||||
|
// does not show.
|
||||||
|
//
|
||||||
|
// It lists enabled plugins only, and rewrites its hidden inputs from those
|
||||||
|
// rows as soon as it has drawn them. A disabled plugin's place in the order
|
||||||
|
// and its Vegas exclusion used to vanish from the inputs on that rewrite, so
|
||||||
|
// any later save of the Display or Rotation & Durations tab stored them
|
||||||
|
// without it: re-enabled, the plugin came back at the end of the rotation and
|
||||||
|
// scrolling in Vegas again. An uninstalled plugin's id is still dropped, as
|
||||||
|
// before, so the lists don't collect ids nothing can show. Runs the shipped
|
||||||
|
// widget in a vm with a minimal fake DOM -- no jsdom and no server needed, so
|
||||||
|
// it runs under test/test_js_unit_suites.py too.
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const vm = require('vm');
|
||||||
|
const WIDGET = path.resolve(__dirname, '../../../web_interface/static/v3/js/widgets/plugin-order-list.js');
|
||||||
|
|
||||||
|
let pass = 0, fail = 0;
|
||||||
|
const ok = (label, cond, extra) => cond
|
||||||
|
? (pass++, console.log(' ok ' + label))
|
||||||
|
: (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' ' + JSON.stringify(extra) : '')));
|
||||||
|
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
||||||
|
|
||||||
|
class FakeElement {
|
||||||
|
constructor(tag) {
|
||||||
|
this.tagName = tag.toUpperCase();
|
||||||
|
this.children = [];
|
||||||
|
this.parent = null;
|
||||||
|
this.dataset = {};
|
||||||
|
this.style = {};
|
||||||
|
this.className = '';
|
||||||
|
this.value = '';
|
||||||
|
this.checked = false;
|
||||||
|
this.listeners = {};
|
||||||
|
this._text = '';
|
||||||
|
}
|
||||||
|
appendChild(child) {
|
||||||
|
if (child.parent) child.parent.children = child.parent.children.filter(c => c !== child);
|
||||||
|
child.parent = this;
|
||||||
|
this.children.push(child);
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
insertBefore(child, ref) {
|
||||||
|
if (!ref) return this.appendChild(child);
|
||||||
|
if (child.parent) child.parent.children = child.parent.children.filter(c => c !== child);
|
||||||
|
child.parent = this;
|
||||||
|
this.children.splice(this.children.indexOf(ref), 0, child);
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
get previousElementSibling() {
|
||||||
|
const siblings = this.parent ? this.parent.children : [];
|
||||||
|
return siblings[siblings.indexOf(this) - 1] || null;
|
||||||
|
}
|
||||||
|
get nextElementSibling() {
|
||||||
|
const siblings = this.parent ? this.parent.children : [];
|
||||||
|
const i = siblings.indexOf(this);
|
||||||
|
return i < 0 ? null : siblings[i + 1] || null;
|
||||||
|
}
|
||||||
|
set textContent(value) { this._text = value; this.children = []; }
|
||||||
|
get textContent() { return this._text; }
|
||||||
|
setAttribute() {}
|
||||||
|
focus() {}
|
||||||
|
addEventListener(type, fn) { (this.listeners[type] ||= []).push(fn); }
|
||||||
|
fire(type, event) { (this.listeners[type] || []).forEach(fn => fn.call(this, event || {})); }
|
||||||
|
descendants() { return this.children.flatMap(c => [c, ...c.descendants()]); }
|
||||||
|
querySelectorAll(selector) {
|
||||||
|
const cls = selector.replace(/^\./, '');
|
||||||
|
return this.descendants().filter(e => e.className.split(/\s+/).includes(cls));
|
||||||
|
}
|
||||||
|
querySelector(selector) { return this.querySelectorAll(selector)[0] || null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run the widget over `plugins` with the given saved inputs; resolves once it has drawn. */
|
||||||
|
async function mount({ plugins, order, excluded, fetchFails }) {
|
||||||
|
const els = {
|
||||||
|
list: new FakeElement('div'),
|
||||||
|
order: Object.assign(new FakeElement('input'), { value: JSON.stringify(order) }),
|
||||||
|
};
|
||||||
|
if (excluded !== undefined) {
|
||||||
|
els.excluded = Object.assign(new FakeElement('input'), { value: JSON.stringify(excluded) });
|
||||||
|
}
|
||||||
|
const context = {
|
||||||
|
// The widget logs a failed list; expected there, so kept off the output.
|
||||||
|
console: fetchFails ? Object.assign({}, console, { error: () => {} }) : console,
|
||||||
|
window: {},
|
||||||
|
document: {
|
||||||
|
getElementById: (id) => els[id] || null,
|
||||||
|
createElement: (tag) => new FakeElement(tag),
|
||||||
|
createTextNode: (text) => new FakeElement('#text'),
|
||||||
|
},
|
||||||
|
fetch: () => (fetchFails ? Promise.reject(new Error('service restarting')) : Promise.resolve({
|
||||||
|
json: () => Promise.resolve({ status: 'success', data: { plugins } }),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
vm.createContext(context);
|
||||||
|
vm.runInContext(fs.readFileSync(WIDGET, 'utf8'), context);
|
||||||
|
context.window.PluginOrderList.init({
|
||||||
|
containerId: 'list', orderInputId: 'order',
|
||||||
|
excludedInputId: excluded !== undefined ? 'excluded' : undefined,
|
||||||
|
});
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
|
const rows = () => els.list.querySelectorAll('.plugin-order-item');
|
||||||
|
return {
|
||||||
|
rows,
|
||||||
|
rowIds: () => rows().map(r => r.dataset.pluginId),
|
||||||
|
order: () => JSON.parse(els.order.value),
|
||||||
|
excluded: () => JSON.parse(els.excluded.value),
|
||||||
|
row: (id) => rows().find(r => r.dataset.pluginId === id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLUGINS = [
|
||||||
|
{ id: 'weather', name: 'Weather', enabled: true },
|
||||||
|
{ id: 'clock', name: 'Clock', enabled: false },
|
||||||
|
{ id: 'stocks', name: 'Stocks', enabled: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
console.log('\nVegas: a disabled plugin keeps its place and its exclusion');
|
||||||
|
{
|
||||||
|
const t = await mount({ plugins: PLUGINS, order: ['weather', 'clock', 'stocks'], excluded: ['clock'] });
|
||||||
|
ok('only enabled plugins get a row', same(t.rowIds(), ['weather', 'stocks']), t.rowIds());
|
||||||
|
ok('drawing the list keeps the disabled plugin in the order, in its place',
|
||||||
|
same(t.order(), ['weather', 'clock', 'stocks']), t.order());
|
||||||
|
ok('drawing the list keeps its exclusion', same(t.excluded(), ['clock']), t.excluded());
|
||||||
|
|
||||||
|
// Move Stocks up: the rows swap, and Clock stays in its saved slot.
|
||||||
|
const up = t.row('stocks').querySelectorAll('.plugin-order-move')[0];
|
||||||
|
up.fire('click');
|
||||||
|
ok('reordering the rows fills the other slots in the new order',
|
||||||
|
same(t.order(), ['stocks', 'clock', 'weather']), t.order());
|
||||||
|
|
||||||
|
const include = t.row('weather').querySelector('.plugin-order-include');
|
||||||
|
include.checked = false;
|
||||||
|
include.fire('change');
|
||||||
|
ok('unchecking a row adds it, and the disabled exclusion stays',
|
||||||
|
same([...t.excluded()].sort(), ['clock', 'weather']), t.excluded());
|
||||||
|
include.checked = true;
|
||||||
|
include.fire('change');
|
||||||
|
ok('checking it again removes only that one', same(t.excluded(), ['clock']), t.excluded());
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nRotation order: the same, without exclusions');
|
||||||
|
{
|
||||||
|
const plugins = [
|
||||||
|
{ id: 'clock', enabled: true },
|
||||||
|
{ id: 'off', enabled: false },
|
||||||
|
{ id: 'weather', enabled: true },
|
||||||
|
{ id: 'new', enabled: true },
|
||||||
|
];
|
||||||
|
const t = await mount({ plugins, order: ['clock', 'off', 'weather'] });
|
||||||
|
ok('the disabled plugin keeps its slot; a plugin not in the saved order goes last',
|
||||||
|
same(t.order(), ['clock', 'off', 'weather', 'new']), t.order());
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nAn uninstalled plugin is dropped; a failed list keeps everything');
|
||||||
|
{
|
||||||
|
const t = await mount({ plugins: PLUGINS, order: ['weather', 'gone', 'clock', 'stocks'],
|
||||||
|
excluded: ['gone', 'clock'] });
|
||||||
|
ok('the disabled plugin is kept and the uninstalled one dropped from the order',
|
||||||
|
same(t.order(), ['weather', 'clock', 'stocks']), t.order());
|
||||||
|
ok('and from the exclusions', same(t.excluded(), ['clock']), t.excluded());
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const t = await mount({ plugins: PLUGINS, order: ['weather', 'gone', 'clock', 'stocks'],
|
||||||
|
excluded: ['gone', 'clock'], fetchFails: true });
|
||||||
|
// No installed list, so nothing can be told apart: no rows, and the
|
||||||
|
// inputs keep what was saved, uninstalled ids included.
|
||||||
|
ok('a failed plugin list draws no rows', t.rowIds().length === 0, t.rowIds());
|
||||||
|
ok('and leaves the saved order as it was',
|
||||||
|
same(t.order(), ['weather', 'gone', 'clock', 'stocks']), t.order());
|
||||||
|
ok('and the saved exclusions', same(t.excluded(), ['gone', 'clock']), t.excluded());
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nOnly what the server would accept is carried over');
|
||||||
|
{
|
||||||
|
const t = await mount({ plugins: PLUGINS, order: ['weather', 7, 'clock', null, 'clock', 'stocks'],
|
||||||
|
excluded: ['clock', 3, 'clock'] });
|
||||||
|
// /config/main refuses a list holding anything but strings, which would
|
||||||
|
// block every later Display save; a repeated id is kept once.
|
||||||
|
ok('non-string and repeated saved ids are dropped from the order',
|
||||||
|
same(t.order(), ['weather', 'clock', 'stocks']), t.order());
|
||||||
|
ok('and from the exclusions', same(t.excluded(), ['clock']), t.excluded());
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n${pass} passed, ${fail} failed`);
|
||||||
|
process.exit(fail ? 1 : 0);
|
||||||
|
})().catch(e => { console.error(e); process.exit(1); });
|
||||||
@@ -19,7 +19,7 @@ const ok = (label, cond, extra) => cond
|
|||||||
? (pass++, console.log(' ok ' + label))
|
? (pass++, console.log(' ok ' + label))
|
||||||
: (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' ' + JSON.stringify(extra) : '')));
|
: (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' ' + JSON.stringify(extra) : '')));
|
||||||
|
|
||||||
function load() {
|
function load(notes) {
|
||||||
const handlers = {};
|
const handlers = {};
|
||||||
const listen = (target) => (type, fn) => { (handlers[target + ':' + type] ||= []).push(fn); };
|
const listen = (target) => (type, fn) => { (handlers[target + ':' + type] ||= []).push(fn); };
|
||||||
const banner = { style: { display: 'none' } };
|
const banner = { style: { display: 'none' } };
|
||||||
@@ -43,14 +43,18 @@ function load() {
|
|||||||
removeItem: (k) => { delete store[k]; },
|
removeItem: (k) => { delete store[k]; },
|
||||||
getItem: (k) => (k in store ? store[k] : null),
|
getItem: (k) => (k in store ? store[k] : null),
|
||||||
},
|
},
|
||||||
showNotification: () => {},
|
showNotification: (m, t) => { if (notes) notes.push([m, t]); },
|
||||||
setTimeout: () => 0,
|
setTimeout: () => 0,
|
||||||
};
|
};
|
||||||
vm.createContext(context);
|
vm.createContext(context);
|
||||||
vm.runInContext(fs.readFileSync(path.join(V3, 'app.js'), 'utf8'), context);
|
vm.runInContext(fs.readFileSync(path.join(V3, 'app.js'), 'utf8'), context);
|
||||||
const afterRequest = (handlers['body:htmx:afterRequest'] || [])[0];
|
const afterRequest = (handlers['body:htmx:afterRequest'] || [])[0];
|
||||||
const fire = ({ status = 200, body, path: reqPath = '/api/v3/anything', reportsItself = false }) => {
|
// `marks`: the attribute selectors the requesting element (or its form)
|
||||||
const elt = { closest: () => (reportsItself ? {} : null) };
|
// 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({
|
afterRequest({
|
||||||
target: { closest: () => null },
|
target: { closest: () => null },
|
||||||
detail: {
|
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');
|
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`);
|
console.log(`\n${pass} passed, ${fail} failed`);
|
||||||
process.exit(fail ? 1 : 0);
|
process.exit(fail ? 1 : 0);
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
// The Plugin Store's category filter offers the categories its plugins have.
|
||||||
|
//
|
||||||
|
// The template listed seven fixed categories. The registry uses about
|
||||||
|
// twenty (productivity, utility, transit, finance, ...), so roughly a third
|
||||||
|
// of the store could not be filtered to at all, and "Financial" missed the
|
||||||
|
// plugin filed under "finance". The options are now built from the store's
|
||||||
|
// plugins, as the Starlark section builds its own; the template ships only
|
||||||
|
// "All Categories".
|
||||||
|
//
|
||||||
|
// Runs the whole of plugins_manager.js in the sandbox.
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { create, templateAttributes } = require('../plugins_manager_sandbox');
|
||||||
|
|
||||||
|
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).slice(0, 400) : '')));
|
||||||
|
|
||||||
|
const STORE = [
|
||||||
|
{ id: 'nfl', name: 'NFL', category: 'sports' },
|
||||||
|
{ id: 'nba', name: 'NBA', category: 'Sports' },
|
||||||
|
{ id: 'todo', name: 'Todo', category: 'productivity' },
|
||||||
|
{ id: 'stocks', name: 'Stocks', category: 'finance' },
|
||||||
|
{ id: 'crypto', name: 'Crypto', category: 'financial' },
|
||||||
|
{ id: 'bus', name: 'Bus', category: 'transit' },
|
||||||
|
{ id: 'mystery', name: 'Mystery' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function route(method, url) {
|
||||||
|
if (url.startsWith('/api/v3/plugins/store/list')) return { json: { status: 'success', data: { plugins: STORE } } };
|
||||||
|
if (url.startsWith('/api/v3/plugins/installed')) return { json: { status: 'success', data: { plugins: [] } } };
|
||||||
|
if (url.startsWith('/api/v3/plugins/store/github-status')) {
|
||||||
|
return { json: { status: 'success', data: { token_status: 'valid', authenticated: true, rate_limit: 5000 } } };
|
||||||
|
}
|
||||||
|
if (url.startsWith('/api/v3/plugins/saved-repositories')) {
|
||||||
|
return { json: { status: 'success', data: { repositories: [] } } };
|
||||||
|
}
|
||||||
|
if (url.startsWith('/api/v3/display/on-demand/status')) {
|
||||||
|
return { json: { status: 'success', data: { state: {}, service: {} } } };
|
||||||
|
}
|
||||||
|
return { json: { status: 'success' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = sel => sel.children.map(o => o.value);
|
||||||
|
const cardIds = sb => [...sb.el('plugin-store-grid').innerHTML.matchAll(/<h4[^>]*>([^<]*)<\/h4>/g)].map(m => m[1]);
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
console.log('\nthe template');
|
||||||
|
{
|
||||||
|
const html = fs.readFileSync(path.resolve(__dirname,
|
||||||
|
'../../../web_interface/templates/v3/partials/plugins.html'), 'utf8');
|
||||||
|
const start = html.indexOf('<select id="plugin-category"');
|
||||||
|
const block = html.slice(start, html.indexOf('</select>', start));
|
||||||
|
const shipped = [...block.matchAll(/<option value="([^"]*)"/g)].map(m => m[1]);
|
||||||
|
ok('ships only "All Categories"', JSON.stringify(shipped) === JSON.stringify(['']), shipped);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sb = create({ route });
|
||||||
|
const attrs = templateAttributes('plugin-category').attrs;
|
||||||
|
const select = sb.el('plugin-category', 'select', attrs);
|
||||||
|
sb.el('plugin-store-grid');
|
||||||
|
sb.el('installed-plugins-grid');
|
||||||
|
sb.window.initPluginsPage();
|
||||||
|
await sb.until(() => sb.requests.some(r => r.url.startsWith('/api/v3/plugins/store/list')), 'the store list');
|
||||||
|
await sb.settle();
|
||||||
|
|
||||||
|
console.log('\noptions come from the store\'s plugins');
|
||||||
|
ok('"All Categories" is still the first choice', /<option value="">All Categories<\/option>/.test(select.innerHTML),
|
||||||
|
select.innerHTML);
|
||||||
|
ok('every category a plugin has is offered once, whatever its case',
|
||||||
|
JSON.stringify(options(select)) === JSON.stringify(['finance', 'financial', 'productivity', 'sports', 'transit']),
|
||||||
|
options(select));
|
||||||
|
ok('labels are capitalised',
|
||||||
|
(select.children.find(o => o.value === 'productivity') || {}).textContent === 'Productivity');
|
||||||
|
|
||||||
|
console.log('\nchoosing one filters to it');
|
||||||
|
select.value = 'productivity';
|
||||||
|
select.dispatch('change');
|
||||||
|
ok('productivity shows its plugin', JSON.stringify(cardIds(sb)) === JSON.stringify(['Todo']), cardIds(sb));
|
||||||
|
select.value = 'finance';
|
||||||
|
select.dispatch('change');
|
||||||
|
ok('finance is not lost to "financial"', JSON.stringify(cardIds(sb)) === JSON.stringify(['Stocks']), cardIds(sb));
|
||||||
|
select.value = 'sports';
|
||||||
|
select.dispatch('change');
|
||||||
|
ok('one option covers both spellings of sports',
|
||||||
|
JSON.stringify(cardIds(sb).sort()) === JSON.stringify(['NBA', 'NFL']), cardIds(sb));
|
||||||
|
|
||||||
|
console.log('\nthe partial is swapped back in (tab switch)');
|
||||||
|
{
|
||||||
|
// A fresh <select> from the template, the store list still cached.
|
||||||
|
const fresh = new sb.FakeElement('plugin-category', 'select', attrs);
|
||||||
|
select.parentNode.replaceChild(fresh, select);
|
||||||
|
sb.window.searchPluginStore(false);
|
||||||
|
await sb.settle();
|
||||||
|
ok('the new select is filled from the cache',
|
||||||
|
JSON.stringify(options(fresh)) === JSON.stringify(['finance', 'financial', 'productivity', 'sports', 'transit']),
|
||||||
|
options(fresh));
|
||||||
|
ok('keeping the chosen category', fresh.value === 'sports', fresh.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n${pass} passed, ${fail} failed`);
|
||||||
|
process.exit(fail ? 1 : 0);
|
||||||
|
})().catch(e => { console.error(e); process.exit(1); });
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
// The store's Install button: which plugin it enables afterwards, and when.
|
||||||
|
//
|
||||||
|
// 1. Weather, Music, Stocks and Leaderboard are registry entries (`weather`)
|
||||||
|
// whose manifests declare another id (`ledmatrix-weather`). The plugin
|
||||||
|
// list, its config section and /plugins/toggle know them by that id, but
|
||||||
|
// the button enabled the registry id: /plugins/toggle answered 404
|
||||||
|
// "Plugin not found" and the plugin stayed disabled behind "installed,
|
||||||
|
// but enabling it failed". It now enables the id the install answer
|
||||||
|
// names (`plugin_id`), or, from an answer without one, the installed
|
||||||
|
// entry the store entry matches (its id, plugin_path name or aliases).
|
||||||
|
//
|
||||||
|
// 2. Reinstall (the same button on an installed plugin) enabled it too, so
|
||||||
|
// reinstalling a plugin the user had switched off switched it back on.
|
||||||
|
// Only a fresh install enables.
|
||||||
|
//
|
||||||
|
// Runs the whole of plugins_manager.js in the sandbox against a fake API.
|
||||||
|
|
||||||
|
const { create } = require('../plugins_manager_sandbox');
|
||||||
|
|
||||||
|
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).slice(0, 400) : '')));
|
||||||
|
|
||||||
|
const STORE = [
|
||||||
|
{ id: 'weather', name: 'Weather', category: 'weather', plugin_path: 'plugins/ledmatrix-weather',
|
||||||
|
aliases: ['ledmatrix-weather'] },
|
||||||
|
{ id: 'clock-simple', name: 'Clock', category: 'time', plugin_path: 'plugins/clock-simple', aliases: [] },
|
||||||
|
];
|
||||||
|
|
||||||
|
// A server with one install in flight. `queue` false answers the install
|
||||||
|
// directly; `names` false leaves plugin_id out of the answer (an older
|
||||||
|
// server); `installsAs` is the id the installed manifest declares.
|
||||||
|
function server({ installed = [], queue = true, names = true, installsAs }) {
|
||||||
|
const state = { installed: installed.map(p => ({ ...p })), polls: 0 };
|
||||||
|
const done = (id) => {
|
||||||
|
if (!state.installed.some(p => p.id === installsAs)) {
|
||||||
|
state.installed.push({ id: installsAs, name: id, enabled: false });
|
||||||
|
}
|
||||||
|
const result = { success: true, message: `Plugin ${id} installed successfully`, restart_required: false };
|
||||||
|
if (names) result.plugin_id = installsAs;
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
state.route = (method, url, body) => {
|
||||||
|
if (url.startsWith('/api/v3/plugins/store/list')) {
|
||||||
|
return { json: { status: 'success', data: { plugins: STORE } } };
|
||||||
|
}
|
||||||
|
if (url.startsWith('/api/v3/plugins/installed')) {
|
||||||
|
return { json: { status: 'success', data: { plugins: state.installed.map(p => ({ ...p })) } } };
|
||||||
|
}
|
||||||
|
if (method === 'POST' && url === '/api/v3/plugins/install') {
|
||||||
|
if (queue) return { json: { status: 'success', message: 'queued', data: { operation_id: 'op-1' } } };
|
||||||
|
return { json: { status: 'success', message: 'Plugin installed successfully', ...done(body.plugin_id) } };
|
||||||
|
}
|
||||||
|
if (url === '/api/v3/plugins/operation/op-1') {
|
||||||
|
state.polls++;
|
||||||
|
if (state.polls < 3) return { json: { status: 'success', data: { status: 'running' } } };
|
||||||
|
return { json: { status: 'success', data: { status: 'completed', result: done('weather') } } };
|
||||||
|
}
|
||||||
|
if (method === 'POST' && url === '/api/v3/plugins/toggle') {
|
||||||
|
const plugin = state.installed.find(p => p.id === body.plugin_id);
|
||||||
|
if (!plugin) return { status: 404, json: { status: 'error', message: 'Plugin not found' } };
|
||||||
|
plugin.enabled = body.enabled;
|
||||||
|
return { json: { status: 'success', message: `Plugin ${body.plugin_id} enabled successfully` } };
|
||||||
|
}
|
||||||
|
return { json: { status: 'success' } };
|
||||||
|
};
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function install(pluginId, opts) {
|
||||||
|
const srv = server(opts);
|
||||||
|
const sb = create({ route: srv.route });
|
||||||
|
sb.window.searchPluginStore();
|
||||||
|
await sb.until(() => sb.requests.some(r => r.url.startsWith('/api/v3/plugins/store/list')), 'store list');
|
||||||
|
await sb.window.pluginManager.loadInstalledPlugins(true);
|
||||||
|
await sb.settle();
|
||||||
|
sb.requests.length = 0;
|
||||||
|
sb.toasts.length = 0;
|
||||||
|
sb.window.installPlugin(pluginId);
|
||||||
|
await sb.until(() => sb.toasts.some(t => /installed and enabled|enabling it failed|reinstalled/.test(t.message)),
|
||||||
|
'the install to finish');
|
||||||
|
await sb.settle();
|
||||||
|
const toggles = sb.requests.filter(r => r.url === '/api/v3/plugins/toggle').map(r => r.body);
|
||||||
|
return { sb, srv, toggles };
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
console.log('\n1. a fresh install enables the id the plugin was installed as');
|
||||||
|
{
|
||||||
|
const { srv, toggles, sb } = await install('weather', { installsAs: 'ledmatrix-weather' });
|
||||||
|
ok('enables ledmatrix-weather, not the registry id',
|
||||||
|
JSON.stringify(toggles) === JSON.stringify([{ plugin_id: 'ledmatrix-weather', enabled: true }]), toggles);
|
||||||
|
ok('...which the server enabled', srv.installed.find(p => p.id === 'ledmatrix-weather').enabled === true, srv.installed);
|
||||||
|
ok('says so', sb.toasts.some(t => t.type === 'success' && /installed and enabled/.test(t.message)), sb.toasts);
|
||||||
|
ok('no "Plugin not found"', !sb.toasts.some(t => /not found|failed/.test(t.message)), sb.toasts);
|
||||||
|
const lastList = sb.requests.map(r => r.url).lastIndexOf('/api/v3/plugins/installed');
|
||||||
|
const toggleAt = sb.requests.findIndex(r => r.url === '/api/v3/plugins/toggle');
|
||||||
|
ok('the installed list is reloaded before enabling, so the new card is there to update',
|
||||||
|
lastList >= 0 && lastList < toggleAt, sb.requests.map(r => r.method + ' ' + r.url));
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const { toggles } = await install('weather', { installsAs: 'ledmatrix-weather', names: false });
|
||||||
|
ok('an answer without plugin_id: the installed entry the store entry matches (its alias)',
|
||||||
|
JSON.stringify(toggles) === JSON.stringify([{ plugin_id: 'ledmatrix-weather', enabled: true }]), toggles);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const { toggles } = await install('weather', { installsAs: 'ledmatrix-weather', queue: false });
|
||||||
|
ok('without the operation queue, from the direct answer',
|
||||||
|
JSON.stringify(toggles) === JSON.stringify([{ plugin_id: 'ledmatrix-weather', enabled: true }]), toggles);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const { toggles } = await install('clock-simple', { installsAs: 'clock-simple', names: false });
|
||||||
|
ok('a plugin installed under its registry id is enabled by that id',
|
||||||
|
JSON.stringify(toggles) === JSON.stringify([{ plugin_id: 'clock-simple', enabled: true }]), toggles);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n2. a reinstall leaves the plugin as the user had it');
|
||||||
|
{
|
||||||
|
const { srv, toggles, sb } = await install('weather', {
|
||||||
|
installsAs: 'ledmatrix-weather', installed: [{ id: 'ledmatrix-weather', name: 'Weather', enabled: false }],
|
||||||
|
});
|
||||||
|
ok('sends no toggle', toggles.length === 0, toggles);
|
||||||
|
ok('the plugin stays disabled', srv.installed.find(p => p.id === 'ledmatrix-weather').enabled === false);
|
||||||
|
ok('says it was reinstalled', sb.toasts.some(t => t.type === 'success' && /reinstalled/.test(t.message)), sb.toasts);
|
||||||
|
ok('and reloads the list',
|
||||||
|
sb.requests.some(r => r.url === '/api/v3/plugins/installed'), sb.requests.map(r => r.url));
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const { toggles } = await install('weather', {
|
||||||
|
installsAs: 'ledmatrix-weather', installed: [{ id: 'ledmatrix-weather', name: 'Weather', enabled: true }],
|
||||||
|
});
|
||||||
|
ok('an enabled plugin is not toggled either', toggles.length === 0, toggles);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n${pass} passed, ${fail} failed`);
|
||||||
|
process.exit(fail ? 1 : 0);
|
||||||
|
})().catch(e => { console.error(e); process.exit(1); });
|
||||||
@@ -52,7 +52,8 @@ global.installedPlugins = [];
|
|||||||
// eslint-disable-next-line no-eval
|
// eslint-disable-next-line no-eval
|
||||||
eval([
|
eval([
|
||||||
'function escapeHtml(text) {', 'function escapeAttribute(text) {', 'function jsStringAttr(value) {',
|
'function escapeHtml(text) {', 'function escapeAttribute(text) {', 'function jsStringAttr(value) {',
|
||||||
'function isStorePluginInstalled(pluginIdOrPlugin) {', 'function renderPluginStore(plugins) {',
|
'function isStorePluginInstalled(pluginIdOrPlugin) {',
|
||||||
|
'function findInstalledStorePlugin(pluginIdOrPlugin) {', 'function renderPluginStore(plugins) {',
|
||||||
].map(extract).join('\n') + '\nglobal.renderPluginStore = renderPluginStore;'
|
].map(extract).join('\n') + '\nglobal.renderPluginStore = renderPluginStore;'
|
||||||
+ '\nglobal.isStorePluginInstalled = isStorePluginInstalled;');
|
+ '\nglobal.isStorePluginInstalled = isStorePluginInstalled;');
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ function fakeApi(behaviour = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function setup(api, { stateList, windowList } = {}) {
|
function setup(api, { stateList, windowList, pluginManager } = {}) {
|
||||||
global.window = {
|
global.window = {
|
||||||
PluginAPI: api,
|
PluginAPI: api,
|
||||||
installedPlugins: windowList,
|
installedPlugins: windowList,
|
||||||
@@ -60,6 +60,7 @@ function setup(api, { stateList, windowList } = {}) {
|
|||||||
installedPlugins: stateList,
|
installedPlugins: stateList,
|
||||||
loadInstalledPlugins: async () => stateList,
|
loadInstalledPlugins: async () => stateList,
|
||||||
},
|
},
|
||||||
|
pluginManager,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,12 +93,68 @@ const noSleep = { sleep: async () => {} };
|
|||||||
ok('progress total counts only what is sent',
|
ok('progress total counts only what is sent',
|
||||||
progress.length === EXPECTED.length && progress.every(([, n]) => n === EXPECTED.length), progress);
|
progress.length === EXPECTED.length && progress.every(([, n]) => n === EXPECTED.length), progress);
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
// A page without the plugin manager has no window.installedPlugins.
|
||||||
|
const api = fakeApi();
|
||||||
|
setup(api, { stateList: INSTALLED });
|
||||||
|
await Manager.updateAll(null, noSleep);
|
||||||
|
ok('the PluginStateManager list (no live list) is filtered the same way',
|
||||||
|
JSON.stringify(api.calls) === JSON.stringify(EXPECTED), api.calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\na second run sends the live list, not the first run\'s snapshot');
|
||||||
|
{
|
||||||
|
// Run 1 leaves PluginStateManager holding a, b, c. Then c is uninstalled
|
||||||
|
// and d installed: plugins_manager.js publishes that only as
|
||||||
|
// window.installedPlugins. Run 2 used to send a, b, c -- c failed as
|
||||||
|
// "plugin not found" and d, which had an update waiting, was skipped.
|
||||||
|
const api = fakeApi({
|
||||||
|
c: () => { throw { error_code: 'PLUGIN_UPDATE_FAILED', message: 'Plugin update failed: plugin not found' }; },
|
||||||
|
});
|
||||||
|
const stale = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
|
||||||
|
setup(api, { stateList: stale, windowList: [{ id: 'a' }, { id: 'b' }, { id: 'd' }] });
|
||||||
|
const results = await Manager.updateAll(null, noSleep);
|
||||||
|
ok('sends exactly what is installed now',
|
||||||
|
JSON.stringify(api.calls) === JSON.stringify(['a', 'b', 'd']), api.calls);
|
||||||
|
ok('...so nothing fails over an uninstalled plugin', results.every(r => r.success), results);
|
||||||
|
}
|
||||||
{
|
{
|
||||||
const api = fakeApi();
|
const api = fakeApi();
|
||||||
setup(api, { stateList: INSTALLED, windowList: [] });
|
setup(api, { stateList: INSTALLED, windowList: [] });
|
||||||
|
const results = await Manager.updateAll(null, noSleep);
|
||||||
|
ok('an empty live list means nothing is installed: nothing is sent',
|
||||||
|
api.calls.length === 0 && results.length === 0, api.calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nthe end-of-run refresh redraws the installed grid');
|
||||||
|
{
|
||||||
|
// PluginStateManager's refresh replaced window.installedPlugins and
|
||||||
|
// nothing else: the cards kept "Update to vX" and the Updates badge
|
||||||
|
// kept its count. The plugin manager's load renders the grid.
|
||||||
|
const loads = [];
|
||||||
|
let stateLoads = 0;
|
||||||
|
const pluginManager = { loadInstalledPlugins: async (force) => { loads.push(force); } };
|
||||||
|
setup(fakeApi(), { stateList: INSTALLED, windowList: INSTALLED, pluginManager });
|
||||||
|
window.PluginStateManager.loadInstalledPlugins = async () => { stateLoads++; };
|
||||||
await Manager.updateAll(null, noSleep);
|
await Manager.updateAll(null, noSleep);
|
||||||
ok('the PluginStateManager list is filtered the same way',
|
ok('reloads through the plugin manager once, forced past its caches',
|
||||||
JSON.stringify(api.calls) === JSON.stringify(EXPECTED), api.calls);
|
JSON.stringify(loads) === JSON.stringify([true]), loads);
|
||||||
|
ok('...instead of PluginStateManager', stateLoads === 0, stateLoads);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const pluginManager = { loadInstalledPlugins: async () => { throw new Error('offline'); } };
|
||||||
|
const answer = { status: 'success', data: { update_status: 'updated' }, restart_required: true };
|
||||||
|
setup(fakeApi({ 'ledmatrix-flights': () => answer }), { windowList: INSTALLED, pluginManager });
|
||||||
|
const warn = console.warn;
|
||||||
|
console.warn = () => {};
|
||||||
|
let results;
|
||||||
|
try {
|
||||||
|
results = await Manager.updateAll(null, noSleep);
|
||||||
|
} finally {
|
||||||
|
console.warn = warn;
|
||||||
|
}
|
||||||
|
ok('a failed plugin-manager reload still returns the results with their restart flag',
|
||||||
|
Array.isArray(results) && Manager.restartRequest(results) === answer);
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
const api = fakeApi();
|
const api = fakeApi();
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ manifest.json off disk and reimplemented PluginManager's own fallbacks.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -155,3 +155,14 @@ class TestOneBadConfigSectionDoesNotBlankTheList:
|
|||||||
side_effect=RuntimeError("GET https://x/y?api_key=SEC123 failed"))
|
side_effect=RuntimeError("GET https://x/y?api_key=SEC123 failed"))
|
||||||
body = api_v3_client.get('/api/v3/display/modes').get_json()
|
body = api_v3_client.get('/api/v3/display/modes').get_json()
|
||||||
assert 'SEC123' not in json.dumps(body)
|
assert 'SEC123' not in json.dumps(body)
|
||||||
|
|
||||||
|
|
||||||
|
class TestOnDemandUsesTheRegisteredSpelling:
|
||||||
|
def test_a_mode_differing_in_case_is_sent_as_registered(self, client):
|
||||||
|
with patch('web_interface.blueprints.api_v3.display._deliver_on_demand',
|
||||||
|
return_value=('socket', None)) as deliver:
|
||||||
|
response = client.post('/api/v3/display/on-demand/start',
|
||||||
|
json={'plugin_id': 'football-scoreboard',
|
||||||
|
'mode': 'NFL_LIVE', 'start_service': False})
|
||||||
|
assert response.status_code == 200, response.get_json()
|
||||||
|
assert deliver.call_args.args[0]['mode'] == 'nfl_live'
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"""POST /plugins/install says which id the plugin was installed as.
|
||||||
|
|
||||||
|
A registry entry can install under another id: `weather` (aliases
|
||||||
|
`ledmatrix-weather`) installs a directory whose manifest declares
|
||||||
|
`ledmatrix-weather`, and that is the id the plugin list, the plugin's config
|
||||||
|
section and /plugins/toggle know it by. The store's Install button enabled
|
||||||
|
the new plugin by the registry id, which /plugins/toggle answered with 404
|
||||||
|
"Plugin not found", so Weather, Music, Stocks and Leaderboard installed
|
||||||
|
disabled behind an "enabling it failed" warning.
|
||||||
|
|
||||||
|
The answer -- the queued operation's result, or the direct response --
|
||||||
|
carries `plugin_id`: the id the installed manifest declares, found the way
|
||||||
|
the store's update and uninstall find an install.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401
|
||||||
|
|
||||||
|
INSTALL = "/api/v3/plugins/install"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def store(api_v3_module, tmp_path):
|
||||||
|
manager = api_v3_module.api_v3.plugin_store_manager
|
||||||
|
manager.install_plugin.return_value = True
|
||||||
|
manager.get_registry_info.return_value = None
|
||||||
|
manager._find_plugin_path.return_value = None
|
||||||
|
|
||||||
|
def installed_as(directory, manifest):
|
||||||
|
path = tmp_path / directory
|
||||||
|
path.mkdir()
|
||||||
|
(path / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
|
||||||
|
manager._find_plugin_path.side_effect = (
|
||||||
|
lambda pid: path if pid == "weather" else None)
|
||||||
|
return path
|
||||||
|
|
||||||
|
manager.installed_as = installed_as
|
||||||
|
return manager
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def queued(api_v3_module):
|
||||||
|
queue = MagicMock()
|
||||||
|
|
||||||
|
def enqueue(operation_type, plugin_id, operation_callback=None):
|
||||||
|
queue.callback_result = operation_callback(MagicMock())
|
||||||
|
return "op-1"
|
||||||
|
|
||||||
|
queue.enqueue_operation.side_effect = enqueue
|
||||||
|
api_v3_module.api_v3.operation_queue = queue
|
||||||
|
return queue
|
||||||
|
|
||||||
|
|
||||||
|
class TestDirectInstall:
|
||||||
|
def test_an_aliased_entry_reports_the_manifest_id(self, api_v3_client, store):
|
||||||
|
store.installed_as("ledmatrix-weather", {"id": "ledmatrix-weather"})
|
||||||
|
body = api_v3_client.post(INSTALL, json={"plugin_id": "weather"}).get_json()
|
||||||
|
assert body["status"] == "success"
|
||||||
|
assert body["plugin_id"] == "ledmatrix-weather"
|
||||||
|
store._find_plugin_path.assert_called_with("weather")
|
||||||
|
|
||||||
|
def test_an_entry_installed_under_its_own_id_reports_that(self, api_v3_client, store):
|
||||||
|
store.installed_as("weather", {"id": "weather"})
|
||||||
|
body = api_v3_client.post(INSTALL, json={"plugin_id": "weather"}).get_json()
|
||||||
|
assert body["plugin_id"] == "weather"
|
||||||
|
|
||||||
|
def test_an_install_that_cannot_be_found_reports_the_requested_id(self, api_v3_client, store):
|
||||||
|
body = api_v3_client.post(INSTALL, json={"plugin_id": "weather"}).get_json()
|
||||||
|
assert body["status"] == "success"
|
||||||
|
assert body["plugin_id"] == "weather"
|
||||||
|
|
||||||
|
def test_a_manifest_id_that_is_not_a_plain_name_is_not_passed_on(self, api_v3_client, store):
|
||||||
|
store.installed_as("ledmatrix-weather", {"id": "../elsewhere"})
|
||||||
|
body = api_v3_client.post(INSTALL, json={"plugin_id": "weather"}).get_json()
|
||||||
|
assert body["plugin_id"] == "weather"
|
||||||
|
|
||||||
|
def test_an_unreadable_manifest_reports_the_requested_id(self, api_v3_client, store):
|
||||||
|
path = store.installed_as("ledmatrix-weather", {})
|
||||||
|
(path / "manifest.json").write_text("[not json", encoding="utf-8")
|
||||||
|
body = api_v3_client.post(INSTALL, json={"plugin_id": "weather"}).get_json()
|
||||||
|
assert body["plugin_id"] == "weather"
|
||||||
|
|
||||||
|
def test_the_restart_fields_are_still_sent(self, api_v3_client, store):
|
||||||
|
store.installed_as("ledmatrix-weather", {"id": "ledmatrix-weather"})
|
||||||
|
body = api_v3_client.post(INSTALL, json={"plugin_id": "weather"}).get_json()
|
||||||
|
assert "restart_required" in body
|
||||||
|
|
||||||
|
|
||||||
|
class TestQueuedInstall:
|
||||||
|
def test_the_operation_result_names_the_manifest_id(self, api_v3_client, store, queued):
|
||||||
|
store.installed_as("ledmatrix-weather", {"id": "ledmatrix-weather"})
|
||||||
|
body = api_v3_client.post(INSTALL, json={"plugin_id": "weather"}).get_json()
|
||||||
|
assert body["data"]["operation_id"] == "op-1"
|
||||||
|
assert queued.callback_result["success"] is True
|
||||||
|
assert queued.callback_result["plugin_id"] == "ledmatrix-weather"
|
||||||
|
|
||||||
|
def test_an_install_that_cannot_be_found_names_the_requested_id(
|
||||||
|
self, api_v3_client, store, queued):
|
||||||
|
api_v3_client.post(INSTALL, json={"plugin_id": "weather"})
|
||||||
|
assert queued.callback_result["plugin_id"] == "weather"
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""GET /api/v3/plugins/installed carries each plugin's ``display_modes``.
|
||||||
|
|
||||||
|
The on-demand modal (plugins_manager.js) fills its Display Mode list from
|
||||||
|
``plugin.display_modes``, but the route never included the field, so every
|
||||||
|
plugin offered one option -- its own id -- under "This plugin exposes a
|
||||||
|
single display mode". The display turns that id into the plugin's first
|
||||||
|
mode, so a multi-mode plugin could only be started, and pinned, on that one.
|
||||||
|
|
||||||
|
The modes come from the plugin catalog (the manifests the web process
|
||||||
|
discovered), the same source /display/modes and on-demand/start use.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from test._api_v3_test_helpers import ( # noqa: F401 - fixtures
|
||||||
|
api_v3_client, api_v3_module,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def installed(api_v3_module, api_v3_client, tmp_path):
|
||||||
|
def _get(declared_modes):
|
||||||
|
api = api_v3_module.api_v3
|
||||||
|
# The listing's own metadata says nothing about modes: what the
|
||||||
|
# route reports must come from the catalog.
|
||||||
|
info = {'id': 'football-scoreboard', 'name': 'Football', 'version': '1.0.0'}
|
||||||
|
api.plugin_catalog.plugins_dir = str(tmp_path) # no manifest on disk
|
||||||
|
api.plugin_catalog.get_all_plugin_info = MagicMock(return_value=[info])
|
||||||
|
api.plugin_catalog.get_plugin_display_modes = MagicMock(return_value=declared_modes)
|
||||||
|
api.plugin_store_manager.get_cached_registry_info = MagicMock(return_value=None)
|
||||||
|
api.config_manager.load_config = MagicMock(return_value={})
|
||||||
|
response = api_v3_client.get('/api/v3/plugins/installed')
|
||||||
|
assert response.status_code == 200
|
||||||
|
plugins = [p for p in response.get_json()['data']['plugins']
|
||||||
|
if p['id'] == 'football-scoreboard']
|
||||||
|
assert len(plugins) == 1
|
||||||
|
api.plugin_catalog.get_plugin_display_modes.assert_any_call('football-scoreboard')
|
||||||
|
return plugins[0]
|
||||||
|
return _get
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_declared_mode_is_listed_in_order(installed):
|
||||||
|
modes = ['nfl_live', 'nfl_recent', 'nfl_upcoming']
|
||||||
|
assert installed(modes)['display_modes'] == modes
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_single_mode_plugin_lists_its_one_mode(installed):
|
||||||
|
assert installed(['clock-simple'])['display_modes'] == ['clock-simple']
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_declared_modes_is_an_empty_list(installed):
|
||||||
|
# The modal falls back to the plugin id for an empty list.
|
||||||
|
assert installed([])['display_modes'] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_hand_edited_manifest_cannot_put_non_strings_in_the_list(installed):
|
||||||
|
assert installed(['nfl_live', 7, None, {'x': 1}])['display_modes'] == ['nfl_live']
|
||||||
@@ -22,7 +22,7 @@ def installed(api_v3_module, api_v3_client, tmp_path):
|
|||||||
info.update(manifest_extra)
|
info.update(manifest_extra)
|
||||||
api.plugin_catalog.plugins_dir = str(tmp_path) # no manifest on disk
|
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_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={})
|
api.config_manager.load_config = MagicMock(return_value={})
|
||||||
response = api_v3_client.get('/api/v3/plugins/installed')
|
response = api_v3_client.get('/api/v3/plugins/installed')
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401
|
|||||||
START_URL = "/api/v3/display/on-demand/start"
|
START_URL = "/api/v3/display/on-demand/start"
|
||||||
STOP_URL = "/api/v3/display/on-demand/stop"
|
STOP_URL = "/api/v3/display/on-demand/stop"
|
||||||
MAILBOX = "display_on_demand_request"
|
MAILBOX = "display_on_demand_request"
|
||||||
|
DISPLAY = "web_interface.blueprints.api_v3.display"
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -150,6 +151,102 @@ class TestStartWhileTheServiceIsStopped:
|
|||||||
assert response.get_json()["status"] == "error"
|
assert response.get_json()["status"] == "error"
|
||||||
|
|
||||||
|
|
||||||
|
class _Mailbox:
|
||||||
|
"""The CacheManager calls the routes make, over a dict."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.entries = {}
|
||||||
|
|
||||||
|
def set(self, key, value, ttl=None):
|
||||||
|
self.entries[key] = value
|
||||||
|
|
||||||
|
def get(self, key, max_age=300, memory_ttl=None):
|
||||||
|
return self.entries.get(key)
|
||||||
|
|
||||||
|
def delete(self, key):
|
||||||
|
self.entries.pop(key, None)
|
||||||
|
|
||||||
|
|
||||||
|
class TestARefusedStartLeavesNoRequestBehind:
|
||||||
|
"""A start the route answers with an error must not run later.
|
||||||
|
|
||||||
|
The request was posted (to the mailbox, with the display stopped) before
|
||||||
|
the route refused it, and the display reads the mailbox for an hour
|
||||||
|
without looking at a request's age. So "Display service is not running"
|
||||||
|
(start_service off) or "Failed to start display service" left the
|
||||||
|
request waiting, and the next time the display started -- minutes later,
|
||||||
|
by hand -- it ran that plugin, pinned if the request said so.
|
||||||
|
|
||||||
|
A socket acknowledgement is the other side of it: the display answered,
|
||||||
|
so it is running and has the request, whatever systemd says (a display
|
||||||
|
run by hand or in the emulator has no active unit). That is a success,
|
||||||
|
not "not running", and no unit is started beside it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mailbox(self, api_v3_module, service):
|
||||||
|
box = _Mailbox()
|
||||||
|
api_v3_module.api_v3.cache_manager = box
|
||||||
|
service["state"]["active"] = False
|
||||||
|
return box
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("body", [
|
||||||
|
{"plugin_id": "weather", "start_service": False},
|
||||||
|
{"plugin_id": "weather"}, # start_service defaults on
|
||||||
|
])
|
||||||
|
def test_a_socket_ack_is_a_success_whatever_systemd_says(
|
||||||
|
self, api_v3_client, service, mailbox, body):
|
||||||
|
with patch(f"{DISPLAY}.control_client.on_demand_start",
|
||||||
|
side_effect=lambda request_id, *a: {"accepted": True}):
|
||||||
|
response = api_v3_client.post(START_URL, json=body)
|
||||||
|
assert response.status_code == 200, response.get_json()
|
||||||
|
assert response.get_json()["data"]["transport"] == "socket"
|
||||||
|
assert MAILBOX not in mailbox.entries
|
||||||
|
assert _systemctl_verbs(service["systemctl"]) == [], (
|
||||||
|
"a unit was started beside a display that answered the socket")
|
||||||
|
|
||||||
|
def test_without_start_service_the_request_is_taken_back(
|
||||||
|
self, api_v3_client, service, mailbox):
|
||||||
|
response = api_v3_client.post(START_URL, json={
|
||||||
|
"plugin_id": "weather", "pinned": True, "start_service": False})
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.get_json()["status"] == "error"
|
||||||
|
assert MAILBOX not in mailbox.entries
|
||||||
|
|
||||||
|
def test_a_start_that_fails_takes_its_request_back(self, api_v3_client, service, mailbox):
|
||||||
|
service["systemctl"].side_effect = lambda args: {
|
||||||
|
"returncode": 1, "stdout": "", "stderr": "denied"}
|
||||||
|
response = api_v3_client.post(START_URL, json={"plugin_id": "weather"})
|
||||||
|
assert response.status_code == 500
|
||||||
|
assert MAILBOX not in mailbox.entries
|
||||||
|
|
||||||
|
def test_a_newer_request_is_left_alone_on_the_400(self, api_v3_client, service, mailbox):
|
||||||
|
newer = {"request_id": "someone-else", "action": "start", "plugin_id": "clock"}
|
||||||
|
|
||||||
|
def stopped_and_another_post_lands(*args):
|
||||||
|
mailbox.entries[MAILBOX] = newer
|
||||||
|
return {"active": False}
|
||||||
|
|
||||||
|
with patch(f"{DISPLAY}._get_display_service_status",
|
||||||
|
side_effect=stopped_and_another_post_lands):
|
||||||
|
response = api_v3_client.post(START_URL, json={
|
||||||
|
"plugin_id": "weather", "start_service": False})
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert mailbox.entries[MAILBOX] is newer
|
||||||
|
|
||||||
|
def test_a_newer_request_is_left_alone_on_the_500(self, api_v3_client, service, mailbox):
|
||||||
|
newer = {"request_id": "someone-else", "action": "start", "plugin_id": "clock"}
|
||||||
|
|
||||||
|
def start_fails_after_another_post(args):
|
||||||
|
mailbox.entries[MAILBOX] = newer
|
||||||
|
return {"returncode": 1, "stdout": "", "stderr": "denied"}
|
||||||
|
|
||||||
|
service["systemctl"].side_effect = start_fails_after_another_post
|
||||||
|
response = api_v3_client.post(START_URL, json={"plugin_id": "weather"})
|
||||||
|
assert response.status_code == 500
|
||||||
|
assert mailbox.entries[MAILBOX] is newer
|
||||||
|
|
||||||
|
|
||||||
class TestStop:
|
class TestStop:
|
||||||
def test_stop_posts_a_stop_request_and_leaves_the_service_running(
|
def test_stop_posts_a_stop_request_and_leaves_the_service_running(
|
||||||
self, api_v3_client, service):
|
self, api_v3_client, service):
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
"""POST /display/on-demand/start and /stop: control socket first, mailbox fallback.
|
"""POST /display/on-demand/start and /stop: control socket first, mailbox fallback.
|
||||||
|
|
||||||
The routes hand the request to the display over the control socket
|
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
|
(src/ipc) and get an acknowledgement. When the socket could not carry the
|
||||||
display, or one older than the socket), a timeout, a refusal, a bug in the
|
request -- no socket (a stopped display, or one older than the socket), a
|
||||||
client -- they write the file mailbox exactly as they did before the socket
|
refused or timed-out connect, a display too old to know the command, a bug
|
||||||
existed. These tests pin both paths, that exactly one of them is used, that
|
in the client -- they write the file mailbox exactly as they did before the
|
||||||
|
socket existed. When the display had the request and failed it (a full
|
||||||
|
queue, bad arguments, no answer in time) the route says so and writes
|
||||||
|
nothing. These tests pin each path, that at most one of them is used, that
|
||||||
the response says which, and that the request id is the same either way (the
|
the response says which, and that the request id is the same either way (the
|
||||||
display deduplicates on it).
|
display deduplicates on it).
|
||||||
|
|
||||||
@@ -101,12 +104,15 @@ class TestSocketPath:
|
|||||||
class TestMailboxFallback:
|
class TestMailboxFallback:
|
||||||
@pytest.mark.parametrize("reason", [
|
@pytest.mark.parametrize("reason", [
|
||||||
"no_socket", "refused", "timeout", "closed", "bad_response", "invalid_request",
|
"no_socket", "refused", "timeout", "closed", "bad_response", "invalid_request",
|
||||||
"busy", "unknown_command", "unsupported_version", "disabled", "unsupported",
|
"busy", "forbidden", "unknown_command", "unsupported_version", "disabled",
|
||||||
|
"unsupported",
|
||||||
])
|
])
|
||||||
def test_any_socket_failure_writes_the_mailbox_as_before(
|
def test_a_request_the_socket_never_carried_writes_the_mailbox(
|
||||||
self, api_v3_client, service, reason):
|
self, api_v3_client, service, reason):
|
||||||
|
# sent=False: the display never had it (no socket, a refused or
|
||||||
|
# timed-out connect, turned away at the door).
|
||||||
with patch(f"{CLIENT}.on_demand_start",
|
with patch(f"{CLIENT}.on_demand_start",
|
||||||
side_effect=control_client.ControlError(reason, "x")):
|
side_effect=control_client.ControlError(reason, "x", sent=False)):
|
||||||
resp = api_v3_client.post(START_URL, json={
|
resp = api_v3_client.post(START_URL, json={
|
||||||
"plugin_id": "weather", "mode": "weather_current",
|
"plugin_id": "weather", "mode": "weather_current",
|
||||||
"duration": 60, "pinned": True})
|
"duration": 60, "pinned": True})
|
||||||
@@ -168,6 +174,60 @@ class TestMailboxFallback:
|
|||||||
assert data["socket_error"] in ("disabled", "unsupported") # Linux, Windows
|
assert data["socket_error"] in ("disabled", "unsupported") # Linux, Windows
|
||||||
|
|
||||||
|
|
||||||
|
class TestTheDisplayHadIt:
|
||||||
|
"""Once the display has the request, its answer stands: no mailbox copy.
|
||||||
|
|
||||||
|
A busy queue, a refusal or silence after the request was sent mean the
|
||||||
|
display may have applied it, or would refuse the mailbox copy too, so
|
||||||
|
the route reports the failure instead of posting it a second time.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("reason,status", [
|
||||||
|
("busy", 503), ("internal", 503), ("timeout", 503), ("closed", 503),
|
||||||
|
("bad_response", 503), ("invalid_args", 400),
|
||||||
|
])
|
||||||
|
def test_start_is_answered_with_the_failure(self, api_v3_client, service, reason, status):
|
||||||
|
with patch(f"{CLIENT}.on_demand_start",
|
||||||
|
side_effect=control_client.ControlError(reason, "x", sent=True)):
|
||||||
|
resp = api_v3_client.post(START_URL, json={"plugin_id": "weather"})
|
||||||
|
assert resp.status_code == status
|
||||||
|
body = resp.get_json()
|
||||||
|
assert body["status"] == "error"
|
||||||
|
assert body["data"]["transport"] == "socket"
|
||||||
|
assert body["data"]["socket_error"] == reason
|
||||||
|
assert _mailbox_writes(service["cache"]) == []
|
||||||
|
assert not [call for call in service["calls"] if call[0] == "systemctl"]
|
||||||
|
|
||||||
|
def test_stop_is_answered_with_the_failure(self, api_v3_client, service):
|
||||||
|
with patch(f"{CLIENT}.on_demand_stop",
|
||||||
|
side_effect=control_client.ControlError("busy", "x", sent=True)):
|
||||||
|
resp = api_v3_client.post(STOP_URL, json={})
|
||||||
|
assert resp.status_code == 503
|
||||||
|
assert _mailbox_writes(service["cache"]) == []
|
||||||
|
|
||||||
|
def test_a_stop_with_stop_service_still_stops_the_service(self, api_v3_client, service):
|
||||||
|
with patch(f"{CLIENT}.on_demand_stop",
|
||||||
|
side_effect=control_client.ControlError("timeout", "x", sent=True)), \
|
||||||
|
patch("web_interface.blueprints.api_v3.display._stop_display_service",
|
||||||
|
return_value={"active": False}) as stop:
|
||||||
|
resp = api_v3_client.post(STOP_URL, json={"stop_service": True})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.get_json()["data"]
|
||||||
|
assert data["transport"] == "socket" and data["socket_error"] == "timeout"
|
||||||
|
stop.assert_called_once()
|
||||||
|
assert _mailbox_writes(service["cache"]) == []
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("reason", ["unknown_command", "unsupported_version"])
|
||||||
|
def test_an_older_display_that_does_not_speak_it_gets_the_mailbox(
|
||||||
|
self, api_v3_client, service, reason):
|
||||||
|
# The upgrade case: new web interface, display still on an old build.
|
||||||
|
with patch(f"{CLIENT}.on_demand_start",
|
||||||
|
side_effect=control_client.ControlError(reason, "x", sent=True)):
|
||||||
|
data = api_v3_client.post(START_URL, json={"plugin_id": "weather"}).get_json()["data"]
|
||||||
|
assert data["transport"] == "mailbox" and data["socket_error"] == reason
|
||||||
|
assert len(_mailbox_writes(service["cache"])) == 1
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(not c.socket_supported(), reason="AF_UNIX sockets are Linux/macOS only")
|
@pytest.mark.skipif(not c.socket_supported(), reason="AF_UNIX sockets are Linux/macOS only")
|
||||||
class TestRealSocket:
|
class TestRealSocket:
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -204,3 +264,23 @@ class TestRealSocket:
|
|||||||
data = api_v3_client.post(START_URL, json={"plugin_id": "weather"}).get_json()["data"]
|
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 data["transport"] == "mailbox" and data["socket_error"] == "no_socket"
|
||||||
assert len(_mailbox_writes(service["cache"])) == 1
|
assert len(_mailbox_writes(service["cache"])) == 1
|
||||||
|
|
||||||
|
def test_a_full_queue_is_reported_not_mailed(self, api_v3_client, service, monkeypatch):
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
from src.ipc.server import ControlServer
|
||||||
|
d = tempfile.mkdtemp(prefix="lmipc-")
|
||||||
|
path = os.path.join(d, "control.sock")
|
||||||
|
server = ControlServer(path, status_provider=dict, queue_size=1)
|
||||||
|
assert server.start()
|
||||||
|
monkeypatch.setenv(c.SOCKET_PATH_ENV, path)
|
||||||
|
try:
|
||||||
|
first = api_v3_client.post(START_URL, json={"plugin_id": "weather"})
|
||||||
|
assert first.get_json()["data"]["transport"] == "socket"
|
||||||
|
second = api_v3_client.post(START_URL, json={"plugin_id": "weather"})
|
||||||
|
assert second.status_code == 503
|
||||||
|
assert second.get_json()["data"]["socket_error"] == "busy"
|
||||||
|
assert _mailbox_writes(service["cache"]) == []
|
||||||
|
finally:
|
||||||
|
server.close()
|
||||||
|
shutil.rmtree(d, ignore_errors=True)
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""GET /api/v3/plugins/operation/<id> answers for an operation still waiting.
|
||||||
|
|
||||||
|
PluginOperationQueue keeps an operation's callback in its parameters, under
|
||||||
|
``_callback``, until the worker takes it to run. PluginOperation.to_dict()
|
||||||
|
returned the parameters as they were, so for a pending operation the route
|
||||||
|
handed jsonify a function and answered 500 "A system error occurred". That
|
||||||
|
is every poll of an install queued behind another plugin's: the second of
|
||||||
|
two installs read as broken until the first one finished.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
||||||
|
|
||||||
|
from src.plugin_system.operation_queue import PluginOperationQueue # noqa: E402
|
||||||
|
from src.plugin_system.operation_types import ( # noqa: E402
|
||||||
|
OperationType, PluginOperation,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _callback(op):
|
||||||
|
return {"success": True, "message": "done"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestToDict:
|
||||||
|
def test_private_parameters_are_left_out(self):
|
||||||
|
op = PluginOperation(OperationType.INSTALL, "demo",
|
||||||
|
parameters={"_callback": _callback, "branch": "main"})
|
||||||
|
assert op.to_dict()["parameters"] == {"branch": "main"}
|
||||||
|
json.dumps(op.to_dict()) # serializable
|
||||||
|
|
||||||
|
def test_the_operation_keeps_its_callback_for_the_worker(self):
|
||||||
|
op = PluginOperation(OperationType.INSTALL, "demo",
|
||||||
|
parameters={"_callback": _callback})
|
||||||
|
op.to_dict()
|
||||||
|
assert op.parameters["_callback"] is _callback
|
||||||
|
|
||||||
|
def test_the_other_fields_are_unchanged(self):
|
||||||
|
op = PluginOperation(OperationType.UNINSTALL, "demo", operation_id="op-1")
|
||||||
|
assert op.to_dict() == {
|
||||||
|
"operation_id": "op-1", "operation_type": "uninstall", "plugin_id": "demo",
|
||||||
|
"parameters": {}, "status": "pending", "progress": 0.0, "message": "",
|
||||||
|
"error": None, "result": None,
|
||||||
|
"created_at": op.created_at.isoformat(), "started_at": None,
|
||||||
|
"completed_at": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestTheRoute:
|
||||||
|
@pytest.fixture
|
||||||
|
def busy_queue(self, api_v3_module):
|
||||||
|
"""A real queue whose worker is held by another plugin's operation."""
|
||||||
|
queue = PluginOperationQueue(max_history=10)
|
||||||
|
api_v3_module.api_v3.operation_queue = queue
|
||||||
|
started, release = threading.Event(), threading.Event()
|
||||||
|
|
||||||
|
def blocker(op):
|
||||||
|
started.set()
|
||||||
|
release.wait(10)
|
||||||
|
return {"success": True, "message": "done"}
|
||||||
|
|
||||||
|
queue.enqueue_operation(OperationType.INSTALL, "busy", operation_callback=blocker)
|
||||||
|
assert started.wait(5)
|
||||||
|
yield queue
|
||||||
|
release.set()
|
||||||
|
queue.shutdown()
|
||||||
|
|
||||||
|
def test_a_pending_operation_reports_pending(self, api_v3_client, busy_queue):
|
||||||
|
op_id = busy_queue.enqueue_operation(
|
||||||
|
OperationType.INSTALL, "demo", operation_callback=_callback)
|
||||||
|
response = api_v3_client.get(f"/api/v3/plugins/operation/{op_id}")
|
||||||
|
assert response.status_code == 200, response.get_json()
|
||||||
|
data = response.get_json()["data"]
|
||||||
|
assert data["status"] == "pending"
|
||||||
|
assert data["plugin_id"] == "demo"
|
||||||
|
assert "_callback" not in data["parameters"]
|
||||||
@@ -253,6 +253,32 @@ class TestVegasCycleDurations:
|
|||||||
assert saved['config']['display']['display_durations'] == {'clock': 45}
|
assert saved['config']['display']['display_durations'] == {'clock': 45}
|
||||||
|
|
||||||
|
|
||||||
|
class TestMalformedBody:
|
||||||
|
"""A JSON body that does not parse is the caller's mistake: a 400.
|
||||||
|
|
||||||
|
get_json() raised Werkzeug's BadRequest inside the handler's try, whose
|
||||||
|
catch-all answered 500 CONFIG_SAVE_FAILED with "check file permissions"
|
||||||
|
advice and logged a traceback at ERROR.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_is_a_400_in_the_raw_routes_shape(self, api_v3_client, saved, api_v3_module):
|
||||||
|
api_v3_module.api_v3.config_manager.get_raw_file_content.return_value = {}
|
||||||
|
resp = api_v3_client.post('/api/v3/config/main', data='{not json',
|
||||||
|
content_type='application/json')
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert resp.get_json() == {'status': 'error', 'message': 'Invalid JSON in request body'}
|
||||||
|
assert 'config' not in saved
|
||||||
|
raw = api_v3_client.post('/api/v3/config/raw/main', data='{not json',
|
||||||
|
content_type='application/json')
|
||||||
|
assert (raw.status_code, raw.get_json()) == (400, resp.get_json())
|
||||||
|
|
||||||
|
def test_an_empty_json_post_is_still_no_data(self, api_v3_client, saved):
|
||||||
|
resp = api_v3_client.post('/api/v3/config/main', data='',
|
||||||
|
content_type='application/json')
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert resp.get_json()['message'] == 'No data provided'
|
||||||
|
|
||||||
|
|
||||||
class TestRawSaveStartsAutoUpdateSetup:
|
class TestRawSaveStartsAutoUpdateSetup:
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def raw_env(self, api_v3_module, monkeypatch):
|
def raw_env(self, api_v3_module, monkeypatch):
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""POST /api/v3/plugins/action hands ``params`` to the plugin's script intact.
|
||||||
|
|
||||||
|
The route runs the script through a generated wrapper, and the params went
|
||||||
|
into that wrapper as Python source: ``params = {json.dumps(params)}``. JSON is
|
||||||
|
not Python. ``true``, ``false`` and ``null`` are undefined names there, so any
|
||||||
|
params holding a boolean or a null died with a NameError before the script
|
||||||
|
ran. The plugin file manager's category toggle sends ``{"category_name": ...,
|
||||||
|
"enabled": true}``, so of-the-day's category toggle failed every time with
|
||||||
|
"Action failed".
|
||||||
|
|
||||||
|
The script's side of the contract is unchanged and pinned here too: the
|
||||||
|
params arrive on stdin as one JSON document, LEDMATRIX_ROOT is set, and what
|
||||||
|
the script prints to stdout is what the route parses.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
||||||
|
|
||||||
|
ACTION_URL = "/api/v3/plugins/action"
|
||||||
|
|
||||||
|
# The action script: report what it was handed, as JSON on stdout.
|
||||||
|
ECHO_SCRIPT = (
|
||||||
|
"import json, os, sys\n"
|
||||||
|
"raw = sys.stdin.read()\n"
|
||||||
|
"print(json.dumps({'status': 'success', 'got': json.loads(raw),\n"
|
||||||
|
" 'root': os.environ.get('LEDMATRIX_ROOT')}))\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def echo_plugin(tmp_path, api_v3_module, monkeypatch):
|
||||||
|
plugin_dir = tmp_path / "demo"
|
||||||
|
plugin_dir.mkdir()
|
||||||
|
(plugin_dir / "manifest.json").write_text(json.dumps({
|
||||||
|
"id": "demo",
|
||||||
|
"web_ui_actions": [{"id": "toggle", "type": "script", "script": "echo.py"}],
|
||||||
|
}), encoding="utf-8")
|
||||||
|
(plugin_dir / "echo.py").write_text(ECHO_SCRIPT, encoding="utf-8")
|
||||||
|
api_v3_module.api_v3.plugin_catalog.get_plugin_directory.return_value = str(plugin_dir)
|
||||||
|
|
||||||
|
# The route runs `python3`; use this interpreter, so the test does not
|
||||||
|
# depend on what that name resolves to here.
|
||||||
|
real_run = subprocess.run
|
||||||
|
|
||||||
|
def run(cmd, *args, **kwargs):
|
||||||
|
if isinstance(cmd, list) and cmd and cmd[0] == "python3":
|
||||||
|
cmd = [sys.executable] + cmd[1:]
|
||||||
|
return real_run(cmd, *args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(subprocess, "run", run)
|
||||||
|
return plugin_dir
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("params", [
|
||||||
|
{"category_name": "jokes", "enabled": True}, # the file manager's toggle
|
||||||
|
{"category_name": "jokes", "enabled": False},
|
||||||
|
{"filename": None},
|
||||||
|
{"nested": {"list": [1, None, True, 2.5], "empty": {}}},
|
||||||
|
{"text": "café ✓ \U0001F600"},
|
||||||
|
{"text": "he said \"hi\" and 'bye' \\ ''' \"\"\" \n\t end"},
|
||||||
|
], ids=["true", "false", "null", "nested", "unicode", "quotes"])
|
||||||
|
def test_the_script_receives_the_params_it_was_sent(api_v3_client, echo_plugin, params):
|
||||||
|
response = api_v3_client.post(ACTION_URL, json={
|
||||||
|
"plugin_id": "demo", "action_id": "toggle", "params": params})
|
||||||
|
body = response.get_json()
|
||||||
|
assert response.status_code == 200, body
|
||||||
|
assert body["got"] == params
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_param_cannot_run_code_in_the_wrapper(api_v3_client, echo_plugin, tmp_path):
|
||||||
|
marker = tmp_path / "PWNED"
|
||||||
|
hostile = "\"}\nopen(%r, 'w').write('ran')\n#" % str(marker)
|
||||||
|
params = {"name": hostile, "flag": True}
|
||||||
|
response = api_v3_client.post(ACTION_URL, json={
|
||||||
|
"plugin_id": "demo", "action_id": "toggle", "params": params})
|
||||||
|
assert response.status_code == 200, response.get_json()
|
||||||
|
assert response.get_json()["got"] == params
|
||||||
|
assert not marker.exists(), "a param value ran as code"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_script_still_gets_ledmatrix_root(api_v3_client, echo_plugin, api_v3_module):
|
||||||
|
response = api_v3_client.post(ACTION_URL, json={
|
||||||
|
"plugin_id": "demo", "action_id": "toggle", "params": {"enabled": True}})
|
||||||
|
assert response.status_code == 200, response.get_json()
|
||||||
|
assert response.get_json()["root"] == str(api_v3_module.PROJECT_ROOT)
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""A second install or uninstall while one is in progress is a 409, not a 500.
|
||||||
|
|
||||||
|
PluginOperationQueue refuses a second operation for a plugin that already
|
||||||
|
has one waiting or running (test_operation_queue_pending_and_trim.py), and
|
||||||
|
says so by raising ValueError. /plugins/install let that escape to the
|
||||||
|
blueprint's catch-all, so a double-clicked Install answered 500 "An error
|
||||||
|
occurred; see logs for details" while the first install carried on.
|
||||||
|
/plugins/uninstall caught it in its own catch-all: a 500 "Failed to
|
||||||
|
uninstall plugin", and an "uninstall failed" entry in the operation
|
||||||
|
history for an uninstall that never started.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
||||||
|
|
||||||
|
from src.plugin_system.operation_queue import PluginOperationQueue # noqa: E402
|
||||||
|
|
||||||
|
INSTALL = "/api/v3/plugins/install"
|
||||||
|
UNINSTALL = "/api/v3/plugins/uninstall"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def installing(api_v3_module, tmp_path):
|
||||||
|
"""A real queue with an install of "clock" running and held there."""
|
||||||
|
queue = PluginOperationQueue(max_history=10)
|
||||||
|
api_v3_module.api_v3.operation_queue = queue
|
||||||
|
started, release = threading.Event(), threading.Event()
|
||||||
|
|
||||||
|
def slow_install(plugin_id, branch=None):
|
||||||
|
started.set()
|
||||||
|
release.wait(10)
|
||||||
|
return True
|
||||||
|
|
||||||
|
store = api_v3_module.api_v3.plugin_store_manager
|
||||||
|
store.install_plugin.side_effect = slow_install
|
||||||
|
store.get_registry_info.return_value = None
|
||||||
|
store.plugins_dir = str(tmp_path)
|
||||||
|
api_v3_module.api_v3.plugin_catalog.get_plugin_directory.return_value = None
|
||||||
|
yield {"queue": queue, "started": started, "store": store}
|
||||||
|
release.set()
|
||||||
|
queue.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
def _start_first_install(client, installing):
|
||||||
|
response = client.post(INSTALL, json={"plugin_id": "clock"})
|
||||||
|
assert response.status_code == 200, response.get_json()
|
||||||
|
assert installing["started"].wait(5)
|
||||||
|
|
||||||
|
|
||||||
|
def _failed_history(api_v3_module):
|
||||||
|
return [c for c in api_v3_module.api_v3.operation_history.record_operation.call_args_list
|
||||||
|
if c.kwargs.get("status") == "failed"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_second_install_click_is_a_conflict(api_v3_client, api_v3_module, installing):
|
||||||
|
_start_first_install(api_v3_client, installing)
|
||||||
|
response = api_v3_client.post(INSTALL, json={"plugin_id": "clock"})
|
||||||
|
assert response.status_code == 409, response.get_json()
|
||||||
|
body = response.get_json()
|
||||||
|
assert body["status"] == "error"
|
||||||
|
assert body["error_code"] == "PLUGIN_OPERATION_CONFLICT"
|
||||||
|
assert "clock" in body["message"]
|
||||||
|
assert installing["store"].install_plugin.call_count == 1
|
||||||
|
assert _failed_history(api_v3_module) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_uninstall_during_the_install_is_a_conflict(api_v3_client, api_v3_module,
|
||||||
|
installing):
|
||||||
|
_start_first_install(api_v3_client, installing)
|
||||||
|
response = api_v3_client.post(UNINSTALL, json={"plugin_id": "clock"})
|
||||||
|
assert response.status_code == 409, response.get_json()
|
||||||
|
assert response.get_json()["error_code"] == "PLUGIN_OPERATION_CONFLICT"
|
||||||
|
assert _failed_history(api_v3_module) == [], (
|
||||||
|
"an uninstall that never started was recorded as failed")
|
||||||
|
api_v3_module.api_v3.plugin_store_manager.uninstall_plugin.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_another_plugin_is_still_queued(api_v3_client, installing):
|
||||||
|
_start_first_install(api_v3_client, installing)
|
||||||
|
response = api_v3_client.post(INSTALL, json={"plugin_id": "weather"})
|
||||||
|
assert response.status_code == 200, response.get_json()
|
||||||
|
assert response.get_json()["data"]["operation_id"]
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""GET /api/v3/plugins/<plugin_id>/static/<path> serves binary files too.
|
||||||
|
|
||||||
|
The route opened every file as UTF-8 text, so an image -- what the API
|
||||||
|
reference says it is for, plugin previews and icons -- failed to decode and
|
||||||
|
answered 500 "UnicodeDecodeError". Files are now sent as bytes. The text
|
||||||
|
types the route always set are unchanged, and the path checks are pinned in
|
||||||
|
test_path_traversal_guards.py::TestServePluginStatic.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
||||||
|
|
||||||
|
PNG = (b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
|
||||||
|
b"\x08\x06\x00\x00\x00\x1f\x15\xc4\x89")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def plugin_dir(tmp_path, api_v3_module):
|
||||||
|
d = tmp_path / "demo"
|
||||||
|
(d / "web_ui").mkdir(parents=True)
|
||||||
|
(d / "manifest.json").write_text(json.dumps({"id": "demo"}), encoding="utf-8")
|
||||||
|
api_v3_module.api_v3.plugin_catalog.get_plugin_directory.side_effect = (
|
||||||
|
lambda pid: str(d) if pid == "demo" else None)
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def _get(client, path):
|
||||||
|
return client.get(f"/api/v3/plugins/demo/static/{path}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_image_is_served_as_its_bytes(api_v3_client, plugin_dir):
|
||||||
|
(plugin_dir / "web_ui" / "icon.png").write_bytes(PNG)
|
||||||
|
response = _get(api_v3_client, "web_ui/icon.png")
|
||||||
|
assert response.status_code == 200, response.get_json(silent=True)
|
||||||
|
assert response.mimetype == "image/png"
|
||||||
|
assert response.data == PNG
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unknown_binary_file_is_served_too(api_v3_client, plugin_dir):
|
||||||
|
blob = bytes(range(256))
|
||||||
|
(plugin_dir / "data.bin").write_bytes(blob)
|
||||||
|
response = _get(api_v3_client, "data.bin")
|
||||||
|
assert response.status_code == 200, response.get_json(silent=True)
|
||||||
|
assert response.data == blob
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name,mimetype", [
|
||||||
|
("page.html", "text/html"),
|
||||||
|
("app.js", "application/javascript"),
|
||||||
|
("style.css", "text/css"),
|
||||||
|
("data.json", "application/json"),
|
||||||
|
("notes.txt", "text/plain"),
|
||||||
|
("README.md", "text/plain"),
|
||||||
|
("helper.py", "text/plain"),
|
||||||
|
])
|
||||||
|
def test_text_files_keep_their_types(api_v3_client, plugin_dir, name, mimetype):
|
||||||
|
content = "caf\u00e9 \u2713 <p>hi</p>\n"
|
||||||
|
(plugin_dir / name).write_bytes(content.encode("utf-8"))
|
||||||
|
response = _get(api_v3_client, name)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.mimetype == mimetype
|
||||||
|
assert response.data == content.encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_missing_file_is_still_a_404(api_v3_client, plugin_dir):
|
||||||
|
assert _get(api_v3_client, "nope.png").status_code == 404
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
"""Four web answers that disagreed with the rig they describe (found on ledpi).
|
||||||
|
|
||||||
|
1. POST /config/schedule refused the schedule GET returns on a fresh install
|
||||||
|
(config.template.json: per-day, every day off, schedule disabled) with
|
||||||
|
"At least one day must be enabled", as did /config/dim-schedule. A
|
||||||
|
disabled schedule needs no enabled day.
|
||||||
|
2. A brightness-only POST /config/main answered ``restart_required: true``,
|
||||||
|
though the display applies brightness live (brightness.set over the
|
||||||
|
socket, and the config watcher). The flag now says whether anything
|
||||||
|
changed that the running display does not pick up by itself.
|
||||||
|
3. /health stayed "healthy" with the display service stopped: only the
|
||||||
|
sub-checks changed. Service inactive, no socket and no live heartbeat
|
||||||
|
is now ``display_loop: stopped`` and "degraded".
|
||||||
|
4. /display/current-status kept answering ``is_display_active: true`` from
|
||||||
|
the cache for up to 120 s after the display stopped. With no socket and
|
||||||
|
no live heartbeat it is now unknown.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
||||||
|
|
||||||
|
from src import display_watchdog # noqa: E402
|
||||||
|
from src.ipc import client as control_client # noqa: E402
|
||||||
|
from web_interface import display_state # noqa: E402
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parent.parent
|
||||||
|
TEMPLATE = json.loads((REPO / 'config' / 'config.template.json').read_text(encoding='utf-8'))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def store(api_v3_module, monkeypatch):
|
||||||
|
state = {'config': {}, 'saves': 0}
|
||||||
|
api_v3_module.api_v3.config_manager.load_config.side_effect = \
|
||||||
|
lambda *a, **k: copy.deepcopy(state['config'])
|
||||||
|
|
||||||
|
def fake_save(_manager, config, **_kwargs):
|
||||||
|
state['config'] = copy.deepcopy(config)
|
||||||
|
state['saves'] += 1
|
||||||
|
return True, ''
|
||||||
|
|
||||||
|
monkeypatch.setattr(api_v3_module, '_save_config_atomic', fake_save)
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
# --- 1. schedules ---------------------------------------------------------------
|
||||||
|
|
||||||
|
SCHEDULE_ROUTES = [('/api/v3/config/schedule', 'schedule'),
|
||||||
|
('/api/v3/config/dim-schedule', 'dim_schedule')]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('route,section', SCHEDULE_ROUTES)
|
||||||
|
def test_the_templates_disabled_per_day_schedule_saves_back(api_v3_client, store,
|
||||||
|
route, section):
|
||||||
|
stored = copy.deepcopy(TEMPLATE[section])
|
||||||
|
stored['mode'] = 'per-day'
|
||||||
|
assert stored['enabled'] is False
|
||||||
|
assert not any(day['enabled'] for day in stored['days'].values())
|
||||||
|
store['config'] = {section: copy.deepcopy(stored)}
|
||||||
|
|
||||||
|
read = api_v3_client.get(route).get_json()['data']
|
||||||
|
resp = api_v3_client.post(route, json=read)
|
||||||
|
|
||||||
|
assert resp.status_code == 200, resp.get_json()
|
||||||
|
saved = store['config'][section]
|
||||||
|
assert saved['enabled'] is False and saved['mode'] == 'per-day'
|
||||||
|
# The disabled days keep their times: switching one on finds them.
|
||||||
|
assert saved['days'] == stored['days']
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('route,section', SCHEDULE_ROUTES)
|
||||||
|
def test_an_enabled_per_day_schedule_still_needs_a_day(api_v3_client, store, route, section):
|
||||||
|
body = copy.deepcopy(TEMPLATE[section])
|
||||||
|
body.update(enabled=True, mode='per-day')
|
||||||
|
resp = api_v3_client.post(route, json=body)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert 'At least one day must be enabled' in resp.get_json()['message']
|
||||||
|
assert store['saves'] == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('route', [r for r, _ in SCHEDULE_ROUTES])
|
||||||
|
def test_the_pickers_form_post_with_every_day_off_saves(api_v3_client, store, route):
|
||||||
|
"""What schedule-picker.js posts: flat hidden inputs, booleans as strings,
|
||||||
|
times for every day."""
|
||||||
|
body = {'enabled': 'false', 'mode': 'per_day', 'start_time': '07:00', 'end_time': '23:00'}
|
||||||
|
for day in ('monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'):
|
||||||
|
body.update({f'{day}_enabled': 'false', f'{day}_start': '06:30', f'{day}_end': '22:15'})
|
||||||
|
resp = api_v3_client.post(route, json=body)
|
||||||
|
assert resp.status_code == 200, resp.get_json()
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_invalid_time_on_a_disabled_day_is_dropped_not_refused(api_v3_client, store):
|
||||||
|
body = {'enabled': False, 'mode': 'per-day',
|
||||||
|
'days': {'monday': {'enabled': False, 'start_time': 'soon', 'end_time': '22:00'}}}
|
||||||
|
resp = api_v3_client.post('/api/v3/config/schedule', json=body)
|
||||||
|
assert resp.status_code == 200, resp.get_json()
|
||||||
|
assert store['config']['schedule']['days']['monday'] == {'enabled': False,
|
||||||
|
'end_time': '22:00'}
|
||||||
|
|
||||||
|
|
||||||
|
# --- 2. restart_required on /config/main ------------------------------------------
|
||||||
|
|
||||||
|
STORED_MAIN = {
|
||||||
|
'timezone': 'America/Chicago',
|
||||||
|
'display': {
|
||||||
|
'hardware': {'rows': 32, 'cols': 64, 'chain_length': 2, 'brightness': 90,
|
||||||
|
'disable_hardware_pulsing': False, 'inverse_colors': False,
|
||||||
|
'show_refresh_rate': False},
|
||||||
|
'runtime': {'gpio_slowdown': 4},
|
||||||
|
'display_durations': {'clock': 15},
|
||||||
|
'use_short_date_format': False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def main_store(store):
|
||||||
|
store['config'] = copy.deepcopy(STORED_MAIN)
|
||||||
|
return store
|
||||||
|
|
||||||
|
|
||||||
|
def _save_main(client, body):
|
||||||
|
with patch('web_interface.blueprints.api_v3.control_client.brightness_set',
|
||||||
|
side_effect=control_client.ControlError('no_socket', 'x')):
|
||||||
|
resp = client.post('/api/v3/config/main', data=json.dumps(body),
|
||||||
|
content_type='application/json')
|
||||||
|
assert resp.status_code == 200, resp.get_json()
|
||||||
|
return resp.get_json()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_brightness_only_save_needs_no_restart(api_v3_client, main_store):
|
||||||
|
body = _save_main(api_v3_client, {'brightness': 40})
|
||||||
|
assert main_store['config']['display']['hardware']['brightness'] == 40
|
||||||
|
assert body['restart_required'] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_brightness_save_on_a_config_without_a_display_section(api_v3_client, store):
|
||||||
|
"""The route creates display.hardware and display.runtime on the way;
|
||||||
|
empty sections are not a change."""
|
||||||
|
store['config'] = {}
|
||||||
|
assert _save_main(api_v3_client, {'brightness': 40})['restart_required'] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_display_form_with_only_brightness_changed_needs_no_restart(api_v3_client,
|
||||||
|
main_store):
|
||||||
|
hw = STORED_MAIN['display']['hardware']
|
||||||
|
body = {'__form_section': 'display', 'rows': 32, 'cols': 64, 'chain_length': 2,
|
||||||
|
'brightness': 55, 'gpio_slowdown': 4}
|
||||||
|
body.update({k: 'on' for k in ('disable_hardware_pulsing', 'inverse_colors',
|
||||||
|
'show_refresh_rate') if hw[k]})
|
||||||
|
assert _save_main(api_v3_client, body)['restart_required'] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_mode_duration_needs_no_restart(api_v3_client, main_store):
|
||||||
|
body = _save_main(api_v3_client, {'duration__clock': 40})
|
||||||
|
assert main_store['config']['display']['display_durations']['clock'] == 40
|
||||||
|
assert body['restart_required'] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('change', [{'rows': 64}, {'brightness': 40, 'chain_length': 3},
|
||||||
|
{'gpio_slowdown': 2}, {'timezone': 'UTC'}])
|
||||||
|
def test_a_setting_the_display_reads_at_startup_still_needs_one(api_v3_client, main_store,
|
||||||
|
change):
|
||||||
|
assert _save_main(api_v3_client, change)['restart_required'] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_restart_needed_compares_leaves():
|
||||||
|
from web_interface.blueprints.api_v3.config import restart_needed
|
||||||
|
before = {'display': {'hardware': {'brightness': 90, 'rows': 32}}}
|
||||||
|
assert not restart_needed(before, copy.deepcopy(before))
|
||||||
|
assert not restart_needed(before, {'display': {'hardware': {'brightness': 10, 'rows': 32},
|
||||||
|
'runtime': {}}})
|
||||||
|
assert restart_needed(before, {'display': {'hardware': {'brightness': 90}}}) # removed
|
||||||
|
assert not restart_needed({}, {'clock': {'enabled': True}}, live_paths=[('clock',)])
|
||||||
|
assert restart_needed({}, {'clockwork': {'enabled': True}}, live_paths=[('clock',)])
|
||||||
|
|
||||||
|
|
||||||
|
# --- 3 and 4. a stopped display -----------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def no_display(monkeypatch, tmp_path):
|
||||||
|
"""A Pi whose display service has stopped: the socket is expected here
|
||||||
|
but does not answer, and systemd took the heartbeat's directory away."""
|
||||||
|
monkeypatch.setattr(display_state, 'socket_supported', lambda: True)
|
||||||
|
monkeypatch.setattr(display_state, 'client_socket_paths', lambda: [str(tmp_path / 'gone')])
|
||||||
|
monkeypatch.setattr(display_state, 'read_state', lambda: None)
|
||||||
|
path = tmp_path / 'display-heartbeat.json'
|
||||||
|
monkeypatch.setattr(display_watchdog, 'HEARTBEAT_PATH', str(path))
|
||||||
|
|
||||||
|
def beat(age, pid=None):
|
||||||
|
path.write_text(json.dumps({'pid': os.getpid() if pid is None else pid,
|
||||||
|
'mono': time.monotonic() - age,
|
||||||
|
'wall': time.time() - age}))
|
||||||
|
return beat
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def service(monkeypatch):
|
||||||
|
status = {'active': False, 'returncode': 3, 'stdout': 'inactive', 'stderr': ''}
|
||||||
|
monkeypatch.setattr('web_interface.blueprints.api_v3.misc._get_display_service_status',
|
||||||
|
lambda: dict(status))
|
||||||
|
return status
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fresh_preview(tmp_path, monkeypatch):
|
||||||
|
"""The preview frame the display left behind, under 60 s old: on its own
|
||||||
|
it kept the hardware check "connected"."""
|
||||||
|
from web_interface import display_preview
|
||||||
|
snapshot = tmp_path / 'preview.png'
|
||||||
|
snapshot.write_bytes(b'png')
|
||||||
|
monkeypatch.setattr(display_preview, 'SNAPSHOT_PATH', str(snapshot))
|
||||||
|
|
||||||
|
|
||||||
|
def _health(client):
|
||||||
|
resp = client.get('/api/v3/health')
|
||||||
|
assert resp.status_code == 200, resp.get_json()
|
||||||
|
return resp.get_json()['data']
|
||||||
|
|
||||||
|
|
||||||
|
class TestHealth:
|
||||||
|
def test_a_stopped_display_service_is_degraded(self, api_v3_client, no_display, service,
|
||||||
|
fresh_preview):
|
||||||
|
data = _health(api_v3_client)
|
||||||
|
assert data['services']['display_service']['status'] == 'inactive'
|
||||||
|
assert data['checks']['display_loop']['status'] == 'stopped'
|
||||||
|
assert data['status'] == 'degraded'
|
||||||
|
|
||||||
|
def test_a_service_still_starting_is_not(self, api_v3_client, no_display, service,
|
||||||
|
fresh_preview):
|
||||||
|
"""Active, before its socket and first heartbeat: not stopped."""
|
||||||
|
service.update(active=True, stdout='active', returncode=0)
|
||||||
|
data = _health(api_v3_client)
|
||||||
|
assert data['checks']['display_loop']['status'] == 'not_reported'
|
||||||
|
assert data['status'] == 'healthy'
|
||||||
|
|
||||||
|
def test_a_display_run_by_hand_is_not_stopped(self, api_v3_client, no_display, service,
|
||||||
|
fresh_preview):
|
||||||
|
"""The service is off but a display process beats (sudo python3 run.py)."""
|
||||||
|
no_display(age=2)
|
||||||
|
data = _health(api_v3_client)
|
||||||
|
assert data['checks']['display_loop']['status'] == 'running'
|
||||||
|
assert data['status'] == 'healthy'
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('platform', ['no_unix_sockets', 'socket_off'])
|
||||||
|
def test_without_a_socket_to_expect_nothing_changes(self, api_v3_client, no_display,
|
||||||
|
service, fresh_preview, monkeypatch,
|
||||||
|
platform):
|
||||||
|
"""Windows and the dev server (no systemd unit), or the socket
|
||||||
|
deliberately off: no heartbeat is no signal, as before."""
|
||||||
|
if platform == 'no_unix_sockets':
|
||||||
|
monkeypatch.setattr(display_state, 'socket_supported', lambda: False)
|
||||||
|
else:
|
||||||
|
monkeypatch.setattr(display_state, 'client_socket_paths', lambda: [])
|
||||||
|
service.update(returncode=-1, stdout='', stderr='systemctl not found')
|
||||||
|
data = _health(api_v3_client)
|
||||||
|
assert data['checks']['display_loop']['status'] == 'not_reported'
|
||||||
|
assert data['status'] == 'healthy'
|
||||||
|
|
||||||
|
def test_the_status_only_answer_says_degraded(self, api_v3_client, no_display, service,
|
||||||
|
fresh_preview, monkeypatch):
|
||||||
|
monkeypatch.setattr('web_interface.blueprints.api_v3.misc.request_is_authenticated',
|
||||||
|
lambda: False)
|
||||||
|
resp = api_v3_client.get('/api/v3/health')
|
||||||
|
assert resp.get_json()['data'] == {'status': 'degraded'}
|
||||||
|
|
||||||
|
|
||||||
|
class TestCurrentStatus:
|
||||||
|
CACHED = {'mode': 'clock', 'plugin_id': 'clock', 'is_display_active': True,
|
||||||
|
'on_demand_active': False, 'last_updated': None}
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def cached(self, api_v3_module):
|
||||||
|
entry = dict(self.CACHED, last_updated=time.time() - 30)
|
||||||
|
cache = api_v3_module.api_v3.cache_manager
|
||||||
|
cache.get.side_effect = lambda key, *a, **kw: (
|
||||||
|
dict(entry) if key == 'display_current_state' else None)
|
||||||
|
return entry
|
||||||
|
|
||||||
|
def _status(self, client):
|
||||||
|
resp = client.get('/api/v3/display/current-status')
|
||||||
|
assert resp.status_code == 200
|
||||||
|
return resp.get_json()['data']
|
||||||
|
|
||||||
|
def test_a_stopped_display_is_not_reported_active(self, api_v3_client, no_display, cached):
|
||||||
|
data = self._status(api_v3_client)
|
||||||
|
assert not data.get('is_display_active')
|
||||||
|
assert data['mode'] is None and data['last_updated'] is None
|
||||||
|
assert data['source'] == 'cache'
|
||||||
|
|
||||||
|
def test_a_stale_heartbeat_is_not_active_either(self, api_v3_client, no_display, cached):
|
||||||
|
no_display(age=display_watchdog.HEARTBEAT_STALE_SECONDS + 5)
|
||||||
|
assert self._status(api_v3_client)['mode'] is None
|
||||||
|
|
||||||
|
@pytest.mark.skipif(os.name != 'posix', reason='process_exists answers only on POSIX')
|
||||||
|
def test_a_heartbeat_from_a_dead_process_is_not_active(self, api_v3_client, no_display,
|
||||||
|
cached):
|
||||||
|
no_display(age=1, pid=2 ** 22 + 12345)
|
||||||
|
assert self._status(api_v3_client)['mode'] is None
|
||||||
|
|
||||||
|
def test_a_live_heartbeat_without_a_socket_reads_the_cache(self, api_v3_client,
|
||||||
|
no_display, cached):
|
||||||
|
"""An older display with no socket, still running."""
|
||||||
|
no_display(age=2)
|
||||||
|
data = self._status(api_v3_client)
|
||||||
|
assert data['mode'] == 'clock' and data['is_display_active'] is True
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('platform', ['no_unix_sockets', 'socket_off'])
|
||||||
|
def test_without_a_socket_to_expect_the_cache_answers(self, api_v3_client, no_display,
|
||||||
|
cached, monkeypatch, platform):
|
||||||
|
if platform == 'no_unix_sockets':
|
||||||
|
monkeypatch.setattr(display_state, 'socket_supported', lambda: False)
|
||||||
|
else:
|
||||||
|
monkeypatch.setattr(display_state, 'client_socket_paths', lambda: [])
|
||||||
|
data = self._status(api_v3_client)
|
||||||
|
assert data['mode'] == 'clock' and data['is_display_active'] is True
|
||||||
@@ -13,6 +13,7 @@ The invariants that keep this change safe:
|
|||||||
inline path exactly.
|
inline path exactly.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
@@ -209,6 +210,32 @@ class TestFailurePaths:
|
|||||||
assert pm.get_plugin_lock(plugin_id).acquire(blocking=False) is True
|
assert pm.get_plugin_lock(plugin_id).acquire(blocking=False) is True
|
||||||
pm.get_plugin_lock(plugin_id).release()
|
pm.get_plugin_lock(plugin_id).release()
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("raised", [asyncio.CancelledError, SystemExit])
|
||||||
|
def test_update_raising_a_base_exception_still_releases_the_plugin(self, pm, raised):
|
||||||
|
"""asyncio.CancelledError and SystemExit derive from BaseException,
|
||||||
|
not Exception. Raised from update() on the worker, one skipped the
|
||||||
|
bookkeeping entirely: the plugin kept its lock and stayed RUNNING for
|
||||||
|
the life of the process -- never updated again, and every display()
|
||||||
|
skipped as busy."""
|
||||||
|
class CancellingPlugin(SlowPlugin):
|
||||||
|
def update(self):
|
||||||
|
self.update_calls += 1
|
||||||
|
raise raised()
|
||||||
|
|
||||||
|
plugin_id = _install(pm, CancellingPlugin())
|
||||||
|
pm.run_scheduled_updates()
|
||||||
|
deadline = time.monotonic() + 3
|
||||||
|
while pm.plugins[plugin_id].update_calls == 0 and time.monotonic() < deadline:
|
||||||
|
time.sleep(0.05)
|
||||||
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
assert pm.get_plugin_lock(plugin_id).acquire(blocking=False) is True
|
||||||
|
pm.get_plugin_lock(plugin_id).release()
|
||||||
|
assert pm.state_manager.can_execute(plugin_id) is True
|
||||||
|
assert pm.plugin_last_update.get(plugin_id, 0) > 0
|
||||||
|
error = pm.state_manager.get_error_info(plugin_id)
|
||||||
|
assert error is not None and error["error_type"] == raised.__name__
|
||||||
|
|
||||||
def test_unloaded_while_queued_is_harmless(self, pm):
|
def test_unloaded_while_queued_is_harmless(self, pm):
|
||||||
"""Exercise the public unload_plugin() lifecycle rather than
|
"""Exercise the public unload_plugin() lifecycle rather than
|
||||||
deleting pm.plugins directly: queue the target's update behind a
|
deleting pm.plugins directly: queue the target's update behind a
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""A cache key too long to be a filename still gets a cache file.
|
||||||
|
|
||||||
|
The calendar plugin's key joins every calendar id the user picked; on a real
|
||||||
|
install it passed 300 bytes, and since ext4 caps a filename at 255 every write
|
||||||
|
failed with ENAMETOOLONG -- logged as "permission denied", every update.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from src.cache.disk_cache import DiskCache, _MAX_KEY_FILENAME_BYTES, _filename_stem
|
||||||
|
from src.cache_manager import CacheManager
|
||||||
|
|
||||||
|
# The shape of the key that failed on hdpi, ids anonymised.
|
||||||
|
CALENDAR_KEY = (
|
||||||
|
"calendar_events_someone@example.com_en.usa#holiday@group.v.calendar.google.com_"
|
||||||
|
"family13997378751670666433@group.calendar.google.com_ncaaf_-m-07kbp5_"
|
||||||
|
"%47eorgia+%42ulldogs+football#sports@group.v.calendar.google.com_nfl_-m-07l24_"
|
||||||
|
"%54ampa+%42ay+%42uccaneers#sports@group.v.calendar.google.com_primary"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ext4/xfs/btrfs NAME_MAX; set()'s temp file adds 15 bytes to the stem.
|
||||||
|
NAME_MAX = 255
|
||||||
|
TEMP_OVERHEAD = len(".") + len(".json") + len(".") + 8
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_real_key_was_too_long_to_write():
|
||||||
|
assert len((CALENDAR_KEY + ".json").encode()) > NAME_MAX - 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_long_key_round_trips(tmp_path):
|
||||||
|
cache = DiskCache(str(tmp_path))
|
||||||
|
cache.set(CALENDAR_KEY, {"events": [1, 2, 3]})
|
||||||
|
|
||||||
|
assert cache.get(CALENDAR_KEY, max_age=None) == {"events": [1, 2, 3]}
|
||||||
|
path = cache.get_cache_path(CALENDAR_KEY)
|
||||||
|
assert os.path.isfile(path)
|
||||||
|
stem = os.path.basename(path)[:-len(".json")]
|
||||||
|
assert len(stem.encode()) + TEMP_OVERHEAD <= NAME_MAX
|
||||||
|
|
||||||
|
|
||||||
|
def test_short_keys_keep_their_filename(tmp_path):
|
||||||
|
cache = DiskCache(str(tmp_path))
|
||||||
|
exactly = "k" * _MAX_KEY_FILENAME_BYTES
|
||||||
|
assert cache.get_cache_path("weather_current") == str(tmp_path / "weather_current.json")
|
||||||
|
assert cache.get_cache_path(exactly) == str(tmp_path / f"{exactly}.json")
|
||||||
|
assert cache.get_cache_path(exactly + "k") != str(tmp_path / f"{exactly}k.json")
|
||||||
|
|
||||||
|
|
||||||
|
def test_long_keys_sharing_a_prefix_stay_apart(tmp_path):
|
||||||
|
cache = DiskCache(str(tmp_path))
|
||||||
|
first, second = CALENDAR_KEY + "_a", CALENDAR_KEY + "_b"
|
||||||
|
cache.set(first, {"which": "a"})
|
||||||
|
cache.set(second, {"which": "b"})
|
||||||
|
|
||||||
|
assert cache.get_cache_path(first) != cache.get_cache_path(second)
|
||||||
|
assert cache.get(first, max_age=None) == {"which": "a"}
|
||||||
|
assert cache.get(second, max_age=None) == {"which": "b"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_prefix_never_splits_a_character():
|
||||||
|
key = "news_" + "é" * 300 # two bytes each, so the cut lands mid-character
|
||||||
|
stem = _filename_stem(key)
|
||||||
|
|
||||||
|
assert stem.startswith("news_é")
|
||||||
|
assert len(stem.encode("utf-8")) <= _MAX_KEY_FILENAME_BYTES
|
||||||
|
stem.encode("utf-8").decode("utf-8") # well-formed
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_stem_listed_by_the_web_ui_deletes_the_same_file(tmp_path):
|
||||||
|
with patch('src.cache_manager.CacheManager._get_writable_cache_dir', return_value=str(tmp_path)):
|
||||||
|
manager = CacheManager()
|
||||||
|
try:
|
||||||
|
manager.save_cache(CALENDAR_KEY, {"events": []})
|
||||||
|
|
||||||
|
listed = [entry["key"] for entry in manager.list_cache_files()]
|
||||||
|
assert len(listed) == 1
|
||||||
|
manager.clear_cache(listed[0])
|
||||||
|
|
||||||
|
assert [n for n in os.listdir(tmp_path) if n.endswith(".json")] == []
|
||||||
|
finally:
|
||||||
|
manager.stop_cleanup_thread()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_failed_write_names_the_real_error(tmp_path, monkeypatch, caplog):
|
||||||
|
blocker = tmp_path / "a-file"
|
||||||
|
blocker.write_text("")
|
||||||
|
# No writable fallback either, so set() gives up and says why.
|
||||||
|
monkeypatch.setattr(os.path, "expanduser", lambda _p: str(blocker / "home"))
|
||||||
|
cache = DiskCache(str(tmp_path / "missing"))
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
cache.set("weather_current", {"t": 1})
|
||||||
|
|
||||||
|
gave_up = [r.getMessage() for r in caplog.records if "Could not write cache" in r.getMessage()]
|
||||||
|
assert len(gave_up) == 1
|
||||||
|
assert "permission denied" not in gave_up[0]
|
||||||
|
assert os.strerror(2) in gave_up[0] # ENOENT: the directory does not exist
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""The memory tier never serves a record older than the reader asked for.
|
||||||
|
|
||||||
|
A record read from disk went into the memory tier timed from the read, not
|
||||||
|
from when it was written, so get(max_age=300) could hand out data up to twice
|
||||||
|
that old: after a restart, after the memory sweep, or in a second process that
|
||||||
|
loaded a record once and kept serving it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.cache_manager import CacheManager
|
||||||
|
|
||||||
|
|
||||||
|
class Clock:
|
||||||
|
def __init__(self, now):
|
||||||
|
self.now = now
|
||||||
|
|
||||||
|
def __call__(self):
|
||||||
|
return self.now
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def clock(monkeypatch):
|
||||||
|
fake = Clock(1_800_000_000.0)
|
||||||
|
monkeypatch.setattr(time, "time", fake)
|
||||||
|
return fake
|
||||||
|
|
||||||
|
|
||||||
|
def _manager(path):
|
||||||
|
# No disk sweep: it judges files by their real mtime against the fake
|
||||||
|
# clock and would delete them as months old.
|
||||||
|
with patch('src.cache_manager.CacheManager._get_writable_cache_dir',
|
||||||
|
return_value=str(path)), \
|
||||||
|
patch('src.cache_manager.CacheManager.start_cleanup_thread'):
|
||||||
|
return CacheManager()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_record_loaded_late_expires_on_its_own_timestamp(tmp_path, clock):
|
||||||
|
writer = _manager(tmp_path)
|
||||||
|
writer.set("weather_current", {"t": 1})
|
||||||
|
|
||||||
|
reader = _manager(tmp_path) # a restart, or the other process
|
||||||
|
clock.now += 250
|
||||||
|
assert reader.get("weather_current", max_age=300) == {"t": 1}
|
||||||
|
|
||||||
|
clock.now += 100 # the data is 350 s old; it sat in memory for 100 s
|
||||||
|
assert reader.get("weather_current", max_age=300) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_stored_ttl_bounds_the_memory_copy_too(tmp_path, clock):
|
||||||
|
writer = _manager(tmp_path)
|
||||||
|
writer.set("odds_espn_football_nfl_401", {"spread": 6.5}, ttl=60)
|
||||||
|
|
||||||
|
reader = _manager(tmp_path)
|
||||||
|
clock.now += 55
|
||||||
|
assert reader.get("odds_espn_football_nfl_401", max_age=3600) == {"spread": 6.5}
|
||||||
|
|
||||||
|
clock.now += 60
|
||||||
|
assert reader.get("odds_espn_football_nfl_401", max_age=3600) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_stale_memory_copy_gives_way_to_a_newer_write_on_disk(tmp_path, clock):
|
||||||
|
writer = _manager(tmp_path)
|
||||||
|
reader = _manager(tmp_path)
|
||||||
|
writer.set("stocks_AAPL", {"price": 1})
|
||||||
|
assert reader.get("stocks_AAPL", max_age=300) == {"price": 1}
|
||||||
|
|
||||||
|
clock.now += 280
|
||||||
|
writer.set("stocks_AAPL", {"price": 2})
|
||||||
|
clock.now += 40 # reader's copy: 40 s in memory, 320 s old
|
||||||
|
|
||||||
|
assert reader.get("stocks_AAPL", max_age=300) == {"price": 2}
|
||||||
|
|
||||||
|
|
||||||
|
def test_fresh_records_are_still_served_from_memory(tmp_path, clock):
|
||||||
|
manager = _manager(tmp_path)
|
||||||
|
manager.set("news_NFL", {"items": []})
|
||||||
|
clock.now += 100
|
||||||
|
|
||||||
|
with patch.object(manager._disk_cache_component, "get") as disk_get:
|
||||||
|
assert manager.get("news_NFL", max_age=300) == {"items": []}
|
||||||
|
disk_get.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_max_age_none_and_records_without_a_timestamp_never_expire(tmp_path, clock):
|
||||||
|
manager = _manager(tmp_path)
|
||||||
|
manager.set("plugin_health_x", {"ok": True})
|
||||||
|
manager.save_cache("raw_record", {"no": "timestamp"})
|
||||||
|
clock.now += 10 ** 6
|
||||||
|
|
||||||
|
assert manager.get("plugin_health_x", max_age=None) == {"ok": True}
|
||||||
|
assert manager.get_cached_data("raw_record", max_age=None) == {"no": "timestamp"}
|
||||||
@@ -58,7 +58,8 @@ class TestFastPath:
|
|||||||
for _ in range(10):
|
for _ in range(10):
|
||||||
again = m.load_config()
|
again = m.load_config()
|
||||||
assert counts["n"] == 0, "fast path must not re-open any config file"
|
assert counts["n"] == 0, "fast path must not re-open any config file"
|
||||||
assert again is first # same aliasing semantics as the full path
|
assert again == first
|
||||||
|
assert again is not first # each caller gets its own copy, see below
|
||||||
|
|
||||||
def test_config_change_triggers_reload(self, mgr):
|
def test_config_change_triggers_reload(self, mgr):
|
||||||
m, config, secrets, template = mgr
|
m, config, secrets, template = mgr
|
||||||
@@ -98,6 +99,33 @@ class TestFastPath:
|
|||||||
assert m.load_config()["timezone"] == "America/New_York"
|
assert m.load_config()["timezone"] == "America/New_York"
|
||||||
|
|
||||||
|
|
||||||
|
class TestCallersGetACopy:
|
||||||
|
"""A web handler edits what load_config returned, then validates. When
|
||||||
|
validation failed, the edit stayed in the cache the fast path serves, and
|
||||||
|
the next unrelated save wrote it -- a nested secret included, in plain
|
||||||
|
text, because it had never reached config_secrets.json to be stripped."""
|
||||||
|
|
||||||
|
def test_editing_a_loaded_config_does_not_change_the_next_load(self, mgr):
|
||||||
|
m, config, secrets, template = mgr
|
||||||
|
loaded = m.load_config()
|
||||||
|
loaded["display"]["brightness"] = 1
|
||||||
|
loaded["weather"]["api_key"] = "typed-but-never-saved"
|
||||||
|
again = m.load_config()
|
||||||
|
assert again["display"]["brightness"] == 90
|
||||||
|
assert again["weather"]["api_key"] == "sek"
|
||||||
|
|
||||||
|
def test_the_full_path_also_returns_a_copy(self, mgr):
|
||||||
|
m, config, secrets, template = mgr
|
||||||
|
m.load_config()["display"]["brightness"] = 1 # first load: full path
|
||||||
|
assert m.load_config()["display"]["brightness"] == 90
|
||||||
|
|
||||||
|
def test_an_edit_never_reaches_a_later_save(self, mgr):
|
||||||
|
m, config, secrets, template = mgr
|
||||||
|
m.load_config()["display"]["new_secret"] = "hunter2" # then bailed out
|
||||||
|
m.save_config(m.load_config()) # some other handler saves
|
||||||
|
assert "hunter2" not in config.read_text()
|
||||||
|
|
||||||
|
|
||||||
class TestSaveCoherence:
|
class TestSaveCoherence:
|
||||||
def test_save_config_then_load_returns_saved_data(self, mgr, monkeypatch):
|
def test_save_config_then_load_returns_saved_data(self, mgr, monkeypatch):
|
||||||
m, config, secrets, template = mgr
|
m, config, secrets, template = mgr
|
||||||
@@ -111,6 +139,15 @@ class TestSaveCoherence:
|
|||||||
assert loaded["weather"]["api_key"] == "sek" # secrets survive in memory
|
assert loaded["weather"]["api_key"] == "sek" # secrets survive in memory
|
||||||
assert counts["n"] == 0 # signature refreshed by save; no re-read
|
assert counts["n"] == 0 # signature refreshed by save; no re-read
|
||||||
|
|
||||||
|
def test_the_saved_dict_does_not_become_the_cache(self, mgr):
|
||||||
|
m, config, secrets, template = mgr
|
||||||
|
m.load_config()
|
||||||
|
new = {"display": {"brightness": 42}, "timezone": "UTC",
|
||||||
|
"weather": {"api_key": "sek"}}
|
||||||
|
m.save_config(new)
|
||||||
|
new["display"]["brightness"] = 7 # the caller keeps using its dict
|
||||||
|
assert m.load_config()["display"]["brightness"] == 42
|
||||||
|
|
||||||
def test_cross_process_save_is_picked_up(self, mgr):
|
def test_cross_process_save_is_picked_up(self, mgr):
|
||||||
"""Another process writing config.json (different mtime) must bust
|
"""Another process writing config.json (different mtime) must bust
|
||||||
this process's fast path — the core cross-process guarantee."""
|
this process's fast path — the core cross-process guarantee."""
|
||||||
|
|||||||
@@ -143,7 +143,10 @@ class TestLoadFastPath:
|
|||||||
manager = make_manager(tmp_path, config={"timezone": "UTC"})
|
manager = make_manager(tmp_path, config={"timezone": "UTC"})
|
||||||
first = manager.load_config()
|
first = manager.load_config()
|
||||||
second = manager.load_config()
|
second = manager.load_config()
|
||||||
assert second is first # same aliased dict, no re-read
|
# A copy of the cached dict, never the dict itself; that it is not
|
||||||
|
# re-read is test_config_load_cache's test_unchanged_files_are_not_reread
|
||||||
|
assert second == first
|
||||||
|
assert second is not first
|
||||||
|
|
||||||
def test_touching_secrets_file_invalidates_cache(self, tmp_path):
|
def test_touching_secrets_file_invalidates_cache(self, tmp_path):
|
||||||
manager = make_manager(
|
manager = make_manager(
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
"""ConfigService notifies subscribers outside its lock, in order.
|
||||||
|
|
||||||
|
Subscribers ran while _load_config held the service's lock. The display's
|
||||||
|
per-plugin subscriber is PluginManager.apply_config_change, which waits up to
|
||||||
|
PLUGIN_LOCK_TIMEOUT (5 s) for a busy plugin. The same save that toggles a
|
||||||
|
plugin's ``enabled`` flags a reconcile, and the render thread runs it: its
|
||||||
|
get_config() -- and the unsubscribe() of a plugin it disables -- waited behind
|
||||||
|
every slow callback, freezing the panel for up to 5 s per busy plugin.
|
||||||
|
|
||||||
|
What callers could rely on before still holds: one reload's notifications
|
||||||
|
finish before the next reload's start, and a callback unsubscribe() removed is
|
||||||
|
not running, and will not run, once unsubscribe() returns.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import itertools
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.config_manager import ConfigManager
|
||||||
|
from src.config_service import ConfigService
|
||||||
|
|
||||||
|
SLOW = 2.0 # how long a blocked callback waits before giving up
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def service(tmp_path):
|
||||||
|
config_path = tmp_path / "config.json"
|
||||||
|
config_path.write_text(json.dumps({"display": {"brightness": 50},
|
||||||
|
"weather": {"enabled": True}}),
|
||||||
|
encoding="utf-8")
|
||||||
|
manager = ConfigManager(str(config_path), str(tmp_path / "config_secrets.json"))
|
||||||
|
manager.template_path = str(tmp_path / "no-template.json")
|
||||||
|
svc = ConfigService(manager, enable_hot_reload=False)
|
||||||
|
yield svc, config_path
|
||||||
|
svc.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
_saves = itertools.count(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _save(config_path, **sections):
|
||||||
|
config = json.loads(config_path.read_text(encoding="utf-8"))
|
||||||
|
config.update(sections)
|
||||||
|
config_path.write_text(json.dumps(config), encoding="utf-8")
|
||||||
|
# ConfigManager re-reads only when (mtime, size) moves. Two quick saves of
|
||||||
|
# the same size can share an mtime tick (about 16 ms on Windows), so step
|
||||||
|
# it forward explicitly.
|
||||||
|
st = config_path.stat()
|
||||||
|
os.utime(config_path, ns=(st.st_atime_ns, st.st_mtime_ns + next(_saves) * 50_000_000))
|
||||||
|
|
||||||
|
|
||||||
|
def _reload_in_background(svc):
|
||||||
|
thread = threading.Thread(target=svc._load_config, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
return thread
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_config_does_not_wait_for_a_slow_subscriber(service):
|
||||||
|
svc, config_path = service
|
||||||
|
entered, release = threading.Event(), threading.Event()
|
||||||
|
|
||||||
|
def slow(_old, _new):
|
||||||
|
entered.set()
|
||||||
|
release.wait(SLOW)
|
||||||
|
|
||||||
|
svc.subscribe(slow, plugin_id="weather")
|
||||||
|
_save(config_path, weather={"enabled": False})
|
||||||
|
reload = _reload_in_background(svc)
|
||||||
|
assert entered.wait(SLOW)
|
||||||
|
|
||||||
|
start = time.monotonic()
|
||||||
|
config = svc.get_config()
|
||||||
|
waited = time.monotonic() - start
|
||||||
|
release.set()
|
||||||
|
reload.join(SLOW)
|
||||||
|
|
||||||
|
assert waited < 0.5
|
||||||
|
# Swapped before anyone was told: a subscriber that reads it sees the new one.
|
||||||
|
assert config["weather"]["enabled"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_unsubscribing_another_callback_does_not_wait(service):
|
||||||
|
svc, config_path = service
|
||||||
|
entered, release = threading.Event(), threading.Event()
|
||||||
|
|
||||||
|
def slow(_old, _new):
|
||||||
|
entered.set()
|
||||||
|
release.wait(SLOW)
|
||||||
|
|
||||||
|
def other(_old, _new):
|
||||||
|
pass
|
||||||
|
|
||||||
|
svc.subscribe(slow, plugin_id="weather")
|
||||||
|
svc.subscribe(other, plugin_id="clock")
|
||||||
|
_save(config_path, weather={"enabled": False})
|
||||||
|
reload = _reload_in_background(svc)
|
||||||
|
assert entered.wait(SLOW)
|
||||||
|
|
||||||
|
start = time.monotonic()
|
||||||
|
svc.unsubscribe(other, plugin_id="clock")
|
||||||
|
waited = time.monotonic() - start
|
||||||
|
release.set()
|
||||||
|
reload.join(SLOW)
|
||||||
|
|
||||||
|
assert waited < 0.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_callback_unsubscribed_mid_notification_is_not_called(service):
|
||||||
|
svc, config_path = service
|
||||||
|
entered, release = threading.Event(), threading.Event()
|
||||||
|
called = []
|
||||||
|
|
||||||
|
def slow_global(_old, _new): # global subscribers are notified first
|
||||||
|
entered.set()
|
||||||
|
release.wait(SLOW)
|
||||||
|
|
||||||
|
def weather(_old, _new):
|
||||||
|
called.append("weather")
|
||||||
|
|
||||||
|
svc.subscribe(slow_global)
|
||||||
|
svc.subscribe(weather, plugin_id="weather")
|
||||||
|
_save(config_path, weather={"enabled": False})
|
||||||
|
reload = _reload_in_background(svc)
|
||||||
|
assert entered.wait(SLOW)
|
||||||
|
|
||||||
|
svc.unsubscribe(weather, plugin_id="weather")
|
||||||
|
release.set()
|
||||||
|
reload.join(SLOW)
|
||||||
|
|
||||||
|
assert called == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_unsubscribe_waits_for_its_own_callback_to_return(service):
|
||||||
|
svc, config_path = service
|
||||||
|
entered, release = threading.Event(), threading.Event()
|
||||||
|
returned = threading.Event()
|
||||||
|
|
||||||
|
def slow(_old, _new):
|
||||||
|
entered.set()
|
||||||
|
release.wait(SLOW)
|
||||||
|
returned.set()
|
||||||
|
|
||||||
|
svc.subscribe(slow, plugin_id="weather")
|
||||||
|
_save(config_path, weather={"enabled": False})
|
||||||
|
reload = _reload_in_background(svc)
|
||||||
|
assert entered.wait(SLOW)
|
||||||
|
|
||||||
|
threading.Timer(0.2, release.set).start()
|
||||||
|
svc.unsubscribe(slow, plugin_id="weather")
|
||||||
|
|
||||||
|
assert returned.is_set()
|
||||||
|
reload.join(SLOW)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_callback_may_read_config_and_unsubscribe_itself(service):
|
||||||
|
svc, config_path = service
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
def once(_old, _new):
|
||||||
|
seen.append(svc.get_config()["weather"]["enabled"])
|
||||||
|
svc.unsubscribe(once, plugin_id="weather")
|
||||||
|
|
||||||
|
svc.subscribe(once, plugin_id="weather")
|
||||||
|
_save(config_path, weather={"enabled": False})
|
||||||
|
reload = _reload_in_background(svc)
|
||||||
|
reload.join(SLOW)
|
||||||
|
|
||||||
|
assert not reload.is_alive()
|
||||||
|
assert seen == [False]
|
||||||
|
|
||||||
|
|
||||||
|
def test_two_reloads_notify_in_order(service):
|
||||||
|
svc, config_path = service
|
||||||
|
entered, release = threading.Event(), threading.Event()
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
def record(old, new):
|
||||||
|
seen.append((old["brightness"], new["brightness"]))
|
||||||
|
if len(seen) == 1:
|
||||||
|
entered.set()
|
||||||
|
release.wait(SLOW)
|
||||||
|
|
||||||
|
svc.subscribe(record, plugin_id="display")
|
||||||
|
_save(config_path, display={"brightness": 60})
|
||||||
|
first = _reload_in_background(svc)
|
||||||
|
assert entered.wait(SLOW)
|
||||||
|
|
||||||
|
_save(config_path, display={"brightness": 100})
|
||||||
|
second = _reload_in_background(svc)
|
||||||
|
time.sleep(0.2)
|
||||||
|
release.set()
|
||||||
|
first.join(SLOW)
|
||||||
|
second.join(SLOW)
|
||||||
|
|
||||||
|
assert seen == [(50, 60), (60, 100)]
|
||||||
+399
-12
@@ -9,6 +9,7 @@ that overrides the schedule and ends (#714).
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import itertools
|
import itertools
|
||||||
|
from dataclasses import replace
|
||||||
import os
|
import os
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ os.environ.setdefault("EMULATOR", "true")
|
|||||||
from src import display_arbiter # noqa: E402
|
from src import display_arbiter # noqa: E402
|
||||||
from src.display_arbiter import ( # noqa: E402
|
from src.display_arbiter import ( # noqa: E402
|
||||||
SCHEDULED_OFF_DWELL,
|
SCHEDULED_OFF_DWELL,
|
||||||
|
SCREEN_PREEMPTERS,
|
||||||
WIFI_NOTICE_DWELL,
|
WIFI_NOTICE_DWELL,
|
||||||
Arbiter,
|
Arbiter,
|
||||||
ArbiterInputs,
|
ArbiterInputs,
|
||||||
@@ -26,6 +28,9 @@ from src.display_arbiter import ( # noqa: E402
|
|||||||
ScreenPlan,
|
ScreenPlan,
|
||||||
Source,
|
Source,
|
||||||
WifiNotice,
|
WifiNotice,
|
||||||
|
live_pick,
|
||||||
|
live_takeover,
|
||||||
|
on_demand_bound,
|
||||||
wifi_notice_preempts,
|
wifi_notice_preempts,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -34,7 +39,9 @@ NOTICE = WifiNotice(message="Connected to HomeNet", expires_at=1_000.0)
|
|||||||
OFF = Source.SCHEDULED_OFF
|
OFF = Source.SCHEDULED_OFF
|
||||||
FOLLOW = Source.FOLLOWER
|
FOLLOW = Source.FOLLOWER
|
||||||
WIFI = Source.WIFI
|
WIFI = Source.WIFI
|
||||||
|
ONDEM = Source.ON_DEMAND
|
||||||
LEGACY = Source.LEGACY
|
LEGACY = Source.LEGACY
|
||||||
|
ROTATION = Source.ROTATION
|
||||||
|
|
||||||
# (schedule_on, on_demand_active, follower_active, notice) -> Source.
|
# (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.
|
# 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, None, OFF),
|
||||||
(False, False, True, NOTICE, OFF),
|
(False, False, True, NOTICE, OFF),
|
||||||
# Scheduled off, but on-demand overrides the gate.
|
# Scheduled off, but on-demand overrides the gate.
|
||||||
(False, True, False, None, LEGACY), # on-demand: run() decides
|
(False, True, False, None, ONDEM), # on-demand overrides the gate
|
||||||
(False, True, False, NOTICE, LEGACY), # on-demand outranks WiFi
|
(False, True, False, NOTICE, ONDEM), # on-demand outranks WiFi
|
||||||
(False, True, True, None, FOLLOW), # follower outranks on-demand
|
(False, True, True, None, FOLLOW), # follower outranks on-demand
|
||||||
(False, True, True, NOTICE, FOLLOW),
|
(False, True, True, NOTICE, FOLLOW),
|
||||||
# Scheduled on.
|
# 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, False, NOTICE, WIFI),
|
||||||
(True, False, True, None, FOLLOW),
|
(True, False, True, None, FOLLOW),
|
||||||
(True, False, True, NOTICE, FOLLOW), # follower outranks WiFi
|
(True, False, True, NOTICE, FOLLOW), # follower outranks WiFi
|
||||||
(True, True, False, None, LEGACY),
|
(True, True, False, None, ONDEM),
|
||||||
(True, True, False, NOTICE, LEGACY), # on-demand outranks WiFi
|
(True, True, False, NOTICE, ONDEM), # on-demand outranks WiFi
|
||||||
(True, True, True, None, FOLLOW),
|
(True, True, True, None, FOLLOW),
|
||||||
(True, True, True, NOTICE, FOLLOW),
|
(True, True, True, NOTICE, FOLLOW),
|
||||||
]
|
]
|
||||||
@@ -95,8 +102,15 @@ class TestDecide:
|
|||||||
elif expected is WIFI:
|
elif expected is WIFI:
|
||||||
assert plan == ScreenPlan(WIFI, max_duration=WIFI_NOTICE_DWELL,
|
assert plan == ScreenPlan(WIFI, max_duration=WIFI_NOTICE_DWELL,
|
||||||
notice=NOTICE)
|
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:
|
else:
|
||||||
# A follower paces itself; LEGACY is run()'s existing code.
|
# A follower paces itself.
|
||||||
assert plan == ScreenPlan(expected)
|
assert plan == ScreenPlan(expected)
|
||||||
|
|
||||||
def test_dwells_are_todays(self):
|
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
|
assert (plan.source is OFF) is (not dc.is_display_active), time_str
|
||||||
return plan.source
|
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
|
assert step("23:00:00") is OFF # window ends
|
||||||
dc.on_demand_active = True
|
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 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
|
dc._reset_on_demand_fields() # session ends
|
||||||
assert step("23:01:20") is OFF # same minute: blanks
|
assert step("23:01:20") is OFF # same minute: blanks
|
||||||
dc.on_demand_active = True
|
dc.on_demand_active = True
|
||||||
assert step("06:59:00") is LEGACY
|
assert step("06:59:00") is ONDEM
|
||||||
assert step("07:00:00") is LEGACY # schedule back on mid-session
|
assert step("07:00:00") is ONDEM # schedule back on mid-session
|
||||||
dc._reset_on_demand_fields()
|
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,112 @@
|
|||||||
|
"""A plugin duration that is not a number must not stop the display.
|
||||||
|
|
||||||
|
Several plugins return their ``display_duration`` setting as it is in
|
||||||
|
config.json (``return self.config.get('display_duration', 15.0)``), so a
|
||||||
|
value saved as ``"20"`` or ``null`` -- from the raw config editor, or by
|
||||||
|
hand -- reached run() as a string or None. _resolve_durations then compared
|
||||||
|
it with 0, the TypeError went past every handler in the loop, and the
|
||||||
|
display service exited; systemd restarted it into the same screen and the
|
||||||
|
same crash.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
os.environ.setdefault("EMULATOR", "true")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.display_controller import DisplayController
|
||||||
|
from test._run_loop_harness import FakePlugin, RunLoopHarness
|
||||||
|
|
||||||
|
|
||||||
|
def _controller(plugin_modes):
|
||||||
|
dc = object.__new__(DisplayController)
|
||||||
|
dc.config = {}
|
||||||
|
dc.plugin_modes = plugin_modes
|
||||||
|
return dc
|
||||||
|
|
||||||
|
|
||||||
|
def _plugin(duration, plugin_id='clock-simple'):
|
||||||
|
plugin = MagicMock()
|
||||||
|
plugin.plugin_id = plugin_id
|
||||||
|
plugin.get_display_duration.return_value = duration
|
||||||
|
return plugin
|
||||||
|
|
||||||
|
|
||||||
|
class TestPluginDurationIsCoerced:
|
||||||
|
@pytest.mark.parametrize('value, expected', [
|
||||||
|
('20', 20.0), (' 7.5 ', 7.5), (12, 12.0), (12.5, 12.5)])
|
||||||
|
def test_numbers_and_numeric_strings_are_used(self, value, expected):
|
||||||
|
dc = _controller({'clock': _plugin(value)})
|
||||||
|
duration = dc._get_display_duration('clock')
|
||||||
|
assert duration == expected and isinstance(duration, float)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('value', [
|
||||||
|
None, '', 'twenty', True, False, float('nan'), float('inf'), 'inf',
|
||||||
|
[20], {'seconds': 20}])
|
||||||
|
def test_anything_but_a_finite_number_gets_the_default(self, value):
|
||||||
|
dc = _controller({'clock': _plugin(value)})
|
||||||
|
assert dc._get_display_duration('clock') == 30
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('value', [0, -5, '-5', '0'])
|
||||||
|
def test_a_number_not_above_zero_still_gets_the_15s_rule(self, value):
|
||||||
|
"""Unchanged: _resolve_durations turns it into 15 s, with its warning."""
|
||||||
|
plugin = _plugin(value)
|
||||||
|
dc = _controller({'clock': plugin})
|
||||||
|
base = dc._get_display_duration('clock')
|
||||||
|
assert dc._resolve_durations(plugin, 'clock', base, False)[1] == 15.0
|
||||||
|
|
||||||
|
def test_a_raising_get_display_duration_gets_the_default(self):
|
||||||
|
plugin = _plugin(None)
|
||||||
|
plugin.get_display_duration.side_effect = KeyError('display_duration')
|
||||||
|
assert _controller({'clock': plugin})._get_display_duration('clock') == 30
|
||||||
|
|
||||||
|
def test_the_result_feeds_resolve_durations(self):
|
||||||
|
"""The two calls run() makes back to back, for one screen."""
|
||||||
|
plugin = _plugin('bad')
|
||||||
|
dc = _controller({'clock': plugin})
|
||||||
|
base = dc._get_display_duration('clock')
|
||||||
|
assert dc._resolve_durations(plugin, 'clock', base, False) == (30, 30)
|
||||||
|
|
||||||
|
def test_logged_once_per_plugin(self, caplog):
|
||||||
|
dc = _controller({'clock': _plugin('twenty'),
|
||||||
|
'clock_big': _plugin('twenty'),
|
||||||
|
'calendar': _plugin(None, plugin_id='calendar')})
|
||||||
|
# clock_big is a second mode of the same plugin.
|
||||||
|
dc.plugin_modes['clock_big'].plugin_id = 'clock-simple'
|
||||||
|
with caplog.at_level(logging.WARNING, logger='src.display_controller'):
|
||||||
|
for _ in range(3):
|
||||||
|
for mode in ('clock', 'clock_big', 'calendar'):
|
||||||
|
dc._get_display_duration(mode)
|
||||||
|
warnings = [r for r in caplog.records if 'display duration' in r.getMessage()]
|
||||||
|
assert len(warnings) == 2
|
||||||
|
assert {'clock-simple', 'calendar'} == {
|
||||||
|
next(p for p in ('clock-simple', 'calendar') if p in r.getMessage())
|
||||||
|
for r in warnings}
|
||||||
|
|
||||||
|
def test_a_good_value_after_a_bad_one_is_used(self):
|
||||||
|
plugin = _plugin(None)
|
||||||
|
dc = _controller({'clock': plugin})
|
||||||
|
assert dc._get_display_duration('clock') == 30
|
||||||
|
plugin.get_display_duration.return_value = 45
|
||||||
|
assert dc._get_display_duration('clock') == 45.0
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunLoopSurvives:
|
||||||
|
"""Through the real run() on the harness's fake clock."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('duration, shown_for', [('20', 20.0), (None, 30.0),
|
||||||
|
('twenty', 30.0)])
|
||||||
|
def test_the_screen_runs_and_the_rotation_goes_on(self, tmp_path, duration, shown_for):
|
||||||
|
harness = RunLoopHarness(tmp_path, horizon=120)
|
||||||
|
harness.add_plugin(FakePlugin("weather", ["weather"], duration=30))
|
||||||
|
harness.add_plugin(FakePlugin("clock-simple", ["clock"], duration=duration))
|
||||||
|
# Before the fix run() returned at t=30, when the clock came up, and
|
||||||
|
# the harness raised "run() returned ... before the horizon".
|
||||||
|
rows = harness.run()["screens"]
|
||||||
|
clock = next(row for row in rows if row[1] == "clock")
|
||||||
|
assert math.isclose(clock[2], shown_for, abs_tol=1.0)
|
||||||
|
assert [row[1] for row in rows][:3] == ["weather", "clock", "weather"]
|
||||||
@@ -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()
|
||||||
@@ -430,6 +430,157 @@ class TestClear:
|
|||||||
assert "clear request" in response.get_json()["message"]
|
assert "clear request" in response.get_json()["message"]
|
||||||
|
|
||||||
|
|
||||||
|
CLIENT = "web_interface.blueprints.api_v3.control_client"
|
||||||
|
|
||||||
|
|
||||||
|
def _mailbox_file(shared_cache):
|
||||||
|
_, _, directory = shared_cache
|
||||||
|
return directory / f"{ERROR_CLEAR_REQUEST_KEY}.json"
|
||||||
|
|
||||||
|
|
||||||
|
class TestClearOverTheSocket:
|
||||||
|
"""``errors.clear``: the display applies the clear before it answers, and
|
||||||
|
the mailbox is written only when the socket could not carry it."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def socket_up(self, display, monkeypatch):
|
||||||
|
"""The control socket, as the display serves it: errors_clear runs
|
||||||
|
the display's own handler against its publisher."""
|
||||||
|
from src.ipc import client as control_client
|
||||||
|
from src.ipc.contract import ErrorsClearArgs
|
||||||
|
_, publisher, _ = display
|
||||||
|
monkeypatch.setattr(errors, "_snapshot_publisher", publisher)
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def errors_clear(request_id, cutoff, **kw):
|
||||||
|
calls.append((request_id, cutoff))
|
||||||
|
return errors.apply_error_clear(request_id, ErrorsClearArgs(cutoff=cutoff))
|
||||||
|
|
||||||
|
monkeypatch.setattr(f"{CLIENT}.errors_clear", errors_clear)
|
||||||
|
assert control_client.errors_clear is errors_clear
|
||||||
|
return calls
|
||||||
|
|
||||||
|
def test_socket_clear_is_applied_before_the_answer(self, web, display, socket_up,
|
||||||
|
shared_cache):
|
||||||
|
aggregator, publisher, _ = display
|
||||||
|
for _ in range(3):
|
||||||
|
_fail(aggregator)
|
||||||
|
publisher.tick()
|
||||||
|
response = web.post("/api/v3/errors/clear", json={"all": True})
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.get_json()
|
||||||
|
data = body["data"]
|
||||||
|
assert data["transport"] == "socket" and data["applied"] is True
|
||||||
|
assert data["cleared_count"] == 3
|
||||||
|
assert body["message"] == "Cleared all errors"
|
||||||
|
[(request_id, _)] = socket_up
|
||||||
|
assert data["request_id"] == request_id
|
||||||
|
# Applied already: no display tick needed, nothing pending.
|
||||||
|
assert aggregator.get_error_summary()["total_errors"] == 0
|
||||||
|
summary = _summary(web)
|
||||||
|
assert summary["total_errors"] == 0 and summary["clear_pending"] is False
|
||||||
|
# And no mailbox file.
|
||||||
|
assert not _mailbox_file(shared_cache).exists()
|
||||||
|
|
||||||
|
def test_an_older_mailbox_request_does_not_read_as_pending(self, web, display, socket_up,
|
||||||
|
shared_cache, monkeypatch):
|
||||||
|
# A clear that went to the mailbox while the socket was down, then a
|
||||||
|
# wider one over the socket: the old request has nothing left to hide.
|
||||||
|
from unittest.mock import patch
|
||||||
|
from src.ipc import client as control_client
|
||||||
|
aggregator, publisher, _ = display
|
||||||
|
_fail(aggregator)
|
||||||
|
publisher.tick()
|
||||||
|
with patch(f"{CLIENT}.errors_clear",
|
||||||
|
side_effect=control_client.ControlError("no_socket")):
|
||||||
|
data = web.post("/api/v3/errors/clear", json={"max_age_hours": 1}).get_json()["data"]
|
||||||
|
assert data["transport"] == "mailbox"
|
||||||
|
assert _mailbox_file(shared_cache).exists()
|
||||||
|
data = web.post("/api/v3/errors/clear", json={"all": True}).get_json()["data"]
|
||||||
|
assert data["transport"] == "socket"
|
||||||
|
assert _summary(web)["clear_pending"] is False
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("reason", ["no_socket", "refused", "disabled", "unsupported"])
|
||||||
|
def test_no_socket_writes_the_mailbox(self, web, display, shared_cache, monkeypatch, reason):
|
||||||
|
from src.ipc import client as control_client
|
||||||
|
monkeypatch.setattr(f"{CLIENT}.errors_clear", MagicMock(
|
||||||
|
side_effect=control_client.ControlError(reason, sent=False)))
|
||||||
|
aggregator, publisher, _ = display
|
||||||
|
_fail(aggregator)
|
||||||
|
publisher.tick()
|
||||||
|
data = web.post("/api/v3/errors/clear", json={"all": True}).get_json()["data"]
|
||||||
|
assert data["transport"] == "mailbox" and data["applied"] is False
|
||||||
|
assert _mailbox_file(shared_cache).exists()
|
||||||
|
assert _summary(web)["clear_pending"] is True
|
||||||
|
publisher.tick()
|
||||||
|
assert aggregator.get_error_summary()["total_errors"] == 0
|
||||||
|
|
||||||
|
def test_an_older_display_gets_the_mailbox(self, web, display, shared_cache, monkeypatch):
|
||||||
|
# The upgrade case: new web interface, a display from before errors.clear.
|
||||||
|
from src.ipc import client as control_client
|
||||||
|
monkeypatch.setattr(f"{CLIENT}.errors_clear", MagicMock(
|
||||||
|
side_effect=control_client.ControlError("unknown_command", sent=True)))
|
||||||
|
aggregator, publisher, _ = display
|
||||||
|
_fail(aggregator)
|
||||||
|
publisher.tick()
|
||||||
|
data = web.post("/api/v3/errors/clear", json={"all": True}).get_json()["data"]
|
||||||
|
assert data["transport"] == "mailbox"
|
||||||
|
publisher.tick()
|
||||||
|
assert aggregator.get_error_summary()["total_errors"] == 0
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("reason", ["internal", "timeout", "busy", "invalid_args"])
|
||||||
|
def test_a_display_that_had_it_and_failed_is_an_error(self, web, display, shared_cache,
|
||||||
|
monkeypatch, reason):
|
||||||
|
from src.ipc import client as control_client
|
||||||
|
monkeypatch.setattr(f"{CLIENT}.errors_clear", MagicMock(
|
||||||
|
side_effect=control_client.ControlError(reason, sent=True)))
|
||||||
|
response = web.post("/api/v3/errors/clear", json={"all": True})
|
||||||
|
assert response.status_code == 503
|
||||||
|
assert response.get_json()["context"]["socket_error"] == reason
|
||||||
|
assert not _mailbox_file(shared_cache).exists()
|
||||||
|
|
||||||
|
|
||||||
|
class TestPublisherMailboxPoll:
|
||||||
|
def test_the_mailbox_is_read_only_when_its_file_changed(self, display, shared_cache):
|
||||||
|
_, publisher, _ = display
|
||||||
|
_, web_cache, _ = shared_cache
|
||||||
|
publisher.tick()
|
||||||
|
publisher.cache_manager = MagicMock(wraps=publisher.cache_manager)
|
||||||
|
|
||||||
|
def reads():
|
||||||
|
return [c for c in publisher.cache_manager.get.call_args_list
|
||||||
|
if c.args[0] == ERROR_CLEAR_REQUEST_KEY]
|
||||||
|
|
||||||
|
for _ in range(5):
|
||||||
|
publisher.tick()
|
||||||
|
assert reads() == [] # no file: a stat per tick, no read
|
||||||
|
errors.request_error_clear(web_cache, 1.0)
|
||||||
|
publisher.tick()
|
||||||
|
assert len(reads()) == 1
|
||||||
|
for _ in range(5):
|
||||||
|
publisher.tick()
|
||||||
|
assert len(reads()) == 1 # unchanged file: not read again
|
||||||
|
errors.request_error_clear(web_cache, 2.0)
|
||||||
|
publisher.tick()
|
||||||
|
assert len(reads()) == 2
|
||||||
|
|
||||||
|
def test_clear_now_publishes_what_it_applied(self, display, shared_cache):
|
||||||
|
aggregator, publisher, _ = display
|
||||||
|
_, web_cache, _ = shared_cache
|
||||||
|
_fail(aggregator)
|
||||||
|
assert publisher.clear_now("sock-1", datetime.now().timestamp() + 1) == 1
|
||||||
|
snapshot = web_cache.get(ERROR_SNAPSHOT_KEY, max_age=None, memory_ttl=0)
|
||||||
|
assert snapshot["applied_clear_id"] == "sock-1"
|
||||||
|
assert snapshot["total_errors"] == 0
|
||||||
|
assert snapshot["applied_clear_cutoff"] is not None
|
||||||
|
|
||||||
|
def test_the_handler_needs_a_running_publisher(self, monkeypatch):
|
||||||
|
from src.ipc.contract import ErrorsClearArgs
|
||||||
|
monkeypatch.setattr(errors, "_snapshot_publisher", None)
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
errors.apply_error_clear("x", ErrorsClearArgs(cutoff=1.0))
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(not hasattr(os, "fchmod") or os.name == "nt",
|
@pytest.mark.skipif(not hasattr(os, "fchmod") or os.name == "nt",
|
||||||
reason="POSIX file modes")
|
reason="POSIX file modes")
|
||||||
def test_both_files_are_group_readable(web, display, shared_cache):
|
def test_both_files_are_group_readable(web, display, shared_cache):
|
||||||
|
|||||||
@@ -489,3 +489,171 @@ class TestConcurrency:
|
|||||||
|
|
||||||
assert live["peak"] <= espn_dates.ESPN_CHUNK_WORKERS
|
assert live["peak"] <= espn_dates.ESPN_CHUNK_WORKERS
|
||||||
assert live["peak"] > 1, "chunks should actually overlap"
|
assert live["peak"] > 1, "chunks should actually overlap"
|
||||||
|
|
||||||
|
|
||||||
|
class TestEdgeMonths:
|
||||||
|
"""A window's partial edge months are asked whole and trimmed.
|
||||||
|
|
||||||
|
The default scoreboard window -- a fortnight either side of today -- spans
|
||||||
|
two partial months, so it used to cost 29 day requests per league. ESPN's
|
||||||
|
``dates=YYYYMMDD`` means a US Eastern day (verified against the live API
|
||||||
|
on 2026-10-03, 417 of 417 soccer events), so a month answer trimmed to
|
||||||
|
the window's Eastern days is what the day requests returned.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_a_fortnight_either_side_is_two_requests(self):
|
||||||
|
planned = espn_dates.espn_request_chunks(date(2026, 9, 20), date(2026, 10, 18))
|
||||||
|
assert planned == [
|
||||||
|
("202609", (date(2026, 9, 20), date(2026, 9, 30))),
|
||||||
|
("202610", (date(2026, 10, 1), date(2026, 10, 18))),
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_a_live_polls_two_days_stay_two_days(self):
|
||||||
|
planned = espn_dates.espn_request_chunks(date(2026, 10, 2), date(2026, 10, 3))
|
||||||
|
assert planned == [("20261002", None), ("20261003", None)]
|
||||||
|
|
||||||
|
def test_the_threshold_is_inclusive(self):
|
||||||
|
n = espn_dates.ESPN_MONTH_COVER_MIN_DAYS
|
||||||
|
short = espn_dates.espn_request_chunks(date(2026, 10, 1), date(2026, 10, n - 1))
|
||||||
|
assert [chunk for chunk, _ in short] == [
|
||||||
|
"202610%02d" % day for day in range(1, n)]
|
||||||
|
enough = espn_dates.espn_request_chunks(date(2026, 10, 1), date(2026, 10, n))
|
||||||
|
assert enough == [("202610", (date(2026, 10, 1), date(2026, 10, n)))]
|
||||||
|
|
||||||
|
def test_whole_months_and_short_edges_are_unchanged(self):
|
||||||
|
planned = espn_dates.espn_request_chunks(date(2026, 8, 30), date(2026, 10, 2))
|
||||||
|
assert planned == [
|
||||||
|
("20260830", None), ("20260831", None), ("202609", None),
|
||||||
|
("20261001", None), ("20261002", None),
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_without_time_zone_data_edges_stay_days(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(espn_dates, "_EASTERN", None)
|
||||||
|
planned = espn_dates.espn_request_chunks(date(2026, 9, 20), date(2026, 10, 18))
|
||||||
|
assert len(planned) == 29
|
||||||
|
assert all(trim is None for _, trim in planned)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("start,end", [
|
||||||
|
(date(2026, 9, 20), date(2026, 10, 18)),
|
||||||
|
(date(2026, 1, 25), date(2026, 3, 3)),
|
||||||
|
(date(2026, 12, 20), date(2027, 1, 9)),
|
||||||
|
(date(2026, 10, 5), date(2026, 10, 9)),
|
||||||
|
])
|
||||||
|
def test_the_planned_requests_still_cover_every_day_exactly_once(self, start, end):
|
||||||
|
covered = []
|
||||||
|
for chunk, trim in espn_dates.espn_request_chunks(start, end):
|
||||||
|
if trim is None:
|
||||||
|
covered.extend(days_covered_by([chunk]))
|
||||||
|
else:
|
||||||
|
assert chunk == trim[0].strftime("%Y%m") == trim[1].strftime("%Y%m")
|
||||||
|
covered.extend(trim[0] + timedelta(days=offset)
|
||||||
|
for offset in range((trim[1] - trim[0]).days + 1))
|
||||||
|
expected = [start + timedelta(days=offset) for offset in range((end - start).days + 1)]
|
||||||
|
assert covered == expected
|
||||||
|
|
||||||
|
def test_a_trimmed_month_keeps_only_the_windows_eastern_days(self):
|
||||||
|
september = [
|
||||||
|
# 03:30Z on the 20th is still the 19th in New York: outside.
|
||||||
|
{"id": "before", "date": "2026-09-20T03:30Z"},
|
||||||
|
{"id": "first", "date": "2026-09-20T14:00Z"},
|
||||||
|
{"id": "late", "date": "2026-09-30T23:30Z"},
|
||||||
|
]
|
||||||
|
october = [
|
||||||
|
{"id": "oct1", "date": "2026-10-01T19:00Z"},
|
||||||
|
# 03:30Z on the 19th is the evening of the 18th in New York: inside.
|
||||||
|
{"id": "last", "date": "2026-10-19T03:30Z"},
|
||||||
|
{"id": "after", "date": "2026-10-19T14:00Z"},
|
||||||
|
{"id": "undated"},
|
||||||
|
]
|
||||||
|
session = FakeSession({"202609": september, "202610": october})
|
||||||
|
|
||||||
|
data = fetch_espn_date_chunks(session, URL, params={"dates": "20260920-20261018"})
|
||||||
|
|
||||||
|
assert sorted(call["dates"] for call in session.calls) == ["202609", "202610"]
|
||||||
|
# An event with no readable date is kept, never dropped on a guess.
|
||||||
|
assert [e["id"] for e in data["events"]] == ["first", "late", "oct1", "last", "undated"]
|
||||||
|
|
||||||
|
def test_eastern_standard_time_is_honoured_after_the_clocks_change(self):
|
||||||
|
# 2026-11-01 ends daylight saving: Eastern is UTC-5 from then on.
|
||||||
|
november = [
|
||||||
|
{"id": "out", "date": "2026-11-15T04:30Z"}, # Nov 14, 23:30 EST
|
||||||
|
{"id": "in", "date": "2026-11-15T05:30Z"}, # Nov 15, 00:30 EST
|
||||||
|
]
|
||||||
|
session = FakeSession({"202611": november})
|
||||||
|
data = fetch_espn_date_chunks(session, URL, params={"dates": "20261115-20261121"})
|
||||||
|
assert [e["id"] for e in data["events"]] == ["in"]
|
||||||
|
|
||||||
|
def test_a_capped_edge_month_re_asks_only_the_windows_days(self):
|
||||||
|
full = [{"id": "cap%d" % i, "date": "2026-10-05T18:00Z"} for i in range(ESPN_MAX_LIMIT)]
|
||||||
|
by_chunk = {"202610": full}
|
||||||
|
by_chunk.update({"202610%02d" % day: [{"id": "o%02d" % day}] for day in range(1, 32)})
|
||||||
|
session = FakeSession(by_chunk)
|
||||||
|
|
||||||
|
data = fetch_espn_date_chunks(session, URL, params={"dates": "20261001-20261010"})
|
||||||
|
|
||||||
|
sent = [call["dates"] for call in session.calls]
|
||||||
|
assert sent[0] == "202610"
|
||||||
|
assert sorted(sent[1:]) == ["202610%02d" % day for day in range(1, 11)]
|
||||||
|
assert [e["id"] for e in data["events"]] == ["o%02d" % day for day in range(1, 11)]
|
||||||
|
|
||||||
|
|
||||||
|
class TestProcessWideChunkCap:
|
||||||
|
"""The chunk cap holds across windows, not per window.
|
||||||
|
|
||||||
|
A soccer board starting eight leagues fetches sixteen windows at once.
|
||||||
|
With a pool of ``ESPN_CHUNK_WORKERS`` each, ~40 requests were in flight
|
||||||
|
and every one past a session's pool opened a connection -- and a DNS
|
||||||
|
lookup. On ledpi that was ~90 NameResolutionErrors per start.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_concurrent_windows_share_one_budget(self):
|
||||||
|
live = {"now": 0, "peak": 0}
|
||||||
|
guard = threading.Lock()
|
||||||
|
|
||||||
|
class CountingSession(FakeSession):
|
||||||
|
def get(self, url, params=None, headers=None, timeout=None):
|
||||||
|
with guard:
|
||||||
|
live["now"] += 1
|
||||||
|
live["peak"] = max(live["peak"], live["now"])
|
||||||
|
try:
|
||||||
|
time.sleep(0.01)
|
||||||
|
return super().get(url, params=params, headers=headers, timeout=timeout)
|
||||||
|
finally:
|
||||||
|
with guard:
|
||||||
|
live["now"] -= 1
|
||||||
|
|
||||||
|
sessions = [CountingSession() for _ in range(6)]
|
||||||
|
# Six leagues, so the fetch service cannot merge them into one, on a
|
||||||
|
# host with no token bucket: earlier tests may have spent ESPN's
|
||||||
|
# burst, and a bucket paced at 20/s would serialise these by itself.
|
||||||
|
threads = [
|
||||||
|
threading.Thread(target=fetch_espn_date_chunks,
|
||||||
|
args=(session, "https://scores.example.test/league%d" % index),
|
||||||
|
kwargs={"params": {"dates": "20260101-20261231"}})
|
||||||
|
for index, session in enumerate(sessions)
|
||||||
|
]
|
||||||
|
for thread in threads:
|
||||||
|
thread.start()
|
||||||
|
for thread in threads:
|
||||||
|
thread.join(timeout=30)
|
||||||
|
|
||||||
|
assert all(len(session.calls) == 12 for session in sessions)
|
||||||
|
assert live["peak"] <= espn_dates.ESPN_CHUNK_WORKERS
|
||||||
|
assert live["peak"] > 1, "chunks should still overlap"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_fresh_process_skips_the_doomed_range_request():
|
||||||
|
"""Every start used to spend one 400 per window learning that ranges are
|
||||||
|
still rejected -- eleven at once from a soccer board. A new process now
|
||||||
|
starts inside the retry period instead."""
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
out = subprocess.run(
|
||||||
|
[sys.executable, "-c",
|
||||||
|
"import src.common.espn_dates as e; print(e._ranges_known_rejected())"],
|
||||||
|
cwd=str(Path(__file__).resolve().parents[1]),
|
||||||
|
capture_output=True, text=True, timeout=60,
|
||||||
|
)
|
||||||
|
assert out.stdout.strip() == "True", out.stderr
|
||||||
|
|||||||
@@ -576,6 +576,42 @@ class TestCounters:
|
|||||||
assert snap["hosts"]["site.api.espn.com"]["requests"] == 1
|
assert snap["hosts"]["site.api.espn.com"]["requests"] == 1
|
||||||
assert snap["totals"]["bytes"] == 3 * len(b'{"ok": 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 test_errors_and_http_errors(self, service):
|
||||||
def handler(url, kwargs):
|
def handler(url, kwargs):
|
||||||
if url.endswith("/down"):
|
if url.endswith("/down"):
|
||||||
@@ -670,14 +706,14 @@ class TestCallerIdentity:
|
|||||||
assert _counters(global_service, plugin="football-scoreboard")["requests"] == 1
|
assert _counters(global_service, plugin="football-scoreboard")["requests"] == 1
|
||||||
|
|
||||||
def test_espn_chunks_on_worker_threads_count_against_the_caller(self, global_service):
|
def test_espn_chunks_on_worker_threads_count_against_the_caller(self, global_service):
|
||||||
from src.common.espn_dates import espn_date_chunks, fetch_espn_date_chunks, parse_espn_date_range
|
from src.common.espn_dates import espn_request_chunks, fetch_espn_date_chunks, parse_espn_date_range
|
||||||
|
|
||||||
session = FakeSession(lambda url, kw: make_response(body=b'{"events": []}', url=url))
|
session = FakeSession(lambda url, kw: make_response(body=b'{"events": []}', url=url))
|
||||||
dates = "20260801-20261015"
|
dates = "20260801-20261015"
|
||||||
with plugin_scope("baseball-scoreboard"):
|
with plugin_scope("baseball-scoreboard"):
|
||||||
fetch_espn_date_chunks(session, "https://site.api.espn.com/s/scoreboard",
|
fetch_espn_date_chunks(session, "https://site.api.espn.com/s/scoreboard",
|
||||||
params={"dates": dates})
|
params={"dates": dates})
|
||||||
chunks = len(espn_date_chunks(*parse_espn_date_range(dates)))
|
chunks = len(espn_request_chunks(*parse_espn_date_range(dates)))
|
||||||
assert chunks > 1
|
assert chunks > 1
|
||||||
assert len(session.calls) == chunks
|
assert len(session.calls) == chunks
|
||||||
assert _counters(global_service, plugin="baseball-scoreboard")["requests"] == chunks
|
assert _counters(global_service, plugin="baseball-scoreboard")["requests"] == chunks
|
||||||
|
|||||||
@@ -185,6 +185,149 @@ class TestPluginFonts:
|
|||||||
assert fm.font_catalog["my-plugin::bundled"] == str(plugin_dir / "fonts" / "Bundled.ttf")
|
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:
|
class TestDownloadFont:
|
||||||
"""_download_font: plugin fonts declared by URL, cached in temp_font_dir."""
|
"""_download_font: plugin fonts declared by URL, cached in temp_font_dir."""
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user