mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-05 14:55:08 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ea1fd42df |
-410
@@ -19,217 +19,6 @@ accepts both, but the store flags the old spelling as deprecated
|
||||
|
||||
## Unreleased
|
||||
|
||||
### 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.
|
||||
|
||||
### ESPN date-range fetches: fewer requests, fewer at once
|
||||
|
||||
A soccer board (8 leagues, ESPN rejecting `dates=` ranges) logged ~90
|
||||
`NameResolutionError` lines and an `update() timed out` at every start on a
|
||||
Pi: each league's fortnight-either-side window was 29 day requests, fetched
|
||||
by several managers at once, ~40 in flight. Measured against live ESPN with
|
||||
soccer-scoreboard 2.39.2, alternating runs: **~450 requests per start, peak
|
||||
~45 in flight, ~75 DNS lookups -> 46 requests, peak 13, ~30 lookups**.
|
||||
|
||||
- `fetch_espn_date_chunks()` asks for a window's partial edge month whole
|
||||
when the window covers `ESPN_MONTH_COVER_MIN_DAYS` (7) or more of its days,
|
||||
and trims the answer to the window's days by each event's US Eastern start
|
||||
date -- the day ESPN's `dates=YYYYMMDD` means (417 of 417 live soccer
|
||||
events matched). A 29-day window spanning two months is 2 requests instead
|
||||
of 29. Short windows (a live poll's 1-2 days) stay day by day. A trimmed
|
||||
month that comes back at the 500-event cap re-asks only the window's days.
|
||||
An event with no readable date is kept. New: `espn_request_chunks()`.
|
||||
- Chunk requests share one process-wide cap of `ESPN_CHUNK_WORKERS` (6) in
|
||||
flight, across every window being fetched, instead of six per window.
|
||||
- A new process starts as if a range had just been rejected, so it no longer
|
||||
spends one doomed 400 per window at every start (eleven at once from a
|
||||
soccer board); the range is still retried `RANGE_RETRY_SECONDS` in.
|
||||
|
||||
### Fetch stats: bytes on the wire, not just decoded
|
||||
|
||||
`GET /api/v3/plugins/fetch-stats` reported only `bytes`, the decoded body
|
||||
size, and that read as the download volume. ESPN gzips every scoreboard, so
|
||||
it overstated what crossed the network about 14x: a college football
|
||||
Saturday's scoreboard is 865 KB decoded and 63 KB on the wire, and ledpi's
|
||||
"643 MB in 6 hours" of football was ~47 MB of actual traffic. Every counter
|
||||
set (totals, per plugin, per host) now has `wire_bytes` too, read from
|
||||
urllib3's count of the raw bytes it took off the socket. A response with no
|
||||
urllib3 response behind it is counted at its decoded size. `bytes` keeps its
|
||||
meaning.
|
||||
|
||||
### Cheap per-frame and per-fetch savings
|
||||
|
||||
- `BaseOddsManager.get_odds()` no longer pretty-prints every odds response
|
||||
@@ -716,54 +505,6 @@ policies are unchanged.
|
||||
stored `ttl` was stretched the same way. A memory hit is now also checked against
|
||||
the record's own timestamp, and a stale one falls through to disk, which
|
||||
returns a newer write if there is one.
|
||||
- An on-demand request that names a `*_live` mode now shows that mode. On
|
||||
ledpi, `{"plugin_id": "football-scoreboard", "mode": "ncaa_fb_live"}` with
|
||||
15 college games on answered 200 and showed `nfl_recent`. The session's
|
||||
mode list kept a live mode only when the plugin's `has_live_content()`
|
||||
said so. That method answers the live-priority question, and the sports
|
||||
plugins answer it for favourite teams only. A mode the request names
|
||||
(not one resolved from a bare plugin id) now leads the session, with the
|
||||
plugin's other modes after it. If it has nothing to draw, the session
|
||||
moves on to the next of those modes, like any empty on-demand mode. The
|
||||
name is saved with the session (`named_mode` in
|
||||
`display_on_demand_config`), so a restart resumes on it.
|
||||
- A restart during an on-demand session whose plugin then fails to load no
|
||||
longer leaves a session with no modes. On ledpi, `clock-simple` failed
|
||||
config validation after a crash. The display logged `No valid display
|
||||
modes found for on-demand plugin 'clock-simple' after restoration` and
|
||||
kept reporting the session as active until its first pass ended it as
|
||||
`idle`. The cached request stayed behind for the next restart. The session
|
||||
now ends at startup with status `error` and error `restore-failed`, which
|
||||
`/display/on-demand/status` reports, and the cached request is dropped. The
|
||||
same applies when the plugin system itself fails to start.
|
||||
- `POST /api/v3/config/schedule` and `/config/dim-schedule` accept a
|
||||
disabled per-day schedule with every day off. That is the shape
|
||||
`config.template.json` ships, so posting back what GET returned on a fresh
|
||||
install answered 400 "At least one day must be enabled". An enabled per-day
|
||||
schedule still needs a day on. A day that is off now keeps the times it
|
||||
was posted with (the schedule picker sends them). Before, saving dropped
|
||||
them, so turning the day back on showed the defaults.
|
||||
- `POST /api/v3/config/main` answers `restart_required: true` only when the
|
||||
save changed a setting the running display does not apply by itself.
|
||||
Brightness (`brightness.set` and the config watcher), the per-mode
|
||||
durations and plugin sections are applied live. A brightness-only save,
|
||||
such as the MQTT bridge's slider, or a save that changed nothing, no longer
|
||||
shows the restart banner. Hardware, rotation order, timezone and every
|
||||
other setting still ask for the restart.
|
||||
- `GET /api/v3/health` reports `degraded` when the display service is
|
||||
stopped. Before, only the sub-checks changed, and the overall status stayed
|
||||
`healthy` for as long as the last preview frame was under 60 s old.
|
||||
`checks.display_loop.status` is now `stopped` when three things agree:
|
||||
systemd says the service is not active, the control socket does not
|
||||
answer, and there is no live heartbeat. Where the platform has no socket
|
||||
(Windows) or it is switched off, nothing changes.
|
||||
- `GET /api/v3/display/current-status` no longer reports the stopped
|
||||
display's last state (`is_display_active: true`) from the cache for up to
|
||||
120 s. When the control socket does not answer and the render loop's
|
||||
heartbeat is absent, stale, or from a process that is gone (#726's rules),
|
||||
the answer is unknown, with every field `null`. A display that still beats
|
||||
without a socket, Windows and a socket switched off read the cache as
|
||||
before. New `web_interface.display_state.display_gone()`.
|
||||
- The garbage-collection timer (`GcMonitor`, above) no longer prints
|
||||
`Exception ignored while calling GC callback ... 'NoneType' object has no
|
||||
attribute 'perf_counter'` when the display service or a test run exits.
|
||||
@@ -877,57 +618,6 @@ policies are unchanged.
|
||||
the plugin leaves rotation until the cooldown ends, the same as a raising
|
||||
`update()`. The display still moves straight on to the next mode. A hung
|
||||
`display()` is still recorded once, as a hang.
|
||||
- A plugin settings save that failed validation no longer leaks into the next
|
||||
save. `ConfigManager.load_config()` returned its cached config itself (the
|
||||
fast path from #410), so the form save's edits went into the cache before
|
||||
validation ran, and a refused save left them there. The next save of any
|
||||
other setting (another plugin's, a plugin toggle, the schedule) wrote them
|
||||
to config.json: the refused value, and a nested secret typed into the same
|
||||
form (`mqtt.password`, `league.espn_s2`, `flightaware.api_key`) in plain
|
||||
text, because it had never reached config_secrets.json to be stripped.
|
||||
The form also reloaded showing the refused values. `load_config()` now
|
||||
returns a private copy, and the saves keep one, so nothing a caller edits
|
||||
reaches the cache unless it is saved. The copy duplicates only the dicts
|
||||
and lists (every other JSON value is immutable): 2.1 ms for a real 60 KiB
|
||||
config on a Pi 4, against 6.8 ms for `copy.deepcopy`.
|
||||
- `GET /api/v3/plugins/config` no longer returns secrets. It sent back the
|
||||
plugin's section with config_secrets.json merged in, API keys and tokens
|
||||
in plain text: the masking #276 added was dropped in #330. It also took
|
||||
any id, so `?plugin_id=web_auth` returned the login's cookie-signing key
|
||||
and password hash and `?plugin_id=github` the Plugin Store token. Secret
|
||||
fields now come back blank, as the settings page renders them, and a
|
||||
plugin with no schema has its credential-named fields blanked, as
|
||||
`GET /config/main` does. Blank rather than the `••••••••` of
|
||||
`GET /config/secrets`, because the save reads a blank secret as
|
||||
"unchanged", so a client can post the response back without erasing
|
||||
one. Core sections and malformed ids get a 400, as they already did from
|
||||
reset and uninstall.
|
||||
- Plugin settings with a table (a list of rows, such as geochron's cities
|
||||
or the countdowns) save again when a text cell is blank or holds only
|
||||
digits. A row posts its cells as `cities.0.timezone`, and the schema
|
||||
lookup stopped at the list, so each cell was parsed with no schema: a
|
||||
blank optional text cell became null, and a name like "2027" became a
|
||||
number. Either failed validation, and every save of the page failed for
|
||||
as long as the row existed. A plugin with a secret in its rows could not
|
||||
be saved from the page at all, since the secret cell is drawn blank. The
|
||||
lookup now steps from the index into the list's item schema.
|
||||
- A plugin whose API key is required and has no default (youtube-stats)
|
||||
can be saved from its settings page without typing the key in again. The
|
||||
page draws a stored secret blank and posts the blank back; for a required
|
||||
secret the save read that blank as null, failed validation, and refused
|
||||
every save of the page. A blank secret field now means "unchanged", as it
|
||||
already did for an optional one.
|
||||
- `POST /api/v3/plugins/config` refuses a core section or a malformed
|
||||
plugin id with a 400, as reset and uninstall already did.
|
||||
`{"plugin_id": "display", ...}` merged unvalidated values into the core
|
||||
display section (and added `"enabled": true` to it), and an id that was
|
||||
not a string answered with a 500.
|
||||
- A plugin text setting saves what was typed when that looks like a
|
||||
boolean or JSON. The form save tried `true`/`false` and `[...]`/`{...}`
|
||||
before it looked at the schema, so a text field holding "true", "False",
|
||||
"[1, 2]" or "{}" was stored as a boolean, list or object, and the save
|
||||
failed validation. Text fields, nullable ones included, are now taken as
|
||||
typed; other types convert as before.
|
||||
- A WiFi notice (such as "Connected to HomeNet" or "AP mode on") now shows
|
||||
within about a second of being posted. It was only checked between
|
||||
screens, so a 5 s notice posted during a 20 s screen expired before that
|
||||
@@ -936,42 +626,6 @@ policies are unchanged.
|
||||
notice is what shows next, and Vegas resumes after it; before, a rotation
|
||||
screen showed instead and the notice expired behind it. An active
|
||||
on-demand session still holds the panel until it ends.
|
||||
- The Config Editor tab no longer shows API keys and tokens in plain
|
||||
text. Its `config_secrets.json` editor (`/partials/raw-json`) was filled
|
||||
with the file as it is on disk, so while the web login is off (the
|
||||
default) anyone who could reach the port could read every credential,
|
||||
although `GET /api/v3/config/secrets` masks them. The editor now shows the
|
||||
same masked values. Saving it unchanged changes nothing, because the save
|
||||
drops the masks and merges onto the stored file; to change a secret,
|
||||
replace its mask. A list of secrets still needs every entry's real value
|
||||
to be changed. The `config.json` editor is unchanged: its save writes the
|
||||
file as given, so a mask there would be stored.
|
||||
- A disabled plugin keeps its place in the rotation order and its Vegas
|
||||
exclusion when the Display or Rotation & Durations tab is saved. The order
|
||||
lists show enabled plugins only and rewrite their hidden inputs from those
|
||||
rows as soon as they are drawn, so any save of either tab stored the lists
|
||||
without the disabled plugin. Once re-enabled, it came back at the end of
|
||||
the rotation and scrolling in Vegas again. A disabled plugin's saved id
|
||||
now stays in its saved place (`widgets/plugin-order-list.js`); the id of
|
||||
a plugin that is no longer installed is still dropped.
|
||||
- Restoring a backup with "Reinstall missing plugins" installs only the
|
||||
plugins that are missing. Every plugin the backup listed was sent to the
|
||||
store's install, which replaces an installed copy with a fresh download,
|
||||
so a restore onto the same device re-downloaded all of them in one
|
||||
request. A plugin installed from its own URL is not in the registry, so
|
||||
its "reinstall" failed and the restore answered "Restore failed" while
|
||||
the plugin sat there installed. An installed plugin, found by the store's
|
||||
own lookup (registry aliases included), is now listed under Skipped as
|
||||
`plugin:<id> (installed)`.
|
||||
- `POST /api/v3/config/main` answers a JSON body that does not parse with
|
||||
400 `Invalid JSON in request body`, as `/config/raw/main` does, and an
|
||||
empty JSON body with 400 `No data provided`. Both were a 500
|
||||
`CONFIG_SAVE_FAILED` suggesting file permissions and disk space, with a
|
||||
traceback logged at ERROR: `get_json()` raised inside the handler's
|
||||
catch-all.
|
||||
- Fonts restored from a backup show up in the Fonts tab and the font
|
||||
pickers straight away. The font catalog is cached for five minutes, and
|
||||
upload and delete cleared it but a restore did not.
|
||||
- A game that goes live now takes over the panel within about a second.
|
||||
Live priority was only checked between screens, so a game that went live
|
||||
during a 30 s screen waited for that screen to end. The frame loops and the
|
||||
@@ -981,45 +635,6 @@ policies are unchanged.
|
||||
screen showed first and the game came after it. Each check also asks each
|
||||
plugin `has_live_content()` once, where a plugin registered under several
|
||||
modes used to be asked once per mode.
|
||||
- A plugin action whose params hold `true`, `false` or `null` runs again.
|
||||
`/api/v3/plugins/action` wrote the params into the source of the wrapper
|
||||
that runs the plugin's script, and those JSON words are not Python, so the
|
||||
wrapper stopped with a NameError and the action answered "Action failed".
|
||||
The plugin file manager's category toggle sends `"enabled": true`, so
|
||||
turning a category on or off in of-the-day always failed. The params now
|
||||
reach the wrapper on its stdin; the script still receives them as JSON on
|
||||
its own stdin, as before.
|
||||
- An on-demand request that `/api/v3/display/on-demand/start` refuses no
|
||||
longer runs later. With the display stopped the request goes to the
|
||||
display's mailbox, and the display reads that mailbox for an hour without
|
||||
looking at a request's age. So with "Start display service" unticked, the
|
||||
answer was "Display service is not running", yet the next time the
|
||||
display was started it ran that plugin, pinned if the request said so.
|
||||
The same happened after "Failed to start display service". On either
|
||||
refusal the route now takes its request back out of the mailbox, unless a
|
||||
newer one has replaced it. A request the display acknowledges over the
|
||||
control socket is now a success whatever systemd reports: a display run
|
||||
by hand or in the emulator was told "not running" for a request it had
|
||||
already taken, and with "Start display service" ticked the route tried to
|
||||
start the service beside it.
|
||||
- `/api/v3/plugins/operation/<id>` reports a queued operation as `pending`
|
||||
instead of answering 500. The queue keeps an operation's callback among
|
||||
its parameters until it runs, and the status route tried to send that
|
||||
function as JSON. An install queued behind another plugin's install
|
||||
failed every status poll until the first one finished. Parameters whose
|
||||
name starts with `_` are internal and are no longer in the answer.
|
||||
- A second click on Install while that plugin is still installing, or an
|
||||
Uninstall during its install, now answers 409 "already has an install,
|
||||
update or uninstall in progress" instead of 500 "An error occurred". The
|
||||
first operation carried on either way. The uninstall route also stopped
|
||||
recording a failed uninstall in the operation history for an uninstall
|
||||
that never started.
|
||||
- `/api/v3/plugins/<plugin_id>/static/<path>` serves images and other
|
||||
binary files. It opened every file as UTF-8 text, so a plugin's icon or
|
||||
preview image answered 500 `UnicodeDecodeError`. Files are now sent as
|
||||
they are on disk, an image with its own content type; HTML, JavaScript,
|
||||
CSS, JSON and other text keep the types they had. The path checks are
|
||||
unchanged.
|
||||
- The display schedule turns the panel off at exactly the end time. A window
|
||||
now runs from its start time up to, but not including, its end time: with
|
||||
07:00-23:00 the panel is on at 07:00 and off at 23:00. Before, the end
|
||||
@@ -1027,31 +642,6 @@ policies are unchanged.
|
||||
the panel went off at 23:00 or at 23:01 depending on when in the minute
|
||||
that check ran. Windows that cross midnight and per-day schedules follow
|
||||
the same rule, and so does the dim schedule.
|
||||
- The MQTT bridge settings on the Tools tab can save a broker password with
|
||||
TLS off. The server refuses that unless `allow_insecure_mqtt` is set, and
|
||||
the form had no way to set it, so a password-protected broker on a home
|
||||
network without TLS could not be saved from the web UI, and once such a
|
||||
password was stored every later save failed too. While "Use TLS" is
|
||||
unchecked the form now shows "Allow without TLS (trusted network)",
|
||||
prefilled from the saved settings. It is off until ticked, so the server
|
||||
still refuses a cleartext password by default.
|
||||
- The Overview's plugin-config warning check stops polling. It asked
|
||||
`/api/v3/plugins/reconciliation-status` every 2 s until startup
|
||||
reconciliation reported done, and the route reports not done whenever its
|
||||
status file is missing: reconciliation raised before writing it, or /tmp
|
||||
was cleaned under a long-running web service. The page then sent that
|
||||
request every 2 s for as long as it stayed open, whichever tab was showing.
|
||||
It now gives up after a minute and only polls while the Overview is on
|
||||
screen.
|
||||
- Moving the Brightness slider on the Display tab no longer throws an error
|
||||
in the browser console on every step. Its handler also updated a "LED
|
||||
brightness" line that was removed from the page in #387; the lookup is
|
||||
gone.
|
||||
- Creating an API token on the General tab no longer leaves the page asking
|
||||
"Leave site?" on reload. The unsaved-changes guard marks a form when you
|
||||
type in it and clears the mark only after an htmx save, and the token form
|
||||
saves with a plain request, so it stayed marked after the token was
|
||||
created. It is cleared once the token is saved.
|
||||
- An on-demand session that ends during scheduled-off hours, by expiring or
|
||||
being stopped, blanks the panel within about a second. It used to stay on
|
||||
until the next minute, because the once-a-minute schedule check had
|
||||
|
||||
@@ -649,9 +649,7 @@ When nothing is running on demand, `data.state` is
|
||||
> on-demand machinery is internal — drive it through the REST endpoints
|
||||
> above (or the web UI buttons). The API handlers
|
||||
> (`start_on_demand_display()` / `stop_on_demand_display()` in
|
||||
> `web_interface/blueprints/api_v3/display.py`) send the request over the
|
||||
> display's control socket ([IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md)).
|
||||
> Only when the socket cannot carry it do they write it into the cache
|
||||
> `web_interface/blueprints/api_v3/display.py`) write a request into the cache
|
||||
> manager under the `display_on_demand_request` key, which
|
||||
> `DisplayController._poll_on_demand_requests()`
|
||||
> (`src/display_controller.py`) picks up. A separate
|
||||
@@ -749,14 +747,8 @@ keys helps troubleshoot stuck states.
|
||||
"timestamp": 1234567890.123
|
||||
}
|
||||
```
|
||||
**Purpose:** Communication from web interface to display controller, as the
|
||||
fallback when the control socket cannot carry the request (deprecated; it
|
||||
will be removed in a later release)
|
||||
**When Set:** API endpoint receives a request and the display's control
|
||||
socket is unavailable (display stopped, or older than the socket or the
|
||||
command); some plugins also write it directly
|
||||
**Read:** once a second while the display serves the control socket (0.25 s
|
||||
without it), and only when the file changed since the last look
|
||||
**Purpose:** Communication from web interface to display controller
|
||||
**When Set:** API endpoint receives request
|
||||
**Auto-Cleared:** After processing or 1 hour TTL
|
||||
|
||||
**2. display_on_demand_config** (No TTL)
|
||||
|
||||
+8
-18
@@ -42,11 +42,11 @@ each other. They share three things:
|
||||
| State | Where | Written by | Read by |
|
||||
|---|---|---|---|
|
||||
| On-demand command | control socket `/run/ledmatrix/control.sock` ([IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md)) | web: `start_on_demand_display()` / `stop_on_demand_display()` in [`api_v3/display.py`](../web_interface/blueprints/api_v3/display.py), via [`src/ipc/client.py`](../src/ipc/client.py) | display: [`src/ipc/server.py`](../src/ipc/server.py) acks; the render thread applies it in `_poll_on_demand_requests()` |
|
||||
| On-demand request (fallback) | cache `display_on_demand_request` | web, only when the socket could not carry the request (`should_fall_back`); four plugins write it directly | display: `_poll_on_demand_requests()`, a `stat()` every 1 s while the socket is up (0.25 s without), read only when the file changed |
|
||||
| On-demand request (fallback) | cache `display_on_demand_request` | web, when the socket fails; four plugins write it directly | display: `_poll_on_demand_requests()` |
|
||||
| On-demand state | cache `display_on_demand_state` | display: `_publish_on_demand_state()` | web: `/api/v3/display/on-demand/status` |
|
||||
| Current screen | cache `display_current_state` | display | web: `/api/v3/display/current-status` |
|
||||
| Plugin errors | cache `plugin_error_snapshot` | display: `ErrorSnapshotPublisher` ([`src/error_aggregator.py`](../src/error_aggregator.py)) | web: `read_error_report()` for `/api/v3/errors/*` |
|
||||
| Error clear | control socket `errors.clear`; cache `plugin_error_clear_request` as the fallback | web: `POST /api/v3/errors/clear` | display: applied before the socket answers; the mailbox on the error publisher's 5 s tick, read only when the file changed |
|
||||
| Error clear | cache `plugin_error_clear_request` | web | display |
|
||||
| Font usage | cache `font_usage_snapshot` | display: `FontUsagePublisher` ([`src/font_usage.py`](../src/font_usage.py)) | web: Fonts tab |
|
||||
| Fetch statistics (requests per plugin and host) | cache `fetch_stats_snapshot` | display: `FetchStatsPublisher` ([`src/common/fetch_service.py`](../src/common/fetch_service.py)), at most once a minute on change | web: `read_fetch_stats()` for `/api/v3/plugins/fetch-stats` |
|
||||
| Plugin health | cache `plugin_health:<id>` | display (web writes on reset) | web: `/api/v3/plugins/health` |
|
||||
@@ -58,16 +58,11 @@ each other. They share three things:
|
||||
|
||||
The on-demand start route starts `ledmatrix.service` when it is not running
|
||||
(`start_service`, on by default) but never restarts a running one. The routes
|
||||
send the command over the display's control socket and get an ack; only when
|
||||
the socket could not carry it (a stopped display, one older than the socket
|
||||
or the command) do they write the mailbox instead. A display that had the
|
||||
request and refused it is answered with the error, not posted a mailbox
|
||||
copy. The display looks at the mailbox every
|
||||
`MAILBOX_POLL_INTERVAL_WITH_SOCKET` (1 s) while it serves the socket, and
|
||||
every `ON_DEMAND_POLL_INTERVAL` (0.25 s) without one, from its dwell sleep,
|
||||
its render loops and Vegas's interrupt check as well as the main loop; a
|
||||
look is one `stat()` unless the file changed. Both ways end in the same
|
||||
handler, `_handle_on_demand_request()`.
|
||||
send the command over the display's control socket and get an ack; when that
|
||||
fails (a stopped display, one older than the socket) they write the mailbox
|
||||
instead, which the display reads every `ON_DEMAND_POLL_INTERVAL` (0.25s), from
|
||||
its dwell sleep, its render loops and Vegas's interrupt check as well as the
|
||||
main loop. Both ways end in the same handler, `_handle_on_demand_request()`.
|
||||
The socket's handlers only queue; see [IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md)
|
||||
for the protocol, the permission model and the plan to retire the mailboxes.
|
||||
|
||||
@@ -143,9 +138,7 @@ loaded and when. Nothing else keeps plugin state:
|
||||
`DisplayController` right after it creates the `PluginManager`, writes the
|
||||
cache key `plugin_runtime_snapshot`: per plugin `loaded`, `state`, `error`
|
||||
(type, a redacted message of at most 200 characters, when, recoverable),
|
||||
`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`.
|
||||
`version` and `loaded_at`, plus `published_at`, `stale_after` and `running`.
|
||||
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
|
||||
minute as a heartbeat. RUNNING, which every `update()` passes through, is
|
||||
@@ -161,9 +154,6 @@ truth cannot leak into a response. `/api/v3/plugins/installed` returns
|
||||
`loaded`, `state`, `error_info`, `loaded_version` and `loaded_at` per
|
||||
plugin and `data.runtime` (`status`, `published_at`, `age_seconds`);
|
||||
`/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**
|
||||
([`state_reconciliation.py`](../src/plugin_system/state_reconciliation.py))
|
||||
|
||||
@@ -203,7 +203,6 @@ Current methods:
|
||||
| `measure_text(text, font)` | `(width, height, baseline)` |
|
||||
| `get_font_height(font)` | Line height |
|
||||
| `register_plugin_fonts(plugin_id, font_manifest)` | Register a plugin's fonts (core calls it at load) |
|
||||
| `forget_plugin_fonts(plugin_id)` | Drop a plugin's manifest fonts and their cached objects (core calls it when a plugin unloads) |
|
||||
| `clear_cache()` | Drop cached fonts and metrics |
|
||||
| `font_catalog` (attribute) | Family name → file path |
|
||||
|
||||
@@ -219,6 +218,5 @@ Removed in 3.8.0, after logging a deprecation warning on first call since
|
||||
| `get_performance_stats()` | — |
|
||||
| `set_override()`, `remove_override()`, `get_overrides()` | a font field in your plugin's config schema |
|
||||
| `get_manager_fonts()`, `get_detected_fonts()` | — |
|
||||
| `get_plugin_fonts()` | — |
|
||||
| `unregister_plugin_fonts()` | `forget_plugin_fonts()` (core calls it on unload) |
|
||||
| `get_plugin_fonts()`, `unregister_plugin_fonts()` | — |
|
||||
| `add_font()`, `remove_font()`, `validate_font()` | the web UI's Fonts tab |
|
||||
|
||||
+24
-133
@@ -7,18 +7,14 @@ status. Stage 2 makes those commands land within a frame on every kind of
|
||||
screen, and adds `brightness.set` and `plugin.reload`. Stage 3 adds a state
|
||||
stream (`state.get`, `state.subscribe`), so the web interface reads what the
|
||||
display is doing from the socket instead of from cache files the display
|
||||
wrote to the SD card. Stage 4 makes the socket the only way a command goes
|
||||
while it works: the web interface writes a mailbox only when the socket
|
||||
cannot carry the request, `errors.clear` replaces the last command that
|
||||
always went through a mailbox, and the display looks at the mailboxes once
|
||||
a second, with a `stat()`. The file mailboxes and the cache keys stay as a
|
||||
fallback for one release.
|
||||
wrote to the SD card. The file mailbox and the cache keys stay as a fallback
|
||||
for one release.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Socket | `/run/ledmatrix/control.sock` (tmpfs) |
|
||||
| Served by | the display process ([`src/ipc/server.py`](../src/ipc/server.py)), started by `DisplayController.run()` |
|
||||
| Used by | the web interface ([`src/ipc/client.py`](../src/ipc/client.py)): `POST /api/v3/display/on-demand/start` and `/stop`, `POST /api/v3/plugins/update` (reload), `POST /api/v3/config/main` (brightness), `POST /api/v3/errors/clear`; and through [`web_interface/display_state.py`](../web_interface/display_state.py) (the state stream), `GET /api/v3/display/current-status`, `/display/on-demand/status`, `/plugins/installed` (`runtime`), `/plugins/state` and the reconciliations, `/health` (`display_loop`) |
|
||||
| Used by | the web interface ([`src/ipc/client.py`](../src/ipc/client.py)): `POST /api/v3/display/on-demand/start` and `/stop`, `POST /api/v3/plugins/update` (reload), `POST /api/v3/config/main` (brightness); and through [`web_interface/display_state.py`](../web_interface/display_state.py) (the state stream), `GET /api/v3/display/current-status`, `/display/on-demand/status`, `/plugins/installed` (`runtime`), `/plugins/state` and the reconciliations, `/health` (`display_loop`) |
|
||||
| Contract | [`src/ipc/contract.py`](../src/ipc/contract.py): messages, versions, framing and the socket path; both sides import it |
|
||||
| Override | `LEDMATRIX_CONTROL_SOCKET=/some/path.sock` for both processes, or `=off` to disable it |
|
||||
|
||||
@@ -89,17 +85,6 @@ one. Clients branch on `error.code`, never on the message text.
|
||||
| `plugin.reload` | `{plugin_id}` | `{plugin_id, reloaded: true, version, modes}` | queued, awaited (10 s) |
|
||||
| `state.get` | `{since?, epoch?}` | a state snapshot (see "The state stream") | answered directly |
|
||||
| `state.subscribe` | — | a state snapshot, then pushed `state` / `tick` events | answered directly, then a stream |
|
||||
| `errors.clear` | `{cutoff: number}` (epoch seconds, finite, ≥ 0) | `{request_id, cutoff, cleared}` | answered directly, once applied |
|
||||
|
||||
`errors.clear` (stage 4) forgets the plugin errors the display recorded at
|
||||
or before `cutoff` and rewrites its error snapshot (`plugin_error_snapshot`)
|
||||
before it answers, so the web interface's next read already has it. The
|
||||
request `id` is the clear's id, which the snapshot reports as
|
||||
`applied_clear_id`. It is answered on the connection thread by a handler the
|
||||
display registers (`ControlServer(handlers=...)`, the contract's
|
||||
`DIRECT_COMMANDS`): the error aggregator and its publisher have their own
|
||||
locks, and nothing the render thread owns is touched. A display that has no
|
||||
handler answers `unknown_command`, as an older display does.
|
||||
|
||||
`duration` is a number of seconds, or a numeric string. `0`, `null` or `""`
|
||||
mean "until stopped". `pinned` must be a real boolean: the REST route has
|
||||
@@ -382,9 +367,7 @@ place it reads the mailbox:
|
||||
Vegas iteration is on the stack (`_apply_pending_plugin_reloads`). Until
|
||||
then the current screen ends early, as it does for a WiFi notice: the
|
||||
frame loops, the dwell and Vegas's interrupt check all treat a pending
|
||||
reload as a reason to stop (the frame loops through the Arbiter's
|
||||
mid-screen check, `Source.RELOAD`; the dwell through
|
||||
`_plugin_reload_pending`).
|
||||
reload as a reason to stop (`_screen_preempted`).
|
||||
- Only the quick half of the reload runs on the render thread
|
||||
(`_start_plugin_reload`): the plugin's modes leave the rotation, its
|
||||
config subscription is dropped, and `PluginManager.detach_plugin` takes
|
||||
@@ -407,7 +390,7 @@ place it reads the mailbox:
|
||||
unloads it mid-load; a disable saved meanwhile is applied once the
|
||||
reload is done. A second reload of the same plugin runs after the first.
|
||||
|
||||
The floor on the mailbox read (0.25 s, 1 s since stage 4) does not apply to the queue, because
|
||||
The 0.25 s floor on the mailbox read does not apply to the queue, because
|
||||
draining it costs no disk read. A queued command also lets
|
||||
`_service_pending_changes()` skip its own floor.
|
||||
|
||||
@@ -435,7 +418,7 @@ Now the queue wakes the render thread:
|
||||
|
||||
So a command lands within a millisecond or so on a static screen and in a
|
||||
dwell, and within one frame in Vegas and on a scrolling screen. The mailbox
|
||||
is slower on purpose (see "The mailboxes now"). Commands still run only on the render thread: the
|
||||
keeps its old delays. Commands still run only on the render thread: the
|
||||
connection threads only queue them and set the event. The one exception is
|
||||
the slow half of `plugin.reload` (tearing down and loading the plugin),
|
||||
which runs on its own thread. Every change to the display's state still
|
||||
@@ -453,74 +436,11 @@ bookkeeping. A client's send to the render thread waking took 0.72 ms median
|
||||
Without a socket (Windows, `LEDMATRIX_CONTROL_SOCKET=off`) the waits are the
|
||||
plain sleeps they were.
|
||||
|
||||
**Exactly once.** Since stage 4 the web interface writes the mailbox only
|
||||
when the display never had the request (see "When the web interface falls
|
||||
back"), so a request goes one way or the other, never both. A command and a
|
||||
mailbox write for the same request still share one `request_id`, and the
|
||||
`on_demand_request_id` and processed-id checks still drop a second copy: an
|
||||
older web interface (before stage 4) wrote the mailbox after a reply timed
|
||||
out, too. The display takes such a copy out of the mailbox when it drops it.
|
||||
|
||||
## When the web interface falls back (stage 4)
|
||||
|
||||
The client tells a request the display never had from one it had and then
|
||||
failed. `ControlError.sent` is True once the whole request was written to a
|
||||
connected display; a refusal the display sends before reading anything
|
||||
(`forbidden`, too many connections) carries no request id, and leaves it
|
||||
False. `src.ipc.client.should_fall_back()` is the one rule every route uses:
|
||||
|
||||
| What happened | Example reasons | Mailbox? | The route answers |
|
||||
|---|---|---|---|
|
||||
| The display never had it | `no_socket`, `refused`, `disabled`, `unsupported`, a connect or send that timed out, `forbidden` / `busy` at the door, `invalid_request` (refused by the client itself) | yes | success, `transport: "mailbox"`, `socket_error` |
|
||||
| A display too old to know it (the upgrade case) | `unknown_command`, `unsupported_version` | yes | as above |
|
||||
| The display had it and failed | `busy` (queue full), `invalid_args`, `internal`, a timeout or hang-up after the send, `bad_response` | no | `503` (`400` for `invalid_args`), `socket_error` |
|
||||
|
||||
A display that had the request may have applied it (a reply that timed out),
|
||||
or would refuse the mailbox copy as well (bad arguments), or is stuck and
|
||||
would not read the mailbox either (a full queue). Writing the copy anyway
|
||||
only turned that into a "success". An on-demand stop with `stop_service`
|
||||
still stops the service, which ends on-demand whatever happened.
|
||||
|
||||
Brightness and plugin reload never had a mailbox: without the socket, the
|
||||
config watcher applies the saved brightness and a reload becomes the
|
||||
restart banner, as before.
|
||||
|
||||
### The mailboxes now
|
||||
|
||||
| Mailbox | Written by | Read by the display | While the socket is up |
|
||||
|---|---|---|---|
|
||||
| `display_on_demand_request` | the web interface, only on fallback; plugins that predate `BasePlugin.request_on_demand()`, or run on a core without it | the render thread, `_poll_on_demand_requests()` | looked at every 1 s (`MAILBOX_POLL_INTERVAL_WITH_SOCKET`), 0.25 s without a socket |
|
||||
| `plugin_error_clear_request` | the web interface, only on fallback | the error publisher's thread, every 5 s tick | unchanged rate |
|
||||
|
||||
A look is one `stat()` of the mailbox file (`CacheManager.file_signature`):
|
||||
`(inode, mtime, size)`, and every write renames a new file into place, so a
|
||||
new write always looks different. `MailboxWatch` reads the file only when
|
||||
that changed since the last look, so a mailbox that holds nothing new, or
|
||||
nothing at all, costs no open and no parse. A socket command never reads or
|
||||
deletes the on-demand mailbox. A start already processed is taken out of
|
||||
the mailbox instead of being re-read until it expires.
|
||||
|
||||
A request that comes through the on-demand mailbox while the socket is up
|
||||
is logged once per writer (`came through the file mailbox although the
|
||||
control socket is up`), which names the plugins that still write it.
|
||||
|
||||
### Plugins in the display process
|
||||
|
||||
A plugin asks for the screen with `BasePlugin.request_on_demand()` and gives
|
||||
it back with `end_on_demand()` (see "On-demand display" in
|
||||
[PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md)). Neither goes through
|
||||
the socket or a file: `PluginManager` hands the mailbox-shaped request,
|
||||
marked `source: 'plugin'`, to `DisplayController.submit_plugin_on_demand`,
|
||||
which queues it in memory (at most `PLUGIN_ON_DEMAND_QUEUE_SIZE`, 32) from
|
||||
whatever thread the plugin called on, and wakes the render thread through
|
||||
the socket's queue flag (`ControlServer.wake()`). The render thread applies
|
||||
it in `_drain_control_commands`, after the socket's commands, through the
|
||||
same `_handle_on_demand_request`, so it lands within a frame like a socket
|
||||
command. Without a socket it lands on the next pending-changes pass (typically
|
||||
within 0.25 s). A plugin's stop ends only a session that plugin owns. The four
|
||||
plugins that wrote the mailbox (birdnet-go, mqtt-notifications, on-air,
|
||||
pomodoro-timer) use it where the core has it and write the mailbox
|
||||
otherwise.
|
||||
**Exactly once.** A command and a mailbox write for the same request share
|
||||
one `request_id`. If the client times out after the display queued the
|
||||
command and then also writes the mailbox, the display processes the request
|
||||
once. The existing `on_demand_request_id` and processed-id checks drop the
|
||||
second copy.
|
||||
|
||||
## Robustness
|
||||
|
||||
@@ -537,10 +457,9 @@ block the render loop or crash it:
|
||||
and the connection is closed, because the next message boundary cannot be
|
||||
found. A client that disconnects mid-message is dropped silently. No
|
||||
exception from a handler leaves the connection thread.
|
||||
- **Full queue.** When the queue is full, the client gets `busy`, and the web
|
||||
interface answers `503` rather than write the mailbox, which the stuck
|
||||
render thread would not read either. A full queue means the render thread
|
||||
is stuck, and the systemd watchdog deals with that.
|
||||
- **Full queue.** When the queue is full, the client gets `busy` and falls
|
||||
back to the mailbox. A full queue means the render thread is stuck, and the
|
||||
systemd watchdog deals with that.
|
||||
- **Awaited commands.** The wait for an awaited command's outcome happens on
|
||||
its connection thread and is bounded (`AWAIT_SECONDS`), so a stuck render
|
||||
thread costs that client `pending` and one connection slot for at most
|
||||
@@ -657,32 +576,15 @@ device never touches the live display.
|
||||
an uninstall that keeps its config, still answer `restart_required`.
|
||||
They can now use a load/unload command and report the result the same
|
||||
way the update route does.
|
||||
4. **The mailboxes become a fallback (done).**
|
||||
- The web interface writes a mailbox only when the socket could not carry
|
||||
the request (`should_fall_back`); a display that had it and failed is
|
||||
answered as that (see "When the web interface falls back").
|
||||
- `errors.clear` replaces `plugin_error_clear_request` as the way a clear
|
||||
reaches the display.
|
||||
- The display looks at the on-demand mailbox once a second while the
|
||||
socket is up, reads either mailbox only when its file changed, and logs
|
||||
who still writes the on-demand one (see "The mailboxes now").
|
||||
- Not changed, deliberately: config saves (the schedule, the dim
|
||||
schedule, plugin settings) still reach the display through
|
||||
`config.json` and its watcher, which is the setting itself rather than
|
||||
a message; see `config.reload` under stage 2. The preview viewer marker
|
||||
(`/tmp/led_matrix_preview_viewer`) is a presence signal the display
|
||||
already stats at most once a second. Plugin health and metrics resets
|
||||
write the persisted record the display publishes and do not reach the
|
||||
running display (their routes say so); they are not mailboxes.
|
||||
5. **Remove the mailboxes (next release).** Once every device has run a
|
||||
display with stage 4, the web interface stops writing both mailboxes and
|
||||
the display stops reading them. The four plugins that wrote
|
||||
`display_on_demand_request` now have an in-process way to ask for the
|
||||
screen (`BasePlugin.request_on_demand()` / `end_on_demand()`, see
|
||||
"Plugins in the display process"); they keep the mailbox write only as
|
||||
their fallback on older cores. The display also stops writing `display_current_state`,
|
||||
`display_on_demand_state` and `plugin_runtime_snapshot` once the web
|
||||
interface no longer falls back to them.
|
||||
4. **Retire the mailboxes.** After a release in which every device has had the
|
||||
socket, the web interface stops writing `display_on_demand_request`, and
|
||||
the display stops polling it, logging the plugins that still write it so
|
||||
they can move to an in-process `request_display()`. The other cache keys
|
||||
used as messages (`plugin_error_clear_request` and the remaining
|
||||
`display_*` keys) move to the socket or to tmpfs. The display also stops
|
||||
writing `display_current_state`, `display_on_demand_state` and
|
||||
`plugin_runtime_snapshot` once the web interface no longer falls back to
|
||||
them.
|
||||
|
||||
## Checking it on a device
|
||||
|
||||
@@ -697,18 +599,7 @@ curl -s -X POST localhost:5000/api/v3/display/on-demand/start \
|
||||
If the response says `"transport": "mailbox"`, `socket_error` gives the
|
||||
reason. `no_socket` means the display is stopped or predates the socket.
|
||||
`refused` usually means the web user is not in the socket's group, which
|
||||
takes effect when the web service restarts after the user is added. A `503`
|
||||
with `"transport": "socket"` means the display had the request and did not
|
||||
take it (`busy`, `timeout`, ...): nothing was written to the mailbox.
|
||||
|
||||
An error clear:
|
||||
|
||||
```bash
|
||||
curl -s -X POST localhost:5000/api/v3/errors/clear \
|
||||
-H 'Content-Type: application/json' -d '{"all":true}'
|
||||
# ... "applied": true, "transport": "socket"
|
||||
sudo journalctl -u ledmatrix | grep -E "Cleared .* plugin error|file mailbox"
|
||||
```
|
||||
takes effect when the web service restarts after the user is added.
|
||||
|
||||
Brightness and a plugin reload:
|
||||
|
||||
|
||||
@@ -488,78 +488,6 @@ working for the plugin itself. `get_vegas_segment_width()` read the
|
||||
`vegas_panel_count` config value, which has never affected Vegas — a card's
|
||||
width comes from `get_vegas_content()` and `vegas_width_pct`.
|
||||
|
||||
### On-demand display
|
||||
|
||||
A plugin that reacts to something outside the rotation (an MQTT message, a
|
||||
timer, a detection) can take the screen for it, and give it back. Both
|
||||
methods are safe from any thread, including an MQTT callback: they only
|
||||
queue the request, and the display applies it on its render thread within a
|
||||
frame or so, exactly like an on-demand start or stop from the web interface.
|
||||
|
||||
#### `request_on_demand(mode=None, duration=None, pinned=False) -> Optional[str]`
|
||||
|
||||
Show this plugin now.
|
||||
|
||||
- `mode`: one of the plugin's display modes; `None` for its first.
|
||||
- `duration`: seconds before the rotation resumes; `None` (or `0`) for no
|
||||
limit, until `end_on_demand()` or the user stops it.
|
||||
- `pinned`: stay on `mode` instead of cycling through the plugin's other
|
||||
modes.
|
||||
|
||||
Returns the request id once the display has queued it, or `None` when
|
||||
there is no display in this process to ask (the web interface's plugin
|
||||
manager, `scripts/check_plugin.py`) or its queue is full. A bad argument
|
||||
(a `mode` that is not a string, a `duration` that is not a number) raises
|
||||
`ValueError`.
|
||||
|
||||
#### `end_on_demand() -> Optional[str]`
|
||||
|
||||
Give the screen back. Ends only a session this plugin owns: a session the
|
||||
user started for another plugin, or one that already ended, is left alone.
|
||||
Returns the request id once queued, or `None` as above.
|
||||
|
||||
#### Older cores: feature detection
|
||||
|
||||
These methods are new after core 3.8.0 (see `CHANGELOG.md`). Before them,
|
||||
plugins wrote the `display_on_demand_request` cache key (the "mailbox")
|
||||
themselves. The display reads it only once a second while the control
|
||||
socket is up, and it will be removed in a future release (see
|
||||
[IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md), stage 5). A plugin that
|
||||
must keep working on older cores checks for the method, and writes the
|
||||
mailbox only when the method is missing or answers `None`:
|
||||
|
||||
```python
|
||||
import time, uuid
|
||||
|
||||
def _show_alert(self):
|
||||
if hasattr(self, "request_on_demand") and self.request_on_demand(
|
||||
mode="my_alert", duration=15):
|
||||
return
|
||||
# Older core, or no display in this process: the mailbox, as before.
|
||||
self.cache_manager.set("display_on_demand_request", {
|
||||
"request_id": str(uuid.uuid4()), "action": "start",
|
||||
"plugin_id": self.plugin_id, "mode": "my_alert",
|
||||
"duration": 15, "pinned": False, "timestamp": time.time(),
|
||||
})
|
||||
|
||||
def _release(self):
|
||||
if hasattr(self, "end_on_demand") and self.end_on_demand():
|
||||
return
|
||||
self.cache_manager.set("display_on_demand_request", {
|
||||
"request_id": str(uuid.uuid4()), "action": "stop",
|
||||
"plugin_id": self.plugin_id, "timestamp": time.time(),
|
||||
})
|
||||
```
|
||||
|
||||
Keep `ledmatrix_min_version` where it is: the fallback is what keeps the
|
||||
plugin working on older cores. A mailbox stop ends any on-demand session,
|
||||
whoever started it; `end_on_demand()` ends only the plugin's own.
|
||||
|
||||
Both methods answer a request id only when the plugin manager returned a
|
||||
string, so a test that gives the plugin a `MagicMock()` plugin manager gets
|
||||
`None` and exercises the mailbox path. To test the new path, set
|
||||
`plugin_manager.request_on_demand.return_value = "some-id"`.
|
||||
|
||||
> The full source for `BasePlugin` lives in
|
||||
> `src/plugin_system/base_plugin.py`. If a method here disagrees with the
|
||||
> source, the source wins — please open an issue or PR to fix the doc.
|
||||
@@ -1038,14 +966,6 @@ if info:
|
||||
self.logger.info(f"Plugin: {info['name']}, Version: {info.get('version')}")
|
||||
```
|
||||
|
||||
#### `request_on_demand(plugin_id, mode=None, duration=None, pinned=False)` / `end_on_demand(plugin_id)`
|
||||
|
||||
What `BasePlugin.request_on_demand()` and `end_on_demand()` call, with the
|
||||
plugin's own id. Call those instead; see
|
||||
[On-demand display](#on-demand-display). The display controller routes them
|
||||
to itself with `set_on_demand_handler()`; a plugin manager without a
|
||||
display behind it answers `None`.
|
||||
|
||||
#### `get_all_plugin_info() -> List[Dict[str, Any]]`
|
||||
|
||||
Get information for all plugins.
|
||||
|
||||
+31
-67
@@ -159,16 +159,14 @@ there an unchecked checkbox — which the browser omits — is saved as
|
||||
}
|
||||
```
|
||||
|
||||
`restart_required` is true when the save changed a setting that takes
|
||||
effect when the display restarts: display hardware, rotation order,
|
||||
timezone, general settings and the rest. The web UI shows its restart banner
|
||||
on the flag. It is false when the save changed only what the running display
|
||||
applies by itself, or nothing: `brightness`, the per-mode durations
|
||||
(`duration__<mode>`, `display.display_durations`) and plugin sections, which
|
||||
reach the running plugin live, like `POST /plugins/config`.
|
||||
`restart_required` is always true here: display hardware, rotation,
|
||||
durations and general settings take effect when the display restarts, and
|
||||
the web UI shows its restart banner on the flag. (Plugin sections saved
|
||||
through this route reach the running plugin live, like
|
||||
`POST /plugins/config`.)
|
||||
|
||||
A saved `brightness` reaches the panel without a restart. The route also
|
||||
sends it to the running display over the control
|
||||
A saved `brightness` is the exception: it reaches the panel without a
|
||||
restart. The route also sends it to the running display over the control
|
||||
socket (`brightness.set`), which puts it on the panel at once, and the
|
||||
response adds `"brightness_transport": "socket"`. Otherwise it is
|
||||
`"config"`, with `brightness_socket_error` giving the reason, and the
|
||||
@@ -248,10 +246,7 @@ Replace the schedule configuration.
|
||||
```
|
||||
|
||||
A day whose `<day>_enabled` key is absent counts as enabled, with default
|
||||
times `07:00`-`23:00`. An enabled schedule needs at least one day enabled; a
|
||||
disabled one (`"enabled": false`) may have every day off, as
|
||||
`config.template.json` ships it. A day that is off keeps the times sent for
|
||||
it, when they are valid `HH:MM`.
|
||||
times `07:00`-`23:00`. At least one day must be enabled.
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
@@ -348,11 +343,7 @@ control socket ([IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md)), and `cache`
|
||||
when it came from the `display_current_state` cache key (no socket: the
|
||||
display is stopped or older, or this is Windows). A display whose render
|
||||
loop has not refreshed its state for 120 seconds is reported with every
|
||||
field `null`, either way. So is a stopped display: when the socket does not
|
||||
answer and the render loop's heartbeat
|
||||
(`/run/ledmatrix/display-heartbeat.json`) is absent, stale or from a process
|
||||
that is gone, the cache's last entry is not used. A display still beating
|
||||
without a socket, Windows, or a socket switched off reads the cache.
|
||||
field `null`, either way.
|
||||
|
||||
### List Display Modes
|
||||
|
||||
@@ -363,11 +354,9 @@ it. This is the list the force-display dialog offers.
|
||||
|
||||
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
|
||||
`plugin_id` is omitted. While the display is running, this list and that
|
||||
lookup use the modes the display registered, including ones a plugin generates
|
||||
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.
|
||||
`plugin_id` is omitted, and that lookup only sees modes declared in a static
|
||||
manifest — a plugin whose modes are generated (each installed Starlark app is
|
||||
one) returns 404 there.
|
||||
|
||||
Triggers plugin discovery, which is otherwise lazy — so a caller that never
|
||||
opens the dashboard still gets the full list.
|
||||
@@ -466,7 +455,7 @@ Request a specific plugin to display on-demand.
|
||||
- `mode` (string, optional): Display mode name (plugin_id inferred if not provided)
|
||||
- `duration` (number, optional): Duration in seconds (0 = until stopped)
|
||||
- `pinned` (boolean, optional): Pin display (pause rotation)
|
||||
- `start_service` (boolean, optional): Start the display service if it is not running (default: true). A running service is never restarted: it picks the request up within a frame over its control socket (within about a second through the mailbox fallback). When false and the service is stopped, the route returns 400.
|
||||
- `start_service` (boolean, optional): Start the display service if it is not running (default: true). A running service is never restarted: it picks the request up within about a quarter of a second. When false and the service is stopped, the route returns 400.
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
@@ -491,19 +480,10 @@ display's control socket acknowledged it (it is queued for the render thread,
|
||||
which wakes for it and applies it within a frame; see
|
||||
[IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md)), `"mailbox"` means it was
|
||||
written to the cache mailbox the display polls, as before the socket existed.
|
||||
The mailbox is used only when the socket could not carry the request. With
|
||||
`"mailbox"`, `socket_error` gives the reason (`no_socket` when the display is
|
||||
stopped or predates the socket, `refused`, a connect `timeout`,
|
||||
`unknown_command` from a display too old for the command, ...). Either way
|
||||
the request is applied the same way; `request_id` is the same id in both.
|
||||
|
||||
When the display had the request and did not take it -- a full queue
|
||||
(`busy`), bad arguments (`invalid_args`), no answer after the request was
|
||||
sent (`timeout`, `closed`) -- the route answers `503` (`400` for
|
||||
`invalid_args`) with `status: "error"` and `data: {request_id, transport:
|
||||
"socket", socket_error}`, and writes nothing to the mailbox. The stop route
|
||||
does the same, except that with `stop_service: true` it still stops the
|
||||
service and answers success.
|
||||
With `"mailbox"`, `socket_error` gives the reason the socket was not used
|
||||
(`no_socket` when the display is stopped or predates the socket, `timeout`,
|
||||
`refused`, `busy`, ...). Either way the request is applied the same way;
|
||||
`request_id` is the same id in both.
|
||||
|
||||
### Stop On-Demand Display
|
||||
|
||||
@@ -2259,11 +2239,13 @@ error with `"all": true` (`max_age_hours` is then ignored).
|
||||
}
|
||||
```
|
||||
|
||||
The clear goes to the display service over its control socket
|
||||
(`errors.clear`, see [IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md)), which
|
||||
applies it, rebuilding its counts from the errors it keeps, and republishes
|
||||
before it answers: `applied` is `true`, `transport` is `"socket"`, and
|
||||
`cleared_count` is the display's own count.
|
||||
The clear is asynchronous. The web interface records a request
|
||||
(`plugin_error_clear_request` in the shared cache), and the display service
|
||||
applies it within about 5 seconds, rebuilding its counts from the errors it
|
||||
keeps and republishing. Reads hide the cleared errors from the moment the
|
||||
request is recorded. Until the display service applies an age-based clear,
|
||||
`recent_errors` and `active_patterns` are already filtered but the counts
|
||||
are the old ones, and `clear_pending` is `true`.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -2271,30 +2253,17 @@ before it answers: `applied` is `true`, `transport` is `"socket"`, and
|
||||
"data": {
|
||||
"cleared_count": 13,
|
||||
"clear_requested": true,
|
||||
"applied": true,
|
||||
"transport": "socket",
|
||||
"request_id": "5f0c1e...",
|
||||
"cutoff": "2026-09-23T09:59:02.310000"
|
||||
},
|
||||
"message": "Cleared all errors"
|
||||
"message": "Clear of all errors requested; the display service applies it within about 5 seconds"
|
||||
}
|
||||
```
|
||||
|
||||
When the socket cannot carry it (the display is stopped, or older than
|
||||
`errors.clear`) the clear is asynchronous, as before: the web interface
|
||||
records a request (`plugin_error_clear_request` in the shared cache),
|
||||
`applied` is `false` and `transport` is `"mailbox"`, and the display service
|
||||
applies it within about 5 seconds. Reads hide the cleared errors from the
|
||||
moment the request is recorded. Until the display service applies an
|
||||
age-based clear, `recent_errors` and `active_patterns` are already filtered
|
||||
but the counts are the old ones, and `clear_pending` is `true`. Then
|
||||
`cleared_count` is how many of the reported errors the clear hides, and
|
||||
`cleared_count` is how many of the reported errors the clear hides. It is
|
||||
`null` when that cannot be known before the display service applies it (an
|
||||
age-based clear over more errors than the report lists).
|
||||
|
||||
A request that could not be written to the shared cache answers `500`. A
|
||||
display that had the request and failed it (`internal`, a timeout after the
|
||||
request was sent) answers `503`, with `context.socket_error`.
|
||||
age-based clear over more errors than the report lists). A request that
|
||||
could not be written to the shared cache answers `500`.
|
||||
|
||||
---
|
||||
|
||||
@@ -2312,11 +2281,7 @@ display snapshot. `data.status` is `healthy` or `degraded`, with
|
||||
(with `heartbeat_age_seconds`), `stalled` (no heartbeat for 60s: the panel is
|
||||
frozen even if the service is active; the status turns `degraded`), or
|
||||
`not_reported` when the display writes none (not started yet, the dev server,
|
||||
Windows), which does not affect the status, or `stopped` (with `source:
|
||||
"service"`) when the display service is not active, the control socket does
|
||||
not answer and there is no live heartbeat; the status then turns
|
||||
`degraded`. A platform with no control socket (Windows) or a socket switched
|
||||
off never reports `stopped`. Its `source` is `socket` when the
|
||||
Windows), which does not affect the status. Its `source` is `socket` when the
|
||||
age came from the display's state stream over the control socket (measured
|
||||
in memory by the display) and `heartbeat_file` when it came from
|
||||
`/run/ledmatrix/display-heartbeat.json`.
|
||||
@@ -2379,9 +2344,8 @@ Replace the dim schedule. `dim_brightness` is 0-100 (default 30). In
|
||||
`per-day` mode the days can be sent either as the `days` object that GET
|
||||
returns, or as the web form's flat fields (`monday_enabled`,
|
||||
`monday_start`, `monday_end`, ...). A day that is not sent counts as
|
||||
enabled with default times `20:00`-`07:00`. As for the schedule above, an
|
||||
enabled dim schedule needs at least one day enabled and a disabled one may
|
||||
have every day off.
|
||||
enabled with default times `20:00`-`07:00`; at least one day must be
|
||||
enabled.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+83
-208
@@ -36,189 +36,122 @@ Each pass, in order:
|
||||
1. `loop_pass()` (watchdog). Apply a pending plugin enable/disable, then
|
||||
any plugin reloads the control socket asked for
|
||||
(`_apply_pending_plugin_reloads`; a pending reload ends the screen
|
||||
before it, like a WiFi notice, as `Source.RELOAD` at the runner's
|
||||
service points). The static screen's frame sleep and the dwell wait on
|
||||
the socket's queue instead of sleeping (`_wait_frame_interval`,
|
||||
`_sleep_with_plugin_updates`); without a socket, as in the golden
|
||||
traces, they are the plain sleeps.
|
||||
before it, like a WiFi notice, through `_screen_preempted`). The static
|
||||
screen's frame sleep and the dwell wait on the socket's queue instead of
|
||||
sleeping (`_wait_frame_interval`, `_sleep_with_plugin_updates`); without
|
||||
a socket, as in the golden traces, they are the plain sleeps.
|
||||
2. With no modes: dwell 1 s, next pass.
|
||||
3. Poll on-demand requests and expiry, release plugins loaded only for
|
||||
on-demand, tick plugin updates, drop an expired WiFi notice, evaluate
|
||||
the schedule (an on-demand session overrides scheduled-off), apply the
|
||||
brightness target. Then gather the Arbiter's inputs
|
||||
(`_arbiter_inputs`) and call `Arbiter.decide()`.
|
||||
(`_arbiter_inputs`) and call `Arbiter.decide()`, which picks one of
|
||||
steps 4-6 or returns `LEGACY` for steps 7-9 (stage 2).
|
||||
4. **Scheduled off:** blank, dwell up to 60 s. `_blank_while_scheduled_off`
|
||||
5. **Follower:** render one frame from the leader. `_run_follower_frame`
|
||||
6. **WiFi notice** (unless on-demand): draw it, dwell 0.5 s. `_show_wifi_notice`.
|
||||
It also ends a running screen within about a second (the runner's
|
||||
service points), and a screen cut short resumes after it.
|
||||
7. **The Sources below the notice:** read whether Vegas is on and make the
|
||||
live-priority scan (`_arbiter_inputs_below_wifi`, where run() always
|
||||
read them), and call `decide()` again. It answers OnDemand (the
|
||||
session's current mode), Live (the next live mode, round-robin; a game
|
||||
that goes live during a screen takes over at the next service point, at
|
||||
most once a second), Vegas (`LEGACY`) or Rotation. `_take_plan` applies
|
||||
the answer: a live claim or the resume when live priority ends, the
|
||||
on-demand index.
|
||||
8. **Vegas** (`_run_vegas_iteration`): one iteration of up to
|
||||
`max_cycle_duration`. A completed iteration ends the pass, and so does
|
||||
one that yielded for the schedule, a reload or a WiFi notice. Any other
|
||||
interrupted one asks `decide()` once more (`vegas_yielded`): a game that
|
||||
stopped the ticker, or an on-demand session that started, shows next.
|
||||
9. **One screen:** pick the plugin (`_plugin_for_mode`) and hand the plan
|
||||
to the `ScreenRunner` (`src/screen_runner.py`). It draws the first frame
|
||||
through the executor (`_dispatch_first_frame`), has the controller fill
|
||||
in the plugin's durations, dynamic flag and frame policy
|
||||
(`_complete_plan`), runs the 125 Hz or 1 Hz frame loop with a service
|
||||
point after each frame, makes up the minimum duration, and returns an
|
||||
`Outcome`. On `PREEMPTED` the pass ends without advancing. On no
|
||||
content, rotate at once (`_note_empty_pass`, `_skip_failed_plugin_modes`).
|
||||
Otherwise `ArbiterState.after()` picks the next mode
|
||||
(`_advance_after_screen`).
|
||||
It is also polled mid-screen (`_wifi_notice_pending`): the frame loops,
|
||||
the dwell sleep and an interrupted Vegas iteration end within about a
|
||||
second when one arrives, and a screen cut short resumes after it.
|
||||
7. **Live priority** (unless on-demand, or Vegas keeps live content in the
|
||||
ticker): switch to the next live mode, or resume the rotation. A game
|
||||
that goes live during a screen is caught sooner, by
|
||||
`_check_live_takeover` in the frame loops and the dwell sleep (at most
|
||||
once a second, and not while a live mode is showing).
|
||||
8. **Vegas** (unless on-demand, or live content preempts it): run one
|
||||
iteration of up to `max_cycle_duration`. A completed iteration ends the
|
||||
pass, and so does one that yielded for a WiFi notice or the schedule.
|
||||
Any other interrupted one falls through to step 9 in the same pass.
|
||||
9. **One screen:** pick the mode (`_resolve_active_mode`), the plugin
|
||||
(`_plugin_for_mode`), draw the first frame through the executor
|
||||
(`_dispatch_first_frame`). On no content, rotate at once
|
||||
(`_note_empty_pass`, `_skip_failed_plugin_modes`). Otherwise work out the
|
||||
bounds (`_track_dynamic_cycle`, `_resolve_durations`,
|
||||
`_clamp_to_on_demand`) and the frame rate (`_needs_high_fps`), run the
|
||||
125 Hz or 1 Hz frame loop, make up the minimum duration, then pick the
|
||||
next mode (`_advance_after_screen`).
|
||||
|
||||
## Design
|
||||
The helpers named above were extracted in stage 1 without changing
|
||||
behaviour. Since stage 2 the choice between steps 4, 5, 6 and the rest is
|
||||
made by `Arbiter.decide()` in `src/display_arbiter.py`. The frame loops, the
|
||||
Vegas branch and every early exit are still inline in `run()`.
|
||||
|
||||
## Target design
|
||||
|
||||
```python
|
||||
def run(self):
|
||||
while True:
|
||||
inputs = self._arbiter_inputs() # schedule, follower, notice
|
||||
plan = Arbiter.decide(self._arbiter_state(), inputs, now)
|
||||
... # off / follower / notice
|
||||
plan = self._take_plan(Arbiter.decide(state, self._arbiter_inputs_below_wifi(inputs), now))
|
||||
if plan.source is Source.LEGACY: # Vegas, until stage 4
|
||||
plan = self._run_vegas_iteration(...)
|
||||
outcome = runner.run(plan, plugin) # ExitReason + elapsed
|
||||
if outcome.exit_reason is not ExitReason.PREEMPTED:
|
||||
self._advance_after_screen(plan, outcome) # ArbiterState.after
|
||||
inputs = self._drain_inputs() # requests, schedule, config, sync
|
||||
plan = self.arbiter.decide(self.state, inputs, clock.now())
|
||||
outcome = self.runner.run(plan) # ExitReason + elapsed
|
||||
self.state = self.state.after(plan, outcome) # rotation, on-demand index, live resume
|
||||
```
|
||||
|
||||
The controller's attributes (`current_display_mode`, `current_mode_index`,
|
||||
`on_demand_*`, `_live_resume_index`) stay the record that the web UI, the
|
||||
control socket and the on-demand cache read. `_arbiter_state()` snapshots
|
||||
them into a frozen `ArbiterState`; the transitions are pure methods on it,
|
||||
and the controller writes their result back (`_adopt_state`).
|
||||
|
||||
### Sources
|
||||
|
||||
Each kind of content is a Source. A Source looks at the state and the
|
||||
inputs and either offers a screen or passes. The Arbiter asks them in this
|
||||
order:
|
||||
|
||||
| Order | Source | Offers a screen when | Code |
|
||||
| Order | Source | Offers a screen when | Today |
|
||||
|---|---|---|---|
|
||||
| gate | ScheduledOff | the schedule is off and no on-demand session overrides it | `decide` |
|
||||
| 1 | Follower | a sync leader is driving this panel | `decide` |
|
||||
| 2 | OnDemand | a session is active (its mode list, index, expiry and pin) | `_on_demand_plan` |
|
||||
| 3 | Wifi | a status message is pending and on-demand is not active | `decide` |
|
||||
| 4 | Live | a live-priority plugin has live content (round-robin across several) | `live_pick` |
|
||||
| 5 | Vegas | Vegas is enabled and nothing above wants the panel | `LEGACY`, run by `_run_vegas_iteration` |
|
||||
| 6 | Rotation | always: the rotation's current mode | `rotation_plan` |
|
||||
| gate | ScheduledOff | the schedule is off and no on-demand session overrides it | step 4 |
|
||||
| 1 | Follower | a sync leader is driving this panel | step 5 |
|
||||
| 2 | OnDemand | a session is active (its mode list, index, expiry and pin) | `_resolve_active_mode` |
|
||||
| 3 | Wifi | a status message is pending and on-demand is not active | step 6 |
|
||||
| 4 | Live | a live-priority plugin has live content (round-robin across several) | step 7 |
|
||||
| 5 | Vegas | Vegas is enabled and nothing above wants the panel | step 8 |
|
||||
| 6 | Rotation | always: `available_modes[current_mode_index]` | step 9 |
|
||||
|
||||
ScheduledOff is a gate in front of the Sources because that is how it works
|
||||
today: a scheduled-off panel stays blank even for a follower, and only an
|
||||
on-demand session overrides it.
|
||||
|
||||
The Rotation answers `state.current_mode`, not
|
||||
`available_modes[current_mode_index]`: the two agree except where something
|
||||
moved the panel off the list and the rotation carries on from there (a live
|
||||
mode no rotation entry names, or None after a session ended with no enabled
|
||||
mode to resume to), and `run()` always showed `current_display_mode`.
|
||||
|
||||
### Arbiter
|
||||
|
||||
```python
|
||||
Arbiter.decide(state, inputs, now, running=None) -> ScreenPlan
|
||||
Arbiter.decide(state, inputs, now) -> ScreenPlan
|
||||
```
|
||||
|
||||
`decide` is a pure function: it does no I/O, takes no locks and does not
|
||||
sleep. It can be tested with plain tables of (state, inputs, now) mapped to
|
||||
an expected plan.
|
||||
|
||||
- `ArbiterState`: the current mode; the rotation and its index; the
|
||||
on-demand session's modes, index, expiry and pin; the live resume point;
|
||||
whether a mid-screen takeover has not shown yet. Transitions:
|
||||
`next_on_demand`, `showing`, `claim_live`, `release_live`, `after`.
|
||||
- `ArbiterInputs`: whether the schedule has the panel on, an on-demand
|
||||
session, a follower, the WiFi notice, the live modes (None where no scan
|
||||
was made), whether Vegas is on and keeps live content in its ticker,
|
||||
whether this pass's Vegas iteration has yielded, and (mid-screen) whether
|
||||
a plugin reload is waiting.
|
||||
- `ScreenPlan`:
|
||||
an expected plan. It returns a `ScreenPlan`:
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `source` | which Source won |
|
||||
| `mode`, `plugin` | what to draw (None for a blank or follower plan); the plugin id once resolved |
|
||||
| `min_duration`, `max_duration` | from `_resolve_durations` and the on-demand bound (`on_demand_bound`), filled in after the first frame; an on-demand plan's `max_duration` is what is left of the session at `now` |
|
||||
| `mode`, `plugin` | what to draw (None for a blank or follower plan) |
|
||||
| `min_duration`, `max_duration` | from `_resolve_durations` and `_clamp_to_on_demand` |
|
||||
| `dynamic` | run until the plugin's cycle completes, between min and max |
|
||||
| `frame_policy` | `HIGH_FPS` or `STATIC`, today `_needs_high_fps`; see stage 5 |
|
||||
| `frame_policy` | today `_needs_high_fps` (125 Hz or 1 Hz); see stage 5 |
|
||||
| `preemptible_by` | the Sources allowed to interrupt this plan mid-screen |
|
||||
| `notice`, `deadline`, `ends_live` | the WiFi notice; the on-demand expiry for the bound; "live priority just ended, resume the rotation first" |
|
||||
|
||||
`decide` cannot ask a plugin anything, so the fields a plugin answers are
|
||||
filled in by the controller after the first frame, where they were always
|
||||
read (`_complete_plan`).
|
||||
|
||||
With `running`, `decide` answers the mid-screen question instead: `running`
|
||||
itself while the screen holds, else the plan that ends it
|
||||
(`_hold_or_preempt`), in the order the frame loops always checked:
|
||||
|
||||
1. Live: a game went live while a non-live screen runs. It is the one
|
||||
preemption that changes the state (the rotation moves to the live mode
|
||||
and remembers where it was), and it is claimed even when a WiFi notice
|
||||
is also pending; the next pass shows the notice, then the game.
|
||||
2. The panel's mode moved under the screen (on-demand started, ended or
|
||||
changed mode; the rotation was rebuilt).
|
||||
3. The schedule turned the panel off.
|
||||
4. A WiFi notice (unless on-demand outranks it), compared with its expiry.
|
||||
5. A plugin reload is waiting (between frames only).
|
||||
|
||||
Every screen is preemptible by the gate, OnDemand, Wifi, Live, Rotation and
|
||||
a reload (`SCREEN_PREEMPTERS`), except that a live screen leaves Live out
|
||||
(`LIVE_PREEMPTERS`): live games take turns between screens. A follower and
|
||||
Vegas are looked at only between screens.
|
||||
|
||||
### ScreenRunner
|
||||
|
||||
```python
|
||||
ScreenRunner(clock: FrameClock, host: ScreenHost).run(plan, plugin) -> Outcome
|
||||
ScreenRunner(clock: FrameClock).run(plan) -> Outcome(exit_reason, elapsed)
|
||||
```
|
||||
|
||||
The ScreenRunner draws the first frame (`_dispatch_first_frame`), runs the
|
||||
frame loop that the plan's frame policy selects, services pending changes
|
||||
between frames, and returns one `ExitReason`:
|
||||
|
||||
| ExitReason | Golden-trace exit |
|
||||
| ExitReason | Today's equivalent (golden-trace exit) |
|
||||
|---|---|
|
||||
| `DURATION` | target duration reached (`duration`) |
|
||||
| `CYCLE_COMPLETE` | dynamic plugin finished after its minimum (`cycle-complete`) |
|
||||
| `EMPTY` | first frame returned False, or no plugin (`empty`; `raised` when display() raised inside the executor; `no-plugin`, `breaker`) |
|
||||
| `EMPTY` | first frame returned False (`empty`; `raised` when display() raised inside the executor) |
|
||||
| `ERROR` | the dispatch itself raised (`error`) |
|
||||
| `DISPLAY_FALSE` | a later frame returned False (`display-false`) |
|
||||
| `PREEMPTED` | another Source took the panel (`on-demand-*`, `schedule-off`, `live`, `wifi`, ...) |
|
||||
| `RELOAD` | a plugin reload is waiting: the screen ends early but counts as shown, and the rotation advances |
|
||||
| `PREEMPTED` | another Source took the panel (`on-demand-*`, `schedule-off`, `vegas-interrupt`, ...) |
|
||||
|
||||
`PREEMPTED` replaces the five `current_display_mode != active_mode` checks.
|
||||
The runner asks its host at named service points (`Checkpoint`): `FRAME`
|
||||
after each frame (and when a socket command wakes the 1 Hz wait),
|
||||
`AFTER_LOOP` / `AFTER_COMPLETED_LOOP` when the frame loop ends,
|
||||
`after_dwell` after the make-up dwell, and `FINAL` before the rotation
|
||||
advances. Each is one `decide(..., running=plan)` call
|
||||
(`DisplayController._screen_check`). The checkpoint says whether a pending
|
||||
reload counts there and when the WiFi notice file is read (`NoticeRead`):
|
||||
the read is throttled to once a second and deletes an expired file, so it
|
||||
happens exactly where the loop always read it.
|
||||
The runner asks the Arbiter, at the throttled service points it already has,
|
||||
whether a Source in `plan.preemptible_by` now wants the panel.
|
||||
|
||||
In the 125 Hz loop the live-priority scan is made before the frame's sleep
|
||||
(`_screen_service`), at the moments it always was, and weighed by the
|
||||
service point after the sleep, where the loop always decided to end the
|
||||
screen.
|
||||
|
||||
`FrameClock` provides `time()`, `perf_counter()` and `sleep()`, the shape of
|
||||
the `time` module. In production it is `_ModuleClock`, which looks up
|
||||
`src.display_controller.time` on each call, so the golden traces' fake clock
|
||||
drives the runner as it drove the inline loops. The runner's log lines use
|
||||
the controller's logger, so they keep their source in the journal.
|
||||
`FrameClock` provides `now()` and `sleep()`. In production it is
|
||||
`time.monotonic`/`time.sleep`. In the golden traces it is the fake clock
|
||||
that the harness patches in today.
|
||||
|
||||
## Stages
|
||||
|
||||
@@ -248,14 +181,13 @@ the controller's logger, so they keep their source in the journal.
|
||||
- dynamic duration (cycle complete, plugin cap, global cap)
|
||||
- live priority taking over and handing back; live round-robin
|
||||
- on-demand start/stop/expiry; pinned on-demand; a session resumed after
|
||||
a restart, and one that cannot resume (its plugin did not load); a
|
||||
request naming a live mode the plugin's live check would drop
|
||||
a restart
|
||||
- schedule off and dim, with an on-demand override during downtime
|
||||
- WiFi notice; sync follower
|
||||
- Vegas, with and without `live_in_ticker`
|
||||
- Each trace row is `[start, mode, duration, exit_reason, frames,
|
||||
force_clear]`. The exit reason is the event that decided what came next.
|
||||
- All 18 tests run in under a second. The goldens were generated from
|
||||
- All 16 tests run in under a second. The goldens were generated from
|
||||
main's `run()` before any code moved.
|
||||
- Vegas uses `FakeVegas`, which implements only the contract the controller
|
||||
depends on: `run_iteration()` returns True after its duration and False
|
||||
@@ -317,84 +249,37 @@ What shipped:
|
||||
Source, the dwells, the expiry comparison, the snapshot's reads, each
|
||||
dispatch in `run()`); every one failed a test.
|
||||
|
||||
### Stage 3: ScreenRunner and `PREEMPTED` (done; awaiting the ledpi soak)
|
||||
### Stage 3: ScreenRunner and `PREEMPTED`
|
||||
|
||||
The plan, from where stage 2 left off:
|
||||
Move the two frame loops, the make-up dwell and the dynamic-duration exit
|
||||
into `ScreenRunner.run(plan)` with an injected `FrameClock`. Replace the
|
||||
five re-checks with `PREEMPTED`. Add the OnDemand, Live and Rotation Sources
|
||||
so `LEGACY` is left meaning only Vegas.
|
||||
|
||||
Concretely, from where stage 2 left off:
|
||||
|
||||
1. `ArbiterState` gains the rotation index, the on-demand mode list, index,
|
||||
expiry and pin, and the live resume point. `ArbiterInputs` gains the
|
||||
live modes and whether Vegas is enabled and keeps live content in the
|
||||
ticker.
|
||||
2. OnDemand returns its current mode with the session's bound, reading
|
||||
`now` for the expiry. Live returns the next live mode (round-robin).
|
||||
Rotation returns the rotation's mode. `ScreenPlan` gains `mode`,
|
||||
`plugin`, `min_duration`, `max_duration`, `dynamic`, `frame_policy` and
|
||||
`preemptible_by`.
|
||||
3. `ScreenRunner.run(plan)` returns an `ExitReason`; `state.after(outcome)`
|
||||
replaces `_advance_after_screen`'s step and the live-resume bookkeeping.
|
||||
Each mid-screen check asks `decide()` whether a Source in
|
||||
`plan.preemptible_by` now wins.
|
||||
expiry and pin, and the live resume point (today `current_mode_index`,
|
||||
`on_demand_*` and the live-priority stash). `ArbiterInputs` gains the
|
||||
live modes (`_collect_live_modes`) and whether Vegas is enabled and keeps
|
||||
live content in the ticker.
|
||||
2. OnDemand returns its current mode with `_clamp_to_on_demand`'s bound,
|
||||
reading `now` for the expiry. Live returns the next live mode
|
||||
(round-robin). Rotation returns `available_modes[current_mode_index]`.
|
||||
`ScreenPlan` gains `mode`, `plugin`, `min_duration`, `max_duration`,
|
||||
`dynamic`, `frame_policy` and `preemptible_by`.
|
||||
3. `ScreenRunner.run(plan)` returns an `ExitReason`; `state.after(plan,
|
||||
outcome)` replaces `_advance_after_screen` and the live-resume
|
||||
bookkeeping. Each mid-screen check asks `decide()` whether a Source in
|
||||
`plan.preemptible_by` now wins, so `_screen_preempted`,
|
||||
`_check_live_takeover` and `_wifi_notice_pending` become one call.
|
||||
4. The control socket (`_drain_control_commands`, `_wait_for_control`) and
|
||||
state publishing stay where they are; the runner calls them at its
|
||||
service points.
|
||||
|
||||
What shipped, one commit each: the runner; then the OnDemand, Live and
|
||||
Rotation Sources; then one `decide()` call at the service points.
|
||||
|
||||
- `src/screen_runner.py` (on the mypy ratchet): `ScreenRunner`,
|
||||
`FrameClock`, `ExitReason`, `Outcome`, `Checkpoint`, `NoticeRead`,
|
||||
`Screen` and the `ScreenHost` protocol, which `DisplayController`
|
||||
implements through `_ScreenHost` (one-line forwards to its own methods).
|
||||
The two frame loops, the make-up dwell and the dynamic-duration exit
|
||||
moved in unchanged, pacing included.
|
||||
- `src/display_arbiter.py`: `Source` gains `ON_DEMAND`, `LIVE`, `ROTATION`
|
||||
and `RELOAD`; `LEGACY` means only Vegas. `FramePolicy`. `ArbiterState`
|
||||
and `ArbiterInputs` as listed under "Arbiter". The pure helpers
|
||||
`on_demand_bound` (`_clamp_to_on_demand`), `live_pick`
|
||||
(`_check_live_priority`'s pick), `live_takeover` (the mid-screen claim)
|
||||
and `rotation_plan`.
|
||||
- A pass asks `decide()` twice: once with the inputs every pass reads, and
|
||||
once, only when nothing above the notice took the panel, with the Vegas
|
||||
check and the live scan, read where run() always read them (the scan
|
||||
asks every live-priority plugin, and the Vegas check applies queued
|
||||
Vegas config, so reading them earlier, on a follower or notice pass,
|
||||
would be a change). A Vegas iteration that yields asks a third time.
|
||||
- `_resolve_active_mode`, `_clamp_to_on_demand` and `_screen_preempted` are
|
||||
gone. `_apply_live_priority`, `_check_live_priority`,
|
||||
`_check_live_takeover` and `_wifi_notice_pending` remain (Vegas, the
|
||||
dwell sleep and the tests call them), built on the same pure rules.
|
||||
- `_sleep_with_plugin_updates` keeps its own break rules. It also serves
|
||||
the blank, the notice and the idle wait, which are not screens, and its
|
||||
rules are edge-triggered (an on-demand session starting on the mode
|
||||
already showing ends a dwell but not a frame loop); folding them into
|
||||
`decide()` would change behaviour.
|
||||
|
||||
Behaviour, checked three ways:
|
||||
|
||||
- Golden traces: unchanged, no regeneration.
|
||||
- Every harness run in the suite (67: the goldens plus the live-takeover,
|
||||
WiFi+live, socket-wake, plugin-reload, schedule and tick tests) was
|
||||
captured with every sleep, `display()` call, WiFi read, live scan,
|
||||
publish, dwell and scroll-state call logged, and diffed against
|
||||
`origin/main`. Identical, except:
|
||||
- a Vegas pass used to scan the live plugins twice at the same instant
|
||||
(step 7, then step 8's "is anything live?"); it scans once;
|
||||
- `_apply_live_priority(None)` calls that changed nothing are not made;
|
||||
- throttled WiFi reads that returned the cached answer (no side effect)
|
||||
after a notice had already ended the screen are not made;
|
||||
- in the 125 Hz loop the live scan still runs before the frame's sleep,
|
||||
but the claim is made by the service point after it, so the "live"
|
||||
state change happens 8 ms later. The screen ends at the same frame as
|
||||
before.
|
||||
- Tables: `test/test_display_arbiter.py` (OnDemand, Live, Vegas/Rotation,
|
||||
`after`, the 24-row mid-screen table, `live_takeover`) and
|
||||
`test/test_screen_runner.py` (the runner on a scripted host and fake
|
||||
clock; the controller's service point and the reads it makes). A
|
||||
mutation run broke each moved or new piece once; see the PR.
|
||||
|
||||
This stage touches frame pacing (the 8 ms deadline sleep, the 1 ms yield),
|
||||
so it needs a frame soak on ledpi, A/B against main, before it merges.
|
||||
Coordinate with whoever owns scroll performance (`docs/SCROLL_PERFORMANCE.md`).
|
||||
so it needs a frame soak on ledpi, A/B against main. Coordinate with
|
||||
whoever owns scroll performance (`docs/SCROLL_PERFORMANCE.md`).
|
||||
|
||||
### Stage 4: Vegas as a Source
|
||||
|
||||
@@ -459,13 +344,3 @@ PR that updates the affected trace and explains why. All six are fixed:
|
||||
|
||||
A new one found later goes the same way: record it here with the trace that
|
||||
shows it, then fix it in its own PR, not inside a restructure stage.
|
||||
|
||||
Open:
|
||||
|
||||
- Vegas stops for a sync follower (its interrupt check includes
|
||||
`is_follower_active`), but the yield path never looks at a follower, so a
|
||||
full rotation screen (20 s in the test) runs before the next pass hands
|
||||
the panel to the leader. Found by stage 3's mutation run;
|
||||
`test_screen_runner.py::TestThroughRun::test_vegas_yielding_to_a_follower_shows_a_rotation_screen_first`
|
||||
pins it. Stage 4, which drops the interrupt callback, is the natural
|
||||
place to fix it.
|
||||
|
||||
@@ -81,14 +81,6 @@ The conventions the converted pages share:
|
||||
to the module's export of the same name and warns once.
|
||||
- **Timers are cleared in `destroy()`**, the one thing `ctx.signal` cannot
|
||||
undo by itself.
|
||||
- **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:
|
||||
|
||||
@@ -247,8 +239,8 @@ are the inline script in each partial today.
|
||||
| 3 | Operation History | 293 lines, now 0 | **Done in stage 2.** Read-only list; rows drawn with `textContent`, the search debounce cleared on destroy. The "Showing x to y" counters now also reset when nothing matches |
|
||||
| 4 | Config Editor (`raw_json.html`) | 212 lines, now 0 | **Done in stage 2.** Plain textareas (no CodeMirror on this page). It defined 5 globals after all (`formatJson`, `manualValidateJson`, `validateJSON`, `saveMainConfig`, `saveSecretsConfig`); nothing else used them, and they are deprecated aliases now. The live "Invalid JSON" line no longer puts the parser's message into `innerHTML` |
|
||||
| 5 | Backup & Restore | 232 lines, now 0 | **Done in stage 2.** Its 5 globals (`exportBackup`, `loadBackupList`, `validateRestoreFile`, `clearRestore`, `runRestore`) are deprecated aliases; the buttons are delegated `data-action`s. Uploads go through `ctx.api.request(..., { body: formData })` (`api.js` gained a raw `body` option) |
|
||||
| 6 | Schedule | 193 lines, now 0 | **Done in stage 3.** Its 2 `hx-on` response handlers (`handleScheduleResponse`, `handleDimScheduleResponse`) are one `htmx:afterRequest` listener on the page root, and deprecated aliases. The forms are marked `data-reports-result` so `app.js` does not repeat the server's message. The saved schedules reach the module as JSON in `data-schedule-config` / `data-dim-schedule-config` instead of being templated into the script |
|
||||
| 7 | General | 153 lines, now 0 | **Done in stage 3.** The Security section's three forms and two buttons are delegated `data-action`s (one submit and one click listener); `window.webLogin` is a deprecated alias of an object with its five methods. Login requests go through `ctx.api`, so the login redirect is quiet. The settings form keeps its `hx-on` call to the shared `showSaveResult`, as Rotation's does |
|
||||
| 6 | Schedule | 193 | 2 globals used as `hx-on` response handlers. Moves `hx-on` handlers into page listeners |
|
||||
| 7 | General | 147 | `webLogin` global and the security section. The first page that touches login |
|
||||
| 8 | Display | 231 | First page with `LEDVisibility` timers: those move to a `ctx.visibility` service that stops on destroy |
|
||||
| 9 | Overview | 410 (4 scripts) | First-run surface: Getting Started, update banner, live preview. Five globals |
|
||||
| 10 | WiFi | 364 | `x-data="wifiSetup()"` is defined by its own script. Moves to `Alpine.data()` registered from the module. AP-mode first screen, so it needs the AP-mode test on a real device |
|
||||
@@ -292,8 +284,6 @@ Unit suites need only node. They import the shipped modules directly:
|
||||
| `dom/test_durations_page.js` | DOM: real partial, real widget, real API shape | One plugin-list request per swap; Move down moves one place after five swaps; the swap cancels a request in flight; a late-loading widget is waited for, and a page swapped away while waiting starts nothing; hostile names stay text |
|
||||
| `dom/test_operation_history_page.js` | DOM: real partial, real API shape | One history request per swap and per Refresh; the plugin filter filled once (from `PluginAPI`'s cache when loaded); paging, filters, debounced search, Clear (one DELETE), error/network/login states, cancel on swap; hostile ids, users and errors stay text |
|
||||
| `dom/test_raw_json_page.js` | DOM: real partial, real config | One POST per Save after five swaps, to the right file; Format and Validate act once; invalid JSON never sent and its message stays text; a save survives a swap and is still reported; the old globals' entry points |
|
||||
| `dom/test_schedule_page.js` | DOM: real partial, real widget | Both pickers drawn once per swap from the saved config; after five swaps each form's answer is one notification (message, fallback, refused, non-JSON, `null`), a request from outside the forms none; the brightness label; a late widget waited for, a page swapped away while waiting draws nothing; the old globals' entry points |
|
||||
| `dom/test_general_page.js` | DOM: real partial, real widget, real API shape | The timezone picker drawn once per swap with the saved zone; the settings form left to htmx; after five swaps each Security action makes one request (create, copy, revoke and its cancel, password and its mismatch); hostile token names stay text; refused, network and login answers; a create made before a swap is still reported and draws nothing; `webLogin`'s entry points |
|
||||
| `dom/test_backup_restore_page.js` | DOM: real partial, real API shape | One request per Refresh, Delete, Export (busy button ignores a second click), Inspect and Restore after five swaps; the upload's fields and the six restore options; reads cancelled by a swap, writes not; hostile file and host names stay text; the old globals' entry points |
|
||||
| `test/web_interface/test_es_modules.py` | pytest | MIME type; `no-cache` without `?v` and immutable with it; `boot.js` loads last; every import resolves inside `core/` and `pages/`; the converted pages are exactly the registered ones, each with its module, `init`, and one root in the rendered partial; a converted partial has no `<script>` and no `onclick`; every moved global is aliased in `boot.js` and exported by its module, and no template defines it any more |
|
||||
| `test/test_field_model_parity.py` | pytest | The model against the macro for every available schema |
|
||||
|
||||
@@ -88,7 +88,6 @@ src/plugin_system/testing/vegas.py
|
||||
src/plugin_system/vegas_elements.py
|
||||
src/redaction.py
|
||||
src/scan_order.py
|
||||
src/screen_runner.py
|
||||
src/startup_validator.py
|
||||
src/vegas_mode/__init__.py
|
||||
src/vegas_mode/config.py
|
||||
|
||||
@@ -213,9 +213,8 @@ def list_installed_plugins(project_root: Path) -> List[Dict[str, Any]]:
|
||||
The plugins are the ``manifest.json`` files in the configured plugin
|
||||
directory (see :func:`_plugins_directory`), with the manifest's version;
|
||||
``enabled`` is config.json's flag by the display's rule (a missing flag
|
||||
is disabled). A restore installs each listed plugin that is missing and
|
||||
takes enabled state from the restored config.json, so ``enabled`` is
|
||||
informational.
|
||||
is disabled). A restore reinstalls every listed plugin and takes enabled
|
||||
state from the restored config.json, so ``enabled`` is informational.
|
||||
|
||||
``data/plugin_state.json`` is not read: it only ever repeated config's
|
||||
enabled flags and the manifests' versions, and is retired (nothing
|
||||
|
||||
+2
-57
@@ -28,7 +28,7 @@ import os
|
||||
import time
|
||||
from datetime import datetime
|
||||
import pytz
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from typing import Any, Dict, List, Optional
|
||||
import logging
|
||||
import threading
|
||||
import tempfile
|
||||
@@ -72,43 +72,6 @@ def _outlived(record: Any, max_age: Optional[float], now: float) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
_NOT_SEEN: Any = object()
|
||||
|
||||
|
||||
class MailboxWatch:
|
||||
"""Tells the poller of a mailbox key whether its file changed since the
|
||||
last look, from one stat() (:meth:`CacheManager.file_signature`).
|
||||
|
||||
The display polls the mailboxes the web interface falls back to. Reading
|
||||
one is an open and a JSON parse; with this a poll that finds the same file
|
||||
(or none) costs a stat, and the file is read only after a new write. A
|
||||
cache without ``file_signature`` (a test double) is read every time.
|
||||
"""
|
||||
|
||||
def __init__(self, key: str):
|
||||
self.key = key
|
||||
self._seen: Any = _NOT_SEEN
|
||||
|
||||
def changed(self, cache_manager: Any) -> bool:
|
||||
"""True when the poller should read the key now."""
|
||||
signature = getattr(cache_manager, 'file_signature', None)
|
||||
sig = signature(self.key) if callable(signature) else _NOT_SEEN
|
||||
if sig is not None and not isinstance(sig, tuple):
|
||||
return True # cannot tell: read it
|
||||
if sig is None:
|
||||
self._seen = None
|
||||
return False # no file, nothing to read
|
||||
if sig == self._seen:
|
||||
return False
|
||||
self._seen = sig
|
||||
return True
|
||||
|
||||
def forget(self) -> None:
|
||||
"""Read the key on the next poll even if its file has not changed
|
||||
(the last read failed)."""
|
||||
self._seen = _NOT_SEEN
|
||||
|
||||
|
||||
class CacheManager:
|
||||
"""Manages caching of API responses to reduce API calls."""
|
||||
|
||||
@@ -332,25 +295,7 @@ class CacheManager:
|
||||
def _get_cache_path(self, key: str) -> Optional[str]:
|
||||
"""Get the path for a cache file."""
|
||||
return self._disk_cache_component.get_cache_path(key)
|
||||
|
||||
def file_signature(self, key: str) -> Optional[Tuple[int, int, int]]:
|
||||
"""``(st_ino, st_mtime_ns, st_size)`` of ``key``'s file, or None when
|
||||
there is no file (the key is absent, or this cache has no disk tier).
|
||||
|
||||
One stat(), no read: a poller of a mailbox another process writes
|
||||
compares it with the last one it saw and reads the file only when it
|
||||
changed. Every write replaces the file (a temp file renamed into
|
||||
place), so a new write always has a new inode, however fast it came.
|
||||
"""
|
||||
path = self._get_cache_path(key)
|
||||
if not path:
|
||||
return None
|
||||
try:
|
||||
st = os.stat(path)
|
||||
except OSError:
|
||||
return None
|
||||
return (st.st_ino, st.st_mtime_ns, st.st_size)
|
||||
|
||||
|
||||
def get_cached_data(self, key: str, max_age: int = 300, memory_ttl: Optional[int] = None) -> Optional[Dict[str, Any]]:
|
||||
"""Get data from cache (memory first, then disk) honoring TTLs.
|
||||
|
||||
|
||||
@@ -109,11 +109,8 @@ and the plugin test harness all use it. Most plugins get BDF text through
|
||||
[`espn_dates.py`](espn_dates.py). ESPN's site API rejects `dates=` ranges
|
||||
and truncates results when `limit` is above 500. `fetch_espn_scoreboard()`
|
||||
splits a range into month and day requests ESPN accepts and merges the
|
||||
results; `espn_date_chunks()`, `espn_request_chunks()`,
|
||||
`fetch_espn_date_chunks()`, `clamp_espn_limit()` and
|
||||
`merge_scoreboard_payloads()` are the pieces. A window's partial edge months
|
||||
are asked whole and trimmed to its days (US Eastern), and chunk requests share
|
||||
one process-wide cap of `ESPN_CHUNK_WORKERS` in flight.
|
||||
results; `espn_date_chunks()`, `fetch_espn_date_chunks()`,
|
||||
`clamp_espn_limit()` and `merge_scoreboard_payloads()` are the pieces.
|
||||
Every request goes through [`fetch_service`](#fetch_service), the chunks
|
||||
counted against the plugin that asked. Scoreboard plugins also bundle a copy
|
||||
for older cores.
|
||||
|
||||
+179
-188
@@ -26,30 +26,10 @@ A month can hold more than 500 events (college baseball's March does), and
|
||||
ESPN answers that with exactly ``limit`` events and no hint that more exist. A
|
||||
month chunk that comes back full is therefore re-asked day by day.
|
||||
|
||||
A window's *partial* edge months are asked for whole, too, once the window
|
||||
covers ``ESPN_MONTH_COVER_MIN_DAYS`` or more of their days, and the answer is
|
||||
trimmed back to the window's days. A scoreboard's default fortnight either side
|
||||
of today (29 days, two partial months) was 29 day requests per league; it is
|
||||
now 2. Trimming needs ESPN's "game day", which is the event's start in US
|
||||
Eastern time -- checked against the live API on 2026-10-03: 417 of 417 soccer
|
||||
events across five leagues and three months (one of them spanning the end of
|
||||
daylight saving) came back from exactly the day query their Eastern date
|
||||
names. A short window (a live poll's one or two days) stays day by day, so it
|
||||
never downloads a whole month to read a day of it.
|
||||
|
||||
Chunk requests share one process-wide budget of ``ESPN_CHUNK_WORKERS`` in
|
||||
flight, however many windows are being fetched at once. Each window used to get
|
||||
its own six, so a scoreboard starting eight leagues -- each with a recent and
|
||||
an upcoming manager -- had ~40 requests in flight, every one beyond a session's
|
||||
pool a new connection and a new DNS lookup. On a Pi whose resolver could not
|
||||
keep up, that was ~90 ``NameResolutionError`` lines within a minute of every
|
||||
start.
|
||||
|
||||
Once a range has been rejected, later ranges skip straight to chunks for
|
||||
``RANGE_RETRY_SECONDS`` instead of spending a doomed request first -- live
|
||||
scoreboards ask every 30 seconds. After that the range is tried again, so the
|
||||
workaround retires itself if ESPN reverts. A process starts inside that
|
||||
period, as if a range had just been rejected.
|
||||
workaround retires itself if ESPN reverts.
|
||||
|
||||
ONE CACHE KEY PER SCOREBOARD
|
||||
----------------------------
|
||||
@@ -66,19 +46,39 @@ entry older than the reader's own ``max_age``, whoever wrote it and whatever
|
||||
ttl they stored with it. Old keys are passed as ``legacy_keys`` and read
|
||||
after the canonical one, so an upgrade does not refetch everything at once;
|
||||
they can go one release after the one that added this.
|
||||
|
||||
Chunks whose days are long over are kept in memory between fetches. The
|
||||
scoreboards re-fetch their whole Recent/Upcoming window (14 days back, 7
|
||||
ahead) every hour, and since ranges went away that is 22 day requests per
|
||||
league. Measured on hdpi on 2026-10-02 (NFL, college football, MLB, college
|
||||
baseball, NHL): the hourly window refresh was ~270 of 321 ESPN requests and
|
||||
~21 of 24.6MB in the hour, and the 12 days that ended three or more days ago
|
||||
were 68% of those bytes (6.9 of 10.2MB per copy of the five windows). A
|
||||
settled chunk is answered from memory for ``SETTLED_CHUNK_TTL_SECONDS``,
|
||||
stored as zlib-compressed JSON (~13x smaller than the body, and far smaller
|
||||
than the parsed objects), so the hourly refresh only goes to ESPN for the
|
||||
days that can still change.
|
||||
"""
|
||||
|
||||
import contextvars
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import zlib
|
||||
from collections import OrderedDict
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import date, datetime, timedelta, tzinfo
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from functools import partial
|
||||
from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple, cast
|
||||
|
||||
try:
|
||||
import orjson
|
||||
except ImportError: # optional; the stdlib parser gives the same objects
|
||||
orjson = None
|
||||
|
||||
try:
|
||||
from src.common.json_body import response_json
|
||||
except ImportError:
|
||||
@@ -120,57 +120,42 @@ RANGE_RETRY_SECONDS = 6 * 60 * 60
|
||||
# pool_maxsize of 10 so the shared Session never has to discard connections.
|
||||
ESPN_CHUNK_WORKERS = 6
|
||||
|
||||
#: An edge month the window covers at least this many days of is asked for
|
||||
#: whole and trimmed, instead of one request per day (see module docstring).
|
||||
#: Below it the days are cheaper than the month: a whole month is two to
|
||||
#: three times the bytes of the half of it a fortnight window holds.
|
||||
ESPN_MONTH_COVER_MIN_DAYS = 7
|
||||
|
||||
# Every chunk request in the process holds one of these while it is in flight
|
||||
# -- the cap is per process, not per window (see module docstring).
|
||||
_chunk_slots = threading.BoundedSemaphore(ESPN_CHUNK_WORKERS)
|
||||
|
||||
|
||||
def _eastern_zone() -> Optional[tzinfo]:
|
||||
"""US Eastern, the zone ESPN's ``dates=YYYYMMDD`` means, or None when
|
||||
this Python has no time zone data (no edge month is trimmed then)."""
|
||||
zone: Optional[tzinfo] = None
|
||||
try:
|
||||
from zoneinfo import ZoneInfo
|
||||
zone = ZoneInfo("America/New_York")
|
||||
except Exception: # noqa: BLE001 - no zoneinfo module or no tz database
|
||||
zone = None
|
||||
if zone is not None:
|
||||
return zone
|
||||
try:
|
||||
import pytz
|
||||
return cast(tzinfo, pytz.timezone("America/New_York"))
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
|
||||
|
||||
_EASTERN = _eastern_zone()
|
||||
|
||||
# What _fetch_one_chunk returns for a month that came back at the cap.
|
||||
_CAPPED: Any = object()
|
||||
|
||||
_range_lock = threading.Lock()
|
||||
# A process starts out assuming ranges are still rejected, as they have been
|
||||
# since 2026-09-15, and tries one again RANGE_RETRY_SECONDS in. Starting
|
||||
# from "unknown" cost one doomed range request per window at every start --
|
||||
# eleven 400s at once from a soccer board, each fetching before any had
|
||||
# answered -- to learn what every start learns.
|
||||
_ranges_rejected_until = time.monotonic() + RANGE_RETRY_SECONDS
|
||||
_ranges_rejected_until = 0.0
|
||||
|
||||
# A chunk is "settled" once its last day is this many UTC days back. ESPN
|
||||
# files games under the US Eastern date, and a late West-coast game ends after
|
||||
# midnight UTC; three days leaves a full day of margin past both, so nothing
|
||||
# still being played, finalised or rescheduled is ever served from memory.
|
||||
SETTLED_AFTER_DAYS = 3
|
||||
|
||||
# How long a settled chunk is trusted. A day's finals do not change, but a
|
||||
# rare correction (or an empty answer during an ESPN outage) should not live
|
||||
# forever: once a day is plenty, and still skips 23 of every 24 hourly asks.
|
||||
SETTLED_CHUNK_TTL_SECONDS = 24 * 60 * 60
|
||||
|
||||
# Bounds on the settled-chunk memory. A settled day measured 90KB (NHL) to
|
||||
# 990KB (a college-football Saturday) of JSON and 9-74KB compressed; the five
|
||||
# windows on hdpi need 60 entries and ~0.55MB. The caps only matter for a
|
||||
# board fetching whole past seasons.
|
||||
SETTLED_CACHE_MAX_ENTRIES = 512
|
||||
SETTLED_CACHE_MAX_BYTES = 8 * 1024 * 1024
|
||||
|
||||
_settled_lock = threading.Lock()
|
||||
# key -> (stored_at monotonic, compressed JSON)
|
||||
_settled_chunks: "OrderedDict[Any, Tuple[float, bytes]]" = OrderedDict()
|
||||
_settled_bytes = 0
|
||||
|
||||
__all__ = [
|
||||
"ESPN_MAX_LIMIT",
|
||||
"ESPN_CHUNK_WORKERS",
|
||||
"ESPN_MONTH_COVER_MIN_DAYS",
|
||||
"RANGE_RETRY_SECONDS",
|
||||
"SETTLED_AFTER_DAYS",
|
||||
"SETTLED_CHUNK_TTL_SECONDS",
|
||||
"clamp_espn_limit",
|
||||
"clear_settled_chunk_cache",
|
||||
"parse_espn_date_range",
|
||||
"espn_date_chunks",
|
||||
"espn_request_chunks",
|
||||
"merge_scoreboard_payloads",
|
||||
"fetch_espn_date_chunks",
|
||||
"fetch_espn_scoreboard",
|
||||
@@ -260,6 +245,88 @@ def _days_of_month(chunk: str) -> List[str]:
|
||||
return days
|
||||
|
||||
|
||||
def _utc_today() -> date:
|
||||
return datetime.now(timezone.utc).date()
|
||||
|
||||
|
||||
def _chunk_last_day(chunk: str) -> Optional[date]:
|
||||
try:
|
||||
if len(chunk) == 8:
|
||||
return date(int(chunk[:4]), int(chunk[4:6]), int(chunk[6:]))
|
||||
if len(chunk) == 6:
|
||||
first = date(int(chunk[:4]), int(chunk[4:6]), 1)
|
||||
return _first_of_next_month(first) - timedelta(days=1)
|
||||
except ValueError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _settled_key(url: str, params: Dict[str, Any], chunk: str) -> Optional[Any]:
|
||||
"""Memory key for a chunk that can no longer change, else None."""
|
||||
last_day = _chunk_last_day(chunk)
|
||||
if last_day is None:
|
||||
return None
|
||||
if last_day > _utc_today() - timedelta(days=SETTLED_AFTER_DAYS):
|
||||
return None
|
||||
# dates is the chunk itself and limit is always ESPN_MAX_LIMIT here;
|
||||
# anything else (groups=80 for FBS, a team filter) changes the answer.
|
||||
rest = tuple(sorted(
|
||||
(str(k), str(v)) for k, v in params.items() if k not in ("dates", "limit")
|
||||
))
|
||||
return (url, rest, chunk)
|
||||
|
||||
|
||||
def _settled_get(key: Any) -> Optional[Dict[str, Any]]:
|
||||
with _settled_lock:
|
||||
entry = _settled_chunks.get(key)
|
||||
if entry is None:
|
||||
return None
|
||||
if time.monotonic() - entry[0] > SETTLED_CHUNK_TTL_SECONDS:
|
||||
_settled_drop(key)
|
||||
return None
|
||||
_settled_chunks.move_to_end(key)
|
||||
blob = entry[1]
|
||||
# Decompress and parse outside the lock: every hit gets its own objects,
|
||||
# so a caller mutating its payload cannot reach another caller's.
|
||||
body = zlib.decompress(blob)
|
||||
return cast(Dict[str, Any], orjson.loads(body) if orjson else json.loads(body))
|
||||
|
||||
|
||||
def _settled_drop(key: Any) -> None:
|
||||
"""Remove one entry. Caller holds _settled_lock."""
|
||||
global _settled_bytes
|
||||
entry = _settled_chunks.pop(key, None)
|
||||
if entry is not None:
|
||||
_settled_bytes -= len(entry[1])
|
||||
|
||||
|
||||
def _settled_put(key: Any, response: Any, payload: Dict[str, Any]) -> None:
|
||||
global _settled_bytes
|
||||
body = getattr(response, "content", None)
|
||||
if not isinstance(body, (bytes, bytearray)):
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
blob = zlib.compress(bytes(body), 6)
|
||||
if len(blob) > SETTLED_CACHE_MAX_BYTES:
|
||||
return
|
||||
with _settled_lock:
|
||||
_settled_drop(key)
|
||||
_settled_chunks[key] = (time.monotonic(), blob)
|
||||
_settled_bytes += len(blob)
|
||||
while _settled_chunks and (
|
||||
len(_settled_chunks) > SETTLED_CACHE_MAX_ENTRIES
|
||||
or _settled_bytes > SETTLED_CACHE_MAX_BYTES
|
||||
):
|
||||
_settled_drop(next(iter(_settled_chunks)))
|
||||
|
||||
|
||||
def clear_settled_chunk_cache() -> None:
|
||||
"""Forget every remembered settled chunk (tests, or a manual refresh)."""
|
||||
global _settled_bytes
|
||||
with _settled_lock:
|
||||
_settled_chunks.clear()
|
||||
_settled_bytes = 0
|
||||
|
||||
|
||||
def espn_date_chunks(start: date, end: date) -> List[str]:
|
||||
"""Cover ``[start, end]`` inclusive with ``dates=`` values ESPN accepts.
|
||||
|
||||
@@ -281,79 +348,6 @@ def espn_date_chunks(start: date, end: date) -> List[str]:
|
||||
return chunks
|
||||
|
||||
|
||||
def espn_request_chunks(
|
||||
start: date,
|
||||
end: date,
|
||||
month_cover_min_days: Optional[int] = None,
|
||||
) -> List[Tuple[str, Optional[Tuple[date, date]]]]:
|
||||
"""The requests that fetch ``[start, end]``, as ``(dates, trim)`` pairs.
|
||||
|
||||
:func:`espn_date_chunks`, except that a partial edge month with
|
||||
``month_cover_min_days`` (default ``ESPN_MONTH_COVER_MIN_DAYS``) or more
|
||||
of its days in the window becomes one ``YYYYMM`` request whose ``trim``
|
||||
is the first and last of those days: its events that start outside them
|
||||
(US Eastern) are dropped. ``trim`` is None for every other request.
|
||||
Without time zone data nothing can be trimmed, so the edge days stay day
|
||||
requests.
|
||||
"""
|
||||
if month_cover_min_days is None:
|
||||
month_cover_min_days = ESPN_MONTH_COVER_MIN_DAYS
|
||||
planned: List[Tuple[str, Optional[Tuple[date, date]]]] = []
|
||||
run: List[str] = []
|
||||
|
||||
def flush() -> None:
|
||||
if (_EASTERN is not None and month_cover_min_days > 0
|
||||
and len(run) >= month_cover_min_days):
|
||||
planned.append((run[0][:6], (_parse_day(run[0]), _parse_day(run[-1]))))
|
||||
else:
|
||||
planned.extend((day, None) for day in run)
|
||||
run.clear()
|
||||
|
||||
for chunk in espn_date_chunks(start, end):
|
||||
if run and (len(chunk) != 8 or chunk[:6] != run[0][:6]):
|
||||
flush()
|
||||
if len(chunk) == 8:
|
||||
run.append(chunk)
|
||||
else:
|
||||
planned.append((chunk, None))
|
||||
flush()
|
||||
return planned
|
||||
|
||||
|
||||
def _parse_day(text: str) -> date:
|
||||
return date(int(text[:4]), int(text[4:6]), int(text[6:8]))
|
||||
|
||||
|
||||
def _eastern_day(stamp: Any) -> Optional[date]:
|
||||
"""The US Eastern date of an ESPN event ``date`` ("2026-10-10T11:30Z"),
|
||||
or None when it cannot be read."""
|
||||
if not isinstance(stamp, str) or _EASTERN is None:
|
||||
return None
|
||||
try:
|
||||
moment = datetime.fromisoformat(stamp.strip().replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
if moment.tzinfo is None:
|
||||
return None
|
||||
return moment.astimezone(_EASTERN).date()
|
||||
|
||||
|
||||
def _trim_to_days(payload: Any, first: date, last: date) -> Any:
|
||||
"""Drop the events of a month payload that start outside ``[first, last]``
|
||||
(US Eastern). An event whose date cannot be read is kept: its day query
|
||||
might well have returned it, and a game is never dropped on a guess.
|
||||
"""
|
||||
if not isinstance(payload, dict) or not isinstance(payload.get("events"), list):
|
||||
return payload
|
||||
kept = []
|
||||
for event in payload["events"]:
|
||||
day = _eastern_day(event.get("date")) if isinstance(event, dict) else None
|
||||
if day is None or first <= day <= last:
|
||||
kept.append(event)
|
||||
payload["events"] = kept
|
||||
return payload
|
||||
|
||||
|
||||
def merge_scoreboard_payloads(payloads: List[Any]) -> Dict[str, Any]:
|
||||
"""Fold chunk responses into one scoreboard payload.
|
||||
|
||||
@@ -384,54 +378,52 @@ def merge_scoreboard_payloads(payloads: List[Any]) -> Dict[str, Any]:
|
||||
def _fetch_one_chunk(
|
||||
session, url: str, params: Dict[str, Any], headers, timeout, logger, chunk: str,
|
||||
cache_max_age: Optional[float] = None,
|
||||
trims: Optional[Dict[str, Tuple[date, date]]] = None,
|
||||
) -> Any:
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""GET a single ``dates=`` chunk, or None when it failed.
|
||||
|
||||
One bad chunk must not sink the rest of the season, so every error is
|
||||
logged and swallowed here rather than raised to the gather below.
|
||||
|
||||
A month that comes back at the cap is truncated: it returns ``_CAPPED``,
|
||||
its payload dropped here before it is ever held beside the others. A
|
||||
month in ``trims`` loses its events outside the days given there.
|
||||
|
||||
The request holds one of the process-wide ``_chunk_slots`` while it runs.
|
||||
A chunk whose days are settled (see ``SETTLED_AFTER_DAYS``) is answered
|
||||
from memory when it was fetched in the last day.
|
||||
"""
|
||||
try:
|
||||
with _chunk_slots:
|
||||
response = fetch_get(
|
||||
session,
|
||||
url,
|
||||
params=dict(params, dates=chunk, limit=ESPN_MAX_LIMIT),
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
**_memo_kwargs(cache_max_age),
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response_json(response)
|
||||
settled = _settled_key(url, params, chunk)
|
||||
if settled is not None:
|
||||
cached = _settled_get(settled)
|
||||
if cached is not None:
|
||||
return cached
|
||||
response = fetch_get(
|
||||
session,
|
||||
url,
|
||||
params=dict(params, dates=chunk, limit=ESPN_MAX_LIMIT),
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
**_memo_kwargs(cache_max_age),
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response_json(response)
|
||||
if settled is not None and isinstance(payload, dict):
|
||||
events = payload.get("events")
|
||||
# A capped month is truncated and gets re-asked day by day;
|
||||
# remembering it would only cost memory.
|
||||
if isinstance(events, list) and len(events) < ESPN_MAX_LIMIT:
|
||||
_settled_put(settled, response, payload)
|
||||
return cast(Optional[Dict[str, Any]], payload)
|
||||
except Exception as exc: # noqa: BLE001 - see docstring
|
||||
if logger:
|
||||
logger.warning("ESPN chunk %s failed, skipping it: %s", chunk, exc)
|
||||
return None
|
||||
if len(chunk) == 6 and isinstance(payload, dict):
|
||||
if len(payload.get("events") or []) >= ESPN_MAX_LIMIT:
|
||||
return _CAPPED
|
||||
trim = (trims or {}).get(chunk)
|
||||
if trim is not None:
|
||||
payload = _trim_to_days(payload, *trim)
|
||||
return payload
|
||||
|
||||
|
||||
def _fetch_chunks(
|
||||
session, url: str, params: Dict[str, Any], headers, timeout, logger,
|
||||
chunks: List[str], cache_max_age: Optional[float] = None,
|
||||
trims: Optional[Dict[str, Tuple[date, date]]] = None,
|
||||
) -> List[Any]:
|
||||
) -> List[Optional[Dict[str, Any]]]:
|
||||
"""Fetch every chunk, returning payloads positionally aligned with ``chunks``.
|
||||
|
||||
Requests go out ``ESPN_CHUNK_WORKERS`` at a time because a cold season is
|
||||
over a hundred of them -- and no more than that across every window the
|
||||
process is fetching, which ``_fetch_one_chunk``'s slot enforces. The order they come back in is not significant --
|
||||
over a hundred of them. The order they come back in is not significant --
|
||||
callers keep ``chunks`` order from the returned list -- but it does mean
|
||||
the session is shared across threads, which is why this only ever issues
|
||||
GETs and never touches session state.
|
||||
@@ -444,7 +436,7 @@ def _fetch_chunks(
|
||||
return []
|
||||
fetch = partial(
|
||||
_fetch_one_chunk, session, url, params, headers, timeout, logger,
|
||||
cache_max_age=cache_max_age, trims=trims,
|
||||
cache_max_age=cache_max_age,
|
||||
)
|
||||
if len(chunks) == 1:
|
||||
return [fetch(chunks[0])]
|
||||
@@ -491,9 +483,7 @@ def fetch_espn_date_chunks(
|
||||
if span is None:
|
||||
return None
|
||||
|
||||
planned = espn_request_chunks(*span)
|
||||
chunks = [chunk for chunk, _ in planned]
|
||||
trims = {chunk: trim for chunk, trim in planned if trim is not None}
|
||||
chunks = espn_date_chunks(*span)
|
||||
if logger:
|
||||
logger.debug(
|
||||
"Fetching ESPN date range %s as %d month/day chunks",
|
||||
@@ -502,31 +492,32 @@ def fetch_espn_date_chunks(
|
||||
|
||||
results = _fetch_chunks(
|
||||
session, url, params, headers, timeout, logger, chunks, cache_max_age,
|
||||
trims,
|
||||
)
|
||||
attempted = len(chunks)
|
||||
|
||||
# A month that came back at the cap is truncated; its days (only the
|
||||
# window's, for a trimmed edge month) replace it in place, so merged
|
||||
# events stay in chunk order however the requests raced. Its payload was
|
||||
# already dropped in the worker: a capped college-baseball month is ~2MB
|
||||
# of parsed JSON, and holding four of them through ~120 day requests added
|
||||
# ~25MB to the peak -- more than the concurrency itself. Low-memory boards
|
||||
# (docs/LOW_MEMORY_BOARDS.md) have under 200MB of headroom.
|
||||
# A month that came back at the cap is truncated; its days replace it in
|
||||
# place, so merged events stay in chunk order however the requests raced.
|
||||
slots: List[Any] = results
|
||||
capped: Dict[int, List[str]] = {}
|
||||
for index, chunk in enumerate(chunks):
|
||||
if slots[index] is not _CAPPED:
|
||||
payload = slots[index]
|
||||
if payload is None or len(chunk) != 6:
|
||||
continue
|
||||
if logger:
|
||||
logger.info(
|
||||
"ESPN month %s hit the %d-event cap; re-asking it day by day",
|
||||
chunk, ESPN_MAX_LIMIT,
|
||||
)
|
||||
trim = trims.get(chunk)
|
||||
capped[index] = (_days_of_month(chunk) if trim is None
|
||||
else espn_date_chunks(*trim))
|
||||
slots[index] = None
|
||||
events = payload.get("events") if isinstance(payload, dict) else None
|
||||
if len(events or []) >= ESPN_MAX_LIMIT:
|
||||
if logger:
|
||||
logger.info(
|
||||
"ESPN month %s hit the %d-event cap; re-asking it day by day",
|
||||
chunk, ESPN_MAX_LIMIT,
|
||||
)
|
||||
capped[index] = _days_of_month(chunk)
|
||||
# Drop the truncated month now rather than after its days arrive:
|
||||
# a capped college-baseball month is ~2MB of parsed JSON, and
|
||||
# holding four of them through ~120 day requests added ~25MB to
|
||||
# the peak -- more than the concurrency itself. Low-memory boards
|
||||
# (docs/LOW_MEMORY_BOARDS.md) have under 200MB of headroom.
|
||||
slots[index] = None
|
||||
payload = events = None
|
||||
|
||||
if capped:
|
||||
days = [day for index in sorted(capped) for day in capped[index]]
|
||||
|
||||
@@ -59,10 +59,7 @@ says how old with ``cache_max_age`` (``fetch_get(..., cache_max_age=ttl)``;
|
||||
Identical means what the validator store keys on: URL, query, effective
|
||||
headers and, for a session with cookies or auth, the session.
|
||||
|
||||
**Counters.** Requests, merged requests, bytes (``bytes`` decoded, as the
|
||||
caller reads them; ``wire_bytes`` as they crossed the network, which is
|
||||
what a metered connection pays for -- ESPN gzips, so the two differ ~14x),
|
||||
304s, errors, HTTP errors,
|
||||
**Counters.** Requests, merged requests, bytes, 304s, errors, HTTP errors,
|
||||
adapter retries, throttled requests and seconds waited, plus requests
|
||||
answered without the network: ``memo_hits`` (the response cache) and
|
||||
``cache_hits`` / ``legacy_cache_hits`` (a shared ESPN scoreboard cache entry,
|
||||
@@ -204,7 +201,6 @@ _COUNTER_FIELDS = (
|
||||
"throttled", # requests that waited for a host budget
|
||||
"overruns", # requests that went after max_wait_seconds anyway
|
||||
"bytes", # decoded response body bytes received
|
||||
"wire_bytes", # body bytes as they came off the socket (still compressed)
|
||||
"wait_seconds", # time spent waiting for host budgets
|
||||
"memo_hits", # answered from the response cache (max-age); nothing sent
|
||||
"cache_hits", # scoreboard fetches answered from a shared ESPN cache entry
|
||||
@@ -620,30 +616,6 @@ def _body_of(response: Any) -> Optional[bytes]:
|
||||
return content if isinstance(content, bytes) else None
|
||||
|
||||
|
||||
def _wire_bytes_of(response: Any, body: Optional[bytes]) -> int:
|
||||
"""How many body bytes came off the socket for ``response``: the
|
||||
compressed size when the server sent gzip, which ESPN does for every
|
||||
scoreboard (63 KB on the wire for an 865 KB college football Saturday).
|
||||
|
||||
urllib3's ``HTTPResponse.tell()`` counts the raw bytes read before
|
||||
decoding. A response without one (a test double, an adapter that is not
|
||||
urllib3) or one whose body was not read is counted at its decoded size,
|
||||
or as 0, so the counter never claims less than it can prove.
|
||||
"""
|
||||
if body is None:
|
||||
return 0
|
||||
raw = getattr(response, "raw", None)
|
||||
tell = getattr(raw, "tell", None)
|
||||
if callable(tell):
|
||||
try:
|
||||
read = tell()
|
||||
except Exception:
|
||||
read = None
|
||||
if isinstance(read, int) and not isinstance(read, bool) and read > 0:
|
||||
return read
|
||||
return len(body)
|
||||
|
||||
|
||||
def _retries_of(response: Any) -> int:
|
||||
raw = getattr(response, "raw", None)
|
||||
retries = getattr(raw, "retries", None)
|
||||
@@ -1145,7 +1117,6 @@ class FetchService:
|
||||
http_errors=int(status is not None and status >= 400),
|
||||
retries=_retries_of(response),
|
||||
bytes=len(body) if body is not None else 0,
|
||||
wire_bytes=_wire_bytes_of(response, body),
|
||||
throttled=int(waited > 0), overruns=int(overrun),
|
||||
wait_seconds=waited)
|
||||
except Exception:
|
||||
|
||||
+10
-43
@@ -46,35 +46,6 @@ from src.common.permission_utils import (
|
||||
get_config_dir_mode
|
||||
)
|
||||
|
||||
|
||||
def _private_copy(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""A deep copy of ``config`` that shares nothing with it.
|
||||
|
||||
load_config() hands one out per call, and the saves keep one, so the
|
||||
cached config is never an object a caller holds. A web handler edits what
|
||||
it loaded, validates, and may refuse the save; when the cache was that
|
||||
same object, the refused edit stayed in it, and the next save of any
|
||||
other setting wrote it to config.json -- a nested secret included, in
|
||||
plain text, since it had never reached config_secrets.json to be
|
||||
stripped.
|
||||
|
||||
The config is JSON data, so only its dicts and lists need copying; every
|
||||
other value in it is immutable. On a Pi 4 with a real 60 KiB config this
|
||||
takes 2.1 ms against copy.deepcopy's 6.8 ms, on a path ~30 handlers call
|
||||
(a pickle round trip is no faster, 1.9 ms, and brings pickle into the
|
||||
config path for nothing).
|
||||
"""
|
||||
return _copy_containers(config)
|
||||
|
||||
|
||||
def _copy_containers(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return {key: _copy_containers(item) for key, item in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [_copy_containers(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
class ConfigManager:
|
||||
"""
|
||||
Reads and writes the main application configuration files.
|
||||
@@ -155,10 +126,9 @@ class ConfigManager:
|
||||
validate_after_write=validate_after_write
|
||||
)
|
||||
|
||||
# Update in-memory config if save was successful. A copy: the caller
|
||||
# still holds new_config_data (see _private_copy).
|
||||
# Update in-memory config if save was successful
|
||||
if result.status == SaveResultStatus.SUCCESS:
|
||||
self.config = _private_copy(new_config_data)
|
||||
self.config = new_config_data
|
||||
# In-memory config now matches what was just written, so the
|
||||
# load_config fast path may return it. It still carries the
|
||||
# merged secrets that were stripped on disk; that matches a full
|
||||
@@ -238,16 +208,14 @@ class ConfigManager:
|
||||
|
||||
Fast path: when config.json, config_secrets.json and the template
|
||||
are all unchanged since the last successful load (mtime_ns + size),
|
||||
a copy of the already-parsed self.config is returned without
|
||||
touching the files.
|
||||
|
||||
Either way the caller gets its own copy (see _private_copy): editing
|
||||
it changes nothing here until it is saved.
|
||||
the already-parsed self.config is returned without touching the
|
||||
files — same aliasing semantics as the full path, which also
|
||||
returns self.config.
|
||||
"""
|
||||
try:
|
||||
current_sig = self._files_signature()
|
||||
if self.config and self._loaded_sig == current_sig:
|
||||
return _private_copy(self.config)
|
||||
return self.config
|
||||
|
||||
# Check if config file exists, if not create from template
|
||||
if not os.path.exists(self.config_path):
|
||||
@@ -281,8 +249,8 @@ class ConfigManager:
|
||||
# Signature taken AFTER load + migration (migration may write the
|
||||
# config back), so it reflects exactly what was read/written.
|
||||
self._loaded_sig = self._files_signature()
|
||||
return _private_copy(self.config)
|
||||
|
||||
return self.config
|
||||
|
||||
except FileNotFoundError as e:
|
||||
# Only config.json can get here: a missing or unreadable secrets
|
||||
# file is handled where it is read.
|
||||
@@ -387,9 +355,8 @@ class ConfigManager:
|
||||
try:
|
||||
atomic_write_json(self.config_path, config_to_write)
|
||||
|
||||
# Update the in-memory config to the new state (which includes
|
||||
# secrets for runtime), as a copy -- see _private_copy
|
||||
self.config = _private_copy(new_config_data)
|
||||
# Update the in-memory config to the new state (which includes secrets for runtime)
|
||||
self.config = new_config_data
|
||||
self._loaded_sig = self._files_signature()
|
||||
self.logger.info(f"Configuration successfully saved to {os.path.abspath(self.config_path)}")
|
||||
if secrets_content:
|
||||
|
||||
+39
-404
@@ -1,56 +1,41 @@
|
||||
"""What the panel shows next: the Arbiter of docs/RUN_LOOP_REDESIGN.md.
|
||||
|
||||
``Arbiter.decide(state, inputs, now)`` takes a snapshot that
|
||||
``DisplayController.run()`` gathers and returns a :class:`ScreenPlan` naming
|
||||
the Source that gets the panel. It is a pure function: no I/O, no clock
|
||||
reads (``now`` is passed in), no locks, and it changes nothing it is given.
|
||||
That is what lets a plain table of cases test the priority order, which
|
||||
used to exist only as the order of ``if`` blocks in ``run()``.
|
||||
``DisplayController.run()`` gathers once per pass and returns a
|
||||
:class:`ScreenPlan` naming the Source that gets the panel. It is a pure
|
||||
function: no I/O, no clock reads (``now`` is passed in), no locks, and it
|
||||
changes nothing it is given. That is what lets a plain table of cases test
|
||||
the priority order, which used to exist only as the order of ``if`` blocks
|
||||
in ``run()``.
|
||||
|
||||
The order is
|
||||
The full order is
|
||||
|
||||
ScheduledOff (a gate), Follower, OnDemand, Wifi, Live, Vegas, Rotation
|
||||
|
||||
Every Source but Vegas is decided here (stage 3). Vegas is the ``LEGACY``
|
||||
plan: the Arbiter picks it, but its iteration is still run()'s own code
|
||||
until stage 4.
|
||||
Stage 2 decides the gate, Follower and Wifi. Every other case returns a
|
||||
``LEGACY`` plan, meaning "carry on with run()'s existing code" (live
|
||||
priority, Vegas, then one rotation screen). OnDemand is in the order already
|
||||
because it outranks the WiFi notice: an active session is a ``LEGACY`` plan
|
||||
even when a notice is pending.
|
||||
|
||||
A pass asks twice: once with the inputs every pass reads (the gate,
|
||||
Follower, OnDemand, Wifi), and once more, only when nothing above the
|
||||
notice took the panel, with the inputs the Sources below it need (whether
|
||||
Vegas is on, the live-priority scan), read where run() always read them.
|
||||
|
||||
``decide(..., running=plan)`` is the other question, asked by the
|
||||
ScreenRunner (src/screen_runner.py) at its service points: does a Source
|
||||
in ``plan.preemptible_by`` now take the panel from the screen that is
|
||||
running? The mid-screen rules are :func:`_hold_or_preempt`.
|
||||
|
||||
The state transitions (the next on-demand mode, a live claim and its
|
||||
release, the rotation's step after a screen) are pure methods of
|
||||
:class:`ArbiterState`; the controller applies what they return.
|
||||
The Wifi Source's mid-screen rule, :func:`wifi_notice_preempts`, lives here
|
||||
too, so both of its answers -- at the top of a pass and between frames --
|
||||
come from one module.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import FrozenSet, Optional, Protocol, Tuple
|
||||
from typing import Optional
|
||||
|
||||
__all__ = [
|
||||
"Arbiter",
|
||||
"ArbiterInputs",
|
||||
"ArbiterState",
|
||||
"FramePolicy",
|
||||
"LIVE_PREEMPTERS",
|
||||
"SCHEDULED_OFF_DWELL",
|
||||
"SCREEN_PREEMPTERS",
|
||||
"ScreenEnd",
|
||||
"ScreenPlan",
|
||||
"Source",
|
||||
"WIFI_NOTICE_DWELL",
|
||||
"WifiNotice",
|
||||
"live_pick",
|
||||
"live_takeover",
|
||||
"on_demand_bound",
|
||||
"rotation_plan",
|
||||
"wifi_notice_preempts",
|
||||
]
|
||||
|
||||
@@ -68,41 +53,11 @@ class Source(Enum):
|
||||
|
||||
SCHEDULED_OFF = "scheduled-off"
|
||||
FOLLOWER = "follower"
|
||||
ON_DEMAND = "on-demand"
|
||||
WIFI = "wifi"
|
||||
LIVE = "live"
|
||||
# Vegas: the Arbiter picks it, but its iteration is still run()'s own
|
||||
# code (and its interrupt callback a second copy of this order) until
|
||||
# stage 4 makes it a Source driven frame by frame.
|
||||
# Not decided by the Arbiter yet: on-demand, live priority, Vegas and the
|
||||
# rotation are still chosen by run()'s own code. Stage 3 adds the
|
||||
# OnDemand, Live and Rotation Sources; stage 4 adds Vegas.
|
||||
LEGACY = "legacy"
|
||||
ROTATION = "rotation"
|
||||
# Not a screen: a plugin reload waits at the top of the loop. It ends a
|
||||
# screen between frames (the screen counts as shown and the rotation
|
||||
# moves on), and the next pass reloads before it draws.
|
||||
RELOAD = "reload"
|
||||
|
||||
|
||||
class FramePolicy(Enum):
|
||||
"""How often a screen draws: today's two frame loops (see
|
||||
DisplayController._needs_high_fps). Stage 5 lets plugins declare it."""
|
||||
|
||||
#: The 125 Hz loop, paced to an 8 ms deadline: scrolling plugins.
|
||||
HIGH_FPS = "high-fps"
|
||||
#: The 1 Hz loop.
|
||||
STATIC = "static"
|
||||
|
||||
|
||||
class ScreenEnd(Protocol):
|
||||
"""What ArbiterState.after needs to know about how a screen ended
|
||||
(screen_runner.Outcome, filled in by the controller)."""
|
||||
|
||||
@property
|
||||
def on_demand_active(self) -> bool:
|
||||
"""An on-demand session was running when the screen ended."""
|
||||
|
||||
@property
|
||||
def still_live(self) -> bool:
|
||||
"""The mode's plugin still had live content: hold the rotation."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -121,122 +76,11 @@ class WifiNotice:
|
||||
class ArbiterState:
|
||||
"""What the Arbiter remembers between passes.
|
||||
|
||||
A snapshot of the controller's own fields, taken when decide() is
|
||||
called (DisplayController._arbiter_state); the transitions below return
|
||||
the next state, and the controller writes it back.
|
||||
|
||||
Attributes:
|
||||
current_mode: The mode on the panel or about to be
|
||||
(``current_display_mode``).
|
||||
on_demand_modes: The on-demand session's modes, in the order it
|
||||
shows them (a pinned mode already moved to the front when the
|
||||
session started, by _apply_on_demand_pin).
|
||||
on_demand_index: Which of them is showing.
|
||||
on_demand_expires_at: When the session ends (wall clock), or None
|
||||
for a session with no duration.
|
||||
on_demand_pinned: The session was started pinned. Carried for the
|
||||
snapshot; the pin itself is already in ``on_demand_modes``.
|
||||
rotation: The rotation's modes (``available_modes``).
|
||||
rotation_index: Where the rotation is (``current_mode_index``).
|
||||
live_resume_index: Where the rotation was when live priority took
|
||||
the panel, so it resumes there once nothing is live; None while
|
||||
live priority holds nothing.
|
||||
live_takeover_unshown: A mid-screen takeover chose current_mode and
|
||||
it has not been shown yet, so the next pass must not advance the
|
||||
live round-robin past it.
|
||||
Nothing yet: the stage-2 Sources decide from the inputs alone. The
|
||||
on-demand index, the rotation index and the live resume point move here
|
||||
with their Sources in stage 3.
|
||||
"""
|
||||
|
||||
current_mode: Optional[str] = None
|
||||
on_demand_modes: Tuple[str, ...] = ()
|
||||
on_demand_index: int = 0
|
||||
on_demand_expires_at: Optional[float] = None
|
||||
on_demand_pinned: bool = False
|
||||
rotation: Tuple[str, ...] = ()
|
||||
rotation_index: int = 0
|
||||
live_resume_index: Optional[int] = None
|
||||
live_takeover_unshown: bool = False
|
||||
|
||||
def next_on_demand(self) -> "ArbiterState":
|
||||
"""The session's next mode, wrapping round. Needs a mode list."""
|
||||
index = (self.on_demand_index + 1) % len(self.on_demand_modes)
|
||||
return replace(self, on_demand_index=index,
|
||||
current_mode=self.on_demand_modes[index])
|
||||
|
||||
def claim_live(self, mode: str) -> "ArbiterState":
|
||||
"""Live priority takes the panel for ``mode``.
|
||||
|
||||
The rotation's position is saved only on the first claim, not on
|
||||
each re-check while the hold continues, so it resumes where live
|
||||
priority interrupted it instead of after the live mode (which would
|
||||
skip every mode between the two).
|
||||
"""
|
||||
if self.current_mode == mode:
|
||||
return self
|
||||
resume = self.rotation_index if self.live_resume_index is None else self.live_resume_index
|
||||
index = self.rotation.index(mode) if mode in self.rotation else self.rotation_index
|
||||
return replace(self, current_mode=mode, rotation_index=index,
|
||||
live_resume_index=resume)
|
||||
|
||||
def after(self, outcome: "ScreenEnd") -> "ArbiterState":
|
||||
"""The state once a screen has run its course: the next mode.
|
||||
|
||||
An on-demand session moves to its next mode. Otherwise the rotation
|
||||
advances -- unless the mode just shown is a live-priority mode that
|
||||
is still live, which holds the panel. A session with no modes left
|
||||
is ended by the controller before it asks (that is not pure: it
|
||||
resumes the rotation and clears the cache).
|
||||
"""
|
||||
if outcome.on_demand_active:
|
||||
return self.next_on_demand() if self.on_demand_modes else self
|
||||
if outcome.still_live or not self.rotation:
|
||||
return self
|
||||
index = (self.rotation_index + 1) % len(self.rotation)
|
||||
return replace(self, rotation_index=index, current_mode=self.rotation[index])
|
||||
|
||||
def release_live(self) -> "ArbiterState":
|
||||
"""Nothing is live any more: the rotation resumes where it was."""
|
||||
if self.live_resume_index is None or not self.rotation:
|
||||
return self
|
||||
index = self.live_resume_index % len(self.rotation)
|
||||
return replace(self, current_mode=self.rotation[index], rotation_index=index,
|
||||
live_resume_index=None)
|
||||
|
||||
def showing(self, plan: "ScreenPlan") -> "ArbiterState":
|
||||
"""The state once ``plan`` is on the panel.
|
||||
|
||||
An on-demand plan puts the session's index on the mode it shows (an
|
||||
index past the end of a shortened list starts it again at 0).
|
||||
"""
|
||||
state = replace(self, current_mode=plan.mode)
|
||||
if plan.source is Source.ON_DEMAND and self.on_demand_modes:
|
||||
state = replace(state, on_demand_index=_on_demand_index(self))
|
||||
return state
|
||||
|
||||
|
||||
def _on_demand_index(state: ArbiterState) -> int:
|
||||
"""The session's index, or 0 once it is past the end of its list."""
|
||||
index = state.on_demand_index
|
||||
return index if index < len(state.on_demand_modes) else 0
|
||||
|
||||
|
||||
def on_demand_bound(min_duration: float, max_duration: float,
|
||||
deadline: Optional[float],
|
||||
now: float) -> Optional[Tuple[float, float]]:
|
||||
"""Shorten a screen's (min, max) seconds to what is left of a timed
|
||||
on-demand session ending at ``deadline``. None when nothing is left.
|
||||
|
||||
The OnDemand Source's bound, applied after the screen's first frame,
|
||||
where it always was (``now`` is read then).
|
||||
"""
|
||||
if deadline is None:
|
||||
return min_duration, max_duration
|
||||
remaining = max(0.0, deadline - now)
|
||||
min_duration = min(min_duration, remaining)
|
||||
max_duration = min(max_duration, remaining)
|
||||
if max_duration <= 0:
|
||||
return None
|
||||
return min_duration, max_duration
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ArbiterInputs:
|
||||
@@ -251,122 +95,57 @@ class ArbiterInputs:
|
||||
when it could win (the panel is on, and neither a follower nor
|
||||
on-demand outranks it), because reading it has side effects: a
|
||||
1 Hz throttle and deleting an expired file.
|
||||
live_modes: The modes with live content, from a live-priority scan,
|
||||
in registration order; None when no scan was made (on-demand,
|
||||
Vegas keeping live content in its ticker, a throttled
|
||||
mid-screen check). A scan asks every live-priority plugin, so it
|
||||
is made only where run() always made it.
|
||||
vegas_enabled: Vegas mode is on (and no on-demand session holds it
|
||||
off).
|
||||
vegas_live_in_ticker: Vegas keeps live content in its ticker
|
||||
instead of yielding the panel to it.
|
||||
vegas_yielded: This pass's Vegas iteration has run and yielded, so
|
||||
the Vegas Source passes and the screen it fell through to is
|
||||
decided.
|
||||
reload_pending: Mid-screen only: a plugin reload is waiting for the
|
||||
top of the loop, at a service point where that ends the screen.
|
||||
"""
|
||||
|
||||
schedule_on: bool
|
||||
on_demand_active: bool
|
||||
follower_active: bool
|
||||
wifi_notice: Optional[WifiNotice] = None
|
||||
live_modes: Optional[Tuple[str, ...]] = None
|
||||
vegas_enabled: bool = False
|
||||
vegas_live_in_ticker: bool = False
|
||||
vegas_yielded: bool = False
|
||||
reload_pending: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScreenPlan:
|
||||
"""The Arbiter's answer for one pass.
|
||||
|
||||
decide() is pure, so it cannot ask a plugin anything: the fields a
|
||||
plugin answers (its durations, whether it runs a dynamic cycle, how
|
||||
often it draws) are filled in by the controller after the screen's first
|
||||
frame, when they have always been read (DisplayController.complete_plan).
|
||||
|
||||
Attributes:
|
||||
source: The Source that gets the panel.
|
||||
mode: The display mode to draw (None for a blank, follower or notice).
|
||||
plugin: The id of the plugin drawing ``mode``, once resolved.
|
||||
min_duration: Seconds the screen runs at least (dynamic duration).
|
||||
max_duration: How long the plan holds the panel, in seconds, at most
|
||||
(its dwell ends early when what the panel should show changes).
|
||||
None when the Source paces itself (a follower frame, Vegas), and
|
||||
for a rotation or live plan until its first frame.
|
||||
dynamic: Run until the plugin's cycle completes, between min and max.
|
||||
frame_policy: Which frame loop the screen runs.
|
||||
preemptible_by: The Sources that may end the screen mid-way.
|
||||
None when the Source paces itself: a follower frame, or LEGACY.
|
||||
notice: The WiFi notice to draw, for a WIFI plan.
|
||||
deadline: For an on-demand plan, when the session ends (wall
|
||||
clock): after the first frame the screen's durations are cut to
|
||||
what is left (:func:`on_demand_bound`).
|
||||
ends_live: Nothing is live any more and live priority had
|
||||
interrupted the rotation: taking this plan resumes the rotation
|
||||
where it was (ArbiterState.release_live) before it shows.
|
||||
"""
|
||||
|
||||
source: Source
|
||||
mode: Optional[str] = None
|
||||
plugin: Optional[str] = None
|
||||
min_duration: Optional[float] = None
|
||||
max_duration: Optional[float] = None
|
||||
dynamic: bool = False
|
||||
frame_policy: Optional[FramePolicy] = None
|
||||
preemptible_by: FrozenSet[Source] = frozenset()
|
||||
notice: Optional[WifiNotice] = None
|
||||
deadline: Optional[float] = None
|
||||
ends_live: bool = False
|
||||
|
||||
|
||||
SCHEDULED_OFF_PLAN = ScreenPlan(Source.SCHEDULED_OFF, max_duration=SCHEDULED_OFF_DWELL)
|
||||
FOLLOWER_PLAN = ScreenPlan(Source.FOLLOWER)
|
||||
RELOAD_PLAN = ScreenPlan(Source.RELOAD)
|
||||
|
||||
#: What may end a screen mid-way: the schedule, an on-demand session
|
||||
#: starting or ending, a WiFi notice, a live game, the rotation moving
|
||||
#: under the screen, and a plugin reload. Not a follower or Vegas: those
|
||||
#: are only looked at between screens.
|
||||
SCREEN_PREEMPTERS: FrozenSet[Source] = frozenset(
|
||||
{Source.SCHEDULED_OFF, Source.ON_DEMAND, Source.WIFI, Source.LIVE, Source.ROTATION,
|
||||
Source.RELOAD})
|
||||
|
||||
#: A live screen is not preempted by Live: live games take turns between
|
||||
#: screens, never mid-screen.
|
||||
LIVE_PREEMPTERS: FrozenSet[Source] = SCREEN_PREEMPTERS - {Source.LIVE}
|
||||
LEGACY_PLAN = ScreenPlan(Source.LEGACY)
|
||||
|
||||
|
||||
class Arbiter:
|
||||
"""Decides which Source gets the panel. Stateless; see the module docstring."""
|
||||
|
||||
@staticmethod
|
||||
def decide(state: ArbiterState, inputs: ArbiterInputs, now: float,
|
||||
running: Optional[ScreenPlan] = None) -> ScreenPlan:
|
||||
def decide(state: ArbiterState, inputs: ArbiterInputs, now: float) -> ScreenPlan:
|
||||
"""The plan for this pass, from the Sources in priority order.
|
||||
|
||||
With ``running``, the question is the ScreenRunner's at one of its
|
||||
service points instead: does a Source in ``running.preemptible_by``
|
||||
now take the panel from that screen? The answer is ``running``
|
||||
itself (the same object) while it holds, else the plan that ends it.
|
||||
See :func:`_hold_or_preempt` for the rules.
|
||||
|
||||
Args:
|
||||
state: What the Arbiter remembers between passes.
|
||||
state: What the Arbiter remembers between passes (nothing yet).
|
||||
inputs: This pass's snapshot.
|
||||
now: Wall-clock time of the snapshot. The OnDemand Source reads
|
||||
it for what is left of a timed session, and the mid-screen
|
||||
WiFi rule (:func:`wifi_notice_preempts`) to compare with the
|
||||
notice's expiry. The top-of-pass WiFi check does not: it
|
||||
takes the notice as read.
|
||||
running: The screen on the panel, for a mid-screen check.
|
||||
now: Wall-clock time of the snapshot. No stage-2 Source reads it:
|
||||
the top-of-pass WiFi check takes the notice as read, and only
|
||||
the mid-screen check (:func:`wifi_notice_preempts`) compares
|
||||
it with the expiry. It is in the signature for the Sources
|
||||
stage 3 adds (on-demand expiry, durations).
|
||||
|
||||
Returns:
|
||||
The winning Source's plan (LEGACY for Vegas), or ``running``.
|
||||
The winning Source's plan, or LEGACY_PLAN when the winner is one
|
||||
run() still decides itself.
|
||||
"""
|
||||
if running is not None:
|
||||
return _hold_or_preempt(state, inputs, now, running)
|
||||
del state, now # not read by the stage-2 Sources; see the docstring
|
||||
|
||||
# ScheduledOff is a gate, not a Source: a scheduled-off panel stays
|
||||
# blank even for a follower, and only an on-demand session overrides
|
||||
@@ -378,161 +157,17 @@ class Arbiter:
|
||||
if inputs.follower_active:
|
||||
return FOLLOWER_PLAN
|
||||
|
||||
# 2. OnDemand: the session's current mode. It outranks the notice.
|
||||
# 2. OnDemand: decided by run() until stage 3. It outranks the notice.
|
||||
if inputs.on_demand_active:
|
||||
return _on_demand_plan(state, now)
|
||||
return LEGACY_PLAN
|
||||
|
||||
# 3. Wifi: a pending notice, held for one short dwell per pass.
|
||||
if inputs.wifi_notice is not None:
|
||||
return ScreenPlan(Source.WIFI, max_duration=WIFI_NOTICE_DWELL,
|
||||
notice=inputs.wifi_notice)
|
||||
|
||||
# 4. Live: the next live game, round-robin across several. With
|
||||
# nothing live, a rotation that live priority interrupted resumes.
|
||||
ends_live = False
|
||||
if _live_applies(inputs):
|
||||
pick = live_pick(inputs.live_modes, state.current_mode,
|
||||
advance=not state.live_takeover_unshown)
|
||||
if pick is not None:
|
||||
return ScreenPlan(Source.LIVE, mode=pick, preemptible_by=LIVE_PREEMPTERS)
|
||||
ends_live = state.live_resume_index is not None and bool(state.rotation)
|
||||
|
||||
# 5. Vegas: one iteration of the ticker, run by run()'s own code
|
||||
# until stage 4. Passes once this pass's iteration has yielded.
|
||||
if inputs.vegas_enabled and not inputs.vegas_yielded:
|
||||
return ScreenPlan(Source.LEGACY, ends_live=ends_live)
|
||||
|
||||
# 6. Rotation: the rotation's current mode (after the resume, when
|
||||
# live priority just ended).
|
||||
return rotation_plan(state.release_live() if ends_live else state,
|
||||
ends_live=ends_live)
|
||||
|
||||
|
||||
def _hold_or_preempt(state: ArbiterState, inputs: ArbiterInputs, now: float,
|
||||
running: ScreenPlan) -> ScreenPlan:
|
||||
"""The mid-screen rules: ``running``, or the plan that ends it.
|
||||
|
||||
What the frame loops used to check one by one (_check_live_takeover,
|
||||
then _screen_preempted with _wifi_notice_pending in it, before stage 3),
|
||||
in their order:
|
||||
|
||||
1. Live: a game went live while a non-live screen runs (the inputs
|
||||
carry a scan only when one was due, at most once a second). Checked
|
||||
first because it is the one preemption that changes the state -- the
|
||||
rotation moves to the live mode and remembers where it was -- and it
|
||||
still happens when a WiFi notice is also pending: the next pass then
|
||||
shows the notice, and the game after it.
|
||||
2. The panel's mode moved under the screen: an on-demand session
|
||||
started, ended or changed mode, or the rotation was rebuilt (a
|
||||
plugin enabled, disabled or reloaded).
|
||||
3. The schedule turned the panel off.
|
||||
4. A WiFi notice arrived (unless on-demand outranks it), compared with
|
||||
its expiry because the read throttle can hand back a stale one.
|
||||
5. A plugin reload is waiting at the top of the loop.
|
||||
|
||||
A follower and Vegas are never mid-screen preemptions; they are looked
|
||||
at between screens.
|
||||
"""
|
||||
by = running.preemptible_by
|
||||
if Source.LIVE in by:
|
||||
takeover = live_takeover(state, inputs)
|
||||
if takeover is not None:
|
||||
return ScreenPlan(Source.LIVE, mode=takeover, preemptible_by=LIVE_PREEMPTERS)
|
||||
if state.current_mode != running.mode:
|
||||
source = Source.ON_DEMAND if inputs.on_demand_active else Source.ROTATION
|
||||
if source in by:
|
||||
return ScreenPlan(source, mode=state.current_mode, preemptible_by=SCREEN_PREEMPTERS)
|
||||
if (Source.SCHEDULED_OFF in by and not inputs.schedule_on
|
||||
and not inputs.on_demand_active):
|
||||
return SCHEDULED_OFF_PLAN
|
||||
notice = inputs.wifi_notice
|
||||
if (Source.WIFI in by and notice is not None
|
||||
and wifi_notice_preempts(notice, inputs.on_demand_active, now)):
|
||||
return ScreenPlan(Source.WIFI, max_duration=WIFI_NOTICE_DWELL, notice=notice)
|
||||
if Source.RELOAD in by and inputs.reload_pending:
|
||||
return RELOAD_PLAN
|
||||
return running
|
||||
|
||||
|
||||
def live_takeover(state: ArbiterState, inputs: ArbiterInputs) -> Optional[str]:
|
||||
"""The live mode that takes the panel mid-screen, or None.
|
||||
|
||||
The first live mode, when a scan found one and the panel is not on a
|
||||
live mode already. Never while on-demand holds the panel, while it is
|
||||
scheduled off, or while Vegas keeps live content in its ticker.
|
||||
"""
|
||||
if not _live_applies(inputs) or inputs.on_demand_active or not inputs.schedule_on:
|
||||
return None
|
||||
live = inputs.live_modes
|
||||
if not live or state.current_mode in live:
|
||||
return None
|
||||
return live[0]
|
||||
|
||||
|
||||
def _on_demand_plan(state: ArbiterState, now: float) -> ScreenPlan:
|
||||
"""The OnDemand Source: the session's current mode.
|
||||
|
||||
``max_duration`` is what is left of a timed session at ``now`` (None
|
||||
without a duration); ``deadline`` carries the expiry so the bound can be
|
||||
applied again after the first frame. A session with no modes left (its
|
||||
plugin was unloaded under it) gets a plan with no mode: the controller
|
||||
ends the session and shows the rotation's mode instead.
|
||||
"""
|
||||
modes = state.on_demand_modes
|
||||
if not modes:
|
||||
return ScreenPlan(Source.ON_DEMAND)
|
||||
expires_at = state.on_demand_expires_at
|
||||
remaining = None if expires_at is None else max(0.0, expires_at - now)
|
||||
return ScreenPlan(Source.ON_DEMAND, mode=modes[_on_demand_index(state)],
|
||||
max_duration=remaining, deadline=expires_at,
|
||||
preemptible_by=SCREEN_PREEMPTERS)
|
||||
|
||||
|
||||
def rotation_plan(state: ArbiterState, ends_live: bool = False) -> ScreenPlan:
|
||||
"""The Rotation Source: the mode the rotation is on.
|
||||
|
||||
That is ``state.current_mode``, which is ``rotation[rotation_index]``
|
||||
except where something moved the panel off the list and the rotation
|
||||
carries on from there: a live mode no rotation entry names, or None
|
||||
when a session ended with no enabled mode to resume to.
|
||||
"""
|
||||
return ScreenPlan(Source.ROTATION, mode=state.current_mode, ends_live=ends_live,
|
||||
preemptible_by=SCREEN_PREEMPTERS)
|
||||
|
||||
|
||||
def _live_applies(inputs: ArbiterInputs) -> bool:
|
||||
"""Whether the Live Source has a say: a scan was made, and Vegas is not
|
||||
keeping live content in its ticker (where the live plugin takes extra
|
||||
turns in the marquee instead of the panel)."""
|
||||
if inputs.live_modes is None:
|
||||
return False
|
||||
return not (inputs.vegas_enabled and inputs.vegas_live_in_ticker)
|
||||
|
||||
|
||||
def live_pick(live_modes: Optional[Tuple[str, ...]], current_mode: Optional[str],
|
||||
advance: bool) -> Optional[str]:
|
||||
"""The live mode to show, or None when nothing is live.
|
||||
|
||||
When several plugins are live at once this round-robins between them, so
|
||||
the panel alternates each dwell instead of pinning to the first one
|
||||
registered. The mode on the panel is the cursor, so this stays right as
|
||||
games start and end.
|
||||
|
||||
Args:
|
||||
live_modes: The live modes, in registration order.
|
||||
current_mode: The mode on the panel.
|
||||
advance: True for the rotation's pick (the live mode after the one
|
||||
showing). False for a peek (the one showing if it is still live,
|
||||
else the first), which Vegas uses to ask whether anything is.
|
||||
"""
|
||||
if not live_modes:
|
||||
return None
|
||||
if current_mode in live_modes:
|
||||
if advance:
|
||||
index = live_modes.index(current_mode)
|
||||
return live_modes[(index + 1) % len(live_modes)]
|
||||
return current_mode
|
||||
return live_modes[0]
|
||||
# 4-6. Live, Vegas, Rotation: still run()'s own code.
|
||||
return LEGACY_PLAN
|
||||
|
||||
|
||||
def wifi_notice_preempts(notice: Optional[WifiNotice], on_demand_active: bool,
|
||||
|
||||
+539
-830
File diff suppressed because it is too large
Load Diff
+2
-52
@@ -317,11 +317,6 @@ class DisplayManager:
|
||||
# is handed to the writer; this only once it has been saved, so an
|
||||
# mtime touch never vouches for a frame still waiting to be written.
|
||||
self._saved_snapshot_digest: Optional[int] = None
|
||||
# A changed frame reached _write_snapshot_if_due() inside the write
|
||||
# interval and was skipped. Nothing writes it unless update_display()
|
||||
# runs again, and a screen that draws once and holds never calls it
|
||||
# again -- see write_owed_snapshot().
|
||||
self._snapshot_owed = False
|
||||
self._snapshot_dir_prepared = False
|
||||
# Background writer used mid-scroll; see _write_snapshot_if_due.
|
||||
self._snapshot_cond = threading.Condition()
|
||||
@@ -1793,10 +1788,9 @@ class DisplayManager:
|
||||
|
||||
if frame_checksum is not None:
|
||||
digest = frame_checksum
|
||||
frame_changed = digest != self._last_snapshot_digest
|
||||
action = snapshot_policy.decide(
|
||||
now, self._last_snapshot_ts, self._last_snapshot_touch_ts,
|
||||
viewer_fresh, frame_changed)
|
||||
viewer_fresh, digest != self._last_snapshot_digest)
|
||||
else:
|
||||
# Ask as if the frame had changed before paying to find out.
|
||||
# decide() is monotone in frame_changed -- a SKIP for a
|
||||
@@ -1808,36 +1802,22 @@ class DisplayManager:
|
||||
now, self._last_snapshot_ts, self._last_snapshot_touch_ts,
|
||||
viewer_fresh, True)
|
||||
if action is snapshot_policy.SnapshotAction.SKIP:
|
||||
# Not hashed, so not known to be unchanged: owed until a
|
||||
# later look finds it written or unchanged.
|
||||
self._snapshot_owed = True
|
||||
return
|
||||
digest = zlib.adler32(self.image.tobytes())
|
||||
frame_changed = digest != self._last_snapshot_digest
|
||||
if not frame_changed:
|
||||
if digest == self._last_snapshot_digest:
|
||||
# Unchanged after all: the decision an unchanged frame gets.
|
||||
action = snapshot_policy.decide(
|
||||
now, self._last_snapshot_ts,
|
||||
self._last_snapshot_touch_ts, viewer_fresh, False)
|
||||
if action is snapshot_policy.SnapshotAction.SKIP:
|
||||
# A changed frame inside the write interval stays owed: the
|
||||
# next update_display() would write it, but a static screen
|
||||
# may not make one -- write_owed_snapshot() covers that.
|
||||
self._snapshot_owed = frame_changed
|
||||
return
|
||||
if (action is snapshot_policy.SnapshotAction.TOUCH
|
||||
and self._saved_snapshot_digest == digest):
|
||||
# mtime bump only: keeps the health check (snapshot age)
|
||||
# green without paying for a PNG encode of an unchanged frame
|
||||
# (this frame is already on disk, so nothing is owed).
|
||||
self._snapshot_owed = False
|
||||
os.utime(self._snapshot_path, None)
|
||||
self._last_snapshot_touch_ts = now
|
||||
return
|
||||
# Owed until the write below succeeds: if it raises, the frame
|
||||
# stays owed and write_owed_snapshot() retries it, rather than a
|
||||
# held screen leaving the preview stale after one failed write.
|
||||
self._snapshot_owed = True
|
||||
# (A TOUCH for a frame that isn't on disk yet -- still queued, or
|
||||
# its write failed -- is written instead: touching would make the
|
||||
# older file on disk look current.)
|
||||
@@ -1862,39 +1842,9 @@ class DisplayManager:
|
||||
self._last_snapshot_ts = now
|
||||
self._last_snapshot_touch_ts = now
|
||||
self._last_snapshot_digest = digest
|
||||
self._snapshot_owed = False
|
||||
except Exception as e:
|
||||
self._log_snapshot_failure(e)
|
||||
|
||||
def write_owed_snapshot(self) -> None:
|
||||
"""Write a frame the snapshot throttle skipped, once it is due.
|
||||
|
||||
The preview snapshot is only ever written from update_display(), and
|
||||
at most once per write interval (snapshot_policy). A frame pushed
|
||||
inside that interval is skipped, and is written by the next
|
||||
update_display() that comes after it -- but a screen that draws its
|
||||
card once and then holds it makes no further call. Its frame was on
|
||||
the panel and never in the preview: soccer's recent/upcoming cards
|
||||
skip redundant redraws, and the first one after an on-demand start
|
||||
(pushed a few milliseconds after the controller's clear) left
|
||||
/api/v3/display/current and the web preview black for the whole
|
||||
screen while the panel showed the card.
|
||||
|
||||
The render loop calls this after each frame. Cheap when nothing is
|
||||
owed (one attribute read); otherwise the usual policy decides, so
|
||||
the write still waits out the interval and an unchanged frame is
|
||||
never re-encoded.
|
||||
"""
|
||||
if not self._snapshot_owed:
|
||||
return
|
||||
try:
|
||||
if self._writes_suppressed():
|
||||
return
|
||||
with self._update_lock:
|
||||
self._write_snapshot_if_due()
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
self._log_snapshot_failure(e)
|
||||
|
||||
def _log_snapshot_failure(self, error: Exception) -> None:
|
||||
# Snapshot failures must never break display — but they must not
|
||||
# be silent either: the snapshot's mtime is the web UI's display
|
||||
|
||||
+31
-125
@@ -490,13 +490,10 @@ def record_error(
|
||||
# user reads, and the other way round for the clear request.
|
||||
#
|
||||
# ERROR_SNAPSHOT_KEY written by the display service only
|
||||
# ERROR_CLEAR_REQUEST_KEY written by the web interface only, as a fallback
|
||||
# ERROR_CLEAR_REQUEST_KEY written by the web interface only
|
||||
#
|
||||
# A clear goes over the control socket (``errors.clear``): the display applies
|
||||
# it (clear_before) and republishes the snapshot before it answers. Only when
|
||||
# the socket cannot carry it (no socket, or a display older than the command)
|
||||
# does the web interface record a request in the mailbox, which the display
|
||||
# applies on its next tick; its tick reads that file only when it changed.
|
||||
# A clear is asynchronous: the web interface records a request, and the
|
||||
# display service applies it (clear_before) on its next tick and republishes.
|
||||
# Until it has, the web interface hides whatever the snapshot shows from
|
||||
# before the cutoff, so a clear takes effect for readers immediately and a
|
||||
# snapshot published just before the request cannot bring old errors back.
|
||||
@@ -601,43 +598,13 @@ class ErrorSnapshotPublisher:
|
||||
self._published_version: Optional[int] = None
|
||||
self._last_attempt: Optional[float] = None
|
||||
self._applied_clear_id: Optional[str] = None
|
||||
# The widest cutoff applied in this process: a clear request at or
|
||||
# before it has nothing left to clear (see _pending_cutoff).
|
||||
self._applied_clear_cutoff: Optional[float] = None
|
||||
from src.cache_manager import MailboxWatch # the display's cache, loaded already
|
||||
self._mailbox = MailboxWatch(ERROR_CLEAR_REQUEST_KEY)
|
||||
self._tick_lock = threading.Lock()
|
||||
self._stop = threading.Event()
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
|
||||
def _clear(self, request_id: str, cutoff: float) -> int:
|
||||
"""Apply one clear and remember it. Caller holds _tick_lock."""
|
||||
cleared = 0
|
||||
if math.isfinite(cutoff):
|
||||
cleared = self.aggregator.clear_before(datetime.fromtimestamp(cutoff))
|
||||
_snapshot_logger.info("Cleared %d plugin error record(s) as requested (%s)",
|
||||
cleared, request_id)
|
||||
if self._applied_clear_cutoff is None or cutoff > self._applied_clear_cutoff:
|
||||
self._applied_clear_cutoff = cutoff
|
||||
# A malformed request is acknowledged too, so it is not retried forever.
|
||||
self._applied_clear_id = request_id
|
||||
return cleared
|
||||
|
||||
def _apply_clear_request(self) -> bool:
|
||||
"""Honour a mailbox clear request we have not applied yet. True if one was.
|
||||
|
||||
The mailbox is the fallback for a web interface that could not use
|
||||
the control socket (``errors.clear``, :meth:`clear_now`). It is read
|
||||
only when its file changed since the last tick; otherwise a tick
|
||||
costs one stat().
|
||||
"""
|
||||
if not self._mailbox.changed(self.cache_manager):
|
||||
return False
|
||||
try:
|
||||
request = self.cache_manager.get(ERROR_CLEAR_REQUEST_KEY, max_age=None, memory_ttl=0)
|
||||
except Exception:
|
||||
self._mailbox.forget()
|
||||
raise
|
||||
"""Honour a clear request we have not applied yet. True if one was."""
|
||||
request = self.cache_manager.get(ERROR_CLEAR_REQUEST_KEY, max_age=None, memory_ttl=0)
|
||||
if not isinstance(request, dict):
|
||||
return False
|
||||
request_id = request.get("request_id")
|
||||
@@ -647,30 +614,14 @@ class ErrorSnapshotPublisher:
|
||||
cutoff = float(request.get("cutoff"))
|
||||
except (TypeError, ValueError):
|
||||
cutoff = float("nan")
|
||||
self._clear(request_id, cutoff)
|
||||
if math.isfinite(cutoff):
|
||||
cleared = self.aggregator.clear_before(datetime.fromtimestamp(cutoff))
|
||||
_snapshot_logger.info("Cleared %d plugin error record(s) as requested (%s)",
|
||||
cleared, request_id)
|
||||
# A malformed request is acknowledged too, so it is not retried forever.
|
||||
self._applied_clear_id = request_id
|
||||
return True
|
||||
|
||||
def clear_now(self, request_id: str, cutoff: float) -> int:
|
||||
"""``errors.clear`` over the control socket: apply a clear at once and
|
||||
republish the snapshot, so the web interface's next read has it.
|
||||
Returns how many records were cleared. Raises when the snapshot
|
||||
could not be written, so the caller is not told it worked."""
|
||||
with self._tick_lock:
|
||||
cleared = self._clear(request_id, float(cutoff))
|
||||
self._publish(self.aggregator.version, self._clock())
|
||||
return cleared
|
||||
|
||||
def _publish(self, version: int, now: float) -> None:
|
||||
"""Write the snapshot. Caller holds _tick_lock."""
|
||||
# Stamp the attempt before writing: a cache that keeps failing
|
||||
# is retried at the throttled rate, not on every tick.
|
||||
self._last_attempt = now
|
||||
snapshot = self.aggregator.build_snapshot()
|
||||
snapshot["applied_clear_id"] = self._applied_clear_id
|
||||
snapshot["applied_clear_cutoff"] = self._applied_clear_cutoff
|
||||
self.cache_manager.set(ERROR_SNAPSHOT_KEY, snapshot)
|
||||
self._published_version = version
|
||||
|
||||
def tick(self) -> bool:
|
||||
"""Apply a pending clear and publish if due. True if a snapshot was written."""
|
||||
with self._tick_lock:
|
||||
@@ -684,7 +635,13 @@ class ErrorSnapshotPublisher:
|
||||
if (self._last_attempt is not None
|
||||
and now - self._last_attempt < self.min_interval):
|
||||
return False
|
||||
self._publish(version, now)
|
||||
# Stamp the attempt before writing: a cache that keeps failing
|
||||
# is retried at the throttled rate, not on every tick.
|
||||
self._last_attempt = now
|
||||
snapshot = self.aggregator.build_snapshot()
|
||||
snapshot["applied_clear_id"] = self._applied_clear_id
|
||||
self.cache_manager.set(ERROR_SNAPSHOT_KEY, snapshot)
|
||||
self._published_version = version
|
||||
return True
|
||||
except Exception as err: # never let reporting break the display
|
||||
_snapshot_logger.debug("Could not publish the plugin error snapshot: %s",
|
||||
@@ -736,22 +693,6 @@ def start_error_snapshot_publisher(cache_manager: Any) -> Optional[ErrorSnapshot
|
||||
return None
|
||||
|
||||
|
||||
def apply_error_clear(request_id: str, args: Any) -> Dict[str, Any]:
|
||||
"""The display's handler for ``errors.clear`` on the control socket.
|
||||
|
||||
``args`` is the contract's ErrorsClearArgs (``cutoff``, epoch seconds).
|
||||
Runs on the socket's connection thread: the aggregator and the publisher
|
||||
have their own locks, and nothing here touches rendering. Returns
|
||||
ErrorsClearResult once the clear is applied and the snapshot rewritten.
|
||||
"""
|
||||
publisher = _snapshot_publisher
|
||||
if publisher is None:
|
||||
raise RuntimeError("the error snapshot publisher is not running")
|
||||
cutoff = float(args.cutoff)
|
||||
cleared = publisher.clear_now(request_id, cutoff)
|
||||
return {"request_id": request_id, "cutoff": cutoff, "cleared": cleared}
|
||||
|
||||
|
||||
# --- Reading side (web interface) -------------------------------------------
|
||||
|
||||
def read_error_report(cache_manager: Any) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
||||
@@ -790,15 +731,7 @@ def _pending_cutoff(snapshot: Optional[Dict[str, Any]],
|
||||
cutoff = float(clear_request.get("cutoff"))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if not math.isfinite(cutoff):
|
||||
return None
|
||||
# A wider clear has been applied since (over the control socket): this
|
||||
# older request has nothing left to hide.
|
||||
applied = snapshot.get("applied_clear_cutoff") if snapshot is not None else None
|
||||
if (isinstance(applied, (int, float)) and not isinstance(applied, bool)
|
||||
and applied >= cutoff):
|
||||
return None
|
||||
return cutoff
|
||||
return cutoff if math.isfinite(cutoff) else None
|
||||
|
||||
|
||||
def _is_after(item: Any, field_name: str, cutoff: float) -> bool:
|
||||
@@ -898,31 +831,13 @@ def _count_cleared(summary: Dict[str, Any], cutoff: float) -> Optional[int]:
|
||||
return None
|
||||
|
||||
|
||||
#: ``send(request_id, cutoff)`` hands a clear to the display over the control
|
||||
#: socket and returns its ErrorsClearResult, or None when the socket could
|
||||
#: not carry it and the mailbox should be written instead. Any exception it
|
||||
#: raises reaches the caller: the display had the request and failed it.
|
||||
ClearSender = Callable[[str, float], Optional[Dict[str, Any]]]
|
||||
|
||||
|
||||
def request_error_clear(cache_manager: Any, cutoff: float,
|
||||
send: Optional[ClearSender] = None) -> Dict[str, Any]:
|
||||
def request_error_clear(cache_manager: Any, cutoff: float) -> Dict[str, Any]:
|
||||
"""Ask the display service to forget errors recorded at or before ``cutoff``.
|
||||
|
||||
Over the control socket when ``send`` is given and carries it: the
|
||||
display applies the clear and republishes its snapshot before it
|
||||
answers, so nothing is written here. Otherwise (no socket, or a display
|
||||
older than ``errors.clear``) a request is written to the
|
||||
``plugin_error_clear_request`` mailbox, which the display applies on
|
||||
its next tick, and readers hide the cleared errors until then.
|
||||
|
||||
Returns ``request_id``, ``cutoff`` (ISO, local time), ``cleared_count``,
|
||||
``clear_requested``, ``applied`` (the display has already cleared them)
|
||||
and ``transport`` (``socket`` or ``mailbox``). ``cleared_count`` is the
|
||||
display's own count over the socket, else an estimate from the snapshot
|
||||
(see _count_cleared). Raises OSError when a mailbox request did not reach
|
||||
the shared cache, since a cache without a usable directory accepts set()
|
||||
and keeps nothing.
|
||||
Returns ``request_id``, ``cutoff`` (ISO, local time), ``cleared_count``
|
||||
(see _count_cleared) and ``clear_requested``. Raises OSError when the
|
||||
request did not reach the shared cache, since a cache without a usable
|
||||
directory accepts set() and keeps nothing.
|
||||
|
||||
A request the display has not applied yet is only ever widened: a later,
|
||||
narrower one ("older than 24 hours" after "everything") overwriting it
|
||||
@@ -933,21 +848,8 @@ def request_error_clear(cache_manager: Any, cutoff: float,
|
||||
if pending is not None:
|
||||
cutoff = max(cutoff, pending)
|
||||
before = error_summary_from_report(snapshot, clear_request)
|
||||
request_id = uuid.uuid4().hex
|
||||
answer = {
|
||||
"clear_requested": True,
|
||||
"request_id": request_id,
|
||||
"cutoff": datetime.fromtimestamp(cutoff).isoformat(),
|
||||
}
|
||||
if send is not None:
|
||||
result = send(request_id, cutoff)
|
||||
if result is not None:
|
||||
count = result.get("cleared")
|
||||
return dict(answer, applied=True, transport="socket",
|
||||
cleared_count=count if isinstance(count, int) and not isinstance(count, bool)
|
||||
else _count_cleared(before, cutoff))
|
||||
request = {
|
||||
"request_id": request_id,
|
||||
"request_id": uuid.uuid4().hex,
|
||||
"cutoff": cutoff,
|
||||
"requested_at": time.time(),
|
||||
}
|
||||
@@ -955,5 +857,9 @@ def request_error_clear(cache_manager: Any, cutoff: float,
|
||||
stored = cache_manager.get(ERROR_CLEAR_REQUEST_KEY, max_age=None, memory_ttl=0)
|
||||
if not isinstance(stored, dict) or stored.get("request_id") != request["request_id"]:
|
||||
raise OSError("the clear request was not stored in the shared cache")
|
||||
return dict(answer, applied=False, transport="mailbox",
|
||||
cleared_count=_count_cleared(before, cutoff))
|
||||
return {
|
||||
"cleared_count": _count_cleared(before, cutoff),
|
||||
"clear_requested": True,
|
||||
"request_id": request["request_id"],
|
||||
"cutoff": datetime.fromtimestamp(cutoff).isoformat(),
|
||||
}
|
||||
|
||||
@@ -222,39 +222,6 @@ class FontManager:
|
||||
logger.error(f"Error registering fonts for plugin {plugin_id}: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
def forget_plugin_fonts(self, plugin_id: str) -> bool:
|
||||
"""Drop the fonts ``plugin_id``'s manifest registered: its manifest
|
||||
and catalog, its ``plugin_id::family`` entries in font_catalog, and
|
||||
cached font objects for those families. Called by core when a plugin
|
||||
is unloaded, so a reload registers from its current manifest and a
|
||||
removed plugin's fonts stop resolving.
|
||||
|
||||
FontManager takes no locks; like forget_manager_fonts this relies on
|
||||
single dict operations being atomic and iterates snapshots, so a
|
||||
render thread calling get_font() meanwhile cannot break it. Returns
|
||||
True if the plugin had registered fonts.
|
||||
"""
|
||||
prefix = f"{plugin_id}::"
|
||||
manifest = self.plugin_fonts.pop(plugin_id, None)
|
||||
catalog = self.plugin_font_catalogs.pop(plugin_id, None)
|
||||
# Every namespaced entry, not just the families in the catalog: one
|
||||
# whose file failed to load never made it into the catalog, and a
|
||||
# caller may have added one directly.
|
||||
for family in list(self.font_catalog):
|
||||
if family.startswith(prefix):
|
||||
self.font_catalog.pop(family, None)
|
||||
# get_font() keys the cache f"{family}_{size_px}".
|
||||
dropped = [key for key in list(self.font_cache) if key.startswith(prefix)]
|
||||
for key in dropped:
|
||||
self.font_cache.pop(key, None)
|
||||
if dropped:
|
||||
# Font objects someone may hold were dropped; see cache_generation.
|
||||
self.cache_generation += 1
|
||||
if manifest is None and catalog is None:
|
||||
return False
|
||||
logger.info("Forgot fonts of plugin %s", plugin_id)
|
||||
return True
|
||||
|
||||
def _validate_font_manifest(self, font_manifest: Dict[str, Any]) -> bool:
|
||||
"""Validate the structure of a plugin's font manifest."""
|
||||
required_fields = ["fonts"]
|
||||
|
||||
+10
-71
@@ -3,13 +3,8 @@
|
||||
Every failure -- no socket (the display is stopped, or predates the socket),
|
||||
a refused or timed-out connection, a reply that breaks the contract, or an
|
||||
error the display returned -- raises :class:`ControlError` with a short
|
||||
``reason``. Nothing here blocks for longer than ``timeout`` in total.
|
||||
|
||||
Whether the caller may then write the file mailbox instead is
|
||||
:func:`should_fall_back`: only when the display never took the request (it
|
||||
could not be reached, or it is too old to know the command). A display that
|
||||
took the request and then failed, refused or went quiet is answered as
|
||||
that, not posted a second time through the mailbox.
|
||||
``reason``, and the caller falls back to the file mailbox. Nothing here
|
||||
blocks for longer than ``timeout`` in total.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -27,7 +22,6 @@ from src.ipc.contract import (
|
||||
SUBSCRIBE_KEEPALIVE_SECONDS,
|
||||
SUPPORTED_VERSIONS,
|
||||
Command,
|
||||
ErrorCode,
|
||||
FrameReader,
|
||||
ProtocolError,
|
||||
Request,
|
||||
@@ -55,49 +49,17 @@ class ControlError(Exception):
|
||||
``refused``, ``timeout``, ``closed``, ``bad_response``, ``invalid_request``.
|
||||
When the display answered with an error, ``reason`` is that error's
|
||||
:class:`~src.ipc.contract.ErrorCode` (``busy``, ``unknown_command``, ...).
|
||||
|
||||
``sent`` is True once the whole request was written to a connected
|
||||
display, which may then have acted on it. A refusal the display sends
|
||||
before it reads anything (``forbidden``, too many connections) carries
|
||||
no request id and leaves ``sent`` False.
|
||||
"""
|
||||
|
||||
def __init__(self, reason: str, message: str = '', *, sent: bool = False):
|
||||
def __init__(self, reason: str, message: str = ''):
|
||||
super().__init__(reason, message)
|
||||
self.reason = reason
|
||||
self.message = message
|
||||
self.sent = sent
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f'{self.reason}: {self.message}' if self.message else self.reason
|
||||
|
||||
|
||||
#: Answers from a display that read the request but does not speak it: one
|
||||
#: older than the command (an upgrade in progress) or the protocol version.
|
||||
#: It did nothing, so the mailbox is the way to reach it.
|
||||
UPGRADE_REASONS = frozenset({ErrorCode.UNKNOWN_COMMAND, ErrorCode.UNSUPPORTED_VERSION})
|
||||
|
||||
|
||||
def should_fall_back(error: BaseException) -> bool:
|
||||
"""May the caller write the file mailbox after ``error``?
|
||||
|
||||
Yes when the display never took the request: there is no socket (the
|
||||
display is stopped, predates the socket, or it is switched off), the
|
||||
connection was refused or timed out, the display turned the connection
|
||||
away before reading it, or it is too old to know the command
|
||||
(:data:`UPGRADE_REASONS`). Also for an error that is not a
|
||||
:class:`ControlError` (a bug in the client), as before.
|
||||
|
||||
No once the display had the request: a ``busy`` queue, ``invalid_args``,
|
||||
an ``internal`` error, or a timeout or hang-up after the request was
|
||||
sent. The display may have applied it, or would refuse it from the
|
||||
mailbox too, so a second copy there only hides the failure.
|
||||
"""
|
||||
if not isinstance(error, ControlError):
|
||||
return True
|
||||
return not error.sent or error.reason in UPGRADE_REASONS
|
||||
|
||||
|
||||
def request(cmd: str, args: Optional[Mapping[str, Any]] = None, *,
|
||||
request_id: Optional[str] = None,
|
||||
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
||||
@@ -131,14 +93,11 @@ def request(cmd: str, args: Optional[Mapping[str, Any]] = None, *,
|
||||
# A refusal before the request was read (forbidden, too many
|
||||
# connections) carries no id.
|
||||
if response.id != request_id and not (response.id is None and not response.ok):
|
||||
raise ControlError('bad_response', 'the reply is for a different request', sent=True)
|
||||
raise ControlError('bad_response', 'the reply is for a different request')
|
||||
if not response.ok:
|
||||
error = response.error
|
||||
# No id: refused at the door (forbidden, too many connections),
|
||||
# before the display read the request.
|
||||
raise ControlError(error.code if error else 'bad_response',
|
||||
error.message if error else '',
|
||||
sent=response.id is not None)
|
||||
error.message if error else '')
|
||||
return dict(response.result or {})
|
||||
|
||||
|
||||
@@ -183,31 +142,26 @@ def _connect(paths: Sequence[str], deadline: float) -> socket.socket:
|
||||
|
||||
|
||||
def _exchange(sock: socket.socket, payload: bytes, deadline: float) -> Response:
|
||||
"""Send ``payload`` and read the reply. A failure once the whole request
|
||||
is written raises with ``sent=True``: the display may have it."""
|
||||
sent = False
|
||||
try:
|
||||
sock.settimeout(_remaining(deadline))
|
||||
sock.sendall(payload)
|
||||
sent = True
|
||||
reader = FrameReader(MAX_MESSAGE_BYTES)
|
||||
while True:
|
||||
sock.settimeout(_remaining(deadline))
|
||||
data = sock.recv(4096)
|
||||
if not data:
|
||||
raise ControlError('closed', 'the display closed the connection', sent=sent)
|
||||
raise ControlError('closed', 'the display closed the connection')
|
||||
lines = reader.feed(data)
|
||||
if lines:
|
||||
return Response.from_dict(decode_message(lines[0]))
|
||||
except socket.timeout:
|
||||
raise ControlError('timeout', 'no reply in time', sent=sent) from None
|
||||
raise ControlError('timeout', 'no reply in time') from None
|
||||
except ProtocolError as e:
|
||||
raise ControlError('bad_response', e.message, sent=sent) from None
|
||||
except ControlError as e:
|
||||
e.sent = e.sent or sent
|
||||
raise ControlError('bad_response', e.message) from None
|
||||
except ControlError:
|
||||
raise
|
||||
except OSError as e:
|
||||
raise ControlError('closed', str(e), sent=sent) from None
|
||||
raise ControlError('closed', str(e)) from None
|
||||
|
||||
|
||||
# -- commands ---------------------------------------------------------------------------
|
||||
@@ -273,21 +227,6 @@ def plugin_reload(plugin_id: str, *, timeout: Optional[float] = None,
|
||||
else timeout, paths=paths)
|
||||
|
||||
|
||||
def errors_clear(request_id: str, cutoff: float, *,
|
||||
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
||||
paths: Optional[Sequence[str]] = None) -> Dict[str, Any]:
|
||||
"""Have the display forget the plugin errors recorded at or before
|
||||
``cutoff`` (epoch seconds) and publish its error snapshot again.
|
||||
|
||||
Returns :class:`~src.ipc.contract.ErrorsClearResult` once it is done.
|
||||
Raises :class:`ControlError`: ``unknown_command`` from a display older
|
||||
than the command, which still reads the ``plugin_error_clear_request``
|
||||
mailbox.
|
||||
"""
|
||||
return request(Command.ERRORS_CLEAR, {'cutoff': cutoff}, request_id=request_id,
|
||||
timeout=timeout, paths=paths)
|
||||
|
||||
|
||||
def ping(*, timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
||||
paths: Optional[Sequence[str]] = None) -> Dict[str, Any]:
|
||||
return request(Command.PING, {}, timeout=timeout, paths=paths)
|
||||
|
||||
+4
-44
@@ -149,13 +149,12 @@ class Command:
|
||||
PLUGIN_RELOAD = 'plugin.reload'
|
||||
STATE_GET = 'state.get'
|
||||
STATE_SUBSCRIBE = 'state.subscribe'
|
||||
ERRORS_CLEAR = 'errors.clear'
|
||||
|
||||
|
||||
#: Every command version 1 defines, in the order ``hello`` reports them.
|
||||
#: ``brightness.set`` and ``plugin.reload`` came in stage 2, ``state.get``
|
||||
#: and ``state.subscribe`` in stage 3, and ``errors.clear`` in stage 4, all
|
||||
#: within version 1 (see the module docstring on adding commands).
|
||||
#: ``brightness.set`` and ``plugin.reload`` came in stage 2, and ``state.get``
|
||||
#: and ``state.subscribe`` in stage 3, all within version 1 (see the module
|
||||
#: docstring on adding commands).
|
||||
COMMANDS: Tuple[str, ...] = (
|
||||
Command.HELLO,
|
||||
Command.PING,
|
||||
@@ -166,15 +165,8 @@ COMMANDS: Tuple[str, ...] = (
|
||||
Command.PLUGIN_RELOAD,
|
||||
Command.STATE_GET,
|
||||
Command.STATE_SUBSCRIBE,
|
||||
Command.ERRORS_CLEAR,
|
||||
)
|
||||
|
||||
#: Commands the connection thread answers itself, through a handler the
|
||||
#: display registers (``ControlServer(handlers=...)``), because they touch
|
||||
#: nothing the render thread owns. A display that registered none answers
|
||||
#: ``unknown_command``, and the client falls back as from an older display.
|
||||
DIRECT_COMMANDS = frozenset({Command.ERRORS_CLEAR})
|
||||
|
||||
#: Commands that are queued for the render thread.
|
||||
QUEUED_COMMANDS = frozenset({Command.ON_DEMAND_START, Command.ON_DEMAND_STOP,
|
||||
Command.BRIGHTNESS_SET, Command.PLUGIN_RELOAD})
|
||||
@@ -573,32 +565,8 @@ class StateSubscribeArgs:
|
||||
return cls()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ErrorsClearArgs:
|
||||
"""``errors.clear``: forget the plugin errors recorded at or before
|
||||
``cutoff`` (seconds since the epoch), as ``POST /api/v3/errors/clear``
|
||||
asks. The request id is the clear's id, which the display's error
|
||||
snapshot then reports as ``applied_clear_id``.
|
||||
"""
|
||||
cutoff: float
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {'cutoff': self.cutoff}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, args: Mapping[str, Any]) -> 'ErrorsClearArgs':
|
||||
value = args.get('cutoff')
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise ProtocolError(ErrorCode.INVALID_ARGS, 'cutoff must be a number of seconds')
|
||||
if not math.isfinite(value) or value < 0:
|
||||
raise ProtocolError(ErrorCode.INVALID_ARGS,
|
||||
'cutoff must be a finite, non-negative number of seconds')
|
||||
return cls(cutoff=float(value))
|
||||
|
||||
|
||||
CommandArgs = Union[HelloArgs, OnDemandStartArgs, OnDemandStopArgs, NoArgs,
|
||||
BrightnessSetArgs, PluginReloadArgs, StateGetArgs, StateSubscribeArgs,
|
||||
ErrorsClearArgs]
|
||||
BrightnessSetArgs, PluginReloadArgs, StateGetArgs, StateSubscribeArgs]
|
||||
|
||||
#: The arguments of a command that goes on the render thread's queue.
|
||||
QueuedArgs = Union[OnDemandStartArgs, OnDemandStopArgs, BrightnessSetArgs, PluginReloadArgs]
|
||||
@@ -613,7 +581,6 @@ _ARG_TYPES: Dict[str, Any] = {
|
||||
Command.PLUGIN_RELOAD: PluginReloadArgs,
|
||||
Command.STATE_GET: StateGetArgs,
|
||||
Command.STATE_SUBSCRIBE: StateSubscribeArgs,
|
||||
Command.ERRORS_CLEAR: ErrorsClearArgs,
|
||||
}
|
||||
|
||||
|
||||
@@ -686,13 +653,6 @@ class PluginReloadResult(TypedDict):
|
||||
modes: List[str]
|
||||
|
||||
|
||||
class ErrorsClearResult(TypedDict):
|
||||
"""``errors.clear``, once applied and the error snapshot republished."""
|
||||
request_id: str
|
||||
cutoff: float
|
||||
cleared: int
|
||||
|
||||
|
||||
class LoopState(TypedDict):
|
||||
"""``loop``: is the render loop still going round?
|
||||
|
||||
|
||||
+6
-57
@@ -6,9 +6,7 @@ rendering: a command that changes the panel is validated, put on a bounded
|
||||
queue and acknowledged, and the render thread drains that queue at the point
|
||||
where it reads the file mailbox (``DisplayController._poll_on_demand_requests``),
|
||||
handing each command to the same code. Queries (``on_demand.status``) are
|
||||
answered from a snapshot callable the display provides, and the few commands
|
||||
that touch nothing the render thread owns (``errors.clear``) by a handler the
|
||||
display registers, on the connection thread.
|
||||
answered from a snapshot callable the display provides.
|
||||
|
||||
The queue also wakes the render thread: :meth:`ControlServer.wait_for_command`
|
||||
is what it waits on in place of a sleep, so a command lands within a frame on
|
||||
@@ -64,7 +62,6 @@ from src.ipc.contract import (
|
||||
AWAITED_COMMANDS,
|
||||
COMMANDS,
|
||||
DEFAULT_SOCKET_DIR,
|
||||
DIRECT_COMMANDS,
|
||||
DEFAULT_SOCKET_PATH,
|
||||
MAX_MESSAGE_BYTES,
|
||||
MAX_SUBSCRIBERS,
|
||||
@@ -110,8 +107,7 @@ MAX_CLIENTS = 8
|
||||
|
||||
#: Commands waiting for the render thread. It drains them at least every
|
||||
#: 0.25 s, so a full queue means the render thread is stuck, and the client
|
||||
#: is told ``busy`` instead of piling up work. The mailbox would not be read
|
||||
#: either, so the web interface reports the failure rather than fall back.
|
||||
#: is told ``busy`` (and falls back to the mailbox) instead of piling up work.
|
||||
QUEUE_SIZE = 16
|
||||
|
||||
#: Timeout for one recv()/send() on a connection.
|
||||
@@ -556,12 +552,6 @@ def server_socket_path(environ: Optional[Mapping[str, str]] = None) -> Optional[
|
||||
|
||||
StatusProvider = Callable[[], Dict[str, Any]]
|
||||
|
||||
#: A handler for one of DIRECT_COMMANDS, ``(request_id, args) -> result``. It
|
||||
#: runs on the connection thread, so it must not touch what the render thread
|
||||
#: owns. It may raise ProtocolError to answer with that error's code; any
|
||||
#: other exception is answered ``internal``.
|
||||
DirectHandler = Callable[[str, Any], Mapping[str, Any]]
|
||||
|
||||
|
||||
class ControlServer:
|
||||
"""Serves the control socket on background threads.
|
||||
@@ -579,12 +569,9 @@ class ControlServer:
|
||||
await_seconds: Optional[Mapping[str, float]] = None,
|
||||
state_hub: Optional[StateHub] = None,
|
||||
max_subscribers: int = MAX_SUBSCRIBERS,
|
||||
keepalive: float = SUBSCRIBE_KEEPALIVE_SECONDS,
|
||||
handlers: Optional[Mapping[str, DirectHandler]] = None):
|
||||
keepalive: float = SUBSCRIBE_KEEPALIVE_SECONDS):
|
||||
self.path = path
|
||||
self.state_hub = state_hub
|
||||
self._handlers: Dict[str, DirectHandler] = {
|
||||
cmd: fn for cmd, fn in (handlers or {}).items() if cmd in DIRECT_COMMANDS}
|
||||
self._subscriber_slots = threading.BoundedSemaphore(max_subscribers)
|
||||
self._keepalive = keepalive
|
||||
self._await_seconds: Dict[str, float] = dict(AWAIT_SECONDS)
|
||||
@@ -772,22 +759,8 @@ class ControlServer:
|
||||
"""
|
||||
return self._pending.wait(timeout)
|
||||
|
||||
def wake(self) -> None:
|
||||
"""Wake the render thread as a queued command would, with nothing queued.
|
||||
|
||||
For work that reaches the display another way in the same process (a
|
||||
plugin's on-demand request, ``DisplayController.submit_plugin_on_demand``):
|
||||
the render thread returns from :meth:`wait_for_command` and drains,
|
||||
and reads the caller's own queue there. Safe from any thread.
|
||||
"""
|
||||
self._pending.set()
|
||||
|
||||
def drain(self) -> List[QueuedCommand]:
|
||||
"""Every queued command, oldest first. Called from the render thread.
|
||||
|
||||
Clears the wake flag first, so anything queued (or woken for) while
|
||||
this runs wakes the next wait again.
|
||||
"""
|
||||
"""Every queued command, oldest first. Called from the render thread."""
|
||||
commands: List[QueuedCommand] = []
|
||||
self._pending.clear()
|
||||
while True:
|
||||
@@ -1055,9 +1028,6 @@ class ControlServer:
|
||||
snap = hub.snapshot()
|
||||
return Response.success(request.id, fit_snapshot(snap), v=request.v)
|
||||
|
||||
if request.cmd in DIRECT_COMMANDS:
|
||||
return self._direct(request, args)
|
||||
|
||||
if request.cmd in QUEUED_COMMANDS and isinstance(args, (
|
||||
OnDemandStartArgs, OnDemandStopArgs, BrightnessSetArgs, PluginReloadArgs)):
|
||||
awaited = request.cmd in AWAITED_COMMANDS
|
||||
@@ -1085,25 +1055,6 @@ class ControlServer:
|
||||
return Response.failure(request.id, ErrorCode.INTERNAL,
|
||||
f'{request.cmd} is not implemented', v=request.v)
|
||||
|
||||
def _direct(self, request: Request, args: Any) -> Response:
|
||||
"""A command the display answers on this thread (DIRECT_COMMANDS)."""
|
||||
handler = self._handlers.get(request.cmd)
|
||||
if handler is None:
|
||||
# Answered as an older display would, so the client falls back.
|
||||
return Response.failure(request.id, ErrorCode.UNKNOWN_COMMAND,
|
||||
f'{request.cmd} is not served by this display',
|
||||
v=request.v)
|
||||
try:
|
||||
result = handler(request.id, args)
|
||||
except ProtocolError as e:
|
||||
return Response.failure(request.id, e.code, e.message, v=request.v)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
logger.exception("Control socket: %s %s failed", request.cmd, request.id)
|
||||
return Response.failure(request.id, ErrorCode.INTERNAL,
|
||||
'the display failed to apply it', v=request.v)
|
||||
logger.info("Control socket applied %s %s", request.cmd, request.id)
|
||||
return Response.success(request.id, dict(result), v=request.v)
|
||||
|
||||
def _await_outcome(self, request: Request, outcome: CommandOutcome) -> Response:
|
||||
"""Answer an awaited command once the render thread has applied it.
|
||||
|
||||
@@ -1128,9 +1079,7 @@ class ControlServer:
|
||||
def start_control_server(status_provider: Optional[StatusProvider] = None,
|
||||
cache_dir: Optional[str] = None,
|
||||
environ: Optional[Mapping[str, str]] = None,
|
||||
state_hub: Optional[StateHub] = None,
|
||||
handlers: Optional[Mapping[str, DirectHandler]] = None,
|
||||
) -> Optional[ControlServer]:
|
||||
state_hub: Optional[StateHub] = None) -> Optional[ControlServer]:
|
||||
"""Start the display's control socket, or return None when it can't run.
|
||||
|
||||
None covers Windows, ``LEDMATRIX_CONTROL_SOCKET=off`` and any failure to
|
||||
@@ -1142,7 +1091,7 @@ def start_control_server(status_provider: Optional[StatusProvider] = None,
|
||||
logger.debug("Control socket disabled or unsupported here; using the file mailbox only")
|
||||
return None
|
||||
server = ControlServer(path, status_provider, resolve_socket_group(cache_dir),
|
||||
state_hub=state_hub, handlers=handlers)
|
||||
state_hub=state_hub)
|
||||
return server if server.start() else None
|
||||
|
||||
|
||||
|
||||
@@ -1070,65 +1070,6 @@ class BasePlugin(ABC):
|
||||
if callable(notify):
|
||||
notify(self.plugin_id)
|
||||
|
||||
def request_on_demand(self, mode: Optional[str] = None,
|
||||
duration: Optional[float] = None,
|
||||
pinned: bool = False) -> Optional[str]:
|
||||
"""
|
||||
Take the screen now: show this plugin on demand. Safe from any thread.
|
||||
|
||||
For a plugin that reacts to something outside the rotation -- an MQTT
|
||||
message, a timer, a detection -- and wants the panel for it. The
|
||||
request goes straight to the display in this process and is applied
|
||||
on its render thread within a frame or so, exactly like an on-demand
|
||||
start from the web interface.
|
||||
|
||||
Args:
|
||||
mode: One of this plugin's display modes; None for its first.
|
||||
duration: Seconds to show it before the rotation resumes; None
|
||||
(or zero) for no limit, until end_on_demand() or the user
|
||||
stops it.
|
||||
pinned: Stay on ``mode`` instead of cycling through the
|
||||
plugin's other modes.
|
||||
|
||||
Returns:
|
||||
The request id once the display has queued it, or None when
|
||||
there is no display in this process to ask (the web interface,
|
||||
scripts/check_plugin.py) or its queue is full. A plugin that
|
||||
also runs on cores without this method writes the
|
||||
``display_on_demand_request`` mailbox on None, as before; see
|
||||
"On-demand display" in docs/PLUGIN_API_REFERENCE.md.
|
||||
|
||||
Example::
|
||||
|
||||
if not (hasattr(self, 'request_on_demand')
|
||||
and self.request_on_demand(mode='my_alert', duration=15)):
|
||||
self._write_on_demand_mailbox(...) # older cores
|
||||
"""
|
||||
request = getattr(getattr(self, 'plugin_manager', None), 'request_on_demand', None)
|
||||
if not callable(request):
|
||||
return None
|
||||
request_id = request(self.plugin_id, mode=mode, duration=duration, pinned=pinned)
|
||||
# Only a real id counts: a test's MagicMock manager answers a mock,
|
||||
# which must read as "not taken" so the plugin's fallback runs.
|
||||
return request_id if isinstance(request_id, str) else None
|
||||
|
||||
def end_on_demand(self) -> Optional[str]:
|
||||
"""
|
||||
Give the screen back: end this plugin's on-demand session. Any thread.
|
||||
|
||||
Ends only a session this plugin owns. One the user started for
|
||||
another plugin, or a session that already ended, is left alone. The
|
||||
rotation resumes where it left off.
|
||||
|
||||
Returns:
|
||||
The request id once queued, or None as request_on_demand() does.
|
||||
"""
|
||||
end = getattr(getattr(self, 'plugin_manager', None), 'end_on_demand', None)
|
||||
if not callable(end):
|
||||
return None
|
||||
request_id = end(self.plugin_id)
|
||||
return request_id if isinstance(request_id, str) else None
|
||||
|
||||
def get_vegas_participation(self) -> str:
|
||||
"""
|
||||
How this plugin takes part in Vegas mode: ``'scroll'``, ``'pause'`` or
|
||||
|
||||
@@ -48,19 +48,12 @@ class PluginOperation:
|
||||
completed_at: Optional[datetime] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert operation to dictionary for serialization.
|
||||
|
||||
Parameters whose name starts with ``_`` are internal and left out:
|
||||
PluginOperationQueue keeps the operation's callback there as
|
||||
``_callback`` until its worker runs it, and a pending operation's
|
||||
status answered 500 because that function cannot be serialized.
|
||||
"""
|
||||
"""Convert operation to dictionary for serialization."""
|
||||
return {
|
||||
'operation_id': self.operation_id,
|
||||
'operation_type': self.operation_type.value,
|
||||
'plugin_id': self.plugin_id,
|
||||
'parameters': {key: value for key, value in self.parameters.items()
|
||||
if not str(key).startswith('_')},
|
||||
'parameters': self.parameters,
|
||||
'status': self.status.value,
|
||||
'progress': self.progress,
|
||||
'message': self.message,
|
||||
|
||||
@@ -15,8 +15,7 @@ reads through a catalog unchanged. It has nothing that runs a plugin: no
|
||||
``load_plugin``, ``get_plugin`` or ``plugins``.
|
||||
|
||||
Runtime state -- whether the display has a plugin loaded, its health, its
|
||||
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
|
||||
errors -- is not here either. The display process publishes what it knows to
|
||||
the shared cache (health and resource metrics, the current mode, the error
|
||||
aggregator snapshot), and the web routes read those publications. What the
|
||||
display does not publish (which plugins it has loaded, its plugin state
|
||||
@@ -27,9 +26,8 @@ See docs/ARCHITECTURE.md ("Web and display processes").
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional, Union, cast
|
||||
from typing import Any, Dict, List, Optional, Union, cast
|
||||
|
||||
from src.common.permission_utils import (
|
||||
ensure_directory_permissions, get_plugin_dir_mode,
|
||||
@@ -41,10 +39,6 @@ from src.plugin_system.plugin_dirs import (
|
||||
|
||||
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:
|
||||
"""Manifests, schemas, config and versions of the installed plugins.
|
||||
@@ -55,17 +49,10 @@ class PluginCatalog:
|
||||
"""
|
||||
|
||||
def __init__(self, plugins_dir: PathLike, config_manager: Optional[Any] = None,
|
||||
schema_manager: Optional[Any] = None,
|
||||
runtime_source: Optional[Callable[[], Any]] = None) -> None:
|
||||
schema_manager: Optional[Any] = None) -> None:
|
||||
self.plugins_dir: Path = Path(plugins_dir)
|
||||
self.config_manager = config_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__)
|
||||
|
||||
# Guards plugin_manifests/plugin_directories: request threads read
|
||||
@@ -185,64 +172,22 @@ class PluginCatalog:
|
||||
by_manifest=False)
|
||||
return str(plugin_dir) if plugin_dir is not None else None
|
||||
|
||||
def _runtime_view(self) -> Any:
|
||||
"""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
|
||||
|
||||
def _live_display_modes(self, plugin_id: str) -> Optional[List[str]]:
|
||||
"""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 [].
|
||||
"""The manifest's ``display_modes``, or [].
|
||||
|
||||
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.
|
||||
What the display actually rotates can differ: a plugin may compute
|
||||
its modes at run time (``plugin.modes``). This is the declared list.
|
||||
"""
|
||||
live = self._live_display_modes(plugin_id)
|
||||
if live is not None:
|
||||
return live
|
||||
with self._lock:
|
||||
manifest = self.plugin_manifests.get(plugin_id)
|
||||
modes = (manifest or {}).get('display_modes', [])
|
||||
return list(modes) if isinstance(modes, list) else []
|
||||
|
||||
def find_plugin_for_mode(self, mode: str) -> Optional[str]:
|
||||
"""The plugin that registered ``mode`` on the running display, else
|
||||
the one whose manifest declares it (case-insensitive both ways)."""
|
||||
"""The plugin whose manifest declares ``mode`` (case-insensitive)."""
|
||||
wanted = mode.strip().lower()
|
||||
with self._lock:
|
||||
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():
|
||||
modes = manifest.get('display_modes')
|
||||
if isinstance(modes, list) and any(
|
||||
|
||||
@@ -15,7 +15,6 @@ import sys
|
||||
import time
|
||||
import threading
|
||||
import types
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Callable, Dict, List, NamedTuple, Optional, Any, Tuple, Union
|
||||
import logging
|
||||
@@ -215,9 +214,6 @@ class PluginManager:
|
||||
# add_update_listener(). A tuple, replaced rather than mutated, so the
|
||||
# worker can iterate it without a lock.
|
||||
self._update_listeners: Tuple[Callable[[str], None], ...] = ()
|
||||
# Where plugins' on-demand requests go: the display controller's
|
||||
# submit_plugin_on_demand. See set_on_demand_handler().
|
||||
self._on_demand_handler: Optional[Callable[[Dict[str, Any]], bool]] = None
|
||||
# Config changes that found the plugin's lock busy, latest per plugin,
|
||||
# with the instance they were meant for. See apply_config_change().
|
||||
self._deferred_config_changes: Dict[str, Tuple[Any, Dict[str, Any]]] = {}
|
||||
@@ -601,21 +597,11 @@ class PluginManager:
|
||||
self.plugin_loader.unregister_plugin_modules(plugin_id)
|
||||
except Exception as e: # pragma: no cover - defensive
|
||||
self.logger.debug("Could not drop modules of %s: %s", plugin_id, e)
|
||||
self._forget_plugin_fonts(plugin_id)
|
||||
|
||||
def _forget_plugin_fonts(self, plugin_id: str) -> None:
|
||||
"""Drop what the FontManager holds for a plugin: the fonts its
|
||||
instance reported using (the Fonts tab's "Used by") and the fonts its
|
||||
manifest registered. Never raises."""
|
||||
if self.font_manager is None:
|
||||
return
|
||||
for name in ('forget_manager_fonts', 'forget_plugin_fonts'):
|
||||
if not hasattr(self.font_manager, name):
|
||||
continue
|
||||
try:
|
||||
getattr(self.font_manager, name)(plugin_id)
|
||||
except Exception as e:
|
||||
self.logger.debug("Could not forget fonts of %s (%s): %s", plugin_id, name, e)
|
||||
try:
|
||||
if self.font_manager is not None and hasattr(self.font_manager, 'forget_manager_fonts'):
|
||||
self.font_manager.forget_manager_fonts(plugin_id)
|
||||
except Exception as e:
|
||||
self.logger.debug("Could not forget fonts of %s: %s", plugin_id, e)
|
||||
|
||||
#: Config keys the **core** reads out of a plugin's own config block. The
|
||||
#: plugin never declares them, so a schema with
|
||||
@@ -860,9 +846,12 @@ class PluginManager:
|
||||
# Delegate sub-module and cached-module cleanup to the loader
|
||||
self.plugin_loader.unregister_plugin_modules(plugin_id)
|
||||
|
||||
# Its font registrations go with it: the fonts it reported using
|
||||
# and the ones its manifest registered.
|
||||
self._forget_plugin_fonts(plugin_id)
|
||||
# Its font registrations go with it (the Fonts tab's "Used by").
|
||||
try:
|
||||
if self.font_manager is not None and hasattr(self.font_manager, 'forget_manager_fonts'):
|
||||
self.font_manager.forget_manager_fonts(plugin_id)
|
||||
except Exception as e:
|
||||
self.logger.debug("Could not forget fonts of %s: %s", plugin_id, e)
|
||||
|
||||
# Update state
|
||||
self.state_manager.set_state(plugin_id, PluginState.UNLOADED)
|
||||
@@ -1855,73 +1844,3 @@ class PluginManager:
|
||||
done = sorted(self._completed_updates)
|
||||
self._completed_updates.clear()
|
||||
return done
|
||||
|
||||
# -- on-demand requests from plugins -------------------------------------
|
||||
|
||||
def set_on_demand_handler(
|
||||
self, handler: Optional[Callable[[Dict[str, Any]], bool]]) -> None:
|
||||
"""Route plugins' on-demand requests to ``handler`` (None: nowhere).
|
||||
|
||||
The display controller sets its ``submit_plugin_on_demand`` here
|
||||
before any plugin loads. The handler takes a mailbox-shaped request
|
||||
from any thread, queues it for the render thread and returns True,
|
||||
or False when it could not. A plugin manager with no handler (the
|
||||
web interface's, a test's, scripts/check_plugin.py's) has no screen
|
||||
to give, so request_on_demand() there answers None.
|
||||
"""
|
||||
self._on_demand_handler = handler
|
||||
|
||||
def request_on_demand(self, plugin_id: str, mode: Optional[str] = None,
|
||||
duration: Optional[float] = None,
|
||||
pinned: bool = False) -> Optional[str]:
|
||||
"""Ask the display to show ``plugin_id`` now. Safe from any thread.
|
||||
|
||||
BasePlugin.request_on_demand() lands here; see it for the arguments.
|
||||
Returns the request id once the display has queued the request (it
|
||||
is applied on the render thread within a frame or so), or None when
|
||||
this process has no display to ask or its queue is full.
|
||||
"""
|
||||
if not isinstance(plugin_id, str) or not plugin_id:
|
||||
raise ValueError('plugin_id is required')
|
||||
if mode is not None and (not isinstance(mode, str) or not mode):
|
||||
raise ValueError('mode must be a non-empty string or None')
|
||||
if duration is not None:
|
||||
if isinstance(duration, bool) or not isinstance(duration, (int, float)):
|
||||
raise ValueError('duration must be a number of seconds or None')
|
||||
if not math.isfinite(duration) or duration <= 0:
|
||||
duration = None # the display reads these as "no limit" too
|
||||
else:
|
||||
duration = float(duration)
|
||||
return self._submit_on_demand({
|
||||
'action': 'start', 'plugin_id': plugin_id, 'mode': mode,
|
||||
'duration': duration, 'pinned': bool(pinned)})
|
||||
|
||||
def end_on_demand(self, plugin_id: str) -> Optional[str]:
|
||||
"""Give the screen back, if ``plugin_id``'s on-demand session has it.
|
||||
|
||||
BasePlugin.end_on_demand() lands here. A session the plugin does not
|
||||
own (the user started another plugin from the web interface, say) is
|
||||
left alone. Returns the request id once queued, or None as
|
||||
request_on_demand() does.
|
||||
"""
|
||||
if not isinstance(plugin_id, str) or not plugin_id:
|
||||
raise ValueError('plugin_id is required')
|
||||
return self._submit_on_demand({'action': 'stop', 'plugin_id': plugin_id})
|
||||
|
||||
def _submit_on_demand(self, request: Dict[str, Any]) -> Optional[str]:
|
||||
# __dict__.get: tests build bare managers with PluginManager.__new__.
|
||||
handler = self.__dict__.get('_on_demand_handler')
|
||||
if handler is None:
|
||||
return None
|
||||
request_id = str(uuid.uuid4())
|
||||
request.update({'request_id': request_id, 'timestamp': time.time(),
|
||||
'source': 'plugin'})
|
||||
try:
|
||||
accepted = handler(request)
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
self._warn_rate_limited(
|
||||
"on-demand-handler",
|
||||
"The on-demand request from plugin %s failed: %r",
|
||||
request.get('plugin_id'), exc)
|
||||
return None
|
||||
return request_id if accepted else None
|
||||
|
||||
@@ -56,7 +56,7 @@ import os
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field, replace
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
|
||||
from src import display_watchdog
|
||||
from src.logging_config import get_logger
|
||||
@@ -100,9 +100,6 @@ _ERROR_MESSAGE_CHARS = 200
|
||||
_ERROR_TYPE_CHARS = 80
|
||||
_ID_CHARS = 100
|
||||
_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.
|
||||
LIVE = "live"
|
||||
@@ -157,13 +154,6 @@ 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
|
||||
return [_clip(m, _ID_CHARS) for m in modes[:_MAX_MODES] if isinstance(m, str)]
|
||||
|
||||
|
||||
def build_runtime_snapshot(state_manager: Any, *, started_at: float,
|
||||
now: Optional[float] = None,
|
||||
running: bool = True,
|
||||
@@ -183,7 +173,6 @@ def build_runtime_snapshot(state_manager: Any, *, started_at: float,
|
||||
"error": summarize_error(record.get("error_info")),
|
||||
"version": _clip(version, _VERSION_CHARS) if version else None,
|
||||
"loaded_at": _epoch(record.get("loaded_at")),
|
||||
"modes": _published_modes(record.get("modes")),
|
||||
}
|
||||
return {
|
||||
"schema": SNAPSHOT_SCHEMA,
|
||||
@@ -427,21 +416,6 @@ class PluginRuntimeView:
|
||||
"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]:
|
||||
"""The view's own status, for a response to carry beside the facts."""
|
||||
return {
|
||||
|
||||
@@ -10,7 +10,7 @@ snapshot ``plugin_runtime.PluginRuntimePublisher`` publishes from it.
|
||||
import threading
|
||||
import time
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
@@ -231,26 +231,6 @@ class PluginStateManager:
|
||||
}
|
||||
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:
|
||||
"""Forget the loaded record alone, keeping state and error info: for
|
||||
an unload that failed after the instance was already dropped."""
|
||||
@@ -263,8 +243,7 @@ class PluginStateManager:
|
||||
section so a concurrent load or unload is seen whole or not at all.
|
||||
|
||||
Per plugin: ``state`` (published_state()'s value), ``loaded``,
|
||||
``version``, ``loaded_at`` and ``modes`` (None unless loaded; ``modes``
|
||||
also None until the display registers it) and ``error_info``
|
||||
``version`` and ``loaded_at`` (None unless loaded) and ``error_info``
|
||||
(a copy, or None).
|
||||
"""
|
||||
with self._lock:
|
||||
@@ -278,7 +257,6 @@ class PluginStateManager:
|
||||
'loaded': loaded is not None,
|
||||
'version': loaded['version'] 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,
|
||||
}
|
||||
return records
|
||||
|
||||
@@ -138,11 +138,6 @@ class PluginStoreManager(_RegistryMixin, _InstallMixin, _UpdateMixin):
|
||||
# the registry cache expires. Only one thread fetches; others wait and
|
||||
# then get the result from the warm cache (double-checked locking).
|
||||
self._registry_fetch_lock = threading.Lock()
|
||||
# refresh_registry_in_background: the one refresh thread, and when
|
||||
# an offline one may be retried (see that method).
|
||||
self._registry_refresh_lock = threading.Lock()
|
||||
self._registry_refresh_thread: Optional[threading.Thread] = None
|
||||
self._registry_refresh_retry_after = 0.0
|
||||
|
||||
# Per-plugin locks for _reinstall_with_rollback: the web UI runs
|
||||
# Flask with threaded=True, so two overlapping requests for the
|
||||
|
||||
@@ -7,7 +7,6 @@ methods reach shared state and helpers through ``self``.
|
||||
|
||||
import json
|
||||
import requests
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime
|
||||
@@ -986,14 +985,10 @@ class _RegistryMixin:
|
||||
|
||||
def get_registry_info(self, plugin_id: str) -> Optional[Dict]:
|
||||
"""
|
||||
Get plugin information from the registry (plugins.json).
|
||||
Get plugin information from the registry cache only (no GitHub API calls).
|
||||
|
||||
Makes no GitHub API calls, but it does go through `fetch_registry`:
|
||||
when the in-memory copy is missing or older than
|
||||
``registry_cache_timeout`` it downloads plugins.json, and with no
|
||||
network that waits out the timeout and retries. A caller that must
|
||||
not block on the network (the installed-plugins list) uses
|
||||
`get_cached_registry_info` instead.
|
||||
Use this for lightweight lookups where only registry fields are needed
|
||||
(e.g., verified status, latest_version).
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
@@ -1004,52 +999,3 @@ class _RegistryMixin:
|
||||
registry = self.fetch_registry()
|
||||
plugins = registry.get('plugins', []) or []
|
||||
return self._match_registry_entry(plugins, plugin_id)
|
||||
|
||||
def get_cached_registry_info(self, plugin_id: str) -> Optional[Dict]:
|
||||
"""The registry entry for ``plugin_id`` from the copy already in
|
||||
memory, however old; never touches the network.
|
||||
|
||||
None when no registry has been loaded yet, or the plugin isn't in it.
|
||||
When the copy is missing or past ``registry_cache_timeout`` this
|
||||
starts `refresh_registry_in_background`, so a later call has it.
|
||||
"""
|
||||
cache = getattr(self, 'registry_cache', None)
|
||||
cache_time = getattr(self, 'registry_cache_time', None)
|
||||
if (not cache or not cache_time
|
||||
or (time.time() - cache_time) >= self.registry_cache_timeout):
|
||||
self.refresh_registry_in_background()
|
||||
plugins = cache.get('plugins') if isinstance(cache, dict) else None
|
||||
if not isinstance(plugins, list):
|
||||
return None
|
||||
return self._match_registry_entry(
|
||||
[p for p in plugins if isinstance(p, dict)], plugin_id)
|
||||
|
||||
def refresh_registry_in_background(self) -> bool:
|
||||
"""Fetch the registry on a daemon thread; True when one was started.
|
||||
|
||||
At most one runs at a time. After a fetch that left no registry in
|
||||
memory (offline), no new one starts for ``_failure_backoff_seconds``,
|
||||
so an offline Pi doesn't retry on every page load.
|
||||
"""
|
||||
with self._registry_refresh_lock:
|
||||
running = self._registry_refresh_thread
|
||||
if running is not None and running.is_alive():
|
||||
return False
|
||||
if time.time() < self._registry_refresh_retry_after:
|
||||
return False
|
||||
thread = threading.Thread(
|
||||
target=self._background_registry_refresh,
|
||||
name='registry-refresh', daemon=True)
|
||||
self._registry_refresh_thread = thread
|
||||
thread.start()
|
||||
return True
|
||||
|
||||
def _background_registry_refresh(self) -> None:
|
||||
try:
|
||||
self.fetch_registry()
|
||||
except Exception as e: # noqa: BLE001 - a background warm-up must not crash
|
||||
self.logger.warning("Background registry refresh failed: %s", e)
|
||||
if not getattr(self, 'registry_cache', None):
|
||||
with self._registry_refresh_lock:
|
||||
self._registry_refresh_retry_after = (
|
||||
time.time() + self._failure_backoff_seconds)
|
||||
|
||||
@@ -1,484 +0,0 @@
|
||||
"""Runs one screen: the ScreenRunner of docs/RUN_LOOP_REDESIGN.md.
|
||||
|
||||
``ScreenRunner.run(plan, plugin)`` draws a screen's first frame, runs the
|
||||
frame loop its plan's ``frame_policy`` picks (125 Hz or 1 Hz), makes up the
|
||||
minimum duration when the loop ended early, and returns one
|
||||
:class:`Outcome` saying why the screen ended. Everything that touches the
|
||||
plugin, the panel or the controller's state goes through a
|
||||
:class:`ScreenHost` (the DisplayController); everything that reads or waits
|
||||
on the clock goes through an injected :class:`FrameClock`. The runner itself
|
||||
holds no state between screens.
|
||||
|
||||
What can end a screen early is decided at the runner's service points: after
|
||||
each frame, after the frame loop, and after the make-up dwell. At each one
|
||||
the host gathers a snapshot and asks the Arbiter, once, whether a Source in
|
||||
``plan.preemptible_by`` now wants the panel (:meth:`ScreenHost.check`). A yes
|
||||
is ``ExitReason.PREEMPTED``: the next pass of the loop decides what shows,
|
||||
and the rotation does not advance past the screen that was cut short.
|
||||
|
||||
The frame pacing is the loop that used to be inline in
|
||||
``DisplayController.run()``, unchanged: the 125 Hz loop paces to an 8 ms
|
||||
deadline from the start of each frame (sleeping at least 1 ms, so a frame
|
||||
that overran still yields the GIL), and the 1 Hz loop sleeps a flat second
|
||||
between frames, woken early by a control socket command.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any, NamedTuple, Optional, Protocol, Tuple
|
||||
|
||||
from src.display_arbiter import FramePolicy, ScreenPlan, Source
|
||||
|
||||
__all__ = [
|
||||
"AFTER_COMPLETED_LOOP",
|
||||
"AFTER_LOOP",
|
||||
"Checkpoint",
|
||||
"DYNAMIC_GRACE",
|
||||
"ExitReason",
|
||||
"FINAL",
|
||||
"FRAME",
|
||||
"FirstFrame",
|
||||
"FrameClock",
|
||||
"HIGH_FPS_INTERVAL",
|
||||
"NoticeRead",
|
||||
"Outcome",
|
||||
"STATIC_INTERVAL",
|
||||
"Screen",
|
||||
"ScreenHost",
|
||||
"ScreenRunner",
|
||||
"after_dwell",
|
||||
]
|
||||
|
||||
#: Seconds between frames in the high-FPS loop (125 Hz), for scrolling plugins.
|
||||
HIGH_FPS_INTERVAL = 0.008
|
||||
|
||||
#: Seconds between frames in the static loop (1 Hz).
|
||||
STATIC_INTERVAL = 1.0
|
||||
|
||||
#: A dynamic-duration screen ends on cycle completion only this long after its
|
||||
#: minimum, so timing jitter around the minimum can't end it early.
|
||||
DYNAMIC_GRACE = 0.5
|
||||
|
||||
|
||||
class ExitReason(Enum):
|
||||
"""Why a screen ended. The value is the golden traces' exit column where
|
||||
one exists (test/test_run_loop_golden.py)."""
|
||||
|
||||
#: The screen ran its target duration.
|
||||
DURATION = "duration"
|
||||
#: A dynamic-duration plugin finished its cycle after its minimum.
|
||||
CYCLE_COMPLETE = "cycle-complete"
|
||||
#: The first frame had nothing to show (display() returned False or
|
||||
#: raised inside the executor), or no plugin draws the mode.
|
||||
EMPTY = "empty"
|
||||
#: The first frame's dispatch itself raised.
|
||||
ERROR = "error"
|
||||
#: A later frame returned False (a dynamic-duration screen on the 1 Hz
|
||||
#: loop keeps going instead).
|
||||
DISPLAY_FALSE = "display-false"
|
||||
#: Another Source took the panel, or an on-demand session ran out before
|
||||
#: the screen began. The rotation does not advance.
|
||||
PREEMPTED = "preempted"
|
||||
#: A plugin reload is waiting for the top of the loop. The screen is cut
|
||||
#: short but counts as shown: the rotation advances, and the next pass
|
||||
#: reloads before it draws.
|
||||
RELOAD = "reload"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Outcome:
|
||||
"""How a screen ended.
|
||||
|
||||
Attributes:
|
||||
exit_reason: Why it ended.
|
||||
elapsed: Seconds from the end of the first frame to the end.
|
||||
preempted_by: For PREEMPTED, the plan that took the panel when the
|
||||
Arbiter named one (None when the session simply ran out).
|
||||
on_demand_active: Filled in by the controller when the screen is
|
||||
over: an on-demand session was running at that moment.
|
||||
still_live: Filled in by the controller: the mode's plugin still had
|
||||
live content at that moment, which holds the rotation on it.
|
||||
"""
|
||||
|
||||
exit_reason: ExitReason
|
||||
elapsed: float = 0.0
|
||||
preempted_by: Optional[ScreenPlan] = None
|
||||
on_demand_active: bool = False
|
||||
still_live: bool = False
|
||||
|
||||
|
||||
class FrameClock(Protocol):
|
||||
"""The clocks the runner reads and the sleep it paces with.
|
||||
|
||||
The shape of the ``time`` module, so production passes it (through an
|
||||
indirection that lets tests patch the module) and the golden traces pass
|
||||
their fake clock.
|
||||
"""
|
||||
|
||||
def time(self) -> float:
|
||||
"""Wall-clock seconds: what screen durations are measured in."""
|
||||
|
||||
def perf_counter(self) -> float:
|
||||
"""A monotonic high-resolution clock: what the 8 ms pacing reads."""
|
||||
|
||||
def sleep(self, seconds: float) -> None:
|
||||
"""Block for ``seconds``."""
|
||||
|
||||
|
||||
class NoticeRead(Enum):
|
||||
"""When a service point reads the WiFi notice file.
|
||||
|
||||
The read is throttled to once a second and deletes an expired file, so
|
||||
*when* it happens is behaviour: each service point reads it exactly
|
||||
when the loop always did.
|
||||
"""
|
||||
|
||||
#: Not at all.
|
||||
NEVER = "never"
|
||||
#: Only if nothing cheaper has already ended the screen: no on-demand
|
||||
#: session, the panel on, the mode unchanged and no live takeover.
|
||||
IF_UNDECIDED = "if-undecided"
|
||||
#: Whenever no on-demand session is running.
|
||||
ALWAYS = "always"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Checkpoint:
|
||||
"""What one kind of service point considers.
|
||||
|
||||
Attributes:
|
||||
name: For logs and tests.
|
||||
notice: When the WiFi notice is read (see NoticeRead).
|
||||
notice_counts: Whether a pending notice ends the screen here. After
|
||||
the make-up dwell it does only while the screen had time left.
|
||||
reload: Whether a pending plugin reload ends the screen here. Only
|
||||
between frames: once the frame loop is over the screen is too.
|
||||
"""
|
||||
|
||||
name: str
|
||||
notice: NoticeRead
|
||||
reload: bool
|
||||
notice_counts: bool = True
|
||||
|
||||
|
||||
#: Between frames: the frame loops' check, and the socket wake in the 1 Hz wait.
|
||||
FRAME = Checkpoint("frame", NoticeRead.IF_UNDECIDED, reload=True)
|
||||
#: After a frame loop that ended early (display() returned False, a reload).
|
||||
AFTER_LOOP = Checkpoint("after-loop", NoticeRead.IF_UNDECIDED, reload=False)
|
||||
#: After a frame loop that ran its course: only a mode change or the schedule.
|
||||
AFTER_COMPLETED_LOOP = Checkpoint("after-completed-loop", NoticeRead.NEVER, reload=False)
|
||||
#: The last look before the rotation advances.
|
||||
FINAL = Checkpoint("final", NoticeRead.NEVER, reload=False)
|
||||
|
||||
|
||||
def after_dwell(time_left: bool) -> Checkpoint:
|
||||
"""After the make-up dwell: a notice that cut it short ends the screen,
|
||||
so the mode resumes after the notice instead of rotating past it."""
|
||||
return Checkpoint("after-dwell", NoticeRead.ALWAYS, reload=False,
|
||||
notice_counts=time_left)
|
||||
|
||||
|
||||
class FirstFrame(NamedTuple):
|
||||
"""What the first frame's dispatch returned (see _dispatch_first_frame)."""
|
||||
|
||||
shown: bool
|
||||
raised: bool
|
||||
accepts_display_mode: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class Screen:
|
||||
"""One running screen: its completed plan, the plugin drawing it and
|
||||
when it started. Mutable only in that the runner owns it."""
|
||||
|
||||
plan: ScreenPlan
|
||||
plugin: Any
|
||||
accepts_display_mode: bool
|
||||
start: float
|
||||
|
||||
@property
|
||||
def mode(self) -> Optional[str]:
|
||||
return self.plan.mode
|
||||
|
||||
|
||||
class ScreenHost(Protocol):
|
||||
"""The controller's side of a screen. See DisplayController."""
|
||||
|
||||
def first_frame(self, plan: ScreenPlan, plugin: Any) -> FirstFrame:
|
||||
"""Draw the first frame through the plugin executor."""
|
||||
|
||||
def complete_plan(self, plan: ScreenPlan, plugin: Any) -> Optional[ScreenPlan]:
|
||||
"""The plan with the plugin's durations, dynamic flag and frame
|
||||
policy, read after the first frame. None when an on-demand session
|
||||
has no time left for it."""
|
||||
|
||||
def draw(self, screen: Screen) -> Any:
|
||||
"""One later frame: what display() returned."""
|
||||
|
||||
def after_frame(self, screen: Screen) -> None:
|
||||
"""After a frame that did not end the screen (the follower frame)."""
|
||||
|
||||
def tick(self) -> None:
|
||||
"""Plugin updates that have come due (throttled)."""
|
||||
|
||||
def service(self, screen: Screen) -> Optional[Tuple[str, ...]]:
|
||||
"""Apply pending changes (on-demand requests, schedule, brightness,
|
||||
finished reloads). Returns the live modes when a live-priority scan
|
||||
was due, else None."""
|
||||
|
||||
def wait_frame(self, interval: float, screen: Screen) -> Optional[ScreenPlan]:
|
||||
"""The 1 Hz loop's sleep between frames. The plan that takes the
|
||||
panel when a control socket command ended the screen, else None."""
|
||||
|
||||
def check(self, screen: Screen, checkpoint: Checkpoint,
|
||||
live_scan: Optional[Tuple[str, ...]] = None) -> Optional[ScreenPlan]:
|
||||
"""The service point: the plan that now takes the panel from this
|
||||
screen, or None while it holds. One Arbiter.decide() call."""
|
||||
|
||||
def dwell(self, seconds: float) -> None:
|
||||
"""Sleep up to ``seconds``, servicing changes; returns early on one."""
|
||||
|
||||
def cycle_complete(self, screen: Screen) -> bool:
|
||||
"""The plugin's dynamic-duration cycle is complete."""
|
||||
|
||||
|
||||
class ScreenRunner:
|
||||
"""Runs one screen at a time for a ScreenHost. See the module docstring."""
|
||||
|
||||
def __init__(self, clock: FrameClock, host: ScreenHost,
|
||||
log: Optional[logging.Logger] = None):
|
||||
self.clock = clock
|
||||
self.host = host
|
||||
# The controller passes its own logger, so these lines keep the
|
||||
# source they always had in the journal.
|
||||
self.log = log or logging.getLogger(__name__)
|
||||
|
||||
# -- the screen ------------------------------------------------------
|
||||
|
||||
def run(self, plan: ScreenPlan, plugin: Any) -> Outcome:
|
||||
"""Run ``plan``'s screen, drawn by ``plugin`` (None: nothing draws it)."""
|
||||
if plugin is None:
|
||||
return Outcome(ExitReason.EMPTY)
|
||||
first = self.host.first_frame(plan, plugin)
|
||||
if not first.shown:
|
||||
return Outcome(ExitReason.ERROR if first.raised else ExitReason.EMPTY)
|
||||
completed = self.host.complete_plan(plan, plugin)
|
||||
if completed is None:
|
||||
return Outcome(ExitReason.PREEMPTED)
|
||||
screen = Screen(completed, plugin, first.accepts_display_mode,
|
||||
start=self.clock.time())
|
||||
|
||||
if completed.frame_policy is FramePolicy.HIGH_FPS:
|
||||
reason, by = self._high_fps_loop(screen)
|
||||
else:
|
||||
reason, by = self._static_loop(screen)
|
||||
if reason is ExitReason.PREEMPTED:
|
||||
# The service point that ended the loop has decided; looking
|
||||
# again now, at the same instant, gives the same answer.
|
||||
return self._outcome(screen, reason, by)
|
||||
|
||||
loop_completed = reason in (ExitReason.DURATION, ExitReason.CYCLE_COMPLETE)
|
||||
# LOAD-BEARING: a change the frame loop did not end on (a dwell
|
||||
# inside it, a later frame returning False) must not fall into the
|
||||
# make-up dwell below. It can run for the rest of the screen's
|
||||
# duration, and a freshly requested on-demand mode would sit
|
||||
# invisible for that long -- or be clobbered by a queued stop.
|
||||
by = self.host.check(screen, AFTER_COMPLETED_LOOP if loop_completed else AFTER_LOOP)
|
||||
if by is not None:
|
||||
return self._outcome(screen, ExitReason.PREEMPTED, by)
|
||||
|
||||
# Honour the minimum duration when a static, non-dynamic screen's
|
||||
# loop ended early. A screen cut short for a plugin reload is over:
|
||||
# the dwell returns at once and the rotation advances.
|
||||
if (not completed.dynamic and not loop_completed
|
||||
and completed.frame_policy is not FramePolicy.HIGH_FPS):
|
||||
elapsed = self.clock.time() - screen.start
|
||||
remaining = max(0.0, self._max(screen) - elapsed)
|
||||
if remaining > 0:
|
||||
self.host.dwell(remaining)
|
||||
time_left = self.clock.time() - screen.start < self._max(screen)
|
||||
by = self.host.check(screen, after_dwell(time_left))
|
||||
if by is not None:
|
||||
return self._outcome(screen, ExitReason.PREEMPTED, by)
|
||||
|
||||
if completed.dynamic:
|
||||
self._log_dynamic_end(screen)
|
||||
|
||||
# The dwells above return early when a pending change (on-demand
|
||||
# started or stopped, the panel scheduled off) has already decided
|
||||
# what comes next; rotating now would skip it.
|
||||
by = self.host.check(screen, FINAL)
|
||||
if by is not None:
|
||||
return self._outcome(screen, ExitReason.PREEMPTED, by)
|
||||
return self._outcome(screen, reason, None)
|
||||
|
||||
def _outcome(self, screen: Screen, reason: ExitReason,
|
||||
by: Optional[ScreenPlan]) -> Outcome:
|
||||
return Outcome(reason, self.clock.time() - screen.start, preempted_by=by)
|
||||
|
||||
@staticmethod
|
||||
def _max(screen: Screen) -> float:
|
||||
return float(screen.plan.max_duration or 0.0)
|
||||
|
||||
@staticmethod
|
||||
def _min(screen: Screen) -> float:
|
||||
return float(screen.plan.min_duration or 0.0)
|
||||
|
||||
@staticmethod
|
||||
def _ended_by(by: ScreenPlan) -> ExitReason:
|
||||
return ExitReason.RELOAD if by.source is Source.RELOAD else ExitReason.PREEMPTED
|
||||
|
||||
# -- the frame loops ---------------------------------------------------
|
||||
|
||||
def _high_fps_loop(self, screen: Screen) -> Tuple[ExitReason, Optional[ScreenPlan]]:
|
||||
"""Ultra-smooth frames for scrolling plugins (8 ms = 125 FPS)."""
|
||||
clock, host, log = self.clock, self.host, self.log
|
||||
interval = HIGH_FPS_INTERVAL
|
||||
log.debug("Entering high-FPS loop for %s with display_interval=%.3fs (%.1f FPS)",
|
||||
screen.mode, interval, 1.0 / interval)
|
||||
target = self._max(screen)
|
||||
while True:
|
||||
frame_start = clock.perf_counter()
|
||||
try:
|
||||
result = host.draw(screen)
|
||||
if isinstance(result, bool) and not result:
|
||||
log.debug("Display returned False, breaking early")
|
||||
return ExitReason.DISPLAY_FALSE, None
|
||||
except Exception: # pylint: disable=broad-except
|
||||
log.exception("Error during display update")
|
||||
|
||||
# Multi-display sync: send follower frame after each render
|
||||
host.after_frame(screen)
|
||||
host.tick()
|
||||
# Throttled: one clock compare between passes. A live-priority
|
||||
# scan, when one is due, happens here, before the sleep, as it
|
||||
# always has; the Arbiter weighs it after the sleep.
|
||||
live_scan = host.service(screen)
|
||||
|
||||
# Pace to the frame deadline rather than sleeping a flat
|
||||
# interval on top of the work. display() has already blocked on
|
||||
# the panel's vsync by this point, so an unconditional sleep is
|
||||
# added to a wait that already happened. Measured on a 2x128x64
|
||||
# chain at limit_refresh_rate_hz=100: ~4ms of render plus a flat
|
||||
# 8ms put each iteration at ~12ms against a 10ms refresh grid, so
|
||||
# every swap missed a refresh and the loop settled at 50fps where
|
||||
# display_interval asks for 125 -- and with zero headroom, ~14% of
|
||||
# frames slipped a further refresh, which is what reads as scroll
|
||||
# stutter.
|
||||
remaining = interval - (clock.perf_counter() - frame_start)
|
||||
# Yield even when the frame overran its budget, so plugin update
|
||||
# threads and the web UI are not starved of the GIL.
|
||||
clock.sleep(remaining if remaining > 0 else 0.001)
|
||||
|
||||
by = host.check(screen, FRAME, live_scan)
|
||||
if by is not None:
|
||||
log.debug("Mode changed during high-FPS loop, breaking early")
|
||||
return self._ended_by(by), by
|
||||
|
||||
elapsed = clock.time() - screen.start
|
||||
if elapsed >= target:
|
||||
log.debug("Reached high-FPS target duration %.2fs for mode %s",
|
||||
target, screen.mode)
|
||||
return ExitReason.DURATION, None
|
||||
if self._should_exit_dynamic(screen, elapsed):
|
||||
log.debug("Dynamic duration cycle complete for %s after %.2fs",
|
||||
screen.mode, elapsed)
|
||||
return ExitReason.CYCLE_COMPLETE, None
|
||||
|
||||
def _static_loop(self, screen: Screen) -> Tuple[ExitReason, Optional[ScreenPlan]]:
|
||||
"""One frame a second for everything else."""
|
||||
clock, host, log = self.clock, self.host, self.log
|
||||
interval = STATIC_INTERVAL
|
||||
log.debug("Entering normal FPS loop for %s with display_interval=%.3fs",
|
||||
screen.mode, interval)
|
||||
target = self._max(screen)
|
||||
dynamic = screen.plan.dynamic
|
||||
while True:
|
||||
# Wakes for a control socket command and applies it at once,
|
||||
# instead of up to a second later.
|
||||
by = host.wait_frame(interval, screen)
|
||||
if by is not None:
|
||||
log.info("Mode changed during display loop from %s to %s (%s), "
|
||||
"breaking early", screen.mode, by.mode, by.source.value)
|
||||
return self._ended_by(by), by
|
||||
host.tick()
|
||||
|
||||
elapsed = clock.time() - screen.start
|
||||
if elapsed >= target:
|
||||
log.debug("Reached standard target duration %.2fs for mode %s",
|
||||
target, screen.mode)
|
||||
return ExitReason.DURATION, None
|
||||
|
||||
try:
|
||||
result = host.draw(screen)
|
||||
if isinstance(result, bool) and not result:
|
||||
# A dynamic-duration screen doesn't end on False: it
|
||||
# keeps looping until its cycle completes or its maximum.
|
||||
if not dynamic:
|
||||
log.info("Display returned False for %s (no dynamic duration), "
|
||||
"breaking early", screen.mode)
|
||||
return ExitReason.DISPLAY_FALSE, None
|
||||
log.debug("Display returned False for %s (dynamic duration enabled), "
|
||||
"continuing loop", screen.mode)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
log.exception("Error during display update")
|
||||
|
||||
# Multi-display sync: send follower frame after each render
|
||||
host.after_frame(screen)
|
||||
|
||||
live_scan = host.service(screen)
|
||||
by = host.check(screen, FRAME, live_scan)
|
||||
if by is not None:
|
||||
log.info("Mode changed during display loop from %s to %s (%s), "
|
||||
"breaking early", screen.mode, by.mode, by.source.value)
|
||||
return self._ended_by(by), by
|
||||
|
||||
if self._should_exit_dynamic(screen, elapsed):
|
||||
log.info("Dynamic duration cycle complete for %s after %.2fs",
|
||||
screen.mode, elapsed)
|
||||
return ExitReason.CYCLE_COMPLETE, None
|
||||
|
||||
# -- dynamic duration --------------------------------------------------
|
||||
|
||||
def _should_exit_dynamic(self, screen: Screen, elapsed: float) -> bool:
|
||||
if not screen.plan.dynamic:
|
||||
return False
|
||||
minimum = self._min(screen)
|
||||
# A small grace period after min_duration prevents premature exits
|
||||
# due to timing issues.
|
||||
if elapsed < minimum + DYNAMIC_GRACE:
|
||||
self.log.debug(
|
||||
"_should_exit_dynamic: elapsed %.2fs < min_duration %.2fs + grace %.2fs, "
|
||||
"returning False", elapsed, minimum, DYNAMIC_GRACE)
|
||||
return False
|
||||
cycle_complete = self.host.cycle_complete(screen)
|
||||
self.log.debug(
|
||||
"_should_exit_dynamic: elapsed %.2fs >= min %.2fs, cycle_complete=%s, returning %s",
|
||||
elapsed, minimum + DYNAMIC_GRACE, cycle_complete, cycle_complete)
|
||||
if cycle_complete:
|
||||
self.log.debug("Cycle complete detected for %s after %.2fs (min: %.2fs, grace: %.2fs)",
|
||||
screen.mode, elapsed, minimum, DYNAMIC_GRACE)
|
||||
return cycle_complete
|
||||
|
||||
def _log_dynamic_end(self, screen: Screen) -> None:
|
||||
"""How a dynamic-duration screen ended, for the log. Asks the plugin
|
||||
once more whether its cycle is complete, as the loop always did."""
|
||||
elapsed_total = self.clock.time() - screen.start
|
||||
cycle_done = self.host.cycle_complete(screen)
|
||||
minimum, maximum = self._min(screen), self._max(screen)
|
||||
if cycle_done:
|
||||
self.log.info(
|
||||
"Dynamic duration cycle completed for %s after %.2fs "
|
||||
"(target: %.2fs, min: %.2fs, max: %.2fs)",
|
||||
screen.mode, elapsed_total, maximum, minimum, maximum)
|
||||
elif elapsed_total >= maximum:
|
||||
self.log.info(
|
||||
"Dynamic duration cap reached before cycle completion for %s "
|
||||
"(%.2fs/%ds, min: %.2fs)",
|
||||
screen.mode, elapsed_total, int(maximum), minimum)
|
||||
else:
|
||||
self.log.debug(
|
||||
"Dynamic duration cycle in progress for %s: %.2fs elapsed "
|
||||
"(target: %.2fs, min: %.2fs, max: %.2fs)",
|
||||
screen.mode, elapsed_total, maximum, minimum, maximum)
|
||||
@@ -143,24 +143,12 @@ class FakeCache:
|
||||
def __init__(self):
|
||||
self.data: Dict[str, Any] = {}
|
||||
self.cache_dir = "/nonexistent/run-loop-harness"
|
||||
self._writes = 0
|
||||
self._written: Dict[str, int] = {}
|
||||
|
||||
def get(self, key, max_age=None, memory_ttl=None):
|
||||
return self.data.get(key)
|
||||
|
||||
def set(self, key, data, ttl=None):
|
||||
self.data[key] = data
|
||||
# Every write is a new file, as DiskCache's rename makes it.
|
||||
self._writes += 1
|
||||
self._written[key] = self._writes
|
||||
|
||||
def file_signature(self, key):
|
||||
"""CacheManager.file_signature: None without a file, else a value
|
||||
that changes with every write."""
|
||||
if key not in self.data:
|
||||
return None
|
||||
return (self._written.get(key, 0), 0, 0)
|
||||
|
||||
def delete(self, key):
|
||||
self.data.pop(key, None)
|
||||
@@ -736,14 +724,11 @@ class RunLoopHarness:
|
||||
self.clock.at(t, post)
|
||||
|
||||
def restore_on_demand(self, plugin_id: str, mode: Optional[str] = None,
|
||||
duration: Optional[float] = None, pinned: bool = False,
|
||||
named_mode: Optional[str] = None):
|
||||
duration: Optional[float] = None, pinned: bool = False):
|
||||
"""Start with an on-demand session resumed from the cache, as after
|
||||
a restart: the state _select_startup_plugins restores, then
|
||||
_populate_on_demand_modes_from_plugin, as __init__ calls it. A
|
||||
session that cannot resume is logged as ``on-demand-error``."""
|
||||
_populate_on_demand_modes_from_plugin, as __init__ calls it."""
|
||||
dc = self.controller
|
||||
dc._on_demand_named_mode = named_mode
|
||||
dc.on_demand_active = True
|
||||
dc.on_demand_plugin_id = plugin_id
|
||||
dc.on_demand_mode = mode
|
||||
@@ -754,8 +739,6 @@ class RunLoopHarness:
|
||||
dc.on_demand_status = 'active'
|
||||
dc.on_demand_schedule_override = True
|
||||
dc._populate_on_demand_modes_from_plugin()
|
||||
if dc.on_demand_status == 'error':
|
||||
self.log("on-demand-error", dc.on_demand_last_error)
|
||||
|
||||
def wifi_message(self, t: float, message: str, duration: float = 5):
|
||||
def write():
|
||||
|
||||
@@ -343,6 +343,24 @@ def _hermetic_unit_refresh(monkeypatch, tmp_path_factory):
|
||||
monkeypatch.setattr(unit_refresh, 'SYSTEMD_DIR', str(tmp_path_factory.getbasetemp() / 'no-systemd'))
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _forget_settled_espn_chunks(monkeypatch):
|
||||
"""src.common.espn_dates remembers past days process-wide; tests fake
|
||||
different answers for the same dates, so none may inherit another's.
|
||||
|
||||
"Today" is also pinned to 2000-01-01, so no date a test uses counts as
|
||||
settled unless the test says so (by pinning _utc_today itself). Without
|
||||
that, a test asking for last month twice passes while that month is
|
||||
recent and fails once it is three days old: the second ask is answered
|
||||
from memory."""
|
||||
from datetime import date
|
||||
from src.common import espn_dates
|
||||
monkeypatch.setattr(espn_dates, "_utc_today", lambda: date(2000, 1, 1))
|
||||
espn_dates.clear_settled_chunk_cache()
|
||||
yield
|
||||
espn_dates.clear_settled_chunk_cache()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_logging():
|
||||
"""Reset logging configuration before each test."""
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"screens": [
|
||||
[0.0, "clock", 5.0, "on-demand-start", 6, false],
|
||||
[5.0, "sports_live", 15.0, "duration", 15, true],
|
||||
[20.0, "sports_recent", 15.0, "duration", 15, true],
|
||||
[35.0, "sports_upcoming", 5.0, "on-demand-requested-stop", 6, true],
|
||||
[40.0, "clock", 20.0, "duration", 20, true],
|
||||
[60.0, "sports_live", 15.0, "display-false", 11, true],
|
||||
[75.0, "sports_recent", 15.0, "duration", 15, true],
|
||||
[90.0, "sports_upcoming", 10.0, "on-demand-start", 11, true],
|
||||
[100.0, "sports_live", 0.0, "empty", 1, true],
|
||||
[100.0, "sports_recent", 15.0, "duration", 15, true],
|
||||
[115.0, "sports_upcoming", 15.0, "duration", 15, true],
|
||||
[130.0, "sports_live", 0.0, "empty", 1, true],
|
||||
[130.0, "sports_recent", 10.0, "on-demand-requested-stop", 11, true],
|
||||
[140.0, "sports_upcoming", 15.0, "duration", 15, true],
|
||||
[155.0, "clock", 5.0, "horizon", 5, true]
|
||||
],
|
||||
"events": [
|
||||
[5.0, "request", "start:n1"],
|
||||
[5.0, "on-demand-start", "sports"],
|
||||
[40.0, "request", "stop:n2"],
|
||||
[40.0, "on-demand-requested-stop"],
|
||||
[100.0, "request", "start:n3"],
|
||||
[100.0, "on-demand-start", "sports"],
|
||||
[140.0, "request", "stop:n4"],
|
||||
[140.0, "on-demand-requested-stop"]
|
||||
]
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"screens": [
|
||||
[0.0, "clock", 20.0, "duration", 20, false],
|
||||
[20.0, "weather", 20.0, "duration", 20, true],
|
||||
[40.0, "clock", 20.0, "horizon", 20, true]
|
||||
],
|
||||
"events": [
|
||||
[0.0, "on-demand-error", "restore-failed"]
|
||||
]
|
||||
}
|
||||
@@ -50,7 +50,6 @@ server has none.
|
||||
| `unit/test_store_categories.js` | no | The store's category filter (sandbox): the template ships only All Categories, the rest come from the store's plugins (one per category whatever its case), choosing one filters to it, and a swapped-in select is refilled from the cache keeping the choice |
|
||||
| `unit/test_github_url_install.js` | no | Install Single Plugin (sandbox, the button as `plugins.html` ships it): no inline `onclick`, so a click or Enter sends exactly one `install-from-url` request and raises no error |
|
||||
| `unit/test_render_cards.js` | no | `renderInstalledCards` markup, both empty states, and HTML-escaping of hostile plugin metadata |
|
||||
| `unit/test_plugin_order_list.js` | no | `widgets/plugin-order-list.js` (the Vegas and rotation order lists): a disabled plugin, which gets no row, keeps its slot in the saved order and its Vegas exclusion when the list rewrites its hidden inputs, around reordering and include/exclude; an uninstalled plugin's id is dropped, a failed plugin list leaves the inputs as saved, and only string ids are carried over, once each |
|
||||
| `unit/test_style_editor_element_keys.js` | no | `elementKeys()`/`styleRows()`/`positionRows()` from `widgets/style-editor.js`: every `customization.layout` entry gets exactly one row -- paired with its style element through core's `x-layout-key` (so `score` belongs to `score_text`, not a second row), or a position row of its own, leaves included -- since the widget claims the whole `layout` block from the generic fallback renderer |
|
||||
| `unit/test_style_editor_layout_leaf_columns.js` | no | `columnsFor()` from `widgets/style-editor.js`: a layout-only key whose own value is a leaf (no x/y sub-object, e.g. a `show_logo` toggle) gets a self-keyed column instead of a blank, uneditable row |
|
||||
| `unit/test_style_editor_layout_leaf_collision.js` | no | `columnsFor()` from `widgets/style-editor.js`: a layout-only leaf key still gets its own column even when its name collides with an unrelated element's style sub-field or another layout axis's sub-field |
|
||||
@@ -58,9 +57,6 @@ server has none.
|
||||
| `unit/test_store_registry_fields.js` | no | The store card's registry fields from `plugins_manager.js`: the commit that introduced the listed version (a hex SHA only, linked to that tree), the "Needs LEDMatrix X+" warning, a card from an older registry without either, and `isStorePluginInstalled` answering to `aliases` |
|
||||
| `unit/test_page_registry.js` | no | The page lifecycle in `js/core/registry.js` (a minimal DOM shim): one `init` per `data-page` root, `destroy` and an aborted `ctx.signal` when htmx swaps it away, a vetoed swap keeps it, lazy page modules, a root removed without htmx swept on the next swap |
|
||||
| `unit/test_core_modules.js` | no | `js/core/api.js` (JSON envelope, HTTP/`status: error`/network errors, abort passthrough, the #683 login redirect, same-server paths only) and `js/core/facade.js` (`window.LEDMatrix`, deprecated aliases) |
|
||||
| `unit/test_overview_reconciliation_poll.js` | no | The Overview's reconciliation-banner poll from `partials/overview.html`, run in a vm: it gives up after a bounded number of requests when the status never says done, runs only while the Overview is on screen (`LEDVisibility`, its own key), and stops once the banner is shown |
|
||||
| `unit/test_display_partial_ids.js` | no | `partials/display.html`: every literal `getElementById()` in its inline scripts names an id the partial renders, and moving the brightness slider (the shipped script, in a vm with a fake DOM) updates its label without throwing |
|
||||
| `unit/test_general_web_login_token.js` | no | `createToken` from `js/pages/general.js`, imported with a fake DOM and fetch: a created API token clears the form's `data-dirty` mark (so a reload does not ask "Leave site?"), a refused one keeps it |
|
||||
| `unit/test_plugin_action_delegation.js` | no | The document-level card-action delegation and `handlePluginAction` from `plugins_manager.js`, run with the handler inside an IIFE as in the real file: each action is handled once, a Starlark app uninstall goes to `DELETE /starlark/apps/<id>`, and an uninstall is confirmed once |
|
||||
| `dom/test_installed_dom.js` | yes | The toolbar in a real DOM: pill/search/sort interaction, the HTMX partial re-swap, and a `getComputedStyle` check that `.filter-pill[data-active]` really matches the emitted markup |
|
||||
| `dom/test_store_dom.js` | yes | Store pagination, per-page, category, tri-state Installed button, and persistence across a re-boot, against the live registry |
|
||||
@@ -69,8 +65,6 @@ server has none.
|
||||
| `dom/test_durations_page.js` | yes | The Rotation tab (`js/pages/durations.js`) with the real `plugin-order-list.js` widget: one plugin-list request per swap, one move per click after repeated swaps, a swap cancels the request in flight, a late widget is waited for |
|
||||
| `dom/test_operation_history_page.js` | yes | The Operation History tab (`js/pages/operation-history.js`): one request per swap and per Refresh, the plugin filter filled once, paging, filters, search, Clear, error/login states, hostile values stay text |
|
||||
| `dom/test_raw_json_page.js` | yes | The Config Editor tab (`js/pages/raw-json.js`): one POST per Save after repeated swaps, Format/Validate, invalid JSON never sent, a save survives a swap, the old global entry points |
|
||||
| `dom/test_schedule_page.js` | yes | The Schedule tab (`js/pages/schedule.js`) with the real `schedule-picker` widget: both pickers drawn once per swap from the saved config, one notification per save answer after repeated swaps, the brightness label, a late widget waited for, the old global entry points |
|
||||
| `dom/test_general_page.js` | yes | The General tab (`js/pages/general.js`) with the real `timezone-selector` widget: the picker drawn once per swap, one request per Security action after repeated swaps, hostile token names stay text, refused/network/login answers, a write survives a swap, `webLogin`'s entry points |
|
||||
| `dom/test_backup_restore_page.js` | yes | The Backup & Restore tab (`js/pages/backup-restore.js`): one request per action after repeated swaps, the upload and restore options, reads cancelled and writes not on a swap, hostile names stay text, the old global entry points |
|
||||
| `dom/test_tools_sections.js` | yes | The Tools tab's MQTT bridge and Pixlet editor sections: form prefill, the write-only password (blank means unchanged), the running-session banner and countdown, and that the editor link points at the host you loaded the page from |
|
||||
|
||||
|
||||
@@ -98,11 +98,7 @@ const ok = (l, c, x) => c ? (pass++, console.log(' ok ' + l))
|
||||
|
||||
const lists = () => requests.filter(r => r.url === '/api/v3/plugins/installed').length;
|
||||
const $ = id => doc.getElementById(id);
|
||||
// The rows' ids, in order. The input also keeps saved ids that have no row
|
||||
// (a disabled plugin's place, see test/js/unit/test_plugin_order_list.js),
|
||||
// and the saved order comes from whatever config the server has.
|
||||
const SHOWN = plugins.filter(p => p.enabled).map(p => p.id);
|
||||
const order = () => JSON.parse($('rotation_plugin_order_value').value || '[]').filter(id => SHOWN.includes(id));
|
||||
const order = () => JSON.parse($('rotation_plugin_order_value').value || '[]');
|
||||
async function swap() {
|
||||
panel.dispatchEvent(new window.CustomEvent('htmx:beforeSwap', { bubbles: true, detail: { target: panel, shouldSwap: true } }));
|
||||
panel.innerHTML = partial;
|
||||
|
||||
@@ -1,272 +0,0 @@
|
||||
// The General tab as a page module (static/v3/js/pages/general.js), in a real
|
||||
// DOM (jsdom) with the real server-rendered partial, the real timezone
|
||||
// widget and the real web-login endpoints' answer shapes. Built like
|
||||
// test_cache_page.js:
|
||||
//
|
||||
// * the partial ships no <script> and no inline handlers; its root is
|
||||
// data-page="general" and the Security section's forms and buttons name
|
||||
// an action
|
||||
// * the timezone picker is drawn once per swap-in, with the saved zone
|
||||
// * after five swaps, each Security action makes exactly one request
|
||||
// * a login change is a write: a swap does not cancel it, its result is
|
||||
// still reported, and nothing is drawn into the page that has gone
|
||||
// * token names reach the page as text
|
||||
// * the settings form itself is left to htmx
|
||||
// * window.webLogin's entry points still work
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { pathToFileURL } = require('url');
|
||||
const { JSDOM, VirtualConsole } = require('jsdom');
|
||||
|
||||
const BASE = process.env.BASE || 'http://localhost:5000';
|
||||
const JS = path.resolve(__dirname, '../../../web_interface/static/v3/js');
|
||||
const get = p => new Promise((res, rej) =>
|
||||
http.get(BASE + p, r => { let d = ''; r.on('data', c => d += c); r.on('end', () => res(d)); }).on('error', rej));
|
||||
const load = f => import(pathToFileURL(path.join(JS, f)).href);
|
||||
const tick = ms => new Promise(r => setTimeout(r, ms || 0));
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
const ok = (l, c, x) => c ? (pass++, console.log(' ok ' + l))
|
||||
: (fail++, console.log(' FAIL ' + l + (x !== undefined ? ' -> ' + JSON.stringify(x).slice(0, 300) : '')));
|
||||
|
||||
(async () => {
|
||||
const partial = await get('/partials/general');
|
||||
const realTokens = JSON.parse(await get('/api/v3/auth/tokens'));
|
||||
const { createRegistry } = await load('core/registry.js');
|
||||
const { createApi } = await load('core/api.js');
|
||||
const generalPage = await load('pages/general.js');
|
||||
|
||||
console.log('\n── General tab: page module (real DOM) ──');
|
||||
ok('the partial ships no inline script', !/<script/i.test(partial));
|
||||
ok('the partial has no inline click or submit handlers', !/\son(click|submit|input)=/i.test(partial));
|
||||
ok('the partial root is data-page="general"', /data-page="general"/.test(partial));
|
||||
const security = /id="web-login-settings"/.test(partial);
|
||||
ok('the server renders the Security section (it has a login store)', security);
|
||||
ok('the real token list answers in the shape the section shows',
|
||||
realTokens.status === 'success' && realTokens.data && Array.isArray(realTokens.data.tokens), realTokens);
|
||||
ok('the Security forms name their action',
|
||||
/<form[^>]*data-action="set-password"/.test(partial) && /<form[^>]*data-action="create-token"/.test(partial));
|
||||
ok('the Copy button names its action', /data-action="copy-token"/.test(partial));
|
||||
|
||||
const errs = [];
|
||||
const logged = [];
|
||||
const vc = new VirtualConsole();
|
||||
vc.on('jsdomError', e => errs.push(String(e.message || e).split('\n')[0]));
|
||||
vc.on('error', (...a) => logged.push(a.join(' ')));
|
||||
const dom = new JSDOM(`<!doctype html><html><body><div id="general-content">${partial}</div></body></html>`,
|
||||
{ url: BASE + '/', virtualConsole: vc, runScripts: 'outside-only' });
|
||||
const { window } = dom;
|
||||
const doc = window.document;
|
||||
const panel = doc.getElementById('general-content');
|
||||
require('../led_escape').install(window);
|
||||
window.eval(fs.readFileSync(path.join(JS, 'widgets/registry.js'), 'utf8'));
|
||||
window.eval(fs.readFileSync(path.join(JS, 'widgets/timezone-selector.js'), 'utf8'));
|
||||
const widgets = window.LEDMatrixWidgets;
|
||||
ok('the widget scripts register timezone-selector', !!(widgets && widgets.get('timezone-selector')));
|
||||
|
||||
let confirmAnswer = true;
|
||||
const confirms = [];
|
||||
window.confirm = m => { confirms.push(m); return confirmAnswer; };
|
||||
const reloads = [];
|
||||
window.htmx = { ajax: (method, url, opts) => reloads.push([method, url, opts.target]) };
|
||||
|
||||
const HOSTILE = '<img src=x onerror="window.pwned=1">';
|
||||
let mode = 'ok';
|
||||
let nextId = 1;
|
||||
const requests = [];
|
||||
const pending = [];
|
||||
function fakeFetch(url, init) {
|
||||
requests.push({ url, method: init.method, body: init.body ? JSON.parse(init.body) : undefined });
|
||||
const respond = (status, body, headers) => Promise.resolve({
|
||||
status, ok: status >= 200 && status < 300,
|
||||
headers: { get: h => (headers || {})[h] || null },
|
||||
text: () => Promise.resolve(JSON.stringify(body)),
|
||||
});
|
||||
if (mode === 'network') return Promise.reject(new TypeError('Failed to fetch'));
|
||||
if (mode === 'login') return respond(401, { status: 'error' }, { 'X-LEDMatrix-Login': '/login' });
|
||||
if (mode === 'refuse') return respond(400, { status: 'error', message: 'Give the token a name.' });
|
||||
if (url === '/api/v3/auth/tokens' && init.method === 'POST') {
|
||||
const id = 'tok' + (nextId++);
|
||||
const name = JSON.parse(init.body).name;
|
||||
const answer = () => respond(201, {
|
||||
status: 'success', message: 'Token created. Copy it now: it is not shown again.',
|
||||
data: { token: 'lmx_' + id, record: { id, name, prefix: 'lmx_' + id.slice(0, 3), created_at: '2026-10-04T00:00:00' } },
|
||||
});
|
||||
if (mode === 'hang') return new Promise(resolve => pending.push(() => resolve(answer())));
|
||||
return answer();
|
||||
}
|
||||
if (url.startsWith('/api/v3/auth/tokens/') && init.method === 'DELETE') {
|
||||
return respond(200, { status: 'success', message: 'Token revoked.', data: { tokens: [] } });
|
||||
}
|
||||
if (url === '/api/v3/auth/password') {
|
||||
return respond(200, { status: 'success', message: 'Login is on. Other browsers now need the password.' });
|
||||
}
|
||||
return respond(404, { status: 'error', message: 'unexpected ' + url });
|
||||
}
|
||||
const notes = [];
|
||||
const registry = createRegistry({
|
||||
document: doc,
|
||||
context: { api: createApi({ fetch: fakeFetch }), notify: (m, t) => notes.push([m, t]) },
|
||||
});
|
||||
registry.register('general', generalPage);
|
||||
|
||||
const $ = id => doc.getElementById(id);
|
||||
const root = () => doc.querySelector('[data-page="general"]');
|
||||
const timezoneWidgets = () => $('timezone_container').querySelectorAll('.timezone-selector-widget').length;
|
||||
const rows = () => doc.querySelectorAll('#web-login-tokens [data-token-id]');
|
||||
const calls = (method, prefix) => requests.filter(r => r.method === method && r.url.startsWith(prefix));
|
||||
const lastNote = () => notes[notes.length - 1] || [];
|
||||
function submit(form) {
|
||||
const event = new window.Event('submit', { bubbles: true, cancelable: true });
|
||||
form.dispatchEvent(event);
|
||||
return event;
|
||||
}
|
||||
const form = action => root().querySelector(`form[data-action="${action}"]`);
|
||||
async function swap(html) {
|
||||
panel.dispatchEvent(new window.CustomEvent('htmx:beforeSwap', { bubbles: true, detail: { target: panel, shouldSwap: true } }));
|
||||
panel.innerHTML = html === undefined ? partial : html;
|
||||
panel.dispatchEvent(new window.CustomEvent('htmx:afterSwap', { bubbles: true, detail: { target: panel } }));
|
||||
await tick(20);
|
||||
}
|
||||
|
||||
await registry.start();
|
||||
await tick(20);
|
||||
|
||||
// ── the timezone picker ─────────────────────────────────────────────────
|
||||
const savedZone = $('timezone_container').dataset.timezone;
|
||||
ok('the partial carries the saved timezone', !!savedZone, savedZone);
|
||||
ok('the timezone picker is drawn once', timezoneWidgets() === 1, timezoneWidgets());
|
||||
ok('...holding the saved zone', $('timezone_data') && $('timezone_data').value === savedZone,
|
||||
$('timezone_data') && $('timezone_data').value);
|
||||
ok('...posted as "timezone"', $('timezone_data') && $('timezone_data').name === 'timezone');
|
||||
|
||||
for (let i = 0; i < 5; i++) await swap();
|
||||
ok('one mounted page after five swaps', registry.list().length === 1, registry.list().length);
|
||||
ok('the timezone picker is drawn once, not stacked', timezoneWidgets() === 1, timezoneWidgets());
|
||||
|
||||
// ── the settings form is htmx's ─────────────────────────────────────────
|
||||
const settings = root().querySelector('form[hx-post="/api/v3/config/main"]');
|
||||
ok('submitting the settings form is not prevented', settings && !submit(settings).defaultPrevented);
|
||||
ok('...and makes no request of the page\'s own', requests.length === 0, requests.length);
|
||||
|
||||
if (security) {
|
||||
// ── create a token ────────────────────────────────────────────────────
|
||||
const before = rows().length;
|
||||
const create = form('create-token');
|
||||
create.querySelector('[name="name"]').value = HOSTILE;
|
||||
create.setAttribute('data-dirty', '');
|
||||
ok('Create token is handled by the page', submit(create).defaultPrevented);
|
||||
await tick(20);
|
||||
ok('one POST to /api/v3/auth/tokens', calls('POST', '/api/v3/auth/tokens').length === 1, requests);
|
||||
ok('...with the name typed', calls('POST', '/api/v3/auth/tokens')[0].body.name === HOSTILE);
|
||||
ok('a row is added', rows().length === before + 1, rows().length);
|
||||
ok('the hostile token name is shown as text', root().querySelector('#web-login-tokens').textContent.includes(HOSTILE));
|
||||
ok('...and created no element', !root().querySelector('#web-login-tokens img') && !window.pwned);
|
||||
ok('the "No tokens yet" line is gone', !root().querySelector('#web-login-tokens [data-empty]'));
|
||||
ok('the token is shown once', $('web-login-new-token-value').textContent === 'lmx_tok1'
|
||||
&& !$('web-login-new-token').classList.contains('hidden'));
|
||||
ok('the form is clean again (no "Leave site?")', !create.hasAttribute('data-dirty'));
|
||||
ok('one success notification', lastNote()[1] === 'success' && /Token created/.test(lastNote()[0]), notes);
|
||||
|
||||
// ── copy it (plain http: not a secure context, so it is selected) ────
|
||||
root().querySelector('button[data-action="copy-token"]').click();
|
||||
ok('Copy selects the token where the clipboard API is unavailable',
|
||||
window.getSelection().toString() === 'lmx_tok1' && /Selected/.test(lastNote()[0]), lastNote());
|
||||
|
||||
// ── revoke it (the row drawn by the page, so delegation covers it) ───
|
||||
confirmAnswer = false;
|
||||
const added = rows()[rows().length - 1];
|
||||
added.querySelector('button[data-action="revoke-token"]').click();
|
||||
await tick(20);
|
||||
ok('a cancelled Revoke sends nothing', calls('DELETE', '/api/v3/auth/tokens/').length === 0);
|
||||
ok('...after asking with the token name as written', confirms.length === 1 && confirms[0].includes(HOSTILE), confirms);
|
||||
confirmAnswer = true;
|
||||
added.querySelector('button[data-action="revoke-token"]').click();
|
||||
await tick(20);
|
||||
ok('Revoke sends one DELETE for that token',
|
||||
calls('DELETE', '/api/v3/auth/tokens/').length === 1 && calls('DELETE', '/api/v3/auth/tokens/')[0].url === '/api/v3/auth/tokens/tok1',
|
||||
calls('DELETE', '/api/v3/auth/tokens/'));
|
||||
ok('...and removes its row', rows().length === before, rows().length);
|
||||
|
||||
// ── the password ──────────────────────────────────────────────────────
|
||||
const pw = form('set-password');
|
||||
pw.querySelector('[name="new_password"]').value = 'correct horse battery';
|
||||
pw.querySelector('[name="confirm_password"]').value = 'correct horse batterY';
|
||||
submit(pw);
|
||||
await tick(20);
|
||||
ok('mismatched passwords are never sent', calls('POST', '/api/v3/auth/password').length === 0);
|
||||
ok('...and say so', lastNote()[1] === 'error' && /do not match/.test(lastNote()[0]), lastNote());
|
||||
pw.querySelector('[name="confirm_password"]').value = 'correct horse battery';
|
||||
submit(pw);
|
||||
await tick(20);
|
||||
const sent = calls('POST', '/api/v3/auth/password');
|
||||
ok('a matching password is sent once', sent.length === 1, sent.length);
|
||||
ok('...with the current password only when the form has one',
|
||||
sent[0] && sent[0].body.new_password === 'correct horse battery'
|
||||
&& (('current_password' in sent[0].body) === !!pw.querySelector('[name="current_password"]')), sent[0]);
|
||||
ok('...and the section is reloaded once', reloads.length === 1 && reloads[0][1] === '/v3/partials/general'
|
||||
&& reloads[0][2] === '#general-content', reloads);
|
||||
|
||||
// ── refused, network failure, login redirect ──────────────────────────
|
||||
mode = 'refuse';
|
||||
submit(form('create-token'));
|
||||
await tick(20);
|
||||
ok('a refused request shows the server message', lastNote()[1] === 'error' && lastNote()[0] === 'Give the token a name.', lastNote());
|
||||
mode = 'network';
|
||||
submit(form('create-token'));
|
||||
await tick(20);
|
||||
ok('a network failure says the request failed', lastNote()[1] === 'error' && /^Request failed: /.test(lastNote()[0]), lastNote());
|
||||
mode = 'login';
|
||||
const quiet = notes.length;
|
||||
submit(form('create-token'));
|
||||
await tick(20);
|
||||
ok('the login redirect shows nothing (the page is leaving)', notes.length === quiet, notes.slice(quiet));
|
||||
|
||||
// ── a write survives a swap ───────────────────────────────────────────
|
||||
mode = 'hang';
|
||||
await swap();
|
||||
const rowsBefore = rows().length;
|
||||
form('create-token').querySelector('[name="name"]').value = 'Late';
|
||||
submit(form('create-token'));
|
||||
await tick(5);
|
||||
await swap();
|
||||
pending.shift()();
|
||||
await tick(20);
|
||||
ok('a token created before a swap is still reported', lastNote()[1] === 'success', lastNote());
|
||||
ok('...and draws nothing into the new page', rows().length === rowsBefore
|
||||
&& $('web-login-new-token').classList.contains('hidden'), rows().length);
|
||||
mode = 'ok';
|
||||
|
||||
// ── window.webLogin ────────────────────────────────────────────────────
|
||||
const viaAlias = calls('POST', '/api/v3/auth/tokens').length;
|
||||
form('create-token').querySelector('[name="name"]').value = 'Alias';
|
||||
await generalPage.webLogin.createToken(form('create-token'));
|
||||
ok('webLogin.createToken(form) creates one token', calls('POST', '/api/v3/auth/tokens').length === viaAlias + 1);
|
||||
ok('webLogin has the five old methods',
|
||||
['setPassword', 'disable', 'createToken', 'copyToken', 'revoke'].every(m => typeof generalPage.webLogin[m] === 'function'));
|
||||
}
|
||||
|
||||
// ── the widget loads late ───────────────────────────────────────────────
|
||||
delete window.LEDMatrixWidgets;
|
||||
await swap();
|
||||
ok('nothing drawn while the widget is missing', timezoneWidgets() === 0, timezoneWidgets());
|
||||
window.LEDMatrixWidgets = widgets;
|
||||
await tick(150);
|
||||
ok('drawn once the widget arrives', timezoneWidgets() === 1, timezoneWidgets());
|
||||
|
||||
delete window.LEDMatrixWidgets;
|
||||
await swap();
|
||||
const kept = root();
|
||||
await swap('<p>another tab</p>');
|
||||
window.LEDMatrixWidgets = widgets;
|
||||
await tick(250);
|
||||
ok('a page swapped away while waiting draws nothing', kept.querySelectorAll('.timezone-selector-widget').length === 0);
|
||||
ok('nothing left mounted', registry.list().length === 0, registry.list().length);
|
||||
|
||||
ok('no console errors', logged.length === 0, logged);
|
||||
ok('no DOM errors', errs.length === 0, errs);
|
||||
|
||||
console.log(`\n${pass} passed, ${fail} failed`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
})().catch(e => { console.error(e); process.exit(1); });
|
||||
@@ -1,167 +0,0 @@
|
||||
// The Schedule tab as a page module (static/v3/js/pages/schedule.js), in a
|
||||
// real DOM (jsdom) with the real server-rendered partial and the real
|
||||
// widget registry and schedule-picker widget. Built like test_cache_page.js:
|
||||
//
|
||||
// * the partial ships no <script> and no inline handlers; its root is
|
||||
// data-page="schedule" and carries both saved schedules as JSON
|
||||
// * both pickers are drawn once per swap-in, from the saved config, however
|
||||
// many swaps came first
|
||||
// * each form's save is reported in exactly one notification (the forms
|
||||
// are marked data-reports-result so app.js stays quiet)
|
||||
// * the dim brightness label follows the slider
|
||||
// * a widget that loads late is waited for, and a page swapped away while
|
||||
// waiting draws nothing
|
||||
// * the old globals' entry points still work
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { pathToFileURL } = require('url');
|
||||
const { JSDOM, VirtualConsole } = require('jsdom');
|
||||
|
||||
const BASE = process.env.BASE || 'http://localhost:5000';
|
||||
const JS = path.resolve(__dirname, '../../../web_interface/static/v3/js');
|
||||
const get = p => new Promise((res, rej) =>
|
||||
http.get(BASE + p, r => { let d = ''; r.on('data', c => d += c); r.on('end', () => res(d)); }).on('error', rej));
|
||||
const load = f => import(pathToFileURL(path.join(JS, f)).href);
|
||||
const tick = ms => new Promise(r => setTimeout(r, ms || 0));
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
const ok = (l, c, x) => c ? (pass++, console.log(' ok ' + l))
|
||||
: (fail++, console.log(' FAIL ' + l + (x !== undefined ? ' -> ' + JSON.stringify(x).slice(0, 300) : '')));
|
||||
|
||||
(async () => {
|
||||
const partial = await get('/partials/schedule');
|
||||
const { createRegistry } = await load('core/registry.js');
|
||||
const schedulePage = await load('pages/schedule.js');
|
||||
|
||||
console.log('\n── Schedule tab: page module (real DOM) ──');
|
||||
ok('the partial ships no inline script', !/<script/i.test(partial));
|
||||
ok('the partial has no inline handlers', !/\son(click|input|change|submit)=/i.test(partial));
|
||||
ok('the forms carry no hx-on handler', !/hx-on/i.test(partial));
|
||||
ok('the partial root is data-page="schedule"', /data-page="schedule"/.test(partial));
|
||||
ok('both forms are marked data-reports-result',
|
||||
(partial.match(/<form[^>]*data-reports-result/g) || []).length === 2);
|
||||
|
||||
const errs = [];
|
||||
const logged = [];
|
||||
const vc = new VirtualConsole();
|
||||
vc.on('jsdomError', e => errs.push(String(e.message || e).split('\n')[0]));
|
||||
vc.on('error', (...a) => logged.push(a.join(' ')));
|
||||
const dom = new JSDOM(`<!doctype html><html><body><div id="schedule-content">${partial}</div></body></html>`,
|
||||
{ url: BASE + '/', virtualConsole: vc, runScripts: 'outside-only' });
|
||||
const { window } = dom;
|
||||
const doc = window.document;
|
||||
const panel = doc.getElementById('schedule-content');
|
||||
// base.html defines LEDEscape (app-early.js) before any tab loads; the
|
||||
// widget escapes with it.
|
||||
require('../led_escape').install(window);
|
||||
window.eval(fs.readFileSync(path.join(JS, 'widgets/registry.js'), 'utf8'));
|
||||
window.eval(fs.readFileSync(path.join(JS, 'widgets/schedule-picker.js'), 'utf8'));
|
||||
const widgets = window.LEDMatrixWidgets;
|
||||
ok('the widget scripts register schedule-picker', !!(widgets && widgets.get('schedule-picker')));
|
||||
|
||||
const notes = [];
|
||||
const registry = createRegistry({
|
||||
document: doc,
|
||||
context: { api: null, notify: (m, t) => notes.push([m, t]) },
|
||||
});
|
||||
registry.register('schedule', schedulePage);
|
||||
|
||||
const $ = id => doc.getElementById(id);
|
||||
const root = () => doc.querySelector('[data-page="schedule"]');
|
||||
const saved = key => JSON.parse(root().dataset[key]);
|
||||
const drawn = () => ['schedule_picker_container', 'dim_schedule_picker_container']
|
||||
.map(id => $(id).querySelectorAll('.schedule-picker-widget').length);
|
||||
async function swap(html) {
|
||||
panel.dispatchEvent(new window.CustomEvent('htmx:beforeSwap', { bubbles: true, detail: { target: panel, shouldSwap: true } }));
|
||||
panel.innerHTML = html === undefined ? partial : html;
|
||||
panel.dispatchEvent(new window.CustomEvent('htmx:afterSwap', { bubbles: true, detail: { target: panel } }));
|
||||
await tick(20);
|
||||
}
|
||||
function answer(formId, xhr) {
|
||||
$(formId).dispatchEvent(new window.CustomEvent('htmx:afterRequest', {
|
||||
bubbles: true, detail: { xhr, elt: $(formId), successful: xhr.status < 300 } }));
|
||||
}
|
||||
|
||||
await registry.start();
|
||||
await tick(20);
|
||||
|
||||
// ── first load ──────────────────────────────────────────────────────────
|
||||
ok('both pickers drawn once', drawn().join() === '1,1', drawn());
|
||||
const schedule = saved('scheduleConfig');
|
||||
const dim = saved('dimScheduleConfig');
|
||||
ok('the saved config reaches the page as JSON', schedule && typeof schedule === 'object' && dim && typeof dim === 'object');
|
||||
const mode = cfg => cfg.mode ? cfg.mode.replace('-', '_') : (cfg.days ? 'per_day' : 'global');
|
||||
ok('the display picker shows the saved mode', $('schedule_mode_value').value === mode(schedule),
|
||||
[$('schedule_mode_value').value, schedule.mode]);
|
||||
ok('the dim picker shows the saved mode', $('dim_schedule_mode_value').value === mode(dim),
|
||||
[$('dim_schedule_mode_value').value, dim.mode]);
|
||||
ok('the dim picker shows the saved start time',
|
||||
$('dim_schedule_start_time_hidden').value === (dim.start_time || '20:00'), $('dim_schedule_start_time_hidden').value);
|
||||
|
||||
// ── repeated swaps ──────────────────────────────────────────────────────
|
||||
for (let i = 0; i < 5; i++) await swap();
|
||||
ok('one mounted page after five swaps', registry.list().length === 1, registry.list().length);
|
||||
ok('each picker drawn once, not stacked', drawn().join() === '1,1', drawn());
|
||||
|
||||
const okXhr = body => ({ status: 200, responseText: JSON.stringify(body) });
|
||||
answer('schedule_form', okXhr({ status: 'success', message: 'Schedule configuration saved successfully' }));
|
||||
ok('a schedule save is reported once', notes.length === 1, notes);
|
||||
ok('...with the server message and status',
|
||||
notes[0] && notes[0][0] === 'Schedule configuration saved successfully' && notes[0][1] === 'success', notes[0]);
|
||||
answer('dim_schedule_form', okXhr({ status: 'success' }));
|
||||
ok('a dim schedule save without a message says so',
|
||||
notes.length === 2 && notes[1][0] === 'Dim schedule settings saved' && notes[1][1] === 'success', notes[1]);
|
||||
answer('schedule_form', { status: 400, responseText: JSON.stringify({ status: 'error' }) });
|
||||
ok('a refused save without a message says so',
|
||||
notes.length === 3 && notes[2][0] === 'Error saving schedule' && notes[2][1] === 'error', notes[2]);
|
||||
answer('dim_schedule_form', { status: 502, responseText: '<html>Bad gateway</html>' });
|
||||
ok('a non-JSON answer is an error',
|
||||
notes.length === 4 && notes[3][0] === 'Invalid response from server' && notes[3][1] === 'error', notes[3]);
|
||||
answer('schedule_form', { status: 200, responseText: 'null' });
|
||||
ok('a JSON null answer is an error, not a crash',
|
||||
notes.length === 5 && notes[4][1] === 'error', notes[4]);
|
||||
// An htmx request from elsewhere on the page (outside both forms) is not a save.
|
||||
root().querySelector('.settings-filter').dispatchEvent(new window.CustomEvent('htmx:afterRequest', {
|
||||
bubbles: true, detail: { xhr: okXhr({ status: 'success', message: 'x' }) } }));
|
||||
ok('a request from outside the two forms reports nothing', notes.length === 5, notes.length);
|
||||
|
||||
// ── the brightness label ────────────────────────────────────────────────
|
||||
$('dim_brightness').value = '42';
|
||||
$('dim_brightness').dispatchEvent(new window.Event('input', { bubbles: true }));
|
||||
ok('the dim brightness label follows the slider', $('dim_brightness_display').textContent === '42%',
|
||||
$('dim_brightness_display').textContent);
|
||||
|
||||
// ── the widget loads late ───────────────────────────────────────────────
|
||||
delete window.LEDMatrixWidgets;
|
||||
await swap();
|
||||
ok('nothing drawn while the widget is missing', drawn().join() === '0,0', drawn());
|
||||
window.LEDMatrixWidgets = widgets;
|
||||
await tick(150);
|
||||
ok('drawn once the widget arrives', drawn().join() === '1,1', drawn());
|
||||
|
||||
delete window.LEDMatrixWidgets;
|
||||
await swap();
|
||||
const kept = root();
|
||||
await swap('<p>another tab</p>');
|
||||
window.LEDMatrixWidgets = widgets;
|
||||
await tick(250);
|
||||
ok('a page swapped away while waiting draws nothing',
|
||||
kept.querySelectorAll('.schedule-picker-widget').length === 0);
|
||||
ok('nothing left mounted', registry.list().length === 0, registry.list().length);
|
||||
|
||||
// ── the old globals ─────────────────────────────────────────────────────
|
||||
await swap();
|
||||
const before = notes.length;
|
||||
schedulePage.handleScheduleResponse({ target: $('schedule_form'), detail: { xhr: okXhr({ status: 'success' }) } });
|
||||
schedulePage.handleDimScheduleResponse({ target: $('dim_schedule_form'), detail: { xhr: okXhr({ status: 'success' }) } });
|
||||
ok('handleScheduleResponse(event) and handleDimScheduleResponse(event) report once each',
|
||||
notes.length === before + 2 && notes[before][0] === 'Schedule settings saved'
|
||||
&& notes[before + 1][0] === 'Dim schedule settings saved', notes.slice(before));
|
||||
|
||||
ok('no console errors', logged.length === 0, logged);
|
||||
ok('no DOM errors', errs.length === 0, errs);
|
||||
|
||||
console.log(`\n${pass} passed, ${fail} failed`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
})().catch(e => { console.error(e); process.exit(1); });
|
||||
@@ -98,50 +98,8 @@ const get = p => new Promise((res, rej) =>
|
||||
window.saveMqttBridge();
|
||||
await tick(150);
|
||||
ok('save includes password once typed', sent && sent.mqtt_password === 'typed-secret');
|
||||
|
||||
// A password with TLS off is refused unless allow_insecure_mqtt is set
|
||||
// (CWE-319, api_v3/misc.py). The form has to be able to send it, or a
|
||||
// plain-LAN broker with a password can never be saved from here.
|
||||
const allowRow = () => $('mqtt-allow-insecure-row');
|
||||
const shown = el => !!el && !el.classList.contains('hidden');
|
||||
ok('allow-without-TLS control rendered', !!$('mqtt-allow-insecure'));
|
||||
ok('allow-without-TLS starts as saved',
|
||||
!!$('mqtt-allow-insecure') && $('mqtt-allow-insecure').checked === !!bridge.data.config.allow_insecure_mqtt);
|
||||
ok('allow-without-TLS shown only while TLS is off',
|
||||
shown(allowRow()) === !$('mqtt-tls').checked);
|
||||
$('mqtt-tls').checked = true;
|
||||
$('mqtt-tls').dispatchEvent(new window.Event('change', { bubbles: true }));
|
||||
ok('ticking TLS hides it', !shown(allowRow()));
|
||||
$('mqtt-tls').checked = false;
|
||||
$('mqtt-tls').dispatchEvent(new window.Event('change', { bubbles: true }));
|
||||
ok('unticking TLS shows it again', shown(allowRow()));
|
||||
|
||||
const setAllow = v => { if ($('mqtt-allow-insecure')) $('mqtt-allow-insecure').checked = v; };
|
||||
setAllow(false);
|
||||
window.saveMqttBridge();
|
||||
await tick(150);
|
||||
ok('save sends allow_insecure_mqtt false when unticked', !!sent && sent.allow_insecure_mqtt === false, sent);
|
||||
setAllow(true);
|
||||
window.saveMqttBridge();
|
||||
await tick(150);
|
||||
ok('save sends allow_insecure_mqtt true when ticked', !!sent && sent.allow_insecure_mqtt === true, sent);
|
||||
onPut = null;
|
||||
|
||||
// Prefilled from the saved settings, and hidden while TLS is saved on.
|
||||
bridgePayload = JSON.parse(JSON.stringify(bridge));
|
||||
bridgePayload.data.config.allow_insecure_mqtt = true;
|
||||
bridgePayload.data.config.mqtt_tls = false;
|
||||
window.loadMqttBridge();
|
||||
await tick(150);
|
||||
ok('a saved opt-in is prefilled', !!$('mqtt-allow-insecure') && $('mqtt-allow-insecure').checked === true);
|
||||
bridgePayload.data.config.mqtt_tls = true;
|
||||
window.loadMqttBridge();
|
||||
await tick(150);
|
||||
ok('hidden on load when TLS is saved on', !shown(allowRow()));
|
||||
bridgePayload = bridge;
|
||||
window.loadMqttBridge();
|
||||
await tick(150);
|
||||
|
||||
// ── Pixlet editor, idle ────────────────────────────────────────────────
|
||||
const appIds = (apps.data.apps || []).map(a => a.id);
|
||||
ok('editor lists the apps on disk',
|
||||
|
||||
+2
-7
@@ -17,7 +17,6 @@ const fs = require('fs');
|
||||
|
||||
const BASE = process.env.BASE || 'http://localhost:5000';
|
||||
const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.js',
|
||||
'unit/test_plugin_order_list.js',
|
||||
'unit/test_html_escaping.js', 'unit/test_style_editor_element_keys.js',
|
||||
'unit/test_style_editor_layout_leaf_columns.js',
|
||||
'unit/test_style_editor_layout_leaf_collision.js',
|
||||
@@ -29,15 +28,11 @@ const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.js',
|
||||
'unit/test_inline_handler_escaping.js',
|
||||
'unit/test_plugin_action_delegation.js', 'unit/test_file_upload_widget.js',
|
||||
'unit/test_store_registry_fields.js', 'unit/test_restart_banner.js',
|
||||
'unit/test_page_registry.js', 'unit/test_core_modules.js',
|
||||
'unit/test_overview_reconciliation_poll.js',
|
||||
'unit/test_display_partial_ids.js',
|
||||
'unit/test_general_web_login_token.js'];
|
||||
'unit/test_page_registry.js', 'unit/test_core_modules.js'];
|
||||
const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js',
|
||||
'dom/test_tools_sections.js', 'dom/test_cache_page.js',
|
||||
'dom/test_durations_page.js', 'dom/test_operation_history_page.js',
|
||||
'dom/test_raw_json_page.js', 'dom/test_backup_restore_page.js',
|
||||
'dom/test_schedule_page.js', 'dom/test_general_page.js'];
|
||||
'dom/test_raw_json_page.js', 'dom/test_backup_restore_page.js'];
|
||||
|
||||
function reachable(url) {
|
||||
return new Promise(res => {
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
// The Display tab's inline script must only look up elements the partial
|
||||
// renders.
|
||||
//
|
||||
// Its brightness slider handler also wrote to #brightness-display, a "LED
|
||||
// brightness: N%" line that #387 removed from partials/display.html. The
|
||||
// lookup returned null, so every movement of the slider threw a TypeError.
|
||||
// This checks every literal getElementById() in the partial's inline scripts
|
||||
// against the ids its markup renders, and runs the shipped script in a vm
|
||||
// with a fake DOM (null for an id the markup lacks, as in a browser) to move
|
||||
// the slider.
|
||||
//
|
||||
// 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/display.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) : '')));
|
||||
|
||||
const html = fs.readFileSync(PARTIAL, 'utf8');
|
||||
const blocks = [...html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/gi)];
|
||||
const scripts = blocks.map(m => m[1]);
|
||||
// The markup is what lies between the script blocks (sliced around them, not
|
||||
// a replace(), which CodeQL reads as an incomplete HTML sanitizer).
|
||||
let markup = '';
|
||||
let from = 0;
|
||||
for (const m of blocks) {
|
||||
markup += html.slice(from, m.index);
|
||||
from = m.index + m[0].length;
|
||||
}
|
||||
markup += html.slice(from);
|
||||
const rendered = new Set([...markup.matchAll(/\bid="([^"{}]+)"/g)].map(m => m[1]));
|
||||
|
||||
console.log('\n── Display partial: element lookups ──');
|
||||
|
||||
// 1. Static: every literal lookup names an id the partial renders.
|
||||
const lookups = scripts.flatMap(s => [...s.matchAll(/getElementById\('([^']+)'\)/g)].map(m => m[1]));
|
||||
const missing = [...new Set(lookups.filter(id => !rendered.has(id)))];
|
||||
ok('the inline scripts look elements up', lookups.length > 0, lookups.length);
|
||||
ok('every looked-up id is rendered by the partial', missing.length === 0, missing);
|
||||
|
||||
// 2. Behaviour: moving the brightness slider updates its label and throws nothing.
|
||||
function fakeElement(id) {
|
||||
const listeners = {};
|
||||
const classes = new Set();
|
||||
return {
|
||||
id, value: '', textContent: '', min: '', max: '', checked: false,
|
||||
style: {}, dataset: {}, className: '',
|
||||
classList: {
|
||||
add: c => classes.add(c), remove: c => classes.delete(c),
|
||||
toggle: (c, on) => (on === undefined ? (classes.has(c) ? classes.delete(c) : classes.add(c)) : (on ? classes.add(c) : classes.delete(c))),
|
||||
contains: c => classes.has(c),
|
||||
},
|
||||
addEventListener: (type, fn) => { (listeners[type] ||= []).push(fn); },
|
||||
dispatchEvent() { return true; },
|
||||
appendChild() {},
|
||||
listeners,
|
||||
};
|
||||
}
|
||||
|
||||
const main = scripts.find(s => s.includes("getElementById('brightness')"));
|
||||
ok('found the script that wires the brightness slider', !!main);
|
||||
if (main) {
|
||||
const elements = new Map();
|
||||
const document = {
|
||||
readyState: 'complete',
|
||||
hidden: false,
|
||||
getElementById: id => {
|
||||
if (!rendered.has(id)) return null;
|
||||
if (!elements.has(id)) elements.set(id, fakeElement(id));
|
||||
return elements.get(id);
|
||||
},
|
||||
createElement: () => fakeElement(''),
|
||||
createTextNode: () => ({}),
|
||||
addEventListener() {},
|
||||
};
|
||||
const window = {
|
||||
LEDEscape: { html: v => String(v), attr: v => String(v) },
|
||||
LEDVisibility: { onActive() {} },
|
||||
};
|
||||
const context = {
|
||||
window, document, console, URLSearchParams,
|
||||
fetch: () => new Promise(() => {}),
|
||||
setTimeout: () => 0, clearTimeout() {}, setInterval: () => 0, clearInterval() {},
|
||||
};
|
||||
vm.createContext(context);
|
||||
let loadError = null;
|
||||
try { vm.runInContext(main, context); } catch (e) { loadError = e; }
|
||||
ok('the script loads', !loadError, loadError && String(loadError));
|
||||
|
||||
const slider = elements.get('brightness');
|
||||
const handlers = (slider && slider.listeners.input) || [];
|
||||
ok('the slider has an input handler', handlers.length > 0);
|
||||
let thrown = null;
|
||||
slider.value = '42';
|
||||
try { handlers.forEach(fn => fn.call(slider, { target: slider })); } catch (e) { thrown = e; }
|
||||
ok('moving the slider throws nothing', !thrown, thrown && String(thrown));
|
||||
ok('...and shows the new value', elements.get('brightness-value').textContent === '42',
|
||||
elements.get('brightness-value').textContent);
|
||||
}
|
||||
|
||||
console.log(`\n${pass} passed, ${fail} failed\n`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
@@ -1,94 +0,0 @@
|
||||
// Creating an API token on the General tab must leave its form clean.
|
||||
//
|
||||
// app.js marks a form data-dirty on any input in it and clears the mark only
|
||||
// after a successful htmx request; its beforeunload handler then asks "Leave
|
||||
// site?" while any visible form is still dirty. The token form posts with
|
||||
// fetch (createToken in static/v3/js/pages/general.js), so after a token was
|
||||
// created the form stayed dirty and reloading the page while the General tab
|
||||
// was open prompted about changes that had been saved.
|
||||
//
|
||||
// Imports the shipped page module and runs createToken with a fake fetch and
|
||||
// DOM -- no jsdom and no server needed.
|
||||
|
||||
const path = require('path');
|
||||
const { pathToFileURL } = require('url');
|
||||
|
||||
const JS = path.resolve(__dirname, '../../../web_interface/static/v3/js');
|
||||
const load = f => import(pathToFileURL(path.join(JS, f)).href);
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
const ok = (label, cond, extra) => cond
|
||||
? (pass++, console.log(' ok ' + label))
|
||||
: (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' ' + JSON.stringify(extra) : '')));
|
||||
|
||||
function el() {
|
||||
const classes = new Set(['hidden']);
|
||||
return {
|
||||
textContent: '', dataset: {}, style: {}, className: '',
|
||||
classList: { add: c => classes.add(c), remove: c => classes.delete(c), contains: c => classes.has(c) },
|
||||
appendChild() {}, addEventListener() {}, querySelector: () => null,
|
||||
};
|
||||
}
|
||||
|
||||
async function setup(answer) {
|
||||
const { createApi } = await load('core/api.js');
|
||||
const general = await load('pages/general.js');
|
||||
const elements = {
|
||||
'#web-login-tokens': el(),
|
||||
'#web-login-new-token-value': el(),
|
||||
'#web-login-new-token': el(),
|
||||
};
|
||||
const notes = [];
|
||||
const doc = { createElement: () => el(), defaultView: { confirm: () => true } };
|
||||
const root = { ownerDocument: doc, querySelector: sel => elements[sel] || null, querySelectorAll: () => [] };
|
||||
const fetch = () => Promise.resolve({
|
||||
ok: answer.ok, status: answer.ok ? 200 : 400,
|
||||
headers: { get: () => null },
|
||||
text: () => Promise.resolve(JSON.stringify(answer.body)),
|
||||
});
|
||||
const ctx = {
|
||||
root, state: {}, signal: { aborted: false },
|
||||
api: createApi({ fetch }),
|
||||
notify: (m, t) => notes.push([m, t]),
|
||||
};
|
||||
return { general, ctx, elements, notes };
|
||||
}
|
||||
|
||||
function dirtyForm() {
|
||||
const attrs = new Map([['data-dirty', '']]);
|
||||
return {
|
||||
querySelector: sel => (sel === '[name="name"]' ? { value: 'Home Assistant' } : null),
|
||||
reset() {},
|
||||
hasAttribute: name => attrs.has(name),
|
||||
setAttribute: (name, value) => attrs.set(name, String(value)),
|
||||
removeAttribute: name => attrs.delete(name),
|
||||
};
|
||||
}
|
||||
|
||||
(async () => {
|
||||
console.log('\n── General tab: API token form ──');
|
||||
|
||||
{
|
||||
const t = await setup({ ok: true, body: {
|
||||
status: 'success', message: 'Token created',
|
||||
data: { token: 'lmx_secret', record: { id: 't1', name: 'Home Assistant', prefix: 'lmx_sec' } },
|
||||
} });
|
||||
const form = dirtyForm();
|
||||
await t.general.createToken(t.ctx, form);
|
||||
ok('the new token is shown', t.elements['#web-login-new-token-value'].textContent === 'lmx_secret');
|
||||
ok('a created token leaves the form clean (no "Leave site?" on reload)',
|
||||
!form.hasAttribute('data-dirty'));
|
||||
}
|
||||
|
||||
{
|
||||
const t = await setup({ ok: false, body: { status: 'error', message: 'Name is required' } });
|
||||
const form = dirtyForm();
|
||||
await t.general.createToken(t.ctx, form);
|
||||
ok('a refused request reports the error', t.notes.some(([m, type]) => type === 'error' && /Name is required/.test(m)),
|
||||
t.notes);
|
||||
ok('...and keeps the form dirty: nothing was saved', form.hasAttribute('data-dirty'));
|
||||
}
|
||||
|
||||
console.log(`\n${pass} passed, ${fail} failed\n`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
})().catch(e => { console.log('HARNESS ERROR: ' + e.stack); process.exit(1); });
|
||||
@@ -1,137 +0,0 @@
|
||||
// The Overview's "Plugin Config Warning" poll must end.
|
||||
//
|
||||
// The banner script in partials/overview.html asks
|
||||
// /api/v3/plugins/reconciliation-status every 2 s until startup reconciliation
|
||||
// says it is done. The route answers done: false whenever its status file is
|
||||
// missing -- reconciliation raised before writing it, or /tmp was cleaned
|
||||
// under a long-running web service -- so the poll used to run every 2 s for
|
||||
// as long as the page stayed open, on every tab. It now gives up after a
|
||||
// bounded number of tries and runs only while the Overview is on screen
|
||||
// (LEDVisibility, like the other partials' pollers).
|
||||
//
|
||||
// Runs the shipped inline script in a vm with fake timers, fetch and DOM --
|
||||
// no jsdom and no server needed.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
const PARTIAL = path.resolve(__dirname, '../../../web_interface/templates/v3/partials/overview.html');
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
const ok = (label, cond, extra) => cond
|
||||
? (pass++, console.log(' ok ' + label))
|
||||
: (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' ' + JSON.stringify(extra) : '')));
|
||||
|
||||
function bannerScript() {
|
||||
const html = fs.readFileSync(PARTIAL, 'utf8');
|
||||
const scripts = [...html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/gi)].map(m => m[1]);
|
||||
const found = scripts.find(s => s.includes('ledmatrix-recon-dismissed'));
|
||||
if (!found) throw new Error('reconciliation banner script not found in overview.html');
|
||||
return found;
|
||||
}
|
||||
|
||||
const flush = async () => { for (let i = 0; i < 10; i++) await new Promise(r => setImmediate(r)); };
|
||||
|
||||
function load({ payload, visibility = true }) {
|
||||
const timers = new Map();
|
||||
let nextId = 1;
|
||||
const calls = [];
|
||||
const banner = { style: { setProperty() {} }, dataset: {} };
|
||||
const text = { textContent: '' };
|
||||
const registrations = [];
|
||||
const window = {};
|
||||
if (visibility) {
|
||||
window.LEDVisibility = {
|
||||
onActive(tab, start, stop, key) { registrations.push({ tab, start, stop, key }); start(); },
|
||||
};
|
||||
}
|
||||
const context = {
|
||||
window,
|
||||
document: {
|
||||
getElementById: id => ({ 'reconciliation-banner': banner, 'reconciliation-banner-text': text })[id] || null,
|
||||
},
|
||||
sessionStorage: { getItem: () => null, setItem() {} },
|
||||
fetch: (url) => {
|
||||
calls.push(url);
|
||||
return Promise.resolve({ json: () => Promise.resolve(payload()) });
|
||||
},
|
||||
setTimeout: (fn) => { const id = nextId++; timers.set(id, fn); return id; },
|
||||
clearTimeout: (id) => { timers.delete(id); },
|
||||
};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(bannerScript(), context);
|
||||
const fireTimers = async () => {
|
||||
const due = [...timers.entries()];
|
||||
timers.clear();
|
||||
due.forEach(([, fn]) => fn());
|
||||
await flush();
|
||||
};
|
||||
return { calls, timers, registrations, banner, text, window, fireTimers };
|
||||
}
|
||||
|
||||
(async () => {
|
||||
console.log('\n── Overview reconciliation poll ──');
|
||||
|
||||
// 1. A status file that never says done: the poll stops on its own.
|
||||
{
|
||||
const t = load({ payload: () => ({ status: 'success', data: { done: false, unresolved: [] } }) });
|
||||
await flush();
|
||||
for (let i = 0; i < 200; i++) await t.fireTimers();
|
||||
ok('a status that never turns done stops being polled', t.timers.size === 0,
|
||||
{ pending: t.timers.size, requests: t.calls.length });
|
||||
ok('...after a bounded number of requests (at most 30, a minute at 2 s)',
|
||||
t.calls.length > 1 && t.calls.length <= 30, t.calls.length);
|
||||
}
|
||||
|
||||
// 2. Runs only while the Overview is on screen.
|
||||
{
|
||||
const t = load({ payload: () => ({ status: 'success', data: { done: false, unresolved: [] } }) });
|
||||
await flush();
|
||||
const reg = t.registrations[0];
|
||||
ok('registers with LEDVisibility for the overview tab', !!reg && reg.tab === 'overview', reg && reg.tab);
|
||||
ok('under its own key, so it does not replace another overview poller',
|
||||
!!reg && !!reg.key && reg.key !== 'overview', reg && reg.key);
|
||||
ok('first request goes out at once', t.calls.length === 1, t.calls.length);
|
||||
if (reg) {
|
||||
reg.stop();
|
||||
ok('leaving the tab cancels the pending retry', t.timers.size === 0, t.timers.size);
|
||||
for (let i = 0; i < 5; i++) await t.fireTimers();
|
||||
ok('no requests while another tab is active', t.calls.length === 1, t.calls.length);
|
||||
reg.start();
|
||||
await flush();
|
||||
ok('coming back asks again at once', t.calls.length === 2, t.calls.length);
|
||||
ok('...and keeps polling', t.timers.size === 1, t.timers.size);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. A finished reconciliation with findings shows the banner and stops.
|
||||
{
|
||||
let done = false;
|
||||
const t = load({ payload: () => (done
|
||||
? { status: 'success', data: { done: true, unresolved: [{ plugin_id: 'clock', type: 'plugin_missing_on_disk' }] } }
|
||||
: { status: 'success', data: { done: false, unresolved: [] } }) });
|
||||
await flush();
|
||||
await t.fireTimers();
|
||||
done = true;
|
||||
await t.fireTimers();
|
||||
ok('the banner names the finding once reconciliation is done',
|
||||
t.text.textContent.includes('clock'), t.text.textContent);
|
||||
const before = t.calls.length;
|
||||
for (let i = 0; i < 5; i++) await t.fireTimers();
|
||||
ok('no more requests once it is done', t.calls.length === before && t.timers.size === 0,
|
||||
{ before, after: t.calls.length, pending: t.timers.size });
|
||||
}
|
||||
|
||||
// 4. Without LEDVisibility (base.html always has it) it still runs, bounded.
|
||||
{
|
||||
const t = load({ visibility: false, payload: () => ({ status: 'success', data: { done: false } }) });
|
||||
await flush();
|
||||
ok('runs without LEDVisibility', t.calls.length === 1, t.calls.length);
|
||||
for (let i = 0; i < 200; i++) await t.fireTimers();
|
||||
ok('...and is still bounded', t.timers.size === 0 && t.calls.length <= 30, t.calls.length);
|
||||
}
|
||||
|
||||
console.log(`\n${pass} passed, ${fail} failed\n`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
})().catch(e => { console.log('HARNESS ERROR: ' + e.stack); process.exit(1); });
|
||||
@@ -1,189 +0,0 @@
|
||||
// The shared plugin order list (widgets/plugin-order-list.js) keeps what it
|
||||
// does not show.
|
||||
//
|
||||
// It lists enabled plugins only, and rewrites its hidden inputs from those
|
||||
// rows as soon as it has drawn them. A disabled plugin's place in the order
|
||||
// and its Vegas exclusion used to vanish from the inputs on that rewrite, so
|
||||
// any later save of the Display or Rotation & Durations tab stored them
|
||||
// without it: re-enabled, the plugin came back at the end of the rotation and
|
||||
// scrolling in Vegas again. An uninstalled plugin's id is still dropped, as
|
||||
// before, so the lists don't collect ids nothing can show. Runs the shipped
|
||||
// widget in a vm with a minimal fake DOM -- no jsdom and no server needed, so
|
||||
// it runs under test/test_js_unit_suites.py too.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
const WIDGET = path.resolve(__dirname, '../../../web_interface/static/v3/js/widgets/plugin-order-list.js');
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
const ok = (label, cond, extra) => cond
|
||||
? (pass++, console.log(' ok ' + label))
|
||||
: (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' ' + JSON.stringify(extra) : '')));
|
||||
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
||||
|
||||
class FakeElement {
|
||||
constructor(tag) {
|
||||
this.tagName = tag.toUpperCase();
|
||||
this.children = [];
|
||||
this.parent = null;
|
||||
this.dataset = {};
|
||||
this.style = {};
|
||||
this.className = '';
|
||||
this.value = '';
|
||||
this.checked = false;
|
||||
this.listeners = {};
|
||||
this._text = '';
|
||||
}
|
||||
appendChild(child) {
|
||||
if (child.parent) child.parent.children = child.parent.children.filter(c => c !== child);
|
||||
child.parent = this;
|
||||
this.children.push(child);
|
||||
return child;
|
||||
}
|
||||
insertBefore(child, ref) {
|
||||
if (!ref) return this.appendChild(child);
|
||||
if (child.parent) child.parent.children = child.parent.children.filter(c => c !== child);
|
||||
child.parent = this;
|
||||
this.children.splice(this.children.indexOf(ref), 0, child);
|
||||
return child;
|
||||
}
|
||||
get previousElementSibling() {
|
||||
const siblings = this.parent ? this.parent.children : [];
|
||||
return siblings[siblings.indexOf(this) - 1] || null;
|
||||
}
|
||||
get nextElementSibling() {
|
||||
const siblings = this.parent ? this.parent.children : [];
|
||||
const i = siblings.indexOf(this);
|
||||
return i < 0 ? null : siblings[i + 1] || null;
|
||||
}
|
||||
set textContent(value) { this._text = value; this.children = []; }
|
||||
get textContent() { return this._text; }
|
||||
setAttribute() {}
|
||||
focus() {}
|
||||
addEventListener(type, fn) { (this.listeners[type] ||= []).push(fn); }
|
||||
fire(type, event) { (this.listeners[type] || []).forEach(fn => fn.call(this, event || {})); }
|
||||
descendants() { return this.children.flatMap(c => [c, ...c.descendants()]); }
|
||||
querySelectorAll(selector) {
|
||||
const cls = selector.replace(/^\./, '');
|
||||
return this.descendants().filter(e => e.className.split(/\s+/).includes(cls));
|
||||
}
|
||||
querySelector(selector) { return this.querySelectorAll(selector)[0] || null; }
|
||||
}
|
||||
|
||||
/** Run the widget over `plugins` with the given saved inputs; resolves once it has drawn. */
|
||||
async function mount({ plugins, order, excluded, fetchFails }) {
|
||||
const els = {
|
||||
list: new FakeElement('div'),
|
||||
order: Object.assign(new FakeElement('input'), { value: JSON.stringify(order) }),
|
||||
};
|
||||
if (excluded !== undefined) {
|
||||
els.excluded = Object.assign(new FakeElement('input'), { value: JSON.stringify(excluded) });
|
||||
}
|
||||
const context = {
|
||||
// The widget logs a failed list; expected there, so kept off the output.
|
||||
console: fetchFails ? Object.assign({}, console, { error: () => {} }) : console,
|
||||
window: {},
|
||||
document: {
|
||||
getElementById: (id) => els[id] || null,
|
||||
createElement: (tag) => new FakeElement(tag),
|
||||
createTextNode: (text) => new FakeElement('#text'),
|
||||
},
|
||||
fetch: () => (fetchFails ? Promise.reject(new Error('service restarting')) : Promise.resolve({
|
||||
json: () => Promise.resolve({ status: 'success', data: { plugins } }),
|
||||
})),
|
||||
};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(fs.readFileSync(WIDGET, 'utf8'), context);
|
||||
context.window.PluginOrderList.init({
|
||||
containerId: 'list', orderInputId: 'order',
|
||||
excludedInputId: excluded !== undefined ? 'excluded' : undefined,
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
const rows = () => els.list.querySelectorAll('.plugin-order-item');
|
||||
return {
|
||||
rows,
|
||||
rowIds: () => rows().map(r => r.dataset.pluginId),
|
||||
order: () => JSON.parse(els.order.value),
|
||||
excluded: () => JSON.parse(els.excluded.value),
|
||||
row: (id) => rows().find(r => r.dataset.pluginId === id),
|
||||
};
|
||||
}
|
||||
|
||||
const PLUGINS = [
|
||||
{ id: 'weather', name: 'Weather', enabled: true },
|
||||
{ id: 'clock', name: 'Clock', enabled: false },
|
||||
{ id: 'stocks', name: 'Stocks', enabled: true },
|
||||
];
|
||||
|
||||
(async () => {
|
||||
console.log('\nVegas: a disabled plugin keeps its place and its exclusion');
|
||||
{
|
||||
const t = await mount({ plugins: PLUGINS, order: ['weather', 'clock', 'stocks'], excluded: ['clock'] });
|
||||
ok('only enabled plugins get a row', same(t.rowIds(), ['weather', 'stocks']), t.rowIds());
|
||||
ok('drawing the list keeps the disabled plugin in the order, in its place',
|
||||
same(t.order(), ['weather', 'clock', 'stocks']), t.order());
|
||||
ok('drawing the list keeps its exclusion', same(t.excluded(), ['clock']), t.excluded());
|
||||
|
||||
// Move Stocks up: the rows swap, and Clock stays in its saved slot.
|
||||
const up = t.row('stocks').querySelectorAll('.plugin-order-move')[0];
|
||||
up.fire('click');
|
||||
ok('reordering the rows fills the other slots in the new order',
|
||||
same(t.order(), ['stocks', 'clock', 'weather']), t.order());
|
||||
|
||||
const include = t.row('weather').querySelector('.plugin-order-include');
|
||||
include.checked = false;
|
||||
include.fire('change');
|
||||
ok('unchecking a row adds it, and the disabled exclusion stays',
|
||||
same([...t.excluded()].sort(), ['clock', 'weather']), t.excluded());
|
||||
include.checked = true;
|
||||
include.fire('change');
|
||||
ok('checking it again removes only that one', same(t.excluded(), ['clock']), t.excluded());
|
||||
}
|
||||
|
||||
console.log('\nRotation order: the same, without exclusions');
|
||||
{
|
||||
const plugins = [
|
||||
{ id: 'clock', enabled: true },
|
||||
{ id: 'off', enabled: false },
|
||||
{ id: 'weather', enabled: true },
|
||||
{ id: 'new', enabled: true },
|
||||
];
|
||||
const t = await mount({ plugins, order: ['clock', 'off', 'weather'] });
|
||||
ok('the disabled plugin keeps its slot; a plugin not in the saved order goes last',
|
||||
same(t.order(), ['clock', 'off', 'weather', 'new']), t.order());
|
||||
}
|
||||
|
||||
console.log('\nAn uninstalled plugin is dropped; a failed list keeps everything');
|
||||
{
|
||||
const t = await mount({ plugins: PLUGINS, order: ['weather', 'gone', 'clock', 'stocks'],
|
||||
excluded: ['gone', 'clock'] });
|
||||
ok('the disabled plugin is kept and the uninstalled one dropped from the order',
|
||||
same(t.order(), ['weather', 'clock', 'stocks']), t.order());
|
||||
ok('and from the exclusions', same(t.excluded(), ['clock']), t.excluded());
|
||||
}
|
||||
{
|
||||
const t = await mount({ plugins: PLUGINS, order: ['weather', 'gone', 'clock', 'stocks'],
|
||||
excluded: ['gone', 'clock'], fetchFails: true });
|
||||
// No installed list, so nothing can be told apart: no rows, and the
|
||||
// inputs keep what was saved, uninstalled ids included.
|
||||
ok('a failed plugin list draws no rows', t.rowIds().length === 0, t.rowIds());
|
||||
ok('and leaves the saved order as it was',
|
||||
same(t.order(), ['weather', 'gone', 'clock', 'stocks']), t.order());
|
||||
ok('and the saved exclusions', same(t.excluded(), ['gone', 'clock']), t.excluded());
|
||||
}
|
||||
|
||||
console.log('\nOnly what the server would accept is carried over');
|
||||
{
|
||||
const t = await mount({ plugins: PLUGINS, order: ['weather', 7, 'clock', null, 'clock', 'stocks'],
|
||||
excluded: ['clock', 3, 'clock'] });
|
||||
// /config/main refuses a list holding anything but strings, which would
|
||||
// block every later Display save; a repeated id is kept once.
|
||||
ok('non-string and repeated saved ids are dropped from the order',
|
||||
same(t.order(), ['weather', 'clock', 'stocks']), t.order());
|
||||
ok('and from the exclusions', same(t.excluded(), ['clock']), t.excluded());
|
||||
}
|
||||
|
||||
console.log(`\n${pass} passed, ${fail} failed`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
})().catch(e => { console.error(e); process.exit(1); });
|
||||
@@ -19,7 +19,7 @@ const ok = (label, cond, extra) => cond
|
||||
? (pass++, console.log(' ok ' + label))
|
||||
: (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' ' + JSON.stringify(extra) : '')));
|
||||
|
||||
function load(notes) {
|
||||
function load() {
|
||||
const handlers = {};
|
||||
const listen = (target) => (type, fn) => { (handlers[target + ':' + type] ||= []).push(fn); };
|
||||
const banner = { style: { display: 'none' } };
|
||||
@@ -43,18 +43,14 @@ function load(notes) {
|
||||
removeItem: (k) => { delete store[k]; },
|
||||
getItem: (k) => (k in store ? store[k] : null),
|
||||
},
|
||||
showNotification: (m, t) => { if (notes) notes.push([m, t]); },
|
||||
showNotification: () => {},
|
||||
setTimeout: () => 0,
|
||||
};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(fs.readFileSync(path.join(V3, 'app.js'), 'utf8'), context);
|
||||
const afterRequest = (handlers['body:htmx:afterRequest'] || [])[0];
|
||||
// `marks`: the attribute selectors the requesting element (or its form)
|
||||
// matches, for app.js's elt.closest(<selector list>).
|
||||
const fire = ({ status = 200, body, path: reqPath = '/api/v3/anything', reportsItself = false, marks = [] }) => {
|
||||
const elt = {
|
||||
closest: (sel) => (reportsItself || sel.split(',').some(s => marks.includes(s.trim())) ? {} : null),
|
||||
};
|
||||
const fire = ({ status = 200, body, path: reqPath = '/api/v3/anything', reportsItself = false }) => {
|
||||
const elt = { closest: () => (reportsItself ? {} : null) };
|
||||
afterRequest({
|
||||
target: { closest: () => null },
|
||||
detail: {
|
||||
@@ -104,20 +100,5 @@ console.log('\nhtmx after-request follows the flag, not the URL');
|
||||
ok('a flagged answer raises it, even from a form that reports itself', t.banner.style.display === 'block');
|
||||
}
|
||||
|
||||
console.log('\nthe server message toast');
|
||||
{
|
||||
const notes = [];
|
||||
const t = load(notes);
|
||||
const answer = { status: 'success', message: 'Schedule saved' };
|
||||
t.fire({ body: answer });
|
||||
ok('a plain htmx request shows the server message', notes.length === 1 && notes[0][0] === 'Schedule saved', notes);
|
||||
t.fire({ body: answer, marks: ['[hx-on\\:htmx\\:after-request]'] });
|
||||
ok('a form with its own hx-on after-request handler does not', notes.length === 1, notes);
|
||||
// Page modules (js/pages/schedule.js) report a form's save from a listener
|
||||
// and mark the form data-reports-result instead of an hx-on attribute.
|
||||
t.fire({ body: answer, marks: ['[data-reports-result]'] });
|
||||
ok('nor does a form a page module reports for (data-reports-result)', notes.length === 1, notes);
|
||||
}
|
||||
|
||||
console.log(`\n${pass} passed, ${fail} failed`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
|
||||
@@ -29,7 +29,7 @@ def installed(api_v3_module, api_v3_client, tmp_path):
|
||||
api.plugin_catalog.plugins_dir = str(tmp_path) # no manifest on disk
|
||||
api.plugin_catalog.get_all_plugin_info = MagicMock(return_value=[info])
|
||||
api.plugin_catalog.get_plugin_display_modes = MagicMock(return_value=declared_modes)
|
||||
api.plugin_store_manager.get_cached_registry_info = MagicMock(return_value=None)
|
||||
api.plugin_store_manager.get_registry_info = MagicMock(return_value=None)
|
||||
api.config_manager.load_config = MagicMock(return_value={})
|
||||
response = api_v3_client.get('/api/v3/plugins/installed')
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -22,7 +22,7 @@ def installed(api_v3_module, api_v3_client, tmp_path):
|
||||
info.update(manifest_extra)
|
||||
api.plugin_catalog.plugins_dir = str(tmp_path) # no manifest on disk
|
||||
api.plugin_catalog.get_all_plugin_info = MagicMock(return_value=[info])
|
||||
api.plugin_store_manager.get_cached_registry_info = MagicMock(return_value=None)
|
||||
api.plugin_store_manager.get_registry_info = MagicMock(return_value=None)
|
||||
api.config_manager.load_config = MagicMock(return_value={})
|
||||
response = api_v3_client.get('/api/v3/plugins/installed')
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -37,7 +37,6 @@ from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401
|
||||
START_URL = "/api/v3/display/on-demand/start"
|
||||
STOP_URL = "/api/v3/display/on-demand/stop"
|
||||
MAILBOX = "display_on_demand_request"
|
||||
DISPLAY = "web_interface.blueprints.api_v3.display"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -151,102 +150,6 @@ class TestStartWhileTheServiceIsStopped:
|
||||
assert response.get_json()["status"] == "error"
|
||||
|
||||
|
||||
class _Mailbox:
|
||||
"""The CacheManager calls the routes make, over a dict."""
|
||||
|
||||
def __init__(self):
|
||||
self.entries = {}
|
||||
|
||||
def set(self, key, value, ttl=None):
|
||||
self.entries[key] = value
|
||||
|
||||
def get(self, key, max_age=300, memory_ttl=None):
|
||||
return self.entries.get(key)
|
||||
|
||||
def delete(self, key):
|
||||
self.entries.pop(key, None)
|
||||
|
||||
|
||||
class TestARefusedStartLeavesNoRequestBehind:
|
||||
"""A start the route answers with an error must not run later.
|
||||
|
||||
The request was posted (to the mailbox, with the display stopped) before
|
||||
the route refused it, and the display reads the mailbox for an hour
|
||||
without looking at a request's age. So "Display service is not running"
|
||||
(start_service off) or "Failed to start display service" left the
|
||||
request waiting, and the next time the display started -- minutes later,
|
||||
by hand -- it ran that plugin, pinned if the request said so.
|
||||
|
||||
A socket acknowledgement is the other side of it: the display answered,
|
||||
so it is running and has the request, whatever systemd says (a display
|
||||
run by hand or in the emulator has no active unit). That is a success,
|
||||
not "not running", and no unit is started beside it.
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def mailbox(self, api_v3_module, service):
|
||||
box = _Mailbox()
|
||||
api_v3_module.api_v3.cache_manager = box
|
||||
service["state"]["active"] = False
|
||||
return box
|
||||
|
||||
@pytest.mark.parametrize("body", [
|
||||
{"plugin_id": "weather", "start_service": False},
|
||||
{"plugin_id": "weather"}, # start_service defaults on
|
||||
])
|
||||
def test_a_socket_ack_is_a_success_whatever_systemd_says(
|
||||
self, api_v3_client, service, mailbox, body):
|
||||
with patch(f"{DISPLAY}.control_client.on_demand_start",
|
||||
side_effect=lambda request_id, *a: {"accepted": True}):
|
||||
response = api_v3_client.post(START_URL, json=body)
|
||||
assert response.status_code == 200, response.get_json()
|
||||
assert response.get_json()["data"]["transport"] == "socket"
|
||||
assert MAILBOX not in mailbox.entries
|
||||
assert _systemctl_verbs(service["systemctl"]) == [], (
|
||||
"a unit was started beside a display that answered the socket")
|
||||
|
||||
def test_without_start_service_the_request_is_taken_back(
|
||||
self, api_v3_client, service, mailbox):
|
||||
response = api_v3_client.post(START_URL, json={
|
||||
"plugin_id": "weather", "pinned": True, "start_service": False})
|
||||
assert response.status_code == 400
|
||||
assert response.get_json()["status"] == "error"
|
||||
assert MAILBOX not in mailbox.entries
|
||||
|
||||
def test_a_start_that_fails_takes_its_request_back(self, api_v3_client, service, mailbox):
|
||||
service["systemctl"].side_effect = lambda args: {
|
||||
"returncode": 1, "stdout": "", "stderr": "denied"}
|
||||
response = api_v3_client.post(START_URL, json={"plugin_id": "weather"})
|
||||
assert response.status_code == 500
|
||||
assert MAILBOX not in mailbox.entries
|
||||
|
||||
def test_a_newer_request_is_left_alone_on_the_400(self, api_v3_client, service, mailbox):
|
||||
newer = {"request_id": "someone-else", "action": "start", "plugin_id": "clock"}
|
||||
|
||||
def stopped_and_another_post_lands(*args):
|
||||
mailbox.entries[MAILBOX] = newer
|
||||
return {"active": False}
|
||||
|
||||
with patch(f"{DISPLAY}._get_display_service_status",
|
||||
side_effect=stopped_and_another_post_lands):
|
||||
response = api_v3_client.post(START_URL, json={
|
||||
"plugin_id": "weather", "start_service": False})
|
||||
assert response.status_code == 400
|
||||
assert mailbox.entries[MAILBOX] is newer
|
||||
|
||||
def test_a_newer_request_is_left_alone_on_the_500(self, api_v3_client, service, mailbox):
|
||||
newer = {"request_id": "someone-else", "action": "start", "plugin_id": "clock"}
|
||||
|
||||
def start_fails_after_another_post(args):
|
||||
mailbox.entries[MAILBOX] = newer
|
||||
return {"returncode": 1, "stdout": "", "stderr": "denied"}
|
||||
|
||||
service["systemctl"].side_effect = start_fails_after_another_post
|
||||
response = api_v3_client.post(START_URL, json={"plugin_id": "weather"})
|
||||
assert response.status_code == 500
|
||||
assert mailbox.entries[MAILBOX] is newer
|
||||
|
||||
|
||||
class TestStop:
|
||||
def test_stop_posts_a_stop_request_and_leaves_the_service_running(
|
||||
self, api_v3_client, service):
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
"""POST /display/on-demand/start and /stop: control socket first, mailbox fallback.
|
||||
|
||||
The routes hand the request to the display over the control socket
|
||||
(src/ipc) and get an acknowledgement. When the socket could not carry the
|
||||
request -- no socket (a stopped display, or one older than the socket), a
|
||||
refused or timed-out connect, a display too old to know the command, a bug
|
||||
in the client -- they write the file mailbox exactly as they did before the
|
||||
socket existed. When the display had the request and failed it (a full
|
||||
queue, bad arguments, no answer in time) the route says so and writes
|
||||
nothing. These tests pin each path, that at most one of them is used, that
|
||||
(src/ipc) and get an acknowledgement. On any failure -- no socket (a stopped
|
||||
display, or one older than the socket), a timeout, a refusal, a bug in the
|
||||
client -- they write the file mailbox exactly as they did before the socket
|
||||
existed. These tests pin both paths, that exactly one of them is used, that
|
||||
the response says which, and that the request id is the same either way (the
|
||||
display deduplicates on it).
|
||||
|
||||
@@ -104,15 +101,12 @@ class TestSocketPath:
|
||||
class TestMailboxFallback:
|
||||
@pytest.mark.parametrize("reason", [
|
||||
"no_socket", "refused", "timeout", "closed", "bad_response", "invalid_request",
|
||||
"busy", "forbidden", "unknown_command", "unsupported_version", "disabled",
|
||||
"unsupported",
|
||||
"busy", "unknown_command", "unsupported_version", "disabled", "unsupported",
|
||||
])
|
||||
def test_a_request_the_socket_never_carried_writes_the_mailbox(
|
||||
def test_any_socket_failure_writes_the_mailbox_as_before(
|
||||
self, api_v3_client, service, reason):
|
||||
# sent=False: the display never had it (no socket, a refused or
|
||||
# timed-out connect, turned away at the door).
|
||||
with patch(f"{CLIENT}.on_demand_start",
|
||||
side_effect=control_client.ControlError(reason, "x", sent=False)):
|
||||
side_effect=control_client.ControlError(reason, "x")):
|
||||
resp = api_v3_client.post(START_URL, json={
|
||||
"plugin_id": "weather", "mode": "weather_current",
|
||||
"duration": 60, "pinned": True})
|
||||
@@ -174,60 +168,6 @@ class TestMailboxFallback:
|
||||
assert data["socket_error"] in ("disabled", "unsupported") # Linux, Windows
|
||||
|
||||
|
||||
class TestTheDisplayHadIt:
|
||||
"""Once the display has the request, its answer stands: no mailbox copy.
|
||||
|
||||
A busy queue, a refusal or silence after the request was sent mean the
|
||||
display may have applied it, or would refuse the mailbox copy too, so
|
||||
the route reports the failure instead of posting it a second time.
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize("reason,status", [
|
||||
("busy", 503), ("internal", 503), ("timeout", 503), ("closed", 503),
|
||||
("bad_response", 503), ("invalid_args", 400),
|
||||
])
|
||||
def test_start_is_answered_with_the_failure(self, api_v3_client, service, reason, status):
|
||||
with patch(f"{CLIENT}.on_demand_start",
|
||||
side_effect=control_client.ControlError(reason, "x", sent=True)):
|
||||
resp = api_v3_client.post(START_URL, json={"plugin_id": "weather"})
|
||||
assert resp.status_code == status
|
||||
body = resp.get_json()
|
||||
assert body["status"] == "error"
|
||||
assert body["data"]["transport"] == "socket"
|
||||
assert body["data"]["socket_error"] == reason
|
||||
assert _mailbox_writes(service["cache"]) == []
|
||||
assert not [call for call in service["calls"] if call[0] == "systemctl"]
|
||||
|
||||
def test_stop_is_answered_with_the_failure(self, api_v3_client, service):
|
||||
with patch(f"{CLIENT}.on_demand_stop",
|
||||
side_effect=control_client.ControlError("busy", "x", sent=True)):
|
||||
resp = api_v3_client.post(STOP_URL, json={})
|
||||
assert resp.status_code == 503
|
||||
assert _mailbox_writes(service["cache"]) == []
|
||||
|
||||
def test_a_stop_with_stop_service_still_stops_the_service(self, api_v3_client, service):
|
||||
with patch(f"{CLIENT}.on_demand_stop",
|
||||
side_effect=control_client.ControlError("timeout", "x", sent=True)), \
|
||||
patch("web_interface.blueprints.api_v3.display._stop_display_service",
|
||||
return_value={"active": False}) as stop:
|
||||
resp = api_v3_client.post(STOP_URL, json={"stop_service": True})
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()["data"]
|
||||
assert data["transport"] == "socket" and data["socket_error"] == "timeout"
|
||||
stop.assert_called_once()
|
||||
assert _mailbox_writes(service["cache"]) == []
|
||||
|
||||
@pytest.mark.parametrize("reason", ["unknown_command", "unsupported_version"])
|
||||
def test_an_older_display_that_does_not_speak_it_gets_the_mailbox(
|
||||
self, api_v3_client, service, reason):
|
||||
# The upgrade case: new web interface, display still on an old build.
|
||||
with patch(f"{CLIENT}.on_demand_start",
|
||||
side_effect=control_client.ControlError(reason, "x", sent=True)):
|
||||
data = api_v3_client.post(START_URL, json={"plugin_id": "weather"}).get_json()["data"]
|
||||
assert data["transport"] == "mailbox" and data["socket_error"] == reason
|
||||
assert len(_mailbox_writes(service["cache"])) == 1
|
||||
|
||||
|
||||
@pytest.mark.skipif(not c.socket_supported(), reason="AF_UNIX sockets are Linux/macOS only")
|
||||
class TestRealSocket:
|
||||
@pytest.fixture
|
||||
@@ -264,23 +204,3 @@ class TestRealSocket:
|
||||
data = api_v3_client.post(START_URL, json={"plugin_id": "weather"}).get_json()["data"]
|
||||
assert data["transport"] == "mailbox" and data["socket_error"] == "no_socket"
|
||||
assert len(_mailbox_writes(service["cache"])) == 1
|
||||
|
||||
def test_a_full_queue_is_reported_not_mailed(self, api_v3_client, service, monkeypatch):
|
||||
import shutil
|
||||
import tempfile
|
||||
from src.ipc.server import ControlServer
|
||||
d = tempfile.mkdtemp(prefix="lmipc-")
|
||||
path = os.path.join(d, "control.sock")
|
||||
server = ControlServer(path, status_provider=dict, queue_size=1)
|
||||
assert server.start()
|
||||
monkeypatch.setenv(c.SOCKET_PATH_ENV, path)
|
||||
try:
|
||||
first = api_v3_client.post(START_URL, json={"plugin_id": "weather"})
|
||||
assert first.get_json()["data"]["transport"] == "socket"
|
||||
second = api_v3_client.post(START_URL, json={"plugin_id": "weather"})
|
||||
assert second.status_code == 503
|
||||
assert second.get_json()["data"]["socket_error"] == "busy"
|
||||
assert _mailbox_writes(service["cache"]) == []
|
||||
finally:
|
||||
server.close()
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
"""GET /api/v3/plugins/operation/<id> answers for an operation still waiting.
|
||||
|
||||
PluginOperationQueue keeps an operation's callback in its parameters, under
|
||||
``_callback``, until the worker takes it to run. PluginOperation.to_dict()
|
||||
returned the parameters as they were, so for a pending operation the route
|
||||
handed jsonify a function and answered 500 "A system error occurred". That
|
||||
is every poll of an install queued behind another plugin's: the second of
|
||||
two installs read as broken until the first one finished.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
||||
|
||||
from src.plugin_system.operation_queue import PluginOperationQueue # noqa: E402
|
||||
from src.plugin_system.operation_types import ( # noqa: E402
|
||||
OperationType, PluginOperation,
|
||||
)
|
||||
|
||||
|
||||
def _callback(op):
|
||||
return {"success": True, "message": "done"}
|
||||
|
||||
|
||||
class TestToDict:
|
||||
def test_private_parameters_are_left_out(self):
|
||||
op = PluginOperation(OperationType.INSTALL, "demo",
|
||||
parameters={"_callback": _callback, "branch": "main"})
|
||||
assert op.to_dict()["parameters"] == {"branch": "main"}
|
||||
json.dumps(op.to_dict()) # serializable
|
||||
|
||||
def test_the_operation_keeps_its_callback_for_the_worker(self):
|
||||
op = PluginOperation(OperationType.INSTALL, "demo",
|
||||
parameters={"_callback": _callback})
|
||||
op.to_dict()
|
||||
assert op.parameters["_callback"] is _callback
|
||||
|
||||
def test_the_other_fields_are_unchanged(self):
|
||||
op = PluginOperation(OperationType.UNINSTALL, "demo", operation_id="op-1")
|
||||
assert op.to_dict() == {
|
||||
"operation_id": "op-1", "operation_type": "uninstall", "plugin_id": "demo",
|
||||
"parameters": {}, "status": "pending", "progress": 0.0, "message": "",
|
||||
"error": None, "result": None,
|
||||
"created_at": op.created_at.isoformat(), "started_at": None,
|
||||
"completed_at": None,
|
||||
}
|
||||
|
||||
|
||||
class TestTheRoute:
|
||||
@pytest.fixture
|
||||
def busy_queue(self, api_v3_module):
|
||||
"""A real queue whose worker is held by another plugin's operation."""
|
||||
queue = PluginOperationQueue(max_history=10)
|
||||
api_v3_module.api_v3.operation_queue = queue
|
||||
started, release = threading.Event(), threading.Event()
|
||||
|
||||
def blocker(op):
|
||||
started.set()
|
||||
release.wait(10)
|
||||
return {"success": True, "message": "done"}
|
||||
|
||||
queue.enqueue_operation(OperationType.INSTALL, "busy", operation_callback=blocker)
|
||||
assert started.wait(5)
|
||||
yield queue
|
||||
release.set()
|
||||
queue.shutdown()
|
||||
|
||||
def test_a_pending_operation_reports_pending(self, api_v3_client, busy_queue):
|
||||
op_id = busy_queue.enqueue_operation(
|
||||
OperationType.INSTALL, "demo", operation_callback=_callback)
|
||||
response = api_v3_client.get(f"/api/v3/plugins/operation/{op_id}")
|
||||
assert response.status_code == 200, response.get_json()
|
||||
data = response.get_json()["data"]
|
||||
assert data["status"] == "pending"
|
||||
assert data["plugin_id"] == "demo"
|
||||
assert "_callback" not in data["parameters"]
|
||||
@@ -253,32 +253,6 @@ class TestVegasCycleDurations:
|
||||
assert saved['config']['display']['display_durations'] == {'clock': 45}
|
||||
|
||||
|
||||
class TestMalformedBody:
|
||||
"""A JSON body that does not parse is the caller's mistake: a 400.
|
||||
|
||||
get_json() raised Werkzeug's BadRequest inside the handler's try, whose
|
||||
catch-all answered 500 CONFIG_SAVE_FAILED with "check file permissions"
|
||||
advice and logged a traceback at ERROR.
|
||||
"""
|
||||
|
||||
def test_is_a_400_in_the_raw_routes_shape(self, api_v3_client, saved, api_v3_module):
|
||||
api_v3_module.api_v3.config_manager.get_raw_file_content.return_value = {}
|
||||
resp = api_v3_client.post('/api/v3/config/main', data='{not json',
|
||||
content_type='application/json')
|
||||
assert resp.status_code == 400
|
||||
assert resp.get_json() == {'status': 'error', 'message': 'Invalid JSON in request body'}
|
||||
assert 'config' not in saved
|
||||
raw = api_v3_client.post('/api/v3/config/raw/main', data='{not json',
|
||||
content_type='application/json')
|
||||
assert (raw.status_code, raw.get_json()) == (400, resp.get_json())
|
||||
|
||||
def test_an_empty_json_post_is_still_no_data(self, api_v3_client, saved):
|
||||
resp = api_v3_client.post('/api/v3/config/main', data='',
|
||||
content_type='application/json')
|
||||
assert resp.status_code == 400
|
||||
assert resp.get_json()['message'] == 'No data provided'
|
||||
|
||||
|
||||
class TestRawSaveStartsAutoUpdateSetup:
|
||||
@pytest.fixture
|
||||
def raw_env(self, api_v3_module, monkeypatch):
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
"""POST /api/v3/plugins/action hands ``params`` to the plugin's script intact.
|
||||
|
||||
The route runs the script through a generated wrapper, and the params went
|
||||
into that wrapper as Python source: ``params = {json.dumps(params)}``. JSON is
|
||||
not Python. ``true``, ``false`` and ``null`` are undefined names there, so any
|
||||
params holding a boolean or a null died with a NameError before the script
|
||||
ran. The plugin file manager's category toggle sends ``{"category_name": ...,
|
||||
"enabled": true}``, so of-the-day's category toggle failed every time with
|
||||
"Action failed".
|
||||
|
||||
The script's side of the contract is unchanged and pinned here too: the
|
||||
params arrive on stdin as one JSON document, LEDMATRIX_ROOT is set, and what
|
||||
the script prints to stdout is what the route parses.
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
||||
|
||||
ACTION_URL = "/api/v3/plugins/action"
|
||||
|
||||
# The action script: report what it was handed, as JSON on stdout.
|
||||
ECHO_SCRIPT = (
|
||||
"import json, os, sys\n"
|
||||
"raw = sys.stdin.read()\n"
|
||||
"print(json.dumps({'status': 'success', 'got': json.loads(raw),\n"
|
||||
" 'root': os.environ.get('LEDMATRIX_ROOT')}))\n"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def echo_plugin(tmp_path, api_v3_module, monkeypatch):
|
||||
plugin_dir = tmp_path / "demo"
|
||||
plugin_dir.mkdir()
|
||||
(plugin_dir / "manifest.json").write_text(json.dumps({
|
||||
"id": "demo",
|
||||
"web_ui_actions": [{"id": "toggle", "type": "script", "script": "echo.py"}],
|
||||
}), encoding="utf-8")
|
||||
(plugin_dir / "echo.py").write_text(ECHO_SCRIPT, encoding="utf-8")
|
||||
api_v3_module.api_v3.plugin_catalog.get_plugin_directory.return_value = str(plugin_dir)
|
||||
|
||||
# The route runs `python3`; use this interpreter, so the test does not
|
||||
# depend on what that name resolves to here.
|
||||
real_run = subprocess.run
|
||||
|
||||
def run(cmd, *args, **kwargs):
|
||||
if isinstance(cmd, list) and cmd and cmd[0] == "python3":
|
||||
cmd = [sys.executable] + cmd[1:]
|
||||
return real_run(cmd, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", run)
|
||||
return plugin_dir
|
||||
|
||||
|
||||
@pytest.mark.parametrize("params", [
|
||||
{"category_name": "jokes", "enabled": True}, # the file manager's toggle
|
||||
{"category_name": "jokes", "enabled": False},
|
||||
{"filename": None},
|
||||
{"nested": {"list": [1, None, True, 2.5], "empty": {}}},
|
||||
{"text": "café ✓ \U0001F600"},
|
||||
{"text": "he said \"hi\" and 'bye' \\ ''' \"\"\" \n\t end"},
|
||||
], ids=["true", "false", "null", "nested", "unicode", "quotes"])
|
||||
def test_the_script_receives_the_params_it_was_sent(api_v3_client, echo_plugin, params):
|
||||
response = api_v3_client.post(ACTION_URL, json={
|
||||
"plugin_id": "demo", "action_id": "toggle", "params": params})
|
||||
body = response.get_json()
|
||||
assert response.status_code == 200, body
|
||||
assert body["got"] == params
|
||||
|
||||
|
||||
def test_a_param_cannot_run_code_in_the_wrapper(api_v3_client, echo_plugin, tmp_path):
|
||||
marker = tmp_path / "PWNED"
|
||||
hostile = "\"}\nopen(%r, 'w').write('ran')\n#" % str(marker)
|
||||
params = {"name": hostile, "flag": True}
|
||||
response = api_v3_client.post(ACTION_URL, json={
|
||||
"plugin_id": "demo", "action_id": "toggle", "params": params})
|
||||
assert response.status_code == 200, response.get_json()
|
||||
assert response.get_json()["got"] == params
|
||||
assert not marker.exists(), "a param value ran as code"
|
||||
|
||||
|
||||
def test_the_script_still_gets_ledmatrix_root(api_v3_client, echo_plugin, api_v3_module):
|
||||
response = api_v3_client.post(ACTION_URL, json={
|
||||
"plugin_id": "demo", "action_id": "toggle", "params": {"enabled": True}})
|
||||
assert response.status_code == 200, response.get_json()
|
||||
assert response.get_json()["root"] == str(api_v3_module.PROJECT_ROOT)
|
||||
@@ -1,89 +0,0 @@
|
||||
"""A second install or uninstall while one is in progress is a 409, not a 500.
|
||||
|
||||
PluginOperationQueue refuses a second operation for a plugin that already
|
||||
has one waiting or running (test_operation_queue_pending_and_trim.py), and
|
||||
says so by raising ValueError. /plugins/install let that escape to the
|
||||
blueprint's catch-all, so a double-clicked Install answered 500 "An error
|
||||
occurred; see logs for details" while the first install carried on.
|
||||
/plugins/uninstall caught it in its own catch-all: a 500 "Failed to
|
||||
uninstall plugin", and an "uninstall failed" entry in the operation
|
||||
history for an uninstall that never started.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
||||
|
||||
from src.plugin_system.operation_queue import PluginOperationQueue # noqa: E402
|
||||
|
||||
INSTALL = "/api/v3/plugins/install"
|
||||
UNINSTALL = "/api/v3/plugins/uninstall"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def installing(api_v3_module, tmp_path):
|
||||
"""A real queue with an install of "clock" running and held there."""
|
||||
queue = PluginOperationQueue(max_history=10)
|
||||
api_v3_module.api_v3.operation_queue = queue
|
||||
started, release = threading.Event(), threading.Event()
|
||||
|
||||
def slow_install(plugin_id, branch=None):
|
||||
started.set()
|
||||
release.wait(10)
|
||||
return True
|
||||
|
||||
store = api_v3_module.api_v3.plugin_store_manager
|
||||
store.install_plugin.side_effect = slow_install
|
||||
store.get_registry_info.return_value = None
|
||||
store.plugins_dir = str(tmp_path)
|
||||
api_v3_module.api_v3.plugin_catalog.get_plugin_directory.return_value = None
|
||||
yield {"queue": queue, "started": started, "store": store}
|
||||
release.set()
|
||||
queue.shutdown()
|
||||
|
||||
|
||||
def _start_first_install(client, installing):
|
||||
response = client.post(INSTALL, json={"plugin_id": "clock"})
|
||||
assert response.status_code == 200, response.get_json()
|
||||
assert installing["started"].wait(5)
|
||||
|
||||
|
||||
def _failed_history(api_v3_module):
|
||||
return [c for c in api_v3_module.api_v3.operation_history.record_operation.call_args_list
|
||||
if c.kwargs.get("status") == "failed"]
|
||||
|
||||
|
||||
def test_a_second_install_click_is_a_conflict(api_v3_client, api_v3_module, installing):
|
||||
_start_first_install(api_v3_client, installing)
|
||||
response = api_v3_client.post(INSTALL, json={"plugin_id": "clock"})
|
||||
assert response.status_code == 409, response.get_json()
|
||||
body = response.get_json()
|
||||
assert body["status"] == "error"
|
||||
assert body["error_code"] == "PLUGIN_OPERATION_CONFLICT"
|
||||
assert "clock" in body["message"]
|
||||
assert installing["store"].install_plugin.call_count == 1
|
||||
assert _failed_history(api_v3_module) == []
|
||||
|
||||
|
||||
def test_an_uninstall_during_the_install_is_a_conflict(api_v3_client, api_v3_module,
|
||||
installing):
|
||||
_start_first_install(api_v3_client, installing)
|
||||
response = api_v3_client.post(UNINSTALL, json={"plugin_id": "clock"})
|
||||
assert response.status_code == 409, response.get_json()
|
||||
assert response.get_json()["error_code"] == "PLUGIN_OPERATION_CONFLICT"
|
||||
assert _failed_history(api_v3_module) == [], (
|
||||
"an uninstall that never started was recorded as failed")
|
||||
api_v3_module.api_v3.plugin_store_manager.uninstall_plugin.assert_not_called()
|
||||
|
||||
|
||||
def test_another_plugin_is_still_queued(api_v3_client, installing):
|
||||
_start_first_install(api_v3_client, installing)
|
||||
response = api_v3_client.post(INSTALL, json={"plugin_id": "weather"})
|
||||
assert response.status_code == 200, response.get_json()
|
||||
assert response.get_json()["data"]["operation_id"]
|
||||
@@ -1,73 +0,0 @@
|
||||
"""GET /api/v3/plugins/<plugin_id>/static/<path> serves binary files too.
|
||||
|
||||
The route opened every file as UTF-8 text, so an image -- what the API
|
||||
reference says it is for, plugin previews and icons -- failed to decode and
|
||||
answered 500 "UnicodeDecodeError". Files are now sent as bytes. The text
|
||||
types the route always set are unchanged, and the path checks are pinned in
|
||||
test_path_traversal_guards.py::TestServePluginStatic.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
||||
|
||||
PNG = (b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
|
||||
b"\x08\x06\x00\x00\x00\x1f\x15\xc4\x89")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plugin_dir(tmp_path, api_v3_module):
|
||||
d = tmp_path / "demo"
|
||||
(d / "web_ui").mkdir(parents=True)
|
||||
(d / "manifest.json").write_text(json.dumps({"id": "demo"}), encoding="utf-8")
|
||||
api_v3_module.api_v3.plugin_catalog.get_plugin_directory.side_effect = (
|
||||
lambda pid: str(d) if pid == "demo" else None)
|
||||
return d
|
||||
|
||||
|
||||
def _get(client, path):
|
||||
return client.get(f"/api/v3/plugins/demo/static/{path}")
|
||||
|
||||
|
||||
def test_an_image_is_served_as_its_bytes(api_v3_client, plugin_dir):
|
||||
(plugin_dir / "web_ui" / "icon.png").write_bytes(PNG)
|
||||
response = _get(api_v3_client, "web_ui/icon.png")
|
||||
assert response.status_code == 200, response.get_json(silent=True)
|
||||
assert response.mimetype == "image/png"
|
||||
assert response.data == PNG
|
||||
|
||||
|
||||
def test_an_unknown_binary_file_is_served_too(api_v3_client, plugin_dir):
|
||||
blob = bytes(range(256))
|
||||
(plugin_dir / "data.bin").write_bytes(blob)
|
||||
response = _get(api_v3_client, "data.bin")
|
||||
assert response.status_code == 200, response.get_json(silent=True)
|
||||
assert response.data == blob
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,mimetype", [
|
||||
("page.html", "text/html"),
|
||||
("app.js", "application/javascript"),
|
||||
("style.css", "text/css"),
|
||||
("data.json", "application/json"),
|
||||
("notes.txt", "text/plain"),
|
||||
("README.md", "text/plain"),
|
||||
("helper.py", "text/plain"),
|
||||
])
|
||||
def test_text_files_keep_their_types(api_v3_client, plugin_dir, name, mimetype):
|
||||
content = "caf\u00e9 \u2713 <p>hi</p>\n"
|
||||
(plugin_dir / name).write_bytes(content.encode("utf-8"))
|
||||
response = _get(api_v3_client, name)
|
||||
assert response.status_code == 200
|
||||
assert response.mimetype == mimetype
|
||||
assert response.data == content.encode("utf-8")
|
||||
|
||||
|
||||
def test_a_missing_file_is_still_a_404(api_v3_client, plugin_dir):
|
||||
assert _get(api_v3_client, "nope.png").status_code == 404
|
||||
@@ -1,326 +0,0 @@
|
||||
"""Four web answers that disagreed with the rig they describe (found on ledpi).
|
||||
|
||||
1. POST /config/schedule refused the schedule GET returns on a fresh install
|
||||
(config.template.json: per-day, every day off, schedule disabled) with
|
||||
"At least one day must be enabled", as did /config/dim-schedule. A
|
||||
disabled schedule needs no enabled day.
|
||||
2. A brightness-only POST /config/main answered ``restart_required: true``,
|
||||
though the display applies brightness live (brightness.set over the
|
||||
socket, and the config watcher). The flag now says whether anything
|
||||
changed that the running display does not pick up by itself.
|
||||
3. /health stayed "healthy" with the display service stopped: only the
|
||||
sub-checks changed. Service inactive, no socket and no live heartbeat
|
||||
is now ``display_loop: stopped`` and "degraded".
|
||||
4. /display/current-status kept answering ``is_display_active: true`` from
|
||||
the cache for up to 120 s after the display stopped. With no socket and
|
||||
no live heartbeat it is now unknown.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
||||
|
||||
from src import display_watchdog # noqa: E402
|
||||
from src.ipc import client as control_client # noqa: E402
|
||||
from web_interface import display_state # noqa: E402
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
TEMPLATE = json.loads((REPO / 'config' / 'config.template.json').read_text(encoding='utf-8'))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(api_v3_module, monkeypatch):
|
||||
state = {'config': {}, 'saves': 0}
|
||||
api_v3_module.api_v3.config_manager.load_config.side_effect = \
|
||||
lambda *a, **k: copy.deepcopy(state['config'])
|
||||
|
||||
def fake_save(_manager, config, **_kwargs):
|
||||
state['config'] = copy.deepcopy(config)
|
||||
state['saves'] += 1
|
||||
return True, ''
|
||||
|
||||
monkeypatch.setattr(api_v3_module, '_save_config_atomic', fake_save)
|
||||
return state
|
||||
|
||||
|
||||
# --- 1. schedules ---------------------------------------------------------------
|
||||
|
||||
SCHEDULE_ROUTES = [('/api/v3/config/schedule', 'schedule'),
|
||||
('/api/v3/config/dim-schedule', 'dim_schedule')]
|
||||
|
||||
|
||||
@pytest.mark.parametrize('route,section', SCHEDULE_ROUTES)
|
||||
def test_the_templates_disabled_per_day_schedule_saves_back(api_v3_client, store,
|
||||
route, section):
|
||||
stored = copy.deepcopy(TEMPLATE[section])
|
||||
stored['mode'] = 'per-day'
|
||||
assert stored['enabled'] is False
|
||||
assert not any(day['enabled'] for day in stored['days'].values())
|
||||
store['config'] = {section: copy.deepcopy(stored)}
|
||||
|
||||
read = api_v3_client.get(route).get_json()['data']
|
||||
resp = api_v3_client.post(route, json=read)
|
||||
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
saved = store['config'][section]
|
||||
assert saved['enabled'] is False and saved['mode'] == 'per-day'
|
||||
# The disabled days keep their times: switching one on finds them.
|
||||
assert saved['days'] == stored['days']
|
||||
|
||||
|
||||
@pytest.mark.parametrize('route,section', SCHEDULE_ROUTES)
|
||||
def test_an_enabled_per_day_schedule_still_needs_a_day(api_v3_client, store, route, section):
|
||||
body = copy.deepcopy(TEMPLATE[section])
|
||||
body.update(enabled=True, mode='per-day')
|
||||
resp = api_v3_client.post(route, json=body)
|
||||
assert resp.status_code == 400
|
||||
assert 'At least one day must be enabled' in resp.get_json()['message']
|
||||
assert store['saves'] == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize('route', [r for r, _ in SCHEDULE_ROUTES])
|
||||
def test_the_pickers_form_post_with_every_day_off_saves(api_v3_client, store, route):
|
||||
"""What schedule-picker.js posts: flat hidden inputs, booleans as strings,
|
||||
times for every day."""
|
||||
body = {'enabled': 'false', 'mode': 'per_day', 'start_time': '07:00', 'end_time': '23:00'}
|
||||
for day in ('monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'):
|
||||
body.update({f'{day}_enabled': 'false', f'{day}_start': '06:30', f'{day}_end': '22:15'})
|
||||
resp = api_v3_client.post(route, json=body)
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
|
||||
|
||||
def test_an_invalid_time_on_a_disabled_day_is_dropped_not_refused(api_v3_client, store):
|
||||
body = {'enabled': False, 'mode': 'per-day',
|
||||
'days': {'monday': {'enabled': False, 'start_time': 'soon', 'end_time': '22:00'}}}
|
||||
resp = api_v3_client.post('/api/v3/config/schedule', json=body)
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert store['config']['schedule']['days']['monday'] == {'enabled': False,
|
||||
'end_time': '22:00'}
|
||||
|
||||
|
||||
# --- 2. restart_required on /config/main ------------------------------------------
|
||||
|
||||
STORED_MAIN = {
|
||||
'timezone': 'America/Chicago',
|
||||
'display': {
|
||||
'hardware': {'rows': 32, 'cols': 64, 'chain_length': 2, 'brightness': 90,
|
||||
'disable_hardware_pulsing': False, 'inverse_colors': False,
|
||||
'show_refresh_rate': False},
|
||||
'runtime': {'gpio_slowdown': 4},
|
||||
'display_durations': {'clock': 15},
|
||||
'use_short_date_format': False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def main_store(store):
|
||||
store['config'] = copy.deepcopy(STORED_MAIN)
|
||||
return store
|
||||
|
||||
|
||||
def _save_main(client, body):
|
||||
with patch('web_interface.blueprints.api_v3.control_client.brightness_set',
|
||||
side_effect=control_client.ControlError('no_socket', 'x')):
|
||||
resp = client.post('/api/v3/config/main', data=json.dumps(body),
|
||||
content_type='application/json')
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
return resp.get_json()
|
||||
|
||||
|
||||
def test_a_brightness_only_save_needs_no_restart(api_v3_client, main_store):
|
||||
body = _save_main(api_v3_client, {'brightness': 40})
|
||||
assert main_store['config']['display']['hardware']['brightness'] == 40
|
||||
assert body['restart_required'] is False
|
||||
|
||||
|
||||
def test_a_brightness_save_on_a_config_without_a_display_section(api_v3_client, store):
|
||||
"""The route creates display.hardware and display.runtime on the way;
|
||||
empty sections are not a change."""
|
||||
store['config'] = {}
|
||||
assert _save_main(api_v3_client, {'brightness': 40})['restart_required'] is False
|
||||
|
||||
|
||||
def test_the_display_form_with_only_brightness_changed_needs_no_restart(api_v3_client,
|
||||
main_store):
|
||||
hw = STORED_MAIN['display']['hardware']
|
||||
body = {'__form_section': 'display', 'rows': 32, 'cols': 64, 'chain_length': 2,
|
||||
'brightness': 55, 'gpio_slowdown': 4}
|
||||
body.update({k: 'on' for k in ('disable_hardware_pulsing', 'inverse_colors',
|
||||
'show_refresh_rate') if hw[k]})
|
||||
assert _save_main(api_v3_client, body)['restart_required'] is False
|
||||
|
||||
|
||||
def test_a_mode_duration_needs_no_restart(api_v3_client, main_store):
|
||||
body = _save_main(api_v3_client, {'duration__clock': 40})
|
||||
assert main_store['config']['display']['display_durations']['clock'] == 40
|
||||
assert body['restart_required'] is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize('change', [{'rows': 64}, {'brightness': 40, 'chain_length': 3},
|
||||
{'gpio_slowdown': 2}, {'timezone': 'UTC'}])
|
||||
def test_a_setting_the_display_reads_at_startup_still_needs_one(api_v3_client, main_store,
|
||||
change):
|
||||
assert _save_main(api_v3_client, change)['restart_required'] is True
|
||||
|
||||
|
||||
def test_restart_needed_compares_leaves():
|
||||
from web_interface.blueprints.api_v3.config import restart_needed
|
||||
before = {'display': {'hardware': {'brightness': 90, 'rows': 32}}}
|
||||
assert not restart_needed(before, copy.deepcopy(before))
|
||||
assert not restart_needed(before, {'display': {'hardware': {'brightness': 10, 'rows': 32},
|
||||
'runtime': {}}})
|
||||
assert restart_needed(before, {'display': {'hardware': {'brightness': 90}}}) # removed
|
||||
assert not restart_needed({}, {'clock': {'enabled': True}}, live_paths=[('clock',)])
|
||||
assert restart_needed({}, {'clockwork': {'enabled': True}}, live_paths=[('clock',)])
|
||||
|
||||
|
||||
# --- 3 and 4. a stopped display -----------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def no_display(monkeypatch, tmp_path):
|
||||
"""A Pi whose display service has stopped: the socket is expected here
|
||||
but does not answer, and systemd took the heartbeat's directory away."""
|
||||
monkeypatch.setattr(display_state, 'socket_supported', lambda: True)
|
||||
monkeypatch.setattr(display_state, 'client_socket_paths', lambda: [str(tmp_path / 'gone')])
|
||||
monkeypatch.setattr(display_state, 'read_state', lambda: None)
|
||||
path = tmp_path / 'display-heartbeat.json'
|
||||
monkeypatch.setattr(display_watchdog, 'HEARTBEAT_PATH', str(path))
|
||||
|
||||
def beat(age, pid=None):
|
||||
path.write_text(json.dumps({'pid': os.getpid() if pid is None else pid,
|
||||
'mono': time.monotonic() - age,
|
||||
'wall': time.time() - age}))
|
||||
return beat
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service(monkeypatch):
|
||||
status = {'active': False, 'returncode': 3, 'stdout': 'inactive', 'stderr': ''}
|
||||
monkeypatch.setattr('web_interface.blueprints.api_v3.misc._get_display_service_status',
|
||||
lambda: dict(status))
|
||||
return status
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fresh_preview(tmp_path, monkeypatch):
|
||||
"""The preview frame the display left behind, under 60 s old: on its own
|
||||
it kept the hardware check "connected"."""
|
||||
from web_interface import display_preview
|
||||
snapshot = tmp_path / 'preview.png'
|
||||
snapshot.write_bytes(b'png')
|
||||
monkeypatch.setattr(display_preview, 'SNAPSHOT_PATH', str(snapshot))
|
||||
|
||||
|
||||
def _health(client):
|
||||
resp = client.get('/api/v3/health')
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
return resp.get_json()['data']
|
||||
|
||||
|
||||
class TestHealth:
|
||||
def test_a_stopped_display_service_is_degraded(self, api_v3_client, no_display, service,
|
||||
fresh_preview):
|
||||
data = _health(api_v3_client)
|
||||
assert data['services']['display_service']['status'] == 'inactive'
|
||||
assert data['checks']['display_loop']['status'] == 'stopped'
|
||||
assert data['status'] == 'degraded'
|
||||
|
||||
def test_a_service_still_starting_is_not(self, api_v3_client, no_display, service,
|
||||
fresh_preview):
|
||||
"""Active, before its socket and first heartbeat: not stopped."""
|
||||
service.update(active=True, stdout='active', returncode=0)
|
||||
data = _health(api_v3_client)
|
||||
assert data['checks']['display_loop']['status'] == 'not_reported'
|
||||
assert data['status'] == 'healthy'
|
||||
|
||||
def test_a_display_run_by_hand_is_not_stopped(self, api_v3_client, no_display, service,
|
||||
fresh_preview):
|
||||
"""The service is off but a display process beats (sudo python3 run.py)."""
|
||||
no_display(age=2)
|
||||
data = _health(api_v3_client)
|
||||
assert data['checks']['display_loop']['status'] == 'running'
|
||||
assert data['status'] == 'healthy'
|
||||
|
||||
@pytest.mark.parametrize('platform', ['no_unix_sockets', 'socket_off'])
|
||||
def test_without_a_socket_to_expect_nothing_changes(self, api_v3_client, no_display,
|
||||
service, fresh_preview, monkeypatch,
|
||||
platform):
|
||||
"""Windows and the dev server (no systemd unit), or the socket
|
||||
deliberately off: no heartbeat is no signal, as before."""
|
||||
if platform == 'no_unix_sockets':
|
||||
monkeypatch.setattr(display_state, 'socket_supported', lambda: False)
|
||||
else:
|
||||
monkeypatch.setattr(display_state, 'client_socket_paths', lambda: [])
|
||||
service.update(returncode=-1, stdout='', stderr='systemctl not found')
|
||||
data = _health(api_v3_client)
|
||||
assert data['checks']['display_loop']['status'] == 'not_reported'
|
||||
assert data['status'] == 'healthy'
|
||||
|
||||
def test_the_status_only_answer_says_degraded(self, api_v3_client, no_display, service,
|
||||
fresh_preview, monkeypatch):
|
||||
monkeypatch.setattr('web_interface.blueprints.api_v3.misc.request_is_authenticated',
|
||||
lambda: False)
|
||||
resp = api_v3_client.get('/api/v3/health')
|
||||
assert resp.get_json()['data'] == {'status': 'degraded'}
|
||||
|
||||
|
||||
class TestCurrentStatus:
|
||||
CACHED = {'mode': 'clock', 'plugin_id': 'clock', 'is_display_active': True,
|
||||
'on_demand_active': False, 'last_updated': None}
|
||||
|
||||
@pytest.fixture
|
||||
def cached(self, api_v3_module):
|
||||
entry = dict(self.CACHED, last_updated=time.time() - 30)
|
||||
cache = api_v3_module.api_v3.cache_manager
|
||||
cache.get.side_effect = lambda key, *a, **kw: (
|
||||
dict(entry) if key == 'display_current_state' else None)
|
||||
return entry
|
||||
|
||||
def _status(self, client):
|
||||
resp = client.get('/api/v3/display/current-status')
|
||||
assert resp.status_code == 200
|
||||
return resp.get_json()['data']
|
||||
|
||||
def test_a_stopped_display_is_not_reported_active(self, api_v3_client, no_display, cached):
|
||||
data = self._status(api_v3_client)
|
||||
assert not data.get('is_display_active')
|
||||
assert data['mode'] is None and data['last_updated'] is None
|
||||
assert data['source'] == 'cache'
|
||||
|
||||
def test_a_stale_heartbeat_is_not_active_either(self, api_v3_client, no_display, cached):
|
||||
no_display(age=display_watchdog.HEARTBEAT_STALE_SECONDS + 5)
|
||||
assert self._status(api_v3_client)['mode'] is None
|
||||
|
||||
@pytest.mark.skipif(os.name != 'posix', reason='process_exists answers only on POSIX')
|
||||
def test_a_heartbeat_from_a_dead_process_is_not_active(self, api_v3_client, no_display,
|
||||
cached):
|
||||
no_display(age=1, pid=2 ** 22 + 12345)
|
||||
assert self._status(api_v3_client)['mode'] is None
|
||||
|
||||
def test_a_live_heartbeat_without_a_socket_reads_the_cache(self, api_v3_client,
|
||||
no_display, cached):
|
||||
"""An older display with no socket, still running."""
|
||||
no_display(age=2)
|
||||
data = self._status(api_v3_client)
|
||||
assert data['mode'] == 'clock' and data['is_display_active'] is True
|
||||
|
||||
@pytest.mark.parametrize('platform', ['no_unix_sockets', 'socket_off'])
|
||||
def test_without_a_socket_to_expect_the_cache_answers(self, api_v3_client, no_display,
|
||||
cached, monkeypatch, platform):
|
||||
if platform == 'no_unix_sockets':
|
||||
monkeypatch.setattr(display_state, 'socket_supported', lambda: False)
|
||||
else:
|
||||
monkeypatch.setattr(display_state, 'client_socket_paths', lambda: [])
|
||||
data = self._status(api_v3_client)
|
||||
assert data['mode'] == 'clock' and data['is_display_active'] is True
|
||||
@@ -58,8 +58,7 @@ class TestFastPath:
|
||||
for _ in range(10):
|
||||
again = m.load_config()
|
||||
assert counts["n"] == 0, "fast path must not re-open any config file"
|
||||
assert again == first
|
||||
assert again is not first # each caller gets its own copy, see below
|
||||
assert again is first # same aliasing semantics as the full path
|
||||
|
||||
def test_config_change_triggers_reload(self, mgr):
|
||||
m, config, secrets, template = mgr
|
||||
@@ -99,33 +98,6 @@ class TestFastPath:
|
||||
assert m.load_config()["timezone"] == "America/New_York"
|
||||
|
||||
|
||||
class TestCallersGetACopy:
|
||||
"""A web handler edits what load_config returned, then validates. When
|
||||
validation failed, the edit stayed in the cache the fast path serves, and
|
||||
the next unrelated save wrote it -- a nested secret included, in plain
|
||||
text, because it had never reached config_secrets.json to be stripped."""
|
||||
|
||||
def test_editing_a_loaded_config_does_not_change_the_next_load(self, mgr):
|
||||
m, config, secrets, template = mgr
|
||||
loaded = m.load_config()
|
||||
loaded["display"]["brightness"] = 1
|
||||
loaded["weather"]["api_key"] = "typed-but-never-saved"
|
||||
again = m.load_config()
|
||||
assert again["display"]["brightness"] == 90
|
||||
assert again["weather"]["api_key"] == "sek"
|
||||
|
||||
def test_the_full_path_also_returns_a_copy(self, mgr):
|
||||
m, config, secrets, template = mgr
|
||||
m.load_config()["display"]["brightness"] = 1 # first load: full path
|
||||
assert m.load_config()["display"]["brightness"] == 90
|
||||
|
||||
def test_an_edit_never_reaches_a_later_save(self, mgr):
|
||||
m, config, secrets, template = mgr
|
||||
m.load_config()["display"]["new_secret"] = "hunter2" # then bailed out
|
||||
m.save_config(m.load_config()) # some other handler saves
|
||||
assert "hunter2" not in config.read_text()
|
||||
|
||||
|
||||
class TestSaveCoherence:
|
||||
def test_save_config_then_load_returns_saved_data(self, mgr, monkeypatch):
|
||||
m, config, secrets, template = mgr
|
||||
@@ -139,15 +111,6 @@ class TestSaveCoherence:
|
||||
assert loaded["weather"]["api_key"] == "sek" # secrets survive in memory
|
||||
assert counts["n"] == 0 # signature refreshed by save; no re-read
|
||||
|
||||
def test_the_saved_dict_does_not_become_the_cache(self, mgr):
|
||||
m, config, secrets, template = mgr
|
||||
m.load_config()
|
||||
new = {"display": {"brightness": 42}, "timezone": "UTC",
|
||||
"weather": {"api_key": "sek"}}
|
||||
m.save_config(new)
|
||||
new["display"]["brightness"] = 7 # the caller keeps using its dict
|
||||
assert m.load_config()["display"]["brightness"] == 42
|
||||
|
||||
def test_cross_process_save_is_picked_up(self, mgr):
|
||||
"""Another process writing config.json (different mtime) must bust
|
||||
this process's fast path — the core cross-process guarantee."""
|
||||
|
||||
@@ -143,10 +143,7 @@ class TestLoadFastPath:
|
||||
manager = make_manager(tmp_path, config={"timezone": "UTC"})
|
||||
first = manager.load_config()
|
||||
second = manager.load_config()
|
||||
# A copy of the cached dict, never the dict itself; that it is not
|
||||
# re-read is test_config_load_cache's test_unchanged_files_are_not_reread
|
||||
assert second == first
|
||||
assert second is not first
|
||||
assert second is first # same aliased dict, no re-read
|
||||
|
||||
def test_touching_secrets_file_invalidates_cache(self, tmp_path):
|
||||
manager = make_manager(
|
||||
|
||||
+12
-399
@@ -9,7 +9,6 @@ that overrides the schedule and ends (#714).
|
||||
"""
|
||||
|
||||
import itertools
|
||||
from dataclasses import replace
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@@ -20,7 +19,6 @@ os.environ.setdefault("EMULATOR", "true")
|
||||
from src import display_arbiter # noqa: E402
|
||||
from src.display_arbiter import ( # noqa: E402
|
||||
SCHEDULED_OFF_DWELL,
|
||||
SCREEN_PREEMPTERS,
|
||||
WIFI_NOTICE_DWELL,
|
||||
Arbiter,
|
||||
ArbiterInputs,
|
||||
@@ -28,9 +26,6 @@ from src.display_arbiter import ( # noqa: E402
|
||||
ScreenPlan,
|
||||
Source,
|
||||
WifiNotice,
|
||||
live_pick,
|
||||
live_takeover,
|
||||
on_demand_bound,
|
||||
wifi_notice_preempts,
|
||||
)
|
||||
|
||||
@@ -39,9 +34,7 @@ NOTICE = WifiNotice(message="Connected to HomeNet", expires_at=1_000.0)
|
||||
OFF = Source.SCHEDULED_OFF
|
||||
FOLLOW = Source.FOLLOWER
|
||||
WIFI = Source.WIFI
|
||||
ONDEM = Source.ON_DEMAND
|
||||
LEGACY = Source.LEGACY
|
||||
ROTATION = Source.ROTATION
|
||||
|
||||
# (schedule_on, on_demand_active, follower_active, notice) -> Source.
|
||||
# Every combination of the stage-2 inputs: 2 x 2 x 2 x 2 = 16 rows.
|
||||
@@ -53,17 +46,17 @@ DECIDE_TABLE = [
|
||||
(False, False, True, None, OFF),
|
||||
(False, False, True, NOTICE, OFF),
|
||||
# Scheduled off, but on-demand overrides the gate.
|
||||
(False, True, False, None, ONDEM), # on-demand overrides the gate
|
||||
(False, True, False, NOTICE, ONDEM), # on-demand outranks WiFi
|
||||
(False, True, False, None, LEGACY), # on-demand: run() decides
|
||||
(False, True, False, NOTICE, LEGACY), # on-demand outranks WiFi
|
||||
(False, True, True, None, FOLLOW), # follower outranks on-demand
|
||||
(False, True, True, NOTICE, FOLLOW),
|
||||
# Scheduled on.
|
||||
(True, False, False, None, ROTATION), # live / Vegas / rotation
|
||||
(True, False, False, None, LEGACY), # live / Vegas / rotation
|
||||
(True, False, False, NOTICE, WIFI),
|
||||
(True, False, True, None, FOLLOW),
|
||||
(True, False, True, NOTICE, FOLLOW), # follower outranks WiFi
|
||||
(True, True, False, None, ONDEM),
|
||||
(True, True, False, NOTICE, ONDEM), # on-demand outranks WiFi
|
||||
(True, True, False, None, LEGACY),
|
||||
(True, True, False, NOTICE, LEGACY), # on-demand outranks WiFi
|
||||
(True, True, True, None, FOLLOW),
|
||||
(True, True, True, NOTICE, FOLLOW),
|
||||
]
|
||||
@@ -102,15 +95,8 @@ class TestDecide:
|
||||
elif expected is WIFI:
|
||||
assert plan == ScreenPlan(WIFI, max_duration=WIFI_NOTICE_DWELL,
|
||||
notice=NOTICE)
|
||||
elif expected is ONDEM:
|
||||
# An empty state: a session with no modes, which the
|
||||
# controller ends (see TestOnDemand for real sessions).
|
||||
assert plan == ScreenPlan(ONDEM)
|
||||
elif expected is ROTATION:
|
||||
# No live scan and Vegas off: the rotation's (empty) mode.
|
||||
assert plan == ScreenPlan(ROTATION, preemptible_by=SCREEN_PREEMPTERS)
|
||||
else:
|
||||
# A follower paces itself.
|
||||
# A follower paces itself; LEGACY is run()'s existing code.
|
||||
assert plan == ScreenPlan(expected)
|
||||
|
||||
def test_dwells_are_todays(self):
|
||||
@@ -238,389 +224,16 @@ class TestControllerSnapshot:
|
||||
assert (plan.source is OFF) is (not dc.is_display_active), time_str
|
||||
return plan.source
|
||||
|
||||
assert step("22:59:30") is ROTATION
|
||||
assert step("22:59:30") is LEGACY
|
||||
assert step("23:00:00") is OFF # window ends
|
||||
dc.on_demand_active = True
|
||||
assert step("23:00:10") is ONDEM # on-demand overrides
|
||||
assert step("23:00:10") is LEGACY # on-demand overrides
|
||||
assert dc.on_demand_schedule_override is True
|
||||
assert step("23:01:00") is ONDEM # next minute, still on
|
||||
assert step("23:01:00") is LEGACY # next minute, still on
|
||||
dc._reset_on_demand_fields() # session ends
|
||||
assert step("23:01:20") is OFF # same minute: blanks
|
||||
dc.on_demand_active = True
|
||||
assert step("06:59:00") is ONDEM
|
||||
assert step("07:00:00") is ONDEM # schedule back on mid-session
|
||||
assert step("06:59:00") is LEGACY
|
||||
assert step("07:00:00") is LEGACY # schedule back on mid-session
|
||||
dc._reset_on_demand_fields()
|
||||
assert step("07:00:30") is ROTATION
|
||||
|
||||
|
||||
# -- OnDemand (stage 3) ---------------------------------------------------
|
||||
|
||||
ON = ArbiterInputs(schedule_on=True, on_demand_active=True, follower_active=False)
|
||||
|
||||
|
||||
def _session(modes=("a", "b", "c"), index=0, expires_at=None, current=None):
|
||||
return ArbiterState(current_mode=current, on_demand_modes=tuple(modes),
|
||||
on_demand_index=index, on_demand_expires_at=expires_at)
|
||||
|
||||
|
||||
class TestOnDemand:
|
||||
|
||||
# (modes, index, expires_at, now) -> (mode, max_duration)
|
||||
TABLE = [
|
||||
(("a", "b", "c"), 0, None, 100.0, "a", None), # untimed
|
||||
(("a", "b", "c"), 2, None, 100.0, "c", None),
|
||||
(("a", "b", "c"), 3, None, 100.0, "a", None), # past the end: 0
|
||||
(("a", "b", "c"), 9, None, 100.0, "a", None),
|
||||
(("a",), 0, 130.0, 100.0, "a", 30.0), # 30 s left
|
||||
(("a",), 0, 130.0, 130.0, "a", 0.0), # none left
|
||||
(("a",), 0, 130.0, 200.0, "a", 0.0), # never negative
|
||||
]
|
||||
|
||||
@pytest.mark.parametrize("modes,index,expires_at,now,mode,max_duration", TABLE)
|
||||
def test_current_mode_and_time_left(self, modes, index, expires_at, now, mode,
|
||||
max_duration):
|
||||
plan = Arbiter.decide(_session(modes, index, expires_at), ON, now)
|
||||
assert plan.source is ONDEM
|
||||
assert plan.mode == mode
|
||||
assert plan.max_duration == max_duration
|
||||
assert plan.deadline == expires_at
|
||||
|
||||
def test_no_modes_left_is_a_plan_with_no_mode(self):
|
||||
plan = Arbiter.decide(_session(modes=()), ON, 0.0)
|
||||
assert plan == ScreenPlan(ONDEM)
|
||||
|
||||
def test_preemptible_by_the_schedule_a_reload_and_its_own_changes(self):
|
||||
plan = Arbiter.decide(_session(), ON, 0.0)
|
||||
assert {Source.SCHEDULED_OFF, ONDEM, Source.RELOAD} <= plan.preemptible_by
|
||||
assert Source.FOLLOWER not in plan.preemptible_by
|
||||
|
||||
@pytest.mark.parametrize("index,expected_index,expected_mode", [
|
||||
(0, 1, "b"), (1, 2, "c"), (2, 0, "a")])
|
||||
def test_next_on_demand_wraps(self, index, expected_index, expected_mode):
|
||||
nxt = _session(index=index).next_on_demand()
|
||||
assert (nxt.on_demand_index, nxt.current_mode) == (expected_index, expected_mode)
|
||||
|
||||
def test_showing_a_plan_resets_an_index_past_the_end(self):
|
||||
state = _session(index=5, current="x")
|
||||
plan = Arbiter.decide(state, ON, 0.0)
|
||||
shown = state.showing(plan)
|
||||
assert (shown.on_demand_index, shown.current_mode) == (0, "a")
|
||||
assert state.on_demand_index == 5 # not mutated
|
||||
|
||||
|
||||
# (min, max, deadline, now) -> bounds. The bound applied after the first frame.
|
||||
BOUND_TABLE = [
|
||||
(10.0, 20.0, None, 0.0, (10.0, 20.0)), # untimed: unchanged
|
||||
(10.0, 20.0, 100.0, 50.0, (10.0, 20.0)), # plenty left
|
||||
(10.0, 20.0, 100.0, 85.0, (10.0, 15.0)), # max cut to what is left
|
||||
(10.0, 20.0, 100.0, 95.0, (5.0, 5.0)), # both cut
|
||||
(10.0, 20.0, 100.0, 100.0, None), # nothing left
|
||||
(10.0, 20.0, 100.0, 150.0, None),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("min_d,max_d,deadline,now,expected", BOUND_TABLE)
|
||||
def test_on_demand_bound(min_d, max_d, deadline, now, expected):
|
||||
assert on_demand_bound(min_d, max_d, deadline, now) == expected
|
||||
|
||||
|
||||
# -- Live (stage 3) -------------------------------------------------------
|
||||
|
||||
LIVE = Source.LIVE
|
||||
|
||||
# (live_modes, current_mode, advance) -> pick. _check_live_priority's rule.
|
||||
PICK_TABLE = [
|
||||
((), "clock", True, None),
|
||||
(None, "clock", True, None),
|
||||
(("nfl",), "clock", True, "nfl"), # not on a live mode: first
|
||||
(("nfl", "nhl"), "clock", True, "nfl"),
|
||||
(("nfl", "nhl"), "clock", False, "nfl"),
|
||||
(("nfl", "nhl"), "nfl", True, "nhl"), # round-robin
|
||||
(("nfl", "nhl"), "nhl", True, "nfl"), # wraps
|
||||
(("nfl", "nhl"), "nhl", False, "nhl"), # a peek stays put
|
||||
(("nfl",), "nfl", True, "nfl"), # one game: itself
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("live,current,advance,expected", PICK_TABLE)
|
||||
def test_live_pick(live, current, advance, expected):
|
||||
assert live_pick(live, current, advance) == expected
|
||||
|
||||
|
||||
def _below(live=None, vegas=False, keeps=False, yielded=False, on_demand=False):
|
||||
"""Inputs for the Sources below the notice (no notice, no follower)."""
|
||||
return ArbiterInputs(schedule_on=True, on_demand_active=on_demand,
|
||||
follower_active=False, live_modes=live, vegas_enabled=vegas,
|
||||
vegas_live_in_ticker=keeps, vegas_yielded=yielded)
|
||||
|
||||
|
||||
ROT = ("clock", "weather", "nfl_live", "nhl_live")
|
||||
|
||||
|
||||
def _rot(current="clock", index=0, resume=None, unshown=False):
|
||||
return ArbiterState(current_mode=current, rotation=ROT, rotation_index=index,
|
||||
live_resume_index=resume, live_takeover_unshown=unshown)
|
||||
|
||||
|
||||
class TestLive:
|
||||
|
||||
# (state, inputs) -> (source, mode, ends_live)
|
||||
TABLE = [
|
||||
# Nothing live, nothing to resume.
|
||||
(_rot(), _below(live=()), "below", None, False),
|
||||
# Not scanned (on-demand, or the ticker keeps live content).
|
||||
(_rot(), _below(live=None), "below", None, False),
|
||||
# A game is live: it takes the panel.
|
||||
(_rot(), _below(live=("nfl_live",)), LIVE, "nfl_live", False),
|
||||
# Two: round-robin from the one showing.
|
||||
(_rot("nfl_live", 2), _below(live=("nfl_live", "nhl_live")), LIVE, "nhl_live", False),
|
||||
# ... unless a mid-screen takeover chose it and it has not shown yet.
|
||||
(_rot("nfl_live", 2, resume=0, unshown=True),
|
||||
_below(live=("nfl_live", "nhl_live")), LIVE, "nfl_live", False),
|
||||
# Vegas keeps live content in its ticker: Live has no say at all,
|
||||
# not even the resume.
|
||||
(_rot(), _below(live=("nfl_live",), vegas=True, keeps=True), "below", None, False),
|
||||
(_rot("nfl_live", 2, resume=1),
|
||||
_below(live=(), vegas=True, keeps=True), "below", None, False),
|
||||
# Vegas that yields to live content: Live outranks it.
|
||||
(_rot(), _below(live=("nfl_live",), vegas=True), LIVE, "nfl_live", False),
|
||||
# The game ended: the interrupted rotation resumes.
|
||||
(_rot("nfl_live", 2, resume=1), _below(live=()), "below", None, True),
|
||||
(_rot("nfl_live", 2, resume=1), _below(live=(), vegas=True), "below", None, True),
|
||||
# On-demand outranks Live.
|
||||
(ArbiterState(on_demand_modes=("x",)), _below(live=("nfl_live",), on_demand=True),
|
||||
ONDEM, "x", False),
|
||||
]
|
||||
|
||||
@pytest.mark.parametrize("state,inputs,source,mode,ends_live", TABLE)
|
||||
def test_decide(self, state, inputs, source, mode, ends_live):
|
||||
plan = Arbiter.decide(state, inputs, 0.0)
|
||||
if source == "below":
|
||||
assert plan.source not in (LIVE, ONDEM, OFF, FOLLOW, WIFI)
|
||||
else:
|
||||
assert plan.source is source
|
||||
assert plan.mode == mode
|
||||
assert plan.ends_live is ends_live
|
||||
|
||||
def test_a_live_plan_has_no_durations_until_its_first_frame(self):
|
||||
plan = Arbiter.decide(_rot(), _below(live=("nfl_live",)), 0.0)
|
||||
assert (plan.min_duration, plan.max_duration, plan.frame_policy) == (None, None, None)
|
||||
|
||||
|
||||
class TestLiveTransitions:
|
||||
|
||||
def test_claim_saves_where_the_rotation_was(self):
|
||||
nxt = _rot("weather", 1).claim_live("nfl_live")
|
||||
assert (nxt.current_mode, nxt.rotation_index, nxt.live_resume_index) == ("nfl_live", 2, 1)
|
||||
|
||||
def test_a_second_claim_keeps_the_first_resume_point(self):
|
||||
nxt = _rot("nfl_live", 2, resume=1).claim_live("nhl_live")
|
||||
assert (nxt.current_mode, nxt.rotation_index, nxt.live_resume_index) == ("nhl_live", 3, 1)
|
||||
|
||||
def test_claiming_the_mode_showing_changes_nothing(self):
|
||||
state = _rot("nfl_live", 2, resume=1)
|
||||
assert state.claim_live("nfl_live") is state
|
||||
|
||||
def test_a_live_mode_outside_the_rotation_keeps_the_index(self):
|
||||
nxt = _rot("weather", 1).claim_live("mlb_live")
|
||||
assert (nxt.current_mode, nxt.rotation_index, nxt.live_resume_index) == ("mlb_live", 1, 1)
|
||||
|
||||
def test_release_resumes_and_forgets(self):
|
||||
nxt = _rot("nhl_live", 3, resume=1).release_live()
|
||||
assert (nxt.current_mode, nxt.rotation_index, nxt.live_resume_index) == ("weather", 1, None)
|
||||
|
||||
def test_release_wraps_a_resume_point_past_a_shortened_rotation(self):
|
||||
nxt = _rot("nhl_live", 3, resume=6).release_live()
|
||||
assert (nxt.current_mode, nxt.rotation_index) == ("nfl_live", 2) # 6 % 4
|
||||
|
||||
def test_release_with_nothing_to_resume_changes_nothing(self):
|
||||
state = _rot("clock", 0)
|
||||
assert state.release_live() is state
|
||||
empty = ArbiterState(current_mode="x", live_resume_index=2)
|
||||
assert empty.release_live() is empty # no rotation to resume into
|
||||
|
||||
|
||||
# -- Vegas and Rotation (stage 3) -----------------------------------------
|
||||
|
||||
class TestVegasAndRotation:
|
||||
|
||||
# (state, inputs) -> (source, mode, ends_live). LEGACY now means Vegas only.
|
||||
TABLE = [
|
||||
(_rot("weather", 1), _below(live=()), ROTATION, "weather", False),
|
||||
(_rot("weather", 1), _below(live=None), ROTATION, "weather", False),
|
||||
(_rot("weather", 1), _below(live=(), vegas=True), LEGACY, None, False),
|
||||
(_rot("weather", 1), _below(live=None, vegas=True, keeps=True), LEGACY, None, False),
|
||||
# The iteration yielded: the screen it fell through to.
|
||||
(_rot("weather", 1), _below(live=(), vegas=True, yielded=True),
|
||||
ROTATION, "weather", False),
|
||||
(_rot("weather", 1), _below(live=("nfl_live",), vegas=True, yielded=True),
|
||||
LIVE, "nfl_live", False),
|
||||
# Live priority just ended: the rotation resumes where it was cut.
|
||||
(_rot("nhl_live", 3, resume=1), _below(live=()), ROTATION, "weather", True),
|
||||
# ... and Vegas carries the resume through to its own pass.
|
||||
(_rot("nhl_live", 3, resume=1), _below(live=(), vegas=True), LEGACY, None, True),
|
||||
# A rotation that something moved off its list carries on from there.
|
||||
(ArbiterState(current_mode=None, rotation=ROT), _below(live=()), ROTATION, None, False),
|
||||
]
|
||||
|
||||
@pytest.mark.parametrize("state,inputs,source,mode,ends_live", TABLE)
|
||||
def test_decide(self, state, inputs, source, mode, ends_live):
|
||||
plan = Arbiter.decide(state, inputs, 0.0)
|
||||
assert (plan.source, plan.mode, plan.ends_live) == (source, mode, ends_live)
|
||||
|
||||
def test_a_rotation_plan_may_be_preempted_by_everything_a_screen_watches(self):
|
||||
plan = Arbiter.decide(_rot(), _below(live=()), 0.0)
|
||||
assert plan.preemptible_by == SCREEN_PREEMPTERS
|
||||
assert {OFF, ONDEM, WIFI, LIVE, ROTATION, Source.RELOAD} == SCREEN_PREEMPTERS
|
||||
|
||||
|
||||
class _End:
|
||||
def __init__(self, on_demand_active=False, still_live=False):
|
||||
self.on_demand_active = on_demand_active
|
||||
self.still_live = still_live
|
||||
|
||||
|
||||
class TestAfter:
|
||||
"""ArbiterState.after: _advance_after_screen's step."""
|
||||
|
||||
def test_the_rotation_advances(self):
|
||||
nxt = _rot("weather", 1).after(_End())
|
||||
assert (nxt.current_mode, nxt.rotation_index) == ("nfl_live", 2)
|
||||
|
||||
def test_it_wraps(self):
|
||||
nxt = _rot("nhl_live", 3).after(_End())
|
||||
assert (nxt.current_mode, nxt.rotation_index) == ("clock", 0)
|
||||
|
||||
def test_a_live_mode_still_live_holds(self):
|
||||
state = _rot("nfl_live", 2)
|
||||
assert state.after(_End(still_live=True)) is state
|
||||
|
||||
def test_an_on_demand_session_moves_to_its_next_mode(self):
|
||||
state = replace(_session(index=1, current="b"), rotation=ROT, rotation_index=1)
|
||||
nxt = state.after(_End(on_demand_active=True))
|
||||
assert (nxt.current_mode, nxt.on_demand_index, nxt.rotation_index) == ("c", 2, 1)
|
||||
|
||||
def test_a_session_with_no_modes_is_left_to_the_controller(self):
|
||||
state = ArbiterState(current_mode="x", rotation=ROT)
|
||||
assert state.after(_End(on_demand_active=True)) is state
|
||||
|
||||
def test_no_rotation_no_step(self):
|
||||
state = ArbiterState(current_mode="x")
|
||||
assert state.after(_End()) is state
|
||||
|
||||
|
||||
# -- Mid-screen: decide(..., running=plan) (stage 3) ----------------------
|
||||
#
|
||||
# What the ScreenRunner asks at each service point. These rows are what
|
||||
# _check_live_takeover, _screen_preempted and _wifi_notice_pending answered
|
||||
# between frames before stage 3, written out.
|
||||
|
||||
CLOCK = Arbiter.decide(ArbiterState(current_mode="clock"), _below(live=()), 0.0)
|
||||
NFL = Arbiter.decide(ArbiterState(current_mode="clock"), _below(live=("nfl_live",)), 0.0)
|
||||
OD = Arbiter.decide(_session(modes=("x", "y")), ON, 0.0)
|
||||
FRESH = WifiNotice(message="AP mode", expires_at=1_000.0)
|
||||
|
||||
HELD = "held"
|
||||
|
||||
|
||||
def _mid(on_demand=False, schedule_on=True, live=None, notice=None, reload=False):
|
||||
return ArbiterInputs(schedule_on=schedule_on, on_demand_active=on_demand,
|
||||
follower_active=False, live_modes=live, wifi_notice=notice,
|
||||
reload_pending=reload)
|
||||
|
||||
|
||||
# (running, current_mode, inputs, now) -> HELD or (source, mode)
|
||||
MID_TABLE = [
|
||||
# Nothing changed.
|
||||
(CLOCK, "clock", _mid(), 500.0, HELD),
|
||||
(CLOCK, "clock", _mid(live=()), 500.0, HELD),
|
||||
# A game went live: it takes the panel (the first live mode).
|
||||
(CLOCK, "clock", _mid(live=("nfl_live", "nhl_live")), 500.0, (LIVE, "nfl_live")),
|
||||
# ... even with a notice pending: the claim is made now, and the next
|
||||
# pass shows the notice first (top-of-pass order), then the game.
|
||||
(CLOCK, "clock", _mid(live=("nfl_live",), notice=FRESH), 500.0, (LIVE, "nfl_live")),
|
||||
# ... but not over the schedule or an on-demand session.
|
||||
(CLOCK, "clock", _mid(live=("nfl_live",), schedule_on=False), 500.0, (OFF, None)),
|
||||
(CLOCK, "x", _mid(live=("nfl_live",), on_demand=True), 500.0, (ONDEM, "x")),
|
||||
# A screen already on a live mode is not taken over by another.
|
||||
(CLOCK, "nfl_live", _mid(live=("nfl_live", "nhl_live")), 500.0, (ROTATION, "nfl_live")),
|
||||
(NFL, "nfl_live", _mid(live=("nhl_live",)), 500.0, HELD),
|
||||
# The mode moved under the screen: on-demand started, or ended, or the
|
||||
# rotation was rebuilt.
|
||||
(CLOCK, "x", _mid(on_demand=True), 500.0, (ONDEM, "x")),
|
||||
(OD, "weather", _mid(), 500.0, (ROTATION, "weather")),
|
||||
(CLOCK, "weather", _mid(), 500.0, (ROTATION, "weather")),
|
||||
# An on-demand session that ends on the same mode keeps the screen.
|
||||
(OD, "x", _mid(), 500.0, HELD),
|
||||
# The schedule: off ends it; an on-demand override holds.
|
||||
(CLOCK, "clock", _mid(schedule_on=False), 500.0, (OFF, None)),
|
||||
(OD, "x", _mid(on_demand=True, schedule_on=False), 500.0, HELD),
|
||||
# A WiFi notice, compared with its expiry; on-demand outranks it.
|
||||
(CLOCK, "clock", _mid(notice=FRESH), 999.9, (WIFI, None)),
|
||||
(CLOCK, "clock", _mid(notice=FRESH), 1_000.0, HELD),
|
||||
(NFL, "nfl_live", _mid(notice=FRESH), 500.0, (WIFI, None)),
|
||||
(OD, "x", _mid(on_demand=True, notice=FRESH), 500.0, HELD),
|
||||
# A plugin reload waits at the top of the loop.
|
||||
(CLOCK, "clock", _mid(reload=True), 500.0, (Source.RELOAD, None)),
|
||||
(OD, "x", _mid(on_demand=True, reload=True), 500.0, (Source.RELOAD, None)),
|
||||
# The order between them: a moved mode before the schedule, the
|
||||
# schedule before a notice, a notice before a reload.
|
||||
(CLOCK, "weather", _mid(schedule_on=False), 500.0, (ROTATION, "weather")),
|
||||
(CLOCK, "clock", _mid(schedule_on=False, notice=FRESH), 500.0, (OFF, None)),
|
||||
(CLOCK, "clock", _mid(notice=FRESH, reload=True), 500.0, (WIFI, None)),
|
||||
]
|
||||
|
||||
|
||||
class TestMidScreen:
|
||||
|
||||
@pytest.mark.parametrize("running,current,inputs,now,expected", MID_TABLE)
|
||||
def test_decide(self, running, current, inputs, now, expected):
|
||||
state = ArbiterState(current_mode=current)
|
||||
plan = Arbiter.decide(state, inputs, now, running=running)
|
||||
if expected == HELD:
|
||||
assert plan is running
|
||||
else:
|
||||
assert plan is not running
|
||||
assert (plan.source, plan.mode) == expected
|
||||
|
||||
def test_the_running_plans(self):
|
||||
assert (CLOCK.source, CLOCK.mode) == (ROTATION, "clock")
|
||||
assert (NFL.source, NFL.mode) == (LIVE, "nfl_live")
|
||||
assert LIVE not in NFL.preemptible_by
|
||||
assert (OD.source, OD.mode) == (ONDEM, "x")
|
||||
|
||||
def test_a_follower_and_vegas_never_preempt(self):
|
||||
for plan in (CLOCK, NFL, OD):
|
||||
assert FOLLOW not in plan.preemptible_by
|
||||
assert LEGACY not in plan.preemptible_by
|
||||
|
||||
def test_nothing_in_preemptible_by_means_nothing_preempts(self):
|
||||
bare = replace(CLOCK, preemptible_by=frozenset())
|
||||
inputs = _mid(live=("nfl_live",), schedule_on=False, notice=FRESH, reload=True)
|
||||
assert Arbiter.decide(ArbiterState(current_mode="weather"), inputs, 0.0,
|
||||
running=bare) is bare
|
||||
|
||||
def test_mid_screen_decide_reads_no_clock(self):
|
||||
boom = MagicMock(side_effect=AssertionError("decide read the clock"))
|
||||
with patch("time.time", boom), patch("time.monotonic", boom):
|
||||
for running, current, inputs, now, _ in MID_TABLE:
|
||||
Arbiter.decide(ArbiterState(current_mode=current), inputs, now,
|
||||
running=running)
|
||||
|
||||
|
||||
# (current, inputs) -> live_takeover. The mode a mid-screen check claims.
|
||||
TAKEOVER_TABLE = [
|
||||
("clock", _mid(live=("nfl_live",)), "nfl_live"),
|
||||
("clock", _mid(live=()), None),
|
||||
("clock", _mid(live=None), None), # no scan was due
|
||||
("nfl_live", _mid(live=("nfl_live",)), None),
|
||||
("clock", _mid(live=("nfl_live",), on_demand=True), None),
|
||||
("clock", _mid(live=("nfl_live",), schedule_on=False), None),
|
||||
("clock", replace(_mid(live=("nfl_live",)), vegas_enabled=True,
|
||||
vegas_live_in_ticker=True), None),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("current,inputs,expected", TAKEOVER_TABLE)
|
||||
def test_live_takeover(current, inputs, expected):
|
||||
assert live_takeover(ArbiterState(current_mode=current), inputs) == expected
|
||||
assert step("07:00:30") is LEGACY
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
"""The report of a scrolling screen held by its plugin's update().
|
||||
|
||||
While a plugin's update() runs it holds the plugin's lock, and its screen's
|
||||
frames are skipped -- on a scroller, a frozen strip -- with nothing logged.
|
||||
_note_display_hold times each such run and reports one of
|
||||
DISPLAY_HOLD_REPORT_SECONDS or more.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import types
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class _Clock:
|
||||
"""display_controller's clock: moves only when run() sleeps or a test says."""
|
||||
|
||||
def __init__(self, start=10_000.0):
|
||||
self.t = start
|
||||
|
||||
def now(self):
|
||||
return self.t
|
||||
|
||||
def sleep(self, seconds):
|
||||
self.t += max(seconds, 0.0005)
|
||||
|
||||
def module(self):
|
||||
return types.SimpleNamespace(time=self.now, monotonic=self.now,
|
||||
perf_counter=self.now, sleep=self.sleep)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clock(monkeypatch):
|
||||
c = _Clock()
|
||||
monkeypatch.setattr("src.display_controller.time", c.module())
|
||||
return c
|
||||
|
||||
|
||||
class _Locks:
|
||||
"""get_plugin_lock for one plugin, whose lock the test can hold."""
|
||||
|
||||
def __init__(self):
|
||||
self.lock = threading.Lock()
|
||||
|
||||
def __call__(self, plugin_id):
|
||||
return self.lock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def held(test_display_controller):
|
||||
c = test_display_controller
|
||||
locks = _Locks()
|
||||
c.plugin_manager.get_plugin_lock = locks
|
||||
c.plugin_manager._warn_rate_limited = MagicMock()
|
||||
c.plugin_manager.health_tracker = MagicMock()
|
||||
c._display_hold = None
|
||||
return c, locks.lock
|
||||
|
||||
|
||||
def _plugin(plugin_id):
|
||||
p = MagicMock()
|
||||
p.plugin_id = plugin_id
|
||||
p.display.return_value = True
|
||||
return p
|
||||
|
||||
|
||||
class TestTheDisplayHoldReport:
|
||||
def test_a_long_hold_is_reported_when_it_ends(self, held, clock):
|
||||
c, lock = held
|
||||
ticker = _plugin("ticker")
|
||||
lock.acquire() # update() running
|
||||
assert c._display_once(ticker, "ticker", False, report_hold=True) is True
|
||||
clock.t += 0.2
|
||||
assert c._display_once(ticker, "ticker", False, report_hold=True) is True
|
||||
assert ticker.display.call_count == 0
|
||||
c.plugin_manager._warn_rate_limited.assert_not_called()
|
||||
clock.t += 0.2
|
||||
lock.release() # update() done
|
||||
c._display_once(ticker, "ticker", False, report_hold=True)
|
||||
assert ticker.display.call_count == 1
|
||||
key, message, plugin_id, ms = c.plugin_manager._warn_rate_limited.call_args[0]
|
||||
assert key == "display-hold:ticker" and plugin_id == "ticker"
|
||||
assert "held" in message and ms == pytest.approx(400.0)
|
||||
c.plugin_manager.health_tracker.record_busy_skip.assert_called_once_with(
|
||||
"ticker", "display hold", pytest.approx(0.4))
|
||||
|
||||
def test_a_short_hold_is_not(self, held, clock):
|
||||
c, lock = held
|
||||
ticker = _plugin("ticker")
|
||||
lock.acquire()
|
||||
c._display_once(ticker, "ticker", False, report_hold=True)
|
||||
clock.t += 0.1
|
||||
lock.release()
|
||||
c._display_once(ticker, "ticker", False, report_hold=True)
|
||||
c.plugin_manager._warn_rate_limited.assert_not_called()
|
||||
c.plugin_manager.health_tracker.record_busy_skip.assert_not_called()
|
||||
|
||||
def test_a_hold_that_ends_on_another_plugins_screen_is_not_blamed_on_it(
|
||||
self, held, clock):
|
||||
c, lock = held
|
||||
lock.acquire()
|
||||
c._display_once(_plugin("ticker"), "ticker", False, report_hold=True)
|
||||
clock.t += 1.0
|
||||
lock.release()
|
||||
c._display_once(_plugin("clock"), "clock", False, report_hold=True)
|
||||
c.plugin_manager._warn_rate_limited.assert_not_called()
|
||||
assert c._display_hold is None
|
||||
|
||||
def test_frames_that_draw_report_nothing(self, held, clock):
|
||||
c, _lock = held
|
||||
ticker = _plugin("ticker")
|
||||
for _ in range(5):
|
||||
c._display_once(ticker, "ticker", False, report_hold=True)
|
||||
clock.t += 0.5
|
||||
c.plugin_manager._warn_rate_limited.assert_not_called()
|
||||
|
||||
def test_the_1hz_loop_reports_no_holds(self, held, clock):
|
||||
# A static screen's frames are a second apart: one skipped frame is
|
||||
# not a measured hold, and nothing on the panel froze. (On ledpi the
|
||||
# first version reported every such skip as "held 1000 ms".)
|
||||
c, lock = held
|
||||
board = _plugin("board")
|
||||
lock.acquire()
|
||||
c._display_once(board, "board", False)
|
||||
clock.t += 1.0
|
||||
lock.release()
|
||||
c._display_once(board, "board", False)
|
||||
c.plugin_manager._warn_rate_limited.assert_not_called()
|
||||
c.plugin_manager.health_tracker.record_busy_skip.assert_not_called()
|
||||
|
||||
def test_a_run_left_open_is_dropped_by_a_1hz_frame(self, held, clock):
|
||||
c, lock = held
|
||||
ticker = _plugin("ticker")
|
||||
lock.acquire()
|
||||
c._display_once(ticker, "ticker", False, report_hold=True)
|
||||
lock.release()
|
||||
c._display_once(ticker, "ticker", False) # the 1 Hz loop draws
|
||||
assert c._display_hold is None
|
||||
clock.t += 5.0
|
||||
c._display_once(ticker, "ticker", False, report_hold=True)
|
||||
c.plugin_manager._warn_rate_limited.assert_not_called()
|
||||
@@ -430,157 +430,6 @@ class TestClear:
|
||||
assert "clear request" in response.get_json()["message"]
|
||||
|
||||
|
||||
CLIENT = "web_interface.blueprints.api_v3.control_client"
|
||||
|
||||
|
||||
def _mailbox_file(shared_cache):
|
||||
_, _, directory = shared_cache
|
||||
return directory / f"{ERROR_CLEAR_REQUEST_KEY}.json"
|
||||
|
||||
|
||||
class TestClearOverTheSocket:
|
||||
"""``errors.clear``: the display applies the clear before it answers, and
|
||||
the mailbox is written only when the socket could not carry it."""
|
||||
|
||||
@pytest.fixture
|
||||
def socket_up(self, display, monkeypatch):
|
||||
"""The control socket, as the display serves it: errors_clear runs
|
||||
the display's own handler against its publisher."""
|
||||
from src.ipc import client as control_client
|
||||
from src.ipc.contract import ErrorsClearArgs
|
||||
_, publisher, _ = display
|
||||
monkeypatch.setattr(errors, "_snapshot_publisher", publisher)
|
||||
calls = []
|
||||
|
||||
def errors_clear(request_id, cutoff, **kw):
|
||||
calls.append((request_id, cutoff))
|
||||
return errors.apply_error_clear(request_id, ErrorsClearArgs(cutoff=cutoff))
|
||||
|
||||
monkeypatch.setattr(f"{CLIENT}.errors_clear", errors_clear)
|
||||
assert control_client.errors_clear is errors_clear
|
||||
return calls
|
||||
|
||||
def test_socket_clear_is_applied_before_the_answer(self, web, display, socket_up,
|
||||
shared_cache):
|
||||
aggregator, publisher, _ = display
|
||||
for _ in range(3):
|
||||
_fail(aggregator)
|
||||
publisher.tick()
|
||||
response = web.post("/api/v3/errors/clear", json={"all": True})
|
||||
assert response.status_code == 200
|
||||
body = response.get_json()
|
||||
data = body["data"]
|
||||
assert data["transport"] == "socket" and data["applied"] is True
|
||||
assert data["cleared_count"] == 3
|
||||
assert body["message"] == "Cleared all errors"
|
||||
[(request_id, _)] = socket_up
|
||||
assert data["request_id"] == request_id
|
||||
# Applied already: no display tick needed, nothing pending.
|
||||
assert aggregator.get_error_summary()["total_errors"] == 0
|
||||
summary = _summary(web)
|
||||
assert summary["total_errors"] == 0 and summary["clear_pending"] is False
|
||||
# And no mailbox file.
|
||||
assert not _mailbox_file(shared_cache).exists()
|
||||
|
||||
def test_an_older_mailbox_request_does_not_read_as_pending(self, web, display, socket_up,
|
||||
shared_cache, monkeypatch):
|
||||
# A clear that went to the mailbox while the socket was down, then a
|
||||
# wider one over the socket: the old request has nothing left to hide.
|
||||
from unittest.mock import patch
|
||||
from src.ipc import client as control_client
|
||||
aggregator, publisher, _ = display
|
||||
_fail(aggregator)
|
||||
publisher.tick()
|
||||
with patch(f"{CLIENT}.errors_clear",
|
||||
side_effect=control_client.ControlError("no_socket")):
|
||||
data = web.post("/api/v3/errors/clear", json={"max_age_hours": 1}).get_json()["data"]
|
||||
assert data["transport"] == "mailbox"
|
||||
assert _mailbox_file(shared_cache).exists()
|
||||
data = web.post("/api/v3/errors/clear", json={"all": True}).get_json()["data"]
|
||||
assert data["transport"] == "socket"
|
||||
assert _summary(web)["clear_pending"] is False
|
||||
|
||||
@pytest.mark.parametrize("reason", ["no_socket", "refused", "disabled", "unsupported"])
|
||||
def test_no_socket_writes_the_mailbox(self, web, display, shared_cache, monkeypatch, reason):
|
||||
from src.ipc import client as control_client
|
||||
monkeypatch.setattr(f"{CLIENT}.errors_clear", MagicMock(
|
||||
side_effect=control_client.ControlError(reason, sent=False)))
|
||||
aggregator, publisher, _ = display
|
||||
_fail(aggregator)
|
||||
publisher.tick()
|
||||
data = web.post("/api/v3/errors/clear", json={"all": True}).get_json()["data"]
|
||||
assert data["transport"] == "mailbox" and data["applied"] is False
|
||||
assert _mailbox_file(shared_cache).exists()
|
||||
assert _summary(web)["clear_pending"] is True
|
||||
publisher.tick()
|
||||
assert aggregator.get_error_summary()["total_errors"] == 0
|
||||
|
||||
def test_an_older_display_gets_the_mailbox(self, web, display, shared_cache, monkeypatch):
|
||||
# The upgrade case: new web interface, a display from before errors.clear.
|
||||
from src.ipc import client as control_client
|
||||
monkeypatch.setattr(f"{CLIENT}.errors_clear", MagicMock(
|
||||
side_effect=control_client.ControlError("unknown_command", sent=True)))
|
||||
aggregator, publisher, _ = display
|
||||
_fail(aggregator)
|
||||
publisher.tick()
|
||||
data = web.post("/api/v3/errors/clear", json={"all": True}).get_json()["data"]
|
||||
assert data["transport"] == "mailbox"
|
||||
publisher.tick()
|
||||
assert aggregator.get_error_summary()["total_errors"] == 0
|
||||
|
||||
@pytest.mark.parametrize("reason", ["internal", "timeout", "busy", "invalid_args"])
|
||||
def test_a_display_that_had_it_and_failed_is_an_error(self, web, display, shared_cache,
|
||||
monkeypatch, reason):
|
||||
from src.ipc import client as control_client
|
||||
monkeypatch.setattr(f"{CLIENT}.errors_clear", MagicMock(
|
||||
side_effect=control_client.ControlError(reason, sent=True)))
|
||||
response = web.post("/api/v3/errors/clear", json={"all": True})
|
||||
assert response.status_code == 503
|
||||
assert response.get_json()["context"]["socket_error"] == reason
|
||||
assert not _mailbox_file(shared_cache).exists()
|
||||
|
||||
|
||||
class TestPublisherMailboxPoll:
|
||||
def test_the_mailbox_is_read_only_when_its_file_changed(self, display, shared_cache):
|
||||
_, publisher, _ = display
|
||||
_, web_cache, _ = shared_cache
|
||||
publisher.tick()
|
||||
publisher.cache_manager = MagicMock(wraps=publisher.cache_manager)
|
||||
|
||||
def reads():
|
||||
return [c for c in publisher.cache_manager.get.call_args_list
|
||||
if c.args[0] == ERROR_CLEAR_REQUEST_KEY]
|
||||
|
||||
for _ in range(5):
|
||||
publisher.tick()
|
||||
assert reads() == [] # no file: a stat per tick, no read
|
||||
errors.request_error_clear(web_cache, 1.0)
|
||||
publisher.tick()
|
||||
assert len(reads()) == 1
|
||||
for _ in range(5):
|
||||
publisher.tick()
|
||||
assert len(reads()) == 1 # unchanged file: not read again
|
||||
errors.request_error_clear(web_cache, 2.0)
|
||||
publisher.tick()
|
||||
assert len(reads()) == 2
|
||||
|
||||
def test_clear_now_publishes_what_it_applied(self, display, shared_cache):
|
||||
aggregator, publisher, _ = display
|
||||
_, web_cache, _ = shared_cache
|
||||
_fail(aggregator)
|
||||
assert publisher.clear_now("sock-1", datetime.now().timestamp() + 1) == 1
|
||||
snapshot = web_cache.get(ERROR_SNAPSHOT_KEY, max_age=None, memory_ttl=0)
|
||||
assert snapshot["applied_clear_id"] == "sock-1"
|
||||
assert snapshot["total_errors"] == 0
|
||||
assert snapshot["applied_clear_cutoff"] is not None
|
||||
|
||||
def test_the_handler_needs_a_running_publisher(self, monkeypatch):
|
||||
from src.ipc.contract import ErrorsClearArgs
|
||||
monkeypatch.setattr(errors, "_snapshot_publisher", None)
|
||||
with pytest.raises(RuntimeError):
|
||||
errors.apply_error_clear("x", ErrorsClearArgs(cutoff=1.0))
|
||||
|
||||
|
||||
@pytest.mark.skipif(not hasattr(os, "fchmod") or os.name == "nt",
|
||||
reason="POSIX file modes")
|
||||
def test_both_files_are_group_readable(web, display, shared_cache):
|
||||
|
||||
+83
-151
@@ -39,6 +39,14 @@ def forget_rejected_ranges(monkeypatch):
|
||||
monkeypatch.setattr(espn_dates, "_ranges_rejected_until", 0.0)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def nothing_is_settled_yet(monkeypatch):
|
||||
"""Pin "today" before every date these tests use, so the settled-chunk
|
||||
memory stays out of tests that are not about it whatever the real date.
|
||||
TestSettledChunkCache moves it forward."""
|
||||
monkeypatch.setattr(espn_dates, "_utc_today", lambda: date(2000, 1, 1))
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, status_code=200, payload=None):
|
||||
self.status_code = status_code
|
||||
@@ -491,169 +499,93 @@ class TestConcurrency:
|
||||
assert live["peak"] > 1, "chunks should actually overlap"
|
||||
|
||||
|
||||
class TestEdgeMonths:
|
||||
"""A window's partial edge months are asked whole and trimmed.
|
||||
class TestSettledChunkCache:
|
||||
"""Days that ended three or more days ago are fetched once a day, not hourly.
|
||||
|
||||
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.
|
||||
The scoreboards re-fetch a 22-day window every hour; on hdpi (2026-10-02)
|
||||
the 12 settled days were 68% of that window's bytes.
|
||||
"""
|
||||
|
||||
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))),
|
||||
]
|
||||
TODAY = date(2026, 10, 2)
|
||||
# The scoreboards' default window on that day: 14 back, 7 ahead.
|
||||
WINDOW = "20260918-20261009"
|
||||
|
||||
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)]
|
||||
@pytest.fixture(autouse=True)
|
||||
def frozen_today(self, monkeypatch):
|
||||
monkeypatch.setattr(espn_dates, "_utc_today", lambda: self.TODAY)
|
||||
|
||||
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 _events(self):
|
||||
days = [(9, d) for d in range(18, 31)] + [(10, d) for d in range(1, 10)]
|
||||
return {"2026%02d%02d" % (m, d): [{"id": f"{m}-{d}"}] for m, d in days}
|
||||
|
||||
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_the_second_refresh_only_asks_for_unsettled_days(self):
|
||||
session = FakeSession(self._events())
|
||||
first = fetch_espn_scoreboard(session, URL, params={"dates": self.WINDOW})
|
||||
session.calls.clear()
|
||||
|
||||
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)
|
||||
second = fetch_espn_scoreboard(session, URL, params={"dates": self.WINDOW})
|
||||
|
||||
@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
|
||||
asked = sorted(call["dates"] for call in session.calls)
|
||||
# Sep 29 is the last settled day (today minus three).
|
||||
assert asked == ["20260930"] + ["202610%02d" % d for d in range(1, 10)]
|
||||
assert second["events"] == first["events"] # same events, same order
|
||||
assert len(second["events"]) == 22
|
||||
|
||||
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})
|
||||
def test_a_hit_is_a_fresh_copy(self):
|
||||
session = FakeSession(self._events())
|
||||
fetch_espn_date_chunks(session, URL, params={"dates": self.WINDOW})
|
||||
hit = fetch_espn_date_chunks(session, URL, params={"dates": self.WINDOW})
|
||||
hit["events"][0]["id"] = "mutated"
|
||||
again = fetch_espn_date_chunks(session, URL, params={"dates": self.WINDOW})
|
||||
assert again["events"][0]["id"] == "9-18"
|
||||
|
||||
data = fetch_espn_date_chunks(session, URL, params={"dates": "20260920-20261018"})
|
||||
def test_other_params_are_part_of_the_key(self):
|
||||
session = FakeSession(self._events())
|
||||
fetch_espn_date_chunks(session, URL, params={"dates": self.WINDOW, "groups": 80})
|
||||
session.calls.clear()
|
||||
fetch_espn_date_chunks(session, URL, params={"dates": self.WINDOW})
|
||||
assert len(session.calls) == 22 # a different question, nothing reused
|
||||
|
||||
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_entries_expire_after_a_day(self, monkeypatch):
|
||||
clock = [1000.0]
|
||||
monkeypatch.setattr(espn_dates.time, "monotonic", lambda: clock[0])
|
||||
session = FakeSession(self._events())
|
||||
fetch_espn_date_chunks(session, URL, params={"dates": self.WINDOW})
|
||||
clock[0] += espn_dates.SETTLED_CHUNK_TTL_SECONDS + 1
|
||||
session.calls.clear()
|
||||
fetch_espn_date_chunks(session, URL, params={"dates": self.WINDOW})
|
||||
assert len(session.calls) == 22
|
||||
|
||||
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_failed_chunks_are_not_remembered(self):
|
||||
session = FakeSession(self._events(), fail_chunks={"20260920"})
|
||||
fetch_espn_date_chunks(session, URL, params={"dates": self.WINDOW})
|
||||
session.fail_chunks.clear()
|
||||
session.calls.clear()
|
||||
data = fetch_espn_date_chunks(session, URL, params={"dates": self.WINDOW})
|
||||
assert "20260920" in [call["dates"] for call in session.calls]
|
||||
assert len(data["events"]) == 22
|
||||
|
||||
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)
|
||||
def test_a_capped_month_is_not_remembered_but_its_days_are(self):
|
||||
full = [{"id": f"x{i}"} for i in range(ESPN_MAX_LIMIT)]
|
||||
session = FakeSession({"202608": full, "20260801": [{"id": "d1"}]})
|
||||
fetch_espn_date_chunks(session, URL, params={"dates": "20260801-20260831"})
|
||||
session.calls.clear()
|
||||
data = fetch_espn_date_chunks(session, URL, params={"dates": "20260801-20260831"})
|
||||
assert [call["dates"] for call in session.calls] == ["202608"]
|
||||
assert [event["id"] for event in data["events"]] == ["d1"]
|
||||
|
||||
data = fetch_espn_date_chunks(session, URL, params={"dates": "20261001-20261010"})
|
||||
def test_memory_is_bounded(self, monkeypatch):
|
||||
monkeypatch.setattr(espn_dates, "SETTLED_CACHE_MAX_ENTRIES", 5)
|
||||
session = FakeSession(self._events())
|
||||
fetch_espn_date_chunks(session, URL, params={"dates": self.WINDOW})
|
||||
assert len(espn_dates._settled_chunks) == 5
|
||||
assert espn_dates._settled_bytes == sum(
|
||||
len(blob) for _, blob in espn_dates._settled_chunks.values())
|
||||
|
||||
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
|
||||
def test_single_day_requests_are_untouched(self):
|
||||
# The live path asks for today (or one day) as a plain request; that
|
||||
# never goes through chunks or the memory.
|
||||
session = FakeSession(self._events())
|
||||
for _ in range(2):
|
||||
fetch_espn_scoreboard(session, URL, params={"dates": "20260918"})
|
||||
assert len(session.calls) == 2
|
||||
|
||||
@@ -576,42 +576,6 @@ class TestCounters:
|
||||
assert snap["hosts"]["site.api.espn.com"]["requests"] == 1
|
||||
assert snap["totals"]["bytes"] == 3 * len(b'{"ok": 1}')
|
||||
|
||||
def test_wire_bytes_are_the_compressed_size(self, service):
|
||||
# Built the way requests builds a real response: a urllib3
|
||||
# HTTPResponse carrying a gzip body, decoded when .content is read.
|
||||
import gzip
|
||||
import io
|
||||
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.response import HTTPResponse
|
||||
|
||||
decoded = json.dumps({"events": [{"id": str(i), "name": "x" * 200}
|
||||
for i in range(50)]}).encode()
|
||||
wire = gzip.compress(decoded)
|
||||
|
||||
def handler(url, kwargs):
|
||||
raw = HTTPResponse(body=io.BytesIO(wire), status=200,
|
||||
headers={"Content-Encoding": "gzip",
|
||||
"Content-Type": "application/json"},
|
||||
preload_content=False, decode_content=True)
|
||||
request = requests.Request("GET", url).prepare()
|
||||
response = HTTPAdapter().build_response(request, raw)
|
||||
response.content # what Session.get does for a non-streamed call
|
||||
return response
|
||||
|
||||
response = service.get(FakeSession(handler), "https://site.api.espn.com/x")
|
||||
assert response.content == decoded
|
||||
totals = _counters(service)
|
||||
assert totals["bytes"] == len(decoded)
|
||||
assert totals["wire_bytes"] == len(wire) < len(decoded)
|
||||
|
||||
def test_wire_bytes_fall_back_to_the_decoded_size(self, service):
|
||||
# No urllib3 response behind it (a test double, another adapter):
|
||||
# count what is known rather than nothing.
|
||||
service.get(FakeSession(), "https://api.test/x")
|
||||
totals = _counters(service)
|
||||
assert totals["wire_bytes"] == totals["bytes"] == len(b'{"ok": 1}')
|
||||
|
||||
def test_errors_and_http_errors(self, service):
|
||||
def handler(url, kwargs):
|
||||
if url.endswith("/down"):
|
||||
@@ -706,14 +670,14 @@ class TestCallerIdentity:
|
||||
assert _counters(global_service, plugin="football-scoreboard")["requests"] == 1
|
||||
|
||||
def test_espn_chunks_on_worker_threads_count_against_the_caller(self, global_service):
|
||||
from src.common.espn_dates import espn_request_chunks, fetch_espn_date_chunks, parse_espn_date_range
|
||||
from src.common.espn_dates import espn_date_chunks, fetch_espn_date_chunks, parse_espn_date_range
|
||||
|
||||
session = FakeSession(lambda url, kw: make_response(body=b'{"events": []}', url=url))
|
||||
dates = "20260801-20261015"
|
||||
with plugin_scope("baseball-scoreboard"):
|
||||
fetch_espn_date_chunks(session, "https://site.api.espn.com/s/scoreboard",
|
||||
params={"dates": dates})
|
||||
chunks = len(espn_request_chunks(*parse_espn_date_range(dates)))
|
||||
chunks = len(espn_date_chunks(*parse_espn_date_range(dates)))
|
||||
assert chunks > 1
|
||||
assert len(session.calls) == chunks
|
||||
assert _counters(global_service, plugin="baseball-scoreboard")["requests"] == chunks
|
||||
|
||||
@@ -185,149 +185,6 @@ class TestPluginFonts:
|
||||
assert fm.font_catalog["my-plugin::bundled"] == str(plugin_dir / "fonts" / "Bundled.ttf")
|
||||
|
||||
|
||||
class TestForgetPluginFonts:
|
||||
"""forget_plugin_fonts drops what a plugin's manifest registered. Before
|
||||
it, unloading a plugin left its fonts resolvable and its cached font
|
||||
objects alive until a restart."""
|
||||
|
||||
@staticmethod
|
||||
def _register(fm, root, plugin_id, family="bundled"):
|
||||
plugin_dir = root / plugin_id
|
||||
(plugin_dir / "fonts").mkdir(parents=True, exist_ok=True)
|
||||
font_file = plugin_dir / "fonts" / f"{family}.ttf"
|
||||
if not font_file.exists(): # a loaded font may hold it open (Windows)
|
||||
shutil.copy(resolve_asset_path("assets/fonts/PressStart2P-Regular.ttf"), font_file)
|
||||
manifest = {"fonts": [{"family": family, "source": f"plugin://fonts/{family}.ttf"}]}
|
||||
assert fm.register_plugin_fonts(plugin_id, manifest, plugin_dir=plugin_dir)
|
||||
return plugin_dir
|
||||
|
||||
@staticmethod
|
||||
def _entries_of(fm, plugin_id):
|
||||
prefix = f"{plugin_id}::"
|
||||
return {
|
||||
"plugin_fonts": plugin_id in fm.plugin_fonts,
|
||||
"plugin_font_catalogs": plugin_id in fm.plugin_font_catalogs,
|
||||
"font_catalog": [k for k in fm.font_catalog if k.startswith(prefix)],
|
||||
"font_cache": [k for k in fm.font_cache if k.startswith(prefix)],
|
||||
}
|
||||
|
||||
NONE = {"plugin_fonts": False, "plugin_font_catalogs": False,
|
||||
"font_catalog": [], "font_cache": []}
|
||||
|
||||
def test_unload_leaves_no_plugin_entries(self, fm, tmp_path):
|
||||
self._register(fm, tmp_path, "alpha")
|
||||
fm.resolve_font("alpha.title", "bundled", 8, plugin_id="alpha")
|
||||
fm.get_font("alpha::bundled", 10)
|
||||
assert self._entries_of(fm, "alpha")["font_cache"] # cached before
|
||||
gen = fm.cache_generation
|
||||
|
||||
assert fm.forget_plugin_fonts("alpha") is True
|
||||
|
||||
assert self._entries_of(fm, "alpha") == self.NONE
|
||||
assert fm.cache_generation == gen + 1
|
||||
# The family no longer resolves to the plugin's file.
|
||||
assert fm.font_catalog.get("alpha::bundled") is None
|
||||
|
||||
def test_other_plugins_and_core_fonts_are_untouched(self, fm, tmp_path):
|
||||
self._register(fm, tmp_path, "alpha")
|
||||
self._register(fm, tmp_path, "beta")
|
||||
# A plugin whose id is a prefix of another's must not take it along.
|
||||
self._register(fm, tmp_path, "alpha-two")
|
||||
for pid in ("alpha", "beta", "alpha-two"):
|
||||
fm.get_font(f"{pid}::bundled", 8)
|
||||
core_font = fm.get_font("press_start", 8)
|
||||
beta_before = self._entries_of(fm, "beta")
|
||||
alpha_two_before = self._entries_of(fm, "alpha-two")
|
||||
|
||||
fm.forget_plugin_fonts("alpha")
|
||||
|
||||
assert self._entries_of(fm, "beta") == beta_before
|
||||
assert self._entries_of(fm, "alpha-two") == alpha_two_before
|
||||
assert fm.get_font("press_start", 8) is core_font
|
||||
|
||||
def test_reload_re_registers_cleanly(self, fm, tmp_path):
|
||||
plugin_dir = self._register(fm, tmp_path, "alpha")
|
||||
old = fm.get_font("alpha::bundled", 8)
|
||||
fm.forget_plugin_fonts("alpha")
|
||||
|
||||
self._register(fm, tmp_path, "alpha")
|
||||
|
||||
assert fm.font_catalog["alpha::bundled"] == str(plugin_dir / "fonts" / "bundled.ttf")
|
||||
font = fm.resolve_font("alpha.title", "bundled", 8, plugin_id="alpha")
|
||||
assert isinstance(font, ImageFont.FreeTypeFont)
|
||||
assert font is not old # loaded fresh, not the dropped cache entry
|
||||
|
||||
def test_a_family_the_new_manifest_drops_stops_resolving(self, fm, tmp_path):
|
||||
self._register(fm, tmp_path, "alpha", family="old_face")
|
||||
fm.forget_plugin_fonts("alpha")
|
||||
self._register(fm, tmp_path, "alpha", family="new_face")
|
||||
|
||||
assert "alpha::old_face" not in fm.font_catalog
|
||||
assert "alpha::new_face" in fm.font_catalog
|
||||
|
||||
def test_unknown_plugin_is_a_no_op(self, fm):
|
||||
catalog = dict(fm.font_catalog)
|
||||
gen = fm.cache_generation
|
||||
|
||||
assert fm.forget_plugin_fonts("never-registered") is False
|
||||
|
||||
assert fm.font_catalog == catalog
|
||||
assert fm.cache_generation == gen
|
||||
|
||||
|
||||
class TestPluginManagerReloadFonts:
|
||||
"""Through PluginManager: unloading a plugin forgets its manifest fonts,
|
||||
and reload_plugin (unload + load) registers them again so they resolve."""
|
||||
|
||||
PLUGIN_ID = "font-reload-demo"
|
||||
MODULE = "plugin_font_reload_demo"
|
||||
|
||||
def test_unload_forgets_and_reload_resolves(self, tmp_path):
|
||||
import sys
|
||||
from src.plugin_system.plugin_manager import PluginManager
|
||||
|
||||
plugins_dir = tmp_path / "plugins"
|
||||
plugin_dir = plugins_dir / self.PLUGIN_ID
|
||||
(plugin_dir / "fonts").mkdir(parents=True)
|
||||
shutil.copy(resolve_asset_path("assets/fonts/PressStart2P-Regular.ttf"),
|
||||
plugin_dir / "fonts" / "Bundled.ttf")
|
||||
manifest = {"id": self.PLUGIN_ID, "name": "Demo", "class_name": "Demo",
|
||||
"entry_point": "manager.py",
|
||||
"fonts": {"fonts": [{"family": "bundled",
|
||||
"source": "plugin://fonts/Bundled.ttf"}]}}
|
||||
(plugin_dir / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
|
||||
(plugin_dir / "manager.py").write_text(
|
||||
"class Demo:\n"
|
||||
" def __init__(self, plugin_id, config, display_manager, cache_manager, plugin_manager):\n"
|
||||
" self.enabled = True\n", encoding="utf-8")
|
||||
|
||||
pm = PluginManager(plugins_dir=str(plugins_dir))
|
||||
fm = FontManager({})
|
||||
pm.font_manager = fm
|
||||
pm.plugin_manifests[self.PLUGIN_ID] = manifest
|
||||
key = f"{self.PLUGIN_ID}::bundled"
|
||||
try:
|
||||
assert pm.load_plugin(self.PLUGIN_ID) is True
|
||||
assert key in fm.font_catalog
|
||||
fm.register_manager_font(self.PLUGIN_ID, "demo.title", "bundled", 8)
|
||||
old = fm.resolve_font("demo.title", "bundled", 8, plugin_id=self.PLUGIN_ID)
|
||||
|
||||
assert pm.unload_plugin(self.PLUGIN_ID) is True
|
||||
assert self.PLUGIN_ID not in fm.plugin_fonts
|
||||
assert self.PLUGIN_ID not in fm.plugin_font_catalogs
|
||||
assert key not in fm.font_catalog
|
||||
assert not [k for k in fm.font_cache if k.startswith(f"{self.PLUGIN_ID}::")]
|
||||
assert self.PLUGIN_ID not in fm.manager_fonts
|
||||
|
||||
assert pm.reload_plugin(self.PLUGIN_ID) is True
|
||||
assert fm.font_catalog[key] == str(plugin_dir / "fonts" / "Bundled.ttf")
|
||||
font = fm.resolve_font("demo.title", "bundled", 8, plugin_id=self.PLUGIN_ID)
|
||||
assert isinstance(font, ImageFont.FreeTypeFont)
|
||||
assert font is not old
|
||||
finally:
|
||||
sys.modules.pop(self.MODULE, None)
|
||||
|
||||
|
||||
class TestDownloadFont:
|
||||
"""_download_font: plugin fonts declared by URL, cached in temp_font_dir."""
|
||||
|
||||
|
||||
@@ -709,6 +709,6 @@ class TestTheControllersOwnScreens:
|
||||
c._check_wifi_status_message.return_value = None
|
||||
inputs = DisplayController._arbiter_inputs(c)
|
||||
assert inputs.wifi_notice is None
|
||||
assert Arbiter.decide(ArbiterState(), inputs, 0.0).source is Source.ROTATION
|
||||
assert Arbiter.decide(ArbiterState(), inputs, 0.0).source is Source.LEGACY
|
||||
assert dm.is_currently_scrolling()
|
||||
assert dm._frame_hold == 2
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
"""GET /api/v3/plugins/installed never waits on the network for registry data.
|
||||
|
||||
The route used `get_registry_info`, which goes through `fetch_registry`: on a
|
||||
cold (or expired) cache that downloads plugins.json from GitHub with a 10s
|
||||
timeout and three attempts -- and, with no registry to fall back on, every
|
||||
plugin's lookup repeated the whole cycle. The first plugin-list load after a
|
||||
restart waited on GitHub, and offline it waited out every timeout.
|
||||
|
||||
Now the route reads the registry copy already in memory, however old, and a
|
||||
missing or expired copy only starts a background refresh. These tests block
|
||||
the network at the socket layer (DNS lookups hang, then fail) and assert the
|
||||
request returns quickly without a single network attempt on the request path.
|
||||
"""
|
||||
|
||||
import functools
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from test._api_v3_test_helpers import ( # noqa: F401 - fixtures
|
||||
api_v3_client, api_v3_module,
|
||||
)
|
||||
from src.plugin_system.store_manager import PluginStoreManager
|
||||
|
||||
# How long a blocked lookup hangs before failing: long enough that a single
|
||||
# one on the request path blows the response budget below.
|
||||
HANG_SECONDS = 1.5
|
||||
FAST_SECONDS = 1.0
|
||||
|
||||
REGISTRY = {'plugins': [
|
||||
{'id': 'weather', 'name': 'Weather', 'verified': True, 'latest_version': '1.2.0'},
|
||||
]}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def blocked_network(monkeypatch):
|
||||
"""Every DNS lookup hangs, then fails; records the thread it came from."""
|
||||
attempts = []
|
||||
|
||||
def hang_then_fail(host, *args, **kwargs):
|
||||
attempts.append((host, threading.current_thread().name))
|
||||
time.sleep(HANG_SECONDS)
|
||||
raise socket.gaierror(-3, 'Temporary failure in name resolution (blocked by test)')
|
||||
|
||||
monkeypatch.setattr(socket, 'getaddrinfo', hang_then_fail)
|
||||
return attempts
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_path, monkeypatch):
|
||||
store = PluginStoreManager(plugins_dir=str(tmp_path / 'plugins'))
|
||||
# One attempt, no pause between attempts, so a background refresh against
|
||||
# the blocked network ends within the test (teardown joins it).
|
||||
monkeypatch.setattr(store, '_http_get_with_retries', functools.partial(
|
||||
PluginStoreManager._http_get_with_retries, store, max_retries=1))
|
||||
yield store
|
||||
thread = getattr(store, '_registry_refresh_thread', None)
|
||||
if thread is not None:
|
||||
thread.join(timeout=30)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def get_installed(api_v3_module, api_v3_client, store, tmp_path):
|
||||
api = api_v3_module.api_v3
|
||||
api.plugin_store_manager = store
|
||||
api.plugin_catalog.plugins_dir = str(tmp_path / 'plugins')
|
||||
api.plugin_catalog.get_all_plugin_info = MagicMock(return_value=[
|
||||
{'id': 'weather', 'name': 'Weather', 'version': '1.0.0'},
|
||||
{'id': 'clock', 'name': 'Clock', 'version': '2.0.0'},
|
||||
])
|
||||
api.plugin_catalog.get_plugin_display_modes = MagicMock(return_value=[])
|
||||
api.config_manager.load_config = MagicMock(return_value={})
|
||||
|
||||
def _get():
|
||||
start = time.perf_counter()
|
||||
response = api_v3_client.get('/api/v3/plugins/installed')
|
||||
elapsed = time.perf_counter() - start
|
||||
assert response.status_code == 200
|
||||
plugins = {p['id']: p for p in response.get_json()['data']['plugins']}
|
||||
return plugins, elapsed
|
||||
return _get
|
||||
|
||||
|
||||
def _request_path_attempts(attempts):
|
||||
return [a for a in attempts if a[1] != 'registry-refresh']
|
||||
|
||||
|
||||
def test_a_cold_cache_offline_returns_fast_without_registry_info(get_installed, blocked_network):
|
||||
plugins, elapsed = get_installed()
|
||||
|
||||
assert _request_path_attempts(blocked_network) == []
|
||||
assert elapsed < FAST_SECONDS, f"installed list took {elapsed:.2f}s with the network blocked"
|
||||
weather = plugins['weather']
|
||||
assert weather['latest_version'] == ''
|
||||
assert weather['update_available'] is False
|
||||
assert weather['verified'] is False
|
||||
|
||||
|
||||
def test_a_stale_cache_is_used_as_is_without_a_fetch(get_installed, store, blocked_network):
|
||||
store.registry_cache = REGISTRY
|
||||
store.registry_cache_time = time.time() - store.registry_cache_timeout - 3600
|
||||
|
||||
plugins, elapsed = get_installed()
|
||||
|
||||
assert _request_path_attempts(blocked_network) == []
|
||||
assert elapsed < FAST_SECONDS
|
||||
weather = plugins['weather']
|
||||
assert weather['latest_version'] == '1.2.0'
|
||||
assert weather['update_available'] is True
|
||||
assert weather['verified'] is True
|
||||
assert plugins['clock']['latest_version'] == ''
|
||||
|
||||
|
||||
def test_a_fresh_cache_starts_no_refresh(get_installed, store, blocked_network):
|
||||
store.registry_cache = REGISTRY
|
||||
store.registry_cache_time = time.time()
|
||||
|
||||
plugins, _ = get_installed()
|
||||
|
||||
assert blocked_network == []
|
||||
assert store._registry_refresh_thread is None
|
||||
assert plugins['weather']['update_available'] is True
|
||||
|
||||
|
||||
def test_a_cold_cache_is_filled_in_the_background_for_the_next_load(get_installed, store, monkeypatch):
|
||||
response = MagicMock()
|
||||
response.json.return_value = REGISTRY
|
||||
fetched_on = []
|
||||
|
||||
def fake_get(url, **kwargs):
|
||||
fetched_on.append(threading.current_thread().name)
|
||||
return response
|
||||
|
||||
monkeypatch.setattr(store, '_http_get_with_retries', fake_get)
|
||||
|
||||
first, _ = get_installed()
|
||||
assert first['weather']['update_available'] is False
|
||||
store._registry_refresh_thread.join(timeout=10)
|
||||
|
||||
second, _ = get_installed()
|
||||
assert second['weather']['latest_version'] == '1.2.0'
|
||||
assert second['weather']['update_available'] is True
|
||||
# One background fetch for the whole listing, none on the request path.
|
||||
assert fetched_on == ['registry-refresh']
|
||||
|
||||
|
||||
def test_an_offline_background_refresh_backs_off(store, monkeypatch):
|
||||
def offline(url, **kwargs):
|
||||
raise requests.ConnectionError('blocked by test')
|
||||
|
||||
monkeypatch.setattr(store, '_http_get_with_retries', offline)
|
||||
|
||||
assert store.refresh_registry_in_background() is True
|
||||
store._registry_refresh_thread.join(timeout=10)
|
||||
assert store.registry_cache is None
|
||||
# Offline: the next page load does not start another attempt straight away.
|
||||
assert store.refresh_registry_in_background() is False
|
||||
store._registry_refresh_retry_after = 0.0
|
||||
assert store.refresh_registry_in_background() is True
|
||||
|
||||
|
||||
def test_only_one_background_refresh_runs_at_a_time(store, monkeypatch):
|
||||
release = threading.Event()
|
||||
|
||||
def slow(url, **kwargs):
|
||||
release.wait(10)
|
||||
raise requests.ConnectionError('blocked by test')
|
||||
|
||||
monkeypatch.setattr(store, '_http_get_with_retries', slow)
|
||||
try:
|
||||
assert store.refresh_registry_in_background() is True
|
||||
assert store.refresh_registry_in_background() is False
|
||||
finally:
|
||||
release.set()
|
||||
|
||||
|
||||
def test_get_registry_info_still_fetches_for_the_store(store, monkeypatch):
|
||||
"""The store, install and update paths keep fetching a cold registry."""
|
||||
response = MagicMock()
|
||||
response.json.return_value = REGISTRY
|
||||
monkeypatch.setattr(store, '_http_get_with_retries', MagicMock(return_value=response))
|
||||
|
||||
assert store.get_registry_info('weather')['latest_version'] == '1.2.0'
|
||||
store._http_get_with_retries.assert_called_once()
|
||||
@@ -167,8 +167,7 @@ class TestOnDemandArgs:
|
||||
def test_every_command_has_an_argument_type(self, cmd):
|
||||
args = {Command.ON_DEMAND_START: {'plugin_id': 'p'},
|
||||
Command.PLUGIN_RELOAD: {'plugin_id': 'p'},
|
||||
Command.BRIGHTNESS_SET: {'brightness': 50},
|
||||
Command.ERRORS_CLEAR: {'cutoff': 1790000000.0}}.get(cmd, {})
|
||||
Command.BRIGHTNESS_SET: {'brightness': 50}}.get(cmd, {})
|
||||
c.parse_args(cmd, args)
|
||||
|
||||
def test_hello_versions(self):
|
||||
|
||||
@@ -41,15 +41,6 @@ def _reload(plugin_id):
|
||||
return _command(Command.PLUGIN_RELOAD, PluginReloadArgs(plugin_id))
|
||||
|
||||
|
||||
def _screen(mode):
|
||||
"""A rotation screen of ``mode``, as the ScreenRunner hands it to the
|
||||
1 Hz loop's frame wait."""
|
||||
from src.display_arbiter import ArbiterState, rotation_plan
|
||||
from src.screen_runner import Screen
|
||||
plan = rotation_plan(ArbiterState(current_mode=mode))
|
||||
return Screen(plan, plugin=None, accepts_display_mode=False, start=0.0)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dc(test_display_controller):
|
||||
c = test_display_controller
|
||||
@@ -262,8 +253,7 @@ class TestRealTimeWake:
|
||||
dc.on_demand_active = False
|
||||
dc._activate_on_demand = MagicMock(
|
||||
side_effect=lambda request: setattr(dc, 'current_display_mode', 'weather'))
|
||||
dc._wifi_notice_pending = MagicMock(return_value=False) # the dwell's check
|
||||
dc._read_wifi_notice = MagicMock(return_value=None) # the frame wait's
|
||||
dc._wifi_notice_pending = MagicMock(return_value=False)
|
||||
dc._tick_plugin_updates = MagicMock()
|
||||
dc._check_live_takeover = MagicMock()
|
||||
dc.cache_manager.get = MagicMock(return_value=None)
|
||||
@@ -292,10 +282,10 @@ class TestRealTimeWake:
|
||||
dc.current_display_mode = 'clock'
|
||||
stamps = []
|
||||
t = self._post_later(server, self._start_line(f's{i}'), 0.05, stamps)
|
||||
ended = dc._wait_frame_interval(1.0, _screen('clock'))
|
||||
ended = dc._wait_frame_interval(1.0, 'clock')
|
||||
woke = time.monotonic()
|
||||
t.join()
|
||||
assert ended is not None
|
||||
assert ended is True
|
||||
latencies.append(woke - stamps[0])
|
||||
latencies.sort()
|
||||
print(f"static-screen wake latency: median {latencies[5] * 1000:.2f} ms, "
|
||||
@@ -325,7 +315,7 @@ class TestRealTimeWake:
|
||||
'args': {'brightness': 33}}).encode()
|
||||
started = time.monotonic()
|
||||
t = self._post_later(server, line, 0.1, stamps)
|
||||
assert dc._wait_frame_interval(0.5, _screen('clock')) is None
|
||||
assert dc._wait_frame_interval(0.5, 'clock') is False
|
||||
assert time.monotonic() - started >= 0.49
|
||||
t.join()
|
||||
dc.display_manager.set_brightness.assert_called_once_with(33)
|
||||
@@ -333,7 +323,7 @@ class TestRealTimeWake:
|
||||
def test_without_a_socket_it_is_a_plain_sleep(self, dc):
|
||||
dc._control_server = None
|
||||
started = time.monotonic()
|
||||
assert dc._wait_frame_interval(0.2, _screen('clock')) is None
|
||||
assert dc._wait_frame_interval(0.2, 'clock') is False
|
||||
assert time.monotonic() - started >= 0.19
|
||||
|
||||
|
||||
|
||||
@@ -1,509 +0,0 @@
|
||||
"""Stage 4 of the control socket: the file mailboxes are only a fallback.
|
||||
|
||||
* The client knows whether the display had the request (``ControlError.sent``)
|
||||
and ``should_fall_back`` allows a mailbox write only when it did not, or
|
||||
when the display is too old to know the command (the upgrade case).
|
||||
* ``errors.clear`` is answered on the connection thread by a handler the
|
||||
display registers; a display without one answers like an older display.
|
||||
* The display looks at the on-demand mailbox once a second while the socket
|
||||
is up (0.25 s without it), reads it only when its file changed, never
|
||||
touches it for a socket command, and logs who still writes it.
|
||||
* ``CacheManager.file_signature`` / ``MailboxWatch`` make a look one stat().
|
||||
|
||||
The web routes are covered in test_api_v3_on_demand_socket.py and
|
||||
test_error_snapshot_cross_process.py.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.cache_manager import CacheManager, MailboxWatch
|
||||
from src.ipc import client
|
||||
from src.ipc import contract as c
|
||||
from src.ipc.contract import Command, ErrorsClearArgs, OnDemandStartArgs, ProtocolError
|
||||
from src.ipc.server import ControlServer, QueuedCommand
|
||||
|
||||
MAILBOX = 'display_on_demand_request'
|
||||
|
||||
|
||||
# -- the client: was the request sent? ---------------------------------------------
|
||||
|
||||
class FakeSock:
|
||||
"""Stands in for a connected socket in client._exchange."""
|
||||
|
||||
def __init__(self, replies=(), send_error=None, recv_error=None):
|
||||
self.replies = list(replies)
|
||||
self.send_error = send_error
|
||||
self.recv_error = recv_error
|
||||
self.sent = b''
|
||||
|
||||
def settimeout(self, _t):
|
||||
pass
|
||||
|
||||
def sendall(self, data):
|
||||
if self.send_error is not None:
|
||||
raise self.send_error
|
||||
self.sent += data
|
||||
|
||||
def recv(self, _n):
|
||||
if self.recv_error is not None:
|
||||
raise self.recv_error
|
||||
return self.replies.pop(0) if self.replies else b''
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
def _reply(request_id, **body):
|
||||
return (json.dumps(dict({'v': 1, 'id': request_id}, **body)) + '\n').encode()
|
||||
|
||||
|
||||
def _call(sock=None, connect_error=None, request_id='rid-1'):
|
||||
with patch.object(client, 'socket_supported', return_value=True), \
|
||||
patch.object(client, '_connect',
|
||||
side_effect=connect_error, return_value=sock):
|
||||
return client.request(Command.PING, {}, request_id=request_id, paths=['/x.sock'])
|
||||
|
||||
|
||||
def _error(**kw):
|
||||
with pytest.raises(client.ControlError) as e:
|
||||
_call(**kw)
|
||||
return e.value
|
||||
|
||||
|
||||
class TestSent:
|
||||
def test_an_answer_is_returned(self):
|
||||
assert _call(FakeSock([_reply('rid-1', ok=True, result={'pong': True})])) == {'pong': True}
|
||||
|
||||
@pytest.mark.parametrize('reason', ['no_socket', 'refused', 'timeout', 'busy'])
|
||||
def test_a_failed_connect_was_not_sent(self, reason):
|
||||
e = _error(connect_error=client.ControlError(reason))
|
||||
assert e.reason == reason and e.sent is False
|
||||
assert client.should_fall_back(e)
|
||||
|
||||
def test_a_send_that_timed_out_was_not_sent(self):
|
||||
e = _error(sock=FakeSock(send_error=socket.timeout()))
|
||||
assert e.reason == 'timeout' and e.sent is False
|
||||
assert client.should_fall_back(e)
|
||||
|
||||
def test_silence_after_the_request_was_sent(self):
|
||||
e = _error(sock=FakeSock(recv_error=socket.timeout()))
|
||||
assert e.reason == 'timeout' and e.sent is True
|
||||
assert not client.should_fall_back(e)
|
||||
|
||||
def test_a_hang_up_after_the_request_was_sent(self):
|
||||
e = _error(sock=FakeSock([]))
|
||||
assert e.reason == 'closed' and e.sent is True
|
||||
assert not client.should_fall_back(e)
|
||||
|
||||
def test_a_garbled_reply(self):
|
||||
e = _error(sock=FakeSock([b'not json\n']))
|
||||
assert e.reason == 'bad_response' and e.sent is True
|
||||
assert not client.should_fall_back(e)
|
||||
|
||||
@pytest.mark.parametrize('code', ['busy', 'invalid_args', 'internal', 'pending', 'failed'])
|
||||
def test_a_display_error_with_an_id_was_sent(self, code):
|
||||
sock = FakeSock([_reply('rid-1', ok=False, error={'code': code, 'message': 'x'})])
|
||||
e = _error(sock=sock)
|
||||
assert e.reason == code and e.sent is True
|
||||
assert not client.should_fall_back(e)
|
||||
|
||||
@pytest.mark.parametrize('code', ['forbidden', 'busy'])
|
||||
def test_a_refusal_at_the_door_was_not_sent(self, code):
|
||||
# forbidden, or too many connections: answered before the request
|
||||
# was read, so with no id.
|
||||
sock = FakeSock([(json.dumps({'v': 1, 'id': None, 'ok': False,
|
||||
'error': {'code': code, 'message': 'x'}}) + '\n').encode()])
|
||||
e = _error(sock=sock)
|
||||
assert e.reason == code and e.sent is False
|
||||
assert client.should_fall_back(e)
|
||||
|
||||
@pytest.mark.parametrize('code', ['unknown_command', 'unsupported_version'])
|
||||
def test_an_older_display_is_fallen_back_from(self, code):
|
||||
sock = FakeSock([_reply('rid-1', ok=False, error={'code': code, 'message': 'x'})])
|
||||
e = _error(sock=sock)
|
||||
assert e.sent is True
|
||||
assert client.should_fall_back(e)
|
||||
|
||||
def test_a_request_refused_locally_never_left(self):
|
||||
with pytest.raises(client.ControlError) as e:
|
||||
client.request(Command.ERRORS_CLEAR, {'cutoff': 'soon'}, paths=['/x.sock'])
|
||||
assert e.value.reason == 'invalid_request' and e.value.sent is False
|
||||
assert client.should_fall_back(e.value)
|
||||
|
||||
def test_a_client_bug_falls_back(self):
|
||||
assert client.should_fall_back(RuntimeError('boom'))
|
||||
|
||||
|
||||
# -- errors.clear on the server ------------------------------------------------------
|
||||
|
||||
def _line(cmd, args, rid='r1'):
|
||||
return c.encode_message({'v': 1, 'id': rid, 'cmd': cmd, 'args': args})
|
||||
|
||||
|
||||
class TestErrorsClearOnTheServer:
|
||||
def test_the_handler_answers_it(self, tmp_path):
|
||||
seen = []
|
||||
|
||||
def handler(request_id, args):
|
||||
seen.append((request_id, args))
|
||||
return {'request_id': request_id, 'cutoff': args.cutoff, 'cleared': 4}
|
||||
|
||||
server = ControlServer(str(tmp_path / 's.sock'),
|
||||
handlers={Command.ERRORS_CLEAR: handler})
|
||||
response = server.handle_line(_line(Command.ERRORS_CLEAR, {'cutoff': 123}))
|
||||
assert response.ok and response.result == {'request_id': 'r1', 'cutoff': 123.0,
|
||||
'cleared': 4}
|
||||
assert seen == [('r1', ErrorsClearArgs(cutoff=123.0))]
|
||||
assert not server.has_pending # not queued for the render thread
|
||||
|
||||
def test_a_display_without_a_handler_answers_like_an_older_one(self, tmp_path):
|
||||
server = ControlServer(str(tmp_path / 's.sock'))
|
||||
response = server.handle_line(_line(Command.ERRORS_CLEAR, {'cutoff': 1}))
|
||||
assert not response.ok and response.error.code == c.ErrorCode.UNKNOWN_COMMAND
|
||||
|
||||
def test_only_direct_commands_take_a_handler(self, tmp_path):
|
||||
server = ControlServer(str(tmp_path / 's.sock'),
|
||||
handlers={Command.ON_DEMAND_START: lambda *a: {}})
|
||||
response = server.handle_line(_line(Command.ON_DEMAND_START, {'plugin_id': 'p'}))
|
||||
assert response.ok and response.result['accepted'] is True # still queued
|
||||
assert server.has_pending
|
||||
|
||||
def test_a_handler_error_is_contained(self, tmp_path):
|
||||
def boom(*_a):
|
||||
raise ValueError('disk gone')
|
||||
|
||||
server = ControlServer(str(tmp_path / 's.sock'), handlers={Command.ERRORS_CLEAR: boom})
|
||||
response = server.handle_line(_line(Command.ERRORS_CLEAR, {'cutoff': 1}))
|
||||
assert response.error.code == c.ErrorCode.INTERNAL
|
||||
assert 'disk gone' not in response.error.message
|
||||
|
||||
def test_a_handler_can_refuse_with_a_code(self, tmp_path):
|
||||
def refuse(*_a):
|
||||
raise ProtocolError(c.ErrorCode.BUSY, 'later')
|
||||
|
||||
server = ControlServer(str(tmp_path / 's.sock'), handlers={Command.ERRORS_CLEAR: refuse})
|
||||
assert server.handle_line(
|
||||
_line(Command.ERRORS_CLEAR, {'cutoff': 1})).error.code == c.ErrorCode.BUSY
|
||||
|
||||
@pytest.mark.parametrize('cutoff', ['1', None, True, float('inf'), -1])
|
||||
def test_bad_cutoffs_are_refused(self, cutoff):
|
||||
with pytest.raises(ProtocolError) as e:
|
||||
ErrorsClearArgs.from_dict({'cutoff': cutoff})
|
||||
assert e.value.code == c.ErrorCode.INVALID_ARGS
|
||||
|
||||
def test_hello_lists_it(self, tmp_path):
|
||||
server = ControlServer(str(tmp_path / 's.sock'))
|
||||
result = server.handle_line(_line(Command.HELLO, {'versions': [1]})).result
|
||||
assert Command.ERRORS_CLEAR in result['commands']
|
||||
|
||||
|
||||
# -- the display's mailbox poll ------------------------------------------------------
|
||||
|
||||
class SignedCache:
|
||||
"""The slice of CacheManager the poll uses, counting what it costs."""
|
||||
|
||||
def __init__(self):
|
||||
self.data = {}
|
||||
self.writes = 0
|
||||
self.version = {}
|
||||
self.reads = []
|
||||
self.stats = 0
|
||||
self.deletes = []
|
||||
self.sets = []
|
||||
|
||||
def file_signature(self, key):
|
||||
self.stats += 1
|
||||
return (self.version[key], 0, 0) if key in self.data else None
|
||||
|
||||
def get(self, key, *a, **kw):
|
||||
self.reads.append(key)
|
||||
return self.data.get(key)
|
||||
|
||||
def set(self, key, value, *a, **kw):
|
||||
self.sets.append(key)
|
||||
self.data[key] = value
|
||||
self.writes += 1
|
||||
self.version[key] = self.writes
|
||||
|
||||
def delete(self, key):
|
||||
self.deletes.append(key)
|
||||
self.data.pop(key, None)
|
||||
|
||||
|
||||
class FakeServer:
|
||||
def __init__(self):
|
||||
self.commands = []
|
||||
|
||||
@property
|
||||
def has_pending(self):
|
||||
return bool(self.commands)
|
||||
|
||||
def drain(self):
|
||||
out, self.commands = self.commands, []
|
||||
return out
|
||||
|
||||
|
||||
class Clock:
|
||||
def __init__(self):
|
||||
self.t = 1000.0
|
||||
|
||||
def __call__(self):
|
||||
return self.t
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def controller(test_display_controller, monkeypatch):
|
||||
dc = test_display_controller
|
||||
dc.cache_manager = SignedCache()
|
||||
dc._activate_on_demand = MagicMock()
|
||||
dc.on_demand_active = False
|
||||
dc.on_demand_request_id = None
|
||||
dc._last_on_demand_poll = None
|
||||
dc._on_demand_mailbox = None
|
||||
dc._mailbox_writers_logged = frozenset()
|
||||
clock = Clock()
|
||||
monkeypatch.setattr('src.display_controller.time.monotonic', clock)
|
||||
dc.clock = clock
|
||||
return dc
|
||||
|
||||
|
||||
def _post(dc, rid, action='start', **fields):
|
||||
dc.cache_manager.set(MAILBOX, dict({'request_id': rid, 'action': action}, **fields))
|
||||
|
||||
|
||||
def _poll_for(dc, seconds, step=1 / 16): # exact in binary: no drift past a floor
|
||||
end = dc.clock.t + seconds
|
||||
while dc.clock.t < end:
|
||||
dc._poll_on_demand_requests()
|
||||
dc.clock.t += step
|
||||
|
||||
|
||||
class TestMailboxCadence:
|
||||
def test_without_a_socket_it_is_looked_at_every_quarter_second(self, controller):
|
||||
controller._control_server = None
|
||||
_poll_for(controller, 10.0)
|
||||
assert 38 <= controller.cache_manager.stats <= 42
|
||||
|
||||
def test_with_a_socket_it_is_looked_at_once_a_second(self, controller):
|
||||
controller._control_server = FakeServer()
|
||||
_poll_for(controller, 10.0)
|
||||
assert 9 <= controller.cache_manager.stats <= 11
|
||||
|
||||
def test_a_look_that_finds_nothing_reads_nothing(self, controller):
|
||||
controller._control_server = FakeServer()
|
||||
_poll_for(controller, 10.0)
|
||||
assert controller.cache_manager.reads == []
|
||||
|
||||
def test_an_unchanged_mailbox_is_not_read_again(self, controller):
|
||||
# An already-processed start the delete could not remove, say.
|
||||
controller._control_server = FakeServer()
|
||||
controller.cache_manager.delete = MagicMock() # the file stays
|
||||
_post(controller, 'once', plugin_id='clock')
|
||||
_poll_for(controller, 10.0)
|
||||
assert controller.cache_manager.reads.count(MAILBOX) <= 2 # the read + the re-check
|
||||
controller._activate_on_demand.assert_called_once()
|
||||
|
||||
def test_a_mailbox_request_lands_within_a_second_with_the_socket_up(self, controller):
|
||||
# The upgrade case the other way round: a new display, and a web
|
||||
# interface (or a plugin) that still writes the mailbox.
|
||||
controller._control_server = FakeServer()
|
||||
controller._poll_on_demand_requests()
|
||||
controller.clock.t += 0.1
|
||||
_post(controller, 'old-web', plugin_id='clock')
|
||||
posted = controller.clock.t
|
||||
while not controller._activate_on_demand.called:
|
||||
controller._poll_on_demand_requests()
|
||||
controller.clock.t += 0.05
|
||||
assert controller.clock.t - posted < 1.5
|
||||
assert controller.clock.t - posted <= controller.MAILBOX_POLL_INTERVAL_WITH_SOCKET + 0.06
|
||||
assert MAILBOX in controller.cache_manager.deletes # consumed
|
||||
|
||||
def test_socket_commands_still_land_at_once(self, controller):
|
||||
server = controller._control_server = FakeServer()
|
||||
controller._poll_on_demand_requests()
|
||||
server.commands.append(QueuedCommand('sock', Command.ON_DEMAND_START,
|
||||
OnDemandStartArgs(plugin_id='clock'), time.time()))
|
||||
controller._poll_on_demand_requests() # inside the mailbox interval
|
||||
controller._activate_on_demand.assert_called_once()
|
||||
|
||||
|
||||
class TestSocketCommandsLeaveTheMailboxAlone:
|
||||
def test_a_socket_start_reads_and_deletes_no_mailbox(self, controller):
|
||||
server = controller._control_server = FakeServer()
|
||||
controller._poll_on_demand_requests()
|
||||
before = list(controller.cache_manager.reads)
|
||||
server.commands.append(QueuedCommand('s1', Command.ON_DEMAND_START,
|
||||
OnDemandStartArgs(plugin_id='clock'), time.time()))
|
||||
controller._poll_on_demand_requests()
|
||||
controller._activate_on_demand.assert_called_once()
|
||||
assert MAILBOX not in controller.cache_manager.reads[len(before):]
|
||||
assert controller.cache_manager.deletes == []
|
||||
|
||||
def test_a_socket_stop_reads_and_deletes_no_mailbox(self, controller):
|
||||
from src.ipc.contract import OnDemandStopArgs
|
||||
controller.on_demand_active = True
|
||||
controller._clear_on_demand = MagicMock()
|
||||
server = controller._control_server = FakeServer()
|
||||
server.commands.append(QueuedCommand('s2', Command.ON_DEMAND_STOP,
|
||||
OnDemandStopArgs(), time.time()))
|
||||
controller.clock.t += 5
|
||||
controller._drain_control_commands()
|
||||
controller._clear_on_demand.assert_called_once()
|
||||
assert MAILBOX not in controller.cache_manager.reads
|
||||
assert controller.cache_manager.deletes == []
|
||||
|
||||
def test_a_mailbox_copy_of_a_socket_command_is_dropped(self, controller):
|
||||
# An older web interface timed out after the display queued the
|
||||
# command, then wrote the mailbox too.
|
||||
server = controller._control_server = FakeServer()
|
||||
server.commands.append(QueuedCommand('both', Command.ON_DEMAND_START,
|
||||
OnDemandStartArgs(plugin_id='clock'), time.time()))
|
||||
controller._poll_on_demand_requests()
|
||||
_post(controller, 'both', plugin_id='clock')
|
||||
_poll_for(controller, 2.0)
|
||||
controller._activate_on_demand.assert_called_once()
|
||||
assert MAILBOX not in controller.cache_manager.data
|
||||
|
||||
|
||||
class TestDeprecationLog:
|
||||
def test_each_mailbox_writer_is_logged_once(self, controller, caplog):
|
||||
controller._control_server = FakeServer()
|
||||
caplog.set_level(logging.INFO, logger='src.display_controller')
|
||||
for i, plugin in enumerate(['on-air', 'on-air', 'pomodoro-timer']):
|
||||
_post(controller, f'r{i}', plugin_id=plugin)
|
||||
_poll_for(controller, 1.2)
|
||||
lines = [r.getMessage() for r in caplog.records if 'file mailbox' in r.getMessage()]
|
||||
assert len(lines) == 2
|
||||
assert 'on-air' in lines[0] and 'pomodoro-timer' in lines[1]
|
||||
|
||||
def test_nothing_is_logged_without_a_socket(self, controller, caplog):
|
||||
controller._control_server = None
|
||||
caplog.set_level(logging.INFO, logger='src.display_controller')
|
||||
_post(controller, 'r', plugin_id='on-air')
|
||||
_poll_for(controller, 1.0)
|
||||
controller._activate_on_demand.assert_called_once()
|
||||
assert not [r for r in caplog.records if 'file mailbox' in r.getMessage()]
|
||||
|
||||
|
||||
# -- file_signature and MailboxWatch -------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def real_cache(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(CacheManager, '_get_writable_cache_dir', lambda self: str(tmp_path))
|
||||
cache = CacheManager()
|
||||
yield cache
|
||||
cache.stop_cleanup_thread()
|
||||
|
||||
|
||||
class TestFileSignature:
|
||||
def test_absent_key(self, real_cache):
|
||||
assert real_cache.file_signature('nothing') is None
|
||||
|
||||
def test_every_write_is_a_new_signature(self, real_cache):
|
||||
seen = set()
|
||||
for i in range(20):
|
||||
# Same size each time, written as fast as possible.
|
||||
real_cache.set(MAILBOX, {'request_id': f'r{i:02d}'})
|
||||
sig = real_cache.file_signature(MAILBOX)
|
||||
assert isinstance(sig, tuple)
|
||||
seen.add(sig)
|
||||
assert len(seen) == 20
|
||||
|
||||
def test_gone_after_a_delete(self, real_cache):
|
||||
real_cache.set(MAILBOX, {'a': 1})
|
||||
real_cache.delete(MAILBOX)
|
||||
assert real_cache.file_signature(MAILBOX) is None
|
||||
|
||||
|
||||
class TestMailboxWatch:
|
||||
def test_reads_once_per_write(self, real_cache):
|
||||
watch = MailboxWatch(MAILBOX)
|
||||
assert watch.changed(real_cache) is False # no file
|
||||
real_cache.set(MAILBOX, {'request_id': 'a'})
|
||||
assert watch.changed(real_cache) is True
|
||||
assert watch.changed(real_cache) is False
|
||||
real_cache.set(MAILBOX, {'request_id': 'b'})
|
||||
assert watch.changed(real_cache) is True
|
||||
|
||||
def test_forget_reads_again(self, real_cache):
|
||||
watch = MailboxWatch(MAILBOX)
|
||||
real_cache.set(MAILBOX, {'request_id': 'a'})
|
||||
assert watch.changed(real_cache) is True
|
||||
watch.forget()
|
||||
assert watch.changed(real_cache) is True
|
||||
|
||||
def test_a_rewrite_after_a_delete_is_seen(self, real_cache):
|
||||
watch = MailboxWatch(MAILBOX)
|
||||
real_cache.set(MAILBOX, {'request_id': 'a'})
|
||||
assert watch.changed(real_cache)
|
||||
real_cache.delete(MAILBOX)
|
||||
assert watch.changed(real_cache) is False
|
||||
real_cache.set(MAILBOX, {'request_id': 'a'})
|
||||
assert watch.changed(real_cache) is True
|
||||
|
||||
def test_a_cache_that_cannot_tell_is_read_every_time(self):
|
||||
watch = MailboxWatch(MAILBOX)
|
||||
assert watch.changed(MagicMock()) is True
|
||||
assert watch.changed(MagicMock()) is True
|
||||
assert watch.changed(object()) is True
|
||||
|
||||
|
||||
# -- end to end over a real socket ---------------------------------------------------
|
||||
|
||||
@pytest.mark.skipif(not c.socket_supported(), reason='AF_UNIX sockets are Linux/macOS only')
|
||||
class TestOverTheSocket:
|
||||
@pytest.fixture
|
||||
def sock_path(self):
|
||||
import shutil
|
||||
import tempfile
|
||||
d = tempfile.mkdtemp(prefix='lmipc-')
|
||||
yield os.path.join(d, 'control.sock')
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
|
||||
def test_errors_clear_round_trip(self, sock_path):
|
||||
def handler(request_id, args):
|
||||
return {'request_id': request_id, 'cutoff': args.cutoff, 'cleared': 2}
|
||||
|
||||
server = ControlServer(sock_path, handlers={Command.ERRORS_CLEAR: handler})
|
||||
assert server.start()
|
||||
try:
|
||||
result = client.errors_clear('clr-1', 1790000000.0, paths=[sock_path])
|
||||
assert result == {'request_id': 'clr-1', 'cutoff': 1790000000.0, 'cleared': 2}
|
||||
finally:
|
||||
server.close()
|
||||
|
||||
def test_an_older_display_is_an_upgrade_fallback(self, sock_path):
|
||||
server = ControlServer(sock_path) # no errors.clear handler
|
||||
assert server.start()
|
||||
try:
|
||||
with pytest.raises(client.ControlError) as e:
|
||||
client.errors_clear('clr-2', 1.0, paths=[sock_path])
|
||||
assert e.value.reason == 'unknown_command' and e.value.sent is True
|
||||
assert client.should_fall_back(e.value)
|
||||
finally:
|
||||
server.close()
|
||||
|
||||
def test_no_display_is_a_fallback(self, sock_path):
|
||||
with pytest.raises(client.ControlError) as e:
|
||||
client.errors_clear('clr-3', 1.0, paths=[sock_path])
|
||||
assert e.value.reason == 'no_socket' and e.value.sent is False
|
||||
assert client.should_fall_back(e.value)
|
||||
|
||||
def test_a_full_queue_is_not_a_fallback(self, sock_path):
|
||||
server = ControlServer(sock_path, queue_size=1)
|
||||
assert server.start()
|
||||
try:
|
||||
client.on_demand_start('q1', 'clock', None, paths=[sock_path])
|
||||
with pytest.raises(client.ControlError) as e:
|
||||
client.on_demand_start('q2', 'clock', None, paths=[sock_path])
|
||||
assert e.value.reason == 'busy' and e.value.sent is True
|
||||
assert not client.should_fall_back(e.value)
|
||||
finally:
|
||||
server.close()
|
||||
@@ -1,252 +0,0 @@
|
||||
"""The web interface sees the display modes the display actually registered (#668).
|
||||
|
||||
A plugin may compute its modes from its config: soccer-scoreboard registers
|
||||
``soccer_<league>_live/recent/upcoming`` for every league the user adds under
|
||||
``custom_leagues``, and no manifest can list those ahead of time. The display
|
||||
always rotated them -- DisplayController._register_loaded_plugin prefers
|
||||
``plugin.modes`` -- but the web process reads plugins as files, so its mode
|
||||
listing (/display/modes, the on-demand dialog) and find_plugin_for_mode
|
||||
(/display/on-demand/start with a mode and no plugin_id) saw only manifests.
|
||||
|
||||
The display now records each plugin's registered modes in its plugin state,
|
||||
the runtime snapshot carries them, and PluginCatalog prefers them while the
|
||||
snapshot is live, falling back to the manifest when it is not.
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from src.cache_manager import CacheManager # noqa: E402
|
||||
from src.plugin_system import plugin_runtime as rt # noqa: E402
|
||||
from src.plugin_system.plugin_catalog import PluginCatalog # noqa: E402
|
||||
from src.plugin_system.plugin_runtime import ( # noqa: E402
|
||||
PluginRuntimePublisher, build_runtime_snapshot, read_plugin_runtime,
|
||||
view_from_snapshot,
|
||||
)
|
||||
from src.plugin_system.plugin_state import PluginState, PluginStateManager # noqa: E402
|
||||
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
||||
|
||||
DECLARED = ["soccer_eng.1_live", "soccer_eng.1_recent", "soccer_eng.1_upcoming"]
|
||||
CUSTOM = ["soccer_sco.1_live", "soccer_sco.1_recent", "soccer_sco.1_upcoming"]
|
||||
REGISTERED = DECLARED + CUSTOM
|
||||
|
||||
|
||||
def _loaded_states(modes=None):
|
||||
states = PluginStateManager()
|
||||
states.set_state("soccer-scoreboard", PluginState.ENABLED)
|
||||
states.record_loaded("soccer-scoreboard", "2.24.1")
|
||||
if modes is not None:
|
||||
states.record_modes("soccer-scoreboard", modes)
|
||||
return states
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def shared_cache(tmp_path, monkeypatch):
|
||||
"""Two cache managers over one directory: the display's and the web's."""
|
||||
monkeypatch.setattr(CacheManager, "_get_writable_cache_dir",
|
||||
lambda self: str(tmp_path / "cache"))
|
||||
(tmp_path / "cache").mkdir()
|
||||
display_cache, web_cache = CacheManager(), CacheManager()
|
||||
yield display_cache, web_cache
|
||||
display_cache.stop_cleanup_thread()
|
||||
web_cache.stop_cleanup_thread()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plugins_dir(tmp_path):
|
||||
root = tmp_path / "plugins"
|
||||
for plugin_id, modes in (("soccer-scoreboard", DECLARED), ("clock-simple", ["clock"])):
|
||||
(root / plugin_id).mkdir(parents=True)
|
||||
(root / plugin_id / "manifest.json").write_text(json.dumps({
|
||||
"id": plugin_id, "name": plugin_id, "version": "1.0.0",
|
||||
"class_name": "P", "display_modes": modes}), encoding="utf-8")
|
||||
return root
|
||||
|
||||
|
||||
# --- The display records what it registered ---------------------------------
|
||||
|
||||
class TestStateManagerRecordsModes:
|
||||
def test_runtime_records_carry_them(self):
|
||||
assert _loaded_states(REGISTERED).runtime_records()[
|
||||
"soccer-scoreboard"]["modes"] == REGISTERED
|
||||
|
||||
def test_none_until_registered(self):
|
||||
assert _loaded_states().runtime_records()["soccer-scoreboard"]["modes"] is None
|
||||
|
||||
def test_a_new_list_is_a_change_the_same_one_is_not(self):
|
||||
"""change_count drives the publisher: re-registering an unchanged
|
||||
plugin must not cost an SD-card write."""
|
||||
states = _loaded_states(DECLARED)
|
||||
before = states.change_count
|
||||
states.record_modes("soccer-scoreboard", list(DECLARED))
|
||||
assert states.change_count == before
|
||||
states.record_modes("soccer-scoreboard", REGISTERED)
|
||||
assert states.change_count == before + 1
|
||||
|
||||
def test_ignored_for_a_plugin_that_is_not_loaded(self):
|
||||
states = PluginStateManager()
|
||||
states.record_modes("ghost", ["ghost"])
|
||||
assert "ghost" not in states.runtime_records()
|
||||
|
||||
def test_unload_forgets_them(self):
|
||||
states = _loaded_states(REGISTERED)
|
||||
states.clear_state("soccer-scoreboard")
|
||||
assert "soccer-scoreboard" not in states.runtime_records()
|
||||
|
||||
def test_a_reload_starts_without_them_until_registered_again(self):
|
||||
states = _loaded_states(REGISTERED)
|
||||
states.record_loaded("soccer-scoreboard", "2.25.0")
|
||||
assert states.runtime_records()["soccer-scoreboard"]["modes"] is None
|
||||
|
||||
|
||||
class TestControllerRecordsOnRegistration:
|
||||
def test_plugin_modes_reach_the_state_manager(self, test_display_controller):
|
||||
"""_register_loaded_plugin is the one path every load, enable and
|
||||
reload goes through."""
|
||||
c = test_display_controller
|
||||
states = _loaded_states()
|
||||
plugin = MagicMock()
|
||||
plugin.modes = list(REGISTERED)
|
||||
c.plugin_manager.state_manager = states
|
||||
c.plugin_manager.get_plugin = MagicMock(return_value=plugin)
|
||||
c.plugin_manager.plugin_manifests = {"soccer-scoreboard": {"display_modes": DECLARED}}
|
||||
|
||||
c._register_loaded_plugin("soccer-scoreboard")
|
||||
|
||||
assert states.runtime_records()["soccer-scoreboard"]["modes"] == REGISTERED
|
||||
|
||||
def test_a_failing_state_manager_does_not_break_registration(self, test_display_controller):
|
||||
c = test_display_controller
|
||||
plugin = MagicMock()
|
||||
plugin.modes = ["clock"]
|
||||
c.plugin_manager.state_manager.record_modes = MagicMock(side_effect=RuntimeError("x"))
|
||||
c.plugin_manager.get_plugin = MagicMock(return_value=plugin)
|
||||
c.plugin_manager.plugin_manifests = {}
|
||||
|
||||
assert c._register_loaded_plugin("clock-simple") == ["clock"]
|
||||
assert c.mode_to_plugin_id["clock"] == "clock-simple"
|
||||
|
||||
|
||||
# --- The snapshot carries them; only a live view reports them ---------------
|
||||
|
||||
class TestSnapshotAndView:
|
||||
NOW = 1_800_000_000.0
|
||||
|
||||
def _view(self, states, running=True, published_at=None):
|
||||
snapshot = build_runtime_snapshot(states, started_at=1.0, now=self.NOW,
|
||||
running=running)
|
||||
if published_at is not None:
|
||||
snapshot["published_at"] = published_at
|
||||
return view_from_snapshot(snapshot, now=self.NOW)
|
||||
|
||||
def test_live_view_reports_the_registered_modes(self):
|
||||
assert self._view(_loaded_states(REGISTERED)).display_modes(
|
||||
"soccer-scoreboard") == REGISTERED
|
||||
|
||||
def test_stale_and_stopped_views_report_nothing(self):
|
||||
states = _loaded_states(REGISTERED)
|
||||
assert self._view(states, published_at=self.NOW - 10_000).display_modes(
|
||||
"soccer-scoreboard") is None
|
||||
assert self._view(states, running=False).display_modes("soccer-scoreboard") is None
|
||||
|
||||
def test_unregistered_or_unknown_plugins_report_nothing(self):
|
||||
view = self._view(_loaded_states())
|
||||
assert view.display_modes("soccer-scoreboard") is None
|
||||
assert view.display_modes("not-loaded") is None
|
||||
|
||||
def test_a_runaway_list_is_bounded(self):
|
||||
modes = [f"m{i}" for i in range(1000)] + ["x" * 500]
|
||||
snapshot = build_runtime_snapshot(_loaded_states(modes), started_at=1.0, now=self.NOW)
|
||||
published = snapshot["plugins"]["soccer-scoreboard"]["modes"]
|
||||
assert len(published) == rt._MAX_MODES
|
||||
|
||||
def test_non_strings_from_a_hand_made_snapshot_are_dropped(self):
|
||||
snapshot = {"schema": rt.SNAPSHOT_SCHEMA, "running": True,
|
||||
"published_at": self.NOW, "plugins": {
|
||||
"p": {"loaded": True, "modes": ["a", 3, None]}}}
|
||||
assert view_from_snapshot(snapshot, now=self.NOW).display_modes("p") == ["a"]
|
||||
|
||||
|
||||
# --- The web's catalog prefers them -------------------------------------------
|
||||
|
||||
class TestCatalog:
|
||||
def _catalog(self, plugins_dir, web_cache):
|
||||
catalog = PluginCatalog(plugins_dir,
|
||||
runtime_source=lambda: read_plugin_runtime(web_cache))
|
||||
catalog.discover_plugins()
|
||||
return catalog
|
||||
|
||||
def test_live_display_modes_win_over_the_manifest(self, plugins_dir, shared_cache):
|
||||
display_cache, web_cache = shared_cache
|
||||
PluginRuntimePublisher(display_cache, _loaded_states(REGISTERED)).tick()
|
||||
catalog = self._catalog(plugins_dir, web_cache)
|
||||
assert catalog.get_plugin_display_modes("soccer-scoreboard") == REGISTERED
|
||||
|
||||
def test_a_custom_league_mode_resolves_to_its_plugin(self, plugins_dir, shared_cache):
|
||||
"""What /display/on-demand/start does with a mode and no plugin_id."""
|
||||
display_cache, web_cache = shared_cache
|
||||
PluginRuntimePublisher(display_cache, _loaded_states(REGISTERED)).tick()
|
||||
catalog = self._catalog(plugins_dir, web_cache)
|
||||
assert catalog.find_plugin_for_mode("SOCCER_SCO.1_LIVE") == "soccer-scoreboard"
|
||||
|
||||
def test_a_plugin_the_display_has_not_loaded_falls_back_to_its_manifest(
|
||||
self, plugins_dir, shared_cache):
|
||||
display_cache, web_cache = shared_cache
|
||||
PluginRuntimePublisher(display_cache, _loaded_states(REGISTERED)).tick()
|
||||
catalog = self._catalog(plugins_dir, web_cache)
|
||||
assert catalog.get_plugin_display_modes("clock-simple") == ["clock"]
|
||||
assert catalog.find_plugin_for_mode("clock") == "clock-simple"
|
||||
|
||||
def test_a_stopped_display_falls_back_to_manifests(self, plugins_dir, shared_cache):
|
||||
display_cache, web_cache = shared_cache
|
||||
publisher = PluginRuntimePublisher(display_cache, _loaded_states(REGISTERED))
|
||||
publisher.tick()
|
||||
publisher.stop()
|
||||
catalog = self._catalog(plugins_dir, web_cache)
|
||||
assert catalog.get_plugin_display_modes("soccer-scoreboard") == DECLARED
|
||||
assert catalog.find_plugin_for_mode("soccer_sco.1_live") is None
|
||||
|
||||
def test_no_runtime_source_is_manifests_only(self, plugins_dir):
|
||||
catalog = PluginCatalog(plugins_dir)
|
||||
catalog.discover_plugins()
|
||||
assert catalog.get_plugin_display_modes("soccer-scoreboard") == DECLARED
|
||||
|
||||
def test_a_failing_runtime_source_is_manifests_only(self, plugins_dir):
|
||||
def broken():
|
||||
raise OSError("cache gone")
|
||||
catalog = PluginCatalog(plugins_dir, runtime_source=broken)
|
||||
catalog.discover_plugins()
|
||||
assert catalog.get_plugin_display_modes("soccer-scoreboard") == DECLARED
|
||||
|
||||
def test_one_listing_reads_the_view_once(self, plugins_dir):
|
||||
source = MagicMock(return_value=None)
|
||||
catalog = PluginCatalog(plugins_dir, runtime_source=source)
|
||||
catalog.discover_plugins()
|
||||
for _ in range(10):
|
||||
catalog.get_plugin_display_modes("soccer-scoreboard")
|
||||
catalog.find_plugin_for_mode("clock")
|
||||
assert source.call_count == 1
|
||||
|
||||
|
||||
class TestDisplayModesRoute:
|
||||
def test_lists_the_custom_league_modes(self, api_v3_module, api_v3_client, # noqa: F811
|
||||
plugins_dir, shared_cache):
|
||||
display_cache, web_cache = shared_cache
|
||||
PluginRuntimePublisher(display_cache, _loaded_states(REGISTERED)).tick()
|
||||
api = api_v3_module.api_v3
|
||||
api.plugin_catalog = PluginCatalog(
|
||||
plugins_dir, runtime_source=lambda: read_plugin_runtime(web_cache))
|
||||
api.config_manager.load_config = MagicMock(return_value={
|
||||
"soccer-scoreboard": {"enabled": True}})
|
||||
|
||||
response = api_v3_client.get("/api/v3/display/modes")
|
||||
|
||||
assert response.status_code == 200, response.get_data(as_text=True)
|
||||
modes = {m["mode"]: m for m in response.get_json()["data"]["modes"]}
|
||||
assert set(modes) == set(REGISTERED)
|
||||
assert modes["soccer_sco.1_live"]["plugin_id"] == "soccer-scoreboard"
|
||||
@@ -136,22 +136,3 @@ class TestCleartextCredentialsNeedAnExplicitOptIn:
|
||||
"allow_insecure_mqtt": "false"})
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_the_settings_read_reports_the_opt_in(self, client, monkeypatch):
|
||||
"""The Tools form prefills its "Allow without TLS" box from the GET.
|
||||
|
||||
Off until someone saves it on, so an untouched form sends false and
|
||||
the guard above still refuses a cleartext password.
|
||||
"""
|
||||
c, _ = client
|
||||
monkeypatch.setattr(misc, "_mqtt_bridge_service_state",
|
||||
lambda: {"installed": False, "active": False, "enabled": False})
|
||||
|
||||
def read():
|
||||
return c.get("/api/v3/integrations/mqtt-bridge").get_json()["data"]["config"]
|
||||
|
||||
assert read()["allow_insecure_mqtt"] is False
|
||||
r = c.put(URL, json={"mqtt_password": "hunter2", "mqtt_tls": False,
|
||||
"allow_insecure_mqtt": True})
|
||||
assert r.status_code == 200, r.get_json()
|
||||
assert read()["allow_insecure_mqtt"] is True
|
||||
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
"""Two on-demand edges seen on a rig.
|
||||
|
||||
* A request naming a ``*_live`` mode got HTTP 200 and a different mode on
|
||||
the panel. The session's mode list kept live modes only when the plugin's
|
||||
has_live_content() said so, and that is the live-priority question,
|
||||
which the sports plugins answer for favourite teams only: fifteen college
|
||||
games on, no favourite playing, and ``ncaa_fb_live`` became
|
||||
``nfl_recent``.
|
||||
* A restart during a session whose plugin then failed to load (its config
|
||||
no longer validated) logged "No valid display modes found ... after
|
||||
restoration" and left the session active with no modes: published as
|
||||
active for a plugin that was not running, with its cached request kept
|
||||
for the next restart.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
SPORTS_MODES = ['nfl_live', 'nfl_recent', 'nfl_upcoming',
|
||||
'ncaa_fb_live', 'ncaa_fb_recent', 'ncaa_fb_upcoming']
|
||||
|
||||
|
||||
def _sports_plugin(has_live_content=False):
|
||||
plugin = MagicMock(spec=['display', 'has_live_content', 'has_live_priority',
|
||||
'get_live_modes'])
|
||||
plugin.has_live_content.return_value = has_live_content
|
||||
plugin.has_live_priority.return_value = True
|
||||
plugin.get_live_modes.return_value = []
|
||||
return plugin
|
||||
|
||||
|
||||
def _register(controller, plugin_id, modes, plugin):
|
||||
controller.plugin_display_modes[plugin_id] = list(modes)
|
||||
for mode in modes:
|
||||
controller.plugin_modes[mode] = plugin
|
||||
controller.mode_to_plugin_id[mode] = plugin_id
|
||||
if mode not in controller.available_modes:
|
||||
controller.available_modes.append(mode)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def football(test_display_controller):
|
||||
c = test_display_controller
|
||||
_register(c, 'football-scoreboard', SPORTS_MODES, _sports_plugin())
|
||||
return c
|
||||
|
||||
|
||||
class TestANamedLiveModeIsShown:
|
||||
|
||||
def test_it_is_the_first_screen(self, football):
|
||||
football._activate_on_demand({'plugin_id': 'football-scoreboard',
|
||||
'mode': 'ncaa_fb_live'})
|
||||
assert football.on_demand_active
|
||||
assert football.current_display_mode == 'ncaa_fb_live'
|
||||
assert football.on_demand_mode == 'ncaa_fb_live'
|
||||
|
||||
def test_the_plugins_other_modes_follow_it(self, football):
|
||||
football._activate_on_demand({'plugin_id': 'football-scoreboard',
|
||||
'mode': 'ncaa_fb_live'})
|
||||
assert football.on_demand_modes[0] == 'ncaa_fb_live'
|
||||
assert set(football.on_demand_modes[1:]) == {
|
||||
'nfl_recent', 'nfl_upcoming', 'ncaa_fb_recent', 'ncaa_fb_upcoming'}
|
||||
|
||||
def test_pinned_holds_it(self, football):
|
||||
football._activate_on_demand({'plugin_id': 'football-scoreboard',
|
||||
'mode': 'ncaa_fb_live', 'pinned': True})
|
||||
assert football.on_demand_modes == ['ncaa_fb_live']
|
||||
|
||||
def test_a_bare_plugin_request_still_skips_quiet_live_modes(self, football):
|
||||
"""Only a mode asked for by name is kept: a plugin-only request
|
||||
resolves to the plugin's first mode (nfl_live), and opening on an
|
||||
empty live screen there is what the ordering exists to avoid."""
|
||||
football._activate_on_demand({'plugin_id': 'football-scoreboard'})
|
||||
assert not any(m.endswith('_live') for m in football.on_demand_modes)
|
||||
|
||||
def test_a_named_second_live_mode_with_content_leads(self, test_display_controller):
|
||||
"""With live content both live modes are kept, nfl_live first; a
|
||||
request naming ncaa_fb_live must still open on it, not rotate away."""
|
||||
c = test_display_controller
|
||||
_register(c, 'football-scoreboard', SPORTS_MODES, _sports_plugin(has_live_content=True))
|
||||
c._activate_on_demand({'plugin_id': 'football-scoreboard', 'mode': 'ncaa_fb_live'})
|
||||
assert c.on_demand_modes[0] == 'ncaa_fb_live'
|
||||
assert c.on_demand_modes.count('ncaa_fb_live') == 1
|
||||
assert 'nfl_live' in c.on_demand_modes[1:]
|
||||
|
||||
def test_the_named_mode_survives_a_restart(self, football):
|
||||
football._activate_on_demand({'plugin_id': 'football-scoreboard',
|
||||
'mode': 'ncaa_fb_live'})
|
||||
# The last on-demand config write, not the last write of any key: the
|
||||
# font-usage publisher thread writes its own key at its own pace.
|
||||
saved = [c for c in football.cache_manager.set.call_args_list
|
||||
if c.args and c.args[0] == 'display_on_demand_config'][-1]
|
||||
config = saved.args[1]
|
||||
assert config['named_mode'] == 'ncaa_fb_live'
|
||||
|
||||
football._reset_on_demand_fields()
|
||||
football._select_startup_plugins(['football-scoreboard'], config)
|
||||
football._populate_on_demand_modes_from_plugin()
|
||||
assert football.on_demand_modes[football.on_demand_mode_index] == 'ncaa_fb_live'
|
||||
|
||||
|
||||
class TestARestoreWithNothingToResume:
|
||||
|
||||
@pytest.fixture
|
||||
def restored(self, test_display_controller):
|
||||
c = test_display_controller
|
||||
c.config['clock-simple'] = {'enabled': True}
|
||||
c._select_startup_plugins(['clock-simple'],
|
||||
{'plugin_id': 'clock-simple', 'mode': 'clock-simple'})
|
||||
assert c.on_demand_active
|
||||
# The plugin's load then fails: nothing is registered for it.
|
||||
c.cache_manager.clear_cache.reset_mock()
|
||||
c._populate_on_demand_modes_from_plugin()
|
||||
return c
|
||||
|
||||
def test_the_session_ends(self, restored):
|
||||
assert not restored.on_demand_active
|
||||
assert restored.on_demand_plugin_id is None
|
||||
assert not restored.on_demand_schedule_override
|
||||
|
||||
def test_it_is_reported_as_an_error(self, restored):
|
||||
assert restored.on_demand_status == 'error'
|
||||
assert restored.on_demand_last_error == 'restore-failed'
|
||||
# The last on-demand state write, not the last write of any key: the
|
||||
# font-usage publisher thread writes its own key at its own pace.
|
||||
published = [c for c in restored.cache_manager.set.call_args_list
|
||||
if c.args and c.args[0] == 'display_on_demand_state'][-1]
|
||||
assert published.args[1]['status'] == 'error'
|
||||
assert published.args[1]['error'] == 'restore-failed'
|
||||
|
||||
def test_the_cached_request_is_dropped(self, restored):
|
||||
restored.cache_manager.clear_cache.assert_any_call('display_on_demand_config')
|
||||
|
||||
|
||||
def test_a_plugin_system_failure_ends_a_cached_session_not_yet_restored(
|
||||
mock_config_manager, mock_display_manager, mock_cache_manager,
|
||||
test_config_with_plugins, emulator_mode):
|
||||
"""Initialization can fail before the cached session is read, with
|
||||
on_demand_active still False: the session must still end, visibly."""
|
||||
from unittest.mock import patch
|
||||
from src.display_controller import DisplayController
|
||||
|
||||
mock_config_manager.get_config.return_value = test_config_with_plugins
|
||||
mock_config_manager.load_config.return_value = test_config_with_plugins
|
||||
mock_cache_manager._memory_cache['display_on_demand_config'] = {
|
||||
'plugin_id': 'clock-simple', 'mode': 'clock-simple'}
|
||||
with patch('src.display_controller.ConfigManager', return_value=mock_config_manager), \
|
||||
patch('src.display_controller.DisplayManager', return_value=mock_display_manager), \
|
||||
patch('src.display_controller.CacheManager', return_value=mock_cache_manager), \
|
||||
patch('src.display_controller.FontManager'), \
|
||||
patch('src.plugin_system.PluginManager', side_effect=RuntimeError("boom")):
|
||||
controller = DisplayController()
|
||||
try:
|
||||
assert controller.plugin_manager is None
|
||||
assert not controller.on_demand_active
|
||||
assert controller.on_demand_status == 'error'
|
||||
assert controller.on_demand_last_error == 'restore-failed'
|
||||
mock_cache_manager.clear_cache.assert_any_call('display_on_demand_config')
|
||||
finally:
|
||||
try:
|
||||
controller.cleanup()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -69,7 +69,6 @@ def test_fixed_plugin_loads_new_code_after_failed_load(plugin_env, first_source)
|
||||
assert MODULE_NAME not in sys.modules
|
||||
assert PLUGIN_ID not in pm.plugin_loader._loaded_modules
|
||||
pm.font_manager.forget_manager_fonts.assert_called_with(PLUGIN_ID)
|
||||
pm.font_manager.forget_plugin_fonts.assert_called_with(PLUGIN_ID)
|
||||
|
||||
(plugin_dir / "manager.py").write_text(_FIXED, encoding="utf-8")
|
||||
assert pm.load_plugin(PLUGIN_ID) is True
|
||||
|
||||
@@ -1,418 +0,0 @@
|
||||
"""Plugins asking for the screen in-process: BasePlugin.request_on_demand()
|
||||
and end_on_demand().
|
||||
|
||||
A plugin running in the display process used to write the
|
||||
``display_on_demand_request`` mailbox, which the display reads once a second
|
||||
while the control socket is up. These tests pin the way in that replaces it:
|
||||
|
||||
* BasePlugin -> PluginManager -> DisplayController.submit_plugin_on_demand,
|
||||
which only queues, from any thread;
|
||||
* the render thread applies the queue where it applies socket commands,
|
||||
through the mailbox's own handler, without the mailbox's read floor, and
|
||||
woken by the control socket when it is up;
|
||||
* a plugin's stop ends only its own session;
|
||||
* no display to ask (the web interface's plugin manager, an old core's
|
||||
plugin manager) answers None, which is a plugin's cue to fall back to the
|
||||
mailbox;
|
||||
* the mailbox still works for plugins that write it.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.ipc.server import ControlServer
|
||||
from src.plugin_system.base_plugin import BasePlugin
|
||||
from src.plugin_system.plugin_manager import PluginManager
|
||||
|
||||
|
||||
class _Plugin(BasePlugin):
|
||||
def update(self):
|
||||
pass
|
||||
|
||||
def display(self, force_clear=False):
|
||||
pass
|
||||
|
||||
|
||||
def _plugin(plugin_id, manager):
|
||||
plugin = _Plugin.__new__(_Plugin)
|
||||
plugin.plugin_id = plugin_id
|
||||
plugin.plugin_manager = manager
|
||||
return plugin
|
||||
|
||||
|
||||
def _manager(handler=None):
|
||||
manager = PluginManager.__new__(PluginManager)
|
||||
manager.logger = logging.getLogger('test.plugin_on_demand')
|
||||
if handler is not None:
|
||||
manager.set_on_demand_handler(handler)
|
||||
return manager
|
||||
|
||||
|
||||
class _WakeServer:
|
||||
"""The parts of ControlServer the controller uses, with no socket."""
|
||||
|
||||
def __init__(self):
|
||||
self.woken = 0
|
||||
self.has_pending = False
|
||||
|
||||
def wake(self):
|
||||
self.woken += 1
|
||||
self.has_pending = True
|
||||
|
||||
def drain(self):
|
||||
self.has_pending = False
|
||||
return []
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def controller(test_display_controller):
|
||||
c_ = test_display_controller
|
||||
c_.on_demand_active = False
|
||||
c_.on_demand_request_id = None
|
||||
c_._last_on_demand_poll = None
|
||||
mailbox = {'value': None}
|
||||
|
||||
def fake_get(key, *a, **kw):
|
||||
if key == 'display_on_demand_request':
|
||||
return mailbox['value']
|
||||
return None
|
||||
|
||||
c_.cache_manager.get = MagicMock(side_effect=fake_get)
|
||||
c_.cache_manager.set = MagicMock()
|
||||
c_.cache_manager.delete = MagicMock()
|
||||
c_._activate_on_demand = MagicMock()
|
||||
c_.mailbox = mailbox
|
||||
return c_
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def wired(controller):
|
||||
"""A real PluginManager wired to the controller, as __init__ wires it."""
|
||||
manager = _manager(controller.submit_plugin_on_demand)
|
||||
return controller, manager
|
||||
|
||||
|
||||
class TestWiring:
|
||||
def test_the_controller_wires_its_plugin_manager(self, controller):
|
||||
controller.plugin_manager.set_on_demand_handler.assert_called_once_with(
|
||||
controller.submit_plugin_on_demand)
|
||||
|
||||
def test_a_start_reaches_the_mailbox_handler(self, wired):
|
||||
controller, manager = wired
|
||||
rid = _plugin('pomodoro-timer', manager).request_on_demand(
|
||||
mode='pomodoro', duration=30, pinned=True)
|
||||
assert isinstance(rid, str) and rid
|
||||
controller._activate_on_demand.assert_not_called() # only queued
|
||||
controller._poll_on_demand_requests()
|
||||
controller._activate_on_demand.assert_called_once()
|
||||
request = controller._activate_on_demand.call_args.args[0]
|
||||
assert request['request_id'] == rid
|
||||
assert request['action'] == 'start'
|
||||
assert request['plugin_id'] == 'pomodoro-timer'
|
||||
assert request['mode'] == 'pomodoro'
|
||||
assert request['duration'] == 30.0 and request['pinned'] is True
|
||||
assert request['source'] == 'plugin'
|
||||
assert controller.on_demand_request_id == rid
|
||||
|
||||
def test_a_plugin_request_never_touches_the_mailbox(self, wired):
|
||||
controller, manager = wired
|
||||
_plugin('on-air', manager).request_on_demand(mode='on_air')
|
||||
controller._drain_control_commands()
|
||||
controller._activate_on_demand.assert_called_once()
|
||||
mailbox_reads = [call for call in controller.cache_manager.get.call_args_list
|
||||
if call.args[0] == 'display_on_demand_request']
|
||||
assert mailbox_reads == []
|
||||
controller.cache_manager.delete.assert_not_called()
|
||||
|
||||
def test_requests_apply_in_order(self, wired):
|
||||
controller, manager = wired
|
||||
seen = []
|
||||
controller._activate_on_demand = MagicMock(
|
||||
side_effect=lambda r: seen.append(r['mode']))
|
||||
plugin = _plugin('p', manager)
|
||||
for mode in ('a', 'b', 'c'):
|
||||
plugin.request_on_demand(mode=mode)
|
||||
controller._poll_on_demand_requests()
|
||||
assert seen == ['a', 'b', 'c']
|
||||
|
||||
def test_the_mailbox_still_works_for_older_plugins(self, wired):
|
||||
controller, manager = wired
|
||||
controller.mailbox['value'] = {'request_id': 'mb', 'action': 'start',
|
||||
'plugin_id': 'birdnet-go'}
|
||||
_plugin('on-air', manager).request_on_demand()
|
||||
controller._poll_on_demand_requests()
|
||||
ids = [call.args[0]['request_id'] for call in controller._activate_on_demand.call_args_list]
|
||||
assert 'mb' in ids and len(ids) == 2
|
||||
|
||||
def test_a_failing_request_is_contained(self, wired):
|
||||
controller, manager = wired
|
||||
calls = []
|
||||
|
||||
def activate(request):
|
||||
calls.append(request['mode'])
|
||||
if request['mode'] == 'bad':
|
||||
raise RuntimeError('plugin exploded')
|
||||
|
||||
controller._activate_on_demand = MagicMock(side_effect=activate)
|
||||
plugin = _plugin('p', manager)
|
||||
plugin.request_on_demand(mode='bad')
|
||||
plugin.request_on_demand(mode='good')
|
||||
controller._poll_on_demand_requests()
|
||||
assert calls == ['bad', 'good']
|
||||
|
||||
|
||||
class TestPromptness:
|
||||
def test_a_plugin_request_skips_the_pending_changes_floor(self, wired):
|
||||
controller, manager = wired
|
||||
controller._control_server = None
|
||||
controller._service_pending_changes()
|
||||
_plugin('p', manager).request_on_demand()
|
||||
controller._service_pending_changes() # well inside the 0.25 s floor
|
||||
controller._activate_on_demand.assert_called_once()
|
||||
|
||||
def test_a_plugin_request_skips_the_mailbox_floor(self, wired):
|
||||
controller, manager = wired
|
||||
controller._control_server = _WakeServer()
|
||||
controller._poll_on_demand_requests() # sets the 1 s mailbox floor
|
||||
_plugin('p', manager).request_on_demand()
|
||||
controller._poll_on_demand_requests()
|
||||
controller._activate_on_demand.assert_called_once()
|
||||
|
||||
def test_it_wakes_the_control_socket_wait(self, wired):
|
||||
controller, manager = wired
|
||||
server = ControlServer('/nonexistent/control.sock') # never started
|
||||
controller._control_server = server
|
||||
assert not server.wait_for_command(0)
|
||||
_plugin('p', manager).request_on_demand()
|
||||
assert server.has_pending
|
||||
assert controller._wait_for_control(5.0) is True # returns at once
|
||||
assert controller._control_command_pending()
|
||||
controller._poll_on_demand_requests()
|
||||
controller._activate_on_demand.assert_called_once()
|
||||
assert not server.has_pending
|
||||
assert not controller._control_command_pending()
|
||||
|
||||
def test_without_a_socket_a_waiting_request_cuts_the_sleep(self, wired):
|
||||
controller, manager = wired
|
||||
controller._control_server = None
|
||||
_plugin('p', manager).request_on_demand()
|
||||
started = time.monotonic()
|
||||
assert controller._wait_for_control(5.0) is True
|
||||
assert time.monotonic() - started < 1.0
|
||||
assert controller._control_command_pending()
|
||||
|
||||
def test_nothing_waiting_keeps_the_floor(self, controller):
|
||||
controller._control_server = None
|
||||
controller._poll_on_demand_requests = MagicMock()
|
||||
controller._service_pending_changes()
|
||||
controller._service_pending_changes()
|
||||
assert controller._poll_on_demand_requests.call_count == 1
|
||||
|
||||
|
||||
class TestThreads:
|
||||
def test_requests_from_many_threads_all_land_in_order_per_thread(self, wired):
|
||||
controller, manager = wired
|
||||
seen = []
|
||||
controller._activate_on_demand = MagicMock(
|
||||
side_effect=lambda r: seen.append(r['mode']))
|
||||
controller.PLUGIN_ON_DEMAND_QUEUE_SIZE = 10_000
|
||||
threads_n, each = 8, 50
|
||||
barrier = threading.Barrier(threads_n)
|
||||
|
||||
def ask(n):
|
||||
plugin = _plugin(f'p{n}', manager)
|
||||
barrier.wait()
|
||||
for i in range(each):
|
||||
assert plugin.request_on_demand(mode=f'{n}:{i}')
|
||||
|
||||
threads = [threading.Thread(target=ask, args=(n,)) for n in range(threads_n)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
# Drain while they ask, as the render thread would.
|
||||
while any(t.is_alive() for t in threads):
|
||||
controller._drain_control_commands()
|
||||
for t in threads:
|
||||
t.join()
|
||||
controller._drain_control_commands()
|
||||
assert len(seen) == threads_n * each
|
||||
for n in range(threads_n):
|
||||
mine = [int(m.split(':')[1]) for m in seen if m.startswith(f'{n}:')]
|
||||
assert mine == list(range(each))
|
||||
|
||||
def test_a_full_queue_refuses(self, wired, caplog):
|
||||
controller, manager = wired
|
||||
controller.PLUGIN_ON_DEMAND_QUEUE_SIZE = 2
|
||||
plugin = _plugin('p', manager)
|
||||
assert plugin.request_on_demand()
|
||||
assert plugin.request_on_demand()
|
||||
assert plugin.request_on_demand() is None
|
||||
assert 'queue full' in caplog.text
|
||||
controller._poll_on_demand_requests()
|
||||
assert controller._activate_on_demand.call_count == 2
|
||||
assert plugin.request_on_demand() # room again
|
||||
|
||||
|
||||
class TestStop:
|
||||
def test_a_plugin_ends_its_own_session(self, wired):
|
||||
controller, manager = wired
|
||||
controller.on_demand_active = True
|
||||
controller.on_demand_plugin_id = 'on-air'
|
||||
controller._clear_on_demand = MagicMock()
|
||||
assert _plugin('on-air', manager).end_on_demand()
|
||||
controller._poll_on_demand_requests()
|
||||
controller._clear_on_demand.assert_called_once_with(reason='requested-stop')
|
||||
controller.cache_manager.delete.assert_not_called()
|
||||
|
||||
def test_a_plugin_cannot_end_another_plugins_session(self, wired):
|
||||
controller, manager = wired
|
||||
controller.on_demand_active = True
|
||||
controller.on_demand_plugin_id = 'clock' # the user started it
|
||||
controller.on_demand_request_id = 'user'
|
||||
controller._clear_on_demand = MagicMock()
|
||||
_plugin('pomodoro-timer', manager).end_on_demand()
|
||||
controller._poll_on_demand_requests()
|
||||
controller._clear_on_demand.assert_not_called()
|
||||
assert controller.on_demand_request_id == 'user'
|
||||
|
||||
def test_a_stop_with_no_session_does_nothing(self, wired):
|
||||
controller, manager = wired
|
||||
controller.on_demand_status = 'error'
|
||||
controller._clear_on_demand = MagicMock()
|
||||
_plugin('on-air', manager).end_on_demand()
|
||||
controller._poll_on_demand_requests()
|
||||
controller._clear_on_demand.assert_not_called()
|
||||
|
||||
def test_a_mailbox_stop_still_ends_any_session(self, wired):
|
||||
controller, _ = wired
|
||||
controller.on_demand_active = True
|
||||
controller.on_demand_plugin_id = 'clock'
|
||||
controller._clear_on_demand = MagicMock()
|
||||
controller.mailbox['value'] = {'request_id': 's', 'action': 'stop',
|
||||
'plugin_id': 'on-air'}
|
||||
controller._poll_on_demand_requests()
|
||||
controller._clear_on_demand.assert_called_once_with(reason='requested-stop')
|
||||
|
||||
def test_start_then_stop_from_one_thread_ends_the_session(self, wired):
|
||||
controller, manager = wired
|
||||
|
||||
def activate(request):
|
||||
controller.on_demand_active = True
|
||||
controller.on_demand_plugin_id = request['plugin_id']
|
||||
|
||||
controller._activate_on_demand = MagicMock(side_effect=activate)
|
||||
controller._clear_on_demand = MagicMock()
|
||||
plugin = _plugin('pomodoro-timer', manager)
|
||||
plugin.request_on_demand(mode='pomodoro', pinned=True)
|
||||
plugin.end_on_demand()
|
||||
controller._poll_on_demand_requests()
|
||||
controller._activate_on_demand.assert_called_once()
|
||||
controller._clear_on_demand.assert_called_once_with(reason='requested-stop')
|
||||
|
||||
|
||||
class TestNoDisplay:
|
||||
"""None is a plugin's cue to write the mailbox instead."""
|
||||
|
||||
def test_a_manager_with_no_handler_answers_none(self):
|
||||
plugin = _plugin('p', _manager())
|
||||
assert plugin.request_on_demand() is None
|
||||
assert plugin.end_on_demand() is None
|
||||
|
||||
def test_no_plugin_manager_answers_none(self):
|
||||
plugin = _plugin('p', None)
|
||||
assert plugin.request_on_demand() is None
|
||||
assert plugin.end_on_demand() is None
|
||||
|
||||
def test_an_old_cores_plugin_manager_answers_none(self):
|
||||
class OldManager:
|
||||
plugin_manifests = {}
|
||||
|
||||
plugin = _plugin('p', OldManager())
|
||||
assert plugin.request_on_demand() is None
|
||||
assert plugin.end_on_demand() is None
|
||||
|
||||
def test_a_handler_that_raises_answers_none(self):
|
||||
def broken(request):
|
||||
raise RuntimeError('boom')
|
||||
|
||||
plugin = _plugin('p', _manager(broken))
|
||||
assert plugin.request_on_demand() is None
|
||||
assert plugin.end_on_demand() is None
|
||||
|
||||
def test_a_handler_that_refuses_answers_none(self):
|
||||
plugin = _plugin('p', _manager(lambda request: False))
|
||||
assert plugin.request_on_demand() is None
|
||||
|
||||
def test_a_controller_built_without_init_refuses(self):
|
||||
from src.display_controller import DisplayController
|
||||
bare = DisplayController.__new__(DisplayController)
|
||||
assert bare.submit_plugin_on_demand({'action': 'start'}) is False
|
||||
assert bare._plugin_on_demand_pending() is False
|
||||
bare._drain_plugin_on_demand() # nothing to do, no error
|
||||
|
||||
def test_the_feature_detection_pattern(self):
|
||||
"""The hasattr pattern from docs/PLUGIN_API_REFERENCE.md."""
|
||||
writes = []
|
||||
|
||||
class OldCorePlugin: # an older core's BasePlugin has no such method
|
||||
pass
|
||||
|
||||
for plugin, expect_mailbox in ((OldCorePlugin(), True),
|
||||
(_plugin('p', _manager()), True),
|
||||
(_plugin('p', _manager(lambda r: True)), False)):
|
||||
writes.clear()
|
||||
if not (hasattr(plugin, 'request_on_demand')
|
||||
and plugin.request_on_demand(mode='m')):
|
||||
writes.append('mailbox')
|
||||
assert (writes == ['mailbox']) is expect_mailbox
|
||||
|
||||
|
||||
class TestArguments:
|
||||
def test_the_manager_shapes_the_request(self):
|
||||
got = []
|
||||
plugin = _plugin('p', _manager(lambda r: got.append(r) or True))
|
||||
plugin.request_on_demand()
|
||||
plugin.end_on_demand()
|
||||
start, stop = got
|
||||
assert start['plugin_id'] == 'p' and start['mode'] is None
|
||||
assert start['duration'] is None and start['pinned'] is False
|
||||
assert start['source'] == 'plugin' and start['timestamp'] > 0
|
||||
assert stop == {'action': 'stop', 'plugin_id': 'p', 'request_id': stop['request_id'],
|
||||
'timestamp': stop['timestamp'], 'source': 'plugin'}
|
||||
assert start['request_id'] != stop['request_id']
|
||||
|
||||
@pytest.mark.parametrize('duration', [0, -5, float('inf'), float('nan')])
|
||||
def test_no_positive_duration_means_no_limit(self, duration):
|
||||
got = []
|
||||
_plugin('p', _manager(lambda r: got.append(r) or True)).request_on_demand(
|
||||
duration=duration)
|
||||
assert got[0]['duration'] is None
|
||||
|
||||
@pytest.mark.parametrize('kwargs', [{'mode': 5}, {'mode': ''}, {'duration': '30'},
|
||||
{'duration': True}])
|
||||
def test_bad_arguments_raise(self, kwargs):
|
||||
plugin = _plugin('p', _manager(lambda r: True))
|
||||
with pytest.raises(ValueError):
|
||||
plugin.request_on_demand(**kwargs)
|
||||
|
||||
|
||||
class TestMockManagers:
|
||||
def test_a_magicmock_manager_reads_as_not_taken(self):
|
||||
"""A plugin's test with a MagicMock manager keeps its mailbox path."""
|
||||
plugin = _plugin('p', MagicMock())
|
||||
assert plugin.request_on_demand(mode='m') is None
|
||||
assert plugin.end_on_demand() is None
|
||||
plugin.plugin_manager.request_on_demand.assert_called_once_with(
|
||||
'p', mode='m', duration=None, pinned=False)
|
||||
plugin.plugin_manager.end_on_demand.assert_called_once_with('p')
|
||||
|
||||
def test_a_mocked_id_is_passed_through(self):
|
||||
manager = MagicMock()
|
||||
manager.request_on_demand.return_value = 'rid'
|
||||
manager.end_on_demand.return_value = 'rid2'
|
||||
plugin = _plugin('p', manager)
|
||||
assert plugin.request_on_demand() == 'rid'
|
||||
assert plugin.end_on_demand() == 'rid2'
|
||||
@@ -162,7 +162,7 @@ class TestPublisher:
|
||||
assert snapshot["stale_after"] == rt.STALE_AFTER
|
||||
assert snapshot["plugins"] == {"clock": {
|
||||
"loaded": True, "state": "enabled", "error": None,
|
||||
"version": "1.0.0", "loaded_at": 10.0, "modes": None}}
|
||||
"version": "1.0.0", "loaded_at": 10.0}}
|
||||
|
||||
def test_changes_are_throttled_and_quiet_displays_refresh(self):
|
||||
cache = MagicMock()
|
||||
@@ -423,7 +423,7 @@ def web_listing(api_v3_module, api_v3_client, shared_cache, tmp_path): # noqa:
|
||||
{"id": "clock", "name": "Clock", "version": "1.1.0"},
|
||||
{"id": "weather", "name": "Weather", "version": "3.0.0"},
|
||||
])
|
||||
api.plugin_store_manager.get_cached_registry_info = MagicMock(return_value=None)
|
||||
api.plugin_store_manager.get_registry_info = MagicMock(return_value=None)
|
||||
api.plugin_store_manager._get_local_git_info = MagicMock(return_value=None)
|
||||
api.config_manager.load_config = MagicMock(return_value={
|
||||
"clock": {"enabled": True}, "weather": {"enabled": True}})
|
||||
|
||||
@@ -133,32 +133,6 @@ def scenario_on_demand_restored(h: RunLoopHarness):
|
||||
h.restore_on_demand("sports", mode="sports_upcoming", duration=40)
|
||||
|
||||
|
||||
def scenario_on_demand_named_live(h: RunLoopHarness):
|
||||
# Games are on until t=70, but none involves a favourite, so
|
||||
# has_live_content() (the live-priority answer) stays False throughout.
|
||||
# A request naming sports_live still opens on it (it opened on
|
||||
# sports_recent); asked for again after the games end, it has nothing to
|
||||
# draw and the session moves on to the plugin's next mode.
|
||||
h.add_plugin(FakePlugin("clock", ["clock"], duration=20))
|
||||
h.add_plugin(FakePlugin(
|
||||
"sports", ["sports_live", "sports_recent", "sports_upcoming"], duration=15,
|
||||
live_priority=True,
|
||||
content=lambda t, mode: mode != "sports_live" or t < 70))
|
||||
h.on_demand_request(5, "n1", plugin_id="sports", mode="sports_live")
|
||||
h.on_demand_request(40, "n2", action="stop")
|
||||
h.on_demand_request(100, "n3", plugin_id="sports", mode="sports_live")
|
||||
h.on_demand_request(140, "n4", action="stop")
|
||||
|
||||
|
||||
def scenario_on_demand_restore_failed(h: RunLoopHarness):
|
||||
# A restart during a session whose plugin then fails to load: the
|
||||
# session ends as an error before the first screen, and the rotation
|
||||
# runs normally from the top.
|
||||
h.add_plugin(FakePlugin("clock", ["clock"], duration=20))
|
||||
h.add_plugin(FakePlugin("weather", ["weather"], duration=20))
|
||||
h.restore_on_demand("gone", mode="gone", duration=40)
|
||||
|
||||
|
||||
def scenario_schedule(h: RunLoopHarness):
|
||||
# The clock starts at 22:59:30. Off from 23:01 until 23:05 (the window
|
||||
# spans midnight); dimmed from 23:00 until 23:01.
|
||||
@@ -220,8 +194,6 @@ SCENARIOS = {
|
||||
"on_demand": (scenario_on_demand, 240),
|
||||
"on_demand_pinned": (scenario_on_demand_pinned, 160),
|
||||
"on_demand_restored": (scenario_on_demand_restored, 100),
|
||||
"on_demand_named_live": (scenario_on_demand_named_live, 160),
|
||||
"on_demand_restore_failed": (scenario_on_demand_restore_failed, 60),
|
||||
"schedule": (scenario_schedule, 400),
|
||||
"wifi_notice": (scenario_wifi_notice, 150),
|
||||
"follower": (scenario_follower, 80),
|
||||
|
||||
@@ -1,455 +0,0 @@
|
||||
"""ScreenRunner (src/screen_runner.py) on a scripted host and a fake clock.
|
||||
|
||||
The golden traces (test_run_loop_golden.py) run it inside the real
|
||||
DisplayController; these pin down its own contract: which ExitReason each
|
||||
way of ending gives, the order it calls its host in, how the 125 Hz loop
|
||||
paces, and which service point asks what.
|
||||
"""
|
||||
|
||||
from typing import Any, List, Optional, Tuple
|
||||
|
||||
import pytest
|
||||
|
||||
from src.display_arbiter import (
|
||||
RELOAD_PLAN, SCREEN_PREEMPTERS, FramePolicy, ScreenPlan, Source,
|
||||
)
|
||||
from src.screen_runner import (
|
||||
AFTER_COMPLETED_LOOP, AFTER_LOOP, DYNAMIC_GRACE, FINAL, FRAME, HIGH_FPS_INTERVAL,
|
||||
Checkpoint, ExitReason, FirstFrame, NoticeRead, Screen, ScreenRunner,
|
||||
)
|
||||
|
||||
WIFI_PLAN = ScreenPlan(Source.WIFI)
|
||||
|
||||
|
||||
class Clock:
|
||||
"""time/perf_counter read ``now``; sleep advances it."""
|
||||
|
||||
def __init__(self):
|
||||
self.now = 0.0
|
||||
self.sleeps: List[float] = []
|
||||
|
||||
def time(self) -> float:
|
||||
return self.now
|
||||
|
||||
def perf_counter(self) -> float:
|
||||
return self.now
|
||||
|
||||
def sleep(self, seconds: float) -> None:
|
||||
self.sleeps.append(round(seconds, 6))
|
||||
self.now += seconds
|
||||
|
||||
|
||||
class Host:
|
||||
"""A ScreenHost whose answers are scripted; records every call."""
|
||||
|
||||
def __init__(self, clock: Clock, *, shown=True, raised=False, minimum=10.0,
|
||||
maximum=10.0, dynamic=False, policy=FramePolicy.STATIC,
|
||||
completed=True, draws=None, checks=None, work=0.0,
|
||||
cycle_complete_at=None, dwell_to=None):
|
||||
self.clock = clock
|
||||
self.calls: List[Tuple[Any, ...]] = []
|
||||
self.shown, self.raised = shown, raised
|
||||
self.minimum, self.maximum = minimum, maximum
|
||||
self.dynamic, self.policy, self.completed = dynamic, policy, completed
|
||||
#: display() results for the frames after the first, in order.
|
||||
self.draws = list(draws or [])
|
||||
#: (checkpoint name, frame number) -> plan, or a callable(screen, cp).
|
||||
self.checks = checks or {}
|
||||
self.work = work
|
||||
self.cycle_complete_at = cycle_complete_at
|
||||
self.dwell_to = dwell_to
|
||||
self.frames = 0
|
||||
|
||||
def first_frame(self, plan, plugin):
|
||||
self.calls.append(("first", plan.mode))
|
||||
return FirstFrame(self.shown, self.raised, False)
|
||||
|
||||
def complete_plan(self, plan, plugin):
|
||||
self.calls.append(("complete",))
|
||||
if not self.completed:
|
||||
return None
|
||||
return ScreenPlan(plan.source, mode=plan.mode, min_duration=self.minimum,
|
||||
max_duration=self.maximum, dynamic=self.dynamic,
|
||||
frame_policy=self.policy, preemptible_by=plan.preemptible_by)
|
||||
|
||||
def draw(self, screen):
|
||||
self.frames += 1
|
||||
self.calls.append(("draw", self.frames))
|
||||
self.clock.now += self.work
|
||||
return self.draws.pop(0) if self.draws else True
|
||||
|
||||
def after_frame(self, screen):
|
||||
self.calls.append(("after_frame",))
|
||||
|
||||
def tick(self):
|
||||
self.calls.append(("tick",))
|
||||
|
||||
def service(self, screen):
|
||||
self.calls.append(("service",))
|
||||
return ("scan", self.frames)
|
||||
|
||||
def wait_frame(self, interval, screen):
|
||||
self.calls.append(("wait", interval))
|
||||
self.clock.sleep(interval)
|
||||
return self._scripted("wait", screen)
|
||||
|
||||
def check(self, screen, checkpoint, live_scan=None):
|
||||
self.calls.append(("check", checkpoint.name, live_scan))
|
||||
return self._scripted(checkpoint.name, screen, checkpoint)
|
||||
|
||||
def _scripted(self, name, screen, checkpoint=None):
|
||||
answer = self.checks.get((name, self.frames))
|
||||
if callable(answer):
|
||||
return answer(screen, checkpoint)
|
||||
return answer
|
||||
|
||||
def dwell(self, seconds):
|
||||
self.calls.append(("dwell", round(seconds, 6)))
|
||||
self.clock.now = self.dwell_to if self.dwell_to is not None else self.clock.now + seconds
|
||||
|
||||
def cycle_complete(self, screen):
|
||||
self.calls.append(("cycle?",))
|
||||
return self.cycle_complete_at is not None and self.clock.now >= self.cycle_complete_at
|
||||
|
||||
|
||||
PLAN = ScreenPlan(Source.ROTATION, mode="clock", preemptible_by=SCREEN_PREEMPTERS)
|
||||
|
||||
|
||||
def _run(**kwargs) -> Tuple[Any, Host, Clock]:
|
||||
clock = Clock()
|
||||
host = Host(clock, **kwargs)
|
||||
outcome = ScreenRunner(clock, host).run(PLAN, plugin=object())
|
||||
return outcome, host, clock
|
||||
|
||||
|
||||
def _names(host, *kinds):
|
||||
return [c for c in host.calls if c[0] in kinds]
|
||||
|
||||
|
||||
class TestFirstFrame:
|
||||
|
||||
def test_no_plugin_is_empty_without_a_dispatch(self):
|
||||
clock = Clock()
|
||||
host = Host(clock)
|
||||
outcome = ScreenRunner(clock, host).run(PLAN, plugin=None)
|
||||
assert outcome.exit_reason is ExitReason.EMPTY
|
||||
assert host.calls == []
|
||||
|
||||
@pytest.mark.parametrize("raised,reason", [(False, ExitReason.EMPTY),
|
||||
(True, ExitReason.ERROR)])
|
||||
def test_nothing_shown(self, raised, reason):
|
||||
outcome, host, _ = _run(shown=False, raised=raised)
|
||||
assert outcome.exit_reason is reason
|
||||
assert host.calls == [("first", "clock")]
|
||||
|
||||
def test_an_on_demand_session_with_no_time_left(self):
|
||||
outcome, host, _ = _run(completed=False)
|
||||
assert outcome.exit_reason is ExitReason.PREEMPTED
|
||||
assert outcome.preempted_by is None
|
||||
assert host.calls == [("first", "clock"), ("complete",)]
|
||||
|
||||
|
||||
class TestStaticLoop:
|
||||
|
||||
def test_runs_its_duration_one_frame_a_second(self):
|
||||
outcome, host, clock = _run(maximum=5.0)
|
||||
assert outcome.exit_reason is ExitReason.DURATION
|
||||
assert outcome.elapsed == 5.0
|
||||
# Frames at 1..4 s; the wait that reaches 5 s ends it before a fifth.
|
||||
assert host.frames == 4
|
||||
assert clock.sleeps == [1.0] * 5
|
||||
# Each frame: wait, tick, draw, follower frame, service, the check.
|
||||
assert host.calls[2:9] == [("wait", 1.0), ("tick",), ("draw", 1), ("after_frame",),
|
||||
("service",), ("check", "frame", ("scan", 1)), ("wait", 1.0)]
|
||||
# A completed loop: the after-loop look reads no notice, then FINAL.
|
||||
assert host.calls[-2:] == [("check", "after-completed-loop", None), ("check", "final", None)]
|
||||
|
||||
def test_preempted_between_frames(self):
|
||||
by = ScreenPlan(Source.ON_DEMAND, mode="x")
|
||||
outcome, host, _ = _run(maximum=30.0, checks={("frame", 3): by})
|
||||
assert outcome.exit_reason is ExitReason.PREEMPTED
|
||||
assert outcome.preempted_by is by
|
||||
assert outcome.elapsed == 3.0
|
||||
# Decided at the service point: no second look, no dwell.
|
||||
assert host.calls[-1] == ("check", "frame", ("scan", 3))
|
||||
|
||||
def test_preempted_by_a_socket_command_in_the_frame_wait(self):
|
||||
outcome, host, _ = _run(maximum=30.0, checks={("wait", 2): WIFI_PLAN})
|
||||
assert outcome.exit_reason is ExitReason.PREEMPTED
|
||||
assert host.calls[-1] == ("wait", 1.0)
|
||||
|
||||
def test_display_false_makes_up_the_minimum(self):
|
||||
outcome, host, _ = _run(maximum=12.0, draws=[True, False])
|
||||
assert outcome.exit_reason is ExitReason.DISPLAY_FALSE
|
||||
# The loop ended early: the after-loop look may read a notice, then
|
||||
# the dwell makes up the rest of the 12 s.
|
||||
assert _names(host, "check", "dwell")[-4:] == [
|
||||
("check", "after-loop", None), ("dwell", 10.0),
|
||||
("check", "after-dwell", None), ("check", "final", None)]
|
||||
assert outcome.elapsed == 12.0
|
||||
|
||||
def test_a_notice_that_cuts_the_dwell_short_ends_the_screen(self):
|
||||
def notice(screen, checkpoint):
|
||||
assert checkpoint.notice is NoticeRead.ALWAYS
|
||||
return WIFI_PLAN if checkpoint.notice_counts else None
|
||||
outcome, _, _ = _run(maximum=12.0, draws=[False], dwell_to=6.0,
|
||||
checks={("after-dwell", 1): notice})
|
||||
assert outcome.exit_reason is ExitReason.PREEMPTED
|
||||
|
||||
def test_a_notice_after_a_full_dwell_does_not(self):
|
||||
def notice(screen, checkpoint):
|
||||
return WIFI_PLAN if checkpoint.notice_counts else None
|
||||
outcome, _, _ = _run(maximum=12.0, draws=[False],
|
||||
checks={("after-dwell", 1): notice})
|
||||
assert outcome.exit_reason is ExitReason.DISPLAY_FALSE
|
||||
|
||||
def test_a_reload_ends_the_loop_but_the_screen_counts(self):
|
||||
outcome, host, _ = _run(maximum=30.0, checks={("frame", 2): RELOAD_PLAN})
|
||||
assert outcome.exit_reason is ExitReason.RELOAD
|
||||
# Not decided at the service point: the after-loop look, the dwell
|
||||
# (which returns at once while a reload waits) and FINAL follow.
|
||||
assert ("check", "after-loop", None) in host.calls
|
||||
assert ("check", "final", None) in host.calls
|
||||
|
||||
def test_a_change_after_the_loop_preempts(self):
|
||||
by = ScreenPlan(Source.ROTATION, mode="weather")
|
||||
outcome, _, _ = _run(maximum=3.0, checks={("final", 2): by})
|
||||
assert outcome.exit_reason is ExitReason.PREEMPTED
|
||||
assert outcome.preempted_by is by
|
||||
|
||||
def test_a_dynamic_screen_keeps_going_on_false(self):
|
||||
outcome, host, _ = _run(minimum=3.0, maximum=8.0, dynamic=True,
|
||||
draws=[False, False, False])
|
||||
assert outcome.exit_reason is ExitReason.DURATION
|
||||
assert host.frames == 7
|
||||
|
||||
|
||||
class TestHighFpsLoop:
|
||||
|
||||
def test_paces_to_the_deadline(self):
|
||||
outcome, host, clock = _run(maximum=0.05, policy=FramePolicy.HIGH_FPS, work=0.003)
|
||||
assert outcome.exit_reason is ExitReason.DURATION
|
||||
# 3 ms of drawing leaves 5 ms of an 8 ms frame to sleep.
|
||||
assert set(clock.sleeps) == {round(HIGH_FPS_INTERVAL - 0.003, 6)}
|
||||
|
||||
def test_an_overrun_frame_still_yields(self):
|
||||
_, _, clock = _run(maximum=0.05, policy=FramePolicy.HIGH_FPS, work=0.02)
|
||||
assert set(clock.sleeps) == {0.001}
|
||||
|
||||
def test_service_before_the_sleep_check_after(self):
|
||||
_, host, clock = _run(maximum=0.016, policy=FramePolicy.HIGH_FPS)
|
||||
first = host.calls[2:7]
|
||||
assert first == [("draw", 1), ("after_frame",), ("tick",), ("service",),
|
||||
("check", "frame", ("scan", 1))]
|
||||
assert clock.sleeps[0] == HIGH_FPS_INTERVAL
|
||||
|
||||
def test_preempted_after_the_frames_sleep(self):
|
||||
outcome, _, clock = _run(maximum=30.0, policy=FramePolicy.HIGH_FPS,
|
||||
checks={("frame", 3): WIFI_PLAN})
|
||||
assert outcome.exit_reason is ExitReason.PREEMPTED
|
||||
assert clock.now == pytest.approx(3 * HIGH_FPS_INTERVAL)
|
||||
|
||||
def test_display_false_has_no_make_up_dwell(self):
|
||||
outcome, host, _ = _run(maximum=30.0, policy=FramePolicy.HIGH_FPS,
|
||||
draws=[True, False])
|
||||
assert outcome.exit_reason is ExitReason.DISPLAY_FALSE
|
||||
assert not _names(host, "dwell")
|
||||
|
||||
|
||||
class TestDynamicDuration:
|
||||
|
||||
def test_cycle_complete_after_the_minimum_and_grace(self):
|
||||
outcome, host, _ = _run(minimum=3.0, maximum=20.0, dynamic=True,
|
||||
cycle_complete_at=1.0)
|
||||
assert outcome.exit_reason is ExitReason.CYCLE_COMPLETE
|
||||
# Not asked before minimum + grace (3.5 s): the 1 Hz loop's first
|
||||
# frame at or past it is the one at 4 s.
|
||||
assert outcome.elapsed == 4.0
|
||||
assert DYNAMIC_GRACE == 0.5
|
||||
|
||||
def test_capped_at_the_maximum(self):
|
||||
outcome, host, _ = _run(minimum=3.0, maximum=6.0, dynamic=True)
|
||||
assert outcome.exit_reason is ExitReason.DURATION
|
||||
# The end-of-screen log asks the plugin once more, before FINAL.
|
||||
assert host.calls[-3:] == [("check", "after-completed-loop", None), ("cycle?",),
|
||||
("check", "final", None)]
|
||||
|
||||
def test_high_fps_cycle_complete(self):
|
||||
outcome, _, _ = _run(minimum=0.02, maximum=1.0, dynamic=True,
|
||||
policy=FramePolicy.HIGH_FPS, cycle_complete_at=0.0)
|
||||
assert outcome.exit_reason is ExitReason.CYCLE_COMPLETE
|
||||
assert outcome.elapsed >= 0.02 + DYNAMIC_GRACE
|
||||
|
||||
|
||||
class TestCheckpoints:
|
||||
|
||||
@pytest.mark.parametrize("checkpoint,notice,reload", [
|
||||
(FRAME, NoticeRead.IF_UNDECIDED, True),
|
||||
(AFTER_LOOP, NoticeRead.IF_UNDECIDED, False),
|
||||
(AFTER_COMPLETED_LOOP, NoticeRead.NEVER, False),
|
||||
(FINAL, NoticeRead.NEVER, False),
|
||||
])
|
||||
def test_what_each_service_point_considers(self, checkpoint, notice, reload):
|
||||
"""A reload counts only between frames; the notice file is read
|
||||
where the loop read it before stage 3."""
|
||||
assert isinstance(checkpoint, Checkpoint)
|
||||
assert (checkpoint.notice, checkpoint.reload) == (notice, reload)
|
||||
|
||||
def test_the_screen_carries_the_completed_plan(self):
|
||||
seen: List[Optional[Screen]] = []
|
||||
|
||||
def grab(screen, checkpoint):
|
||||
seen.append(screen)
|
||||
_run(maximum=2.0, checks={("frame", 1): grab})
|
||||
assert seen[0].plan.frame_policy is FramePolicy.STATIC
|
||||
assert seen[0].mode == "clock"
|
||||
|
||||
|
||||
class TestControllerServicePoint:
|
||||
"""DisplayController._screen_check: the reads it makes and what it claims,
|
||||
on a controller built by the run-loop harness."""
|
||||
|
||||
@pytest.fixture
|
||||
def dc(self, tmp_path):
|
||||
import os
|
||||
os.environ.setdefault("EMULATOR", "true")
|
||||
from test._run_loop_harness import FakePlugin, RunLoopHarness
|
||||
h = RunLoopHarness(tmp_path, horizon=10)
|
||||
h.add_plugin(FakePlugin("clock", ["clock"], duration=20))
|
||||
h.add_plugin(FakePlugin("sports", ["sports_live"], duration=20,
|
||||
live=(0, 100), live_priority=True))
|
||||
dc = h.controller
|
||||
dc.current_display_mode = "clock"
|
||||
dc.current_mode_index = 0
|
||||
dc.reads = []
|
||||
|
||||
def read():
|
||||
dc.reads.append(1)
|
||||
return {"message": "AP mode", "expires_at": 1e12}
|
||||
dc._check_wifi_status_message = read
|
||||
return dc
|
||||
|
||||
@staticmethod
|
||||
def _screen(mode="clock"):
|
||||
from src.display_arbiter import ArbiterState, rotation_plan
|
||||
return Screen(rotation_plan(ArbiterState(current_mode=mode)), plugin=None,
|
||||
accepts_display_mode=False, start=0.0)
|
||||
|
||||
def test_a_pending_notice_is_read_and_ends_the_screen(self, dc):
|
||||
by = dc._screen_check(self._screen(), FRAME)
|
||||
assert by.source is Source.WIFI and len(dc.reads) == 1
|
||||
|
||||
def test_not_read_once_the_mode_has_moved(self, dc):
|
||||
dc.current_display_mode = "sports_live"
|
||||
by = dc._screen_check(self._screen(), FRAME)
|
||||
assert by.source is Source.ROTATION and dc.reads == []
|
||||
|
||||
def test_not_read_while_scheduled_off(self, dc):
|
||||
dc.is_display_active = False
|
||||
by = dc._screen_check(self._screen(), FRAME)
|
||||
assert by.source is Source.SCHEDULED_OFF and dc.reads == []
|
||||
|
||||
def test_not_read_during_on_demand(self, dc):
|
||||
dc.on_demand_active = True
|
||||
dc.on_demand_schedule_override = True
|
||||
assert dc._screen_check(self._screen(), FRAME) is None
|
||||
assert dc.reads == []
|
||||
|
||||
def test_a_live_takeover_is_claimed_before_the_notice(self, dc):
|
||||
by = dc._screen_check(self._screen(), FRAME, live_scan=("sports_live",))
|
||||
assert by.source is Source.LIVE and dc.reads == []
|
||||
assert dc.current_display_mode == "sports_live"
|
||||
assert dc._live_takeover_unshown is True and dc._live_resume_index == 0
|
||||
|
||||
def test_after_a_completed_loop_the_notice_is_not_read(self, dc):
|
||||
assert dc._screen_check(self._screen(), AFTER_COMPLETED_LOOP) is None
|
||||
assert dc.reads == []
|
||||
|
||||
def test_after_a_full_dwell_it_is_read_but_does_not_count(self, dc):
|
||||
from src.screen_runner import after_dwell
|
||||
assert dc._screen_check(self._screen(), after_dwell(False)) is None
|
||||
assert len(dc.reads) == 1
|
||||
|
||||
def test_a_reload_counts_only_between_frames(self, dc):
|
||||
dc._check_wifi_status_message = lambda: None
|
||||
dc._pending_plugin_reloads = ("pending",)
|
||||
assert dc._screen_check(self._screen(), FRAME) is RELOAD_PLAN
|
||||
assert dc._screen_check(self._screen(), AFTER_LOOP) is None
|
||||
|
||||
def test_an_on_demand_index_past_a_shortened_list_starts_again(self, dc):
|
||||
"""_take_plan writes the shown index back, so the session's next
|
||||
step goes on from the mode actually shown."""
|
||||
from src.display_arbiter import Arbiter, ArbiterInputs
|
||||
dc.on_demand_active = True
|
||||
dc.on_demand_modes = ["clock", "sports_live"]
|
||||
dc.on_demand_mode_index = 5 # the list shrank under it
|
||||
inputs = ArbiterInputs(schedule_on=True, on_demand_active=True,
|
||||
follower_active=False)
|
||||
plan = dc._take_plan(Arbiter.decide(dc._arbiter_state(), inputs, 0.0))
|
||||
assert plan.mode == "clock" and dc.on_demand_mode_index == 0
|
||||
dc._advance_on_demand()
|
||||
assert dc.current_display_mode == "sports_live"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("policy,report_hold", [(FramePolicy.HIGH_FPS, True),
|
||||
(FramePolicy.STATIC, False)])
|
||||
def test_only_the_high_fps_loop_reports_a_held_frame(policy, report_hold):
|
||||
"""#758's report_hold: the 125 Hz loop times frames held by update();
|
||||
the 1 Hz loop's frames are a second apart and must not."""
|
||||
from unittest.mock import MagicMock
|
||||
from src.display_controller import _ScreenHost
|
||||
controller = MagicMock()
|
||||
plan = ScreenPlan(Source.ROTATION, mode="ticker", frame_policy=policy)
|
||||
plugin = object()
|
||||
_ScreenHost(controller).draw(Screen(plan, plugin, True, 0.0))
|
||||
controller._display_once.assert_called_once_with(plugin, "ticker", True,
|
||||
report_hold=report_hold)
|
||||
|
||||
|
||||
def _rows(tmp_path, horizon, build):
|
||||
import os
|
||||
os.environ.setdefault("EMULATOR", "true")
|
||||
from test._run_loop_harness import RunLoopHarness
|
||||
h = RunLoopHarness(tmp_path, horizon=horizon)
|
||||
build(h)
|
||||
return h.run()["screens"]
|
||||
|
||||
|
||||
class TestThroughRun:
|
||||
"""Service points that only the full loop reaches, on the harness."""
|
||||
|
||||
def test_a_notice_pending_when_a_later_frame_is_empty_ends_the_screen(self, tmp_path):
|
||||
"""The after-loop look reads the notice when the loop ended early: a
|
||||
1 Hz screen whose second frame has nothing to show must not sit in
|
||||
its make-up dwell (which only notices a notice that arrives during
|
||||
it) while the notice expires."""
|
||||
from test._run_loop_harness import FakePlugin
|
||||
|
||||
def build(h):
|
||||
h.add_plugin(FakePlugin("flaky", ["flaky"], duration=12, first_frame_only=True))
|
||||
h.add_plugin(FakePlugin("clock", ["clock"], duration=12))
|
||||
h.wifi_message(0.5, "Connected to HomeNet", duration=5)
|
||||
rows = _rows(tmp_path, 30, build)
|
||||
assert rows[0][1] == "flaky" and rows[0][2] == 1.0
|
||||
assert rows[1][1] == "<wifi>"
|
||||
# ... and the mode it cut short comes back, not the next one.
|
||||
after = next(row for row in rows[1:] if row[1] != "<wifi>")
|
||||
assert after[1] == "flaky"
|
||||
|
||||
def test_vegas_yielding_to_a_follower_shows_a_rotation_screen_first(self, tmp_path):
|
||||
"""Pins today's behaviour (docs/RUN_LOOP_REDESIGN.md, "may be
|
||||
wrong"): the interrupt check stops the ticker for a follower, but
|
||||
the yield path never looks at one, so a rotation screen runs its
|
||||
full duration before the next pass hands the panel to the leader."""
|
||||
from test._run_loop_harness import FakePlugin
|
||||
|
||||
def build(h):
|
||||
h.add_plugin(FakePlugin("clock", ["clock"], duration=20))
|
||||
h.add_plugin(FakePlugin("weather", ["weather"], duration=20))
|
||||
h.enable_vegas(cycle=30)
|
||||
h.sync.follower_windows = [(10, 60)]
|
||||
rows = _rows(tmp_path, 50, build)
|
||||
assert rows[0][1] == "<vegas>" and rows[0][3] == "vegas-interrupt"
|
||||
assert 10.0 <= rows[0][0] + rows[0][2] <= 10.2
|
||||
assert rows[1][1] == "clock" and rows[1][2] == 20.0
|
||||
assert rows[2][1] == "<follower>"
|
||||
@@ -1,183 +0,0 @@
|
||||
"""A frame the preview throttle skipped still reaches the snapshot.
|
||||
|
||||
The preview snapshot (/api/v3/display/current, the web UI's live preview) is
|
||||
only written from update_display(), at most once per write interval. A screen
|
||||
that draws its card once and then holds it -- soccer's recent/upcoming cards
|
||||
skip redundant redraws -- pushes exactly one frame. When that push lands inside
|
||||
the interval, e.g. a few milliseconds after the on-demand start's clear wrote a
|
||||
black frame, the throttle skips it and nothing ever writes it: on ledpi the
|
||||
preview stayed black for soccer's whole 15 s screen while the panel showed the
|
||||
card, and the next screen "rendered immediately".
|
||||
|
||||
Runs the real DisplayManager on the emulator, like test_display_dirty_tracking.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
|
||||
os.environ["EMULATOR"] = "true"
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def dm(tmp_path_factory):
|
||||
from src.display_manager import DisplayManager
|
||||
DisplayManager._instance = None
|
||||
manager = DisplayManager({
|
||||
"display": {
|
||||
"hardware": {"rows": 32, "cols": 64, "chain_length": 2,
|
||||
"parallel": 1, "brightness": 90},
|
||||
"runtime": {"gpio_slowdown": 0},
|
||||
},
|
||||
}, suppress_test_pattern=True)
|
||||
manager._snapshot_path = str(
|
||||
tmp_path_factory.mktemp("owed_snapshot") / "led_matrix_preview.png")
|
||||
yield manager
|
||||
DisplayManager._instance = None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def viewer(dm, monkeypatch, tmp_path):
|
||||
"""A preview is open (1 s write interval); fresh snapshot bookkeeping."""
|
||||
monkeypatch.setattr(dm, "_viewer_is_fresh", lambda now: True)
|
||||
dm._viewer_was_fresh = True
|
||||
dm._snapshot_path = str(tmp_path / "snap.png")
|
||||
dm._last_snapshot_ts = 0.0
|
||||
dm._last_snapshot_touch_ts = 0.0
|
||||
dm._last_snapshot_digest = None
|
||||
dm._saved_snapshot_digest = None
|
||||
dm._snapshot_owed = False
|
||||
dm.set_scrolling_state(False)
|
||||
return dm
|
||||
|
||||
|
||||
def _lit(path):
|
||||
with Image.open(path) as img:
|
||||
return sum(1 for p in img.convert("RGB").getdata() if max(p) > 20)
|
||||
|
||||
|
||||
def _age_last_write(dm, seconds=2.0):
|
||||
"""As if `seconds` had passed since the last snapshot write."""
|
||||
dm._last_snapshot_ts -= seconds
|
||||
dm._last_snapshot_touch_ts -= seconds
|
||||
|
||||
|
||||
def _clear_then_draw_card(dm):
|
||||
"""The on-demand start's clear, then the card a few ms later."""
|
||||
dm.clear()
|
||||
dm.update_display() # black frame: written
|
||||
assert _lit(dm._snapshot_path) == 0
|
||||
dm.draw.rectangle([4, 4, 40, 20], fill=(255, 255, 0))
|
||||
dm.update_display() # the card: inside the interval
|
||||
|
||||
|
||||
def _controller(dm):
|
||||
from src import display_controller as dc_module
|
||||
controller = dc_module.DisplayController.__new__(dc_module.DisplayController)
|
||||
controller.plugin_manager = None
|
||||
controller.display_manager = dm
|
||||
return controller
|
||||
|
||||
|
||||
class _HoldingPlugin:
|
||||
"""Already showing its card: display() returns True and draws nothing."""
|
||||
|
||||
plugin_id = "holding"
|
||||
|
||||
def __init__(self):
|
||||
self.calls = 0
|
||||
|
||||
def display(self, display_mode=None, force_clear=False):
|
||||
self.calls += 1
|
||||
return True
|
||||
|
||||
|
||||
def test_a_held_card_reaches_the_preview_on_the_next_frame(viewer):
|
||||
dm = viewer
|
||||
_clear_then_draw_card(dm)
|
||||
assert _lit(dm._snapshot_path) == 0 # the throttle skipped the card
|
||||
|
||||
controller = _controller(dm)
|
||||
plugin = _HoldingPlugin()
|
||||
_age_last_write(dm)
|
||||
# The render loop's next frame: the plugin draws nothing and makes no
|
||||
# update_display() call, as soccer's switch cards do.
|
||||
assert controller._display_once(plugin, "soccer_eng.1_recent", True) is True
|
||||
assert plugin.calls == 1
|
||||
assert _lit(dm._snapshot_path) > 0
|
||||
|
||||
|
||||
def test_the_owed_write_still_waits_out_the_interval(viewer, monkeypatch):
|
||||
dm = viewer
|
||||
_clear_then_draw_card(dm)
|
||||
saves = []
|
||||
monkeypatch.setattr(dm, "_save_snapshot", lambda image: saves.append(image))
|
||||
dm.write_owed_snapshot() # still inside the interval
|
||||
assert saves == []
|
||||
_age_last_write(dm)
|
||||
dm.write_owed_snapshot()
|
||||
assert len(saves) == 1
|
||||
# Written: nothing is owed, so later frames do no work and the unchanged
|
||||
# frame is not encoded again.
|
||||
assert dm._snapshot_owed is False
|
||||
_age_last_write(dm)
|
||||
dm.write_owed_snapshot()
|
||||
assert len(saves) == 1
|
||||
|
||||
|
||||
def test_a_failed_owed_write_stays_owed_and_is_retried(viewer, monkeypatch):
|
||||
dm = viewer
|
||||
_clear_then_draw_card(dm)
|
||||
_age_last_write(dm)
|
||||
attempts = []
|
||||
|
||||
def failing_save(image):
|
||||
attempts.append(image)
|
||||
raise OSError("disk full")
|
||||
|
||||
monkeypatch.setattr(dm, "_save_snapshot", failing_save)
|
||||
dm.write_owed_snapshot() # the write fails
|
||||
assert len(attempts) == 1
|
||||
assert dm._snapshot_owed is True # still owed: a held screen
|
||||
saves = [] # makes no update_display()
|
||||
monkeypatch.setattr(dm, "_save_snapshot", lambda image: saves.append(image))
|
||||
dm.write_owed_snapshot() # retried on the next frame
|
||||
assert len(saves) == 1
|
||||
assert dm._snapshot_owed is False
|
||||
|
||||
|
||||
def test_nothing_owed_after_a_frame_that_was_written(viewer, monkeypatch):
|
||||
dm = viewer
|
||||
dm.draw.rectangle([0, 0, 8, 8], fill=(0, 255, 0))
|
||||
dm.update_display() # due: written at once
|
||||
assert dm._snapshot_owed is False
|
||||
calls = []
|
||||
monkeypatch.setattr(dm, "_write_snapshot_if_due",
|
||||
lambda *a, **k: calls.append(a))
|
||||
dm.write_owed_snapshot()
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_an_unchanged_frame_inside_the_interval_is_not_owed(viewer):
|
||||
dm = viewer
|
||||
dm.draw.rectangle([0, 0, 8, 8], fill=(0, 0, 255))
|
||||
dm.update_display()
|
||||
dm.update_display() # same frame, inside the interval
|
||||
assert dm._snapshot_owed is False
|
||||
|
||||
|
||||
def test_a_controller_without_the_hook_still_draws():
|
||||
"""Controllers built without a display manager (tests) are unaffected."""
|
||||
from src import display_controller as dc_module
|
||||
controller = dc_module.DisplayController.__new__(dc_module.DisplayController)
|
||||
controller.plugin_manager = None
|
||||
plugin = _HoldingPlugin()
|
||||
assert controller._display_once(plugin, "x", True) is True
|
||||
controller.display_manager = types.SimpleNamespace()
|
||||
assert controller._display_once(plugin, "x", True) is True
|
||||
assert plugin.calls == 2
|
||||
@@ -612,12 +612,6 @@ class TestEndToEnd:
|
||||
cached['display_current_state'] = {'mode': 'from-cache', 'last_updated': 1}
|
||||
path = str(tmp_path / 'control.sock')
|
||||
monkeypatch.setenv(c.SOCKET_PATH_ENV, path)
|
||||
# The display is this process, and its render loop is beating: the
|
||||
# cache is then still its answer once the socket goes.
|
||||
heartbeat = tmp_path / 'display-heartbeat.json'
|
||||
heartbeat.write_text(json.dumps({'pid': os.getpid(), 'mono': time.monotonic(),
|
||||
'wall': time.time()}))
|
||||
monkeypatch.setattr(display_watchdog, 'HEARTBEAT_PATH', str(heartbeat))
|
||||
hub = _hub_with_everything()
|
||||
server = ControlServer(path, state_hub=hub, keepalive=0.2)
|
||||
assert server.start()
|
||||
@@ -644,8 +638,3 @@ class TestEndToEnd:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
assert (data['mode'], data['source']) == ('from-cache', 'cache')
|
||||
# Stopped: systemd takes the heartbeat's directory with it, and the
|
||||
# cache's last answer is no longer anyone's.
|
||||
heartbeat.unlink()
|
||||
data = _data(client, '/api/v3/display/current-status')
|
||||
assert (data['mode'], data['source']) == (None, 'cache')
|
||||
|
||||
@@ -446,7 +446,7 @@ class TestInstalledPluginsApi:
|
||||
**(manifest_extra or {})}
|
||||
api.plugin_catalog.get_all_plugin_info = MagicMock(return_value=[info])
|
||||
api.plugin_catalog.get_plugin_directory = MagicMock(return_value=None)
|
||||
api.plugin_store_manager.get_cached_registry_info = MagicMock(return_value=None)
|
||||
api.plugin_store_manager.get_registry_info = MagicMock(return_value=None)
|
||||
api.config_manager.load_config = MagicMock(return_value={'demo': config})
|
||||
response = api_v3_client.get('/api/v3/plugins/installed')
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -551,7 +551,7 @@ class TestPluginsAPI:
|
||||
mock_plugin_catalog.get_all_plugin_info.return_value = [
|
||||
{'id': 'weather', 'name': 'Weather Plugin'}
|
||||
]
|
||||
api_v3.plugin_store_manager.get_cached_registry_info.return_value = None
|
||||
api_v3.plugin_store_manager.get_registry_info.return_value = None
|
||||
|
||||
response = client.get('/api/v3/plugins/installed')
|
||||
|
||||
@@ -571,7 +571,7 @@ class TestPluginsAPI:
|
||||
{'id': 'weather', 'name': 'Weather', 'version': '1.0.0'}
|
||||
]
|
||||
# Registry advertises a newer version than the installed one.
|
||||
api_v3.plugin_store_manager.get_cached_registry_info.return_value = {
|
||||
api_v3.plugin_store_manager.get_registry_info.return_value = {
|
||||
'verified': True, 'latest_version': '1.2.0'
|
||||
}
|
||||
|
||||
@@ -592,7 +592,7 @@ class TestPluginsAPI:
|
||||
mock_plugin_catalog.get_all_plugin_info.return_value = [
|
||||
{'id': 'weather', 'name': 'Weather', 'version': '1.2.0'}
|
||||
]
|
||||
api_v3.plugin_store_manager.get_cached_registry_info.return_value = {
|
||||
api_v3.plugin_store_manager.get_registry_info.return_value = {
|
||||
'verified': True, 'latest_version': '1.2.0'
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ class TestInstalledList:
|
||||
info = {"id": "demo", "name": "Demo", "version": "1.0.0",
|
||||
"description": "stale cached copy", "loaded": False}
|
||||
api.plugin_catalog.get_all_plugin_info = MagicMock(return_value=[info])
|
||||
api.plugin_store_manager.get_cached_registry_info = MagicMock(return_value=None)
|
||||
api.plugin_store_manager.get_registry_info = MagicMock(return_value=None)
|
||||
api.plugin_store_manager._get_local_git_info = MagicMock(return_value=None)
|
||||
api.config_manager.load_config = MagicMock(return_value={})
|
||||
|
||||
|
||||
@@ -50,13 +50,11 @@ class FakeResult:
|
||||
self.plugins_to_install = plugins_to_install or []
|
||||
self.plugins_installed = []
|
||||
self.plugins_failed = []
|
||||
self.skipped = []
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"success": self.success,
|
||||
"restored": self.restored,
|
||||
"skipped": self.skipped,
|
||||
"errors": self.errors,
|
||||
"plugins_installed": self.plugins_installed,
|
||||
"plugins_failed": self.plugins_failed,
|
||||
@@ -288,109 +286,6 @@ class TestPluginReinstall:
|
||||
assert body["data"]["plugins_failed"][0]["error"] == "Store manager unavailable"
|
||||
|
||||
|
||||
class TestInstalledPluginsAreNotReinstalled:
|
||||
""""Reinstall missing plugins" installs only what is missing.
|
||||
|
||||
Every plugin the backup listed went to install_plugin, which replaces an
|
||||
installed copy with a fresh download: restoring onto the same device
|
||||
re-downloaded all of them inside the request. One installed from its own
|
||||
URL is not in the registry, so its "reinstall" returned False and the
|
||||
whole restore answered 500 "Restore failed" with the plugin still there.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _installed(tmp_path, *names):
|
||||
found = {}
|
||||
for name in names:
|
||||
(tmp_path / name).mkdir()
|
||||
found[name] = tmp_path / name
|
||||
return lambda plugin_id: found.get(plugin_id)
|
||||
|
||||
def test_an_installed_plugin_is_skipped_and_a_missing_one_installed(
|
||||
self, client, restore, tmp_path):
|
||||
restore.return_value = FakeResult(
|
||||
plugins_to_install=[{"plugin_id": "clock"}, {"plugin_id": "weather"}])
|
||||
store = api_v3.plugin_store_manager
|
||||
store._existing_install.side_effect = self._installed(tmp_path, "clock")
|
||||
store.install_plugin.return_value = True
|
||||
response = post(client)
|
||||
assert response.status_code == 200
|
||||
store.install_plugin.assert_called_once_with("weather")
|
||||
data = response.get_json()["data"]
|
||||
assert data["plugins_installed"] == ["weather"]
|
||||
assert data["plugins_failed"] == []
|
||||
assert "plugin:clock (installed)" in data["skipped"]
|
||||
|
||||
def test_an_installed_plugin_the_store_cannot_install_is_not_a_failure(
|
||||
self, client, restore, tmp_path):
|
||||
restore.return_value = FakeResult(plugins_to_install=[{"plugin_id": "my-3p"}])
|
||||
store = api_v3.plugin_store_manager
|
||||
store._existing_install.side_effect = self._installed(tmp_path, "my-3p")
|
||||
store.install_plugin.return_value = False
|
||||
response = post(client)
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()["data"]["plugins_failed"] == []
|
||||
store.install_plugin.assert_not_called()
|
||||
|
||||
@pytest.fixture
|
||||
def real_store(self, tmp_path):
|
||||
from src.plugin_system.store_manager import PluginStoreManager
|
||||
plugins_dir = tmp_path / "plugin-repos"
|
||||
for folder, manifest_id in (("ledmatrix-weather", "ledmatrix-weather"),
|
||||
("my-3p", "my-3p")):
|
||||
(plugins_dir / folder).mkdir(parents=True)
|
||||
(plugins_dir / folder / "manifest.json").write_text(
|
||||
json.dumps({"id": manifest_id, "version": "1.0.0"}))
|
||||
store = PluginStoreManager(plugins_dir=str(plugins_dir),
|
||||
uninstalled_registry_path=str(tmp_path / "uninstalled.json"))
|
||||
# The official weather plugin's registry id differs from the id it
|
||||
# installs under; my-3p was installed from its own URL.
|
||||
registry = {"plugins": [{
|
||||
"id": "weather", "repo": "https://github.com/ChuckBuilds/ledmatrix-plugins",
|
||||
"plugin_path": "plugins/ledmatrix-weather"}]}
|
||||
store.registry_cache = registry
|
||||
store.fetch_registry = lambda *a, **k: registry
|
||||
store.install_plugin = MagicMock(return_value=True)
|
||||
api_v3.plugin_store_manager = store
|
||||
return store
|
||||
|
||||
def test_with_the_real_store_aliases_and_third_party_installs_count(
|
||||
self, client, restore, real_store):
|
||||
restore.return_value = FakeResult(plugins_to_install=[
|
||||
{"plugin_id": "weather"}, {"plugin_id": "my-3p"}, {"plugin_id": "clock"}])
|
||||
response = post(client)
|
||||
assert response.status_code == 200
|
||||
real_store.install_plugin.assert_called_once_with("clock")
|
||||
skipped = response.get_json()["data"]["skipped"]
|
||||
assert "plugin:weather (installed)" in skipped
|
||||
assert "plugin:my-3p (installed)" in skipped
|
||||
|
||||
|
||||
class TestFontsCatalogCache:
|
||||
"""The Fonts tab's catalog is cached for 5 minutes (fonts.py).
|
||||
|
||||
Upload and delete clear it; a restore did not, so restored fonts were
|
||||
missing from the Fonts tab and every font picker until it expired.
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def cached_catalog(self):
|
||||
from web_interface.cache import delete_cached, get_cached, set_cached
|
||||
set_cached('fonts_catalog', {'fonts': ['5x7.bdf']}, ttl_seconds=300)
|
||||
yield lambda: get_cached('fonts_catalog', ttl_seconds=300)
|
||||
delete_cached('fonts_catalog')
|
||||
|
||||
def test_a_restore_that_restored_fonts_clears_it(self, client, restore, cached_catalog):
|
||||
restore.return_value = FakeResult(restored=["config", "fonts (2)"])
|
||||
assert post(client).status_code == 200
|
||||
assert cached_catalog() is None
|
||||
|
||||
def test_a_restore_without_fonts_keeps_it(self, client, restore, cached_catalog):
|
||||
restore.return_value = FakeResult(restored=["config"])
|
||||
assert post(client).status_code == 200
|
||||
assert cached_catalog() == {'fonts': ['5x7.bdf']}
|
||||
|
||||
|
||||
class TestFailureReporting:
|
||||
def test_restore_errors_produce_a_500(self, client, restore):
|
||||
restore.return_value = FakeResult(
|
||||
|
||||
@@ -13,9 +13,7 @@ tmp_path so the assertions are against files on disk rather than mock
|
||||
calls.
|
||||
"""
|
||||
|
||||
import html
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
@@ -223,66 +221,3 @@ class TestRawEndpointsBypassSecretSeparation:
|
||||
env.client.post(MAIN, json={"weather": {"api_key": "PLAINTEXT-KEY"}})
|
||||
# Nothing was moved aside into the secrets file.
|
||||
assert not env.secrets_file.exists() or "PLAINTEXT-KEY" not in env.secrets_file.read_text()
|
||||
|
||||
|
||||
class TestConfigEditorRoundTrip:
|
||||
"""The Config Editor tab (/partials/raw-json) and the save it posts to.
|
||||
|
||||
The secrets editor is shown masked, like GET /config/secrets: the page is
|
||||
served to anyone who can reach the port while the optional web login is
|
||||
off. Its save strips the masks and merges onto the stored file, so a
|
||||
masked editor saved back as it is changes nothing.
|
||||
"""
|
||||
|
||||
STORED = {
|
||||
"github": {"api_token": "ghp_REAL_TOKEN_1234"},
|
||||
"ledmatrix-weather": {"api_key": "WEATHER_KEY_abcdef", "units_id": 42},
|
||||
"calendar": {"accounts": [{"name": "home", "token": "CAL_TOKEN_9"}]},
|
||||
"youtube": {"api_key": "YOUR_YOUTUBE_API_KEY", "channel_secret": ""},
|
||||
}
|
||||
REAL_VALUES = ("ghp_REAL_TOKEN_1234", "WEATHER_KEY_abcdef", "CAL_TOKEN_9")
|
||||
|
||||
@pytest.fixture
|
||||
def editor(self, env, monkeypatch):
|
||||
from web_interface.blueprints import pages_v3 as pages_module
|
||||
env.secrets_file.write_text(json.dumps(self.STORED))
|
||||
monkeypatch.setattr(pages_module.pages_v3, "config_manager",
|
||||
env.config_manager, raising=False)
|
||||
app = Flask(__name__, template_folder=str(project_root / "web_interface" / "templates"))
|
||||
app.config["TESTING"] = True
|
||||
app.register_blueprint(pages_module.pages_v3)
|
||||
app.register_blueprint(api_v3, url_prefix="/api/v3")
|
||||
return app.test_client()
|
||||
|
||||
@staticmethod
|
||||
def _secrets_textarea(client):
|
||||
page = client.get("/partials/raw-json")
|
||||
assert page.status_code == 200
|
||||
match = re.search(r'<textarea id="secrets-config-editor"[^>]*>(.*?)</textarea>',
|
||||
page.get_data(as_text=True), re.S)
|
||||
assert match, "the secrets editor is missing from the partial"
|
||||
return html.unescape(match.group(1))
|
||||
|
||||
def test_the_editor_shows_no_secret_value(self, editor):
|
||||
text = self._secrets_textarea(editor)
|
||||
for value in self.REAL_VALUES:
|
||||
assert value not in text
|
||||
shown = json.loads(text)
|
||||
assert shown["github"]["api_token"] == "\u2022" * 8
|
||||
# Same shape as the file, and "not set" still reads as not set.
|
||||
assert shown["calendar"]["accounts"][0]["name"] == "\u2022" * 8
|
||||
assert shown["youtube"] == {"api_key": "YOUR_YOUTUBE_API_KEY", "channel_secret": ""}
|
||||
|
||||
def test_saving_it_back_unchanged_keeps_every_secret(self, editor, env):
|
||||
shown = json.loads(self._secrets_textarea(editor))
|
||||
response = editor.post(SECRETS, json=shown)
|
||||
assert response.status_code == 200
|
||||
assert json.loads(env.secrets_file.read_text()) == self.STORED
|
||||
|
||||
def test_editing_one_secret_changes_only_that_one(self, editor, env):
|
||||
shown = json.loads(self._secrets_textarea(editor))
|
||||
shown["ledmatrix-weather"]["api_key"] = "NEW_WEATHER_KEY"
|
||||
assert editor.post(SECRETS, json=shown).status_code == 200
|
||||
expected = json.loads(json.dumps(self.STORED))
|
||||
expected["ledmatrix-weather"]["api_key"] = "NEW_WEATHER_KEY"
|
||||
assert json.loads(env.secrets_file.read_text()) == expected
|
||||
|
||||
@@ -169,10 +169,6 @@ class TestGetSchemaProperty:
|
||||
},
|
||||
"fifa.world": {"type": "object",
|
||||
"properties": {"enabled": {"type": "boolean"}}},
|
||||
"cities": {"type": "array",
|
||||
"items": {"type": "object",
|
||||
"properties": {"timezone": {"type": "string"}}}},
|
||||
"color": {"type": ["array", "null"], "items": {"type": "integer"}},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,15 +185,6 @@ class TestGetSchemaProperty:
|
||||
prop = _get_schema_property(self.SCHEMA, "fifa.world.enabled")
|
||||
assert prop == {"type": "boolean"}
|
||||
|
||||
def test_an_index_steps_into_the_array_items(self):
|
||||
# How a table row posts its cells
|
||||
assert _get_schema_property(self.SCHEMA, "cities.0.timezone") == {"type": "string"}
|
||||
assert _get_schema_property(self.SCHEMA, "color.2") == {"type": "integer"}
|
||||
|
||||
def test_a_non_index_under_an_array_is_not_found(self):
|
||||
assert _get_schema_property(self.SCHEMA, "cities.timezone") is None
|
||||
assert _get_schema_property(self.SCHEMA, "cities.0.nope") is None
|
||||
|
||||
def test_missing_path_returns_none(self):
|
||||
assert _get_schema_property(self.SCHEMA, "nope.nope") is None
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ def _url(path):
|
||||
def test_the_module_directories_hold_modules():
|
||||
assert {p.name for p in MODULES} >= {"boot.js", "registry.js", "api.js", "facade.js", "cache.js",
|
||||
"durations.js", "operation-history.js", "raw-json.js",
|
||||
"backup-restore.js", "schedule.js", "general.js"}
|
||||
"backup-restore.js"}
|
||||
for directory in MODULE_DIRS:
|
||||
# node needs this to import them in the JS tests; browsers ignore it.
|
||||
assert '"type": "module"' in (directory / "package.json").read_text(encoding="utf-8")
|
||||
@@ -104,8 +104,6 @@ CONVERTED = {
|
||||
"operation-history": ("operation_history.html", "/partials/operation-history"),
|
||||
"raw-json": ("raw_json.html", "/partials/raw-json"),
|
||||
"backup-restore": ("backup_restore.html", "/partials/backup-restore"),
|
||||
"schedule": ("schedule.html", "/partials/schedule"),
|
||||
"general": ("general.html", "/partials/general"),
|
||||
}
|
||||
|
||||
# Old window.* names that moved into a page module. Each stays as a
|
||||
@@ -116,8 +114,6 @@ ALIASES = {
|
||||
"saveMainConfig", "saveSecretsConfig"],
|
||||
"backup-restore": ["exportBackup", "loadBackupList", "validateRestoreFile",
|
||||
"clearRestore", "runRestore"],
|
||||
"schedule": ["handleScheduleResponse", "handleDimScheduleResponse"],
|
||||
"general": ["webLogin"],
|
||||
}
|
||||
|
||||
|
||||
@@ -159,15 +155,12 @@ def test_moved_globals_stay_as_aliases(name):
|
||||
assert f"'{global_name}'" in boot, f"boot.js does not alias {global_name}"
|
||||
assert re.search(rf"^export (?:function|const) {global_name}\b", module, re.M), (
|
||||
f"pages/{name}.js does not export {global_name}")
|
||||
# No template defines them any more, or calls them (an inline handler
|
||||
# would reach the page only through the deprecated alias).
|
||||
# No template defines them any more.
|
||||
for partial in PARTIALS.glob("*.html"):
|
||||
text = partial.read_text(encoding="utf-8")
|
||||
for global_name in ALIASES[name]:
|
||||
assert f"window.{global_name} =" not in text, partial.name
|
||||
assert f"function {global_name}(" not in text, partial.name
|
||||
assert not re.search(rf"\b{global_name}\b", text), (
|
||||
f"{partial.name} still names {global_name}")
|
||||
|
||||
|
||||
def test_every_registered_page_has_its_module_and_partial():
|
||||
@@ -187,32 +180,3 @@ def test_converted_partials_carry_no_inline_script():
|
||||
if "data-page=" in text:
|
||||
assert "<script" not in text.lower(), (
|
||||
f"{partial.name} is a page module now; its code belongs in js/pages/")
|
||||
|
||||
|
||||
def test_the_schedule_page_reads_its_config_back_intact():
|
||||
"""schedule.html hands both saved schedules to pages/schedule.js as JSON in
|
||||
single-quoted data attributes; a value with quotes or markup must neither
|
||||
end the attribute nor change on the way."""
|
||||
from html.parser import HTMLParser
|
||||
import json
|
||||
import web_interface.app as web_app
|
||||
from flask import render_template
|
||||
|
||||
hostile = {"mode": "per-day", "start_time": "07:00", "note": "it's a \"<b>\" & '</div>"}
|
||||
|
||||
class Root(HTMLParser):
|
||||
attrs = None
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if dict(attrs).get("data-page") == "schedule":
|
||||
self.attrs = dict(attrs)
|
||||
|
||||
with web_app.app.test_request_context():
|
||||
html = render_template("v3/partials/schedule.html", schedule_config=hostile,
|
||||
dim_schedule_config=None, normal_brightness=90)
|
||||
parser = Root()
|
||||
parser.feed(html)
|
||||
assert parser.attrs is not None
|
||||
assert json.loads(parser.attrs["data-schedule-config"]) == hostile
|
||||
# A missing dim schedule arrives as null; the page treats that as {}.
|
||||
assert json.loads(parser.attrs["data-dim-schedule-config"]) is None
|
||||
|
||||
@@ -1,393 +0,0 @@
|
||||
"""GET and POST /plugins/config against a real ConfigManager and SchemaManager.
|
||||
|
||||
Each class is one bug, reproduced through the endpoint the settings form and
|
||||
API clients use, with assertions on config.json and config_secrets.json.
|
||||
"""
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
from src.config_manager import ConfigManager
|
||||
from src.plugin_system.schema_manager import SchemaManager
|
||||
from web_interface.blueprints.api_v3 import api_v3
|
||||
|
||||
PLUGIN_ID = "demo"
|
||||
OTHER_ID = "other"
|
||||
|
||||
SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean", "default": True},
|
||||
"api_key": {"type": "string", "x-secret": True, "default": ""},
|
||||
"city": {"type": "string", "default": "Austin"},
|
||||
"mqtt": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"host": {"type": "string", "default": ""},
|
||||
"port": {"type": "integer", "default": 1883,
|
||||
"minimum": 1, "maximum": 65535},
|
||||
"password": {"type": "string", "x-secret": True, "default": ""},
|
||||
},
|
||||
},
|
||||
"accounts": {
|
||||
"type": "array",
|
||||
"default": [],
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"token": {"type": "string", "x-secret": True},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
OTHER_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean", "default": True},
|
||||
"label": {"type": "string", "default": "x"},
|
||||
},
|
||||
}
|
||||
|
||||
STORED = {
|
||||
PLUGIN_ID: {"enabled": True, "city": "Paris",
|
||||
"mqtt": {"host": "broker", "port": 1883},
|
||||
"accounts": [{"name": "a"}, {"name": "b"}]},
|
||||
OTHER_ID: {"enabled": True, "label": "hello"},
|
||||
}
|
||||
|
||||
STORED_SECRETS = {
|
||||
PLUGIN_ID: {"api_key": "TOPSECRET",
|
||||
"accounts": [{"token": "TOK-A"}, {"token": "TOK-B"}]},
|
||||
}
|
||||
|
||||
_ATTRS = ('config_manager', 'plugin_catalog', 'plugin_store_manager',
|
||||
'saved_repositories_manager', 'schema_manager',
|
||||
'operation_queue', 'operation_history', 'cache_manager')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def env(tmp_path):
|
||||
config_file = tmp_path / "config.json"
|
||||
secrets_file = tmp_path / "config_secrets.json"
|
||||
plugins_dir = tmp_path / "plugins"
|
||||
for plugin_id, schema in ((PLUGIN_ID, SCHEMA), (OTHER_ID, OTHER_SCHEMA)):
|
||||
plugin_dir = plugins_dir / plugin_id
|
||||
plugin_dir.mkdir(parents=True)
|
||||
(plugin_dir / "config_schema.json").write_text(json.dumps(schema))
|
||||
(plugin_dir / "manifest.json").write_text(json.dumps({"id": plugin_id}))
|
||||
config_file.write_text(json.dumps(STORED))
|
||||
secrets_file.write_text(json.dumps(STORED_SECRETS))
|
||||
|
||||
sentinel = object()
|
||||
originals = {name: getattr(api_v3, name, sentinel) for name in _ATTRS}
|
||||
|
||||
config_manager = ConfigManager(config_path=str(config_file),
|
||||
secrets_path=str(secrets_file))
|
||||
config_manager.template_path = str(tmp_path / "no-template.json")
|
||||
plugin_manager = MagicMock()
|
||||
plugin_manager.plugin_manifests = {PLUGIN_ID: {"id": PLUGIN_ID},
|
||||
OTHER_ID: {"id": OTHER_ID}}
|
||||
plugin_manager.plugins_dir = plugins_dir
|
||||
|
||||
for name in _ATTRS:
|
||||
setattr(api_v3, name, MagicMock())
|
||||
api_v3.config_manager = config_manager
|
||||
api_v3.schema_manager = SchemaManager(plugins_dir=plugins_dir, project_root=tmp_path)
|
||||
api_v3.plugin_catalog = plugin_manager
|
||||
api_v3.operation_queue = None
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config["TESTING"] = True
|
||||
app.register_blueprint(api_v3, url_prefix="/api/v3")
|
||||
|
||||
class Env:
|
||||
client = app.test_client()
|
||||
|
||||
@staticmethod
|
||||
def use_schema(schema, plugin_id=PLUGIN_ID):
|
||||
(plugins_dir / plugin_id / "config_schema.json").write_text(json.dumps(schema))
|
||||
|
||||
@staticmethod
|
||||
def store(section, plugin_id=PLUGIN_ID):
|
||||
main = json.loads(config_file.read_text())
|
||||
main[plugin_id] = section
|
||||
config_file.write_text(json.dumps(main))
|
||||
|
||||
@staticmethod
|
||||
def main():
|
||||
return json.loads(config_file.read_text())
|
||||
|
||||
@staticmethod
|
||||
def secrets():
|
||||
return json.loads(secrets_file.read_text())
|
||||
|
||||
@staticmethod
|
||||
def post_form(data, plugin_id=PLUGIN_ID):
|
||||
return Env.client.post(f"/api/v3/plugins/config?plugin_id={plugin_id}",
|
||||
data=data)
|
||||
|
||||
@staticmethod
|
||||
def post_json(config, plugin_id=PLUGIN_ID):
|
||||
return Env.client.post("/api/v3/plugins/config",
|
||||
json={"plugin_id": plugin_id, "config": config})
|
||||
|
||||
yield Env
|
||||
|
||||
for name, original in originals.items():
|
||||
if original is sentinel:
|
||||
if hasattr(api_v3, name):
|
||||
delattr(api_v3, name)
|
||||
else:
|
||||
setattr(api_v3, name, original)
|
||||
|
||||
|
||||
class TestARejectedSaveLeavesNothingBehind:
|
||||
"""The form save edited the cached config load_config hands out, then
|
||||
failed validation. The cache kept the edit, and the next save of any
|
||||
other setting wrote it to config.json -- the rejected value, and a
|
||||
nested secret typed into the same form in plain text."""
|
||||
|
||||
REJECTED = {"mqtt.host": "broker", "mqtt.port": "99999",
|
||||
"mqtt.password": "hunter2", "__rendered_section": ["mqtt"]}
|
||||
|
||||
def test_the_rejected_values_never_reach_config_json(self, env):
|
||||
assert env.post_form(self.REJECTED).status_code == 400
|
||||
|
||||
resp = env.post_json({"label": "bye"}, plugin_id=OTHER_ID)
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
|
||||
main = env.main()
|
||||
assert main[OTHER_ID]["label"] == "bye"
|
||||
assert main[PLUGIN_ID]["mqtt"] == {"host": "broker", "port": 1883}
|
||||
assert "hunter2" not in json.dumps(main)
|
||||
|
||||
def test_the_form_reloads_with_the_stored_values(self, env):
|
||||
assert env.post_form(self.REJECTED).status_code == 400
|
||||
assert api_v3.config_manager.load_config()[PLUGIN_ID]["mqtt"]["port"] == 1883
|
||||
|
||||
|
||||
class TestGetMasksSecrets:
|
||||
"""GET /plugins/config returned the section with config_secrets.json
|
||||
merged in, secrets and all: the masking #276 added was lost when the
|
||||
route was rewritten. The settings page and GET /config/secrets mask."""
|
||||
|
||||
def test_secrets_come_back_blank(self, env):
|
||||
data = env.client.get(f"/api/v3/plugins/config?plugin_id={PLUGIN_ID}").get_json()["data"]
|
||||
assert data["api_key"] == ""
|
||||
assert data["accounts"] == [{"name": "a", "token": ""}, {"name": "b", "token": ""}]
|
||||
assert data["city"] == "Paris"
|
||||
|
||||
def test_posting_the_response_back_keeps_every_secret(self, env):
|
||||
data = env.client.get(f"/api/v3/plugins/config?plugin_id={PLUGIN_ID}").get_json()["data"]
|
||||
resp = env.post_json(data)
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert env.secrets()[PLUGIN_ID] == STORED_SECRETS[PLUGIN_ID]
|
||||
assert "TOPSECRET" not in json.dumps(env.main())
|
||||
|
||||
def test_the_settings_form_posting_masked_fields_keeps_every_secret(self, env):
|
||||
# The page renders secrets blank (pages_v3 masks the same way)
|
||||
resp = env.post_form({
|
||||
"api_key": "", "city": "Lyon", "mqtt.host": "broker", "mqtt.port": "1883",
|
||||
"mqtt.password": "", "__rendered_section": ["api_key", "city", "mqtt"]})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert env.secrets()[PLUGIN_ID] == STORED_SECRETS[PLUGIN_ID]
|
||||
assert env.main()[PLUGIN_ID]["city"] == "Lyon"
|
||||
|
||||
def test_a_plugin_without_a_schema_has_credential_named_fields_blanked(self, env, tmp_path):
|
||||
(tmp_path / "plugins" / "bare").mkdir()
|
||||
env.store({"enabled": True, "station": "KAUS"}, plugin_id="bare")
|
||||
secrets = env.secrets()
|
||||
secrets["bare"] = {"api_token": "BARE-TOKEN"}
|
||||
(tmp_path / "config_secrets.json").write_text(json.dumps(secrets))
|
||||
data = env.client.get("/api/v3/plugins/config?plugin_id=bare").get_json()["data"]
|
||||
assert data["api_token"] == ""
|
||||
assert data["station"] == "KAUS"
|
||||
|
||||
@pytest.mark.parametrize("section", ["web_auth", "github", "display"])
|
||||
def test_a_core_section_is_refused(self, env, tmp_path, section):
|
||||
secrets = env.secrets()
|
||||
secrets["web_auth"] = {"cookie_secret": "COOKIE-KEY", "password_hash": "HASH"}
|
||||
secrets["github"] = {"api_token": "ghp_TOKEN"}
|
||||
(tmp_path / "config_secrets.json").write_text(json.dumps(secrets))
|
||||
env.store({"hardware": {"rows": 32}}, plugin_id="display")
|
||||
resp = env.client.get(f"/api/v3/plugins/config?plugin_id={section}")
|
||||
assert resp.status_code == 400
|
||||
body = resp.get_data(as_text=True)
|
||||
assert "COOKIE-KEY" not in body and "ghp_TOKEN" not in body
|
||||
|
||||
|
||||
ROWS_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean", "default": True},
|
||||
"cities": {
|
||||
"type": "array",
|
||||
"x-widget": "array-table",
|
||||
"default": [],
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"timezone": {"type": "string"},
|
||||
"lat": {"type": "number"},
|
||||
"show": {"type": "boolean", "default": True},
|
||||
},
|
||||
"required": ["name", "lat"],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class TestArrayRowCellsFollowTheItemSchema:
|
||||
"""A table row posts its cells as ``cities.0.timezone``. The schema
|
||||
lookup stopped at the array, so each cell was parsed blind: a blank
|
||||
optional text cell became null and a text cell holding digits became a
|
||||
number, and either failed validation -- every save of the page, for as
|
||||
long as the row existed (geochron's city without a timezone, a countdown
|
||||
named "2027")."""
|
||||
|
||||
ROW = {"cities.0.name": "Tokyo", "cities.0.timezone": "Asia/Tokyo",
|
||||
"cities.0.lat": "35.68", "cities.0.show": "true",
|
||||
"__rendered_section": ["cities"]}
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _rows(self, env):
|
||||
env.use_schema(ROWS_SCHEMA)
|
||||
env.store({"enabled": True, "cities": [
|
||||
{"name": "Tokyo", "timezone": "Asia/Tokyo", "lat": 35.68, "show": True}]})
|
||||
|
||||
def test_a_blank_optional_text_cell_saves(self, env):
|
||||
resp = env.post_form({**self.ROW, "cities.0.timezone": ""})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert env.main()[PLUGIN_ID]["cities"][0]["timezone"] == ""
|
||||
|
||||
def test_a_text_cell_of_digits_stays_text(self, env):
|
||||
resp = env.post_form({**self.ROW, "cities.0.name": "2027"})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert env.main()[PLUGIN_ID]["cities"][0]["name"] == "2027"
|
||||
|
||||
def test_number_and_boolean_cells_still_convert(self, env):
|
||||
resp = env.post_form({**self.ROW, "cities.0.show": "false"})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert env.main()[PLUGIN_ID]["cities"] == [
|
||||
{"name": "Tokyo", "timezone": "Asia/Tokyo", "lat": 35.68, "show": False}]
|
||||
|
||||
|
||||
class TestMaskedSecretCellsInARow:
|
||||
"""The same lookup: a row's secret cell, rendered blank, came back as
|
||||
null and failed validation, so a plugin with secrets in a list could not
|
||||
be saved from its settings page at all."""
|
||||
|
||||
def test_the_stored_tokens_survive_a_save_of_the_form(self, env):
|
||||
resp = env.post_form({
|
||||
"city": "Lyon", "accounts.0.name": "a", "accounts.0.token": "",
|
||||
"accounts.1.name": "b", "accounts.1.token": "",
|
||||
"__rendered_section": ["city", "accounts"]})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert env.secrets()[PLUGIN_ID] == STORED_SECRETS[PLUGIN_ID]
|
||||
assert env.main()[PLUGIN_ID]["accounts"] == [{"name": "a"}, {"name": "b"}]
|
||||
|
||||
|
||||
class TestABlankSecretIsLeftAsStored:
|
||||
"""The form renders a secret blank and posts the blank back. For a
|
||||
required secret with no default (youtube-stats' api_key) the blank was
|
||||
read as null, failed validation, and blocked every save of the page
|
||||
until the key was typed in again."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _required_secret(self, env):
|
||||
schema = json.loads(json.dumps(SCHEMA))
|
||||
del schema["properties"]["api_key"]["default"]
|
||||
schema["required"] = ["api_key"]
|
||||
env.use_schema(schema)
|
||||
|
||||
def test_saving_other_settings_keeps_the_stored_secret(self, env):
|
||||
resp = env.post_form({"api_key": "", "city": "Lyon",
|
||||
"__rendered_section": ["api_key", "city"]})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert env.main()[PLUGIN_ID]["city"] == "Lyon"
|
||||
assert env.secrets()[PLUGIN_ID]["api_key"] == "TOPSECRET"
|
||||
|
||||
def test_a_new_secret_is_still_saved(self, env):
|
||||
resp = env.post_form({"api_key": "NEW-KEY", "city": "Lyon",
|
||||
"__rendered_section": ["api_key", "city"]})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert env.secrets()[PLUGIN_ID]["api_key"] == "NEW-KEY"
|
||||
|
||||
def test_a_changed_secret_then_left_blank_stays_changed(self, env):
|
||||
# The second save must not write back what the first one's load
|
||||
# had merged in (the old key)
|
||||
env.post_form({"api_key": "NEW-KEY", "__rendered_section": ["api_key"]})
|
||||
resp = env.post_form({"api_key": "", "city": "Nice",
|
||||
"__rendered_section": ["api_key", "city"]})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert env.secrets()[PLUGIN_ID]["api_key"] == "NEW-KEY"
|
||||
|
||||
def test_a_blank_list_secret_is_left_as_stored_too(self, env, tmp_path):
|
||||
schema = json.loads(json.dumps(SCHEMA))
|
||||
schema["properties"]["tokens"] = {"type": "array", "x-secret": True,
|
||||
"items": {"type": "string"}, "default": []}
|
||||
env.use_schema(schema)
|
||||
secrets = env.secrets()
|
||||
secrets[PLUGIN_ID]["tokens"] = ["t1", "t2"]
|
||||
(tmp_path / "config_secrets.json").write_text(json.dumps(secrets))
|
||||
resp = env.post_form({"tokens": "", "city": "Lyon",
|
||||
"__rendered_section": ["tokens", "city"]})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert env.secrets()[PLUGIN_ID]["tokens"] == ["t1", "t2"]
|
||||
|
||||
|
||||
class TestSaveRefusesWhatIsNotAPluginId:
|
||||
"""GET and reset refuse a core section or a malformed id; the save took
|
||||
any of them. ``{"plugin_id": "display"}`` merged unvalidated values into
|
||||
the core display section, and an id that was not a string raised a
|
||||
TypeError, answered as a 500."""
|
||||
|
||||
def test_a_core_section_is_refused_and_left_alone(self, env):
|
||||
env.store({"hardware": {"rows": 32}}, plugin_id="display")
|
||||
resp = env.post_json({"hardware": {"rows": "banana"}}, plugin_id="display")
|
||||
assert resp.status_code == 400
|
||||
assert env.main()["display"] == {"hardware": {"rows": 32}}
|
||||
|
||||
def test_the_form_save_refuses_one_too(self, env):
|
||||
resp = env.post_form({"password_hash": "x"}, plugin_id="web_auth")
|
||||
assert resp.status_code == 400
|
||||
assert "web_auth" not in env.main()
|
||||
|
||||
@pytest.mark.parametrize("plugin_id", [["demo"], {"id": "demo"}, 7, "", "../demo"])
|
||||
def test_a_malformed_id_is_a_400(self, env, plugin_id):
|
||||
resp = env.post_json({"city": "Lyon"}, plugin_id=plugin_id)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
class TestTextFieldsKeepWhatWasTyped:
|
||||
"""A text field holding "true", "False", "[1, 2]" or "{}" was converted
|
||||
to a boolean, list or object before the schema's type was consulted, and
|
||||
the save then failed validation for a perfectly good string."""
|
||||
|
||||
@pytest.mark.parametrize("typed", ["true", "False", "[1, 2]", "{}", "42"])
|
||||
def test_a_text_field(self, env, typed):
|
||||
resp = env.post_form({"city": typed, "__rendered_section": ["city"]})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert env.main()[PLUGIN_ID]["city"] == typed
|
||||
|
||||
def test_a_nullable_text_field(self, env):
|
||||
schema = json.loads(json.dumps(SCHEMA))
|
||||
schema["properties"]["nickname"] = {"type": ["string", "null"], "default": None}
|
||||
env.use_schema(schema)
|
||||
resp = env.post_form({"nickname": "false", "__rendered_section": ["nickname"]})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert env.main()[PLUGIN_ID]["nickname"] == "false"
|
||||
|
||||
def test_other_types_still_convert(self, env):
|
||||
resp = env.post_form({"mqtt.host": "true", "mqtt.port": "8883",
|
||||
"__rendered_section": ["mqtt"]})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert env.main()[PLUGIN_ID]["mqtt"] == {"host": "true", "port": 8883}
|
||||
@@ -26,44 +26,13 @@ import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch):
|
||||
from web_interface import app as web_app
|
||||
def client():
|
||||
from web_interface.app import app
|
||||
# The captive-portal before_request hook shells out to systemctl/nmcli
|
||||
# whenever its 30s cache is cold, so on a Linux host whether a request
|
||||
# here runs subprocess depended on how long ago the previous one was --
|
||||
# and several tests below stub subprocess. Pin it: no test in this file
|
||||
# is about AP mode.
|
||||
monkeypatch.setattr(web_app, 'is_ap_mode_active', lambda: False)
|
||||
# GET /plugins/installed looks up each plugin's registry entry, and on a
|
||||
# cold cache that fetches plugins.json from GitHub -- so without a
|
||||
# connection those tests sat in the HTTP retry loop. No test in this
|
||||
# file is about the registry.
|
||||
monkeypatch.setattr(web_app.plugin_store_manager, 'fetch_registry',
|
||||
lambda *args, **kwargs: {'plugins': []})
|
||||
app.config['TESTING'] = True
|
||||
with app.test_client() as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def starlark_apps_dir(tmp_path, monkeypatch):
|
||||
"""Point every Starlark storage path at tmp_path for every test.
|
||||
|
||||
The manifest, its directory and the lock file are three separate module
|
||||
constants. Fixtures that redirected the first two but not the lock left
|
||||
the lock pointing at the repo, and on Linux (where the lock is taken)
|
||||
each test created starlark-apps/manifest.json.lock in the checkout. The
|
||||
directory is not created here; tests that need it make it.
|
||||
"""
|
||||
from web_interface.blueprints import api_v3 as module
|
||||
apps_dir = tmp_path / "starlark-apps"
|
||||
monkeypatch.setattr(module, '_STARLARK_APPS_DIR', apps_dir)
|
||||
monkeypatch.setattr(module, '_STARLARK_MANIFEST_FILE', apps_dir / 'manifest.json')
|
||||
monkeypatch.setattr(module, '_STARLARK_MANIFEST_LOCK_FILE', apps_dir / 'manifest.json.lock')
|
||||
return apps_dir
|
||||
|
||||
|
||||
class TestRoutesAreRegistered:
|
||||
"""The failure was a missing route, so check the URL map directly.
|
||||
|
||||
@@ -422,9 +391,13 @@ class TestTheManifestSurvivesConcurrentWriters:
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def starlark_dir(self, starlark_apps_dir):
|
||||
starlark_apps_dir.mkdir()
|
||||
return starlark_apps_dir
|
||||
def starlark_dir(self, tmp_path, monkeypatch):
|
||||
from web_interface.blueprints import api_v3 as module
|
||||
apps_dir = tmp_path / "starlark-apps"
|
||||
apps_dir.mkdir()
|
||||
monkeypatch.setattr(module, '_STARLARK_APPS_DIR', apps_dir)
|
||||
monkeypatch.setattr(module, '_STARLARK_MANIFEST_FILE', apps_dir / 'manifest.json')
|
||||
return apps_dir
|
||||
|
||||
def test_each_writer_gets_its_own_temp_file(self, starlark_dir):
|
||||
from web_interface.blueprints import api_v3 as module
|
||||
@@ -580,9 +553,13 @@ class TestTheManifestStaysRelocatable:
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def starlark_dir(self, starlark_apps_dir):
|
||||
starlark_apps_dir.mkdir()
|
||||
return starlark_apps_dir
|
||||
def starlark_dir(self, tmp_path, monkeypatch):
|
||||
from web_interface.blueprints import api_v3 as module
|
||||
apps_dir = tmp_path / "starlark-apps"
|
||||
apps_dir.mkdir()
|
||||
monkeypatch.setattr(module, '_STARLARK_APPS_DIR', apps_dir)
|
||||
monkeypatch.setattr(module, '_STARLARK_MANIFEST_FILE', apps_dir / 'manifest.json')
|
||||
return apps_dir
|
||||
|
||||
def _install(self, tmp_path):
|
||||
from web_interface.blueprints import api_v3 as module
|
||||
@@ -622,10 +599,13 @@ class TestManifestLockPreventsLostUpdates:
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def starlark_dir(self, starlark_apps_dir):
|
||||
def starlark_dir(self, tmp_path, monkeypatch):
|
||||
from web_interface.blueprints import api_v3 as module
|
||||
apps_dir = starlark_apps_dir
|
||||
apps_dir = tmp_path / "starlark-apps"
|
||||
apps_dir.mkdir()
|
||||
monkeypatch.setattr(module, '_STARLARK_APPS_DIR', apps_dir)
|
||||
monkeypatch.setattr(module, '_STARLARK_MANIFEST_FILE', apps_dir / 'manifest.json')
|
||||
monkeypatch.setattr(module, '_STARLARK_MANIFEST_LOCK_FILE', apps_dir / 'manifest.json.lock')
|
||||
module._write_starlark_manifest({'apps': {}})
|
||||
return apps_dir
|
||||
|
||||
@@ -713,10 +693,12 @@ class TestConfigAndManifestStayInSync:
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def app_dir(self, starlark_apps_dir):
|
||||
def app_dir(self, tmp_path, monkeypatch):
|
||||
from web_interface.blueprints import api_v3 as module
|
||||
apps_dir = starlark_apps_dir
|
||||
apps_dir = tmp_path / "starlark-apps"
|
||||
apps_dir.mkdir()
|
||||
monkeypatch.setattr(module, '_STARLARK_APPS_DIR', apps_dir)
|
||||
monkeypatch.setattr(module, '_STARLARK_MANIFEST_FILE', apps_dir / 'manifest.json')
|
||||
one_app_dir = apps_dir / 'demo'
|
||||
one_app_dir.mkdir()
|
||||
module._write_starlark_manifest({'apps': {'demo': {'name': 'Demo', 'enabled': True}}})
|
||||
@@ -1060,18 +1042,13 @@ class TestTheStoreUsesTheTokenTheUserConfigured:
|
||||
/plugins/store/github-status reported `authenticated: true` with a
|
||||
rate_limit of 5000 while /starlark/repository/browse reported a limit of
|
||||
60 -- the store going blank was that 60 running out.
|
||||
|
||||
The managers are attributes web_interface/app.py hangs on the blueprint
|
||||
when it is imported, so they exist only once some earlier test has
|
||||
imported the app. Every patch here passes create=True: these tests must
|
||||
not depend on which test ran before them.
|
||||
"""
|
||||
|
||||
def test_the_store_managers_token_is_used(self):
|
||||
from web_interface.blueprints import api_v3 as mod
|
||||
|
||||
with patch.object(mod.api_v3, 'plugin_store_manager',
|
||||
MagicMock(github_token='ghp_configured'), create=True):
|
||||
MagicMock(github_token='ghp_configured')):
|
||||
assert mod._starlark_github_token() == 'ghp_configured'
|
||||
|
||||
def test_a_hand_edited_config_key_still_works(self):
|
||||
@@ -1080,8 +1057,8 @@ class TestTheStoreUsesTheTokenTheUserConfigured:
|
||||
cfg = MagicMock()
|
||||
cfg.load_config.return_value = {'github_token': 'ghp_by_hand'}
|
||||
with patch.object(mod.api_v3, 'plugin_store_manager',
|
||||
MagicMock(github_token=None), create=True), \
|
||||
patch.object(mod.api_v3, 'config_manager', cfg, create=True):
|
||||
MagicMock(github_token=None)), \
|
||||
patch.object(mod.api_v3, 'config_manager', cfg):
|
||||
assert mod._starlark_github_token() == 'ghp_by_hand'
|
||||
|
||||
def test_no_token_anywhere_is_not_an_error(self):
|
||||
@@ -1090,8 +1067,8 @@ class TestTheStoreUsesTheTokenTheUserConfigured:
|
||||
cfg = MagicMock()
|
||||
cfg.load_config.return_value = {}
|
||||
with patch.object(mod.api_v3, 'plugin_store_manager',
|
||||
MagicMock(github_token=None), create=True), \
|
||||
patch.object(mod.api_v3, 'config_manager', cfg, create=True):
|
||||
MagicMock(github_token=None)), \
|
||||
patch.object(mod.api_v3, 'config_manager', cfg):
|
||||
assert mod._starlark_github_token() is None
|
||||
|
||||
def test_an_unreadable_config_does_not_take_the_store_down(self):
|
||||
@@ -1100,8 +1077,8 @@ class TestTheStoreUsesTheTokenTheUserConfigured:
|
||||
cfg = MagicMock()
|
||||
cfg.load_config.side_effect = OSError("config.json is unreadable")
|
||||
with patch.object(mod.api_v3, 'plugin_store_manager',
|
||||
MagicMock(github_token=None), create=True), \
|
||||
patch.object(mod.api_v3, 'config_manager', cfg, create=True):
|
||||
MagicMock(github_token=None)), \
|
||||
patch.object(mod.api_v3, 'config_manager', cfg):
|
||||
assert mod._starlark_github_token() is None
|
||||
|
||||
def test_browse_hands_the_token_to_the_repository(self, client):
|
||||
@@ -1115,7 +1092,7 @@ class TestTheStoreUsesTheTokenTheUserConfigured:
|
||||
repo.return_value.get_rate_limit_info.return_value = {'remaining': 4999}
|
||||
|
||||
with patch.object(mod.api_v3, 'plugin_store_manager',
|
||||
MagicMock(github_token='ghp_configured'), create=True), \
|
||||
MagicMock(github_token='ghp_configured')), \
|
||||
patch('web_interface.blueprints.api_v3._get_tronbyte_repository_class',
|
||||
return_value=repo):
|
||||
client.get('/api/v3/starlark/repository/browse')
|
||||
@@ -1159,23 +1136,12 @@ class TestPixletEditorHostDefaultsButDoesNotOverride:
|
||||
captured['env'] = env
|
||||
return FakeProcess()
|
||||
|
||||
# Swap the route module's own ``subprocess`` binding, not the shared
|
||||
# ``subprocess.Popen``: patching the attribute on the real module is
|
||||
# process-wide, and the app's before_request hook (the captive-portal
|
||||
# check) runs ``subprocess.run`` -- ``with Popen(...)`` -- whenever its
|
||||
# 30s AP-mode cache is cold on a host with systemctl. On the Linux CI
|
||||
# runner that handed it this FakeProcess and 500'd the request, but
|
||||
# only when the previous request was more than 30s earlier.
|
||||
fake_subprocess = types.ModuleType('subprocess')
|
||||
fake_subprocess.__dict__.update(mod.subprocess.__dict__)
|
||||
fake_subprocess.Popen = fake_popen
|
||||
|
||||
with patch.object(mod, '_validate_starlark_app_path',
|
||||
return_value=(app_dir, None)), \
|
||||
patch.object(mod, '_PIXLET_EDITOR_SCRIPT', script), \
|
||||
patch.object(mod, '_PIXLET_EDITOR_STATE', state_file), \
|
||||
patch.object(mod, '_find_pixlet_binary', return_value='/usr/bin/pixlet'), \
|
||||
patch.object(mod, 'subprocess', fake_subprocess), \
|
||||
patch.object(mod.subprocess, 'Popen', side_effect=fake_popen), \
|
||||
patch.dict(os.environ):
|
||||
if operator_host is None:
|
||||
os.environ.pop('PIXLET_EDITOR_HOST', None)
|
||||
@@ -1205,13 +1171,15 @@ class TestStandaloneRenderUsesTheDeviceLocation:
|
||||
SCHEMA = {"schema": [{"typeOf": "location", "id": "location"}]}
|
||||
|
||||
@pytest.fixture
|
||||
def app_dir(self, starlark_apps_dir, monkeypatch):
|
||||
def app_dir(self, tmp_path, monkeypatch):
|
||||
from web_interface.blueprints import api_v3 as module
|
||||
apps_dir = starlark_apps_dir
|
||||
apps_dir = tmp_path / "starlark-apps"
|
||||
app_dir = apps_dir / "weather"
|
||||
app_dir.mkdir(parents=True)
|
||||
(app_dir / "weather.star").write_text("# app")
|
||||
(app_dir / "schema.json").write_text(json.dumps(self.SCHEMA))
|
||||
monkeypatch.setattr(module, '_STARLARK_APPS_DIR', apps_dir)
|
||||
monkeypatch.setattr(module, '_STARLARK_MANIFEST_FILE', apps_dir / 'manifest.json')
|
||||
(apps_dir / 'manifest.json').write_text(json.dumps(
|
||||
{'apps': {'weather': {'star_file': 'weather.star'}}}))
|
||||
config_manager = MagicMock()
|
||||
|
||||
@@ -128,7 +128,6 @@ class Web:
|
||||
store = api.plugin_store_manager
|
||||
store.plugins_dir = str(self.plugins_dir)
|
||||
store.get_registry_info.return_value = None
|
||||
store.get_cached_registry_info.return_value = None
|
||||
store.get_plugin_info.return_value = None
|
||||
store._get_local_git_info.return_value = None
|
||||
store.install_plugin.return_value = True
|
||||
@@ -222,9 +221,7 @@ class TestTheWebProcessNeverRunsAPlugin:
|
||||
def test_saving_its_section_through_the_main_config(self, web):
|
||||
body = web.post("/api/v3/config/main", {PLUGIN_ID: {"message": "via main"}})
|
||||
assert web.stored()["message"] == "via main"
|
||||
# The display's config watcher hands the section to the running
|
||||
# plugin (on_config_change), as for /plugins/config: no restart.
|
||||
assert body["restart_required"] is False
|
||||
assert body["restart_required"] is True
|
||||
assert web.ran() == []
|
||||
|
||||
def test_resetting_its_config(self, web):
|
||||
|
||||
@@ -159,18 +159,10 @@ schema_manager = SchemaManager(
|
||||
# saves reach the running plugins through the display's config watcher; what
|
||||
# the display knows at run time (health, metrics, errors, current mode) it
|
||||
# publishes to the shared cache. See docs/ARCHITECTURE.md.
|
||||
def _catalog_runtime_view():
|
||||
"""The display's runtime view, for the catalog's mode lookups. Imported
|
||||
on call, as the startup reconciliation below imports it."""
|
||||
from web_interface.blueprints.api_v3 import _plugin_runtime_view
|
||||
return _plugin_runtime_view()
|
||||
|
||||
|
||||
plugin_catalog = PluginCatalog(
|
||||
plugins_dir=plugins_dir,
|
||||
config_manager=config_manager,
|
||||
schema_manager=schema_manager,
|
||||
runtime_source=_catalog_runtime_view,
|
||||
)
|
||||
|
||||
# Initialize operation queue for plugin operations
|
||||
|
||||
@@ -957,19 +957,6 @@ def _get_schema_property(schema, key_path):
|
||||
i = j
|
||||
matched = True
|
||||
break
|
||||
# Through an array to its items: a table row posts its cells
|
||||
# as "cities.0.timezone", where the index names no property.
|
||||
# Stopping here left each cell parsed with no schema at all,
|
||||
# so a blank text cell became null and "2027" a number.
|
||||
items = prop.get('items') if _schema_type_is(prop, 'array') else None
|
||||
if isinstance(items, dict) and parts[j].isdigit():
|
||||
if j + 1 == len(parts):
|
||||
return items
|
||||
if 'properties' in items:
|
||||
current = items['properties']
|
||||
i = j + 1
|
||||
matched = True
|
||||
break
|
||||
# Matched a non-object before consuming the path — can't go deeper.
|
||||
return None
|
||||
if not matched:
|
||||
@@ -1053,16 +1040,6 @@ def _parse_form_value_with_schema(value, key_path, schema):
|
||||
|
||||
# Handle None/empty values
|
||||
if value is None or (isinstance(value, str) and value.strip() == ''):
|
||||
# The form draws a stored secret blank, so a blank secret means
|
||||
# "unchanged", and "" is what the save drops as unchanged
|
||||
# (remove_empty_secrets). A required one with no default fell
|
||||
# through to None below, failed validation, and blocked every save
|
||||
# of the page until the secret was typed in again. Not _SKIP_FIELD:
|
||||
# that keeps the merged value from load_config(), which the save
|
||||
# would then write back to config_secrets.json. Text secrets only:
|
||||
# a list or object one gets its empty value below, dropped the same.
|
||||
if prop and prop.get('x-secret') and prop.get('type', 'string') == 'string':
|
||||
return ""
|
||||
# A nullable field left blank means null, not an empty container.
|
||||
# This is the inherit sentinel for per-mode style overrides: an
|
||||
# empty list there would read as "the user chose no colour" rather
|
||||
@@ -1097,14 +1074,6 @@ def _parse_form_value_with_schema(value, key_path, schema):
|
||||
if isinstance(value, str):
|
||||
stripped = value.strip()
|
||||
|
||||
# A text field keeps what was typed. The guesses below ran first, so
|
||||
# "true", "False", "[1, 2]" or "{}" in a text field became a boolean,
|
||||
# list or object, and the save failed validation for a good string.
|
||||
declared = prop.get('type') if isinstance(prop, dict) else None
|
||||
if declared == 'string' or (isinstance(declared, list) and
|
||||
[t for t in declared if t != 'null'] == ['string']):
|
||||
return value
|
||||
|
||||
# Check for boolean strings
|
||||
if stripped.lower() == 'true':
|
||||
return True
|
||||
|
||||
@@ -16,7 +16,6 @@ import web_interface.blueprints.api_v3 as _pkg
|
||||
# as module attributes, and a value binding would not see the patch.
|
||||
# Several are also called from helpers that live in __init__, so the
|
||||
# package is the only patch point that covers every caller.
|
||||
from web_interface.cache import delete_cached
|
||||
|
||||
|
||||
@api_v3.route('/backup/preview', methods=['GET'])
|
||||
@@ -86,17 +85,6 @@ _RESTORE_OPTION_KEYS = frozenset((
|
||||
'restore_config', 'restore_secrets', 'restore_wifi', 'restore_fonts',
|
||||
'restore_plugin_uploads', 'reinstall_plugins',
|
||||
))
|
||||
def _installed_path(psm, plugin_id):
|
||||
"""Where the store finds ``plugin_id`` installed, or None.
|
||||
|
||||
The same lookup install_plugin makes to decide that a copy exists: the
|
||||
id, or an id the registry proves is the same plugin (``aliases``, the
|
||||
``plugin_path`` name), never a bare ``ledmatrix-<id>`` folder.
|
||||
"""
|
||||
found = psm._existing_install(plugin_id)
|
||||
return found if isinstance(found, Path) and found.exists() else None
|
||||
|
||||
|
||||
@api_v3.route('/backup/restore', methods=['POST'])
|
||||
def backup_restore():
|
||||
"""Restore a backup ZIP with optional RestoreOptions."""
|
||||
@@ -146,10 +134,6 @@ def backup_restore():
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
# Restored fonts reach the Fonts tab through a catalog cached for five
|
||||
# minutes (fonts.py); upload and delete clear it, and so must this.
|
||||
if any(str(item).startswith('fonts') for item in result.restored):
|
||||
delete_cached('fonts_catalog')
|
||||
|
||||
# Reinstall plugins if requested and store manager available
|
||||
if options.reinstall_plugins and result.plugins_to_install:
|
||||
@@ -159,15 +143,6 @@ def backup_restore():
|
||||
if not pid:
|
||||
continue
|
||||
try:
|
||||
# Only what is missing. install_plugin replaces an installed
|
||||
# copy with a fresh download, so restoring onto the same
|
||||
# device re-downloaded every plugin, and one installed from
|
||||
# its own URL (not in the registry) "failed" and failed the
|
||||
# whole restore while it sat there installed. The store's
|
||||
# own lookup, so registry aliases count as installed too.
|
||||
if psm and _installed_path(psm, pid) is not None:
|
||||
result.skipped.append(f'plugin:{pid} (installed)')
|
||||
continue
|
||||
if psm and hasattr(psm, 'install_plugin'):
|
||||
ok = psm.install_plugin(pid)
|
||||
if ok:
|
||||
|
||||
@@ -17,8 +17,6 @@ from src.pi5_matrix_support import is_raspberry_pi_5
|
||||
from web_interface.cache import invalidate_cache
|
||||
from web_interface.auth import SECTION as _WEB_AUTH_SECTION, strip_auth_section
|
||||
import web_interface.blueprints.api_v3 as _pkg
|
||||
import copy
|
||||
from typing import Any, Dict, Iterable, Tuple
|
||||
|
||||
# Read through the module rather than bound by value: tests patch these
|
||||
# as module attributes, and a value binding would not see the patch.
|
||||
@@ -35,50 +33,6 @@ FORM_SECTION_FIELD = '__form_section'
|
||||
GENERAL_FIELDS = ('timezone', 'city', 'state', 'country', 'web_display_autostart',
|
||||
'plugins_directory', 'auto_update_enabled', 'auto_update_channel')
|
||||
|
||||
#: Settings in config.json the running display applies without a restart,
|
||||
#: as key paths (a path covers everything under it). Brightness goes over the
|
||||
#: control socket (brightness.set) and the config watcher's refresh
|
||||
#: (DisplayController._refresh_config_cache); the per-mode durations are read
|
||||
#: from the live config each time a mode starts (_get_display_duration).
|
||||
#: Plugin sections are live as well (each plugin's on_config_change), and
|
||||
#: save_main_config adds the ones a request saves.
|
||||
LIVE_CONFIG_PATHS: Tuple[Tuple[str, ...], ...] = (
|
||||
('display', 'hardware', 'brightness'),
|
||||
('display', 'display_durations'),
|
||||
)
|
||||
|
||||
|
||||
_MISSING = object()
|
||||
|
||||
|
||||
def _config_leaves(config: Any, prefix: Tuple[str, ...] = ()) -> Dict[Tuple[str, ...], Any]:
|
||||
"""Every non-dict value in ``config``, by key path. An empty dict has none,
|
||||
so a section created empty on the way to a field is not a change."""
|
||||
if not isinstance(config, dict):
|
||||
return {prefix: config}
|
||||
leaves: Dict[Tuple[str, ...], Any] = {}
|
||||
for key, value in config.items():
|
||||
leaves.update(_config_leaves(value, prefix + (str(key),)))
|
||||
return leaves
|
||||
|
||||
|
||||
def restart_needed(before: Dict[str, Any], after: Dict[str, Any],
|
||||
live_paths: Iterable[Tuple[str, ...]] = LIVE_CONFIG_PATHS) -> bool:
|
||||
"""Does going from config ``before`` to ``after`` need a display restart?
|
||||
|
||||
True when anything changed outside ``live_paths``. A save that changes
|
||||
only live settings, or nothing at all, does not.
|
||||
"""
|
||||
live = tuple(live_paths)
|
||||
old, new = _config_leaves(before), _config_leaves(after)
|
||||
for path in set(old) | set(new):
|
||||
if old.get(path, _MISSING) == new.get(path, _MISSING):
|
||||
continue
|
||||
if not any(path[:len(prefix)] == prefix for prefix in live):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
#: Top-level fields save_main_config stores somewhere of its own (location,
|
||||
#: plugin_system, ...), never as a config key of the same name.
|
||||
_MAPPED_TOP_LEVEL_FIELDS = GENERAL_FIELDS + (
|
||||
@@ -118,22 +72,6 @@ def _day_setting(data, day, flat_key, nested_key):
|
||||
return False, None
|
||||
|
||||
|
||||
def _disabled_day_times(data, day, start_key, end_key):
|
||||
"""The times posted for a day that is off, the valid ones.
|
||||
|
||||
Nothing reads them while the day is off, but the schedule picker posts
|
||||
them and GET returns them, so keeping them means turning the day back on
|
||||
finds what was there. An invalid one is dropped rather than refused, for
|
||||
the same reason.
|
||||
"""
|
||||
times = {}
|
||||
for field, key in (('start_time', start_key), ('end_time', end_key)):
|
||||
value = _day_setting(data, day, key, field)[1]
|
||||
if value and _validate_time_format(value)[0]:
|
||||
times[field] = value
|
||||
return times
|
||||
|
||||
|
||||
@api_v3.route('/config/main', methods=['GET'])
|
||||
def get_main_config():
|
||||
"""Get main configuration, with credentials redacted."""
|
||||
@@ -319,16 +257,11 @@ def save_schedule_config():
|
||||
|
||||
day_config['start_time'] = start_time
|
||||
day_config['end_time'] = end_time
|
||||
else:
|
||||
day_config.update(_disabled_day_times(data, day, start_key, end_key))
|
||||
|
||||
schedule_config['days'][day] = day_config
|
||||
|
||||
# An enabled per-day schedule needs a day to be on. A disabled
|
||||
# one does not: every day off with the schedule off is what
|
||||
# config.template.json ships, so refusing it meant a fresh
|
||||
# install could not post back the schedule GET returned.
|
||||
if enabled_days_count == 0 and enabled_value:
|
||||
# Validate that at least one day is enabled in per-day mode
|
||||
if enabled_days_count == 0:
|
||||
return error_response(
|
||||
ErrorCode.VALIDATION_ERROR,
|
||||
"At least one day must be enabled in per-day schedule mode",
|
||||
@@ -532,13 +465,11 @@ def save_dim_schedule_config():
|
||||
|
||||
day_config['start_time'] = start_time
|
||||
day_config['end_time'] = end_time
|
||||
else:
|
||||
day_config.update(_disabled_day_times(data, day, start_key, end_key))
|
||||
|
||||
dim_schedule_config['days'][day] = day_config
|
||||
|
||||
# As for the on/off schedule: only an enabled one needs a day on.
|
||||
if enabled_days_count == 0 and enabled_value:
|
||||
# Validate that at least one day is enabled in per-day mode
|
||||
if enabled_days_count == 0:
|
||||
return error_response(
|
||||
ErrorCode.VALIDATION_ERROR,
|
||||
"At least one day must be enabled in per-day dim schedule mode",
|
||||
@@ -576,11 +507,7 @@ def save_main_config():
|
||||
# Try to get JSON data first, fallback to form data
|
||||
data = None
|
||||
if request.is_json:
|
||||
# silent=True, as in save_raw_main_config: get_json() raised
|
||||
# Werkzeug's BadRequest into the catch-all below, a 500.
|
||||
data = request.get_json(silent=True)
|
||||
if data is None and request.get_data():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid JSON in request body'}), 400
|
||||
data = request.get_json()
|
||||
if data is not None and not isinstance(data, dict):
|
||||
return jsonify({'status': 'error', 'message': 'Request body must be a JSON object'}), 400
|
||||
else:
|
||||
@@ -626,8 +553,6 @@ def save_main_config():
|
||||
|
||||
# Merge with existing config (similar to original implementation)
|
||||
current_config = api_v3.config_manager.load_config()
|
||||
# What was stored, to tell which settings this save changed.
|
||||
stored_config = copy.deepcopy(current_config)
|
||||
was_auto_update_enabled = bool((current_config.get('auto_update') or {}).get('enabled'))
|
||||
|
||||
is_general_update = any(k in data for k in GENERAL_FIELDS)
|
||||
@@ -1265,15 +1190,13 @@ def save_main_config():
|
||||
message = f'{message}. {note}'
|
||||
except Exception:
|
||||
logger.warning("Automatic update setup could not be started", exc_info=True)
|
||||
# Display hardware, rotation order and general settings take effect
|
||||
# after a display restart; the UI shows its restart banner on this
|
||||
# flag. Brightness, mode durations and plugin settings are applied by
|
||||
# the running display (LIVE_CONFIG_PATHS), so a save that changed
|
||||
# only those -- or nothing -- does not ask for one.
|
||||
live_paths = LIVE_CONFIG_PATHS + tuple((plugin_id,) for plugin_id in plugin_keys_to_remove)
|
||||
extra = {'restart_required': restart_needed(stored_config, current_config, live_paths)}
|
||||
# Over the control socket a saved brightness lands at once, instead
|
||||
# of when the config watcher next looks (up to ~2 s).
|
||||
# Display hardware, rotation/durations and general settings take
|
||||
# effect after a display restart; the UI shows its restart banner on
|
||||
# this flag.
|
||||
extra = {'restart_required': True}
|
||||
# Brightness is the exception: the display applies a saved one
|
||||
# without a restart. Over the control socket it lands at once,
|
||||
# instead of when the config watcher next looks (up to ~2 s).
|
||||
if 'brightness' in data:
|
||||
saved = (current_config.get('display', {}).get('hardware', {}) or {}).get('brightness')
|
||||
if isinstance(saved, int) and not isinstance(saved, bool):
|
||||
|
||||
@@ -33,28 +33,16 @@ def _cache_manager():
|
||||
|
||||
|
||||
|
||||
class _NotDelivered(Exception):
|
||||
"""The display took an on-demand request over the socket and did not
|
||||
accept it (``busy``, ``invalid_args``, ...) or did not answer in time."""
|
||||
|
||||
def __init__(self, reason):
|
||||
super().__init__(reason)
|
||||
self.reason = reason
|
||||
|
||||
|
||||
def _deliver_on_demand(payload):
|
||||
"""Hand an on-demand request to the display: control socket, else mailbox.
|
||||
|
||||
The socket (src/ipc) answers with an acknowledgement as soon as the
|
||||
display has the command queued for its render thread. The file mailbox
|
||||
is written only when the socket could not carry the request at all
|
||||
(``control_client.should_fall_back``): no socket (the display is stopped
|
||||
or predates it), a refused connection, or a display too old to know the
|
||||
command. The display reads it within its mailbox poll interval.
|
||||
|
||||
A display that had the request and refused it, or did not answer in
|
||||
time, raises :class:`_NotDelivered`: a mailbox copy would be refused
|
||||
the same way, or hide a stuck display behind a "success".
|
||||
display has the command queued for its render thread. Any failure -- no
|
||||
socket (the display is stopped or predates it), a timeout, a refusal --
|
||||
writes the file mailbox instead, exactly as before the socket existed;
|
||||
the display reads it within ON_DEMAND_POLL_INTERVAL. Both carry the same
|
||||
request_id, so a request that reached the display both ways (a reply
|
||||
that timed out after the command was queued) is still processed once.
|
||||
|
||||
Returns ``(transport, socket_error)``: ``'socket'`` and None, or
|
||||
``'mailbox'`` and the socket failure's reason code.
|
||||
@@ -69,10 +57,6 @@ def _deliver_on_demand(payload):
|
||||
return 'socket', None
|
||||
except control_client.ControlError as e:
|
||||
reason = _socket_reason_code(e.reason)
|
||||
if not control_client.should_fall_back(e):
|
||||
logger.warning("The display did not accept on-demand %s %s (%s)",
|
||||
payload['action'], payload['request_id'], e)
|
||||
raise _NotDelivered(reason) from None
|
||||
if reason in _QUIET_SOCKET_REASONS:
|
||||
logger.debug("On-demand %s via the mailbox: %s", payload['action'], e)
|
||||
else:
|
||||
@@ -85,37 +69,6 @@ def _deliver_on_demand(payload):
|
||||
return 'mailbox', reason
|
||||
|
||||
|
||||
def _not_delivered_response(request_id, action, reason):
|
||||
"""The answer when the display had the request and did not accept it."""
|
||||
status = 400 if reason == 'invalid_args' else 503
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': (f'The display service did not accept the on-demand {action} '
|
||||
f'request ({reason})'),
|
||||
'data': {'request_id': request_id, 'transport': 'socket', 'socket_error': reason},
|
||||
}), status
|
||||
|
||||
|
||||
def _withdraw_on_demand(request_id):
|
||||
"""Take a start request the route has refused back out of the mailbox.
|
||||
|
||||
The display reads the mailbox for an hour without looking at a
|
||||
request's age, so one left there after an error answer ran whenever the
|
||||
display next started. Only this request is removed: the mailbox is
|
||||
re-read and cleared only while it still holds this request_id, as the
|
||||
display's _consume_on_demand_request does, so a newer request posted in
|
||||
the meantime stays for the display to take.
|
||||
"""
|
||||
cache = _cache_manager()
|
||||
try:
|
||||
current = cache.get('display_on_demand_request', max_age=3600, memory_ttl=0)
|
||||
if isinstance(current, dict) and current.get('request_id') == request_id:
|
||||
cache.delete('display_on_demand_request')
|
||||
except Exception: # the route is answering an error already
|
||||
logger.warning("Could not withdraw on-demand request %s from the mailbox",
|
||||
request_id, exc_info=True)
|
||||
|
||||
|
||||
@api_v3.route('/display/current', methods=['GET'])
|
||||
def get_display_current():
|
||||
"""The latest display preview, as the /stream/display SSE stream sends it.
|
||||
@@ -153,12 +106,10 @@ def get_display_modes():
|
||||
same list the force-display dialog offers, from the source that owns it.
|
||||
|
||||
Knowing each mode's plugin_id also matters because /display/on-demand/start
|
||||
falls back to find_plugin_for_mode when plugin_id is omitted. While the
|
||||
display is running, both that lookup and this list use the modes it
|
||||
registered, so modes a plugin generates from its config (each installed
|
||||
Starlark app, each soccer custom league) are found (#668); with the
|
||||
display stopped they see only what manifests declare. Sending the
|
||||
plugin_id from this list skips the lookup entirely.
|
||||
falls back to find_plugin_for_mode when plugin_id is omitted, and that
|
||||
lookup only sees modes declared in a static manifest -- a plugin whose
|
||||
modes are generated (each installed Starlark app is one) 404s there.
|
||||
Sending the plugin_id from this list skips the lookup entirely.
|
||||
|
||||
Query params:
|
||||
include_disabled: '1' to list modes of disabled plugins too. They can
|
||||
@@ -306,31 +257,11 @@ def start_on_demand_display():
|
||||
'pinned': pinned,
|
||||
'timestamp': _pkg.time.time()
|
||||
}
|
||||
try:
|
||||
transport, socket_error = _deliver_on_demand(request_payload)
|
||||
except _NotDelivered as e:
|
||||
return _not_delivered_response(request_id, 'start', e.reason)
|
||||
|
||||
# A socket acknowledgement is the display itself answering: it is
|
||||
# running and has the request queued, whatever systemd says (a display
|
||||
# run by hand or in the emulator has no active unit). So nothing is
|
||||
# checked or started for it -- that answered "not running" for a request
|
||||
# that had already taken effect. The service is still reported the way
|
||||
# _ensure_display_service_running reports a running one.
|
||||
if transport == 'socket':
|
||||
service_result = (dict(_get_display_service_status(), started=False)
|
||||
if start_service else None)
|
||||
return _on_demand_started(request_id, resolved_plugin, resolved_mode,
|
||||
duration, pinned, service_result, transport,
|
||||
socket_error)
|
||||
transport, socket_error = _deliver_on_demand(request_payload)
|
||||
|
||||
service_status = _get_display_service_status()
|
||||
|
||||
if not service_status.get('active') and not start_service:
|
||||
# The request is in the mailbox, and the display reads it whenever
|
||||
# it next starts: taken back out, or a request answered with this
|
||||
# error ran later anyway.
|
||||
_withdraw_on_demand(request_id)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Display service is not running. Please start the display service or enable "Start Service" option.',
|
||||
@@ -344,10 +275,9 @@ def start_on_demand_display():
|
||||
# MQTT on-demand command, which posts here with the default -- cold-
|
||||
# restarted the display process: every plugin reloaded and the panel was
|
||||
# blank for seconds. The restart bought nothing. The running process
|
||||
# looks at this mailbox at least once a second, from its dwell sleep,
|
||||
# its render loops and Vegas's interrupt check as well as the main loop
|
||||
# (DisplayController._mailbox_poll_interval), and a restarted one got
|
||||
# the request the same way: the
|
||||
# reads this mailbox every ON_DEMAND_POLL_INTERVAL (0.25s), from its
|
||||
# dwell sleep, its render loops and Vegas's interrupt check as well as
|
||||
# the main loop, and a restarted one got the request the same way: the
|
||||
# startup path only restores a session the display itself saved
|
||||
# (display_on_demand_config), so it loaded nothing it would not have had.
|
||||
service_result = None
|
||||
@@ -355,25 +285,16 @@ def start_on_demand_display():
|
||||
service_result = _ensure_display_service_running()
|
||||
# Check if service actually started
|
||||
if service_result and not service_result.get('active'):
|
||||
_withdraw_on_demand(request_id)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Failed to start display service. Please check service logs or start it manually.',
|
||||
'service_result': service_result
|
||||
}), 500
|
||||
|
||||
return _on_demand_started(request_id, resolved_plugin, resolved_mode,
|
||||
duration, pinned, service_result, transport,
|
||||
socket_error)
|
||||
|
||||
|
||||
def _on_demand_started(request_id, plugin_id, mode, duration, pinned,
|
||||
service_result, transport, socket_error):
|
||||
"""The success answer of /display/on-demand/start."""
|
||||
response_data = {
|
||||
'request_id': request_id,
|
||||
'plugin_id': plugin_id,
|
||||
'mode': mode,
|
||||
'plugin_id': resolved_plugin,
|
||||
'mode': resolved_mode,
|
||||
'duration': duration,
|
||||
'pinned': pinned,
|
||||
'service': service_result,
|
||||
@@ -398,14 +319,7 @@ def stop_on_demand_display():
|
||||
'action': 'stop',
|
||||
'timestamp': _pkg.time.time()
|
||||
}
|
||||
try:
|
||||
transport, socket_error = _deliver_on_demand(request_payload)
|
||||
except _NotDelivered as e:
|
||||
if not stop_service:
|
||||
return _not_delivered_response(request_id, 'stop', e.reason)
|
||||
# Stopping the service ends on-demand too, whatever the display did
|
||||
# with the request.
|
||||
transport, socket_error = 'socket', e.reason
|
||||
transport, socket_error = _deliver_on_demand(request_payload)
|
||||
|
||||
service_result = None
|
||||
if stop_service:
|
||||
@@ -426,22 +340,15 @@ def get_current_display_status():
|
||||
Read from the display's state stream over the control socket when it is
|
||||
available (``source: "socket"``). Otherwise from what the display
|
||||
publishes to the shared cache (display_controller._publish_current_mode_state)
|
||||
when the active mode changes (``source: "cache"``). Unknown (every field
|
||||
None) when the socket and the heartbeat both say the display is gone
|
||||
(display_state.display_gone).
|
||||
when the active mode changes (``source: "cache"``).
|
||||
"""
|
||||
snapshot = display_state.read_state()
|
||||
state = display_state.current_status(snapshot)
|
||||
state = display_state.current_status(display_state.read_state())
|
||||
source = 'socket'
|
||||
if state is None:
|
||||
source = 'cache'
|
||||
# A stopped display leaves its last answer in the cache, where it
|
||||
# read as on (is_display_active: true) for the 120 s max_age. With
|
||||
# no socket and no live heartbeat there is no display behind it.
|
||||
if not display_state.display_gone(snapshot):
|
||||
cache = _cache_manager()
|
||||
# memory_ttl=0: written by the display service; see get_on_demand_status.
|
||||
state = cache.get('display_current_state', max_age=120, memory_ttl=0)
|
||||
cache = _cache_manager()
|
||||
# memory_ttl=0: written by the display service; see get_on_demand_status.
|
||||
state = cache.get('display_current_state', max_age=120, memory_ttl=0)
|
||||
if state is None:
|
||||
state = {
|
||||
'mode': None,
|
||||
|
||||
@@ -102,7 +102,6 @@ def get_health():
|
||||
# the only signal, as it always was.
|
||||
# The display reports the same beat's age over the control socket's
|
||||
# state stream, measured in memory; the file is the fallback.
|
||||
snapshot = None
|
||||
try:
|
||||
snapshot = display_state.read_state()
|
||||
if snapshot is not None:
|
||||
@@ -133,26 +132,6 @@ def get_health():
|
||||
'error': 'see logs for details'
|
||||
}
|
||||
|
||||
# A stopped display service. The heartbeat's absence alone says
|
||||
# nothing (the dev server, the emulator and Windows write none), so
|
||||
# the overall status stayed "healthy" with the display down until the
|
||||
# last preview frame it left aged past 60 s (hardware: stale). Together
|
||||
# the three signals are definite: systemd says the service is not
|
||||
# active, the control socket does not answer, and there is no live
|
||||
# heartbeat (display_state.display_gone, which is never true where
|
||||
# the platform has no socket or it is switched off).
|
||||
try:
|
||||
if (not display_service_status.get('active')
|
||||
and display_state.display_gone(snapshot)):
|
||||
health_status['checks']['display_loop'] = {
|
||||
'status': 'stopped',
|
||||
'note': 'The display service is not running',
|
||||
'source': 'service',
|
||||
}
|
||||
except Exception:
|
||||
logger.warning("Health check could not tell whether the display is stopped",
|
||||
exc_info=True)
|
||||
|
||||
# Check hardware connectivity (if display manager available)
|
||||
try:
|
||||
snapshot_path = display_preview.SNAPSHOT_PATH
|
||||
@@ -374,23 +353,6 @@ def _read_errors():
|
||||
return snapshot, clear_request
|
||||
|
||||
|
||||
def _send_error_clear(request_id, cutoff):
|
||||
"""``errors.clear`` over the control socket: the display's answer, or None
|
||||
when the socket could not carry it (no socket, or a display older than
|
||||
the command) and the clear goes to the mailbox instead. A display that
|
||||
took the request and failed raises ControlError (no second copy)."""
|
||||
client = _pkg.control_client
|
||||
try:
|
||||
return client.errors_clear(request_id, cutoff)
|
||||
except client.ControlError as e:
|
||||
if not client.should_fall_back(e):
|
||||
raise
|
||||
_pkg._log_socket_failure('errors.clear', e, _pkg._socket_reason_code(e.reason))
|
||||
except Exception: # never let the socket path break the route
|
||||
logger.exception("Control socket client failed clearing errors; using the mailbox")
|
||||
return None
|
||||
|
||||
|
||||
@api_v3.route('/errors/summary', methods=['GET'])
|
||||
def get_error_summary():
|
||||
"""
|
||||
@@ -488,17 +450,7 @@ def clear_old_errors():
|
||||
now = _pkg.time.time()
|
||||
cutoff = now if clear_all else now - max_age_hours * 3600
|
||||
try:
|
||||
result = _errors.request_error_clear(_errors_cache(), cutoff,
|
||||
send=_send_error_clear)
|
||||
except _pkg.control_client.ControlError as e:
|
||||
reason = _pkg._socket_reason_code(e.reason)
|
||||
logger.warning("The display did not apply the error clear: %s", e)
|
||||
return error_response(
|
||||
error_code=ErrorCode.SYSTEM_ERROR,
|
||||
message="The display service did not apply the clear",
|
||||
context={'socket_error': reason},
|
||||
status_code=503
|
||||
)
|
||||
result = _errors.request_error_clear(_errors_cache(), cutoff)
|
||||
except OSError as e:
|
||||
logger.error("Could not record an error clear request: %s", e)
|
||||
return error_response(
|
||||
@@ -508,12 +460,11 @@ def clear_old_errors():
|
||||
)
|
||||
|
||||
scope = "all errors" if clear_all else f"errors older than {max_age_hours} hours"
|
||||
if result.get('applied'):
|
||||
message = f"Cleared {scope}"
|
||||
else:
|
||||
message = (f"Clear of {scope} requested; the display service applies it "
|
||||
f"within about {int(_errors.SNAPSHOT_TICK_INTERVAL)} seconds")
|
||||
return success_response(data=result, message=message)
|
||||
return success_response(
|
||||
data=result,
|
||||
message=(f"Clear of {scope} requested; the display service applies it "
|
||||
f"within about {int(_errors.SNAPSHOT_TICK_INTERVAL)} seconds")
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error clearing old errors: {e}", exc_info=True)
|
||||
return error_response(
|
||||
|
||||
@@ -3,12 +3,8 @@
|
||||
Routes decorate the shared `api_v3` Blueprint from the package `__init__`,
|
||||
so their endpoint names do not depend on which module they live in.
|
||||
"""
|
||||
import mimetypes
|
||||
|
||||
from flask import send_file
|
||||
|
||||
from web_interface.blueprints.api_v3 import (
|
||||
PROJECT_ROOT, _plugin_directory, api_v3, datetime, hashlib,
|
||||
PROJECT_ROOT, Response, _plugin_directory, api_v3, datetime, hashlib,
|
||||
json, jsonify, logger, os, request, uuid,
|
||||
)
|
||||
from src.common.path_safety import (
|
||||
@@ -235,8 +231,8 @@ def serve_plugin_static(plugin_id, file_path):
|
||||
if not requested_file.exists() or not requested_file.is_file():
|
||||
return jsonify({'status': 'error', 'message': 'File not found'}), 404
|
||||
|
||||
# Determine content type. Text keeps the types this route always set;
|
||||
# anything else (an icon, a preview image) gets its own.
|
||||
# Determine content type
|
||||
content_type = 'text/plain'
|
||||
name = requested_file.name
|
||||
if name.endswith('.html'):
|
||||
content_type = 'text/html'
|
||||
@@ -246,14 +242,12 @@ def serve_plugin_static(plugin_id, file_path):
|
||||
content_type = 'text/css'
|
||||
elif name.endswith('.json'):
|
||||
content_type = 'application/json'
|
||||
else:
|
||||
guessed = mimetypes.guess_type(name)[0]
|
||||
content_type = ('text/plain' if not guessed or guessed.startswith('text/')
|
||||
else guessed)
|
||||
|
||||
# Sent as bytes. Opening it as UTF-8 text failed to decode any binary
|
||||
# file, so an image answered 500 UnicodeDecodeError.
|
||||
return send_file(requested_file, mimetype=content_type)
|
||||
# Read and return file
|
||||
with open(requested_file, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
return Response(content, mimetype=content_type)
|
||||
|
||||
|
||||
@api_v3.route('/plugins/assets/delete', methods=['POST'])
|
||||
|
||||
@@ -9,15 +9,13 @@ from web_interface.blueprints.api_v3 import (
|
||||
_enhance_schema_with_core_properties, _non_plugin_id_error,
|
||||
_filter_config_by_schema, _get_schema_property,
|
||||
_hidden_array_item_property, _plugin_directory,
|
||||
_parse_form_value_with_schema, _redact_credentials, _schema_allows_null,
|
||||
_schema_type_is, _set_missing_booleans_to_false, _set_nested_value, api_v3,
|
||||
datetime, deep_merge, error_response, exception_error_response,
|
||||
find_secret_fields, json, jsonify, logger, merge_secrets, os,
|
||||
remove_empty_secrets, request, separate_secrets, success_response,
|
||||
validate_request_json,
|
||||
_parse_form_value_with_schema, _schema_allows_null, _schema_type_is,
|
||||
_set_missing_booleans_to_false, _set_nested_value, api_v3, datetime,
|
||||
deep_merge, error_response, exception_error_response, find_secret_fields,
|
||||
json, jsonify, logger, merge_secrets, os, remove_empty_secrets, request,
|
||||
separate_secrets, success_response, validate_request_json,
|
||||
)
|
||||
from src.web_interface.config_arrays import coerce_array_shapes
|
||||
from src.web_interface.secret_helpers import mask_secret_fields
|
||||
from src.web_interface.validators import dedup_unique_arrays
|
||||
import web_interface.blueprints.api_v3 as _pkg
|
||||
# Read through the module rather than bound by value: tests patch these
|
||||
@@ -45,12 +43,6 @@ def get_plugin_config():
|
||||
context={'missing_params': ['plugin_id']},
|
||||
status_code=400
|
||||
)
|
||||
# load_config() merges config_secrets.json in, core sections
|
||||
# included: ?plugin_id=web_auth returned the login's cookie key and
|
||||
# password hash, and ?plugin_id=github the Plugin Store token.
|
||||
id_error = _non_plugin_id_error(plugin_id)
|
||||
if id_error:
|
||||
return id_error
|
||||
|
||||
# Get plugin configuration from config manager
|
||||
main_config = api_v3.config_manager.load_config()
|
||||
@@ -60,13 +52,12 @@ def get_plugin_config():
|
||||
# missing fields, reading legacy booleans as objects first: what the
|
||||
# plugin runs with, and what posts back through the JSON save
|
||||
schema_mgr = api_v3.schema_manager
|
||||
schema = None
|
||||
if schema_mgr:
|
||||
try:
|
||||
from src.plugin_system.schema_manager import prepare_plugin_config
|
||||
schema = schema_mgr.load_schema(plugin_id, use_cache=True)
|
||||
defaults = schema_mgr.generate_default_config(plugin_id, use_cache=True)
|
||||
plugin_config = prepare_plugin_config(plugin_config, schema, defaults)
|
||||
plugin_config = prepare_plugin_config(
|
||||
plugin_config, schema_mgr.load_schema(plugin_id, use_cache=True), defaults)
|
||||
except Exception as e:
|
||||
# Log but don't fail - defaults merge is best effort
|
||||
logger.warning("Could not merge defaults for %s: %s", plugin_id, e)
|
||||
@@ -167,17 +158,6 @@ def get_plugin_config():
|
||||
'display_duration': 30
|
||||
}
|
||||
|
||||
# Secrets go out blank, as the settings page renders them (#276 added
|
||||
# this; #330 dropped it). Blank, not the bullets GET /config/secrets
|
||||
# uses: the save reads a blank secret as "unchanged", so this
|
||||
# response posts back without erasing one.
|
||||
properties = schema.get('properties') if isinstance(schema, dict) else None
|
||||
if isinstance(properties, dict):
|
||||
plugin_config = mask_secret_fields(plugin_config, properties)
|
||||
else:
|
||||
# No schema to mark them: blank whatever is named like one
|
||||
plugin_config = _redact_credentials(plugin_config)
|
||||
|
||||
return success_response(data=plugin_config)
|
||||
except Exception as e:
|
||||
return exception_error_response(e, ErrorCode.CONFIG_LOAD_FAILED)
|
||||
@@ -203,12 +183,6 @@ def save_plugin_config():
|
||||
if error:
|
||||
return error
|
||||
plugin_id = data['plugin_id']
|
||||
# As reset and uninstall do: {"plugin_id": "display"} merged
|
||||
# unvalidated values into the core display section, and an id
|
||||
# that was not a string raised a TypeError, answered as a 500.
|
||||
id_error = _non_plugin_id_error(plugin_id)
|
||||
if id_error:
|
||||
return id_error
|
||||
submitted_config = data.get('config', {})
|
||||
if not isinstance(submitted_config, dict):
|
||||
return error_response(
|
||||
@@ -227,9 +201,6 @@ def save_plugin_config():
|
||||
'plugin_id required in query string',
|
||||
status_code=400
|
||||
)
|
||||
id_error = _non_plugin_id_error(plugin_id)
|
||||
if id_error:
|
||||
return id_error
|
||||
|
||||
# Load existing config as base (partial form updates should merge, not replace)
|
||||
existing_config = {}
|
||||
|
||||
@@ -81,27 +81,6 @@ def _listed_plugin_dir(base: Path, name: str) -> Optional[Path]:
|
||||
return None
|
||||
|
||||
|
||||
def _enqueue_or_conflict(operation_type, plugin_id, callback):
|
||||
"""``(operation_id, None)``, or ``(None, a 409 response)``.
|
||||
|
||||
The queue raises ValueError when the plugin already has an operation
|
||||
waiting or running -- a double-clicked Install, an uninstall during an
|
||||
install. That is the caller's timing, not a server fault: it reached
|
||||
the client as a 500, and the uninstall route recorded a failed
|
||||
uninstall that had never started.
|
||||
"""
|
||||
try:
|
||||
return api_v3.operation_queue.enqueue_operation(
|
||||
operation_type, plugin_id, operation_callback=callback), None
|
||||
except ValueError:
|
||||
return None, error_response(
|
||||
ErrorCode.PLUGIN_OPERATION_CONFLICT,
|
||||
f'Plugin {plugin_id} already has an install, update or uninstall '
|
||||
'in progress; wait for it to finish, then try again',
|
||||
status_code=409
|
||||
)
|
||||
|
||||
|
||||
@api_v3.route('/plugins/update', methods=['POST'])
|
||||
def update_plugin():
|
||||
"""Update plugin"""
|
||||
@@ -423,10 +402,11 @@ def uninstall_plugin():
|
||||
preserve_config=preserve_config)}
|
||||
|
||||
# Enqueue operation
|
||||
operation_id, conflict = _enqueue_or_conflict(
|
||||
OperationType.UNINSTALL, plugin_id, uninstall_callback)
|
||||
if conflict:
|
||||
return conflict
|
||||
operation_id = api_v3.operation_queue.enqueue_operation(
|
||||
OperationType.UNINSTALL,
|
||||
plugin_id,
|
||||
operation_callback=uninstall_callback
|
||||
)
|
||||
|
||||
return success_response(
|
||||
data={'operation_id': operation_id},
|
||||
@@ -558,10 +538,11 @@ def install_plugin():
|
||||
raise Exception(error_msg)
|
||||
|
||||
# Enqueue operation
|
||||
operation_id, conflict = _enqueue_or_conflict(
|
||||
OperationType.INSTALL, plugin_id, install_callback)
|
||||
if conflict:
|
||||
return conflict
|
||||
operation_id = api_v3.operation_queue.enqueue_operation(
|
||||
OperationType.INSTALL,
|
||||
plugin_id,
|
||||
operation_callback=install_callback
|
||||
)
|
||||
|
||||
branch_msg = f" (branch: {branch})" if branch else ""
|
||||
return success_response(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user