mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-07 23:56:36 +00:00
Compare commits
11
Commits
ef69201770
...
2236ff3081
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2236ff3081 | ||
|
|
5ad5e9aa59 | ||
|
|
8a0cce1aaf | ||
|
|
0b039c875f | ||
|
|
07abd87d5e | ||
|
|
e32d177cbd | ||
|
|
d18e4d3c9d | ||
|
|
04f0d8d134 | ||
|
|
064b9c9912 | ||
|
|
a6e9e3ef1c | ||
|
|
41192b9588 |
+309
@@ -488,6 +488,23 @@ policies are unchanged.
|
|||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|
||||||
|
- A cache key too long to be a filename is now cached. The calendar
|
||||||
|
plugin's key joins every calendar id the user picked; on a real install
|
||||||
|
it passed 300 bytes, ext4 refuses names over 255, and every write failed
|
||||||
|
with `File name too long` — logged as "(permission denied)", so it read
|
||||||
|
like a cache-directory ownership problem. `DiskCache.get_cache_path` now
|
||||||
|
keeps a key of up to 200 UTF-8 bytes as its filename, as before, and
|
||||||
|
turns a longer one into its first bytes plus a hash of the whole key. The
|
||||||
|
web UI's cache list and delete keep working, because the shortened name
|
||||||
|
maps back to the same file. A failed write now names the real error.
|
||||||
|
- The cache's memory tier no longer serves data older than the reader asked
|
||||||
|
for. A record loaded from disk was timed in memory from the load, not
|
||||||
|
from when it was written, so `get(key, max_age=300)` could return data
|
||||||
|
close to 600 s old (after a restart, after the hourly memory sweep, or in
|
||||||
|
the other process, which only ever loads the record from disk), and a
|
||||||
|
stored `ttl` was stretched the same way. A memory hit is now also checked against
|
||||||
|
the record's own timestamp, and a stale one falls through to disk, which
|
||||||
|
returns a newer write if there is one.
|
||||||
- The garbage-collection timer (`GcMonitor`, above) no longer prints
|
- The garbage-collection timer (`GcMonitor`, above) no longer prints
|
||||||
`Exception ignored while calling GC callback ... 'NoneType' object has no
|
`Exception ignored while calling GC callback ... 'NoneType' object has no
|
||||||
attribute 'perf_counter'` when the display service or a test run exits.
|
attribute 'perf_counter'` when the display service or a test run exits.
|
||||||
@@ -498,6 +515,16 @@ policies are unchanged.
|
|||||||
`DisplayManager.cleanup()` (reached from SIGTERM through `run()`'s
|
`DisplayManager.cleanup()` (reached from SIGTERM through `run()`'s
|
||||||
`finally`) unregisters it with the frame recorder. New
|
`finally`) unregisters it with the frame recorder. New
|
||||||
`frame_timing.uninstall_gc_monitor()`.
|
`frame_timing.uninstall_gc_monitor()`.
|
||||||
|
- The web interface's state subscription (`StateSubscription`,
|
||||||
|
`src/ipc/client.py`) resubscribes about 1 s after a display restart, every
|
||||||
|
time. Its reconnect wait went back to the minimum only when the
|
||||||
|
subscription was stopped. A disconnect after a working connection kept
|
||||||
|
doubling the wait, so successive display restarts were followed by waits
|
||||||
|
of 1, 2, 4, 8, 16 and then 30 s for good.
|
||||||
|
During each wait the web answered from one-shot `state.get` connections
|
||||||
|
instead of its copy. The wait now resets once a connection has stored a
|
||||||
|
snapshot. A display that does not offer the stream is still retried
|
||||||
|
slowly.
|
||||||
- A plugin reload after a store update (`plugin.reload`, #720) no longer
|
- A plugin reload after a store update (`plugin.reload`, #720) no longer
|
||||||
freezes the panel during Vegas. On ledpi a football reload froze it for
|
freezes the panel during Vegas. On ledpi a football reload froze it for
|
||||||
3.0 s (`Render stall over: no frame for 3043ms`). The reload ran on the
|
3.0 s (`Render stall over: no frame for 3043ms`). The reload ran on the
|
||||||
@@ -514,6 +541,25 @@ policies are unchanged.
|
|||||||
for the plugin is refused (`plugin-reloading`), and a config reconcile
|
for the plugin is refused (`plugin-reloading`), and a config reconcile
|
||||||
neither loads it twice nor unloads it mid-load. A Vegas fetch that waited
|
neither loads it twice nor unloads it mid-load. A Vegas fetch that waited
|
||||||
out a reload for the lock skips the old instance.
|
out a reload for the lock skips the old instance.
|
||||||
|
- A plugin display duration that is not a number no longer stops the
|
||||||
|
display. Several plugins (clock-simple, calendar, countdown) return their
|
||||||
|
`display_duration` setting as it is in config.json, so a value saved as
|
||||||
|
`"20"` or `null` (the raw config editor, a hand edit) reached the run loop
|
||||||
|
as a string or None. Comparing it with 0 raised a TypeError that no
|
||||||
|
handler in the loop caught: the display service exited when that plugin's
|
||||||
|
screen came up, and systemd restarted it into the same crash. The
|
||||||
|
controller now reads the plugin's answer as a number: a numeric string
|
||||||
|
counts, and anything else (or a `get_display_duration()` that raises)
|
||||||
|
shows the mode for 30 s, with one warning per plugin.
|
||||||
|
- A scroll strip narrower than the panel scrolls instead of raising on every
|
||||||
|
frame. When a frame ran off the end of the strip, `ScrollHelper` copied
|
||||||
|
the strip's tail and then the rest of the frame from its head, which
|
||||||
|
assumed the head was that wide; for a narrower strip that raised
|
||||||
|
`ValueError: could not broadcast` at every position, so nothing was drawn
|
||||||
|
and each frame logged a traceback. Vegas builds such a strip, with no
|
||||||
|
lead-in, when its content is narrower than the chain. A frame that runs
|
||||||
|
off the strip now continues from its head column by column, so a narrow
|
||||||
|
strip repeats across the panel; a wide strip wraps exactly as before.
|
||||||
- The schedule-off blank and the WiFi notice no longer start with a
|
- The schedule-off blank and the WiFi notice no longer start with a
|
||||||
scroller's leftovers. Both are drawn by the display controller rather than
|
scroller's leftovers. Both are drawn by the display controller rather than
|
||||||
dispatched to a plugin, so #716's handover never reached them: drawn while
|
dispatched to a plugin, so #716's handover never reached them: drawn while
|
||||||
@@ -523,6 +569,46 @@ policies are unchanged.
|
|||||||
scroller or Vegas) were counted as 0.5-1 s freezes and logged as a
|
scroller or Vegas) were counted as 0.5-1 s freezes and logged as a
|
||||||
`Render stall ... mid-scroll`. The controller now ends the scroll state
|
`Render stall ... mid-scroll`. The controller now ends the scroll state
|
||||||
before drawing either.
|
before drawing either.
|
||||||
|
- A plugin that keeps helpers in a package (elections' `providers/`,
|
||||||
|
flights' `enrichment/`, olympics' `data/` and `renderers/`) now runs its
|
||||||
|
updated helpers after a reload. Unloading dropped the package itself but
|
||||||
|
left its modules (`providers.feed`) in `sys.modules`, so the reload after a
|
||||||
|
store update imported the new `manager.py` and got the old helpers back from
|
||||||
|
the cache until the display restarted. `PluginLoader` now drops a plugin's
|
||||||
|
package modules when it unloads, and when a load fails part-way.
|
||||||
|
- Uninstalling a dev plugin that `scripts/dev/dev_plugin_setup.sh` linked
|
||||||
|
into the plugins directory now removes the link and leaves the checkout
|
||||||
|
alone. The store's removal passed the link to `shutil.rmtree`, which
|
||||||
|
refuses a symlink; its fallback then walked through the link and chmodded
|
||||||
|
every directory and file of the linked checkout to 0700, and the sudo stage
|
||||||
|
refused a path outside the plugins directory, so the uninstall failed with
|
||||||
|
the link still in place. The same removal discards the set-aside copy after
|
||||||
|
an install or update. A symlink, dangling or not, is now unlinked.
|
||||||
|
- A dev plugin linked in under a name its checkout does not share now loads.
|
||||||
|
`dev_plugin_setup.sh link-github foo <url>` clones `ledmatrix-foo` (the
|
||||||
|
repository naming convention) and links it as `plugins/foo`. The loader's
|
||||||
|
containment check for dependency installs resolved the link and looked for
|
||||||
|
`ledmatrix-foo` among the plugins directory's entries, found none, and
|
||||||
|
refused the plugin, so the load failed with "Dependency installation
|
||||||
|
failed" even when it had no `requirements.txt`. The check now looks for the
|
||||||
|
entry the path itself names in the plugins directory, the link, and still
|
||||||
|
only ever answers with an entry it found there.
|
||||||
|
- A plugin whose `update()` raises `asyncio.CancelledError` or `SystemExit`
|
||||||
|
no longer goes dark until a restart. Both derive from `BaseException`, not
|
||||||
|
`Exception`, and the update worker's bookkeeping caught only `Exception`:
|
||||||
|
the plugin kept its lock and stayed RUNNING, so it was never updated again
|
||||||
|
and every `display()` was skipped as busy. It is now recorded as that
|
||||||
|
update's failure, the same as any other raise. The plugin executor
|
||||||
|
reported such a call as a timeout; it now reports it as a failure.
|
||||||
|
- Saving a config change no longer freezes the panel while a plugin is busy.
|
||||||
|
`ConfigService` told its subscribers about a change while holding its lock,
|
||||||
|
and the display's per-plugin subscriber waits up to 5 s for a plugin in the
|
||||||
|
middle of an update. A save that enables or disables a plugin also queues a
|
||||||
|
reconcile, which the render thread runs, and its `get_config()` and
|
||||||
|
`unsubscribe()` waited behind every one of those callbacks. Subscribers now
|
||||||
|
run after the lock is released. One reload's notifications still finish
|
||||||
|
before the next one's start, and a callback `unsubscribe()` removed is not
|
||||||
|
running, and will not run, once it returns.
|
||||||
- A plugin whose `display()` raises now opens its circuit breaker. The first
|
- A plugin whose `display()` raises now opens its circuit breaker. The first
|
||||||
frame of each screen goes through the plugin executor, which caught the
|
frame of each screen goes through the plugin executor, which caught the
|
||||||
exception and returned False. The display read that as "no content" and
|
exception and returned False. The display read that as "no content" and
|
||||||
@@ -532,6 +618,57 @@ policies are unchanged.
|
|||||||
the plugin leaves rotation until the cooldown ends, the same as a raising
|
the plugin leaves rotation until the cooldown ends, the same as a raising
|
||||||
`update()`. The display still moves straight on to the next mode. A hung
|
`update()`. The display still moves straight on to the next mode. A hung
|
||||||
`display()` is still recorded once, as a hang.
|
`display()` is still recorded once, as a hang.
|
||||||
|
- A plugin settings save that failed validation no longer leaks into the next
|
||||||
|
save. `ConfigManager.load_config()` returned its cached config itself (the
|
||||||
|
fast path from #410), so the form save's edits went into the cache before
|
||||||
|
validation ran, and a refused save left them there. The next save of any
|
||||||
|
other setting (another plugin's, a plugin toggle, the schedule) wrote them
|
||||||
|
to config.json: the refused value, and a nested secret typed into the same
|
||||||
|
form (`mqtt.password`, `league.espn_s2`, `flightaware.api_key`) in plain
|
||||||
|
text, because it had never reached config_secrets.json to be stripped.
|
||||||
|
The form also reloaded showing the refused values. `load_config()` now
|
||||||
|
returns a private copy, and the saves keep one, so nothing a caller edits
|
||||||
|
reaches the cache unless it is saved. The copy duplicates only the dicts
|
||||||
|
and lists (every other JSON value is immutable): 2.1 ms for a real 60 KiB
|
||||||
|
config on a Pi 4, against 6.8 ms for `copy.deepcopy`.
|
||||||
|
- `GET /api/v3/plugins/config` no longer returns secrets. It sent back the
|
||||||
|
plugin's section with config_secrets.json merged in, API keys and tokens
|
||||||
|
in plain text: the masking #276 added was dropped in #330. It also took
|
||||||
|
any id, so `?plugin_id=web_auth` returned the login's cookie-signing key
|
||||||
|
and password hash and `?plugin_id=github` the Plugin Store token. Secret
|
||||||
|
fields now come back blank, as the settings page renders them, and a
|
||||||
|
plugin with no schema has its credential-named fields blanked, as
|
||||||
|
`GET /config/main` does. Blank rather than the `••••••••` of
|
||||||
|
`GET /config/secrets`, because the save reads a blank secret as
|
||||||
|
"unchanged", so a client can post the response back without erasing
|
||||||
|
one. Core sections and malformed ids get a 400, as they already did from
|
||||||
|
reset and uninstall.
|
||||||
|
- Plugin settings with a table (a list of rows, such as geochron's cities
|
||||||
|
or the countdowns) save again when a text cell is blank or holds only
|
||||||
|
digits. A row posts its cells as `cities.0.timezone`, and the schema
|
||||||
|
lookup stopped at the list, so each cell was parsed with no schema: a
|
||||||
|
blank optional text cell became null, and a name like "2027" became a
|
||||||
|
number. Either failed validation, and every save of the page failed for
|
||||||
|
as long as the row existed. A plugin with a secret in its rows could not
|
||||||
|
be saved from the page at all, since the secret cell is drawn blank. The
|
||||||
|
lookup now steps from the index into the list's item schema.
|
||||||
|
- A plugin whose API key is required and has no default (youtube-stats)
|
||||||
|
can be saved from its settings page without typing the key in again. The
|
||||||
|
page draws a stored secret blank and posts the blank back; for a required
|
||||||
|
secret the save read that blank as null, failed validation, and refused
|
||||||
|
every save of the page. A blank secret field now means "unchanged", as it
|
||||||
|
already did for an optional one.
|
||||||
|
- `POST /api/v3/plugins/config` refuses a core section or a malformed
|
||||||
|
plugin id with a 400, as reset and uninstall already did.
|
||||||
|
`{"plugin_id": "display", ...}` merged unvalidated values into the core
|
||||||
|
display section (and added `"enabled": true` to it), and an id that was
|
||||||
|
not a string answered with a 500.
|
||||||
|
- A plugin text setting saves what was typed when that looks like a
|
||||||
|
boolean or JSON. The form save tried `true`/`false` and `[...]`/`{...}`
|
||||||
|
before it looked at the schema, so a text field holding "true", "False",
|
||||||
|
"[1, 2]" or "{}" was stored as a boolean, list or object, and the save
|
||||||
|
failed validation. Text fields, nullable ones included, are now taken as
|
||||||
|
typed; other types convert as before.
|
||||||
- A WiFi notice (such as "Connected to HomeNet" or "AP mode on") now shows
|
- A WiFi notice (such as "Connected to HomeNet" or "AP mode on") now shows
|
||||||
within about a second of being posted. It was only checked between
|
within about a second of being posted. It was only checked between
|
||||||
screens, so a 5 s notice posted during a 20 s screen expired before that
|
screens, so a 5 s notice posted during a 20 s screen expired before that
|
||||||
@@ -540,6 +677,42 @@ policies are unchanged.
|
|||||||
notice is what shows next, and Vegas resumes after it; before, a rotation
|
notice is what shows next, and Vegas resumes after it; before, a rotation
|
||||||
screen showed instead and the notice expired behind it. An active
|
screen showed instead and the notice expired behind it. An active
|
||||||
on-demand session still holds the panel until it ends.
|
on-demand session still holds the panel until it ends.
|
||||||
|
- The Config Editor tab no longer shows API keys and tokens in plain
|
||||||
|
text. Its `config_secrets.json` editor (`/partials/raw-json`) was filled
|
||||||
|
with the file as it is on disk, so while the web login is off (the
|
||||||
|
default) anyone who could reach the port could read every credential,
|
||||||
|
although `GET /api/v3/config/secrets` masks them. The editor now shows the
|
||||||
|
same masked values. Saving it unchanged changes nothing, because the save
|
||||||
|
drops the masks and merges onto the stored file; to change a secret,
|
||||||
|
replace its mask. A list of secrets still needs every entry's real value
|
||||||
|
to be changed. The `config.json` editor is unchanged: its save writes the
|
||||||
|
file as given, so a mask there would be stored.
|
||||||
|
- A disabled plugin keeps its place in the rotation order and its Vegas
|
||||||
|
exclusion when the Display or Rotation & Durations tab is saved. The order
|
||||||
|
lists show enabled plugins only and rewrite their hidden inputs from those
|
||||||
|
rows as soon as they are drawn, so any save of either tab stored the lists
|
||||||
|
without the disabled plugin. Once re-enabled, it came back at the end of
|
||||||
|
the rotation and scrolling in Vegas again. A disabled plugin's saved id
|
||||||
|
now stays in its saved place (`widgets/plugin-order-list.js`); the id of
|
||||||
|
a plugin that is no longer installed is still dropped.
|
||||||
|
- Restoring a backup with "Reinstall missing plugins" installs only the
|
||||||
|
plugins that are missing. Every plugin the backup listed was sent to the
|
||||||
|
store's install, which replaces an installed copy with a fresh download,
|
||||||
|
so a restore onto the same device re-downloaded all of them in one
|
||||||
|
request. A plugin installed from its own URL is not in the registry, so
|
||||||
|
its "reinstall" failed and the restore answered "Restore failed" while
|
||||||
|
the plugin sat there installed. An installed plugin, found by the store's
|
||||||
|
own lookup (registry aliases included), is now listed under Skipped as
|
||||||
|
`plugin:<id> (installed)`.
|
||||||
|
- `POST /api/v3/config/main` answers a JSON body that does not parse with
|
||||||
|
400 `Invalid JSON in request body`, as `/config/raw/main` does, and an
|
||||||
|
empty JSON body with 400 `No data provided`. Both were a 500
|
||||||
|
`CONFIG_SAVE_FAILED` suggesting file permissions and disk space, with a
|
||||||
|
traceback logged at ERROR: `get_json()` raised inside the handler's
|
||||||
|
catch-all.
|
||||||
|
- Fonts restored from a backup show up in the Fonts tab and the font
|
||||||
|
pickers straight away. The font catalog is cached for five minutes, and
|
||||||
|
upload and delete cleared it but a restore did not.
|
||||||
- A game that goes live now takes over the panel within about a second.
|
- A game that goes live now takes over the panel within about a second.
|
||||||
Live priority was only checked between screens, so a game that went live
|
Live priority was only checked between screens, so a game that went live
|
||||||
during a 30 s screen waited for that screen to end. The frame loops and the
|
during a 30 s screen waited for that screen to end. The frame loops and the
|
||||||
@@ -549,6 +722,45 @@ policies are unchanged.
|
|||||||
screen showed first and the game came after it. Each check also asks each
|
screen showed first and the game came after it. Each check also asks each
|
||||||
plugin `has_live_content()` once, where a plugin registered under several
|
plugin `has_live_content()` once, where a plugin registered under several
|
||||||
modes used to be asked once per mode.
|
modes used to be asked once per mode.
|
||||||
|
- A plugin action whose params hold `true`, `false` or `null` runs again.
|
||||||
|
`/api/v3/plugins/action` wrote the params into the source of the wrapper
|
||||||
|
that runs the plugin's script, and those JSON words are not Python, so the
|
||||||
|
wrapper stopped with a NameError and the action answered "Action failed".
|
||||||
|
The plugin file manager's category toggle sends `"enabled": true`, so
|
||||||
|
turning a category on or off in of-the-day always failed. The params now
|
||||||
|
reach the wrapper on its stdin; the script still receives them as JSON on
|
||||||
|
its own stdin, as before.
|
||||||
|
- An on-demand request that `/api/v3/display/on-demand/start` refuses no
|
||||||
|
longer runs later. With the display stopped the request goes to the
|
||||||
|
display's mailbox, and the display reads that mailbox for an hour without
|
||||||
|
looking at a request's age. So with "Start display service" unticked, the
|
||||||
|
answer was "Display service is not running", yet the next time the
|
||||||
|
display was started it ran that plugin, pinned if the request said so.
|
||||||
|
The same happened after "Failed to start display service". On either
|
||||||
|
refusal the route now takes its request back out of the mailbox, unless a
|
||||||
|
newer one has replaced it. A request the display acknowledges over the
|
||||||
|
control socket is now a success whatever systemd reports: a display run
|
||||||
|
by hand or in the emulator was told "not running" for a request it had
|
||||||
|
already taken, and with "Start display service" ticked the route tried to
|
||||||
|
start the service beside it.
|
||||||
|
- `/api/v3/plugins/operation/<id>` reports a queued operation as `pending`
|
||||||
|
instead of answering 500. The queue keeps an operation's callback among
|
||||||
|
its parameters until it runs, and the status route tried to send that
|
||||||
|
function as JSON. An install queued behind another plugin's install
|
||||||
|
failed every status poll until the first one finished. Parameters whose
|
||||||
|
name starts with `_` are internal and are no longer in the answer.
|
||||||
|
- A second click on Install while that plugin is still installing, or an
|
||||||
|
Uninstall during its install, now answers 409 "already has an install,
|
||||||
|
update or uninstall in progress" instead of 500 "An error occurred". The
|
||||||
|
first operation carried on either way. The uninstall route also stopped
|
||||||
|
recording a failed uninstall in the operation history for an uninstall
|
||||||
|
that never started.
|
||||||
|
- `/api/v3/plugins/<plugin_id>/static/<path>` serves images and other
|
||||||
|
binary files. It opened every file as UTF-8 text, so a plugin's icon or
|
||||||
|
preview image answered 500 `UnicodeDecodeError`. Files are now sent as
|
||||||
|
they are on disk, an image with its own content type; HTML, JavaScript,
|
||||||
|
CSS, JSON and other text keep the types they had. The path checks are
|
||||||
|
unchanged.
|
||||||
- The display schedule turns the panel off at exactly the end time. A window
|
- The display schedule turns the panel off at exactly the end time. A window
|
||||||
now runs from its start time up to, but not including, its end time: with
|
now runs from its start time up to, but not including, its end time: with
|
||||||
07:00-23:00 the panel is on at 07:00 and off at 23:00. Before, the end
|
07:00-23:00 the panel is on at 07:00 and off at 23:00. Before, the end
|
||||||
@@ -556,10 +768,77 @@ policies are unchanged.
|
|||||||
the panel went off at 23:00 or at 23:01 depending on when in the minute
|
the panel went off at 23:00 or at 23:01 depending on when in the minute
|
||||||
that check ran. Windows that cross midnight and per-day schedules follow
|
that check ran. Windows that cross midnight and per-day schedules follow
|
||||||
the same rule, and so does the dim schedule.
|
the same rule, and so does the dim schedule.
|
||||||
|
- The MQTT bridge settings on the Tools tab can save a broker password with
|
||||||
|
TLS off. The server refuses that unless `allow_insecure_mqtt` is set, and
|
||||||
|
the form had no way to set it, so a password-protected broker on a home
|
||||||
|
network without TLS could not be saved from the web UI, and once such a
|
||||||
|
password was stored every later save failed too. While "Use TLS" is
|
||||||
|
unchecked the form now shows "Allow without TLS (trusted network)",
|
||||||
|
prefilled from the saved settings. It is off until ticked, so the server
|
||||||
|
still refuses a cleartext password by default.
|
||||||
|
- The Overview's plugin-config warning check stops polling. It asked
|
||||||
|
`/api/v3/plugins/reconciliation-status` every 2 s until startup
|
||||||
|
reconciliation reported done, and the route reports not done whenever its
|
||||||
|
status file is missing: reconciliation raised before writing it, or /tmp
|
||||||
|
was cleaned under a long-running web service. The page then sent that
|
||||||
|
request every 2 s for as long as it stayed open, whichever tab was showing.
|
||||||
|
It now gives up after a minute and only polls while the Overview is on
|
||||||
|
screen.
|
||||||
|
- Moving the Brightness slider on the Display tab no longer throws an error
|
||||||
|
in the browser console on every step. Its handler also updated a "LED
|
||||||
|
brightness" line that was removed from the page in #387; the lookup is
|
||||||
|
gone.
|
||||||
|
- Creating an API token on the General tab no longer leaves the page asking
|
||||||
|
"Leave site?" on reload. The unsaved-changes guard marks a form when you
|
||||||
|
type in it and clears the mark only after an htmx save, and the token form
|
||||||
|
saves with a plain request, so it stayed marked after the token was
|
||||||
|
created. It is cleared once the token is saved.
|
||||||
- An on-demand session that ends during scheduled-off hours, by expiring or
|
- An on-demand session that ends during scheduled-off hours, by expiring or
|
||||||
being stopped, blanks the panel within about a second. It used to stay on
|
being stopped, blanks the panel within about a second. It used to stay on
|
||||||
until the next minute, because the once-a-minute schedule check had
|
until the next minute, because the once-a-minute schedule check had
|
||||||
already run that minute and the session had overridden its answer.
|
already run that minute and the session had overridden its answer.
|
||||||
|
- Check & Update All updates what is installed now. A second run in the
|
||||||
|
same page sent the plugins the first run had seen, so a plugin uninstalled
|
||||||
|
since then failed with "plugin not found" and one installed since was
|
||||||
|
skipped. After a run the installed cards and the Updates badge show the
|
||||||
|
new versions; they kept offering "Update to vX" for what had just been
|
||||||
|
updated until the page was reloaded.
|
||||||
|
- The Run On-Demand dialog lists a plugin's display modes, so a mode other
|
||||||
|
than the first can be started, and pinned. `/api/v3/plugins/installed`
|
||||||
|
never sent `display_modes`, which the dialog reads, so every plugin
|
||||||
|
offered only its own id under "This plugin exposes a single display
|
||||||
|
mode", and the display started its first mode. Each entry now carries
|
||||||
|
`display_modes`, the modes its manifest declares.
|
||||||
|
- Installing Weather, Music, Stocks or Leaderboard from the Plugin Store
|
||||||
|
enables it, as installing any other plugin does. Each installs under the
|
||||||
|
id its manifest declares (`ledmatrix-weather` for the store's `weather`),
|
||||||
|
but the store enabled the store id, which `/api/v3/plugins/toggle`
|
||||||
|
answered with "Plugin not found": the plugin stayed disabled behind
|
||||||
|
"installed, but enabling it failed". `POST /api/v3/plugins/install` now
|
||||||
|
answers with the installed `plugin_id` (in the operation's result when it
|
||||||
|
is queued), and the store enables that.
|
||||||
|
- Reinstalling a plugin from the Plugin Store leaves it enabled or disabled
|
||||||
|
as it was. Reinstall enabled it as a fresh install does, so a plugin the
|
||||||
|
user had switched off came back on.
|
||||||
|
- A Plugin Store install that takes more than a minute is no longer
|
||||||
|
reported as failed. The store stopped waiting after 60 s and showed
|
||||||
|
"Install operation timed out" while the server, which allows the
|
||||||
|
plugin's dependency install 300 s on its own, carried on and usually
|
||||||
|
succeeded; the plugin was then neither enabled nor listed until the page
|
||||||
|
was reloaded. The store now waits up to 10 minutes, and if it still has
|
||||||
|
no answer it reloads the installed list and says the install may still
|
||||||
|
be running.
|
||||||
|
- The Plugin Store's category filter lists every category its plugins
|
||||||
|
have. It offered a fixed seven while the registry uses about twenty, so
|
||||||
|
plugins filed under productivity, utility, transit and the rest could not
|
||||||
|
be filtered to, and "Financial" missed the plugin filed under "finance".
|
||||||
|
The choices are now built from the store's plugins, as the Starlark
|
||||||
|
section's are.
|
||||||
|
- The Install button under Install Single Plugin (Plugin Manager > Install
|
||||||
|
from GitHub) runs one handler per click. It also had an inline `onclick`
|
||||||
|
whose handler threw a `ReferenceError` on every click; only the other
|
||||||
|
handler's request went out, and making the inline one work would have
|
||||||
|
sent every install twice. The inline handler is gone.
|
||||||
- `/api/v3/plugins/installed` no longer reports the display's plugins as
|
- `/api/v3/plugins/installed` no longer reports the display's plugins as
|
||||||
`live` while `/api/v3/health` says `display_loop: stalled`. The runtime
|
`live` while `/api/v3/health` says `display_loop: stalled`. The runtime
|
||||||
snapshot is written from its own thread, which kept going while the render
|
snapshot is written from its own thread, which kept going while the render
|
||||||
@@ -571,11 +850,41 @@ policies are unchanged.
|
|||||||
heartbeat when the service stops), is `stale` at once instead of `live`
|
heartbeat when the service stops), is `stale` at once instead of `live`
|
||||||
for up to 180 s. No new files or writes: both checks are on the reading
|
for up to 180 s. No new files or writes: both checks are on the reading
|
||||||
side.
|
side.
|
||||||
|
- A scoreboard's scroll and Vegas cards with `scroll_card.date_format:
|
||||||
|
"weekday"` now show the printed date's own weekday. A Friday 8 PM ET game
|
||||||
|
read "Sat Oct 2". The card took the weekday in the plugin's own
|
||||||
|
`timezone` setting, which ships blank, so it fell back to UTC, while the
|
||||||
|
"Oct 2" beside it came from the zone the plugin actually resolves (its
|
||||||
|
setting, then the global one, then the system zone). Every zone is within a
|
||||||
|
day of UTC, so the card now finds which day near the start's UTC date has
|
||||||
|
the printed month and day and names that one. Games east of UTC (Auckland,
|
||||||
|
Kiritimati) were off by a day the other way and are fixed the same way.
|
||||||
|
The switch-mode scorebug, which already used the plugin's resolved zone,
|
||||||
|
shares the same formatter and draws what it drew before.
|
||||||
- `/api/v3/display/current-status` reflects a wake from scheduled-off, a
|
- `/api/v3/display/current-status` reflects a wake from scheduled-off, a
|
||||||
schedule-off blank, or an on-demand session starting or ending at once,
|
schedule-off blank, or an on-demand session starting or ending at once,
|
||||||
even when the mode name stays the same. The display republished its
|
even when the mode name stays the same. The display republished its
|
||||||
current state only on a mode change or every 30 s, so `is_display_active`
|
current state only on a mode change or every 30 s, so `is_display_active`
|
||||||
and `on_demand_active` could be up to 30 s out of date.
|
and `on_demand_active` could be up to 30 s out of date.
|
||||||
|
- `/api/v3/display/current-status` no longer answers `mode: null` over the
|
||||||
|
control socket (#735) once the same mode has been on screen for more than
|
||||||
|
two minutes: a live game under live priority, Vegas, or a single plugin.
|
||||||
|
On ledpi it returned nulls in every sample for 90 minutes while the
|
||||||
|
display was live. The state stream's version leaves out the timestamps
|
||||||
|
that move on every publish, and a subscriber's keepalive tick carried only
|
||||||
|
the loop's heartbeat. So the web interface's copy kept the
|
||||||
|
`display.last_updated` of the last real change, and the reader's 120 s
|
||||||
|
rule called it unknown. The plugin runtime section had the same problem:
|
||||||
|
with no plugin changing state, `/plugins/state` and the `runtime` in
|
||||||
|
`/plugins/installed` read `stale` after 180 s. A tick (and a `state.get`
|
||||||
|
answer with `since`) now carries `volatile`: the current values of those
|
||||||
|
timestamps (`display.last_updated`, `on_demand.last_updated` and
|
||||||
|
`remaining`, `plugins.published_at`), and the subscription merges them
|
||||||
|
into its copy. The verdicts are unchanged. A render thread that stops
|
||||||
|
publishing still reads as `stalled` after 60 s and as unknown after 120 s,
|
||||||
|
a runtime publisher that stops still goes `stale`, and a subscription that
|
||||||
|
goes quiet still falls back to the cache. The cache path's 120 s rule is
|
||||||
|
unchanged.
|
||||||
|
|
||||||
### Scrolling
|
### Scrolling
|
||||||
|
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ connection until either side hangs up:
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{"v": 1, "id": "<the subscribe id>", "event": "state", "result": {...a state snapshot...}}
|
{"v": 1, "id": "<the subscribe id>", "event": "state", "result": {...a state snapshot...}}
|
||||||
{"v": 1, "id": "<the subscribe id>", "event": "tick", "result": {"version": 7, "epoch": "…", "pid": 812, "served_at": 1790000000.1, "changed": false, "loop": {...}}}
|
{"v": 1, "id": "<the subscribe id>", "event": "tick", "result": {"version": 7, "epoch": "…", "pid": 812, "served_at": 1790000000.1, "changed": false, "loop": {...}, "volatile": {"display": {"last_updated": 1790000000.0}, "...": "..."}}}
|
||||||
```
|
```
|
||||||
|
|
||||||
An event has `event` where a response has `ok`, which is how a reader tells
|
An event has `event` where a response has `ok`, which is how a reader tells
|
||||||
@@ -216,8 +216,14 @@ socket.
|
|||||||
counts within an `epoch`, one run of the display process, so a reader that
|
counts within an `epoch`, one run of the display process, so a reader that
|
||||||
sees a new `epoch` has a restarted display.
|
sees a new `epoch` has a restarted display.
|
||||||
- `state.get` with `since` and `epoch` from an earlier answer gets just
|
- `state.get` with `since` and `epoch` from an earlier answer gets just
|
||||||
`{changed: false, version, epoch, pid, served_at, loop}` while nothing has
|
`{changed: false, version, epoch, pid, served_at, loop, volatile}` while
|
||||||
changed.
|
nothing has changed. `volatile` is `{section: {key: value}}`: the current
|
||||||
|
values of those ignored timestamps, which the reader merges into the copy
|
||||||
|
it has. They don't make a new version, but they are still news:
|
||||||
|
`display.last_updated` is how a reader knows the render thread is still
|
||||||
|
publishing, and `plugins.published_at` the runtime publisher. Without
|
||||||
|
them a reader's copy kept the timestamps of the last real change, so a
|
||||||
|
mode on screen for over 120 s read as unknown.
|
||||||
- A snapshot that would not fit in a message (hundreds of plugins) is sent
|
- A snapshot that would not fit in a message (hundreds of plugins) is sent
|
||||||
without `plugins`, and `truncated: ["plugins"]` says so. Readers then use
|
without `plugins`, and `truncated: ["plugins"]` says so. Readers then use
|
||||||
the cache for that section only.
|
the cache for that section only.
|
||||||
@@ -226,8 +232,10 @@ socket.
|
|||||||
|
|
||||||
- a `state` event (a full snapshot) whenever the version changes, and
|
- a `state` event (a full snapshot) whenever the version changes, and
|
||||||
- a `tick` at least every 5 s (`SUBSCRIBE_KEEPALIVE_SECONDS`) when nothing
|
- a `tick` at least every 5 s (`SUBSCRIBE_KEEPALIVE_SECONDS`) when nothing
|
||||||
changed. It carries `loop`, so a stalled render loop shows up within one
|
changed. It is the short `changed: false` answer, so it carries `loop`
|
||||||
tick, and it tells the reader the connection is alive.
|
(a stalled render loop shows up within one tick) and `volatile` (the
|
||||||
|
timestamps stay as fresh as the writers keep them), and it tells the
|
||||||
|
reader the connection is alive.
|
||||||
|
|
||||||
A slow reader is never sent a backlog: each event is the latest version, so
|
A slow reader is never sent a backlog: each event is the latest version, so
|
||||||
one that falls behind skips the versions in between. A reader that has heard
|
one that falls behind skips the versions in between. A reader that has heard
|
||||||
|
|||||||
@@ -213,8 +213,9 @@ def list_installed_plugins(project_root: Path) -> List[Dict[str, Any]]:
|
|||||||
The plugins are the ``manifest.json`` files in the configured plugin
|
The plugins are the ``manifest.json`` files in the configured plugin
|
||||||
directory (see :func:`_plugins_directory`), with the manifest's version;
|
directory (see :func:`_plugins_directory`), with the manifest's version;
|
||||||
``enabled`` is config.json's flag by the display's rule (a missing flag
|
``enabled`` is config.json's flag by the display's rule (a missing flag
|
||||||
is disabled). A restore reinstalls every listed plugin and takes enabled
|
is disabled). A restore installs each listed plugin that is missing and
|
||||||
state from the restored config.json, so ``enabled`` is informational.
|
takes enabled state from the restored config.json, so ``enabled`` is
|
||||||
|
informational.
|
||||||
|
|
||||||
``data/plugin_state.json`` is not read: it only ever repeated config's
|
``data/plugin_state.json`` is not read: it only ever repeated config's
|
||||||
enabled flags and the manifests' versions, and is retired (nothing
|
enabled flags and the manifests' versions, and is retired (nothing
|
||||||
|
|||||||
Vendored
+40
-5
@@ -4,6 +4,7 @@ Disk Cache
|
|||||||
Handles persistent disk-based caching with atomic writes and error recovery.
|
Handles persistent disk-based caching with atomic writes and error recovery.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
@@ -31,6 +32,35 @@ except ImportError: # pragma: no cover - exercised on hosts without the wheel
|
|||||||
# useful, and a half-written file was never useful.
|
# useful, and a half-written file was never useful.
|
||||||
_ORPHAN_TEMP_MAX_AGE_SECONDS = 3600
|
_ORPHAN_TEMP_MAX_AGE_SECONDS = 3600
|
||||||
|
|
||||||
|
# Longest key, in UTF-8 bytes, used verbatim as a filename stem. ext4 caps a
|
||||||
|
# name at 255 bytes and set()'s temp file is ".<stem>.json.<8 random>", 15
|
||||||
|
# bytes longer than the stem, so anything near the cap could never be written:
|
||||||
|
# the calendar plugin's key joins every calendar id and passed 300 bytes on a
|
||||||
|
# real install, failing every write with ENAMETOOLONG. Longer keys keep this
|
||||||
|
# many bytes as a readable prefix and end in a hash of the whole key.
|
||||||
|
_MAX_KEY_FILENAME_BYTES = 200
|
||||||
|
_KEY_HASH_CHARS = 16
|
||||||
|
|
||||||
|
|
||||||
|
def _filename_stem(key: str) -> str:
|
||||||
|
"""The filename stem for a key that is already a safe path component.
|
||||||
|
|
||||||
|
Short keys are used as they are, so every file already on disk keeps its
|
||||||
|
name. A long one becomes its first bytes plus a hash of the full key: the
|
||||||
|
prefix keeps the stem recognisable (and keeps the data-type words that
|
||||||
|
cleanup's retention lookup reads from it), the hash keeps two keys that
|
||||||
|
share a long prefix apart. The result is itself short, so a stem read back
|
||||||
|
from a filename -- which is how the web UI names a key it deletes -- maps to
|
||||||
|
the same file.
|
||||||
|
"""
|
||||||
|
encoded = key.encode('utf-8')
|
||||||
|
if len(encoded) <= _MAX_KEY_FILENAME_BYTES:
|
||||||
|
return key
|
||||||
|
digest = hashlib.sha256(encoded).hexdigest()[:_KEY_HASH_CHARS]
|
||||||
|
keep = _MAX_KEY_FILENAME_BYTES - _KEY_HASH_CHARS - 1
|
||||||
|
prefix = encoded[:keep].decode('utf-8', errors='ignore')
|
||||||
|
return f"{prefix}-{digest}"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class CacheStrategyProtocol(Protocol):
|
class CacheStrategyProtocol(Protocol):
|
||||||
@@ -343,6 +373,8 @@ class DiskCache:
|
|||||||
derives them), so rejecting anything with a path component turns
|
derives them), so rejecting anything with a path component turns
|
||||||
away only inputs that could never have been written here.
|
away only inputs that could never have been written here.
|
||||||
|
|
||||||
|
A key too long to be a filename is shortened by _filename_stem.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
key: Cache key
|
key: Cache key
|
||||||
|
|
||||||
@@ -356,7 +388,7 @@ class DiskCache:
|
|||||||
if safe_key is None:
|
if safe_key is None:
|
||||||
self.logger.warning("Rejected unsafe cache key %r", key)
|
self.logger.warning("Rejected unsafe cache key %r", key)
|
||||||
return None
|
return None
|
||||||
return os.path.join(self.cache_dir, f"{safe_key}.json")
|
return os.path.join(self.cache_dir, f"{_filename_stem(safe_key)}.json")
|
||||||
|
|
||||||
def get(self, key: str, max_age: Optional[int] = 300) -> Optional[Dict[str, Any]]:
|
def get(self, key: str, max_age: Optional[int] = 300) -> Optional[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
@@ -561,7 +593,7 @@ class DiskCache:
|
|||||||
# If direct write also fails, try fallback location
|
# If direct write also fails, try fallback location
|
||||||
self.logger.warning("Direct write failed for key '%s' to %s: %s", key, cache_path, write_error)
|
self.logger.warning("Direct write failed for key '%s' to %s: %s", key, cache_path, write_error)
|
||||||
raise # Re-raise to trigger fallback logic
|
raise # Re-raise to trigger fallback logic
|
||||||
except (IOError, OSError, PermissionError):
|
except (IOError, OSError, PermissionError) as primary_error:
|
||||||
# Attempt one-time fallback write to user's home cache directory
|
# Attempt one-time fallback write to user's home cache directory
|
||||||
try:
|
try:
|
||||||
# Try user's home cache directory as fallback
|
# Try user's home cache directory as fallback
|
||||||
@@ -587,11 +619,14 @@ class DiskCache:
|
|||||||
self.logger.debug("Fallback cache write also failed for key '%s': %s", key, e2)
|
self.logger.debug("Fallback cache write also failed for key '%s': %s", key, e2)
|
||||||
|
|
||||||
# If all write attempts failed, log warning but don't raise exception
|
# If all write attempts failed, log warning but don't raise exception
|
||||||
# Cache is a performance optimization, not critical for operation
|
# Cache is a performance optimization, not critical for operation.
|
||||||
|
# Name the real error: this used to say "permission denied"
|
||||||
|
# whatever happened, which sent a too-long filename off to
|
||||||
|
# be debugged as a directory-ownership problem.
|
||||||
self.logger.warning(
|
self.logger.warning(
|
||||||
"Could not write cache for key '%s' to %s (permission denied). "
|
"Could not write cache for key '%s' to %s (%s). "
|
||||||
"Cache will be unavailable for this key, but application will continue.",
|
"Cache will be unavailable for this key, but application will continue.",
|
||||||
key, cache_path
|
key, cache_path, primary_error.strerror or primary_error
|
||||||
)
|
)
|
||||||
return # Exit gracefully without raising exception
|
return # Exit gracefully without raising exception
|
||||||
|
|
||||||
|
|||||||
+34
-2
@@ -46,6 +46,32 @@ from src.cache.disk_cache import DateTimeEncoder # noqa: F401 - deliberate re-e
|
|||||||
# CacheManager.config_manager not built yet (None means "not available").
|
# CacheManager.config_manager not built yet (None means "not available").
|
||||||
_UNSET: Any = object()
|
_UNSET: Any = object()
|
||||||
|
|
||||||
|
|
||||||
|
def _outlived(record: Any, max_age: Optional[float], now: float) -> bool:
|
||||||
|
"""Whether a record's own timestamp puts it past max_age.
|
||||||
|
|
||||||
|
The memory tier times an entry from when it was put there, and a record
|
||||||
|
loaded from disk is put there when it is read, not when it was written: a
|
||||||
|
record 290 s old, read after a restart, could be served for another
|
||||||
|
max_age from memory. This is the age check DiskCache.get makes, with the
|
||||||
|
same rule that a stored ttl wins over the caller's max_age. A record that
|
||||||
|
carries no timestamp is left to the memory tier's own clock.
|
||||||
|
"""
|
||||||
|
if not isinstance(record, dict):
|
||||||
|
return False
|
||||||
|
stored_ttl = record.get('ttl')
|
||||||
|
if isinstance(stored_ttl, (int, float)) and not isinstance(stored_ttl, bool) \
|
||||||
|
and stored_ttl >= 0:
|
||||||
|
max_age = stored_ttl
|
||||||
|
stamp = record.get('timestamp')
|
||||||
|
if max_age is None or stamp is None or isinstance(stamp, bool):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return now - float(stamp) > max_age
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
class CacheManager:
|
class CacheManager:
|
||||||
"""Manages caching of API responses to reduce API calls."""
|
"""Manages caching of API responses to reduce API calls."""
|
||||||
|
|
||||||
@@ -284,7 +310,11 @@ class CacheManager:
|
|||||||
# 1) Memory cache
|
# 1) Memory cache
|
||||||
cached = self._memory_cache_component.get(key, max_age=in_memory_ttl)
|
cached = self._memory_cache_component.get(key, max_age=in_memory_ttl)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
return cached
|
if not _outlived(cached, max_age, time.time()):
|
||||||
|
return cached
|
||||||
|
# Too old for this reader. Disk may hold a newer write (from the
|
||||||
|
# other process), and if it does not, the miss is the right answer.
|
||||||
|
self._memory_cache_component.clear(key)
|
||||||
|
|
||||||
# 2) Disk cache
|
# 2) Disk cache
|
||||||
record = self._disk_cache_component.get(key, max_age=max_age)
|
record = self._disk_cache_component.get(key, max_age=max_age)
|
||||||
@@ -318,7 +348,9 @@ class CacheManager:
|
|||||||
# Check memory cache first (1 minute TTL)
|
# Check memory cache first (1 minute TTL)
|
||||||
cached = self._memory_cache_component.get(key, max_age=60)
|
cached = self._memory_cache_component.get(key, max_age=60)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
return cached
|
if not _outlived(cached, 3600, time.time()):
|
||||||
|
return cached
|
||||||
|
self._memory_cache_component.clear(key)
|
||||||
|
|
||||||
# Check disk cache
|
# Check disk cache
|
||||||
data = self._disk_cache_component.get(key, max_age=3600) # 1 hour for load_cache
|
data = self._disk_cache_component.get(key, max_age=3600) # 1 hour for load_cache
|
||||||
|
|||||||
+24
-24
@@ -561,7 +561,7 @@ class ScrollHelper:
|
|||||||
width = self.display_width
|
width = self.display_width
|
||||||
strip_width = self.cached_array.shape[1]
|
strip_width = self.cached_array.shape[1]
|
||||||
|
|
||||||
if start_x + width + 1 <= strip_width:
|
if 0 <= start_x and start_x + width + 1 <= strip_width:
|
||||||
# Slice the backing array directly. Going via
|
# Slice the backing array directly. Going via
|
||||||
# _get_visible_portion_integer would build two PIL images only for
|
# _get_visible_portion_integer would build two PIL images only for
|
||||||
# them to be converted straight back to arrays, which measured 15x
|
# them to be converted straight back to arrays, which measured 15x
|
||||||
@@ -569,9 +569,10 @@ class ScrollHelper:
|
|||||||
near = self.cached_array[:, start_x:start_x + width]
|
near = self.cached_array[:, start_x:start_x + width]
|
||||||
far = self.cached_array[:, start_x + 1:start_x + 1 + width]
|
far = self.cached_array[:, start_x + 1:start_x + 1 + width]
|
||||||
else:
|
else:
|
||||||
# Close enough to the end that one of the slices wraps; let the
|
# One of the slices wraps (close to the end, or a strip narrower
|
||||||
# integer path handle that and pay the conversion. Continuous mode
|
# than the panel); let the integer path handle that and pay the
|
||||||
# extends the strip before reaching here, so this is the rare case.
|
# conversion. Continuous mode extends the strip before reaching
|
||||||
|
# here, so this is the rare case.
|
||||||
near = np.asarray(
|
near = np.asarray(
|
||||||
self._get_visible_portion_integer(start_x, start_x + width))
|
self._get_visible_portion_integer(start_x, start_x + width))
|
||||||
far = np.asarray(
|
far = np.asarray(
|
||||||
@@ -601,34 +602,33 @@ class ScrollHelper:
|
|||||||
_size = (self.display_width, self.display_height)
|
_size = (self.display_width, self.display_height)
|
||||||
img_w = self.cached_array.shape[1]
|
img_w = self.cached_array.shape[1]
|
||||||
|
|
||||||
if end_x <= img_w:
|
if 0 <= start_x and end_x <= img_w:
|
||||||
# Normal case: single contiguous slice (fastest path). tobytes()
|
# Normal case: single contiguous slice (fastest path). tobytes()
|
||||||
# on the column-slice view already returns C-order bytes, so
|
# on the column-slice view already returns C-order bytes, so
|
||||||
# ascontiguousarray() first only added a second full-frame copy.
|
# ascontiguousarray() first only added a second full-frame copy.
|
||||||
return Image.frombytes(
|
return Image.frombytes(
|
||||||
'RGB', _size,
|
'RGB', _size,
|
||||||
self.cached_array[:, start_x:end_x].tobytes())
|
self.cached_array[:, start_x:end_x].tobytes())
|
||||||
|
|
||||||
|
# Ensure frame buffer is allocated for all non-simple paths
|
||||||
|
if self._frame_buffer is None or self._frame_buffer.shape != (self.display_height, self.display_width, 3):
|
||||||
|
self._frame_buffer = np.zeros((self.display_height, self.display_width, 3), dtype=np.uint8)
|
||||||
|
|
||||||
|
if img_w == 0:
|
||||||
|
self._frame_buffer[:] = 0
|
||||||
else:
|
else:
|
||||||
# Ensure frame buffer is allocated for all non-simple paths
|
# The frame runs off the strip, so it carries on from the head:
|
||||||
if self._frame_buffer is None or self._frame_buffer.shape != (self.display_height, self.display_width, 3):
|
# frame column j is strip column (start_x + j) modulo the strip's
|
||||||
self._frame_buffer = np.zeros((self.display_height, self.display_width, 3), dtype=np.uint8)
|
# width -- the tail and then the head, and a strip narrower than
|
||||||
|
# the panel repeated across it. Copying the tail and then the rest
|
||||||
|
# of the frame from the head assumed the head was that wide, and
|
||||||
|
# raised at every position for a strip narrower than the panel
|
||||||
|
# (Vegas composes one, with no lead-in, when its content is
|
||||||
|
# narrower than the chain).
|
||||||
|
np.take(self.cached_array, np.arange(start_x, end_x), axis=1,
|
||||||
|
mode='wrap', out=self._frame_buffer)
|
||||||
|
|
||||||
width1 = img_w - start_x
|
return Image.frombytes('RGB', _size, self._frame_buffer.tobytes())
|
||||||
if width1 > 0:
|
|
||||||
# Wrap-around: tail of image + head of image
|
|
||||||
self._frame_buffer[:, :width1] = self.cached_array[:, start_x:]
|
|
||||||
remaining_width = self.display_width - width1
|
|
||||||
self._frame_buffer[:, width1:] = self.cached_array[:, :remaining_width]
|
|
||||||
else:
|
|
||||||
# Edge case: start_x at or past image end — show from beginning,
|
|
||||||
# clamped to available width (scroll_position should wrap before
|
|
||||||
# reaching this state in normal operation).
|
|
||||||
available = min(self.display_width, img_w)
|
|
||||||
self._frame_buffer[:, :available] = self.cached_array[:, :available]
|
|
||||||
if available < self.display_width:
|
|
||||||
self._frame_buffer[:, available:] = 0
|
|
||||||
|
|
||||||
return Image.frombytes('RGB', _size, self._frame_buffer.tobytes())
|
|
||||||
|
|
||||||
def calculate_dynamic_duration(self) -> int:
|
def calculate_dynamic_duration(self) -> int:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ the extra guard only stops a None size raising TypeError.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any, Dict, Optional, Tuple
|
from typing import Any, Dict, Optional, Tuple
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
@@ -338,10 +338,46 @@ def format_game_date(config: Optional[Dict[str, Any]], logger, date_text: str,
|
|||||||
if not raw:
|
if not raw:
|
||||||
return ""
|
return ""
|
||||||
fmt = str(scroll_card_option(config, "date_format", "abbrev") or "abbrev")
|
fmt = str(scroll_card_option(config, "date_format", "abbrev") or "abbrev")
|
||||||
return _format_date_as(fmt, raw, lambda: weekday_for(config, logger, game))
|
return _format_date_as(fmt, raw, lambda: weekday_for(config, logger, game),
|
||||||
|
game=game)
|
||||||
|
|
||||||
|
|
||||||
def _format_date_as(fmt: str, raw: str, weekday, months=MONTH_ABBR) -> str:
|
def _printed_weekday(game: Optional[Dict], month: int, day: int) -> str:
|
||||||
|
"""The weekday of the date a card prints as month/day, or '' if unknown.
|
||||||
|
|
||||||
|
The extractor prints "M/D" in the plugin's resolved zone (its own setting,
|
||||||
|
else the global one, else the system zone). The card cannot see that zone:
|
||||||
|
it is handed the plugin's config, whose ``timezone`` ships as "", so
|
||||||
|
card_tzinfo answers UTC and an evening kickoff in the Americas got the
|
||||||
|
next day's weekday ("Sat Oct 2" for a Friday game). Every zone is within
|
||||||
|
a day of UTC, so the printed date is the start's UTC date or a neighbour
|
||||||
|
of it; the one with that month and day is the date on the card.
|
||||||
|
"""
|
||||||
|
if not isinstance(game, dict):
|
||||||
|
return ""
|
||||||
|
raw = game.get("start_time_utc") or game.get("start_time")
|
||||||
|
if not raw:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
start = raw if isinstance(raw, datetime) else datetime.fromisoformat(
|
||||||
|
str(raw).replace("Z", "+00:00"))
|
||||||
|
if start.utcoffset() is None:
|
||||||
|
return "" # naive: no instant to place the date against
|
||||||
|
utc_day = start.astimezone(timezone.utc).date()
|
||||||
|
except (ValueError, TypeError, OverflowError):
|
||||||
|
return ""
|
||||||
|
for offset in (0, -1, 1):
|
||||||
|
try:
|
||||||
|
candidate = utc_day + timedelta(days=offset)
|
||||||
|
except OverflowError:
|
||||||
|
continue
|
||||||
|
if (candidate.month, candidate.day) == (month, day):
|
||||||
|
return WEEKDAY_ABBR[candidate.weekday()]
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _format_date_as(fmt: str, raw: str, weekday, months=MONTH_ABBR,
|
||||||
|
game: Optional[Dict] = None) -> str:
|
||||||
"""Render a stripped, non-empty "M/D" *raw* in style *fmt*.
|
"""Render a stripped, non-empty "M/D" *raw* in style *fmt*.
|
||||||
|
|
||||||
The body both date formatters share. They differ in which setting names the
|
The body both date formatters share. They differ in which setting names the
|
||||||
@@ -349,6 +385,9 @@ def _format_date_as(fmt: str, raw: str, weekday, months=MONTH_ABBR) -> str:
|
|||||||
``SportsCoreSharedMixin._format_game_date``), so those arrive as arguments:
|
``SportsCoreSharedMixin._format_game_date``), so those arrive as arguments:
|
||||||
*weekday* is a zero-argument callable, only called for the "weekday" style.
|
*weekday* is a zero-argument callable, only called for the "weekday" style.
|
||||||
*months* lets the mixin keep reading its (overridable) ``_MONTH_ABBR``.
|
*months* lets the mixin keep reading its (overridable) ``_MONTH_ABBR``.
|
||||||
|
With *game*, the "weekday" style names the printed date's own weekday
|
||||||
|
(:func:`_printed_weekday`), and *weekday* is only the fallback for a
|
||||||
|
date its start time cannot place.
|
||||||
"""
|
"""
|
||||||
if fmt == "numeric":
|
if fmt == "numeric":
|
||||||
return raw
|
return raw
|
||||||
@@ -364,7 +403,7 @@ def _format_date_as(fmt: str, raw: str, weekday, months=MONTH_ABBR) -> str:
|
|||||||
if fmt == "day_first":
|
if fmt == "day_first":
|
||||||
return f"{day} {name}"
|
return f"{day} {name}"
|
||||||
if fmt == "weekday":
|
if fmt == "weekday":
|
||||||
day_name = weekday()
|
day_name = _printed_weekday(game, month, day) or weekday()
|
||||||
return f"{day_name} {name} {day}" if day_name else f"{name} {day}"
|
return f"{day_name} {name} {day}" if day_name else f"{name} {day}"
|
||||||
return f"{name} {day}"
|
return f"{name} {day}"
|
||||||
|
|
||||||
|
|||||||
@@ -360,14 +360,16 @@ class SportsCoreSharedMixin:
|
|||||||
The formatting is sports_card's. What differs from the card's
|
The formatting is sports_card's. What differs from the card's
|
||||||
``format_game_date`` is passed in: the setting (``switch_date_format``,
|
``format_game_date`` is passed in: the setting (``switch_date_format``,
|
||||||
see :meth:`_switch_date_format`) and the weekday, which comes from
|
see :meth:`_switch_date_format`) and the weekday, which comes from
|
||||||
:meth:`_weekday_for` and so from this plugin's resolved timezone.
|
:meth:`_weekday_for` and so from this plugin's resolved timezone
|
||||||
|
when the game's start cannot place the printed date. The game goes
|
||||||
|
in too, so both formatters name the printed date's own weekday.
|
||||||
"""
|
"""
|
||||||
raw = str(date_text or "").strip()
|
raw = str(date_text or "").strip()
|
||||||
if not raw:
|
if not raw:
|
||||||
return raw
|
return raw
|
||||||
return _card._format_date_as(self._switch_date_format(), raw,
|
return _card._format_date_as(self._switch_date_format(), raw,
|
||||||
lambda: self._weekday_for(game),
|
lambda: self._weekday_for(game),
|
||||||
self._MONTH_ABBR)
|
self._MONTH_ABBR, game=game)
|
||||||
|
|
||||||
def _weekday_for(self, game: Optional[Dict]) -> str:
|
def _weekday_for(self, game: Optional[Dict]) -> str:
|
||||||
"""Weekday abbreviation from the game's start time, or ''."""
|
"""Weekday abbreviation from the game's start time, or ''."""
|
||||||
|
|||||||
+43
-10
@@ -46,6 +46,35 @@ from src.common.permission_utils import (
|
|||||||
get_config_dir_mode
|
get_config_dir_mode
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _private_copy(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""A deep copy of ``config`` that shares nothing with it.
|
||||||
|
|
||||||
|
load_config() hands one out per call, and the saves keep one, so the
|
||||||
|
cached config is never an object a caller holds. A web handler edits what
|
||||||
|
it loaded, validates, and may refuse the save; when the cache was that
|
||||||
|
same object, the refused edit stayed in it, and the next save of any
|
||||||
|
other setting wrote it to config.json -- a nested secret included, in
|
||||||
|
plain text, since it had never reached config_secrets.json to be
|
||||||
|
stripped.
|
||||||
|
|
||||||
|
The config is JSON data, so only its dicts and lists need copying; every
|
||||||
|
other value in it is immutable. On a Pi 4 with a real 60 KiB config this
|
||||||
|
takes 2.1 ms against copy.deepcopy's 6.8 ms, on a path ~30 handlers call
|
||||||
|
(a pickle round trip is no faster, 1.9 ms, and brings pickle into the
|
||||||
|
config path for nothing).
|
||||||
|
"""
|
||||||
|
return _copy_containers(config)
|
||||||
|
|
||||||
|
|
||||||
|
def _copy_containers(value: Any) -> Any:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {key: _copy_containers(item) for key, item in value.items()}
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [_copy_containers(item) for item in value]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
class ConfigManager:
|
class ConfigManager:
|
||||||
"""
|
"""
|
||||||
Reads and writes the main application configuration files.
|
Reads and writes the main application configuration files.
|
||||||
@@ -126,9 +155,10 @@ class ConfigManager:
|
|||||||
validate_after_write=validate_after_write
|
validate_after_write=validate_after_write
|
||||||
)
|
)
|
||||||
|
|
||||||
# Update in-memory config if save was successful
|
# Update in-memory config if save was successful. A copy: the caller
|
||||||
|
# still holds new_config_data (see _private_copy).
|
||||||
if result.status == SaveResultStatus.SUCCESS:
|
if result.status == SaveResultStatus.SUCCESS:
|
||||||
self.config = new_config_data
|
self.config = _private_copy(new_config_data)
|
||||||
# In-memory config now matches what was just written, so the
|
# In-memory config now matches what was just written, so the
|
||||||
# load_config fast path may return it. It still carries the
|
# load_config fast path may return it. It still carries the
|
||||||
# merged secrets that were stripped on disk; that matches a full
|
# merged secrets that were stripped on disk; that matches a full
|
||||||
@@ -208,14 +238,16 @@ class ConfigManager:
|
|||||||
|
|
||||||
Fast path: when config.json, config_secrets.json and the template
|
Fast path: when config.json, config_secrets.json and the template
|
||||||
are all unchanged since the last successful load (mtime_ns + size),
|
are all unchanged since the last successful load (mtime_ns + size),
|
||||||
the already-parsed self.config is returned without touching the
|
a copy of the already-parsed self.config is returned without
|
||||||
files — same aliasing semantics as the full path, which also
|
touching the files.
|
||||||
returns self.config.
|
|
||||||
|
Either way the caller gets its own copy (see _private_copy): editing
|
||||||
|
it changes nothing here until it is saved.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
current_sig = self._files_signature()
|
current_sig = self._files_signature()
|
||||||
if self.config and self._loaded_sig == current_sig:
|
if self.config and self._loaded_sig == current_sig:
|
||||||
return self.config
|
return _private_copy(self.config)
|
||||||
|
|
||||||
# Check if config file exists, if not create from template
|
# Check if config file exists, if not create from template
|
||||||
if not os.path.exists(self.config_path):
|
if not os.path.exists(self.config_path):
|
||||||
@@ -249,8 +281,8 @@ class ConfigManager:
|
|||||||
# Signature taken AFTER load + migration (migration may write the
|
# Signature taken AFTER load + migration (migration may write the
|
||||||
# config back), so it reflects exactly what was read/written.
|
# config back), so it reflects exactly what was read/written.
|
||||||
self._loaded_sig = self._files_signature()
|
self._loaded_sig = self._files_signature()
|
||||||
return self.config
|
return _private_copy(self.config)
|
||||||
|
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
# Only config.json can get here: a missing or unreadable secrets
|
# Only config.json can get here: a missing or unreadable secrets
|
||||||
# file is handled where it is read.
|
# file is handled where it is read.
|
||||||
@@ -355,8 +387,9 @@ class ConfigManager:
|
|||||||
try:
|
try:
|
||||||
atomic_write_json(self.config_path, config_to_write)
|
atomic_write_json(self.config_path, config_to_write)
|
||||||
|
|
||||||
# Update the in-memory config to the new state (which includes secrets for runtime)
|
# Update the in-memory config to the new state (which includes
|
||||||
self.config = new_config_data
|
# secrets for runtime), as a copy -- see _private_copy
|
||||||
|
self.config = _private_copy(new_config_data)
|
||||||
self._loaded_sig = self._files_signature()
|
self._loaded_sig = self._files_signature()
|
||||||
self.logger.info(f"Configuration successfully saved to {os.path.abspath(self.config_path)}")
|
self.logger.info(f"Configuration successfully saved to {os.path.abspath(self.config_path)}")
|
||||||
if secrets_content:
|
if secrets_content:
|
||||||
|
|||||||
+92
-42
@@ -14,7 +14,7 @@ import json
|
|||||||
import time
|
import time
|
||||||
import threading
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Any, Optional, List, Callable
|
from typing import Dict, Any, Optional, List, Callable, Tuple
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
import logging
|
import logging
|
||||||
import hashlib
|
import hashlib
|
||||||
@@ -52,7 +52,18 @@ class ConfigService:
|
|||||||
|
|
||||||
# Thread safety
|
# Thread safety
|
||||||
self._lock: threading.RLock = threading.RLock()
|
self._lock: threading.RLock = threading.RLock()
|
||||||
|
# Held across a whole reload -- read, swap, notify -- so one reload's
|
||||||
|
# notifications finish before the next one's start. Subscribers run
|
||||||
|
# under this lock and never under _lock: the display's per-plugin
|
||||||
|
# subscriber can wait seconds for a busy plugin, and get_config(),
|
||||||
|
# subscribe() and unsubscribe() -- called from the render thread --
|
||||||
|
# must not wait behind it.
|
||||||
|
self._notify_lock: threading.RLock = threading.RLock()
|
||||||
|
# (key, callback, thread id) of the callback a notification is running,
|
||||||
|
# so unsubscribe() can wait for that one call; signalled on its return.
|
||||||
|
self._running_callback: Optional[Tuple[str, Callable[..., None], int]] = None
|
||||||
|
self._callback_done = threading.Condition(self._lock)
|
||||||
|
|
||||||
# Current configuration
|
# Current configuration
|
||||||
self._current_config: Dict[str, Any] = {}
|
self._current_config: Dict[str, Any] = {}
|
||||||
self._current_checksum: Optional[str] = None
|
self._current_checksum: Optional[str] = None
|
||||||
@@ -87,32 +98,33 @@ class ConfigService:
|
|||||||
True if config changed, False otherwise
|
True if config changed, False otherwise
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
new_config = self.config_manager.load_config()
|
with self._notify_lock:
|
||||||
new_checksum = self._calculate_checksum(new_config)
|
new_config = self.config_manager.load_config()
|
||||||
|
new_checksum = self._calculate_checksum(new_config)
|
||||||
with self._lock:
|
|
||||||
# Check if config actually changed
|
with self._lock:
|
||||||
if new_checksum == self._current_checksum:
|
# Check if config actually changed
|
||||||
self.logger.debug("Configuration unchanged, skipping reload")
|
if new_checksum == self._current_checksum:
|
||||||
return False
|
self.logger.debug("Configuration unchanged, skipping reload")
|
||||||
|
return False
|
||||||
# Store old config for change detection
|
|
||||||
old_config = self._current_config.copy()
|
# Store old config for change detection
|
||||||
|
old_config = self._current_config.copy()
|
||||||
# Update current config
|
|
||||||
self._current_config = new_config
|
# Update current config
|
||||||
self._current_checksum = new_checksum
|
self._current_config = new_config
|
||||||
|
self._current_checksum = new_checksum
|
||||||
# Notify subscribers
|
|
||||||
|
# Notify subscribers, outside _lock (see _notify_lock)
|
||||||
self._notify_subscribers(old_config, new_config)
|
self._notify_subscribers(old_config, new_config)
|
||||||
|
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
"Configuration reloaded (checksum: %s)",
|
"Configuration reloaded (checksum: %s)",
|
||||||
new_checksum[:8]
|
new_checksum[:8]
|
||||||
)
|
)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except ConfigError as e:
|
except ConfigError as e:
|
||||||
self.logger.error("Error loading configuration: %s", e, exc_info=True)
|
self.logger.error("Error loading configuration: %s", e, exc_info=True)
|
||||||
return False
|
return False
|
||||||
@@ -127,35 +139,64 @@ class ConfigService:
|
|||||||
Args:
|
Args:
|
||||||
old_config: Previous configuration
|
old_config: Previous configuration
|
||||||
new_config: New configuration
|
new_config: New configuration
|
||||||
|
|
||||||
|
Called without _lock held. The subscriber lists are copied under it,
|
||||||
|
and each callback is checked against them again just before it runs.
|
||||||
"""
|
"""
|
||||||
|
with self._lock:
|
||||||
|
subscribers = {key: list(callbacks) for key, callbacks in self._subscribers.items()}
|
||||||
|
|
||||||
# Notify global subscribers (key: '*')
|
# Notify global subscribers (key: '*')
|
||||||
for callback in self._subscribers.get('*', []):
|
for callback in subscribers.get('*', []):
|
||||||
try:
|
self._call_subscriber('*', callback, old_config, new_config)
|
||||||
callback(old_config, new_config)
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error("Error in global config change callback: %s", e, exc_info=True)
|
|
||||||
|
|
||||||
# Notify plugin-specific subscribers
|
# Notify plugin-specific subscribers
|
||||||
for plugin_id in self._subscribers.keys():
|
for plugin_id, callbacks in subscribers.items():
|
||||||
if plugin_id == '*':
|
if plugin_id == '*':
|
||||||
continue
|
continue
|
||||||
|
|
||||||
old_plugin_config = old_config.get(plugin_id, {})
|
old_plugin_config = old_config.get(plugin_id, {})
|
||||||
new_plugin_config = new_config.get(plugin_id, {})
|
new_plugin_config = new_config.get(plugin_id, {})
|
||||||
|
|
||||||
# Only notify if plugin config actually changed
|
# Only notify if plugin config actually changed
|
||||||
if old_plugin_config != new_plugin_config:
|
if old_plugin_config != new_plugin_config:
|
||||||
for callback in self._subscribers[plugin_id]:
|
for callback in callbacks:
|
||||||
try:
|
self._call_subscriber(plugin_id, callback,
|
||||||
callback(old_plugin_config, new_plugin_config)
|
old_plugin_config, new_plugin_config)
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(
|
def _call_subscriber(
|
||||||
"Error in config change callback for %s: %s",
|
self,
|
||||||
plugin_id,
|
key: str,
|
||||||
e,
|
callback: Callable[[Dict[str, Any], Dict[str, Any]], None],
|
||||||
exc_info=True
|
old_config: Dict[str, Any],
|
||||||
)
|
new_config: Dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""Run one callback, unless it was unsubscribed since the snapshot.
|
||||||
|
|
||||||
|
unsubscribe() promises that once it returns the callback is neither
|
||||||
|
running nor will run: the display unloads the plugin straight after.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
if callback not in self._subscribers.get(key, ()):
|
||||||
|
return
|
||||||
|
self._running_callback = (key, callback, threading.get_ident())
|
||||||
|
try:
|
||||||
|
callback(old_config, new_config)
|
||||||
|
except Exception as e:
|
||||||
|
if key == '*':
|
||||||
|
self.logger.error("Error in global config change callback: %s", e, exc_info=True)
|
||||||
|
else:
|
||||||
|
self.logger.error(
|
||||||
|
"Error in config change callback for %s: %s",
|
||||||
|
key,
|
||||||
|
e,
|
||||||
|
exc_info=True
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
with self._lock:
|
||||||
|
self._running_callback = None
|
||||||
|
self._callback_done.notify_all()
|
||||||
|
|
||||||
def _check_file_changes(self) -> bool:
|
def _check_file_changes(self) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if configuration files have been modified.
|
Check if configuration files have been modified.
|
||||||
@@ -276,6 +317,11 @@ class ConfigService:
|
|||||||
"""
|
"""
|
||||||
Unsubscribe from configuration changes.
|
Unsubscribe from configuration changes.
|
||||||
|
|
||||||
|
Once this returns the callback is not running and will not be called
|
||||||
|
again. A notification that is running this very callback is waited
|
||||||
|
for (unless the callback is the caller); one running any other
|
||||||
|
callback is not.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
callback: Callback function to remove
|
callback: Callback function to remove
|
||||||
plugin_id: Optional plugin ID (must match subscription)
|
plugin_id: Optional plugin ID (must match subscription)
|
||||||
@@ -285,6 +331,10 @@ class ConfigService:
|
|||||||
if callback in self._subscribers[key]:
|
if callback in self._subscribers[key]:
|
||||||
self._subscribers[key].remove(callback)
|
self._subscribers[key].remove(callback)
|
||||||
self.logger.debug("Unsubscribed from config changes for %s", key)
|
self.logger.debug("Unsubscribed from config changes for %s", key)
|
||||||
|
while (self._running_callback is not None
|
||||||
|
and self._running_callback[:2] == (key, callback)
|
||||||
|
and self._running_callback[2] != threading.get_ident()):
|
||||||
|
self._callback_done.wait()
|
||||||
|
|
||||||
def shutdown(self) -> None:
|
def shutdown(self) -> None:
|
||||||
"""Shutdown the configuration service."""
|
"""Shutdown the configuration service."""
|
||||||
|
|||||||
@@ -25,11 +25,12 @@ import os
|
|||||||
import inspect
|
import inspect
|
||||||
import signal
|
import signal
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
import threading
|
import threading
|
||||||
import types
|
import types
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from typing import Dict, Any, List, Optional, Callable, Set, Tuple
|
from typing import Dict, Any, FrozenSet, List, Optional, Callable, Set, Tuple
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed # pylint: disable=no-name-in-module
|
from concurrent.futures import ThreadPoolExecutor, as_completed # pylint: disable=no-name-in-module
|
||||||
import pytz
|
import pytz
|
||||||
@@ -89,6 +90,19 @@ _MIN_INITIAL_UPDATE_TIMEOUT_SECONDS = 2.0
|
|||||||
DEFAULT_DYNAMIC_DURATION_CAP = 180.0
|
DEFAULT_DYNAMIC_DURATION_CAP = 180.0
|
||||||
|
|
||||||
|
|
||||||
|
def _finite_seconds(value: Any) -> Optional[float]:
|
||||||
|
"""``value`` as seconds when it is a finite number or a numeric string,
|
||||||
|
else None. A bool is not a number here, though it is an int: True would
|
||||||
|
read as a one-second screen."""
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
seconds = float(value)
|
||||||
|
except (TypeError, ValueError, OverflowError):
|
||||||
|
return None
|
||||||
|
return seconds if math.isfinite(seconds) else None
|
||||||
|
|
||||||
|
|
||||||
class _PluginReloadJob:
|
class _PluginReloadJob:
|
||||||
"""A ``plugin.reload`` whose slow half runs off the render thread.
|
"""A ``plugin.reload`` whose slow half runs off the render thread.
|
||||||
|
|
||||||
@@ -1340,6 +1354,12 @@ class DisplayController:
|
|||||||
"until one does", self.EMPTY_ROTATION_PAUSE)
|
"until one does", self.EMPTY_ROTATION_PAUSE)
|
||||||
self._sleep_with_plugin_updates(self.EMPTY_ROTATION_PAUSE)
|
self._sleep_with_plugin_updates(self.EMPTY_ROTATION_PAUSE)
|
||||||
|
|
||||||
|
#: Plugins already warned about a display duration that is not a number,
|
||||||
|
#: so a bad setting logs once, not at every one of its screens. A
|
||||||
|
#: frozenset, replaced rather than mutated; class-level default for
|
||||||
|
#: controllers built without __init__ (tests).
|
||||||
|
_duration_warned: FrozenSet[str] = frozenset()
|
||||||
|
|
||||||
def _get_display_duration(self, mode_key):
|
def _get_display_duration(self, mode_key):
|
||||||
"""Seconds to show a mode: the Rotation & Durations page's value for it
|
"""Seconds to show a mode: the Rotation & Durations page's value for it
|
||||||
(display.display_durations), else the plugin's own duration.
|
(display.display_durations), else the plugin's own duration.
|
||||||
@@ -1347,6 +1367,17 @@ class DisplayController:
|
|||||||
The saved value has to win. Every plugin inherits
|
The saved value has to win. Every plugin inherits
|
||||||
get_display_duration(), so checking the plugin first meant the page's
|
get_display_duration(), so checking the plugin first meant the page's
|
||||||
values were never read.
|
values were never read.
|
||||||
|
|
||||||
|
The plugin's answer is checked here, not trusted. Several plugins
|
||||||
|
return their display_duration setting straight from config.json, so
|
||||||
|
one saved as "20" or null (the raw config editor, a hand edit) came
|
||||||
|
back as a string or None; _resolve_durations compared it with 0, and
|
||||||
|
the TypeError went past every handler in the loop and stopped the
|
||||||
|
display service, which systemd restarted into the same screen. A
|
||||||
|
numeric string counts, as in BasePlugin.get_display_duration; any
|
||||||
|
other value that is not a finite number, or a raise, gets the 30 s a
|
||||||
|
mode without a plugin gets. A number at or below zero is passed on:
|
||||||
|
_resolve_durations has its own rule for that.
|
||||||
"""
|
"""
|
||||||
display_durations = self.config.get('display', {}).get('display_durations', {}) or {}
|
display_durations = self.config.get('display', {}).get('display_durations', {}) or {}
|
||||||
override = display_durations.get(mode_key)
|
override = display_durations.get(mode_key)
|
||||||
@@ -1354,8 +1385,22 @@ class DisplayController:
|
|||||||
return float(override)
|
return float(override)
|
||||||
|
|
||||||
plugin_instance = self.plugin_modes.get(mode_key)
|
plugin_instance = self.plugin_modes.get(mode_key)
|
||||||
if plugin_instance is not None and hasattr(plugin_instance, 'get_display_duration'):
|
if plugin_instance is None or not hasattr(plugin_instance, 'get_display_duration'):
|
||||||
return plugin_instance.get_display_duration()
|
return 30
|
||||||
|
try:
|
||||||
|
value = plugin_instance.get_display_duration()
|
||||||
|
except Exception as err: # pylint: disable=broad-except
|
||||||
|
problem = f"get_display_duration() raised {type(err).__name__}: {err}"
|
||||||
|
else:
|
||||||
|
seconds = _finite_seconds(value)
|
||||||
|
if seconds is not None:
|
||||||
|
return seconds
|
||||||
|
problem = f"display duration {value!r} is not a number"
|
||||||
|
plugin_id = getattr(plugin_instance, 'plugin_id', None) or mode_key
|
||||||
|
if plugin_id not in self._duration_warned:
|
||||||
|
self._duration_warned = self._duration_warned | {plugin_id}
|
||||||
|
logger.warning("Plugin %s: %s; showing its modes for 30s (logged once)",
|
||||||
|
plugin_id, problem)
|
||||||
return 30
|
return 30
|
||||||
|
|
||||||
def _get_global_dynamic_cap(self) -> Optional[float]:
|
def _get_global_dynamic_cap(self) -> Optional[float]:
|
||||||
|
|||||||
+36
-3
@@ -283,6 +283,29 @@ def snapshot_loop_age(snapshot: Mapping[str, Any],
|
|||||||
return max(float(age), 0.0) + snapshot_age(snapshot, now_mono)
|
return max(float(age), 0.0) + snapshot_age(snapshot, now_mono)
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_volatile(state: Dict[str, Any], volatile: Any) -> None:
|
||||||
|
"""Fold a tick's ``volatile`` values (``{section: {key: value}}``) into
|
||||||
|
``state``, copying each section it touches.
|
||||||
|
|
||||||
|
These are the timestamps the hub leaves out of its version --
|
||||||
|
``display.last_updated``, ``on_demand.last_updated``/``remaining``,
|
||||||
|
``plugins.published_at`` -- and the readers judge freshness by them, so
|
||||||
|
a copy that only full ``state`` events updated would go stale while the
|
||||||
|
same mode stayed on screen. Only keys the section already has are taken:
|
||||||
|
a tick never adds a section or a key the last snapshot did not carry
|
||||||
|
(a section left out of a truncated snapshot stays out).
|
||||||
|
"""
|
||||||
|
if not isinstance(volatile, dict):
|
||||||
|
return # a display from before ticks carried them
|
||||||
|
for name, values in volatile.items():
|
||||||
|
section = state.get(name)
|
||||||
|
if not isinstance(section, dict) or not isinstance(values, dict):
|
||||||
|
continue
|
||||||
|
fresh = {k: v for k, v in values.items() if k in section}
|
||||||
|
if fresh:
|
||||||
|
state[name] = dict(section, **fresh)
|
||||||
|
|
||||||
|
|
||||||
#: A subscription that has heard nothing for this long is not trusted: the
|
#: A subscription that has heard nothing for this long is not trusted: the
|
||||||
#: display sends a tick at least every SUBSCRIBE_KEEPALIVE_SECONDS.
|
#: display sends a tick at least every SUBSCRIBE_KEEPALIVE_SECONDS.
|
||||||
SUBSCRIPTION_SILENCE_SECONDS = 3 * SUBSCRIBE_KEEPALIVE_SECONDS
|
SUBSCRIPTION_SILENCE_SECONDS = 3 * SUBSCRIBE_KEEPALIVE_SECONDS
|
||||||
@@ -373,9 +396,9 @@ class StateSubscription:
|
|||||||
def _run(self) -> None:
|
def _run(self) -> None:
|
||||||
backoff = _RECONNECT_MIN_SECONDS
|
backoff = _RECONNECT_MIN_SECONDS
|
||||||
while not self._stop.is_set():
|
while not self._stop.is_set():
|
||||||
|
snapshots = self.snapshots
|
||||||
try:
|
try:
|
||||||
self._follow()
|
self._follow()
|
||||||
backoff = _RECONNECT_MIN_SECONDS
|
|
||||||
except ControlError as e:
|
except ControlError as e:
|
||||||
self.last_error = e.reason
|
self.last_error = e.reason
|
||||||
if e.reason in _SLOW_RETRY_REASONS:
|
if e.reason in _SLOW_RETRY_REASONS:
|
||||||
@@ -391,6 +414,11 @@ class StateSubscription:
|
|||||||
sock.close()
|
sock.close()
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
if self.snapshots != snapshots:
|
||||||
|
# This connection got as far as the display's state: whatever
|
||||||
|
# ended it (a restart, most often), it was working, so the
|
||||||
|
# next try starts from the shortest wait again.
|
||||||
|
backoff = _RECONNECT_MIN_SECONDS
|
||||||
if self._stop.wait(backoff):
|
if self._stop.wait(backoff):
|
||||||
return
|
return
|
||||||
backoff = min(backoff * 2, _RECONNECT_MAX_SECONDS)
|
backoff = min(backoff * 2, _RECONNECT_MAX_SECONDS)
|
||||||
@@ -449,12 +477,17 @@ class StateSubscription:
|
|||||||
self.snapshots += 1
|
self.snapshots += 1
|
||||||
elif (self._snapshot is not None
|
elif (self._snapshot is not None
|
||||||
and result.get('epoch') == self._snapshot.get('epoch')):
|
and result.get('epoch') == self._snapshot.get('epoch')):
|
||||||
# A tick: nothing changed but the render loop's liveness.
|
# A tick: nothing changed but the render loop's liveness and
|
||||||
|
# the volatile keys (timestamps) the writers keep refreshing.
|
||||||
snap = dict(self._snapshot)
|
snap = dict(self._snapshot)
|
||||||
|
state = dict(snap.get('state') or {})
|
||||||
|
if result.get('version') == snap.get('version'):
|
||||||
|
_merge_volatile(state, result.get('volatile'))
|
||||||
loop = result.get('loop')
|
loop = result.get('loop')
|
||||||
if isinstance(loop, dict):
|
if isinstance(loop, dict):
|
||||||
snap['state'] = dict(snap.get('state') or {}, loop=loop)
|
state['loop'] = loop
|
||||||
snap['loop'] = loop
|
snap['loop'] = loop
|
||||||
|
snap['state'] = state
|
||||||
snap['served_at'] = result.get('served_at', snap.get('served_at'))
|
snap['served_at'] = result.get('served_at', snap.get('served_at'))
|
||||||
self._snapshot = snap
|
self._snapshot = snap
|
||||||
else:
|
else:
|
||||||
|
|||||||
+14
-7
@@ -191,7 +191,7 @@ MAX_SUBSCRIBERS = 4
|
|||||||
|
|
||||||
#: A subscriber hears from the display at least this often: a ``state``
|
#: A subscriber hears from the display at least this often: a ``state``
|
||||||
#: event when something changed, else a ``tick`` carrying the render loop's
|
#: event when something changed, else a ``tick`` carrying the render loop's
|
||||||
#: liveness. A client that has heard nothing for a few of these treats its
|
#: liveness and the latest volatile timestamps. A client that has heard nothing for a few of these treats its
|
||||||
#: copy as unknown.
|
#: copy as unknown.
|
||||||
SUBSCRIBE_KEEPALIVE_SECONDS = 5.0
|
SUBSCRIBE_KEEPALIVE_SECONDS = 5.0
|
||||||
|
|
||||||
@@ -530,8 +530,10 @@ class StateGetArgs:
|
|||||||
"""``state.get``: the display's state, as a versioned snapshot.
|
"""``state.get``: the display's state, as a versioned snapshot.
|
||||||
|
|
||||||
With ``since`` and the ``epoch`` it came from, the answer is only
|
With ``since`` and the ``epoch`` it came from, the answer is only
|
||||||
``{changed: false, version, epoch, served_at, loop}`` while the state is
|
``{changed: false, version, epoch, served_at, loop, volatile}`` while the
|
||||||
still at that version, so a poller that already has it is sent no state.
|
state is still at that version, so a poller that already has it is sent
|
||||||
|
no state -- only the latest values of the keys that do not count as a
|
||||||
|
change (``volatile``, see :class:`StateSnapshot`).
|
||||||
"""
|
"""
|
||||||
since: Optional[int] = None
|
since: Optional[int] = None
|
||||||
epoch: Optional[str] = None
|
epoch: Optional[str] = None
|
||||||
@@ -672,9 +674,13 @@ class StateSnapshot(TypedDict, total=False):
|
|||||||
``version`` counts changes to the state within one ``epoch`` (one run of
|
``version`` counts changes to the state within one ``epoch`` (one run of
|
||||||
the display process): a reader that sees a new epoch starts over.
|
the display process): a reader that sees a new epoch starts over.
|
||||||
``changed`` is False only for a ``state.get`` whose ``since`` is still
|
``changed`` is False only for a ``state.get`` whose ``since`` is still
|
||||||
current, and then ``state`` is absent. ``served_at`` is the display's
|
current, and then ``state`` is absent and ``volatile`` is there instead:
|
||||||
wall clock when it answered. ``loop`` is measured at that moment, so it
|
``{section: {key: value}}``, the current values of the keys the version
|
||||||
is also inside ``state``.
|
ignores (``display.last_updated``, ``on_demand.last_updated`` and
|
||||||
|
``remaining``, ``plugins.published_at``). A reader merges them into the
|
||||||
|
copy it has; they are how it can tell the writers are still publishing.
|
||||||
|
``served_at`` is the display's wall clock when it answered. ``loop`` is
|
||||||
|
measured at that moment, so it is also inside ``state``.
|
||||||
|
|
||||||
``state`` holds the sections in :data:`STATE_SECTIONS`:
|
``state`` holds the sections in :data:`STATE_SECTIONS`:
|
||||||
|
|
||||||
@@ -696,12 +702,13 @@ class StateSnapshot(TypedDict, total=False):
|
|||||||
served_at: float
|
served_at: float
|
||||||
changed: bool
|
changed: bool
|
||||||
state: Dict[str, Any]
|
state: Dict[str, Any]
|
||||||
|
volatile: Dict[str, Dict[str, Any]]
|
||||||
loop: LoopState
|
loop: LoopState
|
||||||
|
|
||||||
|
|
||||||
class StateEventKind:
|
class StateEventKind:
|
||||||
STATE = 'state' # result: a full StateSnapshot, the latest version
|
STATE = 'state' # result: a full StateSnapshot, the latest version
|
||||||
TICK = 'tick' # result: {version, epoch, pid, served_at, loop}; nothing changed
|
TICK = 'tick' # result: {version, epoch, pid, served_at, loop, volatile}; nothing changed
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|||||||
+30
-3
@@ -222,6 +222,22 @@ def _fingerprint(value: Optional[Mapping[str, Any]], volatile: Iterable[str]) ->
|
|||||||
return {k: v for k, v in value.items() if k not in skip} if skip else dict(value)
|
return {k: v for k, v in value.items() if k not in skip} if skip else dict(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _volatile_values(sections: Mapping[str, Optional[Dict[str, Any]]],
|
||||||
|
volatile: Mapping[str, FrozenSet[str]]) -> Dict[str, Dict[str, Any]]:
|
||||||
|
"""``{section: {key: value}}``: the volatile keys each published section
|
||||||
|
has now. The sections are never mutated after publish (a publish swaps
|
||||||
|
in a new dict), so reading them outside the lock is safe."""
|
||||||
|
values: Dict[str, Dict[str, Any]] = {}
|
||||||
|
for name, keys in volatile.items():
|
||||||
|
value = sections.get(name)
|
||||||
|
if not keys or not isinstance(value, dict):
|
||||||
|
continue
|
||||||
|
present = {k: value[k] for k in keys if k in value}
|
||||||
|
if present:
|
||||||
|
values[name] = present
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
def _unknown_loop() -> Dict[str, Any]:
|
def _unknown_loop() -> Dict[str, Any]:
|
||||||
return {'heartbeat_age_seconds': None, 'armed': False, 'stale_after': None}
|
return {'heartbeat_age_seconds': None, 'armed': False, 'stale_after': None}
|
||||||
|
|
||||||
@@ -259,7 +275,11 @@ class StateHub:
|
|||||||
(``loop_probe``), so it keeps ageing while the render thread is stuck.
|
(``loop_probe``), so it keeps ageing while the render thread is stuck.
|
||||||
|
|
||||||
The version goes up when a section's value changes, ignoring the keys
|
The version goes up when a section's value changes, ignoring the keys
|
||||||
the publisher names as volatile (timestamps). Publishing never blocks on
|
the publisher names as volatile (timestamps). Those keys still carry
|
||||||
|
news -- ``display.last_updated`` is the render thread's proof of life --
|
||||||
|
so the short ``changed: false`` answer, which is what a subscriber's
|
||||||
|
tick carries, has their current values in ``volatile``; a reader merges
|
||||||
|
them into its copy. Publishing never blocks on
|
||||||
a reader: the lock is held only to swap a dict reference and compare it,
|
a reader: the lock is held only to swap a dict reference and compare it,
|
||||||
and every socket write happens on the reader's own thread, outside it.
|
and every socket write happens on the reader's own thread, outside it.
|
||||||
A reader that is slow gets the latest version when it next asks, not
|
A reader that is slow gets the latest version when it next asks, not
|
||||||
@@ -274,6 +294,7 @@ class StateHub:
|
|||||||
self._cond = threading.Condition(threading.Lock())
|
self._cond = threading.Condition(threading.Lock())
|
||||||
self._sections: Dict[str, Optional[Dict[str, Any]]] = {}
|
self._sections: Dict[str, Optional[Dict[str, Any]]] = {}
|
||||||
self._fingerprints: Dict[str, Any] = {}
|
self._fingerprints: Dict[str, Any] = {}
|
||||||
|
self._volatile: Dict[str, FrozenSet[str]] = {}
|
||||||
self._version = 0
|
self._version = 0
|
||||||
self.epoch = epoch or uuid.uuid4().hex[:16]
|
self.epoch = epoch or uuid.uuid4().hex[:16]
|
||||||
self.pid = os.getpid() if pid is None else pid
|
self.pid = os.getpid() if pid is None else pid
|
||||||
@@ -301,9 +322,11 @@ class StateHub:
|
|||||||
The value is copied (one level), so the caller may reuse its dict.
|
The value is copied (one level), so the caller may reuse its dict.
|
||||||
"""
|
"""
|
||||||
stored = None if value is None else dict(value)
|
stored = None if value is None else dict(value)
|
||||||
fingerprint = _fingerprint(stored, volatile)
|
skip = frozenset(volatile)
|
||||||
|
fingerprint = _fingerprint(stored, skip)
|
||||||
with self._cond:
|
with self._cond:
|
||||||
self._sections[section] = stored
|
self._sections[section] = stored
|
||||||
|
self._volatile[section] = skip
|
||||||
if self._fingerprints.get(section, _MISSING) == fingerprint:
|
if self._fingerprints.get(section, _MISSING) == fingerprint:
|
||||||
return False
|
return False
|
||||||
self._fingerprints[section] = fingerprint
|
self._fingerprints[section] = fingerprint
|
||||||
@@ -333,11 +356,14 @@ class StateHub:
|
|||||||
"""The :class:`~src.ipc.contract.StateSnapshot` now.
|
"""The :class:`~src.ipc.contract.StateSnapshot` now.
|
||||||
|
|
||||||
``since`` with this hub's ``epoch``, still the current version, gives
|
``since`` with this hub's ``epoch``, still the current version, gives
|
||||||
the short ``changed: false`` form.
|
the short ``changed: false`` form, with ``volatile``: each section's
|
||||||
|
volatile keys at their latest values (the rest of the section is
|
||||||
|
what the reader already has).
|
||||||
"""
|
"""
|
||||||
with self._cond:
|
with self._cond:
|
||||||
version = self._version
|
version = self._version
|
||||||
sections = dict(self._sections)
|
sections = dict(self._sections)
|
||||||
|
volatile = dict(self._volatile)
|
||||||
loop = self.loop()
|
loop = self.loop()
|
||||||
result: Dict[str, Any] = {
|
result: Dict[str, Any] = {
|
||||||
'schema': STATE_SCHEMA,
|
'schema': STATE_SCHEMA,
|
||||||
@@ -349,6 +375,7 @@ class StateHub:
|
|||||||
}
|
}
|
||||||
if since is not None and epoch == self.epoch and since == version:
|
if since is not None and epoch == self.epoch and since == version:
|
||||||
result['changed'] = False
|
result['changed'] = False
|
||||||
|
result['volatile'] = _volatile_values(sections, volatile)
|
||||||
return result
|
return result
|
||||||
state: Dict[str, Any] = {name: sections.get(name) for name in STATE_SECTIONS
|
state: Dict[str, Any] = {name: sections.get(name) for name in STATE_SECTIONS
|
||||||
if name != 'loop'}
|
if name != 'loop'}
|
||||||
|
|||||||
@@ -48,12 +48,19 @@ class PluginOperation:
|
|||||||
completed_at: Optional[datetime] = None
|
completed_at: Optional[datetime] = None
|
||||||
|
|
||||||
def to_dict(self) -> Dict[str, Any]:
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
"""Convert operation to dictionary for serialization."""
|
"""Convert operation to dictionary for serialization.
|
||||||
|
|
||||||
|
Parameters whose name starts with ``_`` are internal and left out:
|
||||||
|
PluginOperationQueue keeps the operation's callback there as
|
||||||
|
``_callback`` until its worker runs it, and a pending operation's
|
||||||
|
status answered 500 because that function cannot be serialized.
|
||||||
|
"""
|
||||||
return {
|
return {
|
||||||
'operation_id': self.operation_id,
|
'operation_id': self.operation_id,
|
||||||
'operation_type': self.operation_type.value,
|
'operation_type': self.operation_type.value,
|
||||||
'plugin_id': self.plugin_id,
|
'plugin_id': self.plugin_id,
|
||||||
'parameters': self.parameters,
|
'parameters': {key: value for key, value in self.parameters.items()
|
||||||
|
if not str(key).startswith('_')},
|
||||||
'status': self.status.value,
|
'status': self.status.value,
|
||||||
'progress': self.progress,
|
'progress': self.progress,
|
||||||
'message': self.message,
|
'message': self.message,
|
||||||
|
|||||||
@@ -92,7 +92,10 @@ class PluginExecutor:
|
|||||||
with plugin_scope(plugin_id):
|
with plugin_scope(plugin_id):
|
||||||
result_container['value'] = operation()
|
result_container['value'] = operation()
|
||||||
result_container['completed'] = True
|
result_container['completed'] = True
|
||||||
except Exception as e:
|
except BaseException as e: # pylint: disable=broad-except
|
||||||
|
# asyncio.CancelledError and SystemExit too: uncaught, one
|
||||||
|
# ended this thread with 'completed' unset, and an operation
|
||||||
|
# that failed at once was reported as timing out.
|
||||||
result_container['exception'] = e
|
result_container['exception'] = e
|
||||||
result_container['completed'] = True
|
result_container['completed'] = True
|
||||||
|
|
||||||
|
|||||||
@@ -199,9 +199,22 @@ def contained_plugin_dir(plugin_dir: Path, plugins_dir: Path) -> Optional[str]:
|
|||||||
name that came out of ``os.scandir()`` on the trusted root carries no
|
name that came out of ``os.scandir()`` on the trusted root carries no
|
||||||
taint, which is a real containment guarantee (and one CodeQL's
|
taint, which is a real containment guarantee (and one CodeQL's
|
||||||
path-injection query can follow), not a string sanitiser.
|
path-injection query can follow), not a string sanitiser.
|
||||||
|
|
||||||
|
The entry looked for is the one ``plugin_dir`` itself names when it sits
|
||||||
|
directly in ``plugins_dir``: for a dev plugin symlinked in under its id,
|
||||||
|
the link's name. Resolving the link first and looking for the target's
|
||||||
|
folder name refused ``plugins/foo -> ~/.ledmatrix-dev-plugins/ledmatrix-foo``
|
||||||
|
(what ``dev_plugin_setup.sh link-github foo <url>`` makes), so the plugin
|
||||||
|
never loaded. Any other path is resolved and matched by its final name,
|
||||||
|
as before.
|
||||||
"""
|
"""
|
||||||
plugin_dir_real = os.path.realpath(str(plugin_dir))
|
|
||||||
plugins_dir_real = os.path.realpath(str(plugins_dir))
|
plugins_dir_real = os.path.realpath(str(plugins_dir))
|
||||||
|
plugin_dir_abs = os.path.abspath(str(plugin_dir))
|
||||||
|
if os.path.realpath(os.path.dirname(plugin_dir_abs)) == plugins_dir_real:
|
||||||
|
matched_name = find_trusted_subdir(plugins_dir_real, os.path.basename(plugin_dir_abs))
|
||||||
|
if matched_name is not None:
|
||||||
|
return os.path.join(plugins_dir_real, matched_name)
|
||||||
|
plugin_dir_real = os.path.realpath(str(plugin_dir))
|
||||||
matched_name = find_trusted_subdir(plugins_dir_real, os.path.basename(plugin_dir_real))
|
matched_name = find_trusted_subdir(plugins_dir_real, os.path.basename(plugin_dir_real))
|
||||||
if matched_name is None:
|
if matched_name is None:
|
||||||
return None
|
return None
|
||||||
@@ -243,6 +256,10 @@ class PluginLoader:
|
|||||||
self.logger = logger or get_logger(__name__)
|
self.logger = logger or get_logger(__name__)
|
||||||
self._loaded_modules: Dict[str, Any] = {}
|
self._loaded_modules: Dict[str, Any] = {}
|
||||||
self._plugin_module_registry: Dict[str, set] = {} # Maps plugin_id to set of module names
|
self._plugin_module_registry: Dict[str, set] = {} # Maps plugin_id to set of module names
|
||||||
|
# plugin_id -> {dotted name: module} for the modules of the plugin's
|
||||||
|
# own packages (``providers.feed``). They keep their names while the
|
||||||
|
# plugin runs and are dropped with it; see _iter_plugin_submodules.
|
||||||
|
self._plugin_submodules: Dict[str, Dict[str, Any]] = {}
|
||||||
# Lock to serialize module loading when plugins share module names
|
# Lock to serialize module loading when plugins share module names
|
||||||
# (e.g., scroll_display.py, game_renderer.py across sport plugins).
|
# (e.g., scroll_display.py, game_renderer.py across sport plugins).
|
||||||
# During exec_module, bare-name sub-modules temporarily appear in
|
# During exec_module, bare-name sub-modules temporarily appear in
|
||||||
@@ -449,6 +466,45 @@ class PluginLoader:
|
|||||||
continue
|
continue
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _iter_plugin_submodules(
|
||||||
|
plugin_dir: Path, before_keys: set
|
||||||
|
) -> list:
|
||||||
|
"""Return dotted-name modules from plugin_dir added after before_keys.
|
||||||
|
|
||||||
|
The modules of a package the plugin ships (``providers.feed`` from
|
||||||
|
``providers/feed.py``). _iter_plugin_bare_modules skips them, so the
|
||||||
|
bare ``providers`` was namespaced and dropped on unload while
|
||||||
|
``providers.feed`` stayed in sys.modules: a reload after a store update
|
||||||
|
imported a fresh ``providers`` and then got the old ``feed`` back from
|
||||||
|
the cache, running the new manager.py against the old helpers until the
|
||||||
|
display restarted.
|
||||||
|
|
||||||
|
A module counts when its ``__file__`` -- or, for a namespace package,
|
||||||
|
which has none, every ``__path__`` entry -- is inside plugin_dir, so a
|
||||||
|
library the plugin imports (``requests.adapters``) never does.
|
||||||
|
|
||||||
|
Returns a list of (mod_name, module) tuples.
|
||||||
|
"""
|
||||||
|
resolved_dir = plugin_dir.resolve()
|
||||||
|
result = []
|
||||||
|
for key in set(sys.modules.keys()) - before_keys:
|
||||||
|
if "." not in key:
|
||||||
|
continue
|
||||||
|
mod = sys.modules.get(key)
|
||||||
|
if mod is None:
|
||||||
|
continue
|
||||||
|
mod_file = getattr(mod, "__file__", None)
|
||||||
|
locations = [mod_file] if mod_file else list(getattr(mod, "__path__", None) or [])
|
||||||
|
if not locations:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if all(Path(loc).resolve().is_relative_to(resolved_dir) for loc in locations):
|
||||||
|
result.append((key, mod))
|
||||||
|
except (ValueError, TypeError, OSError):
|
||||||
|
continue
|
||||||
|
return result
|
||||||
|
|
||||||
def _evict_stale_bare_modules(self, plugin_dir: Path) -> dict:
|
def _evict_stale_bare_modules(self, plugin_dir: Path) -> dict:
|
||||||
"""Temporarily remove bare-name sys.modules entries from other plugins.
|
"""Temporarily remove bare-name sys.modules entries from other plugins.
|
||||||
|
|
||||||
@@ -527,6 +583,13 @@ class PluginLoader:
|
|||||||
# Track for cleanup during unload
|
# Track for cleanup during unload
|
||||||
self._plugin_module_registry[plugin_id] = namespaced_names
|
self._plugin_module_registry[plugin_id] = namespaced_names
|
||||||
|
|
||||||
|
# The modules of the plugin's own packages keep their dotted names
|
||||||
|
# while it runs -- as they always have, so the package and its
|
||||||
|
# children stay a matching set in sys.modules -- and are dropped
|
||||||
|
# with the plugin by unregister_plugin_modules().
|
||||||
|
self._plugin_submodules[plugin_id] = dict(
|
||||||
|
self._iter_plugin_submodules(plugin_dir, before_keys))
|
||||||
|
|
||||||
if namespaced_names:
|
if namespaced_names:
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
"Namespace-isolated %d module(s) for plugin %s",
|
"Namespace-isolated %d module(s) for plugin %s",
|
||||||
@@ -537,10 +600,16 @@ class PluginLoader:
|
|||||||
"""Remove namespaced sub-modules and cached module for a plugin from sys.modules.
|
"""Remove namespaced sub-modules and cached module for a plugin from sys.modules.
|
||||||
|
|
||||||
Called by PluginManager during unload to clean up all module entries
|
Called by PluginManager during unload to clean up all module entries
|
||||||
that were created when the plugin was loaded.
|
that were created when the plugin was loaded, including the dotted
|
||||||
|
modules of its packages. A dotted name is dropped only while it still
|
||||||
|
holds this plugin's module: the name is not namespaced, so another
|
||||||
|
plugin may have put its own there since.
|
||||||
"""
|
"""
|
||||||
for ns_name in self._plugin_module_registry.pop(plugin_id, set()):
|
for ns_name in self._plugin_module_registry.pop(plugin_id, set()):
|
||||||
sys.modules.pop(ns_name, None)
|
sys.modules.pop(ns_name, None)
|
||||||
|
for name, mod in self._plugin_submodules.pop(plugin_id, {}).items():
|
||||||
|
if sys.modules.get(name) is mod:
|
||||||
|
sys.modules.pop(name, None)
|
||||||
self._loaded_modules.pop(plugin_id, None)
|
self._loaded_modules.pop(plugin_id, None)
|
||||||
|
|
||||||
def load_module(
|
def load_module(
|
||||||
@@ -646,11 +715,13 @@ class PluginLoader:
|
|||||||
if evicted_name not in sys.modules:
|
if evicted_name not in sys.modules:
|
||||||
sys.modules[evicted_name] = evicted_mod
|
sys.modules[evicted_name] = evicted_mod
|
||||||
# Clean up the partially-initialized main module and any
|
# Clean up the partially-initialized main module and any
|
||||||
# bare-name sub-modules that were added during exec_module
|
# bare-name or package sub-modules that were added during
|
||||||
# so they don't leak into subsequent plugin loads.
|
# exec_module so they don't leak into subsequent plugin loads.
|
||||||
sys.modules.pop(module_name, None)
|
sys.modules.pop(module_name, None)
|
||||||
for key, _ in self._iter_plugin_bare_modules(plugin_dir, before_keys):
|
for key, _ in self._iter_plugin_bare_modules(plugin_dir, before_keys):
|
||||||
sys.modules.pop(key, None)
|
sys.modules.pop(key, None)
|
||||||
|
for key, _ in self._iter_plugin_submodules(plugin_dir, before_keys):
|
||||||
|
sys.modules.pop(key, None)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
self._loaded_modules[plugin_id] = module
|
self._loaded_modules[plugin_id] = module
|
||||||
|
|||||||
@@ -1163,7 +1163,7 @@ class PluginManager:
|
|||||||
def _record_update_failure(
|
def _record_update_failure(
|
||||||
self,
|
self,
|
||||||
plugin_id: str,
|
plugin_id: str,
|
||||||
exc: Optional[Exception] = None,
|
exc: Optional[BaseException] = None,
|
||||||
log: bool = True,
|
log: bool = True,
|
||||||
count_failure: bool = True,
|
count_failure: bool = True,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -1187,7 +1187,7 @@ class PluginManager:
|
|||||||
"""
|
"""
|
||||||
failure_time = time.time()
|
failure_time = time.time()
|
||||||
if exc is not None:
|
if exc is not None:
|
||||||
err: Exception = exc
|
err: BaseException = exc
|
||||||
error_type = type(exc).__name__
|
error_type = type(exc).__name__
|
||||||
else:
|
else:
|
||||||
err = Exception(f"Plugin {plugin_id} execution failed (timeout or executor error)")
|
err = Exception(f"Plugin {plugin_id} execution failed (timeout or executor error)")
|
||||||
@@ -1653,7 +1653,7 @@ class PluginManager:
|
|||||||
finish_guard = threading.Lock()
|
finish_guard = threading.Lock()
|
||||||
finished = {'done': False}
|
finished = {'done': False}
|
||||||
|
|
||||||
def _finish(success: bool, exc: Optional[Exception] = None) -> None:
|
def _finish(success: bool, exc: Optional[BaseException] = None) -> None:
|
||||||
with finish_guard:
|
with finish_guard:
|
||||||
if finished['done']:
|
if finished['done']:
|
||||||
return
|
return
|
||||||
@@ -1727,7 +1727,13 @@ class PluginManager:
|
|||||||
self.resource_monitor.monitor_call(plugin_id, plugin_instance.update)
|
self.resource_monitor.monitor_call(plugin_id, plugin_instance.update)
|
||||||
else:
|
else:
|
||||||
plugin_instance.update()
|
plugin_instance.update()
|
||||||
except Exception as exc:
|
except BaseException as exc: # pylint: disable=broad-except
|
||||||
|
# BaseException, not just Exception: asyncio.CancelledError
|
||||||
|
# and SystemExit derive from it. Either one skipped _finish,
|
||||||
|
# so the plugin kept its lock and stayed RUNNING for good --
|
||||||
|
# never rescheduled, and every display() skipped as busy.
|
||||||
|
# Re-raised for the executor, which reports it as this
|
||||||
|
# update's failure.
|
||||||
_finish(False, exc=exc)
|
_finish(False, exc=exc)
|
||||||
raise
|
raise
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -344,12 +344,26 @@ class PluginStoreManager(_RegistryMixin, _InstallMixin, _UpdateMixin):
|
|||||||
2. Fix permissions via os.chmod() then retry (works for same-owner files)
|
2. Fix permissions via os.chmod() then retry (works for same-owner files)
|
||||||
3. Use sudo rm -rf as last resort (works for root-owned __pycache__, etc.)
|
3. Use sudo rm -rf as last resort (works for root-owned __pycache__, etc.)
|
||||||
|
|
||||||
|
A symlink -- a dev plugin linked in by scripts/dev/dev_plugin_setup.sh
|
||||||
|
-- is removed as a link, before any of that: rmtree refuses one, and
|
||||||
|
stage 2 would walk through it and chmod the developer's checkout.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
path: Path to directory to remove
|
path: Path to directory to remove
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if directory was removed successfully, False otherwise
|
True if directory was removed successfully, False otherwise
|
||||||
"""
|
"""
|
||||||
|
if path.is_symlink():
|
||||||
|
# Checked before exists(), which follows the link: a dangling one
|
||||||
|
# would read as already removed and be left behind.
|
||||||
|
try:
|
||||||
|
path.unlink()
|
||||||
|
return True
|
||||||
|
except OSError as e:
|
||||||
|
self.logger.error(f"Could not remove the symlink {path}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
return True # Already removed
|
return True # Already removed
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,12 @@ server has none.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `unit/test_list_filter.js` | no | `ListFilter` search/filter/sort/count/sticky, and the installed-plugins config **extracted verbatim** from `plugins_manager.js` so the test can't drift from it |
|
| `unit/test_list_filter.js` | no | `ListFilter` search/filter/sort/count/sticky, and the installed-plugins config **extracted verbatim** from `plugins_manager.js` so the test can't drift from it |
|
||||||
| `unit/test_update_all.js` | no | `PluginInstallManager.updateAll` from `plugins/install_manager.js`: Check & Update All sends only plugin ids (never `starlark:` app entries), re-sends a request that got no HTTP answer (web service restarting) instead of skipping that plugin, never re-sends one that got any HTTP answer (the real `api_client.js` classifies a proxy 502 or a JSON error without `error_code` as `API_ERROR`), and counts a no-op update as already up to date in the summary. Also run by `test/web_interface/test_update_all_plugins.py` so CI covers it |
|
| `unit/test_update_all.js` | no | `PluginInstallManager.updateAll` from `plugins/install_manager.js`: Check & Update All sends only plugin ids (never `starlark:` app entries), re-sends a request that got no HTTP answer (web service restarting) instead of skipping that plugin, never re-sends one that got any HTTP answer (the real `api_client.js` classifies a proxy 502 or a JSON error without `error_code` as `API_ERROR`), and counts a no-op update as already up to date in the summary. Also run by `test/web_interface/test_update_all_plugins.py` so CI covers it |
|
||||||
|
| `unit/test_store_install.js` | no | The store's Install button, with the whole of `plugins_manager.js` run by `plugins_manager_sandbox.js` (a vm context, fake DOM and API): a fresh install reloads the list, then enables the id the plugin was installed as -- the answer's `plugin_id`, else the installed entry the store entry matches (Weather installs as `ledmatrix-weather`); a Reinstall leaves the enabled state alone |
|
||||||
|
| `unit/test_install_polling.js` | no | How long Install waits for a queued install (sandbox): at least the server's 300 s dependency-install timeout; when it stops waiting it reloads the installed list and warns, rather than reporting a failure or enabling anything |
|
||||||
|
| `unit/test_store_categories.js` | no | The store's category filter (sandbox): the template ships only All Categories, the rest come from the store's plugins (one per category whatever its case), choosing one filters to it, and a swapped-in select is refilled from the cache keeping the choice |
|
||||||
|
| `unit/test_github_url_install.js` | no | Install Single Plugin (sandbox, the button as `plugins.html` ships it): no inline `onclick`, so a click or Enter sends exactly one `install-from-url` request and raises no error |
|
||||||
| `unit/test_render_cards.js` | no | `renderInstalledCards` markup, both empty states, and HTML-escaping of hostile plugin metadata |
|
| `unit/test_render_cards.js` | no | `renderInstalledCards` markup, both empty states, and HTML-escaping of hostile plugin metadata |
|
||||||
|
| `unit/test_plugin_order_list.js` | no | `widgets/plugin-order-list.js` (the Vegas and rotation order lists): a disabled plugin, which gets no row, keeps its slot in the saved order and its Vegas exclusion when the list rewrites its hidden inputs, around reordering and include/exclude; an uninstalled plugin's id is dropped, a failed plugin list leaves the inputs as saved, and only string ids are carried over, once each |
|
||||||
| `unit/test_style_editor_element_keys.js` | no | `elementKeys()`/`styleRows()`/`positionRows()` from `widgets/style-editor.js`: every `customization.layout` entry gets exactly one row -- paired with its style element through core's `x-layout-key` (so `score` belongs to `score_text`, not a second row), or a position row of its own, leaves included -- since the widget claims the whole `layout` block from the generic fallback renderer |
|
| `unit/test_style_editor_element_keys.js` | no | `elementKeys()`/`styleRows()`/`positionRows()` from `widgets/style-editor.js`: every `customization.layout` entry gets exactly one row -- paired with its style element through core's `x-layout-key` (so `score` belongs to `score_text`, not a second row), or a position row of its own, leaves included -- since the widget claims the whole `layout` block from the generic fallback renderer |
|
||||||
| `unit/test_style_editor_layout_leaf_columns.js` | no | `columnsFor()` from `widgets/style-editor.js`: a layout-only key whose own value is a leaf (no x/y sub-object, e.g. a `show_logo` toggle) gets a self-keyed column instead of a blank, uneditable row |
|
| `unit/test_style_editor_layout_leaf_columns.js` | no | `columnsFor()` from `widgets/style-editor.js`: a layout-only key whose own value is a leaf (no x/y sub-object, e.g. a `show_logo` toggle) gets a self-keyed column instead of a blank, uneditable row |
|
||||||
| `unit/test_style_editor_layout_leaf_collision.js` | no | `columnsFor()` from `widgets/style-editor.js`: a layout-only leaf key still gets its own column even when its name collides with an unrelated element's style sub-field or another layout axis's sub-field |
|
| `unit/test_style_editor_layout_leaf_collision.js` | no | `columnsFor()` from `widgets/style-editor.js`: a layout-only leaf key still gets its own column even when its name collides with an unrelated element's style sub-field or another layout axis's sub-field |
|
||||||
@@ -53,6 +58,9 @@ server has none.
|
|||||||
| `unit/test_store_registry_fields.js` | no | The store card's registry fields from `plugins_manager.js`: the commit that introduced the listed version (a hex SHA only, linked to that tree), the "Needs LEDMatrix X+" warning, a card from an older registry without either, and `isStorePluginInstalled` answering to `aliases` |
|
| `unit/test_store_registry_fields.js` | no | The store card's registry fields from `plugins_manager.js`: the commit that introduced the listed version (a hex SHA only, linked to that tree), the "Needs LEDMatrix X+" warning, a card from an older registry without either, and `isStorePluginInstalled` answering to `aliases` |
|
||||||
| `unit/test_page_registry.js` | no | The page lifecycle in `js/core/registry.js` (a minimal DOM shim): one `init` per `data-page` root, `destroy` and an aborted `ctx.signal` when htmx swaps it away, a vetoed swap keeps it, lazy page modules, a root removed without htmx swept on the next swap |
|
| `unit/test_page_registry.js` | no | The page lifecycle in `js/core/registry.js` (a minimal DOM shim): one `init` per `data-page` root, `destroy` and an aborted `ctx.signal` when htmx swaps it away, a vetoed swap keeps it, lazy page modules, a root removed without htmx swept on the next swap |
|
||||||
| `unit/test_core_modules.js` | no | `js/core/api.js` (JSON envelope, HTTP/`status: error`/network errors, abort passthrough, the #683 login redirect, same-server paths only) and `js/core/facade.js` (`window.LEDMatrix`, deprecated aliases) |
|
| `unit/test_core_modules.js` | no | `js/core/api.js` (JSON envelope, HTTP/`status: error`/network errors, abort passthrough, the #683 login redirect, same-server paths only) and `js/core/facade.js` (`window.LEDMatrix`, deprecated aliases) |
|
||||||
|
| `unit/test_overview_reconciliation_poll.js` | no | The Overview's reconciliation-banner poll from `partials/overview.html`, run in a vm: it gives up after a bounded number of requests when the status never says done, runs only while the Overview is on screen (`LEDVisibility`, its own key), and stops once the banner is shown |
|
||||||
|
| `unit/test_display_partial_ids.js` | no | `partials/display.html`: every literal `getElementById()` in its inline scripts names an id the partial renders, and moving the brightness slider (the shipped script, in a vm with a fake DOM) updates its label without throwing |
|
||||||
|
| `unit/test_general_web_login_token.js` | no | `window.webLogin.createToken` from `partials/general.html`, run in a vm: a created API token clears the form's `data-dirty` mark (so a reload does not ask "Leave site?"), a refused one keeps it |
|
||||||
| `unit/test_plugin_action_delegation.js` | no | The document-level card-action delegation and `handlePluginAction` from `plugins_manager.js`, run with the handler inside an IIFE as in the real file: each action is handled once, a Starlark app uninstall goes to `DELETE /starlark/apps/<id>`, and an uninstall is confirmed once |
|
| `unit/test_plugin_action_delegation.js` | no | The document-level card-action delegation and `handlePluginAction` from `plugins_manager.js`, run with the handler inside an IIFE as in the real file: each action is handled once, a Starlark app uninstall goes to `DELETE /starlark/apps/<id>`, and an uninstall is confirmed once |
|
||||||
| `dom/test_installed_dom.js` | yes | The toolbar in a real DOM: pill/search/sort interaction, the HTMX partial re-swap, and a `getComputedStyle` check that `.filter-pill[data-active]` really matches the emitted markup |
|
| `dom/test_installed_dom.js` | yes | The toolbar in a real DOM: pill/search/sort interaction, the HTMX partial re-swap, and a `getComputedStyle` check that `.filter-pill[data-active]` really matches the emitted markup |
|
||||||
| `dom/test_store_dom.js` | yes | Store pagination, per-page, category, tri-state Installed button, and persistence across a re-boot, against the live registry |
|
| `dom/test_store_dom.js` | yes | Store pagination, per-page, category, tri-state Installed button, and persistence across a re-boot, against the live registry |
|
||||||
|
|||||||
@@ -98,7 +98,11 @@ const ok = (l, c, x) => c ? (pass++, console.log(' ok ' + l))
|
|||||||
|
|
||||||
const lists = () => requests.filter(r => r.url === '/api/v3/plugins/installed').length;
|
const lists = () => requests.filter(r => r.url === '/api/v3/plugins/installed').length;
|
||||||
const $ = id => doc.getElementById(id);
|
const $ = id => doc.getElementById(id);
|
||||||
const order = () => JSON.parse($('rotation_plugin_order_value').value || '[]');
|
// The rows' ids, in order. The input also keeps saved ids that have no row
|
||||||
|
// (a disabled plugin's place, see test/js/unit/test_plugin_order_list.js),
|
||||||
|
// and the saved order comes from whatever config the server has.
|
||||||
|
const SHOWN = plugins.filter(p => p.enabled).map(p => p.id);
|
||||||
|
const order = () => JSON.parse($('rotation_plugin_order_value').value || '[]').filter(id => SHOWN.includes(id));
|
||||||
async function swap() {
|
async function swap() {
|
||||||
panel.dispatchEvent(new window.CustomEvent('htmx:beforeSwap', { bubbles: true, detail: { target: panel, shouldSwap: true } }));
|
panel.dispatchEvent(new window.CustomEvent('htmx:beforeSwap', { bubbles: true, detail: { target: panel, shouldSwap: true } }));
|
||||||
panel.innerHTML = partial;
|
panel.innerHTML = partial;
|
||||||
|
|||||||
@@ -98,8 +98,50 @@ const get = p => new Promise((res, rej) =>
|
|||||||
window.saveMqttBridge();
|
window.saveMqttBridge();
|
||||||
await tick(150);
|
await tick(150);
|
||||||
ok('save includes password once typed', sent && sent.mqtt_password === 'typed-secret');
|
ok('save includes password once typed', sent && sent.mqtt_password === 'typed-secret');
|
||||||
|
|
||||||
|
// A password with TLS off is refused unless allow_insecure_mqtt is set
|
||||||
|
// (CWE-319, api_v3/misc.py). The form has to be able to send it, or a
|
||||||
|
// plain-LAN broker with a password can never be saved from here.
|
||||||
|
const allowRow = () => $('mqtt-allow-insecure-row');
|
||||||
|
const shown = el => !!el && !el.classList.contains('hidden');
|
||||||
|
ok('allow-without-TLS control rendered', !!$('mqtt-allow-insecure'));
|
||||||
|
ok('allow-without-TLS starts as saved',
|
||||||
|
!!$('mqtt-allow-insecure') && $('mqtt-allow-insecure').checked === !!bridge.data.config.allow_insecure_mqtt);
|
||||||
|
ok('allow-without-TLS shown only while TLS is off',
|
||||||
|
shown(allowRow()) === !$('mqtt-tls').checked);
|
||||||
|
$('mqtt-tls').checked = true;
|
||||||
|
$('mqtt-tls').dispatchEvent(new window.Event('change', { bubbles: true }));
|
||||||
|
ok('ticking TLS hides it', !shown(allowRow()));
|
||||||
|
$('mqtt-tls').checked = false;
|
||||||
|
$('mqtt-tls').dispatchEvent(new window.Event('change', { bubbles: true }));
|
||||||
|
ok('unticking TLS shows it again', shown(allowRow()));
|
||||||
|
|
||||||
|
const setAllow = v => { if ($('mqtt-allow-insecure')) $('mqtt-allow-insecure').checked = v; };
|
||||||
|
setAllow(false);
|
||||||
|
window.saveMqttBridge();
|
||||||
|
await tick(150);
|
||||||
|
ok('save sends allow_insecure_mqtt false when unticked', !!sent && sent.allow_insecure_mqtt === false, sent);
|
||||||
|
setAllow(true);
|
||||||
|
window.saveMqttBridge();
|
||||||
|
await tick(150);
|
||||||
|
ok('save sends allow_insecure_mqtt true when ticked', !!sent && sent.allow_insecure_mqtt === true, sent);
|
||||||
onPut = null;
|
onPut = null;
|
||||||
|
|
||||||
|
// Prefilled from the saved settings, and hidden while TLS is saved on.
|
||||||
|
bridgePayload = JSON.parse(JSON.stringify(bridge));
|
||||||
|
bridgePayload.data.config.allow_insecure_mqtt = true;
|
||||||
|
bridgePayload.data.config.mqtt_tls = false;
|
||||||
|
window.loadMqttBridge();
|
||||||
|
await tick(150);
|
||||||
|
ok('a saved opt-in is prefilled', !!$('mqtt-allow-insecure') && $('mqtt-allow-insecure').checked === true);
|
||||||
|
bridgePayload.data.config.mqtt_tls = true;
|
||||||
|
window.loadMqttBridge();
|
||||||
|
await tick(150);
|
||||||
|
ok('hidden on load when TLS is saved on', !shown(allowRow()));
|
||||||
|
bridgePayload = bridge;
|
||||||
|
window.loadMqttBridge();
|
||||||
|
await tick(150);
|
||||||
|
|
||||||
// ── Pixlet editor, idle ────────────────────────────────────────────────
|
// ── Pixlet editor, idle ────────────────────────────────────────────────
|
||||||
const appIds = (apps.data.apps || []).map(a => a.id);
|
const appIds = (apps.data.apps || []).map(a => a.id);
|
||||||
ok('editor lists the apps on disk',
|
ok('editor lists the apps on disk',
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
// The whole of plugins_manager.js (and list_filter.js before it, as the page
|
||||||
|
// loads them), evaluated in a node vm context against a small fake DOM.
|
||||||
|
//
|
||||||
|
// For suites that drive the plugin manager's real flows -- install, polling,
|
||||||
|
// store filters, the GitHub-URL button -- rather than one function sliced
|
||||||
|
// out of the file. Nothing is mocked inside the script: only what the page
|
||||||
|
// gives it (document, fetch, timers, showNotification, LEDEscape).
|
||||||
|
//
|
||||||
|
// const sb = create({ route: (method, url, body) => ({ status, json }) });
|
||||||
|
// sb.el('plugin-store-grid'); // make an element exist by id
|
||||||
|
// sb.window.installPlugin('weather');
|
||||||
|
// await sb.until(() => sb.requests.some(r => r.url.includes('/toggle')));
|
||||||
|
//
|
||||||
|
// Timers ignore their delays and run on the next turn, so a poll loop that
|
||||||
|
// would take minutes in a browser finishes in milliseconds. The page is in
|
||||||
|
// readyState "loading" with no #installed-plugins-grid, so the script's own
|
||||||
|
// start-up does nothing until a suite asks for it (window.initPluginsPage()).
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const vm = require('vm');
|
||||||
|
const ledEscape = require('./led_escape');
|
||||||
|
|
||||||
|
const V3 = path.resolve(__dirname, '../../web_interface/static/v3');
|
||||||
|
const PLUGINS_HTML = path.resolve(__dirname, '../../web_interface/templates/v3/partials/plugins.html');
|
||||||
|
|
||||||
|
class FakeClassList {
|
||||||
|
constructor() { this.set = new Set(); }
|
||||||
|
add(...c) { c.forEach(x => this.set.add(x)); }
|
||||||
|
remove(...c) { c.forEach(x => this.set.delete(x)); }
|
||||||
|
contains(c) { return this.set.has(c); }
|
||||||
|
toggle(c, force) {
|
||||||
|
const on = force === undefined ? !this.set.has(c) : !!force;
|
||||||
|
if (on) this.set.add(c); else this.set.delete(c);
|
||||||
|
return on;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function create({ route } = {}) {
|
||||||
|
const elements = new Map();
|
||||||
|
const requests = [];
|
||||||
|
const toasts = [];
|
||||||
|
const errors = [];
|
||||||
|
const restartNotes = [];
|
||||||
|
|
||||||
|
class FakeElement {
|
||||||
|
constructor(id, tag = 'div', attributes = {}) {
|
||||||
|
this.id = id;
|
||||||
|
this.tagName = tag.toUpperCase();
|
||||||
|
this.attributes = { ...attributes };
|
||||||
|
this.listeners = {};
|
||||||
|
this.children = [];
|
||||||
|
this.classList = new FakeClassList();
|
||||||
|
this.style = { removeProperty() {} };
|
||||||
|
this.dataset = {};
|
||||||
|
this.value = '';
|
||||||
|
this.textContent = '';
|
||||||
|
this.disabled = false;
|
||||||
|
this.parentNode = null;
|
||||||
|
this._html = '';
|
||||||
|
}
|
||||||
|
get innerHTML() { return this._html; }
|
||||||
|
set innerHTML(v) { this._html = String(v); this.children = []; }
|
||||||
|
getAttribute(n) { return n in this.attributes ? this.attributes[n] : null; }
|
||||||
|
setAttribute(n, v) { this.attributes[n] = String(v); }
|
||||||
|
hasAttribute(n) { return n in this.attributes; }
|
||||||
|
removeAttribute(n) { delete this.attributes[n]; }
|
||||||
|
addEventListener(type, fn) { (this.listeners[type] = this.listeners[type] || []).push(fn); }
|
||||||
|
removeEventListener(type, fn) {
|
||||||
|
this.listeners[type] = (this.listeners[type] || []).filter(f => f !== fn);
|
||||||
|
}
|
||||||
|
appendChild(child) { this.children.push(child); child.parentNode = this; return child; }
|
||||||
|
querySelector() { return null; }
|
||||||
|
querySelectorAll() { return []; }
|
||||||
|
closest() { return null; }
|
||||||
|
cloneNode() {
|
||||||
|
const copy = new FakeElement(this.id, this.tagName, this.attributes);
|
||||||
|
copy._html = this._html;
|
||||||
|
copy.value = this.value;
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
replaceChild(next, prev) {
|
||||||
|
next.parentNode = this;
|
||||||
|
prev.parentNode = null;
|
||||||
|
if (next.id) elements.set(next.id, next);
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
replaceWith(next) { if (this.parentNode) this.parentNode.replaceChild(next, this); }
|
||||||
|
// A browser runs an inline on<type> attribute first (it was set before
|
||||||
|
// any listener was added), then the listeners, and an exception in one
|
||||||
|
// does not stop the next: it is reported, which is what `errors` holds.
|
||||||
|
dispatch(type, init = {}) {
|
||||||
|
const event = {
|
||||||
|
type, target: this, currentTarget: this, key: init.key,
|
||||||
|
defaultPrevented: false,
|
||||||
|
preventDefault() { this.defaultPrevented = true; },
|
||||||
|
stopPropagation() {}, stopImmediatePropagation() {},
|
||||||
|
};
|
||||||
|
const inline = this.getAttribute('on' + type);
|
||||||
|
const handlers = [];
|
||||||
|
if (inline !== null) {
|
||||||
|
handlers.push(vm.runInContext(`(function(event) {\n${inline}\n})`, ctx));
|
||||||
|
}
|
||||||
|
handlers.push(...(this.listeners[type] || []));
|
||||||
|
for (const h of handlers) {
|
||||||
|
try { h.call(this, event); } catch (e) { errors.push(e); }
|
||||||
|
}
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
click() { return this.dispatch('click'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function el(id, tag, attributes) {
|
||||||
|
if (!elements.has(id)) {
|
||||||
|
const parent = new FakeElement(null);
|
||||||
|
parent.appendChild(new FakeElement(id, tag, attributes));
|
||||||
|
elements.set(id, parent.children[0]);
|
||||||
|
}
|
||||||
|
return elements.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timers = [];
|
||||||
|
const ctx = {
|
||||||
|
// Warnings are the script noting elements this fake page doesn't have.
|
||||||
|
console: { log: console.log.bind(console), error: console.error.bind(console),
|
||||||
|
warn: () => {}, info: () => {}, debug: () => {} },
|
||||||
|
debugLog: () => {},
|
||||||
|
addEventListener() {},
|
||||||
|
URL,
|
||||||
|
document: {
|
||||||
|
readyState: 'loading',
|
||||||
|
body: { addEventListener() {} },
|
||||||
|
getElementById: id => elements.get(id) || null,
|
||||||
|
querySelector: () => null,
|
||||||
|
querySelectorAll: () => [],
|
||||||
|
addEventListener() {},
|
||||||
|
dispatchEvent() { return true; },
|
||||||
|
createElement: tag => new FakeElement(null, tag),
|
||||||
|
},
|
||||||
|
CustomEvent: class { constructor(type, init) { this.type = type; this.detail = init && init.detail; } },
|
||||||
|
setTimeout: (fn, _ms, ...args) => { timers.push(setImmediate(() => fn(...args))); return timers.length; },
|
||||||
|
clearTimeout: () => {},
|
||||||
|
setInterval: () => 0,
|
||||||
|
clearInterval: () => {},
|
||||||
|
requestAnimationFrame: fn => setImmediate(fn),
|
||||||
|
getComputedStyle: () => ({ display: 'block' }),
|
||||||
|
scrollTo() {},
|
||||||
|
sessionStorage: { getItem: () => null, setItem() {}, removeItem() {} },
|
||||||
|
localStorage: { getItem: () => null, setItem() {}, removeItem() {} },
|
||||||
|
confirm: () => true,
|
||||||
|
alert: () => {},
|
||||||
|
showNotification: (message, type) => {
|
||||||
|
toasts.push({ message: String(message),
|
||||||
|
type: type && typeof type === 'object' ? type.type : type });
|
||||||
|
},
|
||||||
|
noteRestartRequired: (body) => { restartNotes.push(body); },
|
||||||
|
fetch: async (url, opts = {}) => {
|
||||||
|
const method = (opts.method || 'GET').toUpperCase();
|
||||||
|
let body = null;
|
||||||
|
try { body = opts.body ? JSON.parse(opts.body) : null; } catch (e) { body = opts.body; }
|
||||||
|
requests.push({ method, url: String(url), body });
|
||||||
|
const answer = (route && route(method, String(url), body)) || { status: 200, json: { status: 'success' } };
|
||||||
|
const status = answer.status || 200;
|
||||||
|
return { ok: status < 400, status, json: async () => answer.json };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
ctx.window = ctx;
|
||||||
|
vm.createContext(ctx);
|
||||||
|
ledEscape.install(ctx);
|
||||||
|
for (const file of ['js/plugins/list_filter.js', 'plugins_manager.js']) {
|
||||||
|
vm.runInContext(fs.readFileSync(path.join(V3, file), 'utf8'), ctx, { filename: file });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolves once cond() is true, letting timers and promises run between
|
||||||
|
// checks; rejects if it never is.
|
||||||
|
async function until(cond, label = 'condition', turns = 20000) {
|
||||||
|
for (let i = 0; i < turns; i++) {
|
||||||
|
if (cond()) return;
|
||||||
|
await new Promise(r => setImmediate(r));
|
||||||
|
}
|
||||||
|
throw new Error('timed out waiting for ' + label);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lets every pending timer and promise run.
|
||||||
|
async function settle(turns = 50) {
|
||||||
|
for (let i = 0; i < turns; i++) await new Promise(r => setImmediate(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { window: ctx, el, FakeElement, requests, toasts, errors, restartNotes, until, settle };
|
||||||
|
}
|
||||||
|
|
||||||
|
// The attributes of the element with this id in partials/plugins.html, as
|
||||||
|
// the template ships them (no Jinja on the tags these suites read).
|
||||||
|
function templateAttributes(id) {
|
||||||
|
const html = fs.readFileSync(PLUGINS_HTML, 'utf8');
|
||||||
|
const at = html.indexOf(`id="${id}"`);
|
||||||
|
if (at < 0) throw new Error(`no element with id ${id} in plugins.html`);
|
||||||
|
const start = html.lastIndexOf('<', at);
|
||||||
|
let end = start, quote = null;
|
||||||
|
for (; end < html.length; end++) {
|
||||||
|
const ch = html[end];
|
||||||
|
if (quote) { if (ch === quote) quote = null; } else if (ch === '"' || ch === "'") quote = ch;
|
||||||
|
else if (ch === '>') break;
|
||||||
|
}
|
||||||
|
const tag = html.slice(start, end + 1);
|
||||||
|
const attrs = {};
|
||||||
|
const re = /([\w:-]+)\s*=\s*("([^"]*)"|'([^']*)')/g;
|
||||||
|
let m;
|
||||||
|
while ((m = re.exec(tag))) attrs[m[1]] = m[3] !== undefined ? m[3] : m[4];
|
||||||
|
return { tag: tag.match(/^<(\w+)/)[1], attrs, source: tag };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { create, templateAttributes };
|
||||||
+11
-2
@@ -17,13 +17,22 @@ const fs = require('fs');
|
|||||||
|
|
||||||
const BASE = process.env.BASE || 'http://localhost:5000';
|
const BASE = process.env.BASE || 'http://localhost:5000';
|
||||||
const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.js',
|
const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.js',
|
||||||
|
'unit/test_plugin_order_list.js',
|
||||||
'unit/test_html_escaping.js', 'unit/test_style_editor_element_keys.js',
|
'unit/test_html_escaping.js', 'unit/test_style_editor_element_keys.js',
|
||||||
'unit/test_style_editor_layout_leaf_columns.js',
|
'unit/test_style_editor_layout_leaf_columns.js',
|
||||||
'unit/test_style_editor_layout_leaf_collision.js',
|
'unit/test_style_editor_layout_leaf_collision.js',
|
||||||
'unit/test_update_all.js', 'unit/test_inline_handler_escaping.js',
|
'unit/test_update_all.js',
|
||||||
|
'unit/test_store_install.js',
|
||||||
|
'unit/test_install_polling.js',
|
||||||
|
'unit/test_store_categories.js',
|
||||||
|
'unit/test_github_url_install.js',
|
||||||
|
'unit/test_inline_handler_escaping.js',
|
||||||
'unit/test_plugin_action_delegation.js', 'unit/test_file_upload_widget.js',
|
'unit/test_plugin_action_delegation.js', 'unit/test_file_upload_widget.js',
|
||||||
'unit/test_store_registry_fields.js', 'unit/test_restart_banner.js',
|
'unit/test_store_registry_fields.js', 'unit/test_restart_banner.js',
|
||||||
'unit/test_page_registry.js', 'unit/test_core_modules.js'];
|
'unit/test_page_registry.js', 'unit/test_core_modules.js',
|
||||||
|
'unit/test_overview_reconciliation_poll.js',
|
||||||
|
'unit/test_display_partial_ids.js',
|
||||||
|
'unit/test_general_web_login_token.js'];
|
||||||
const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js',
|
const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js',
|
||||||
'dom/test_tools_sections.js', 'dom/test_cache_page.js',
|
'dom/test_tools_sections.js', 'dom/test_cache_page.js',
|
||||||
'dom/test_durations_page.js', 'dom/test_operation_history_page.js',
|
'dom/test_durations_page.js', 'dom/test_operation_history_page.js',
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
// 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);
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
// 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 (window.webLogin.createToken in partials/general.html), so after a
|
||||||
|
// token was created the form stayed dirty and reloading the page while the
|
||||||
|
// General tab was open prompted about changes that had been saved.
|
||||||
|
//
|
||||||
|
// Runs the shipped inline script in a vm with a fake fetch and DOM -- no jsdom
|
||||||
|
// and no server needed.
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const vm = require('vm');
|
||||||
|
|
||||||
|
const PARTIAL = path.resolve(__dirname, '../../../web_interface/templates/v3/partials/general.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 webLoginScript() {
|
||||||
|
const html = fs.readFileSync(PARTIAL, 'utf8');
|
||||||
|
const scripts = [...html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/gi)].map(m => m[1]);
|
||||||
|
const found = scripts.find(s => s.includes('window.webLogin = {'));
|
||||||
|
if (!found) throw new Error('webLogin script not found in general.html');
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
function el() {
|
||||||
|
const classes = new Set(['hidden']);
|
||||||
|
return {
|
||||||
|
textContent: '', dataset: {}, style: {}, className: '',
|
||||||
|
classList: { add: c => classes.add(c), remove: c => classes.delete(c), contains: c => classes.has(c) },
|
||||||
|
appendChild() {}, addEventListener() {}, querySelector: () => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function load(answer) {
|
||||||
|
const elements = {
|
||||||
|
'web-login-tokens': el(),
|
||||||
|
'web-login-new-token-value': el(),
|
||||||
|
'web-login-new-token': el(),
|
||||||
|
};
|
||||||
|
const notes = [];
|
||||||
|
const window = { showNotification: (m, t) => notes.push([m, t]), alert() {}, confirm: () => true };
|
||||||
|
const context = {
|
||||||
|
window, console,
|
||||||
|
document: {
|
||||||
|
getElementById: id => elements[id] || null,
|
||||||
|
createElement: () => el(),
|
||||||
|
querySelectorAll: () => [],
|
||||||
|
},
|
||||||
|
fetch: () => Promise.resolve({
|
||||||
|
ok: answer.ok, status: answer.ok ? 200 : 400,
|
||||||
|
json: () => Promise.resolve(answer.body),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
vm.createContext(context);
|
||||||
|
vm.runInContext(webLoginScript(), context);
|
||||||
|
return { webLogin: context.window.webLogin, 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const flush = async () => { for (let i = 0; i < 10; i++) await new Promise(r => setImmediate(r)); };
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
console.log('\n── General tab: API token form ──');
|
||||||
|
|
||||||
|
{
|
||||||
|
const t = load({ ok: true, body: {
|
||||||
|
status: 'success', message: 'Token created',
|
||||||
|
data: { token: 'lmx_secret', record: { id: 't1', name: 'Home Assistant', prefix: 'lmx_sec' } },
|
||||||
|
} });
|
||||||
|
const form = dirtyForm();
|
||||||
|
t.webLogin.createToken(form);
|
||||||
|
await flush();
|
||||||
|
ok('the new token is shown', t.elements['web-login-new-token-value'].textContent === 'lmx_secret');
|
||||||
|
ok('a created token leaves the form clean (no "Leave site?" on reload)',
|
||||||
|
!form.hasAttribute('data-dirty'));
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const t = load({ ok: false, body: { status: 'error', message: 'Name is required' } });
|
||||||
|
const form = dirtyForm();
|
||||||
|
t.webLogin.createToken(form);
|
||||||
|
await flush();
|
||||||
|
ok('a refused request reports the error', t.notes.some(([m, type]) => type === 'error' && /Name is required/.test(m)),
|
||||||
|
t.notes);
|
||||||
|
ok('...and keeps the form dirty: nothing was saved', form.hasAttribute('data-dirty'));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n${pass} passed, ${fail} failed\n`);
|
||||||
|
process.exit(fail ? 1 : 0);
|
||||||
|
})().catch(e => { console.log('HARNESS ERROR: ' + e.stack); process.exit(1); });
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// Plugin Manager > Install from GitHub > Install Single Plugin: one click,
|
||||||
|
// one request, no errors.
|
||||||
|
//
|
||||||
|
// The Install button carried an inline onclick calling
|
||||||
|
// window.handleGitHubPluginInstall, and attachInstallButtonHandler also gave
|
||||||
|
// it a click listener that installs. Both ran on every click. The inline one
|
||||||
|
// threw a ReferenceError (it called isGithubUrl, which lives inside the
|
||||||
|
// plugin-manager IIFE, from outside it), so only the listener's request went
|
||||||
|
// out -- and fixing that scope alone would have sent every install twice.
|
||||||
|
// The button now has the listener only.
|
||||||
|
//
|
||||||
|
// Runs the whole of plugins_manager.js in the sandbox, with the button as
|
||||||
|
// partials/plugins.html ships it.
|
||||||
|
|
||||||
|
const { create, templateAttributes } = require('../plugins_manager_sandbox');
|
||||||
|
|
||||||
|
let pass = 0, fail = 0;
|
||||||
|
const ok = (label, cond, extra) => cond
|
||||||
|
? (pass++, console.log(' ok ' + label))
|
||||||
|
: (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' -> ' + JSON.stringify(extra).slice(0, 400) : '')));
|
||||||
|
|
||||||
|
const URL = 'https://github.com/someone/ledmatrix-demo';
|
||||||
|
|
||||||
|
function route(method, url) {
|
||||||
|
if (method === 'POST' && url === '/api/v3/plugins/install-from-url') {
|
||||||
|
return { json: { status: 'success', message: 'Plugin demo installed successfully', plugin_id: 'demo' } };
|
||||||
|
}
|
||||||
|
if (url.startsWith('/api/v3/plugins/installed')) return { json: { status: 'success', data: { plugins: [] } } };
|
||||||
|
return { json: { status: 'success' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function page() {
|
||||||
|
const sb = create({ route });
|
||||||
|
const button = templateAttributes('install-plugin-from-url');
|
||||||
|
sb.el('install-plugin-from-url', button.tag, button.attrs);
|
||||||
|
sb.el('github-plugin-url', 'input');
|
||||||
|
sb.el('github-plugin-status');
|
||||||
|
sb.el('plugin-branch-input', 'input');
|
||||||
|
return sb;
|
||||||
|
}
|
||||||
|
|
||||||
|
const installs = sb => sb.requests.filter(r => r.url === '/api/v3/plugins/install-from-url');
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
console.log('\nthe template');
|
||||||
|
{
|
||||||
|
const { attrs } = templateAttributes('install-plugin-from-url');
|
||||||
|
ok('the Install button has no inline onclick', !('onclick' in attrs), attrs.onclick);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\na click');
|
||||||
|
{
|
||||||
|
const sb = page();
|
||||||
|
sb.window.attachInstallButtonHandler();
|
||||||
|
// htmx:afterSettle runs it again on every swap; that must not add a handler.
|
||||||
|
sb.window.attachInstallButtonHandler();
|
||||||
|
sb.el('github-plugin-url').value = URL;
|
||||||
|
sb.window.document.getElementById('install-plugin-from-url').click();
|
||||||
|
await sb.settle();
|
||||||
|
ok('raises no error', sb.errors.length === 0, sb.errors.map(String));
|
||||||
|
ok('sends exactly one install request', installs(sb).length === 1, installs(sb));
|
||||||
|
ok('for the URL typed', installs(sb)[0] && installs(sb)[0].body.repo_url === URL, installs(sb));
|
||||||
|
ok('and reports the result', /Successfully installed: demo/.test(sb.el('github-plugin-status').innerHTML),
|
||||||
|
sb.el('github-plugin-status').innerHTML);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nEnter in the URL field');
|
||||||
|
{
|
||||||
|
const sb = page();
|
||||||
|
sb.window.attachInstallButtonHandler();
|
||||||
|
const input = sb.el('github-plugin-url');
|
||||||
|
input.value = URL;
|
||||||
|
input.dispatch('keypress', { key: 'Enter' });
|
||||||
|
await sb.settle();
|
||||||
|
ok('raises no error', sb.errors.length === 0, sb.errors.map(String));
|
||||||
|
ok('sends exactly one install request', installs(sb).length === 1, installs(sb));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\na URL that is not GitHub');
|
||||||
|
{
|
||||||
|
const sb = page();
|
||||||
|
sb.window.attachInstallButtonHandler();
|
||||||
|
sb.el('github-plugin-url').value = 'https://example.com/x';
|
||||||
|
sb.window.document.getElementById('install-plugin-from-url').click();
|
||||||
|
await sb.settle();
|
||||||
|
ok('is refused without a request or an error',
|
||||||
|
installs(sb).length === 0 && sb.errors.length === 0 && /valid GitHub URL/.test(sb.el('github-plugin-status').innerHTML),
|
||||||
|
{ errors: sb.errors.map(String), status: sb.el('github-plugin-status').innerHTML });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n${pass} passed, ${fail} failed`);
|
||||||
|
process.exit(fail ? 1 : 0);
|
||||||
|
})().catch(e => { console.error(e); process.exit(1); });
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// How long the store's Install waits for a queued install, and what it says
|
||||||
|
// when it stops waiting.
|
||||||
|
//
|
||||||
|
// It polled the operation 60 times, a second apart, then reported "Install
|
||||||
|
// operation timed out" as an error and did nothing else. The server is
|
||||||
|
// allowed far longer: the plugin's dependency install alone may take 300 s
|
||||||
|
// (install_requirements_file in src/plugin_system/store_install.py), after
|
||||||
|
// a download that fetches the plugin one file at a time. So an install that
|
||||||
|
// went on to succeed was reported as failed, never enabled, and missing from
|
||||||
|
// the installed list until the page was reloaded.
|
||||||
|
//
|
||||||
|
// Runs the whole of plugins_manager.js in the sandbox; its timers ignore
|
||||||
|
// their delays, so each poll here stands for one second on a real page.
|
||||||
|
|
||||||
|
const { create } = require('../plugins_manager_sandbox');
|
||||||
|
|
||||||
|
let pass = 0, fail = 0;
|
||||||
|
const ok = (label, cond, extra) => cond
|
||||||
|
? (pass++, console.log(' ok ' + label))
|
||||||
|
: (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' -> ' + JSON.stringify(extra).slice(0, 400) : '')));
|
||||||
|
|
||||||
|
// The server's dependency-install timeout, in polls (one a second).
|
||||||
|
const DEPENDENCY_INSTALL_TIMEOUT_POLLS = 300;
|
||||||
|
|
||||||
|
function server(completesAfterPolls) {
|
||||||
|
const state = { polls: 0, installed: [] };
|
||||||
|
state.route = (method, url, body) => {
|
||||||
|
if (url.startsWith('/api/v3/plugins/installed')) {
|
||||||
|
return { json: { status: 'success', data: { plugins: state.installed.map(p => ({ ...p })) } } };
|
||||||
|
}
|
||||||
|
if (method === 'POST' && url === '/api/v3/plugins/install') {
|
||||||
|
return { json: { status: 'success', message: 'queued', data: { operation_id: 'op-1' } } };
|
||||||
|
}
|
||||||
|
if (url === '/api/v3/plugins/operation/op-1') {
|
||||||
|
state.polls++;
|
||||||
|
if (completesAfterPolls === null || state.polls < completesAfterPolls) {
|
||||||
|
return { json: { status: 'success', data: { status: 'running' } } };
|
||||||
|
}
|
||||||
|
state.installed = [{ id: 'clock-simple', name: 'Clock', enabled: false }];
|
||||||
|
return { json: { status: 'success', data: { status: 'completed',
|
||||||
|
result: { success: true, message: 'installed', plugin_id: 'clock-simple' } } } };
|
||||||
|
}
|
||||||
|
if (method === 'POST' && url === '/api/v3/plugins/toggle') {
|
||||||
|
return { json: { status: 'success', message: 'enabled' } };
|
||||||
|
}
|
||||||
|
return { json: { status: 'success' } };
|
||||||
|
};
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
console.log('\nan install that takes longer than a minute');
|
||||||
|
{
|
||||||
|
// 200 s: well inside what the server allows.
|
||||||
|
const srv = server(200);
|
||||||
|
const sb = create({ route: srv.route });
|
||||||
|
sb.window.installPlugin('clock-simple');
|
||||||
|
await sb.until(() => sb.toasts.some(t => /installed and enabled|enabling it failed|timed out|still/i.test(t.message)),
|
||||||
|
'the install to finish');
|
||||||
|
await sb.settle();
|
||||||
|
ok('is waited for until it completes', srv.polls === 200, srv.polls);
|
||||||
|
ok('is not reported as an error', !sb.toasts.some(t => t.type === 'error'), sb.toasts);
|
||||||
|
ok('and is enabled', sb.requests.some(r => r.url === '/api/v3/plugins/toggle' && r.body.plugin_id === 'clock-simple'),
|
||||||
|
sb.requests.filter(r => r.method === 'POST'));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nan install that never reports back');
|
||||||
|
{
|
||||||
|
const srv = server(null);
|
||||||
|
const sb = create({ route: srv.route });
|
||||||
|
sb.window.installPlugin('clock-simple');
|
||||||
|
await sb.until(() => sb.toasts.length >= 3, 'the poller to give up');
|
||||||
|
await sb.settle();
|
||||||
|
ok(`is polled for at least the ${DEPENDENCY_INSTALL_TIMEOUT_POLLS} s dependency-install timeout`,
|
||||||
|
srv.polls >= DEPENDENCY_INSTALL_TIMEOUT_POLLS, srv.polls);
|
||||||
|
ok('...but not forever', srv.polls <= 1200, srv.polls);
|
||||||
|
const lastPoll = sb.requests.map(r => r.url).lastIndexOf('/api/v3/plugins/operation/op-1');
|
||||||
|
ok('then the installed list is reloaded, to show what actually happened',
|
||||||
|
sb.requests.slice(lastPoll + 1).some(r => r.url === '/api/v3/plugins/installed'),
|
||||||
|
sb.requests.slice(lastPoll + 1).map(r => r.url));
|
||||||
|
const last = sb.toasts[sb.toasts.length - 1];
|
||||||
|
ok('it says the install may still be running, as a warning, not a failure',
|
||||||
|
last && last.type === 'warning' && !/fail|timed out/i.test(last.message), sb.toasts);
|
||||||
|
ok('nothing is enabled on a guess', !sb.requests.some(r => r.url === '/api/v3/plugins/toggle'));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n${pass} passed, ${fail} failed`);
|
||||||
|
process.exit(fail ? 1 : 0);
|
||||||
|
})().catch(e => { console.error(e); process.exit(1); });
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
// The Overview's "Plugin Config Warning" poll must end.
|
||||||
|
//
|
||||||
|
// The banner script in partials/overview.html asks
|
||||||
|
// /api/v3/plugins/reconciliation-status every 2 s until startup reconciliation
|
||||||
|
// says it is done. The route answers done: false whenever its status file is
|
||||||
|
// missing -- reconciliation raised before writing it, or /tmp was cleaned
|
||||||
|
// under a long-running web service -- so the poll used to run every 2 s for
|
||||||
|
// as long as the page stayed open, on every tab. It now gives up after a
|
||||||
|
// bounded number of tries and runs only while the Overview is on screen
|
||||||
|
// (LEDVisibility, like the other partials' pollers).
|
||||||
|
//
|
||||||
|
// Runs the shipped inline script in a vm with fake timers, fetch and DOM --
|
||||||
|
// no jsdom and no server needed.
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const vm = require('vm');
|
||||||
|
|
||||||
|
const PARTIAL = path.resolve(__dirname, '../../../web_interface/templates/v3/partials/overview.html');
|
||||||
|
|
||||||
|
let pass = 0, fail = 0;
|
||||||
|
const ok = (label, cond, extra) => cond
|
||||||
|
? (pass++, console.log(' ok ' + label))
|
||||||
|
: (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' ' + JSON.stringify(extra) : '')));
|
||||||
|
|
||||||
|
function bannerScript() {
|
||||||
|
const html = fs.readFileSync(PARTIAL, 'utf8');
|
||||||
|
const scripts = [...html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/gi)].map(m => m[1]);
|
||||||
|
const found = scripts.find(s => s.includes('ledmatrix-recon-dismissed'));
|
||||||
|
if (!found) throw new Error('reconciliation banner script not found in overview.html');
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
const flush = async () => { for (let i = 0; i < 10; i++) await new Promise(r => setImmediate(r)); };
|
||||||
|
|
||||||
|
function load({ payload, visibility = true }) {
|
||||||
|
const timers = new Map();
|
||||||
|
let nextId = 1;
|
||||||
|
const calls = [];
|
||||||
|
const banner = { style: { setProperty() {} }, dataset: {} };
|
||||||
|
const text = { textContent: '' };
|
||||||
|
const registrations = [];
|
||||||
|
const window = {};
|
||||||
|
if (visibility) {
|
||||||
|
window.LEDVisibility = {
|
||||||
|
onActive(tab, start, stop, key) { registrations.push({ tab, start, stop, key }); start(); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const context = {
|
||||||
|
window,
|
||||||
|
document: {
|
||||||
|
getElementById: id => ({ 'reconciliation-banner': banner, 'reconciliation-banner-text': text })[id] || null,
|
||||||
|
},
|
||||||
|
sessionStorage: { getItem: () => null, setItem() {} },
|
||||||
|
fetch: (url) => {
|
||||||
|
calls.push(url);
|
||||||
|
return Promise.resolve({ json: () => Promise.resolve(payload()) });
|
||||||
|
},
|
||||||
|
setTimeout: (fn) => { const id = nextId++; timers.set(id, fn); return id; },
|
||||||
|
clearTimeout: (id) => { timers.delete(id); },
|
||||||
|
};
|
||||||
|
vm.createContext(context);
|
||||||
|
vm.runInContext(bannerScript(), context);
|
||||||
|
const fireTimers = async () => {
|
||||||
|
const due = [...timers.entries()];
|
||||||
|
timers.clear();
|
||||||
|
due.forEach(([, fn]) => fn());
|
||||||
|
await flush();
|
||||||
|
};
|
||||||
|
return { calls, timers, registrations, banner, text, window, fireTimers };
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
console.log('\n── Overview reconciliation poll ──');
|
||||||
|
|
||||||
|
// 1. A status file that never says done: the poll stops on its own.
|
||||||
|
{
|
||||||
|
const t = load({ payload: () => ({ status: 'success', data: { done: false, unresolved: [] } }) });
|
||||||
|
await flush();
|
||||||
|
for (let i = 0; i < 200; i++) await t.fireTimers();
|
||||||
|
ok('a status that never turns done stops being polled', t.timers.size === 0,
|
||||||
|
{ pending: t.timers.size, requests: t.calls.length });
|
||||||
|
ok('...after a bounded number of requests (at most 30, a minute at 2 s)',
|
||||||
|
t.calls.length > 1 && t.calls.length <= 30, t.calls.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Runs only while the Overview is on screen.
|
||||||
|
{
|
||||||
|
const t = load({ payload: () => ({ status: 'success', data: { done: false, unresolved: [] } }) });
|
||||||
|
await flush();
|
||||||
|
const reg = t.registrations[0];
|
||||||
|
ok('registers with LEDVisibility for the overview tab', !!reg && reg.tab === 'overview', reg && reg.tab);
|
||||||
|
ok('under its own key, so it does not replace another overview poller',
|
||||||
|
!!reg && !!reg.key && reg.key !== 'overview', reg && reg.key);
|
||||||
|
ok('first request goes out at once', t.calls.length === 1, t.calls.length);
|
||||||
|
if (reg) {
|
||||||
|
reg.stop();
|
||||||
|
ok('leaving the tab cancels the pending retry', t.timers.size === 0, t.timers.size);
|
||||||
|
for (let i = 0; i < 5; i++) await t.fireTimers();
|
||||||
|
ok('no requests while another tab is active', t.calls.length === 1, t.calls.length);
|
||||||
|
reg.start();
|
||||||
|
await flush();
|
||||||
|
ok('coming back asks again at once', t.calls.length === 2, t.calls.length);
|
||||||
|
ok('...and keeps polling', t.timers.size === 1, t.timers.size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. A finished reconciliation with findings shows the banner and stops.
|
||||||
|
{
|
||||||
|
let done = false;
|
||||||
|
const t = load({ payload: () => (done
|
||||||
|
? { status: 'success', data: { done: true, unresolved: [{ plugin_id: 'clock', type: 'plugin_missing_on_disk' }] } }
|
||||||
|
: { status: 'success', data: { done: false, unresolved: [] } }) });
|
||||||
|
await flush();
|
||||||
|
await t.fireTimers();
|
||||||
|
done = true;
|
||||||
|
await t.fireTimers();
|
||||||
|
ok('the banner names the finding once reconciliation is done',
|
||||||
|
t.text.textContent.includes('clock'), t.text.textContent);
|
||||||
|
const before = t.calls.length;
|
||||||
|
for (let i = 0; i < 5; i++) await t.fireTimers();
|
||||||
|
ok('no more requests once it is done', t.calls.length === before && t.timers.size === 0,
|
||||||
|
{ before, after: t.calls.length, pending: t.timers.size });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Without LEDVisibility (base.html always has it) it still runs, bounded.
|
||||||
|
{
|
||||||
|
const t = load({ visibility: false, payload: () => ({ status: 'success', data: { done: false } }) });
|
||||||
|
await flush();
|
||||||
|
ok('runs without LEDVisibility', t.calls.length === 1, t.calls.length);
|
||||||
|
for (let i = 0; i < 200; i++) await t.fireTimers();
|
||||||
|
ok('...and is still bounded', t.timers.size === 0 && t.calls.length <= 30, t.calls.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n${pass} passed, ${fail} failed\n`);
|
||||||
|
process.exit(fail ? 1 : 0);
|
||||||
|
})().catch(e => { console.log('HARNESS ERROR: ' + e.stack); process.exit(1); });
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
// The shared plugin order list (widgets/plugin-order-list.js) keeps what it
|
||||||
|
// does not show.
|
||||||
|
//
|
||||||
|
// It lists enabled plugins only, and rewrites its hidden inputs from those
|
||||||
|
// rows as soon as it has drawn them. A disabled plugin's place in the order
|
||||||
|
// and its Vegas exclusion used to vanish from the inputs on that rewrite, so
|
||||||
|
// any later save of the Display or Rotation & Durations tab stored them
|
||||||
|
// without it: re-enabled, the plugin came back at the end of the rotation and
|
||||||
|
// scrolling in Vegas again. An uninstalled plugin's id is still dropped, as
|
||||||
|
// before, so the lists don't collect ids nothing can show. Runs the shipped
|
||||||
|
// widget in a vm with a minimal fake DOM -- no jsdom and no server needed, so
|
||||||
|
// it runs under test/test_js_unit_suites.py too.
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const vm = require('vm');
|
||||||
|
const WIDGET = path.resolve(__dirname, '../../../web_interface/static/v3/js/widgets/plugin-order-list.js');
|
||||||
|
|
||||||
|
let pass = 0, fail = 0;
|
||||||
|
const ok = (label, cond, extra) => cond
|
||||||
|
? (pass++, console.log(' ok ' + label))
|
||||||
|
: (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' ' + JSON.stringify(extra) : '')));
|
||||||
|
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
||||||
|
|
||||||
|
class FakeElement {
|
||||||
|
constructor(tag) {
|
||||||
|
this.tagName = tag.toUpperCase();
|
||||||
|
this.children = [];
|
||||||
|
this.parent = null;
|
||||||
|
this.dataset = {};
|
||||||
|
this.style = {};
|
||||||
|
this.className = '';
|
||||||
|
this.value = '';
|
||||||
|
this.checked = false;
|
||||||
|
this.listeners = {};
|
||||||
|
this._text = '';
|
||||||
|
}
|
||||||
|
appendChild(child) {
|
||||||
|
if (child.parent) child.parent.children = child.parent.children.filter(c => c !== child);
|
||||||
|
child.parent = this;
|
||||||
|
this.children.push(child);
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
insertBefore(child, ref) {
|
||||||
|
if (!ref) return this.appendChild(child);
|
||||||
|
if (child.parent) child.parent.children = child.parent.children.filter(c => c !== child);
|
||||||
|
child.parent = this;
|
||||||
|
this.children.splice(this.children.indexOf(ref), 0, child);
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
get previousElementSibling() {
|
||||||
|
const siblings = this.parent ? this.parent.children : [];
|
||||||
|
return siblings[siblings.indexOf(this) - 1] || null;
|
||||||
|
}
|
||||||
|
get nextElementSibling() {
|
||||||
|
const siblings = this.parent ? this.parent.children : [];
|
||||||
|
const i = siblings.indexOf(this);
|
||||||
|
return i < 0 ? null : siblings[i + 1] || null;
|
||||||
|
}
|
||||||
|
set textContent(value) { this._text = value; this.children = []; }
|
||||||
|
get textContent() { return this._text; }
|
||||||
|
setAttribute() {}
|
||||||
|
focus() {}
|
||||||
|
addEventListener(type, fn) { (this.listeners[type] ||= []).push(fn); }
|
||||||
|
fire(type, event) { (this.listeners[type] || []).forEach(fn => fn.call(this, event || {})); }
|
||||||
|
descendants() { return this.children.flatMap(c => [c, ...c.descendants()]); }
|
||||||
|
querySelectorAll(selector) {
|
||||||
|
const cls = selector.replace(/^\./, '');
|
||||||
|
return this.descendants().filter(e => e.className.split(/\s+/).includes(cls));
|
||||||
|
}
|
||||||
|
querySelector(selector) { return this.querySelectorAll(selector)[0] || null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run the widget over `plugins` with the given saved inputs; resolves once it has drawn. */
|
||||||
|
async function mount({ plugins, order, excluded, fetchFails }) {
|
||||||
|
const els = {
|
||||||
|
list: new FakeElement('div'),
|
||||||
|
order: Object.assign(new FakeElement('input'), { value: JSON.stringify(order) }),
|
||||||
|
};
|
||||||
|
if (excluded !== undefined) {
|
||||||
|
els.excluded = Object.assign(new FakeElement('input'), { value: JSON.stringify(excluded) });
|
||||||
|
}
|
||||||
|
const context = {
|
||||||
|
// The widget logs a failed list; expected there, so kept off the output.
|
||||||
|
console: fetchFails ? Object.assign({}, console, { error: () => {} }) : console,
|
||||||
|
window: {},
|
||||||
|
document: {
|
||||||
|
getElementById: (id) => els[id] || null,
|
||||||
|
createElement: (tag) => new FakeElement(tag),
|
||||||
|
createTextNode: (text) => new FakeElement('#text'),
|
||||||
|
},
|
||||||
|
fetch: () => (fetchFails ? Promise.reject(new Error('service restarting')) : Promise.resolve({
|
||||||
|
json: () => Promise.resolve({ status: 'success', data: { plugins } }),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
vm.createContext(context);
|
||||||
|
vm.runInContext(fs.readFileSync(WIDGET, 'utf8'), context);
|
||||||
|
context.window.PluginOrderList.init({
|
||||||
|
containerId: 'list', orderInputId: 'order',
|
||||||
|
excludedInputId: excluded !== undefined ? 'excluded' : undefined,
|
||||||
|
});
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
|
const rows = () => els.list.querySelectorAll('.plugin-order-item');
|
||||||
|
return {
|
||||||
|
rows,
|
||||||
|
rowIds: () => rows().map(r => r.dataset.pluginId),
|
||||||
|
order: () => JSON.parse(els.order.value),
|
||||||
|
excluded: () => JSON.parse(els.excluded.value),
|
||||||
|
row: (id) => rows().find(r => r.dataset.pluginId === id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLUGINS = [
|
||||||
|
{ id: 'weather', name: 'Weather', enabled: true },
|
||||||
|
{ id: 'clock', name: 'Clock', enabled: false },
|
||||||
|
{ id: 'stocks', name: 'Stocks', enabled: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
console.log('\nVegas: a disabled plugin keeps its place and its exclusion');
|
||||||
|
{
|
||||||
|
const t = await mount({ plugins: PLUGINS, order: ['weather', 'clock', 'stocks'], excluded: ['clock'] });
|
||||||
|
ok('only enabled plugins get a row', same(t.rowIds(), ['weather', 'stocks']), t.rowIds());
|
||||||
|
ok('drawing the list keeps the disabled plugin in the order, in its place',
|
||||||
|
same(t.order(), ['weather', 'clock', 'stocks']), t.order());
|
||||||
|
ok('drawing the list keeps its exclusion', same(t.excluded(), ['clock']), t.excluded());
|
||||||
|
|
||||||
|
// Move Stocks up: the rows swap, and Clock stays in its saved slot.
|
||||||
|
const up = t.row('stocks').querySelectorAll('.plugin-order-move')[0];
|
||||||
|
up.fire('click');
|
||||||
|
ok('reordering the rows fills the other slots in the new order',
|
||||||
|
same(t.order(), ['stocks', 'clock', 'weather']), t.order());
|
||||||
|
|
||||||
|
const include = t.row('weather').querySelector('.plugin-order-include');
|
||||||
|
include.checked = false;
|
||||||
|
include.fire('change');
|
||||||
|
ok('unchecking a row adds it, and the disabled exclusion stays',
|
||||||
|
same([...t.excluded()].sort(), ['clock', 'weather']), t.excluded());
|
||||||
|
include.checked = true;
|
||||||
|
include.fire('change');
|
||||||
|
ok('checking it again removes only that one', same(t.excluded(), ['clock']), t.excluded());
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nRotation order: the same, without exclusions');
|
||||||
|
{
|
||||||
|
const plugins = [
|
||||||
|
{ id: 'clock', enabled: true },
|
||||||
|
{ id: 'off', enabled: false },
|
||||||
|
{ id: 'weather', enabled: true },
|
||||||
|
{ id: 'new', enabled: true },
|
||||||
|
];
|
||||||
|
const t = await mount({ plugins, order: ['clock', 'off', 'weather'] });
|
||||||
|
ok('the disabled plugin keeps its slot; a plugin not in the saved order goes last',
|
||||||
|
same(t.order(), ['clock', 'off', 'weather', 'new']), t.order());
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nAn uninstalled plugin is dropped; a failed list keeps everything');
|
||||||
|
{
|
||||||
|
const t = await mount({ plugins: PLUGINS, order: ['weather', 'gone', 'clock', 'stocks'],
|
||||||
|
excluded: ['gone', 'clock'] });
|
||||||
|
ok('the disabled plugin is kept and the uninstalled one dropped from the order',
|
||||||
|
same(t.order(), ['weather', 'clock', 'stocks']), t.order());
|
||||||
|
ok('and from the exclusions', same(t.excluded(), ['clock']), t.excluded());
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const t = await mount({ plugins: PLUGINS, order: ['weather', 'gone', 'clock', 'stocks'],
|
||||||
|
excluded: ['gone', 'clock'], fetchFails: true });
|
||||||
|
// No installed list, so nothing can be told apart: no rows, and the
|
||||||
|
// inputs keep what was saved, uninstalled ids included.
|
||||||
|
ok('a failed plugin list draws no rows', t.rowIds().length === 0, t.rowIds());
|
||||||
|
ok('and leaves the saved order as it was',
|
||||||
|
same(t.order(), ['weather', 'gone', 'clock', 'stocks']), t.order());
|
||||||
|
ok('and the saved exclusions', same(t.excluded(), ['gone', 'clock']), t.excluded());
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nOnly what the server would accept is carried over');
|
||||||
|
{
|
||||||
|
const t = await mount({ plugins: PLUGINS, order: ['weather', 7, 'clock', null, 'clock', 'stocks'],
|
||||||
|
excluded: ['clock', 3, 'clock'] });
|
||||||
|
// /config/main refuses a list holding anything but strings, which would
|
||||||
|
// block every later Display save; a repeated id is kept once.
|
||||||
|
ok('non-string and repeated saved ids are dropped from the order',
|
||||||
|
same(t.order(), ['weather', 'clock', 'stocks']), t.order());
|
||||||
|
ok('and from the exclusions', same(t.excluded(), ['clock']), t.excluded());
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n${pass} passed, ${fail} failed`);
|
||||||
|
process.exit(fail ? 1 : 0);
|
||||||
|
})().catch(e => { console.error(e); process.exit(1); });
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
// The Plugin Store's category filter offers the categories its plugins have.
|
||||||
|
//
|
||||||
|
// The template listed seven fixed categories. The registry uses about
|
||||||
|
// twenty (productivity, utility, transit, finance, ...), so roughly a third
|
||||||
|
// of the store could not be filtered to at all, and "Financial" missed the
|
||||||
|
// plugin filed under "finance". The options are now built from the store's
|
||||||
|
// plugins, as the Starlark section builds its own; the template ships only
|
||||||
|
// "All Categories".
|
||||||
|
//
|
||||||
|
// Runs the whole of plugins_manager.js in the sandbox.
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { create, templateAttributes } = require('../plugins_manager_sandbox');
|
||||||
|
|
||||||
|
let pass = 0, fail = 0;
|
||||||
|
const ok = (label, cond, extra) => cond
|
||||||
|
? (pass++, console.log(' ok ' + label))
|
||||||
|
: (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' -> ' + JSON.stringify(extra).slice(0, 400) : '')));
|
||||||
|
|
||||||
|
const STORE = [
|
||||||
|
{ id: 'nfl', name: 'NFL', category: 'sports' },
|
||||||
|
{ id: 'nba', name: 'NBA', category: 'Sports' },
|
||||||
|
{ id: 'todo', name: 'Todo', category: 'productivity' },
|
||||||
|
{ id: 'stocks', name: 'Stocks', category: 'finance' },
|
||||||
|
{ id: 'crypto', name: 'Crypto', category: 'financial' },
|
||||||
|
{ id: 'bus', name: 'Bus', category: 'transit' },
|
||||||
|
{ id: 'mystery', name: 'Mystery' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function route(method, url) {
|
||||||
|
if (url.startsWith('/api/v3/plugins/store/list')) return { json: { status: 'success', data: { plugins: STORE } } };
|
||||||
|
if (url.startsWith('/api/v3/plugins/installed')) return { json: { status: 'success', data: { plugins: [] } } };
|
||||||
|
if (url.startsWith('/api/v3/plugins/store/github-status')) {
|
||||||
|
return { json: { status: 'success', data: { token_status: 'valid', authenticated: true, rate_limit: 5000 } } };
|
||||||
|
}
|
||||||
|
if (url.startsWith('/api/v3/plugins/saved-repositories')) {
|
||||||
|
return { json: { status: 'success', data: { repositories: [] } } };
|
||||||
|
}
|
||||||
|
if (url.startsWith('/api/v3/display/on-demand/status')) {
|
||||||
|
return { json: { status: 'success', data: { state: {}, service: {} } } };
|
||||||
|
}
|
||||||
|
return { json: { status: 'success' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = sel => sel.children.map(o => o.value);
|
||||||
|
const cardIds = sb => [...sb.el('plugin-store-grid').innerHTML.matchAll(/<h4[^>]*>([^<]*)<\/h4>/g)].map(m => m[1]);
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
console.log('\nthe template');
|
||||||
|
{
|
||||||
|
const html = fs.readFileSync(path.resolve(__dirname,
|
||||||
|
'../../../web_interface/templates/v3/partials/plugins.html'), 'utf8');
|
||||||
|
const start = html.indexOf('<select id="plugin-category"');
|
||||||
|
const block = html.slice(start, html.indexOf('</select>', start));
|
||||||
|
const shipped = [...block.matchAll(/<option value="([^"]*)"/g)].map(m => m[1]);
|
||||||
|
ok('ships only "All Categories"', JSON.stringify(shipped) === JSON.stringify(['']), shipped);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sb = create({ route });
|
||||||
|
const attrs = templateAttributes('plugin-category').attrs;
|
||||||
|
const select = sb.el('plugin-category', 'select', attrs);
|
||||||
|
sb.el('plugin-store-grid');
|
||||||
|
sb.el('installed-plugins-grid');
|
||||||
|
sb.window.initPluginsPage();
|
||||||
|
await sb.until(() => sb.requests.some(r => r.url.startsWith('/api/v3/plugins/store/list')), 'the store list');
|
||||||
|
await sb.settle();
|
||||||
|
|
||||||
|
console.log('\noptions come from the store\'s plugins');
|
||||||
|
ok('"All Categories" is still the first choice', /<option value="">All Categories<\/option>/.test(select.innerHTML),
|
||||||
|
select.innerHTML);
|
||||||
|
ok('every category a plugin has is offered once, whatever its case',
|
||||||
|
JSON.stringify(options(select)) === JSON.stringify(['finance', 'financial', 'productivity', 'sports', 'transit']),
|
||||||
|
options(select));
|
||||||
|
ok('labels are capitalised',
|
||||||
|
(select.children.find(o => o.value === 'productivity') || {}).textContent === 'Productivity');
|
||||||
|
|
||||||
|
console.log('\nchoosing one filters to it');
|
||||||
|
select.value = 'productivity';
|
||||||
|
select.dispatch('change');
|
||||||
|
ok('productivity shows its plugin', JSON.stringify(cardIds(sb)) === JSON.stringify(['Todo']), cardIds(sb));
|
||||||
|
select.value = 'finance';
|
||||||
|
select.dispatch('change');
|
||||||
|
ok('finance is not lost to "financial"', JSON.stringify(cardIds(sb)) === JSON.stringify(['Stocks']), cardIds(sb));
|
||||||
|
select.value = 'sports';
|
||||||
|
select.dispatch('change');
|
||||||
|
ok('one option covers both spellings of sports',
|
||||||
|
JSON.stringify(cardIds(sb).sort()) === JSON.stringify(['NBA', 'NFL']), cardIds(sb));
|
||||||
|
|
||||||
|
console.log('\nthe partial is swapped back in (tab switch)');
|
||||||
|
{
|
||||||
|
// A fresh <select> from the template, the store list still cached.
|
||||||
|
const fresh = new sb.FakeElement('plugin-category', 'select', attrs);
|
||||||
|
select.parentNode.replaceChild(fresh, select);
|
||||||
|
sb.window.searchPluginStore(false);
|
||||||
|
await sb.settle();
|
||||||
|
ok('the new select is filled from the cache',
|
||||||
|
JSON.stringify(options(fresh)) === JSON.stringify(['finance', 'financial', 'productivity', 'sports', 'transit']),
|
||||||
|
options(fresh));
|
||||||
|
ok('keeping the chosen category', fresh.value === 'sports', fresh.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n${pass} passed, ${fail} failed`);
|
||||||
|
process.exit(fail ? 1 : 0);
|
||||||
|
})().catch(e => { console.error(e); process.exit(1); });
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
// The store's Install button: which plugin it enables afterwards, and when.
|
||||||
|
//
|
||||||
|
// 1. Weather, Music, Stocks and Leaderboard are registry entries (`weather`)
|
||||||
|
// whose manifests declare another id (`ledmatrix-weather`). The plugin
|
||||||
|
// list, its config section and /plugins/toggle know them by that id, but
|
||||||
|
// the button enabled the registry id: /plugins/toggle answered 404
|
||||||
|
// "Plugin not found" and the plugin stayed disabled behind "installed,
|
||||||
|
// but enabling it failed". It now enables the id the install answer
|
||||||
|
// names (`plugin_id`), or, from an answer without one, the installed
|
||||||
|
// entry the store entry matches (its id, plugin_path name or aliases).
|
||||||
|
//
|
||||||
|
// 2. Reinstall (the same button on an installed plugin) enabled it too, so
|
||||||
|
// reinstalling a plugin the user had switched off switched it back on.
|
||||||
|
// Only a fresh install enables.
|
||||||
|
//
|
||||||
|
// Runs the whole of plugins_manager.js in the sandbox against a fake API.
|
||||||
|
|
||||||
|
const { create } = require('../plugins_manager_sandbox');
|
||||||
|
|
||||||
|
let pass = 0, fail = 0;
|
||||||
|
const ok = (label, cond, extra) => cond
|
||||||
|
? (pass++, console.log(' ok ' + label))
|
||||||
|
: (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' -> ' + JSON.stringify(extra).slice(0, 400) : '')));
|
||||||
|
|
||||||
|
const STORE = [
|
||||||
|
{ id: 'weather', name: 'Weather', category: 'weather', plugin_path: 'plugins/ledmatrix-weather',
|
||||||
|
aliases: ['ledmatrix-weather'] },
|
||||||
|
{ id: 'clock-simple', name: 'Clock', category: 'time', plugin_path: 'plugins/clock-simple', aliases: [] },
|
||||||
|
];
|
||||||
|
|
||||||
|
// A server with one install in flight. `queue` false answers the install
|
||||||
|
// directly; `names` false leaves plugin_id out of the answer (an older
|
||||||
|
// server); `installsAs` is the id the installed manifest declares.
|
||||||
|
function server({ installed = [], queue = true, names = true, installsAs }) {
|
||||||
|
const state = { installed: installed.map(p => ({ ...p })), polls: 0 };
|
||||||
|
const done = (id) => {
|
||||||
|
if (!state.installed.some(p => p.id === installsAs)) {
|
||||||
|
state.installed.push({ id: installsAs, name: id, enabled: false });
|
||||||
|
}
|
||||||
|
const result = { success: true, message: `Plugin ${id} installed successfully`, restart_required: false };
|
||||||
|
if (names) result.plugin_id = installsAs;
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
state.route = (method, url, body) => {
|
||||||
|
if (url.startsWith('/api/v3/plugins/store/list')) {
|
||||||
|
return { json: { status: 'success', data: { plugins: STORE } } };
|
||||||
|
}
|
||||||
|
if (url.startsWith('/api/v3/plugins/installed')) {
|
||||||
|
return { json: { status: 'success', data: { plugins: state.installed.map(p => ({ ...p })) } } };
|
||||||
|
}
|
||||||
|
if (method === 'POST' && url === '/api/v3/plugins/install') {
|
||||||
|
if (queue) return { json: { status: 'success', message: 'queued', data: { operation_id: 'op-1' } } };
|
||||||
|
return { json: { status: 'success', message: 'Plugin installed successfully', ...done(body.plugin_id) } };
|
||||||
|
}
|
||||||
|
if (url === '/api/v3/plugins/operation/op-1') {
|
||||||
|
state.polls++;
|
||||||
|
if (state.polls < 3) return { json: { status: 'success', data: { status: 'running' } } };
|
||||||
|
return { json: { status: 'success', data: { status: 'completed', result: done('weather') } } };
|
||||||
|
}
|
||||||
|
if (method === 'POST' && url === '/api/v3/plugins/toggle') {
|
||||||
|
const plugin = state.installed.find(p => p.id === body.plugin_id);
|
||||||
|
if (!plugin) return { status: 404, json: { status: 'error', message: 'Plugin not found' } };
|
||||||
|
plugin.enabled = body.enabled;
|
||||||
|
return { json: { status: 'success', message: `Plugin ${body.plugin_id} enabled successfully` } };
|
||||||
|
}
|
||||||
|
return { json: { status: 'success' } };
|
||||||
|
};
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function install(pluginId, opts) {
|
||||||
|
const srv = server(opts);
|
||||||
|
const sb = create({ route: srv.route });
|
||||||
|
sb.window.searchPluginStore();
|
||||||
|
await sb.until(() => sb.requests.some(r => r.url.startsWith('/api/v3/plugins/store/list')), 'store list');
|
||||||
|
await sb.window.pluginManager.loadInstalledPlugins(true);
|
||||||
|
await sb.settle();
|
||||||
|
sb.requests.length = 0;
|
||||||
|
sb.toasts.length = 0;
|
||||||
|
sb.window.installPlugin(pluginId);
|
||||||
|
await sb.until(() => sb.toasts.some(t => /installed and enabled|enabling it failed|reinstalled/.test(t.message)),
|
||||||
|
'the install to finish');
|
||||||
|
await sb.settle();
|
||||||
|
const toggles = sb.requests.filter(r => r.url === '/api/v3/plugins/toggle').map(r => r.body);
|
||||||
|
return { sb, srv, toggles };
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
console.log('\n1. a fresh install enables the id the plugin was installed as');
|
||||||
|
{
|
||||||
|
const { srv, toggles, sb } = await install('weather', { installsAs: 'ledmatrix-weather' });
|
||||||
|
ok('enables ledmatrix-weather, not the registry id',
|
||||||
|
JSON.stringify(toggles) === JSON.stringify([{ plugin_id: 'ledmatrix-weather', enabled: true }]), toggles);
|
||||||
|
ok('...which the server enabled', srv.installed.find(p => p.id === 'ledmatrix-weather').enabled === true, srv.installed);
|
||||||
|
ok('says so', sb.toasts.some(t => t.type === 'success' && /installed and enabled/.test(t.message)), sb.toasts);
|
||||||
|
ok('no "Plugin not found"', !sb.toasts.some(t => /not found|failed/.test(t.message)), sb.toasts);
|
||||||
|
const lastList = sb.requests.map(r => r.url).lastIndexOf('/api/v3/plugins/installed');
|
||||||
|
const toggleAt = sb.requests.findIndex(r => r.url === '/api/v3/plugins/toggle');
|
||||||
|
ok('the installed list is reloaded before enabling, so the new card is there to update',
|
||||||
|
lastList >= 0 && lastList < toggleAt, sb.requests.map(r => r.method + ' ' + r.url));
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const { toggles } = await install('weather', { installsAs: 'ledmatrix-weather', names: false });
|
||||||
|
ok('an answer without plugin_id: the installed entry the store entry matches (its alias)',
|
||||||
|
JSON.stringify(toggles) === JSON.stringify([{ plugin_id: 'ledmatrix-weather', enabled: true }]), toggles);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const { toggles } = await install('weather', { installsAs: 'ledmatrix-weather', queue: false });
|
||||||
|
ok('without the operation queue, from the direct answer',
|
||||||
|
JSON.stringify(toggles) === JSON.stringify([{ plugin_id: 'ledmatrix-weather', enabled: true }]), toggles);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const { toggles } = await install('clock-simple', { installsAs: 'clock-simple', names: false });
|
||||||
|
ok('a plugin installed under its registry id is enabled by that id',
|
||||||
|
JSON.stringify(toggles) === JSON.stringify([{ plugin_id: 'clock-simple', enabled: true }]), toggles);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n2. a reinstall leaves the plugin as the user had it');
|
||||||
|
{
|
||||||
|
const { srv, toggles, sb } = await install('weather', {
|
||||||
|
installsAs: 'ledmatrix-weather', installed: [{ id: 'ledmatrix-weather', name: 'Weather', enabled: false }],
|
||||||
|
});
|
||||||
|
ok('sends no toggle', toggles.length === 0, toggles);
|
||||||
|
ok('the plugin stays disabled', srv.installed.find(p => p.id === 'ledmatrix-weather').enabled === false);
|
||||||
|
ok('says it was reinstalled', sb.toasts.some(t => t.type === 'success' && /reinstalled/.test(t.message)), sb.toasts);
|
||||||
|
ok('and reloads the list',
|
||||||
|
sb.requests.some(r => r.url === '/api/v3/plugins/installed'), sb.requests.map(r => r.url));
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const { toggles } = await install('weather', {
|
||||||
|
installsAs: 'ledmatrix-weather', installed: [{ id: 'ledmatrix-weather', name: 'Weather', enabled: true }],
|
||||||
|
});
|
||||||
|
ok('an enabled plugin is not toggled either', toggles.length === 0, toggles);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n${pass} passed, ${fail} failed`);
|
||||||
|
process.exit(fail ? 1 : 0);
|
||||||
|
})().catch(e => { console.error(e); process.exit(1); });
|
||||||
@@ -52,7 +52,8 @@ global.installedPlugins = [];
|
|||||||
// eslint-disable-next-line no-eval
|
// eslint-disable-next-line no-eval
|
||||||
eval([
|
eval([
|
||||||
'function escapeHtml(text) {', 'function escapeAttribute(text) {', 'function jsStringAttr(value) {',
|
'function escapeHtml(text) {', 'function escapeAttribute(text) {', 'function jsStringAttr(value) {',
|
||||||
'function isStorePluginInstalled(pluginIdOrPlugin) {', 'function renderPluginStore(plugins) {',
|
'function isStorePluginInstalled(pluginIdOrPlugin) {',
|
||||||
|
'function findInstalledStorePlugin(pluginIdOrPlugin) {', 'function renderPluginStore(plugins) {',
|
||||||
].map(extract).join('\n') + '\nglobal.renderPluginStore = renderPluginStore;'
|
].map(extract).join('\n') + '\nglobal.renderPluginStore = renderPluginStore;'
|
||||||
+ '\nglobal.isStorePluginInstalled = isStorePluginInstalled;');
|
+ '\nglobal.isStorePluginInstalled = isStorePluginInstalled;');
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ function fakeApi(behaviour = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function setup(api, { stateList, windowList } = {}) {
|
function setup(api, { stateList, windowList, pluginManager } = {}) {
|
||||||
global.window = {
|
global.window = {
|
||||||
PluginAPI: api,
|
PluginAPI: api,
|
||||||
installedPlugins: windowList,
|
installedPlugins: windowList,
|
||||||
@@ -60,6 +60,7 @@ function setup(api, { stateList, windowList } = {}) {
|
|||||||
installedPlugins: stateList,
|
installedPlugins: stateList,
|
||||||
loadInstalledPlugins: async () => stateList,
|
loadInstalledPlugins: async () => stateList,
|
||||||
},
|
},
|
||||||
|
pluginManager,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,12 +93,68 @@ const noSleep = { sleep: async () => {} };
|
|||||||
ok('progress total counts only what is sent',
|
ok('progress total counts only what is sent',
|
||||||
progress.length === EXPECTED.length && progress.every(([, n]) => n === EXPECTED.length), progress);
|
progress.length === EXPECTED.length && progress.every(([, n]) => n === EXPECTED.length), progress);
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
// A page without the plugin manager has no window.installedPlugins.
|
||||||
|
const api = fakeApi();
|
||||||
|
setup(api, { stateList: INSTALLED });
|
||||||
|
await Manager.updateAll(null, noSleep);
|
||||||
|
ok('the PluginStateManager list (no live list) is filtered the same way',
|
||||||
|
JSON.stringify(api.calls) === JSON.stringify(EXPECTED), api.calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\na second run sends the live list, not the first run\'s snapshot');
|
||||||
|
{
|
||||||
|
// Run 1 leaves PluginStateManager holding a, b, c. Then c is uninstalled
|
||||||
|
// and d installed: plugins_manager.js publishes that only as
|
||||||
|
// window.installedPlugins. Run 2 used to send a, b, c -- c failed as
|
||||||
|
// "plugin not found" and d, which had an update waiting, was skipped.
|
||||||
|
const api = fakeApi({
|
||||||
|
c: () => { throw { error_code: 'PLUGIN_UPDATE_FAILED', message: 'Plugin update failed: plugin not found' }; },
|
||||||
|
});
|
||||||
|
const stale = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
|
||||||
|
setup(api, { stateList: stale, windowList: [{ id: 'a' }, { id: 'b' }, { id: 'd' }] });
|
||||||
|
const results = await Manager.updateAll(null, noSleep);
|
||||||
|
ok('sends exactly what is installed now',
|
||||||
|
JSON.stringify(api.calls) === JSON.stringify(['a', 'b', 'd']), api.calls);
|
||||||
|
ok('...so nothing fails over an uninstalled plugin', results.every(r => r.success), results);
|
||||||
|
}
|
||||||
{
|
{
|
||||||
const api = fakeApi();
|
const api = fakeApi();
|
||||||
setup(api, { stateList: INSTALLED, windowList: [] });
|
setup(api, { stateList: INSTALLED, windowList: [] });
|
||||||
|
const results = await Manager.updateAll(null, noSleep);
|
||||||
|
ok('an empty live list means nothing is installed: nothing is sent',
|
||||||
|
api.calls.length === 0 && results.length === 0, api.calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nthe end-of-run refresh redraws the installed grid');
|
||||||
|
{
|
||||||
|
// PluginStateManager's refresh replaced window.installedPlugins and
|
||||||
|
// nothing else: the cards kept "Update to vX" and the Updates badge
|
||||||
|
// kept its count. The plugin manager's load renders the grid.
|
||||||
|
const loads = [];
|
||||||
|
let stateLoads = 0;
|
||||||
|
const pluginManager = { loadInstalledPlugins: async (force) => { loads.push(force); } };
|
||||||
|
setup(fakeApi(), { stateList: INSTALLED, windowList: INSTALLED, pluginManager });
|
||||||
|
window.PluginStateManager.loadInstalledPlugins = async () => { stateLoads++; };
|
||||||
await Manager.updateAll(null, noSleep);
|
await Manager.updateAll(null, noSleep);
|
||||||
ok('the PluginStateManager list is filtered the same way',
|
ok('reloads through the plugin manager once, forced past its caches',
|
||||||
JSON.stringify(api.calls) === JSON.stringify(EXPECTED), api.calls);
|
JSON.stringify(loads) === JSON.stringify([true]), loads);
|
||||||
|
ok('...instead of PluginStateManager', stateLoads === 0, stateLoads);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const pluginManager = { loadInstalledPlugins: async () => { throw new Error('offline'); } };
|
||||||
|
const answer = { status: 'success', data: { update_status: 'updated' }, restart_required: true };
|
||||||
|
setup(fakeApi({ 'ledmatrix-flights': () => answer }), { windowList: INSTALLED, pluginManager });
|
||||||
|
const warn = console.warn;
|
||||||
|
console.warn = () => {};
|
||||||
|
let results;
|
||||||
|
try {
|
||||||
|
results = await Manager.updateAll(null, noSleep);
|
||||||
|
} finally {
|
||||||
|
console.warn = warn;
|
||||||
|
}
|
||||||
|
ok('a failed plugin-manager reload still returns the results with their restart flag',
|
||||||
|
Array.isArray(results) && Manager.restartRequest(results) === answer);
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
const api = fakeApi();
|
const api = fakeApi();
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"""POST /plugins/install says which id the plugin was installed as.
|
||||||
|
|
||||||
|
A registry entry can install under another id: `weather` (aliases
|
||||||
|
`ledmatrix-weather`) installs a directory whose manifest declares
|
||||||
|
`ledmatrix-weather`, and that is the id the plugin list, the plugin's config
|
||||||
|
section and /plugins/toggle know it by. The store's Install button enabled
|
||||||
|
the new plugin by the registry id, which /plugins/toggle answered with 404
|
||||||
|
"Plugin not found", so Weather, Music, Stocks and Leaderboard installed
|
||||||
|
disabled behind an "enabling it failed" warning.
|
||||||
|
|
||||||
|
The answer -- the queued operation's result, or the direct response --
|
||||||
|
carries `plugin_id`: the id the installed manifest declares, found the way
|
||||||
|
the store's update and uninstall find an install.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401
|
||||||
|
|
||||||
|
INSTALL = "/api/v3/plugins/install"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def store(api_v3_module, tmp_path):
|
||||||
|
manager = api_v3_module.api_v3.plugin_store_manager
|
||||||
|
manager.install_plugin.return_value = True
|
||||||
|
manager.get_registry_info.return_value = None
|
||||||
|
manager._find_plugin_path.return_value = None
|
||||||
|
|
||||||
|
def installed_as(directory, manifest):
|
||||||
|
path = tmp_path / directory
|
||||||
|
path.mkdir()
|
||||||
|
(path / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
|
||||||
|
manager._find_plugin_path.side_effect = (
|
||||||
|
lambda pid: path if pid == "weather" else None)
|
||||||
|
return path
|
||||||
|
|
||||||
|
manager.installed_as = installed_as
|
||||||
|
return manager
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def queued(api_v3_module):
|
||||||
|
queue = MagicMock()
|
||||||
|
|
||||||
|
def enqueue(operation_type, plugin_id, operation_callback=None):
|
||||||
|
queue.callback_result = operation_callback(MagicMock())
|
||||||
|
return "op-1"
|
||||||
|
|
||||||
|
queue.enqueue_operation.side_effect = enqueue
|
||||||
|
api_v3_module.api_v3.operation_queue = queue
|
||||||
|
return queue
|
||||||
|
|
||||||
|
|
||||||
|
class TestDirectInstall:
|
||||||
|
def test_an_aliased_entry_reports_the_manifest_id(self, api_v3_client, store):
|
||||||
|
store.installed_as("ledmatrix-weather", {"id": "ledmatrix-weather"})
|
||||||
|
body = api_v3_client.post(INSTALL, json={"plugin_id": "weather"}).get_json()
|
||||||
|
assert body["status"] == "success"
|
||||||
|
assert body["plugin_id"] == "ledmatrix-weather"
|
||||||
|
store._find_plugin_path.assert_called_with("weather")
|
||||||
|
|
||||||
|
def test_an_entry_installed_under_its_own_id_reports_that(self, api_v3_client, store):
|
||||||
|
store.installed_as("weather", {"id": "weather"})
|
||||||
|
body = api_v3_client.post(INSTALL, json={"plugin_id": "weather"}).get_json()
|
||||||
|
assert body["plugin_id"] == "weather"
|
||||||
|
|
||||||
|
def test_an_install_that_cannot_be_found_reports_the_requested_id(self, api_v3_client, store):
|
||||||
|
body = api_v3_client.post(INSTALL, json={"plugin_id": "weather"}).get_json()
|
||||||
|
assert body["status"] == "success"
|
||||||
|
assert body["plugin_id"] == "weather"
|
||||||
|
|
||||||
|
def test_a_manifest_id_that_is_not_a_plain_name_is_not_passed_on(self, api_v3_client, store):
|
||||||
|
store.installed_as("ledmatrix-weather", {"id": "../elsewhere"})
|
||||||
|
body = api_v3_client.post(INSTALL, json={"plugin_id": "weather"}).get_json()
|
||||||
|
assert body["plugin_id"] == "weather"
|
||||||
|
|
||||||
|
def test_an_unreadable_manifest_reports_the_requested_id(self, api_v3_client, store):
|
||||||
|
path = store.installed_as("ledmatrix-weather", {})
|
||||||
|
(path / "manifest.json").write_text("[not json", encoding="utf-8")
|
||||||
|
body = api_v3_client.post(INSTALL, json={"plugin_id": "weather"}).get_json()
|
||||||
|
assert body["plugin_id"] == "weather"
|
||||||
|
|
||||||
|
def test_the_restart_fields_are_still_sent(self, api_v3_client, store):
|
||||||
|
store.installed_as("ledmatrix-weather", {"id": "ledmatrix-weather"})
|
||||||
|
body = api_v3_client.post(INSTALL, json={"plugin_id": "weather"}).get_json()
|
||||||
|
assert "restart_required" in body
|
||||||
|
|
||||||
|
|
||||||
|
class TestQueuedInstall:
|
||||||
|
def test_the_operation_result_names_the_manifest_id(self, api_v3_client, store, queued):
|
||||||
|
store.installed_as("ledmatrix-weather", {"id": "ledmatrix-weather"})
|
||||||
|
body = api_v3_client.post(INSTALL, json={"plugin_id": "weather"}).get_json()
|
||||||
|
assert body["data"]["operation_id"] == "op-1"
|
||||||
|
assert queued.callback_result["success"] is True
|
||||||
|
assert queued.callback_result["plugin_id"] == "ledmatrix-weather"
|
||||||
|
|
||||||
|
def test_an_install_that_cannot_be_found_names_the_requested_id(
|
||||||
|
self, api_v3_client, store, queued):
|
||||||
|
api_v3_client.post(INSTALL, json={"plugin_id": "weather"})
|
||||||
|
assert queued.callback_result["plugin_id"] == "weather"
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""GET /api/v3/plugins/installed carries each plugin's ``display_modes``.
|
||||||
|
|
||||||
|
The on-demand modal (plugins_manager.js) fills its Display Mode list from
|
||||||
|
``plugin.display_modes``, but the route never included the field, so every
|
||||||
|
plugin offered one option -- its own id -- under "This plugin exposes a
|
||||||
|
single display mode". The display turns that id into the plugin's first
|
||||||
|
mode, so a multi-mode plugin could only be started, and pinned, on that one.
|
||||||
|
|
||||||
|
The modes come from the plugin catalog (the manifests the web process
|
||||||
|
discovered), the same source /display/modes and on-demand/start use.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from test._api_v3_test_helpers import ( # noqa: F401 - fixtures
|
||||||
|
api_v3_client, api_v3_module,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def installed(api_v3_module, api_v3_client, tmp_path):
|
||||||
|
def _get(declared_modes):
|
||||||
|
api = api_v3_module.api_v3
|
||||||
|
# The listing's own metadata says nothing about modes: what the
|
||||||
|
# route reports must come from the catalog.
|
||||||
|
info = {'id': 'football-scoreboard', 'name': 'Football', 'version': '1.0.0'}
|
||||||
|
api.plugin_catalog.plugins_dir = str(tmp_path) # no manifest on disk
|
||||||
|
api.plugin_catalog.get_all_plugin_info = MagicMock(return_value=[info])
|
||||||
|
api.plugin_catalog.get_plugin_display_modes = MagicMock(return_value=declared_modes)
|
||||||
|
api.plugin_store_manager.get_registry_info = MagicMock(return_value=None)
|
||||||
|
api.config_manager.load_config = MagicMock(return_value={})
|
||||||
|
response = api_v3_client.get('/api/v3/plugins/installed')
|
||||||
|
assert response.status_code == 200
|
||||||
|
plugins = [p for p in response.get_json()['data']['plugins']
|
||||||
|
if p['id'] == 'football-scoreboard']
|
||||||
|
assert len(plugins) == 1
|
||||||
|
api.plugin_catalog.get_plugin_display_modes.assert_any_call('football-scoreboard')
|
||||||
|
return plugins[0]
|
||||||
|
return _get
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_declared_mode_is_listed_in_order(installed):
|
||||||
|
modes = ['nfl_live', 'nfl_recent', 'nfl_upcoming']
|
||||||
|
assert installed(modes)['display_modes'] == modes
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_single_mode_plugin_lists_its_one_mode(installed):
|
||||||
|
assert installed(['clock-simple'])['display_modes'] == ['clock-simple']
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_declared_modes_is_an_empty_list(installed):
|
||||||
|
# The modal falls back to the plugin id for an empty list.
|
||||||
|
assert installed([])['display_modes'] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_hand_edited_manifest_cannot_put_non_strings_in_the_list(installed):
|
||||||
|
assert installed(['nfl_live', 7, None, {'x': 1}])['display_modes'] == ['nfl_live']
|
||||||
@@ -37,6 +37,7 @@ from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401
|
|||||||
START_URL = "/api/v3/display/on-demand/start"
|
START_URL = "/api/v3/display/on-demand/start"
|
||||||
STOP_URL = "/api/v3/display/on-demand/stop"
|
STOP_URL = "/api/v3/display/on-demand/stop"
|
||||||
MAILBOX = "display_on_demand_request"
|
MAILBOX = "display_on_demand_request"
|
||||||
|
DISPLAY = "web_interface.blueprints.api_v3.display"
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -150,6 +151,102 @@ class TestStartWhileTheServiceIsStopped:
|
|||||||
assert response.get_json()["status"] == "error"
|
assert response.get_json()["status"] == "error"
|
||||||
|
|
||||||
|
|
||||||
|
class _Mailbox:
|
||||||
|
"""The CacheManager calls the routes make, over a dict."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.entries = {}
|
||||||
|
|
||||||
|
def set(self, key, value, ttl=None):
|
||||||
|
self.entries[key] = value
|
||||||
|
|
||||||
|
def get(self, key, max_age=300, memory_ttl=None):
|
||||||
|
return self.entries.get(key)
|
||||||
|
|
||||||
|
def delete(self, key):
|
||||||
|
self.entries.pop(key, None)
|
||||||
|
|
||||||
|
|
||||||
|
class TestARefusedStartLeavesNoRequestBehind:
|
||||||
|
"""A start the route answers with an error must not run later.
|
||||||
|
|
||||||
|
The request was posted (to the mailbox, with the display stopped) before
|
||||||
|
the route refused it, and the display reads the mailbox for an hour
|
||||||
|
without looking at a request's age. So "Display service is not running"
|
||||||
|
(start_service off) or "Failed to start display service" left the
|
||||||
|
request waiting, and the next time the display started -- minutes later,
|
||||||
|
by hand -- it ran that plugin, pinned if the request said so.
|
||||||
|
|
||||||
|
A socket acknowledgement is the other side of it: the display answered,
|
||||||
|
so it is running and has the request, whatever systemd says (a display
|
||||||
|
run by hand or in the emulator has no active unit). That is a success,
|
||||||
|
not "not running", and no unit is started beside it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mailbox(self, api_v3_module, service):
|
||||||
|
box = _Mailbox()
|
||||||
|
api_v3_module.api_v3.cache_manager = box
|
||||||
|
service["state"]["active"] = False
|
||||||
|
return box
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("body", [
|
||||||
|
{"plugin_id": "weather", "start_service": False},
|
||||||
|
{"plugin_id": "weather"}, # start_service defaults on
|
||||||
|
])
|
||||||
|
def test_a_socket_ack_is_a_success_whatever_systemd_says(
|
||||||
|
self, api_v3_client, service, mailbox, body):
|
||||||
|
with patch(f"{DISPLAY}.control_client.on_demand_start",
|
||||||
|
side_effect=lambda request_id, *a: {"accepted": True}):
|
||||||
|
response = api_v3_client.post(START_URL, json=body)
|
||||||
|
assert response.status_code == 200, response.get_json()
|
||||||
|
assert response.get_json()["data"]["transport"] == "socket"
|
||||||
|
assert MAILBOX not in mailbox.entries
|
||||||
|
assert _systemctl_verbs(service["systemctl"]) == [], (
|
||||||
|
"a unit was started beside a display that answered the socket")
|
||||||
|
|
||||||
|
def test_without_start_service_the_request_is_taken_back(
|
||||||
|
self, api_v3_client, service, mailbox):
|
||||||
|
response = api_v3_client.post(START_URL, json={
|
||||||
|
"plugin_id": "weather", "pinned": True, "start_service": False})
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.get_json()["status"] == "error"
|
||||||
|
assert MAILBOX not in mailbox.entries
|
||||||
|
|
||||||
|
def test_a_start_that_fails_takes_its_request_back(self, api_v3_client, service, mailbox):
|
||||||
|
service["systemctl"].side_effect = lambda args: {
|
||||||
|
"returncode": 1, "stdout": "", "stderr": "denied"}
|
||||||
|
response = api_v3_client.post(START_URL, json={"plugin_id": "weather"})
|
||||||
|
assert response.status_code == 500
|
||||||
|
assert MAILBOX not in mailbox.entries
|
||||||
|
|
||||||
|
def test_a_newer_request_is_left_alone_on_the_400(self, api_v3_client, service, mailbox):
|
||||||
|
newer = {"request_id": "someone-else", "action": "start", "plugin_id": "clock"}
|
||||||
|
|
||||||
|
def stopped_and_another_post_lands(*args):
|
||||||
|
mailbox.entries[MAILBOX] = newer
|
||||||
|
return {"active": False}
|
||||||
|
|
||||||
|
with patch(f"{DISPLAY}._get_display_service_status",
|
||||||
|
side_effect=stopped_and_another_post_lands):
|
||||||
|
response = api_v3_client.post(START_URL, json={
|
||||||
|
"plugin_id": "weather", "start_service": False})
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert mailbox.entries[MAILBOX] is newer
|
||||||
|
|
||||||
|
def test_a_newer_request_is_left_alone_on_the_500(self, api_v3_client, service, mailbox):
|
||||||
|
newer = {"request_id": "someone-else", "action": "start", "plugin_id": "clock"}
|
||||||
|
|
||||||
|
def start_fails_after_another_post(args):
|
||||||
|
mailbox.entries[MAILBOX] = newer
|
||||||
|
return {"returncode": 1, "stdout": "", "stderr": "denied"}
|
||||||
|
|
||||||
|
service["systemctl"].side_effect = start_fails_after_another_post
|
||||||
|
response = api_v3_client.post(START_URL, json={"plugin_id": "weather"})
|
||||||
|
assert response.status_code == 500
|
||||||
|
assert mailbox.entries[MAILBOX] is newer
|
||||||
|
|
||||||
|
|
||||||
class TestStop:
|
class TestStop:
|
||||||
def test_stop_posts_a_stop_request_and_leaves_the_service_running(
|
def test_stop_posts_a_stop_request_and_leaves_the_service_running(
|
||||||
self, api_v3_client, service):
|
self, api_v3_client, service):
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""GET /api/v3/plugins/operation/<id> answers for an operation still waiting.
|
||||||
|
|
||||||
|
PluginOperationQueue keeps an operation's callback in its parameters, under
|
||||||
|
``_callback``, until the worker takes it to run. PluginOperation.to_dict()
|
||||||
|
returned the parameters as they were, so for a pending operation the route
|
||||||
|
handed jsonify a function and answered 500 "A system error occurred". That
|
||||||
|
is every poll of an install queued behind another plugin's: the second of
|
||||||
|
two installs read as broken until the first one finished.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
||||||
|
|
||||||
|
from src.plugin_system.operation_queue import PluginOperationQueue # noqa: E402
|
||||||
|
from src.plugin_system.operation_types import ( # noqa: E402
|
||||||
|
OperationType, PluginOperation,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _callback(op):
|
||||||
|
return {"success": True, "message": "done"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestToDict:
|
||||||
|
def test_private_parameters_are_left_out(self):
|
||||||
|
op = PluginOperation(OperationType.INSTALL, "demo",
|
||||||
|
parameters={"_callback": _callback, "branch": "main"})
|
||||||
|
assert op.to_dict()["parameters"] == {"branch": "main"}
|
||||||
|
json.dumps(op.to_dict()) # serializable
|
||||||
|
|
||||||
|
def test_the_operation_keeps_its_callback_for_the_worker(self):
|
||||||
|
op = PluginOperation(OperationType.INSTALL, "demo",
|
||||||
|
parameters={"_callback": _callback})
|
||||||
|
op.to_dict()
|
||||||
|
assert op.parameters["_callback"] is _callback
|
||||||
|
|
||||||
|
def test_the_other_fields_are_unchanged(self):
|
||||||
|
op = PluginOperation(OperationType.UNINSTALL, "demo", operation_id="op-1")
|
||||||
|
assert op.to_dict() == {
|
||||||
|
"operation_id": "op-1", "operation_type": "uninstall", "plugin_id": "demo",
|
||||||
|
"parameters": {}, "status": "pending", "progress": 0.0, "message": "",
|
||||||
|
"error": None, "result": None,
|
||||||
|
"created_at": op.created_at.isoformat(), "started_at": None,
|
||||||
|
"completed_at": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestTheRoute:
|
||||||
|
@pytest.fixture
|
||||||
|
def busy_queue(self, api_v3_module):
|
||||||
|
"""A real queue whose worker is held by another plugin's operation."""
|
||||||
|
queue = PluginOperationQueue(max_history=10)
|
||||||
|
api_v3_module.api_v3.operation_queue = queue
|
||||||
|
started, release = threading.Event(), threading.Event()
|
||||||
|
|
||||||
|
def blocker(op):
|
||||||
|
started.set()
|
||||||
|
release.wait(10)
|
||||||
|
return {"success": True, "message": "done"}
|
||||||
|
|
||||||
|
queue.enqueue_operation(OperationType.INSTALL, "busy", operation_callback=blocker)
|
||||||
|
assert started.wait(5)
|
||||||
|
yield queue
|
||||||
|
release.set()
|
||||||
|
queue.shutdown()
|
||||||
|
|
||||||
|
def test_a_pending_operation_reports_pending(self, api_v3_client, busy_queue):
|
||||||
|
op_id = busy_queue.enqueue_operation(
|
||||||
|
OperationType.INSTALL, "demo", operation_callback=_callback)
|
||||||
|
response = api_v3_client.get(f"/api/v3/plugins/operation/{op_id}")
|
||||||
|
assert response.status_code == 200, response.get_json()
|
||||||
|
data = response.get_json()["data"]
|
||||||
|
assert data["status"] == "pending"
|
||||||
|
assert data["plugin_id"] == "demo"
|
||||||
|
assert "_callback" not in data["parameters"]
|
||||||
@@ -253,6 +253,32 @@ class TestVegasCycleDurations:
|
|||||||
assert saved['config']['display']['display_durations'] == {'clock': 45}
|
assert saved['config']['display']['display_durations'] == {'clock': 45}
|
||||||
|
|
||||||
|
|
||||||
|
class TestMalformedBody:
|
||||||
|
"""A JSON body that does not parse is the caller's mistake: a 400.
|
||||||
|
|
||||||
|
get_json() raised Werkzeug's BadRequest inside the handler's try, whose
|
||||||
|
catch-all answered 500 CONFIG_SAVE_FAILED with "check file permissions"
|
||||||
|
advice and logged a traceback at ERROR.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_is_a_400_in_the_raw_routes_shape(self, api_v3_client, saved, api_v3_module):
|
||||||
|
api_v3_module.api_v3.config_manager.get_raw_file_content.return_value = {}
|
||||||
|
resp = api_v3_client.post('/api/v3/config/main', data='{not json',
|
||||||
|
content_type='application/json')
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert resp.get_json() == {'status': 'error', 'message': 'Invalid JSON in request body'}
|
||||||
|
assert 'config' not in saved
|
||||||
|
raw = api_v3_client.post('/api/v3/config/raw/main', data='{not json',
|
||||||
|
content_type='application/json')
|
||||||
|
assert (raw.status_code, raw.get_json()) == (400, resp.get_json())
|
||||||
|
|
||||||
|
def test_an_empty_json_post_is_still_no_data(self, api_v3_client, saved):
|
||||||
|
resp = api_v3_client.post('/api/v3/config/main', data='',
|
||||||
|
content_type='application/json')
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert resp.get_json()['message'] == 'No data provided'
|
||||||
|
|
||||||
|
|
||||||
class TestRawSaveStartsAutoUpdateSetup:
|
class TestRawSaveStartsAutoUpdateSetup:
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def raw_env(self, api_v3_module, monkeypatch):
|
def raw_env(self, api_v3_module, monkeypatch):
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""POST /api/v3/plugins/action hands ``params`` to the plugin's script intact.
|
||||||
|
|
||||||
|
The route runs the script through a generated wrapper, and the params went
|
||||||
|
into that wrapper as Python source: ``params = {json.dumps(params)}``. JSON is
|
||||||
|
not Python. ``true``, ``false`` and ``null`` are undefined names there, so any
|
||||||
|
params holding a boolean or a null died with a NameError before the script
|
||||||
|
ran. The plugin file manager's category toggle sends ``{"category_name": ...,
|
||||||
|
"enabled": true}``, so of-the-day's category toggle failed every time with
|
||||||
|
"Action failed".
|
||||||
|
|
||||||
|
The script's side of the contract is unchanged and pinned here too: the
|
||||||
|
params arrive on stdin as one JSON document, LEDMATRIX_ROOT is set, and what
|
||||||
|
the script prints to stdout is what the route parses.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
||||||
|
|
||||||
|
ACTION_URL = "/api/v3/plugins/action"
|
||||||
|
|
||||||
|
# The action script: report what it was handed, as JSON on stdout.
|
||||||
|
ECHO_SCRIPT = (
|
||||||
|
"import json, os, sys\n"
|
||||||
|
"raw = sys.stdin.read()\n"
|
||||||
|
"print(json.dumps({'status': 'success', 'got': json.loads(raw),\n"
|
||||||
|
" 'root': os.environ.get('LEDMATRIX_ROOT')}))\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def echo_plugin(tmp_path, api_v3_module, monkeypatch):
|
||||||
|
plugin_dir = tmp_path / "demo"
|
||||||
|
plugin_dir.mkdir()
|
||||||
|
(plugin_dir / "manifest.json").write_text(json.dumps({
|
||||||
|
"id": "demo",
|
||||||
|
"web_ui_actions": [{"id": "toggle", "type": "script", "script": "echo.py"}],
|
||||||
|
}), encoding="utf-8")
|
||||||
|
(plugin_dir / "echo.py").write_text(ECHO_SCRIPT, encoding="utf-8")
|
||||||
|
api_v3_module.api_v3.plugin_catalog.get_plugin_directory.return_value = str(plugin_dir)
|
||||||
|
|
||||||
|
# The route runs `python3`; use this interpreter, so the test does not
|
||||||
|
# depend on what that name resolves to here.
|
||||||
|
real_run = subprocess.run
|
||||||
|
|
||||||
|
def run(cmd, *args, **kwargs):
|
||||||
|
if isinstance(cmd, list) and cmd and cmd[0] == "python3":
|
||||||
|
cmd = [sys.executable] + cmd[1:]
|
||||||
|
return real_run(cmd, *args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(subprocess, "run", run)
|
||||||
|
return plugin_dir
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("params", [
|
||||||
|
{"category_name": "jokes", "enabled": True}, # the file manager's toggle
|
||||||
|
{"category_name": "jokes", "enabled": False},
|
||||||
|
{"filename": None},
|
||||||
|
{"nested": {"list": [1, None, True, 2.5], "empty": {}}},
|
||||||
|
{"text": "café ✓ \U0001F600"},
|
||||||
|
{"text": "he said \"hi\" and 'bye' \\ ''' \"\"\" \n\t end"},
|
||||||
|
], ids=["true", "false", "null", "nested", "unicode", "quotes"])
|
||||||
|
def test_the_script_receives_the_params_it_was_sent(api_v3_client, echo_plugin, params):
|
||||||
|
response = api_v3_client.post(ACTION_URL, json={
|
||||||
|
"plugin_id": "demo", "action_id": "toggle", "params": params})
|
||||||
|
body = response.get_json()
|
||||||
|
assert response.status_code == 200, body
|
||||||
|
assert body["got"] == params
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_param_cannot_run_code_in_the_wrapper(api_v3_client, echo_plugin, tmp_path):
|
||||||
|
marker = tmp_path / "PWNED"
|
||||||
|
hostile = "\"}\nopen(%r, 'w').write('ran')\n#" % str(marker)
|
||||||
|
params = {"name": hostile, "flag": True}
|
||||||
|
response = api_v3_client.post(ACTION_URL, json={
|
||||||
|
"plugin_id": "demo", "action_id": "toggle", "params": params})
|
||||||
|
assert response.status_code == 200, response.get_json()
|
||||||
|
assert response.get_json()["got"] == params
|
||||||
|
assert not marker.exists(), "a param value ran as code"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_script_still_gets_ledmatrix_root(api_v3_client, echo_plugin, api_v3_module):
|
||||||
|
response = api_v3_client.post(ACTION_URL, json={
|
||||||
|
"plugin_id": "demo", "action_id": "toggle", "params": {"enabled": True}})
|
||||||
|
assert response.status_code == 200, response.get_json()
|
||||||
|
assert response.get_json()["root"] == str(api_v3_module.PROJECT_ROOT)
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""A second install or uninstall while one is in progress is a 409, not a 500.
|
||||||
|
|
||||||
|
PluginOperationQueue refuses a second operation for a plugin that already
|
||||||
|
has one waiting or running (test_operation_queue_pending_and_trim.py), and
|
||||||
|
says so by raising ValueError. /plugins/install let that escape to the
|
||||||
|
blueprint's catch-all, so a double-clicked Install answered 500 "An error
|
||||||
|
occurred; see logs for details" while the first install carried on.
|
||||||
|
/plugins/uninstall caught it in its own catch-all: a 500 "Failed to
|
||||||
|
uninstall plugin", and an "uninstall failed" entry in the operation
|
||||||
|
history for an uninstall that never started.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
||||||
|
|
||||||
|
from src.plugin_system.operation_queue import PluginOperationQueue # noqa: E402
|
||||||
|
|
||||||
|
INSTALL = "/api/v3/plugins/install"
|
||||||
|
UNINSTALL = "/api/v3/plugins/uninstall"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def installing(api_v3_module, tmp_path):
|
||||||
|
"""A real queue with an install of "clock" running and held there."""
|
||||||
|
queue = PluginOperationQueue(max_history=10)
|
||||||
|
api_v3_module.api_v3.operation_queue = queue
|
||||||
|
started, release = threading.Event(), threading.Event()
|
||||||
|
|
||||||
|
def slow_install(plugin_id, branch=None):
|
||||||
|
started.set()
|
||||||
|
release.wait(10)
|
||||||
|
return True
|
||||||
|
|
||||||
|
store = api_v3_module.api_v3.plugin_store_manager
|
||||||
|
store.install_plugin.side_effect = slow_install
|
||||||
|
store.get_registry_info.return_value = None
|
||||||
|
store.plugins_dir = str(tmp_path)
|
||||||
|
api_v3_module.api_v3.plugin_catalog.get_plugin_directory.return_value = None
|
||||||
|
yield {"queue": queue, "started": started, "store": store}
|
||||||
|
release.set()
|
||||||
|
queue.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
def _start_first_install(client, installing):
|
||||||
|
response = client.post(INSTALL, json={"plugin_id": "clock"})
|
||||||
|
assert response.status_code == 200, response.get_json()
|
||||||
|
assert installing["started"].wait(5)
|
||||||
|
|
||||||
|
|
||||||
|
def _failed_history(api_v3_module):
|
||||||
|
return [c for c in api_v3_module.api_v3.operation_history.record_operation.call_args_list
|
||||||
|
if c.kwargs.get("status") == "failed"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_second_install_click_is_a_conflict(api_v3_client, api_v3_module, installing):
|
||||||
|
_start_first_install(api_v3_client, installing)
|
||||||
|
response = api_v3_client.post(INSTALL, json={"plugin_id": "clock"})
|
||||||
|
assert response.status_code == 409, response.get_json()
|
||||||
|
body = response.get_json()
|
||||||
|
assert body["status"] == "error"
|
||||||
|
assert body["error_code"] == "PLUGIN_OPERATION_CONFLICT"
|
||||||
|
assert "clock" in body["message"]
|
||||||
|
assert installing["store"].install_plugin.call_count == 1
|
||||||
|
assert _failed_history(api_v3_module) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_uninstall_during_the_install_is_a_conflict(api_v3_client, api_v3_module,
|
||||||
|
installing):
|
||||||
|
_start_first_install(api_v3_client, installing)
|
||||||
|
response = api_v3_client.post(UNINSTALL, json={"plugin_id": "clock"})
|
||||||
|
assert response.status_code == 409, response.get_json()
|
||||||
|
assert response.get_json()["error_code"] == "PLUGIN_OPERATION_CONFLICT"
|
||||||
|
assert _failed_history(api_v3_module) == [], (
|
||||||
|
"an uninstall that never started was recorded as failed")
|
||||||
|
api_v3_module.api_v3.plugin_store_manager.uninstall_plugin.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_another_plugin_is_still_queued(api_v3_client, installing):
|
||||||
|
_start_first_install(api_v3_client, installing)
|
||||||
|
response = api_v3_client.post(INSTALL, json={"plugin_id": "weather"})
|
||||||
|
assert response.status_code == 200, response.get_json()
|
||||||
|
assert response.get_json()["data"]["operation_id"]
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""GET /api/v3/plugins/<plugin_id>/static/<path> serves binary files too.
|
||||||
|
|
||||||
|
The route opened every file as UTF-8 text, so an image -- what the API
|
||||||
|
reference says it is for, plugin previews and icons -- failed to decode and
|
||||||
|
answered 500 "UnicodeDecodeError". Files are now sent as bytes. The text
|
||||||
|
types the route always set are unchanged, and the path checks are pinned in
|
||||||
|
test_path_traversal_guards.py::TestServePluginStatic.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
||||||
|
|
||||||
|
PNG = (b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
|
||||||
|
b"\x08\x06\x00\x00\x00\x1f\x15\xc4\x89")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def plugin_dir(tmp_path, api_v3_module):
|
||||||
|
d = tmp_path / "demo"
|
||||||
|
(d / "web_ui").mkdir(parents=True)
|
||||||
|
(d / "manifest.json").write_text(json.dumps({"id": "demo"}), encoding="utf-8")
|
||||||
|
api_v3_module.api_v3.plugin_catalog.get_plugin_directory.side_effect = (
|
||||||
|
lambda pid: str(d) if pid == "demo" else None)
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def _get(client, path):
|
||||||
|
return client.get(f"/api/v3/plugins/demo/static/{path}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_image_is_served_as_its_bytes(api_v3_client, plugin_dir):
|
||||||
|
(plugin_dir / "web_ui" / "icon.png").write_bytes(PNG)
|
||||||
|
response = _get(api_v3_client, "web_ui/icon.png")
|
||||||
|
assert response.status_code == 200, response.get_json(silent=True)
|
||||||
|
assert response.mimetype == "image/png"
|
||||||
|
assert response.data == PNG
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unknown_binary_file_is_served_too(api_v3_client, plugin_dir):
|
||||||
|
blob = bytes(range(256))
|
||||||
|
(plugin_dir / "data.bin").write_bytes(blob)
|
||||||
|
response = _get(api_v3_client, "data.bin")
|
||||||
|
assert response.status_code == 200, response.get_json(silent=True)
|
||||||
|
assert response.data == blob
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name,mimetype", [
|
||||||
|
("page.html", "text/html"),
|
||||||
|
("app.js", "application/javascript"),
|
||||||
|
("style.css", "text/css"),
|
||||||
|
("data.json", "application/json"),
|
||||||
|
("notes.txt", "text/plain"),
|
||||||
|
("README.md", "text/plain"),
|
||||||
|
("helper.py", "text/plain"),
|
||||||
|
])
|
||||||
|
def test_text_files_keep_their_types(api_v3_client, plugin_dir, name, mimetype):
|
||||||
|
content = "caf\u00e9 \u2713 <p>hi</p>\n"
|
||||||
|
(plugin_dir / name).write_bytes(content.encode("utf-8"))
|
||||||
|
response = _get(api_v3_client, name)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.mimetype == mimetype
|
||||||
|
assert response.data == content.encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_missing_file_is_still_a_404(api_v3_client, plugin_dir):
|
||||||
|
assert _get(api_v3_client, "nope.png").status_code == 404
|
||||||
@@ -13,6 +13,7 @@ The invariants that keep this change safe:
|
|||||||
inline path exactly.
|
inline path exactly.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
@@ -209,6 +210,32 @@ class TestFailurePaths:
|
|||||||
assert pm.get_plugin_lock(plugin_id).acquire(blocking=False) is True
|
assert pm.get_plugin_lock(plugin_id).acquire(blocking=False) is True
|
||||||
pm.get_plugin_lock(plugin_id).release()
|
pm.get_plugin_lock(plugin_id).release()
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("raised", [asyncio.CancelledError, SystemExit])
|
||||||
|
def test_update_raising_a_base_exception_still_releases_the_plugin(self, pm, raised):
|
||||||
|
"""asyncio.CancelledError and SystemExit derive from BaseException,
|
||||||
|
not Exception. Raised from update() on the worker, one skipped the
|
||||||
|
bookkeeping entirely: the plugin kept its lock and stayed RUNNING for
|
||||||
|
the life of the process -- never updated again, and every display()
|
||||||
|
skipped as busy."""
|
||||||
|
class CancellingPlugin(SlowPlugin):
|
||||||
|
def update(self):
|
||||||
|
self.update_calls += 1
|
||||||
|
raise raised()
|
||||||
|
|
||||||
|
plugin_id = _install(pm, CancellingPlugin())
|
||||||
|
pm.run_scheduled_updates()
|
||||||
|
deadline = time.monotonic() + 3
|
||||||
|
while pm.plugins[plugin_id].update_calls == 0 and time.monotonic() < deadline:
|
||||||
|
time.sleep(0.05)
|
||||||
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
assert pm.get_plugin_lock(plugin_id).acquire(blocking=False) is True
|
||||||
|
pm.get_plugin_lock(plugin_id).release()
|
||||||
|
assert pm.state_manager.can_execute(plugin_id) is True
|
||||||
|
assert pm.plugin_last_update.get(plugin_id, 0) > 0
|
||||||
|
error = pm.state_manager.get_error_info(plugin_id)
|
||||||
|
assert error is not None and error["error_type"] == raised.__name__
|
||||||
|
|
||||||
def test_unloaded_while_queued_is_harmless(self, pm):
|
def test_unloaded_while_queued_is_harmless(self, pm):
|
||||||
"""Exercise the public unload_plugin() lifecycle rather than
|
"""Exercise the public unload_plugin() lifecycle rather than
|
||||||
deleting pm.plugins directly: queue the target's update behind a
|
deleting pm.plugins directly: queue the target's update behind a
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""A cache key too long to be a filename still gets a cache file.
|
||||||
|
|
||||||
|
The calendar plugin's key joins every calendar id the user picked; on a real
|
||||||
|
install it passed 300 bytes, and since ext4 caps a filename at 255 every write
|
||||||
|
failed with ENAMETOOLONG -- logged as "permission denied", every update.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from src.cache.disk_cache import DiskCache, _MAX_KEY_FILENAME_BYTES, _filename_stem
|
||||||
|
from src.cache_manager import CacheManager
|
||||||
|
|
||||||
|
# The shape of the key that failed on hdpi, ids anonymised.
|
||||||
|
CALENDAR_KEY = (
|
||||||
|
"calendar_events_someone@example.com_en.usa#holiday@group.v.calendar.google.com_"
|
||||||
|
"family13997378751670666433@group.calendar.google.com_ncaaf_-m-07kbp5_"
|
||||||
|
"%47eorgia+%42ulldogs+football#sports@group.v.calendar.google.com_nfl_-m-07l24_"
|
||||||
|
"%54ampa+%42ay+%42uccaneers#sports@group.v.calendar.google.com_primary"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ext4/xfs/btrfs NAME_MAX; set()'s temp file adds 15 bytes to the stem.
|
||||||
|
NAME_MAX = 255
|
||||||
|
TEMP_OVERHEAD = len(".") + len(".json") + len(".") + 8
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_real_key_was_too_long_to_write():
|
||||||
|
assert len((CALENDAR_KEY + ".json").encode()) > NAME_MAX - 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_long_key_round_trips(tmp_path):
|
||||||
|
cache = DiskCache(str(tmp_path))
|
||||||
|
cache.set(CALENDAR_KEY, {"events": [1, 2, 3]})
|
||||||
|
|
||||||
|
assert cache.get(CALENDAR_KEY, max_age=None) == {"events": [1, 2, 3]}
|
||||||
|
path = cache.get_cache_path(CALENDAR_KEY)
|
||||||
|
assert os.path.isfile(path)
|
||||||
|
stem = os.path.basename(path)[:-len(".json")]
|
||||||
|
assert len(stem.encode()) + TEMP_OVERHEAD <= NAME_MAX
|
||||||
|
|
||||||
|
|
||||||
|
def test_short_keys_keep_their_filename(tmp_path):
|
||||||
|
cache = DiskCache(str(tmp_path))
|
||||||
|
exactly = "k" * _MAX_KEY_FILENAME_BYTES
|
||||||
|
assert cache.get_cache_path("weather_current") == str(tmp_path / "weather_current.json")
|
||||||
|
assert cache.get_cache_path(exactly) == str(tmp_path / f"{exactly}.json")
|
||||||
|
assert cache.get_cache_path(exactly + "k") != str(tmp_path / f"{exactly}k.json")
|
||||||
|
|
||||||
|
|
||||||
|
def test_long_keys_sharing_a_prefix_stay_apart(tmp_path):
|
||||||
|
cache = DiskCache(str(tmp_path))
|
||||||
|
first, second = CALENDAR_KEY + "_a", CALENDAR_KEY + "_b"
|
||||||
|
cache.set(first, {"which": "a"})
|
||||||
|
cache.set(second, {"which": "b"})
|
||||||
|
|
||||||
|
assert cache.get_cache_path(first) != cache.get_cache_path(second)
|
||||||
|
assert cache.get(first, max_age=None) == {"which": "a"}
|
||||||
|
assert cache.get(second, max_age=None) == {"which": "b"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_prefix_never_splits_a_character():
|
||||||
|
key = "news_" + "é" * 300 # two bytes each, so the cut lands mid-character
|
||||||
|
stem = _filename_stem(key)
|
||||||
|
|
||||||
|
assert stem.startswith("news_é")
|
||||||
|
assert len(stem.encode("utf-8")) <= _MAX_KEY_FILENAME_BYTES
|
||||||
|
stem.encode("utf-8").decode("utf-8") # well-formed
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_stem_listed_by_the_web_ui_deletes_the_same_file(tmp_path):
|
||||||
|
with patch('src.cache_manager.CacheManager._get_writable_cache_dir', return_value=str(tmp_path)):
|
||||||
|
manager = CacheManager()
|
||||||
|
try:
|
||||||
|
manager.save_cache(CALENDAR_KEY, {"events": []})
|
||||||
|
|
||||||
|
listed = [entry["key"] for entry in manager.list_cache_files()]
|
||||||
|
assert len(listed) == 1
|
||||||
|
manager.clear_cache(listed[0])
|
||||||
|
|
||||||
|
assert [n for n in os.listdir(tmp_path) if n.endswith(".json")] == []
|
||||||
|
finally:
|
||||||
|
manager.stop_cleanup_thread()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_failed_write_names_the_real_error(tmp_path, monkeypatch, caplog):
|
||||||
|
blocker = tmp_path / "a-file"
|
||||||
|
blocker.write_text("")
|
||||||
|
# No writable fallback either, so set() gives up and says why.
|
||||||
|
monkeypatch.setattr(os.path, "expanduser", lambda _p: str(blocker / "home"))
|
||||||
|
cache = DiskCache(str(tmp_path / "missing"))
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
cache.set("weather_current", {"t": 1})
|
||||||
|
|
||||||
|
gave_up = [r.getMessage() for r in caplog.records if "Could not write cache" in r.getMessage()]
|
||||||
|
assert len(gave_up) == 1
|
||||||
|
assert "permission denied" not in gave_up[0]
|
||||||
|
assert os.strerror(2) in gave_up[0] # ENOENT: the directory does not exist
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""The memory tier never serves a record older than the reader asked for.
|
||||||
|
|
||||||
|
A record read from disk went into the memory tier timed from the read, not
|
||||||
|
from when it was written, so get(max_age=300) could hand out data up to twice
|
||||||
|
that old: after a restart, after the memory sweep, or in a second process that
|
||||||
|
loaded a record once and kept serving it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.cache_manager import CacheManager
|
||||||
|
|
||||||
|
|
||||||
|
class Clock:
|
||||||
|
def __init__(self, now):
|
||||||
|
self.now = now
|
||||||
|
|
||||||
|
def __call__(self):
|
||||||
|
return self.now
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def clock(monkeypatch):
|
||||||
|
fake = Clock(1_800_000_000.0)
|
||||||
|
monkeypatch.setattr(time, "time", fake)
|
||||||
|
return fake
|
||||||
|
|
||||||
|
|
||||||
|
def _manager(path):
|
||||||
|
# No disk sweep: it judges files by their real mtime against the fake
|
||||||
|
# clock and would delete them as months old.
|
||||||
|
with patch('src.cache_manager.CacheManager._get_writable_cache_dir',
|
||||||
|
return_value=str(path)), \
|
||||||
|
patch('src.cache_manager.CacheManager.start_cleanup_thread'):
|
||||||
|
return CacheManager()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_record_loaded_late_expires_on_its_own_timestamp(tmp_path, clock):
|
||||||
|
writer = _manager(tmp_path)
|
||||||
|
writer.set("weather_current", {"t": 1})
|
||||||
|
|
||||||
|
reader = _manager(tmp_path) # a restart, or the other process
|
||||||
|
clock.now += 250
|
||||||
|
assert reader.get("weather_current", max_age=300) == {"t": 1}
|
||||||
|
|
||||||
|
clock.now += 100 # the data is 350 s old; it sat in memory for 100 s
|
||||||
|
assert reader.get("weather_current", max_age=300) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_stored_ttl_bounds_the_memory_copy_too(tmp_path, clock):
|
||||||
|
writer = _manager(tmp_path)
|
||||||
|
writer.set("odds_espn_football_nfl_401", {"spread": 6.5}, ttl=60)
|
||||||
|
|
||||||
|
reader = _manager(tmp_path)
|
||||||
|
clock.now += 55
|
||||||
|
assert reader.get("odds_espn_football_nfl_401", max_age=3600) == {"spread": 6.5}
|
||||||
|
|
||||||
|
clock.now += 60
|
||||||
|
assert reader.get("odds_espn_football_nfl_401", max_age=3600) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_stale_memory_copy_gives_way_to_a_newer_write_on_disk(tmp_path, clock):
|
||||||
|
writer = _manager(tmp_path)
|
||||||
|
reader = _manager(tmp_path)
|
||||||
|
writer.set("stocks_AAPL", {"price": 1})
|
||||||
|
assert reader.get("stocks_AAPL", max_age=300) == {"price": 1}
|
||||||
|
|
||||||
|
clock.now += 280
|
||||||
|
writer.set("stocks_AAPL", {"price": 2})
|
||||||
|
clock.now += 40 # reader's copy: 40 s in memory, 320 s old
|
||||||
|
|
||||||
|
assert reader.get("stocks_AAPL", max_age=300) == {"price": 2}
|
||||||
|
|
||||||
|
|
||||||
|
def test_fresh_records_are_still_served_from_memory(tmp_path, clock):
|
||||||
|
manager = _manager(tmp_path)
|
||||||
|
manager.set("news_NFL", {"items": []})
|
||||||
|
clock.now += 100
|
||||||
|
|
||||||
|
with patch.object(manager._disk_cache_component, "get") as disk_get:
|
||||||
|
assert manager.get("news_NFL", max_age=300) == {"items": []}
|
||||||
|
disk_get.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_max_age_none_and_records_without_a_timestamp_never_expire(tmp_path, clock):
|
||||||
|
manager = _manager(tmp_path)
|
||||||
|
manager.set("plugin_health_x", {"ok": True})
|
||||||
|
manager.save_cache("raw_record", {"no": "timestamp"})
|
||||||
|
clock.now += 10 ** 6
|
||||||
|
|
||||||
|
assert manager.get("plugin_health_x", max_age=None) == {"ok": True}
|
||||||
|
assert manager.get_cached_data("raw_record", max_age=None) == {"no": "timestamp"}
|
||||||
@@ -58,7 +58,8 @@ class TestFastPath:
|
|||||||
for _ in range(10):
|
for _ in range(10):
|
||||||
again = m.load_config()
|
again = m.load_config()
|
||||||
assert counts["n"] == 0, "fast path must not re-open any config file"
|
assert counts["n"] == 0, "fast path must not re-open any config file"
|
||||||
assert again is first # same aliasing semantics as the full path
|
assert again == first
|
||||||
|
assert again is not first # each caller gets its own copy, see below
|
||||||
|
|
||||||
def test_config_change_triggers_reload(self, mgr):
|
def test_config_change_triggers_reload(self, mgr):
|
||||||
m, config, secrets, template = mgr
|
m, config, secrets, template = mgr
|
||||||
@@ -98,6 +99,33 @@ class TestFastPath:
|
|||||||
assert m.load_config()["timezone"] == "America/New_York"
|
assert m.load_config()["timezone"] == "America/New_York"
|
||||||
|
|
||||||
|
|
||||||
|
class TestCallersGetACopy:
|
||||||
|
"""A web handler edits what load_config returned, then validates. When
|
||||||
|
validation failed, the edit stayed in the cache the fast path serves, and
|
||||||
|
the next unrelated save wrote it -- a nested secret included, in plain
|
||||||
|
text, because it had never reached config_secrets.json to be stripped."""
|
||||||
|
|
||||||
|
def test_editing_a_loaded_config_does_not_change_the_next_load(self, mgr):
|
||||||
|
m, config, secrets, template = mgr
|
||||||
|
loaded = m.load_config()
|
||||||
|
loaded["display"]["brightness"] = 1
|
||||||
|
loaded["weather"]["api_key"] = "typed-but-never-saved"
|
||||||
|
again = m.load_config()
|
||||||
|
assert again["display"]["brightness"] == 90
|
||||||
|
assert again["weather"]["api_key"] == "sek"
|
||||||
|
|
||||||
|
def test_the_full_path_also_returns_a_copy(self, mgr):
|
||||||
|
m, config, secrets, template = mgr
|
||||||
|
m.load_config()["display"]["brightness"] = 1 # first load: full path
|
||||||
|
assert m.load_config()["display"]["brightness"] == 90
|
||||||
|
|
||||||
|
def test_an_edit_never_reaches_a_later_save(self, mgr):
|
||||||
|
m, config, secrets, template = mgr
|
||||||
|
m.load_config()["display"]["new_secret"] = "hunter2" # then bailed out
|
||||||
|
m.save_config(m.load_config()) # some other handler saves
|
||||||
|
assert "hunter2" not in config.read_text()
|
||||||
|
|
||||||
|
|
||||||
class TestSaveCoherence:
|
class TestSaveCoherence:
|
||||||
def test_save_config_then_load_returns_saved_data(self, mgr, monkeypatch):
|
def test_save_config_then_load_returns_saved_data(self, mgr, monkeypatch):
|
||||||
m, config, secrets, template = mgr
|
m, config, secrets, template = mgr
|
||||||
@@ -111,6 +139,15 @@ class TestSaveCoherence:
|
|||||||
assert loaded["weather"]["api_key"] == "sek" # secrets survive in memory
|
assert loaded["weather"]["api_key"] == "sek" # secrets survive in memory
|
||||||
assert counts["n"] == 0 # signature refreshed by save; no re-read
|
assert counts["n"] == 0 # signature refreshed by save; no re-read
|
||||||
|
|
||||||
|
def test_the_saved_dict_does_not_become_the_cache(self, mgr):
|
||||||
|
m, config, secrets, template = mgr
|
||||||
|
m.load_config()
|
||||||
|
new = {"display": {"brightness": 42}, "timezone": "UTC",
|
||||||
|
"weather": {"api_key": "sek"}}
|
||||||
|
m.save_config(new)
|
||||||
|
new["display"]["brightness"] = 7 # the caller keeps using its dict
|
||||||
|
assert m.load_config()["display"]["brightness"] == 42
|
||||||
|
|
||||||
def test_cross_process_save_is_picked_up(self, mgr):
|
def test_cross_process_save_is_picked_up(self, mgr):
|
||||||
"""Another process writing config.json (different mtime) must bust
|
"""Another process writing config.json (different mtime) must bust
|
||||||
this process's fast path — the core cross-process guarantee."""
|
this process's fast path — the core cross-process guarantee."""
|
||||||
|
|||||||
@@ -143,7 +143,10 @@ class TestLoadFastPath:
|
|||||||
manager = make_manager(tmp_path, config={"timezone": "UTC"})
|
manager = make_manager(tmp_path, config={"timezone": "UTC"})
|
||||||
first = manager.load_config()
|
first = manager.load_config()
|
||||||
second = manager.load_config()
|
second = manager.load_config()
|
||||||
assert second is first # same aliased dict, no re-read
|
# A copy of the cached dict, never the dict itself; that it is not
|
||||||
|
# re-read is test_config_load_cache's test_unchanged_files_are_not_reread
|
||||||
|
assert second == first
|
||||||
|
assert second is not first
|
||||||
|
|
||||||
def test_touching_secrets_file_invalidates_cache(self, tmp_path):
|
def test_touching_secrets_file_invalidates_cache(self, tmp_path):
|
||||||
manager = make_manager(
|
manager = make_manager(
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
"""ConfigService notifies subscribers outside its lock, in order.
|
||||||
|
|
||||||
|
Subscribers ran while _load_config held the service's lock. The display's
|
||||||
|
per-plugin subscriber is PluginManager.apply_config_change, which waits up to
|
||||||
|
PLUGIN_LOCK_TIMEOUT (5 s) for a busy plugin. The same save that toggles a
|
||||||
|
plugin's ``enabled`` flags a reconcile, and the render thread runs it: its
|
||||||
|
get_config() -- and the unsubscribe() of a plugin it disables -- waited behind
|
||||||
|
every slow callback, freezing the panel for up to 5 s per busy plugin.
|
||||||
|
|
||||||
|
What callers could rely on before still holds: one reload's notifications
|
||||||
|
finish before the next reload's start, and a callback unsubscribe() removed is
|
||||||
|
not running, and will not run, once unsubscribe() returns.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import itertools
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.config_manager import ConfigManager
|
||||||
|
from src.config_service import ConfigService
|
||||||
|
|
||||||
|
SLOW = 2.0 # how long a blocked callback waits before giving up
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def service(tmp_path):
|
||||||
|
config_path = tmp_path / "config.json"
|
||||||
|
config_path.write_text(json.dumps({"display": {"brightness": 50},
|
||||||
|
"weather": {"enabled": True}}),
|
||||||
|
encoding="utf-8")
|
||||||
|
manager = ConfigManager(str(config_path), str(tmp_path / "config_secrets.json"))
|
||||||
|
manager.template_path = str(tmp_path / "no-template.json")
|
||||||
|
svc = ConfigService(manager, enable_hot_reload=False)
|
||||||
|
yield svc, config_path
|
||||||
|
svc.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
_saves = itertools.count(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _save(config_path, **sections):
|
||||||
|
config = json.loads(config_path.read_text(encoding="utf-8"))
|
||||||
|
config.update(sections)
|
||||||
|
config_path.write_text(json.dumps(config), encoding="utf-8")
|
||||||
|
# ConfigManager re-reads only when (mtime, size) moves. Two quick saves of
|
||||||
|
# the same size can share an mtime tick (about 16 ms on Windows), so step
|
||||||
|
# it forward explicitly.
|
||||||
|
st = config_path.stat()
|
||||||
|
os.utime(config_path, ns=(st.st_atime_ns, st.st_mtime_ns + next(_saves) * 50_000_000))
|
||||||
|
|
||||||
|
|
||||||
|
def _reload_in_background(svc):
|
||||||
|
thread = threading.Thread(target=svc._load_config, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
return thread
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_config_does_not_wait_for_a_slow_subscriber(service):
|
||||||
|
svc, config_path = service
|
||||||
|
entered, release = threading.Event(), threading.Event()
|
||||||
|
|
||||||
|
def slow(_old, _new):
|
||||||
|
entered.set()
|
||||||
|
release.wait(SLOW)
|
||||||
|
|
||||||
|
svc.subscribe(slow, plugin_id="weather")
|
||||||
|
_save(config_path, weather={"enabled": False})
|
||||||
|
reload = _reload_in_background(svc)
|
||||||
|
assert entered.wait(SLOW)
|
||||||
|
|
||||||
|
start = time.monotonic()
|
||||||
|
config = svc.get_config()
|
||||||
|
waited = time.monotonic() - start
|
||||||
|
release.set()
|
||||||
|
reload.join(SLOW)
|
||||||
|
|
||||||
|
assert waited < 0.5
|
||||||
|
# Swapped before anyone was told: a subscriber that reads it sees the new one.
|
||||||
|
assert config["weather"]["enabled"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_unsubscribing_another_callback_does_not_wait(service):
|
||||||
|
svc, config_path = service
|
||||||
|
entered, release = threading.Event(), threading.Event()
|
||||||
|
|
||||||
|
def slow(_old, _new):
|
||||||
|
entered.set()
|
||||||
|
release.wait(SLOW)
|
||||||
|
|
||||||
|
def other(_old, _new):
|
||||||
|
pass
|
||||||
|
|
||||||
|
svc.subscribe(slow, plugin_id="weather")
|
||||||
|
svc.subscribe(other, plugin_id="clock")
|
||||||
|
_save(config_path, weather={"enabled": False})
|
||||||
|
reload = _reload_in_background(svc)
|
||||||
|
assert entered.wait(SLOW)
|
||||||
|
|
||||||
|
start = time.monotonic()
|
||||||
|
svc.unsubscribe(other, plugin_id="clock")
|
||||||
|
waited = time.monotonic() - start
|
||||||
|
release.set()
|
||||||
|
reload.join(SLOW)
|
||||||
|
|
||||||
|
assert waited < 0.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_callback_unsubscribed_mid_notification_is_not_called(service):
|
||||||
|
svc, config_path = service
|
||||||
|
entered, release = threading.Event(), threading.Event()
|
||||||
|
called = []
|
||||||
|
|
||||||
|
def slow_global(_old, _new): # global subscribers are notified first
|
||||||
|
entered.set()
|
||||||
|
release.wait(SLOW)
|
||||||
|
|
||||||
|
def weather(_old, _new):
|
||||||
|
called.append("weather")
|
||||||
|
|
||||||
|
svc.subscribe(slow_global)
|
||||||
|
svc.subscribe(weather, plugin_id="weather")
|
||||||
|
_save(config_path, weather={"enabled": False})
|
||||||
|
reload = _reload_in_background(svc)
|
||||||
|
assert entered.wait(SLOW)
|
||||||
|
|
||||||
|
svc.unsubscribe(weather, plugin_id="weather")
|
||||||
|
release.set()
|
||||||
|
reload.join(SLOW)
|
||||||
|
|
||||||
|
assert called == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_unsubscribe_waits_for_its_own_callback_to_return(service):
|
||||||
|
svc, config_path = service
|
||||||
|
entered, release = threading.Event(), threading.Event()
|
||||||
|
returned = threading.Event()
|
||||||
|
|
||||||
|
def slow(_old, _new):
|
||||||
|
entered.set()
|
||||||
|
release.wait(SLOW)
|
||||||
|
returned.set()
|
||||||
|
|
||||||
|
svc.subscribe(slow, plugin_id="weather")
|
||||||
|
_save(config_path, weather={"enabled": False})
|
||||||
|
reload = _reload_in_background(svc)
|
||||||
|
assert entered.wait(SLOW)
|
||||||
|
|
||||||
|
threading.Timer(0.2, release.set).start()
|
||||||
|
svc.unsubscribe(slow, plugin_id="weather")
|
||||||
|
|
||||||
|
assert returned.is_set()
|
||||||
|
reload.join(SLOW)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_callback_may_read_config_and_unsubscribe_itself(service):
|
||||||
|
svc, config_path = service
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
def once(_old, _new):
|
||||||
|
seen.append(svc.get_config()["weather"]["enabled"])
|
||||||
|
svc.unsubscribe(once, plugin_id="weather")
|
||||||
|
|
||||||
|
svc.subscribe(once, plugin_id="weather")
|
||||||
|
_save(config_path, weather={"enabled": False})
|
||||||
|
reload = _reload_in_background(svc)
|
||||||
|
reload.join(SLOW)
|
||||||
|
|
||||||
|
assert not reload.is_alive()
|
||||||
|
assert seen == [False]
|
||||||
|
|
||||||
|
|
||||||
|
def test_two_reloads_notify_in_order(service):
|
||||||
|
svc, config_path = service
|
||||||
|
entered, release = threading.Event(), threading.Event()
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
def record(old, new):
|
||||||
|
seen.append((old["brightness"], new["brightness"]))
|
||||||
|
if len(seen) == 1:
|
||||||
|
entered.set()
|
||||||
|
release.wait(SLOW)
|
||||||
|
|
||||||
|
svc.subscribe(record, plugin_id="display")
|
||||||
|
_save(config_path, display={"brightness": 60})
|
||||||
|
first = _reload_in_background(svc)
|
||||||
|
assert entered.wait(SLOW)
|
||||||
|
|
||||||
|
_save(config_path, display={"brightness": 100})
|
||||||
|
second = _reload_in_background(svc)
|
||||||
|
time.sleep(0.2)
|
||||||
|
release.set()
|
||||||
|
first.join(SLOW)
|
||||||
|
second.join(SLOW)
|
||||||
|
|
||||||
|
assert seen == [(50, 60), (60, 100)]
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""A plugin duration that is not a number must not stop the display.
|
||||||
|
|
||||||
|
Several plugins return their ``display_duration`` setting as it is in
|
||||||
|
config.json (``return self.config.get('display_duration', 15.0)``), so a
|
||||||
|
value saved as ``"20"`` or ``null`` -- from the raw config editor, or by
|
||||||
|
hand -- reached run() as a string or None. _resolve_durations then compared
|
||||||
|
it with 0, the TypeError went past every handler in the loop, and the
|
||||||
|
display service exited; systemd restarted it into the same screen and the
|
||||||
|
same crash.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
os.environ.setdefault("EMULATOR", "true")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.display_controller import DisplayController
|
||||||
|
from test._run_loop_harness import FakePlugin, RunLoopHarness
|
||||||
|
|
||||||
|
|
||||||
|
def _controller(plugin_modes):
|
||||||
|
dc = object.__new__(DisplayController)
|
||||||
|
dc.config = {}
|
||||||
|
dc.plugin_modes = plugin_modes
|
||||||
|
return dc
|
||||||
|
|
||||||
|
|
||||||
|
def _plugin(duration, plugin_id='clock-simple'):
|
||||||
|
plugin = MagicMock()
|
||||||
|
plugin.plugin_id = plugin_id
|
||||||
|
plugin.get_display_duration.return_value = duration
|
||||||
|
return plugin
|
||||||
|
|
||||||
|
|
||||||
|
class TestPluginDurationIsCoerced:
|
||||||
|
@pytest.mark.parametrize('value, expected', [
|
||||||
|
('20', 20.0), (' 7.5 ', 7.5), (12, 12.0), (12.5, 12.5)])
|
||||||
|
def test_numbers_and_numeric_strings_are_used(self, value, expected):
|
||||||
|
dc = _controller({'clock': _plugin(value)})
|
||||||
|
duration = dc._get_display_duration('clock')
|
||||||
|
assert duration == expected and isinstance(duration, float)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('value', [
|
||||||
|
None, '', 'twenty', True, False, float('nan'), float('inf'), 'inf',
|
||||||
|
[20], {'seconds': 20}])
|
||||||
|
def test_anything_but_a_finite_number_gets_the_default(self, value):
|
||||||
|
dc = _controller({'clock': _plugin(value)})
|
||||||
|
assert dc._get_display_duration('clock') == 30
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('value', [0, -5, '-5', '0'])
|
||||||
|
def test_a_number_not_above_zero_still_gets_the_15s_rule(self, value):
|
||||||
|
"""Unchanged: _resolve_durations turns it into 15 s, with its warning."""
|
||||||
|
plugin = _plugin(value)
|
||||||
|
dc = _controller({'clock': plugin})
|
||||||
|
base = dc._get_display_duration('clock')
|
||||||
|
assert dc._resolve_durations(plugin, 'clock', base, False)[1] == 15.0
|
||||||
|
|
||||||
|
def test_a_raising_get_display_duration_gets_the_default(self):
|
||||||
|
plugin = _plugin(None)
|
||||||
|
plugin.get_display_duration.side_effect = KeyError('display_duration')
|
||||||
|
assert _controller({'clock': plugin})._get_display_duration('clock') == 30
|
||||||
|
|
||||||
|
def test_the_result_feeds_resolve_durations(self):
|
||||||
|
"""The two calls run() makes back to back, for one screen."""
|
||||||
|
plugin = _plugin('bad')
|
||||||
|
dc = _controller({'clock': plugin})
|
||||||
|
base = dc._get_display_duration('clock')
|
||||||
|
assert dc._resolve_durations(plugin, 'clock', base, False) == (30, 30)
|
||||||
|
|
||||||
|
def test_logged_once_per_plugin(self, caplog):
|
||||||
|
dc = _controller({'clock': _plugin('twenty'),
|
||||||
|
'clock_big': _plugin('twenty'),
|
||||||
|
'calendar': _plugin(None, plugin_id='calendar')})
|
||||||
|
# clock_big is a second mode of the same plugin.
|
||||||
|
dc.plugin_modes['clock_big'].plugin_id = 'clock-simple'
|
||||||
|
with caplog.at_level(logging.WARNING, logger='src.display_controller'):
|
||||||
|
for _ in range(3):
|
||||||
|
for mode in ('clock', 'clock_big', 'calendar'):
|
||||||
|
dc._get_display_duration(mode)
|
||||||
|
warnings = [r for r in caplog.records if 'display duration' in r.getMessage()]
|
||||||
|
assert len(warnings) == 2
|
||||||
|
assert {'clock-simple', 'calendar'} == {
|
||||||
|
next(p for p in ('clock-simple', 'calendar') if p in r.getMessage())
|
||||||
|
for r in warnings}
|
||||||
|
|
||||||
|
def test_a_good_value_after_a_bad_one_is_used(self):
|
||||||
|
plugin = _plugin(None)
|
||||||
|
dc = _controller({'clock': plugin})
|
||||||
|
assert dc._get_display_duration('clock') == 30
|
||||||
|
plugin.get_display_duration.return_value = 45
|
||||||
|
assert dc._get_display_duration('clock') == 45.0
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunLoopSurvives:
|
||||||
|
"""Through the real run() on the harness's fake clock."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('duration, shown_for', [('20', 20.0), (None, 30.0),
|
||||||
|
('twenty', 30.0)])
|
||||||
|
def test_the_screen_runs_and_the_rotation_goes_on(self, tmp_path, duration, shown_for):
|
||||||
|
harness = RunLoopHarness(tmp_path, horizon=120)
|
||||||
|
harness.add_plugin(FakePlugin("weather", ["weather"], duration=30))
|
||||||
|
harness.add_plugin(FakePlugin("clock-simple", ["clock"], duration=duration))
|
||||||
|
# Before the fix run() returned at t=30, when the clock came up, and
|
||||||
|
# the harness raised "run() returned ... before the horizon".
|
||||||
|
rows = harness.run()["screens"]
|
||||||
|
clock = next(row for row in rows if row[1] == "clock")
|
||||||
|
assert math.isclose(clock[2], shown_for, abs_tol=1.0)
|
||||||
|
assert [row[1] for row in rows][:3] == ["weather", "clock", "weather"]
|
||||||
@@ -96,6 +96,27 @@ class TestStateHub:
|
|||||||
assert hub.snapshot(since=1, epoch='other')['changed'] is True
|
assert hub.snapshot(since=1, epoch='other')['changed'] is True
|
||||||
assert hub.snapshot(since=1)['changed'] is True
|
assert hub.snapshot(since=1)['changed'] is True
|
||||||
|
|
||||||
|
def test_the_short_answer_carries_the_latest_volatile_values(self, hub):
|
||||||
|
"""A version that stays put must not freeze the timestamps a reader
|
||||||
|
judges freshness by: the short answer (a tick) carries them."""
|
||||||
|
hub.publish('on_demand', {'active': False, 'last_updated': 2.0, 'remaining': 0.0},
|
||||||
|
volatile=('last_updated', 'remaining'))
|
||||||
|
hub.publish('brightness', {'brightness': 50})
|
||||||
|
version = hub.version
|
||||||
|
assert not hub.publish('display', _display(updated=1234.5), volatile=('last_updated',))
|
||||||
|
short = hub.snapshot(since=version, epoch='e1')
|
||||||
|
assert short['changed'] is False and 'state' not in short
|
||||||
|
assert short['volatile'] == {'display': {'last_updated': 1234.5},
|
||||||
|
'on_demand': {'last_updated': 2.0, 'remaining': 0.0}}
|
||||||
|
# The full answer has them in place already.
|
||||||
|
assert 'volatile' not in hub.snapshot()
|
||||||
|
|
||||||
|
def test_volatile_values_skip_sections_without_any(self):
|
||||||
|
hub = StateHub(epoch='e1')
|
||||||
|
hub.publish('brightness', {'brightness': 50})
|
||||||
|
hub.publish('plugins', None, volatile=('published_at',))
|
||||||
|
assert hub.snapshot(since=hub.version, epoch='e1')['volatile'] == {}
|
||||||
|
|
||||||
def test_each_display_run_has_its_own_epoch(self):
|
def test_each_display_run_has_its_own_epoch(self):
|
||||||
assert StateHub().epoch != StateHub().epoch
|
assert StateHub().epoch != StateHub().epoch
|
||||||
|
|
||||||
@@ -276,6 +297,40 @@ class TestSubscriptionStore:
|
|||||||
assert latest['state']['loop']['heartbeat_age_seconds'] == 70.0
|
assert latest['state']['loop']['heartbeat_age_seconds'] == 70.0
|
||||||
assert latest['received_mono'] == clock.now
|
assert latest['received_mono'] == clock.now
|
||||||
|
|
||||||
|
def test_a_tick_refreshes_the_volatile_timestamps(self, hub):
|
||||||
|
"""The bug from the ledpi rig: the same mode on screen for minutes
|
||||||
|
left the reader's display.last_updated at the last real change."""
|
||||||
|
clock = FakeClock()
|
||||||
|
sub = client.StateSubscription(paths=['/nowhere'], clock=clock)
|
||||||
|
sub._store(hub.snapshot(), full=True)
|
||||||
|
hub.publish('display', _display(updated=9999.0), volatile=('last_updated',))
|
||||||
|
sub._store(hub.snapshot(since=1, epoch='e1'), full=False)
|
||||||
|
latest = sub.latest()
|
||||||
|
assert latest['state']['display']['last_updated'] == 9999.0
|
||||||
|
assert latest['state']['display']['mode'] == 'clock'
|
||||||
|
assert latest['version'] == 1
|
||||||
|
|
||||||
|
def test_a_tick_adds_no_section_or_key_and_ignores_another_version(self, hub):
|
||||||
|
clock = FakeClock()
|
||||||
|
sub = client.StateSubscription(paths=['/nowhere'], clock=clock)
|
||||||
|
snap = hub.snapshot()
|
||||||
|
sub._store(snap, full=True)
|
||||||
|
updated = snap['state']['display']['last_updated']
|
||||||
|
sub._store({'version': 1, 'epoch': 'e1',
|
||||||
|
'volatile': {'display': {'new_key': 1}, 'plugins': {'published_at': 5.0},
|
||||||
|
'on_demand': 'junk'}}, full=False)
|
||||||
|
state = sub.latest()['state']
|
||||||
|
assert 'new_key' not in state['display'] and state['plugins'] is None
|
||||||
|
assert state['on_demand'] is None
|
||||||
|
# A tick for a version this copy is not at says nothing about it.
|
||||||
|
sub._store({'version': 7, 'epoch': 'e1',
|
||||||
|
'volatile': {'display': {'last_updated': 5.0}}}, full=False)
|
||||||
|
assert sub.latest()['state']['display']['last_updated'] == updated
|
||||||
|
# A display from before ticks carried them: the loop still refreshes.
|
||||||
|
sub._store({'version': 1, 'epoch': 'e1', 'loop': {'heartbeat_age_seconds': 3.0}},
|
||||||
|
full=False)
|
||||||
|
assert sub.latest()['loop'] == {'heartbeat_age_seconds': 3.0}
|
||||||
|
|
||||||
def test_a_tick_from_another_epoch_is_ignored(self, hub):
|
def test_a_tick_from_another_epoch_is_ignored(self, hub):
|
||||||
clock = FakeClock()
|
clock = FakeClock()
|
||||||
sub = client.StateSubscription(paths=['/nowhere'], clock=clock)
|
sub = client.StateSubscription(paths=['/nowhere'], clock=clock)
|
||||||
@@ -291,6 +346,52 @@ class TestSubscriptionStore:
|
|||||||
assert client.snapshot_loop_age(snap, now_mono=104.0) is None
|
assert client.snapshot_loop_age(snap, now_mono=104.0) is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestReconnectBackoff:
|
||||||
|
"""StateSubscription._run's waits between connections, without a socket."""
|
||||||
|
|
||||||
|
def test_a_connection_that_got_a_snapshot_starts_the_backoff_over(self, hub,
|
||||||
|
monkeypatch):
|
||||||
|
"""Three failed tries, then the display is back twice, restarting
|
||||||
|
each time, then gone again. Each restart is retried after the
|
||||||
|
shortest wait, not after whatever the waits had grown to."""
|
||||||
|
sub = client.StateSubscription(paths=['/nowhere'])
|
||||||
|
script = ['refused', 'refused', 'refused', 'snapshot', 'snapshot', 'refused']
|
||||||
|
waits = []
|
||||||
|
|
||||||
|
def follow():
|
||||||
|
step = script.pop(0)
|
||||||
|
if step == 'snapshot': # subscribed, then the display restarted
|
||||||
|
sub._store(hub.snapshot(), full=True)
|
||||||
|
raise client.ControlError('closed', 'the display closed the connection')
|
||||||
|
raise client.ControlError(step)
|
||||||
|
|
||||||
|
def wait(seconds):
|
||||||
|
waits.append(seconds)
|
||||||
|
return not script # True ends _run, as stop() would
|
||||||
|
|
||||||
|
monkeypatch.setattr(sub, '_follow', follow)
|
||||||
|
monkeypatch.setattr(sub._stop, 'wait', wait)
|
||||||
|
sub._run()
|
||||||
|
first = client._RECONNECT_MIN_SECONDS
|
||||||
|
assert waits == [first, 2 * first, 4 * first, first, first, 2 * first]
|
||||||
|
|
||||||
|
def test_a_display_without_the_stream_is_still_retried_slowly(self, monkeypatch):
|
||||||
|
sub = client.StateSubscription(paths=['/nowhere'])
|
||||||
|
waits = []
|
||||||
|
|
||||||
|
def follow():
|
||||||
|
raise client.ControlError('unknown_command')
|
||||||
|
|
||||||
|
def wait(seconds):
|
||||||
|
waits.append(seconds)
|
||||||
|
return len(waits) == 2
|
||||||
|
|
||||||
|
monkeypatch.setattr(sub, '_follow', follow)
|
||||||
|
monkeypatch.setattr(sub._stop, 'wait', wait)
|
||||||
|
sub._run()
|
||||||
|
assert waits == [client._RECONNECT_MAX_SECONDS] * 2
|
||||||
|
|
||||||
|
|
||||||
# --- a real socket ------------------------------------------------------------------
|
# --- a real socket ------------------------------------------------------------------
|
||||||
|
|
||||||
def _wait_until(predicate, timeout=5.0):
|
def _wait_until(predicate, timeout=5.0):
|
||||||
@@ -369,6 +470,33 @@ class TestLiveStream:
|
|||||||
assert kinds == ['tick', 'tick', 'tick']
|
assert kinds == ['tick', 'tick', 'tick']
|
||||||
sock.close()
|
sock.close()
|
||||||
|
|
||||||
|
def test_ticks_carry_the_timestamps_the_version_ignores(self, live, path):
|
||||||
|
_, hub = live
|
||||||
|
sock, reader = self._subscribe(path)
|
||||||
|
pending = []
|
||||||
|
self._next(sock, reader, pending)
|
||||||
|
hub.publish('display', _display(updated=4242.0), volatile=('last_updated',))
|
||||||
|
for _ in range(3): # a tick already on its way may predate the publish
|
||||||
|
tick = self._next(sock, reader, pending)
|
||||||
|
assert tick['event'] == 'tick'
|
||||||
|
if tick['result']['volatile'] == {'display': {'last_updated': 4242.0}}:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
pytest.fail(f'no tick carried the new last_updated: {tick}')
|
||||||
|
sock.close()
|
||||||
|
|
||||||
|
def test_a_subscription_keeps_last_updated_current(self, live, path):
|
||||||
|
_, hub = live
|
||||||
|
sub = client.StateSubscription(paths=[path]).start()
|
||||||
|
try:
|
||||||
|
assert _wait_until(lambda: sub.latest() is not None)
|
||||||
|
hub.publish('display', _display(updated=4242.0), volatile=('last_updated',))
|
||||||
|
assert _wait_until(lambda: (sub.latest() or {}).get('state', {})
|
||||||
|
.get('display', {}).get('last_updated') == 4242.0)
|
||||||
|
assert sub.snapshots == 1 # ticks, not new snapshots
|
||||||
|
finally:
|
||||||
|
sub.stop()
|
||||||
|
|
||||||
def test_a_burst_is_coalesced_to_the_latest(self, live, path):
|
def test_a_burst_is_coalesced_to_the_latest(self, live, path):
|
||||||
_, hub = live
|
_, hub = live
|
||||||
sock, reader = self._subscribe(path)
|
sock, reader = self._subscribe(path)
|
||||||
|
|||||||
@@ -136,3 +136,22 @@ class TestCleartextCredentialsNeedAnExplicitOptIn:
|
|||||||
"allow_insecure_mqtt": "false"})
|
"allow_insecure_mqtt": "false"})
|
||||||
assert r.status_code == 400
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -58,3 +58,83 @@ def test_a_reloaded_plugin_still_gets_its_own_bare_module(plugins):
|
|||||||
assert reloaded.WHO == "alpha"
|
assert reloaded.WHO == "alpha"
|
||||||
assert sys.path.index(str(plugins["alpha"])) < sys.path.index(str(plugins["beta"]))
|
assert sys.path.index(str(plugins["alpha"])) < sys.path.index(str(plugins["beta"]))
|
||||||
assert sys.path.count(str(plugins["alpha"])) == 1
|
assert sys.path.count(str(plugins["alpha"])) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# -- sub-packages ------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# A plugin that keeps helpers in a package (``providers/feed.py``, imported as
|
||||||
|
# ``from providers.feed import ...``) leaves dotted entries in sys.modules.
|
||||||
|
# Only the bare ``providers`` used to be tracked, so ``providers.feed`` outlived
|
||||||
|
# the plugin: a reload after a store update re-ran the new manager.py against
|
||||||
|
# the old feed.py, until the display restarted. Elections (providers/),
|
||||||
|
# flights (enrichment/) and olympics (data/, renderers/) ship packages.
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def package_plugin(tmp_path):
|
||||||
|
before_path = list(sys.path)
|
||||||
|
before_modules = set(sys.modules)
|
||||||
|
plugin_dir = tmp_path / "pkgdemo"
|
||||||
|
(plugin_dir / "providers").mkdir(parents=True)
|
||||||
|
(plugin_dir / "providers" / "__init__.py").write_text("", encoding="utf-8")
|
||||||
|
(plugin_dir / "providers" / "feed.py").write_text("VERSION = 'v1'\n", encoding="utf-8")
|
||||||
|
(plugin_dir / "manager.py").write_text(
|
||||||
|
"from providers.feed import VERSION\n", encoding="utf-8")
|
||||||
|
yield plugin_dir
|
||||||
|
sys.path[:] = before_path
|
||||||
|
for key in set(sys.modules) - before_modules:
|
||||||
|
sys.modules.pop(key, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_reloaded_plugin_runs_its_updated_subpackage_module(package_plugin):
|
||||||
|
loader = PluginLoader()
|
||||||
|
assert loader.load_module("pkgdemo", package_plugin, "manager.py").VERSION == "v1"
|
||||||
|
|
||||||
|
_unload(loader, "pkgdemo")
|
||||||
|
# The store update: a different size, so no cached bytecode can match.
|
||||||
|
(package_plugin / "providers" / "feed.py").write_text(
|
||||||
|
"VERSION = 'v2 from the update'\n", encoding="utf-8")
|
||||||
|
reloaded = loader.load_module("pkgdemo", package_plugin, "manager.py")
|
||||||
|
|
||||||
|
assert reloaded.VERSION == "v2 from the update"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unload_drops_the_plugins_subpackage_modules(package_plugin):
|
||||||
|
loader = PluginLoader()
|
||||||
|
loader.load_module("pkgdemo", package_plugin, "manager.py")
|
||||||
|
# Still importable while the plugin runs, as before.
|
||||||
|
assert "providers.feed" in sys.modules
|
||||||
|
|
||||||
|
_unload(loader, "pkgdemo")
|
||||||
|
|
||||||
|
assert not [k for k in sys.modules if k.startswith("providers")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_failed_load_leaves_no_subpackage_module_behind(package_plugin):
|
||||||
|
(package_plugin / "manager.py").write_text(
|
||||||
|
"from providers.feed import VERSION\nraise RuntimeError('broken')\n",
|
||||||
|
encoding="utf-8")
|
||||||
|
loader = PluginLoader()
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
loader.load_module("pkgdemo", package_plugin, "manager.py")
|
||||||
|
|
||||||
|
assert not [k for k in sys.modules if k.startswith("providers")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_unload_leaves_packages_from_outside_the_plugin_alone(package_plugin, tmp_path):
|
||||||
|
# A library the plugin imports is not the plugin's to drop.
|
||||||
|
lib_root = tmp_path / "site"
|
||||||
|
(lib_root / "extlib").mkdir(parents=True)
|
||||||
|
(lib_root / "extlib" / "__init__.py").write_text("", encoding="utf-8")
|
||||||
|
(lib_root / "extlib" / "sub.py").write_text("X = 1\n", encoding="utf-8")
|
||||||
|
sys.path.append(str(lib_root))
|
||||||
|
(package_plugin / "manager.py").write_text(
|
||||||
|
"import extlib.sub\nfrom providers.feed import VERSION\n", encoding="utf-8")
|
||||||
|
loader = PluginLoader()
|
||||||
|
loader.load_module("pkgdemo", package_plugin, "manager.py")
|
||||||
|
|
||||||
|
_unload(loader, "pkgdemo")
|
||||||
|
|
||||||
|
assert "extlib.sub" in sys.modules
|
||||||
|
assert "extlib" in sys.modules
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""A dev plugin linked in under a name its checkout does not share still loads.
|
||||||
|
|
||||||
|
``scripts/dev/dev_plugin_setup.sh`` links a checkout into the plugins
|
||||||
|
directory under the plugin's id: ``link-github foo <url>`` clones
|
||||||
|
``ledmatrix-foo`` (the repository naming convention) and links it as
|
||||||
|
``plugins/foo``. ``contained_plugin_dir`` resolved the link and looked for the
|
||||||
|
*target's* folder name, ``ledmatrix-foo``, among the plugins directory's
|
||||||
|
entries. There is none, so ``install_dependencies`` refused the plugin as
|
||||||
|
outside the plugins directory and the load failed with "Dependency
|
||||||
|
installation failed" -- even with no requirements.txt at all.
|
||||||
|
|
||||||
|
The containment it exists for still holds: the answer is always rebuilt from
|
||||||
|
an entry enumerated under the plugins directory.
|
||||||
|
|
||||||
|
Skipped where this process cannot create a symlink (Windows without the
|
||||||
|
privilege).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.plugin_system.plugin_loader import PluginLoader, contained_plugin_dir
|
||||||
|
|
||||||
|
|
||||||
|
def _symlink_or_skip(target, link):
|
||||||
|
try:
|
||||||
|
os.symlink(target, link, target_is_directory=True)
|
||||||
|
except (OSError, NotImplementedError) as e:
|
||||||
|
pytest.skip(f"cannot create a symlink here: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def linked(tmp_path):
|
||||||
|
checkout = tmp_path / "dev-plugins" / "ledmatrix-foo"
|
||||||
|
checkout.mkdir(parents=True)
|
||||||
|
plugins_dir = tmp_path / "plugins"
|
||||||
|
plugins_dir.mkdir()
|
||||||
|
link = plugins_dir / "foo"
|
||||||
|
_symlink_or_skip(checkout, link)
|
||||||
|
return plugins_dir, link, checkout
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_link_resolves_to_its_own_entry_in_the_plugins_dir(linked):
|
||||||
|
plugins_dir, link, _checkout = linked
|
||||||
|
|
||||||
|
assert contained_plugin_dir(link, plugins_dir) == os.path.join(
|
||||||
|
os.path.realpath(plugins_dir), "foo")
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_linked_plugin_without_requirements_needs_no_install(linked):
|
||||||
|
plugins_dir, link, _checkout = linked
|
||||||
|
|
||||||
|
with patch("subprocess.run") as pip:
|
||||||
|
assert PluginLoader().install_dependencies(link, "foo", plugins_dir=plugins_dir) is True
|
||||||
|
pip.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@patch("src.plugin_system.plugin_loader.requirements_are_satisfied", return_value=False)
|
||||||
|
def test_a_linked_plugins_requirements_are_installed_through_the_link(_satisfied, linked):
|
||||||
|
plugins_dir, link, checkout = linked
|
||||||
|
(checkout / "requirements.txt").write_text("package1==1.0.0\n", encoding="utf-8")
|
||||||
|
|
||||||
|
with patch("subprocess.run", return_value=MagicMock(returncode=0, stderr="")) as pip:
|
||||||
|
assert PluginLoader().install_dependencies(link, "foo", plugins_dir=plugins_dir) is True
|
||||||
|
|
||||||
|
argv = pip.call_args[0][0]
|
||||||
|
assert argv[argv.index("-r") + 1] == os.path.join(
|
||||||
|
os.path.realpath(plugins_dir), "foo", "requirements.txt")
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_link_outside_the_plugins_dir_is_still_refused(linked, tmp_path):
|
||||||
|
plugins_dir, _link, checkout = linked
|
||||||
|
elsewhere = tmp_path / "elsewhere"
|
||||||
|
elsewhere.mkdir()
|
||||||
|
stray = elsewhere / "bar"
|
||||||
|
_symlink_or_skip(checkout, stray)
|
||||||
|
|
||||||
|
assert contained_plugin_dir(stray, plugins_dir) is None
|
||||||
|
assert contained_plugin_dir(plugins_dir / ".." / "elsewhere" / "bar", plugins_dir) is None
|
||||||
@@ -189,6 +189,24 @@ class TestPluginExecutor:
|
|||||||
|
|
||||||
assert result is False
|
assert result is False
|
||||||
|
|
||||||
|
def test_a_base_exception_is_a_failure_not_a_timeout(self):
|
||||||
|
"""asyncio.CancelledError derives from BaseException. Uncaught on
|
||||||
|
the executor's thread it ended the thread with the call never marked
|
||||||
|
complete, so a call that failed at once was reported, and recorded,
|
||||||
|
as timing out."""
|
||||||
|
import asyncio
|
||||||
|
import pytest
|
||||||
|
from src.exceptions import PluginError
|
||||||
|
from src.plugin_system.plugin_executor import PluginExecutor
|
||||||
|
executor = PluginExecutor(default_timeout=5.0)
|
||||||
|
|
||||||
|
def cancelled():
|
||||||
|
raise asyncio.CancelledError()
|
||||||
|
|
||||||
|
with pytest.raises(PluginError) as raised:
|
||||||
|
executor.execute_with_timeout(cancelled, plugin_id="test_plugin")
|
||||||
|
assert isinstance(raised.value.__cause__, asyncio.CancelledError)
|
||||||
|
|
||||||
|
|
||||||
class TestPluginHealth:
|
class TestPluginHealth:
|
||||||
"""Test plugin health monitoring."""
|
"""Test plugin health monitoring."""
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""
|
||||||
|
ScrollHelper frames for a strip narrower than the panel, and other wraps.
|
||||||
|
|
||||||
|
A frame that runs past the end of the strip continues from its head: column
|
||||||
|
j of the frame is strip column (position + j) modulo the strip's width. The
|
||||||
|
wrap path sliced the strip's tail and then "the rest of the frame" from its
|
||||||
|
head, which assumed the head was at least that wide. For a strip narrower
|
||||||
|
than the panel it raised ValueError at every position, so a narrow strip
|
||||||
|
(Vegas composes one when its content is narrower than the chain, with its
|
||||||
|
lead-in of 0) logged a traceback every frame instead of drawing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from src.common.scroll_helper import ScrollHelper
|
||||||
|
|
||||||
|
W, H = 128, 32
|
||||||
|
|
||||||
|
|
||||||
|
def _strip(width, height=H):
|
||||||
|
"""A strip whose every column is distinct: R and B are the column number."""
|
||||||
|
columns = np.arange(width)
|
||||||
|
pixels = np.zeros((height, width, 3), dtype=np.uint8)
|
||||||
|
pixels[:, :, 0] = columns % 256
|
||||||
|
pixels[:, :, 1] = 255 - (columns % 256)
|
||||||
|
pixels[:, :, 2] = columns // 256
|
||||||
|
return Image.fromarray(pixels, 'RGB')
|
||||||
|
|
||||||
|
|
||||||
|
def _helper(strip_width, sub_pixel=False):
|
||||||
|
sh = ScrollHelper(W, H)
|
||||||
|
sh.set_scrolling_image(_strip(strip_width))
|
||||||
|
sh.sub_pixel_scrolling = sub_pixel
|
||||||
|
return sh
|
||||||
|
|
||||||
|
|
||||||
|
def _frame(sh, position):
|
||||||
|
sh.scroll_position = position
|
||||||
|
frame = sh.get_visible_portion()
|
||||||
|
assert frame is not None and frame.size == (W, H) and frame.mode == 'RGB'
|
||||||
|
return np.asarray(frame)
|
||||||
|
|
||||||
|
|
||||||
|
def _wrapped(sh, start):
|
||||||
|
"""What the panel should show from ``start``: the strip, wrapping."""
|
||||||
|
return sh.cached_array[:, np.arange(start, start + W) % sh.cached_array.shape[1]]
|
||||||
|
|
||||||
|
|
||||||
|
class TestNarrowStrip:
|
||||||
|
@pytest.mark.parametrize('strip_width', [1, 40, 50, W - 1])
|
||||||
|
@pytest.mark.parametrize('position', [0, 10, 39])
|
||||||
|
def test_frame_repeats_the_strip_across_the_panel(self, strip_width, position):
|
||||||
|
sh = _helper(strip_width)
|
||||||
|
position %= strip_width
|
||||||
|
assert np.array_equal(_frame(sh, position), _wrapped(sh, position))
|
||||||
|
|
||||||
|
def test_a_composed_strip_without_lead_in(self):
|
||||||
|
# How Vegas builds its strip: lead_gap=0 (vegas_scroll.lead_in_width).
|
||||||
|
sh = ScrollHelper(W, H)
|
||||||
|
sh.create_scrolling_image([_strip(40)], item_gap=0, element_gap=0, lead_gap=0)
|
||||||
|
assert sh.total_scroll_width == 40
|
||||||
|
for position in range(40):
|
||||||
|
assert np.array_equal(_frame(sh, position), _wrapped(sh, position))
|
||||||
|
|
||||||
|
def test_a_whole_pass_scrolls_without_raising(self):
|
||||||
|
sh = _helper(50)
|
||||||
|
sh.set_pixels_per_frame(3)
|
||||||
|
for _ in range(60):
|
||||||
|
sh.update_scroll_position()
|
||||||
|
assert sh.get_visible_portion().size == (W, H)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('position', [0.5, 10.25, 49.5])
|
||||||
|
def test_sub_pixel_blend_of_a_narrow_strip(self, position):
|
||||||
|
sh = _helper(50, sub_pixel=True)
|
||||||
|
frame = _frame(sh, position)
|
||||||
|
start = int(position)
|
||||||
|
near, far = _wrapped(sh, start), _wrapped(sh, start + 1)
|
||||||
|
lo, hi = np.minimum(near, far), np.maximum(near, far)
|
||||||
|
assert (frame >= lo).all() and (frame <= hi).all()
|
||||||
|
|
||||||
|
|
||||||
|
class TestWrapOfAWideStrip:
|
||||||
|
"""Unchanged: the tail, then the head."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('position', [200 - W + 1, 150, 199])
|
||||||
|
def test_tail_then_head(self, position):
|
||||||
|
sh = _helper(200)
|
||||||
|
frame = _frame(sh, position)
|
||||||
|
tail = 200 - position
|
||||||
|
assert np.array_equal(frame[:, :tail], sh.cached_array[:, position:])
|
||||||
|
assert np.array_equal(frame[:, tail:], sh.cached_array[:, :W - tail])
|
||||||
|
|
||||||
|
def test_at_the_end_shows_the_head(self):
|
||||||
|
sh = _helper(200)
|
||||||
|
assert np.array_equal(_frame(sh, 200), sh.cached_array[:, :W])
|
||||||
|
|
||||||
|
def test_sub_pixel_at_the_last_column(self):
|
||||||
|
sh = _helper(200, sub_pixel=True)
|
||||||
|
assert _frame(sh, 199.5).shape == (H, W, 3)
|
||||||
|
|
||||||
|
def test_a_position_before_the_start_wraps_too(self):
|
||||||
|
# Slicing [-10:118] of the array was an empty slice: frombytes raised.
|
||||||
|
sh = _helper(200)
|
||||||
|
assert np.array_equal(_frame(sh, -10), _wrapped(sh, -10))
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_zero_width_strip_is_a_black_frame():
|
||||||
|
sh = ScrollHelper(W, H)
|
||||||
|
sh.set_scrolling_image(Image.new('RGB', (0, H)))
|
||||||
|
assert not _frame(sh, 0).any()
|
||||||
@@ -13,6 +13,8 @@ body. That is what let all eight adopt this with byte-identical renders.
|
|||||||
import logging
|
import logging
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -169,6 +171,95 @@ class TestDateAndTime:
|
|||||||
assert C.card_tzinfo({"timezone": "Not/AZone"}, log) is timezone.utc
|
assert C.card_tzinfo({"timezone": "Not/AZone"}, log) is timezone.utc
|
||||||
|
|
||||||
|
|
||||||
|
class TestWeekdayMatchesThePrintedDate:
|
||||||
|
"""The weekday is the printed date's, whichever zone printed it.
|
||||||
|
|
||||||
|
The extractor prints "M/D" in the plugin's resolved zone (its own
|
||||||
|
setting, else the global one, else the system zone). The card is handed
|
||||||
|
only the plugin's config, whose ``timezone`` ships as "" -- so a weekday
|
||||||
|
taken in card_tzinfo's zone was UTC's, and an evening kickoff in the
|
||||||
|
Americas read "Sat Oct 2" for a Friday game.
|
||||||
|
"""
|
||||||
|
|
||||||
|
WEEKDAY = {"timezone": "", "scroll_card": {"date_format": "weekday"}}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _as_printed(start_utc, zone):
|
||||||
|
"""The game dict and the date text, as the extractor builds them."""
|
||||||
|
local = datetime.fromisoformat(start_utc).astimezone(ZoneInfo(zone))
|
||||||
|
game = {"start_time_utc": datetime.fromisoformat(start_utc),
|
||||||
|
"game_date": f"{local.month}/{local.day}"}
|
||||||
|
want = f"{C.WEEKDAY_ABBR[local.weekday()]} {C.MONTH_ABBR[local.month - 1]} {local.day}"
|
||||||
|
return game, want
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("start_utc, zone, want", [
|
||||||
|
# Friday 8 PM EDT is Saturday in UTC.
|
||||||
|
("2026-10-03T00:00:00+00:00", "America/New_York", "Fri Oct 2"),
|
||||||
|
# The night US clocks go back: 8:30 PM EDT Saturday, then 11 PM EST
|
||||||
|
# Sunday, each the next day in UTC.
|
||||||
|
("2026-11-01T00:30:00+00:00", "America/New_York", "Sat Oct 31"),
|
||||||
|
("2026-11-02T04:00:00+00:00", "America/New_York", "Sun Nov 1"),
|
||||||
|
# New Year's Eve on the west coast is New Year's Day in UTC.
|
||||||
|
("2027-01-01T04:00:00+00:00", "America/Los_Angeles", "Thu Dec 31"),
|
||||||
|
# Just east of the date line: Pago Pago's Friday evening.
|
||||||
|
("2026-10-03T05:00:00+00:00", "Pacific/Pago_Pago", "Fri Oct 2"),
|
||||||
|
# Just west of it, the other way: Saturday morning in Auckland is
|
||||||
|
# Friday in UTC -- and the 10 AM game on the day NZ clocks go forward.
|
||||||
|
("2026-10-02T20:00:00+00:00", "Pacific/Auckland", "Sat Oct 3"),
|
||||||
|
("2026-09-26T21:00:00+00:00", "Pacific/Auckland", "Sun Sep 27"),
|
||||||
|
# UTC+14, the furthest any zone sits from UTC.
|
||||||
|
("2026-10-02T11:00:00+00:00", "Pacific/Kiritimati", "Sat Oct 3"),
|
||||||
|
# A zone on UTC's own date needs nothing.
|
||||||
|
("2026-10-02T19:00:00+00:00", "Europe/London", "Fri Oct 2"),
|
||||||
|
])
|
||||||
|
def test_the_shipped_blank_timezone(self, log, start_utc, zone, want):
|
||||||
|
game, printed = self._as_printed(start_utc, zone)
|
||||||
|
assert printed == want # the case says what the extractor prints
|
||||||
|
assert C.format_game_date(self.WEEKDAY, log, game["game_date"], game) == want
|
||||||
|
|
||||||
|
def test_an_iso_string_start_reads_the_same(self, log):
|
||||||
|
game = {"start_time_utc": "2026-10-03T00:00:00Z"}
|
||||||
|
assert C.format_game_date(self.WEEKDAY, log, "10/2", game) == "Fri Oct 2"
|
||||||
|
assert C.format_game_date(self.WEEKDAY, log, "10/02", game) == "Fri Oct 2"
|
||||||
|
|
||||||
|
def test_a_plugin_level_zone_still_agrees(self, log):
|
||||||
|
game, want = self._as_printed("2026-10-03T00:00:00+00:00", "America/Chicago")
|
||||||
|
cfg = dict(self.WEEKDAY, timezone="America/Chicago")
|
||||||
|
assert C.format_game_date(cfg, log, game["game_date"], game) == want == "Fri Oct 2"
|
||||||
|
|
||||||
|
def test_a_date_no_zone_could_print_keeps_the_zone_weekday(self, log):
|
||||||
|
"""More than a day from the start: nothing to anchor to, so the
|
||||||
|
weekday is card_tzinfo's, as it always was."""
|
||||||
|
game = {"start_time_utc": datetime(2026, 10, 3, 0, 0, tzinfo=timezone.utc)}
|
||||||
|
assert C.format_game_date(self.WEEKDAY, log, "10/9", game) == "Sat Oct 9"
|
||||||
|
|
||||||
|
def test_a_start_without_an_offset_keeps_the_zone_weekday(self, log):
|
||||||
|
"""A naive time names no instant, so it cannot place the date."""
|
||||||
|
game = {"start_time_utc": datetime(2026, 10, 2, 20, 0)}
|
||||||
|
assert C.format_game_date(self.WEEKDAY, log, "10/3", game) == \
|
||||||
|
f"{C.weekday_for(self.WEEKDAY, log, game)} Oct 3"
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("game", [None, {}, {"start_time_utc": "garbage"}])
|
||||||
|
def test_no_usable_start_draws_no_weekday(self, log, game):
|
||||||
|
assert C.format_game_date(self.WEEKDAY, log, "10/2", game) == "Oct 2"
|
||||||
|
|
||||||
|
def test_the_scorebug_twin_formats_the_same(self, log):
|
||||||
|
"""Switch mode (SportsCoreSharedMixin) shares the formatter body."""
|
||||||
|
from src.common.sports_shared import SportsCoreSharedMixin
|
||||||
|
|
||||||
|
class Host(SportsCoreSharedMixin):
|
||||||
|
config = {"scroll_card": {"date_format": "weekday",
|
||||||
|
"switch_date_format": "inherit"}}
|
||||||
|
logger = log
|
||||||
|
|
||||||
|
def _get_timezone(self):
|
||||||
|
return ZoneInfo("America/New_York")
|
||||||
|
|
||||||
|
game, want = self._as_printed("2026-11-01T00:30:00+00:00", "America/New_York")
|
||||||
|
assert Host()._format_game_date(game["game_date"], game) == want
|
||||||
|
assert C.format_game_date(Host.config, log, game["game_date"], game) == want
|
||||||
|
|
||||||
|
|
||||||
class TestFontSizing:
|
class TestFontSizing:
|
||||||
def test_snaps_to_the_faces_pixel_grid(self):
|
def test_snaps_to_the_faces_pixel_grid(self):
|
||||||
assert C.crisp_size("4x6-font.ttf", 6) == 7 # 7px grid
|
assert C.crisp_size("4x6-font.ttf", 6) == 7 # 7px grid
|
||||||
|
|||||||
@@ -561,21 +561,24 @@ class TestPinnedDivergence:
|
|||||||
assert C.recent_score_color(on, LOG, game, (9, 9, 9)) == (9, 9, 9)
|
assert C.recent_score_color(on, LOG, game, (9, 9, 9)) == (9, 9, 9)
|
||||||
|
|
||||||
def test_weekday_zone_source(self):
|
def test_weekday_zone_source(self):
|
||||||
# DIVERGENCE, user-visible: the scorebug asks the plugin's
|
# DIVERGENCE, not drawn: the scorebug asks the plugin's
|
||||||
# _get_timezone() (plugin setting -> global setting -> system zone);
|
# _get_timezone() (plugin setting -> global setting -> system zone);
|
||||||
# the card reads only config["timezone"] and falls back to UTC. The
|
# the card reads only config["timezone"] and falls back to UTC. The
|
||||||
# scoreboards' schemas default that key to "", and the scroll display
|
# scoreboards' schemas default that key to "", and the scroll display
|
||||||
# hands the renderer the plugin config, so a board that sets only the
|
# hands the renderer the plugin config, so the bare weekday helpers
|
||||||
# global zone gets UTC weekdays in scroll mode: an evening kickoff in
|
# still disagree for an evening kickoff in New York.
|
||||||
# New York is labelled with the next day.
|
|
||||||
game = {"start_time_utc": "2026-09-20T00:30:00+00:00"} # Sat 20:30 EDT
|
game = {"start_time_utc": "2026-09-20T00:30:00+00:00"} # Sat 20:30 EDT
|
||||||
host = _Host({}, tz=ZoneInfo("America/New_York"))
|
host = _Host({}, tz=ZoneInfo("America/New_York"))
|
||||||
assert host._weekday_for(game) == "Sat"
|
assert host._weekday_for(game) == "Sat"
|
||||||
assert C.weekday_for({}, LOG, game) == "Sun"
|
assert C.weekday_for({}, LOG, game) == "Sun"
|
||||||
|
# DECIDED: what a card draws is the printed date's own weekday, so
|
||||||
|
# the scroll card no longer labels that kickoff with the next day
|
||||||
|
# ("Sun Sep 19" before). Both formatters place the extractor's "M/D"
|
||||||
|
# against the start time instead of re-deriving the day in a zone.
|
||||||
cfg = {"scroll_card": {"date_format": "weekday", "switch_date_format": "inherit"}}
|
cfg = {"scroll_card": {"date_format": "weekday", "switch_date_format": "inherit"}}
|
||||||
host = _Host(cfg, tz=ZoneInfo("America/New_York"))
|
host = _Host(cfg, tz=ZoneInfo("America/New_York"))
|
||||||
assert host._format_game_date("9/19", game) == "Sat Sep 19"
|
assert host._format_game_date("9/19", game) == "Sat Sep 19"
|
||||||
assert C.format_game_date(cfg, LOG, "9/19", game) == "Sun Sep 19"
|
assert C.format_game_date(cfg, LOG, "9/19", game) == "Sat Sep 19"
|
||||||
|
|
||||||
def test_weekday_out_of_range_start(self):
|
def test_weekday_out_of_range_start(self):
|
||||||
# DIVERGENCE: the mixin catches OverflowError from astimezone() and
|
# DIVERGENCE: the mixin catches OverflowError from astimezone() and
|
||||||
|
|||||||
@@ -468,6 +468,140 @@ class TestRoutes:
|
|||||||
assert pkg._plugin_runtime_view().source == 'cache'
|
assert pkg._plugin_runtime_view().source == 'cache'
|
||||||
|
|
||||||
|
|
||||||
|
# --- freshness over a long-lived subscription ---------------------------------------------
|
||||||
|
|
||||||
|
class _Rig:
|
||||||
|
"""A display and one web subscription on fake clocks, with no socket.
|
||||||
|
|
||||||
|
Each second the render thread runs its publish point (unless stopped);
|
||||||
|
every 5 s the plugin runtime publisher ticks and the subscription gets
|
||||||
|
what the server would push it: a ``state`` event when the version moved,
|
||||||
|
else a tick (the short ``changed: false`` answer). The loop's heartbeat
|
||||||
|
is the time since the render thread last went round.
|
||||||
|
"""
|
||||||
|
|
||||||
|
MODE = 'ncaa_fb_live'
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
from src.ipc import client as control_client
|
||||||
|
self.wall = FakeClock(1_800_000_000.0)
|
||||||
|
self.mono = FakeClock(5000.0)
|
||||||
|
self.last_render = self.mono.now
|
||||||
|
self.hub = StateHub(loop_probe=lambda: {
|
||||||
|
'heartbeat_age_seconds': self.mono.now - self.last_render, 'armed': True,
|
||||||
|
'stale_after': display_watchdog.HEARTBEAT_STALE_SECONDS},
|
||||||
|
epoch='e1', clock=self.mono, wall_clock=self.wall)
|
||||||
|
self.dc = _controller(self.hub)
|
||||||
|
self.dc.current_display_mode = self.MODE
|
||||||
|
self.dc.mode_to_plugin_id[self.MODE] = 'football-scoreboard'
|
||||||
|
self.publisher = PluginRuntimePublisher(MagicMock(), _states(), clock=self.mono,
|
||||||
|
wall_clock=self.wall)
|
||||||
|
self.publisher.attach_hub(self.hub)
|
||||||
|
self.sub = control_client.StateSubscription(paths=['/nowhere'], clock=self.mono)
|
||||||
|
self.render = self.plugins = self.ticks = True
|
||||||
|
self.sent_version = None
|
||||||
|
self._publish()
|
||||||
|
self._push()
|
||||||
|
|
||||||
|
def _publish(self):
|
||||||
|
from src import display_controller as dc_module
|
||||||
|
with patch.object(dc_module.time, 'monotonic', self.mono), \
|
||||||
|
patch.object(dc_module.time, 'time', self.wall):
|
||||||
|
self.dc._publish_current_mode_state_if_changed()
|
||||||
|
self.last_render = self.mono.now
|
||||||
|
|
||||||
|
def _push(self):
|
||||||
|
snap = self.hub.snapshot(since=self.sent_version, epoch='e1')
|
||||||
|
self.sub._store(snap, full=bool(snap.get('changed')))
|
||||||
|
self.sent_version = snap['version']
|
||||||
|
|
||||||
|
def run(self, seconds):
|
||||||
|
for _ in range(int(seconds)):
|
||||||
|
self.wall.now += 1
|
||||||
|
self.mono.now += 1
|
||||||
|
if self.render:
|
||||||
|
self._publish()
|
||||||
|
if int(self.mono.now) % 5 == 0:
|
||||||
|
if self.plugins:
|
||||||
|
self.publisher.tick()
|
||||||
|
if self.ticks:
|
||||||
|
self._push()
|
||||||
|
|
||||||
|
def status(self):
|
||||||
|
return display_state.current_status(self.sub.latest(), now=self.wall.now)
|
||||||
|
|
||||||
|
def runtime(self):
|
||||||
|
return view_from_socket_state(self.sub.latest(), now=self.wall.now,
|
||||||
|
now_mono=self.mono.now)
|
||||||
|
|
||||||
|
|
||||||
|
class TestFreshnessOverTicks:
|
||||||
|
"""#735 on the ledpi rig: with one mode on screen for more than two
|
||||||
|
minutes (a live game, Vegas, a single plugin) current-status read
|
||||||
|
``mode: null`` from the socket. The version stayed put, so the
|
||||||
|
subscription's copy kept the ``last_updated`` of the last real change."""
|
||||||
|
|
||||||
|
def test_a_mode_on_screen_for_fifteen_minutes_stays_known(self):
|
||||||
|
rig = _Rig()
|
||||||
|
for _ in range(15):
|
||||||
|
rig.run(60)
|
||||||
|
status = rig.status()
|
||||||
|
assert status['mode'] == rig.MODE, status
|
||||||
|
assert rig.wall.now - status['last_updated'] <= 5
|
||||||
|
# The plugin section, unchanged as long, stays live too.
|
||||||
|
assert rig.runtime().status == rt.LIVE
|
||||||
|
assert rig.sub.snapshots == 1 # all of it from ticks
|
||||||
|
|
||||||
|
def test_the_route_keeps_answering_from_the_socket(self, web, monkeypatch):
|
||||||
|
client, cached = web
|
||||||
|
cached['display_current_state'] = {'mode': 'from-cache', 'last_updated': 1}
|
||||||
|
rig = _Rig()
|
||||||
|
rig.run(10 * 60)
|
||||||
|
monkeypatch.setattr(display_state, 'read_state', rig.sub.latest)
|
||||||
|
with patch.object(display_state.time, 'time', rig.wall):
|
||||||
|
data = _data(client, '/api/v3/display/current-status')
|
||||||
|
assert (data['mode'], data['source']) == (rig.MODE, 'socket')
|
||||||
|
|
||||||
|
def test_a_render_thread_that_stops_still_reads_stalled_then_unknown(self):
|
||||||
|
"""#726's verdicts are unchanged: the socket thread keeps ticking,
|
||||||
|
but nothing refreshes last_updated and the heartbeat ages."""
|
||||||
|
rig = _Rig()
|
||||||
|
rig.run(5 * 60)
|
||||||
|
rig.render = False
|
||||||
|
rig.run(display_watchdog.HEARTBEAT_STALE_SECONDS + 5)
|
||||||
|
assert rig.runtime().status == rt.STALLED
|
||||||
|
assert display_state.loop_heartbeat_age(rig.sub.latest()) >= \
|
||||||
|
display_watchdog.HEARTBEAT_STALE_SECONDS
|
||||||
|
assert rig.status()['mode'] == rig.MODE # not 120 s yet
|
||||||
|
rig.run(display_state.CURRENT_STATE_MAX_AGE_SECONDS)
|
||||||
|
assert rig.status() == {'mode': None, 'plugin_id': None, 'last_updated': None}
|
||||||
|
|
||||||
|
def test_a_runtime_publisher_that_stops_goes_stale(self):
|
||||||
|
rig = _Rig()
|
||||||
|
rig.run(60)
|
||||||
|
rig.plugins = False
|
||||||
|
rig.run(rt.STALE_AFTER + 10)
|
||||||
|
assert rig.runtime().status == rt.STALE
|
||||||
|
assert rig.status()['mode'] == rig.MODE
|
||||||
|
|
||||||
|
def test_when_the_ticks_stop_the_route_falls_back_to_the_cache(self, web, monkeypatch):
|
||||||
|
from src.ipc import client as control_client
|
||||||
|
client, cached = web
|
||||||
|
cached['display_current_state'] = {'mode': 'from-cache', 'last_updated': 1}
|
||||||
|
rig = _Rig()
|
||||||
|
rig.run(5 * 60)
|
||||||
|
rig.ticks = False
|
||||||
|
rig.run(control_client.SUBSCRIPTION_SILENCE_SECONDS + 1)
|
||||||
|
assert rig.sub.latest() is None
|
||||||
|
|
||||||
|
def no_socket(*args, **kwargs):
|
||||||
|
raise control_client.ControlError('no_socket')
|
||||||
|
monkeypatch.setattr(display_state, '_subscription', lambda: rig.sub)
|
||||||
|
monkeypatch.setattr(control_client, 'state_get', no_socket)
|
||||||
|
data = _data(client, '/api/v3/display/current-status')
|
||||||
|
assert (data['mode'], data['source']) == ('from-cache', 'cache')
|
||||||
|
|
||||||
|
|
||||||
# --- end to end over a real socket -------------------------------------------------------
|
# --- end to end over a real socket -------------------------------------------------------
|
||||||
|
|
||||||
@needs_unix_sockets
|
@needs_unix_sockets
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""Removing a dev plugin linked into the plugins directory removes the link.
|
||||||
|
|
||||||
|
``scripts/dev/dev_plugin_setup.sh`` symlinks a checkout into the plugins
|
||||||
|
directory. ``PluginStoreManager._safe_remove_directory`` -- behind uninstall,
|
||||||
|
and behind discarding the set-aside copy after an install or update -- handed
|
||||||
|
the link to ``shutil.rmtree``, which refuses a symlink. Its fallback then
|
||||||
|
walked through the link and chmodded every directory and file of the linked
|
||||||
|
checkout to 0700, and the sudo stage refused a path outside the plugins
|
||||||
|
directory. So the uninstall failed, the link stayed, and the developer's
|
||||||
|
checkout lost its group/other permissions and gained execute bits.
|
||||||
|
|
||||||
|
Skipped where this process cannot create a symlink (Windows without the
|
||||||
|
privilege).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.plugin_system.store_manager import PluginStoreManager
|
||||||
|
|
||||||
|
PLUGIN_ID = "linked-demo"
|
||||||
|
|
||||||
|
|
||||||
|
def _symlink_or_skip(target, link):
|
||||||
|
try:
|
||||||
|
os.symlink(target, link, target_is_directory=True)
|
||||||
|
except (OSError, NotImplementedError) as e:
|
||||||
|
pytest.skip(f"cannot create a symlink here: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def linked(tmp_path):
|
||||||
|
checkout = tmp_path / "dev-plugins" / PLUGIN_ID
|
||||||
|
checkout.mkdir(parents=True)
|
||||||
|
(checkout / "manifest.json").write_text(
|
||||||
|
json.dumps({"id": PLUGIN_ID, "name": "Linked", "class_name": "P",
|
||||||
|
"display_modes": ["linked"], "version": "1.0.0"}),
|
||||||
|
encoding="utf-8")
|
||||||
|
(checkout / "manager.py").write_text("X = 1\n", encoding="utf-8")
|
||||||
|
plugins_dir = tmp_path / "plugins"
|
||||||
|
plugins_dir.mkdir()
|
||||||
|
link = plugins_dir / PLUGIN_ID
|
||||||
|
_symlink_or_skip(checkout, link)
|
||||||
|
store = PluginStoreManager(plugins_dir=str(plugins_dir))
|
||||||
|
store.logger = MagicMock()
|
||||||
|
return store, link, checkout
|
||||||
|
|
||||||
|
|
||||||
|
def test_removing_a_linked_plugin_removes_only_the_link(linked):
|
||||||
|
store, link, checkout = linked
|
||||||
|
|
||||||
|
assert store._safe_remove_directory(link) is True
|
||||||
|
|
||||||
|
assert not os.path.lexists(link)
|
||||||
|
assert (checkout / "manager.py").read_text(encoding="utf-8") == "X = 1\n"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(os.name != "posix", reason="POSIX permission bits")
|
||||||
|
def test_removing_a_linked_plugin_leaves_the_checkouts_permissions(linked):
|
||||||
|
store, link, checkout = linked
|
||||||
|
os.chmod(checkout, 0o755)
|
||||||
|
os.chmod(checkout / "manager.py", 0o644)
|
||||||
|
|
||||||
|
store._safe_remove_directory(link)
|
||||||
|
|
||||||
|
assert checkout.stat().st_mode & 0o777 == 0o755
|
||||||
|
assert (checkout / "manager.py").stat().st_mode & 0o777 == 0o644
|
||||||
|
|
||||||
|
|
||||||
|
def test_uninstalling_a_linked_plugin_removes_the_link(linked):
|
||||||
|
store, link, checkout = linked
|
||||||
|
|
||||||
|
assert store.uninstall_plugin(PLUGIN_ID) is True
|
||||||
|
|
||||||
|
assert not os.path.lexists(link)
|
||||||
|
assert (checkout / "manifest.json").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_dangling_link_is_removed_too(linked):
|
||||||
|
store, link, checkout = linked
|
||||||
|
for child in checkout.iterdir():
|
||||||
|
child.unlink()
|
||||||
|
checkout.rmdir()
|
||||||
|
|
||||||
|
assert store._safe_remove_directory(link) is True
|
||||||
|
|
||||||
|
assert not os.path.lexists(link)
|
||||||
@@ -50,11 +50,13 @@ class FakeResult:
|
|||||||
self.plugins_to_install = plugins_to_install or []
|
self.plugins_to_install = plugins_to_install or []
|
||||||
self.plugins_installed = []
|
self.plugins_installed = []
|
||||||
self.plugins_failed = []
|
self.plugins_failed = []
|
||||||
|
self.skipped = []
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
return {
|
return {
|
||||||
"success": self.success,
|
"success": self.success,
|
||||||
"restored": self.restored,
|
"restored": self.restored,
|
||||||
|
"skipped": self.skipped,
|
||||||
"errors": self.errors,
|
"errors": self.errors,
|
||||||
"plugins_installed": self.plugins_installed,
|
"plugins_installed": self.plugins_installed,
|
||||||
"plugins_failed": self.plugins_failed,
|
"plugins_failed": self.plugins_failed,
|
||||||
@@ -286,6 +288,109 @@ class TestPluginReinstall:
|
|||||||
assert body["data"]["plugins_failed"][0]["error"] == "Store manager unavailable"
|
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:
|
class TestFailureReporting:
|
||||||
def test_restore_errors_produce_a_500(self, client, restore):
|
def test_restore_errors_produce_a_500(self, client, restore):
|
||||||
restore.return_value = FakeResult(
|
restore.return_value = FakeResult(
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ tmp_path so the assertions are against files on disk rather than mock
|
|||||||
calls.
|
calls.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import html
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
@@ -221,3 +223,66 @@ class TestRawEndpointsBypassSecretSeparation:
|
|||||||
env.client.post(MAIN, json={"weather": {"api_key": "PLAINTEXT-KEY"}})
|
env.client.post(MAIN, json={"weather": {"api_key": "PLAINTEXT-KEY"}})
|
||||||
# Nothing was moved aside into the secrets file.
|
# Nothing was moved aside into the secrets file.
|
||||||
assert not env.secrets_file.exists() or "PLAINTEXT-KEY" not in env.secrets_file.read_text()
|
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,6 +169,10 @@ class TestGetSchemaProperty:
|
|||||||
},
|
},
|
||||||
"fifa.world": {"type": "object",
|
"fifa.world": {"type": "object",
|
||||||
"properties": {"enabled": {"type": "boolean"}}},
|
"properties": {"enabled": {"type": "boolean"}}},
|
||||||
|
"cities": {"type": "array",
|
||||||
|
"items": {"type": "object",
|
||||||
|
"properties": {"timezone": {"type": "string"}}}},
|
||||||
|
"color": {"type": ["array", "null"], "items": {"type": "integer"}},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,6 +189,15 @@ class TestGetSchemaProperty:
|
|||||||
prop = _get_schema_property(self.SCHEMA, "fifa.world.enabled")
|
prop = _get_schema_property(self.SCHEMA, "fifa.world.enabled")
|
||||||
assert prop == {"type": "boolean"}
|
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):
|
def test_missing_path_returns_none(self):
|
||||||
assert _get_schema_property(self.SCHEMA, "nope.nope") is None
|
assert _get_schema_property(self.SCHEMA, "nope.nope") is None
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,393 @@
|
|||||||
|
"""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}
|
||||||
@@ -957,6 +957,19 @@ def _get_schema_property(schema, key_path):
|
|||||||
i = j
|
i = j
|
||||||
matched = True
|
matched = True
|
||||||
break
|
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.
|
# Matched a non-object before consuming the path — can't go deeper.
|
||||||
return None
|
return None
|
||||||
if not matched:
|
if not matched:
|
||||||
@@ -1040,6 +1053,16 @@ def _parse_form_value_with_schema(value, key_path, schema):
|
|||||||
|
|
||||||
# Handle None/empty values
|
# Handle None/empty values
|
||||||
if value is None or (isinstance(value, str) and value.strip() == ''):
|
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.
|
# A nullable field left blank means null, not an empty container.
|
||||||
# This is the inherit sentinel for per-mode style overrides: an
|
# This is the inherit sentinel for per-mode style overrides: an
|
||||||
# empty list there would read as "the user chose no colour" rather
|
# empty list there would read as "the user chose no colour" rather
|
||||||
@@ -1074,6 +1097,14 @@ def _parse_form_value_with_schema(value, key_path, schema):
|
|||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
stripped = value.strip()
|
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
|
# Check for boolean strings
|
||||||
if stripped.lower() == 'true':
|
if stripped.lower() == 'true':
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import web_interface.blueprints.api_v3 as _pkg
|
|||||||
# as module attributes, and a value binding would not see the patch.
|
# as module attributes, and a value binding would not see the patch.
|
||||||
# Several are also called from helpers that live in __init__, so the
|
# Several are also called from helpers that live in __init__, so the
|
||||||
# package is the only patch point that covers every caller.
|
# package is the only patch point that covers every caller.
|
||||||
|
from web_interface.cache import delete_cached
|
||||||
|
|
||||||
|
|
||||||
@api_v3.route('/backup/preview', methods=['GET'])
|
@api_v3.route('/backup/preview', methods=['GET'])
|
||||||
@@ -85,6 +86,17 @@ _RESTORE_OPTION_KEYS = frozenset((
|
|||||||
'restore_config', 'restore_secrets', 'restore_wifi', 'restore_fonts',
|
'restore_config', 'restore_secrets', 'restore_wifi', 'restore_fonts',
|
||||||
'restore_plugin_uploads', 'reinstall_plugins',
|
'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'])
|
@api_v3.route('/backup/restore', methods=['POST'])
|
||||||
def backup_restore():
|
def backup_restore():
|
||||||
"""Restore a backup ZIP with optional RestoreOptions."""
|
"""Restore a backup ZIP with optional RestoreOptions."""
|
||||||
@@ -134,6 +146,10 @@ def backup_restore():
|
|||||||
os.unlink(tmp_path)
|
os.unlink(tmp_path)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
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
|
# Reinstall plugins if requested and store manager available
|
||||||
if options.reinstall_plugins and result.plugins_to_install:
|
if options.reinstall_plugins and result.plugins_to_install:
|
||||||
@@ -143,6 +159,15 @@ def backup_restore():
|
|||||||
if not pid:
|
if not pid:
|
||||||
continue
|
continue
|
||||||
try:
|
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'):
|
if psm and hasattr(psm, 'install_plugin'):
|
||||||
ok = psm.install_plugin(pid)
|
ok = psm.install_plugin(pid)
|
||||||
if ok:
|
if ok:
|
||||||
|
|||||||
@@ -507,7 +507,11 @@ def save_main_config():
|
|||||||
# Try to get JSON data first, fallback to form data
|
# Try to get JSON data first, fallback to form data
|
||||||
data = None
|
data = None
|
||||||
if request.is_json:
|
if request.is_json:
|
||||||
data = request.get_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
|
||||||
if data is not None and not isinstance(data, dict):
|
if data is not None and not isinstance(data, dict):
|
||||||
return jsonify({'status': 'error', 'message': 'Request body must be a JSON object'}), 400
|
return jsonify({'status': 'error', 'message': 'Request body must be a JSON object'}), 400
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -69,6 +69,26 @@ def _deliver_on_demand(payload):
|
|||||||
return 'mailbox', reason
|
return 'mailbox', reason
|
||||||
|
|
||||||
|
|
||||||
|
def _withdraw_on_demand(request_id):
|
||||||
|
"""Take a start request the route has refused back out of the mailbox.
|
||||||
|
|
||||||
|
The display reads the mailbox for an hour without looking at a
|
||||||
|
request's age, so one left there after an error answer ran whenever the
|
||||||
|
display next started. Only this request is removed: the mailbox is
|
||||||
|
re-read and cleared only while it still holds this request_id, as the
|
||||||
|
display's _consume_on_demand_request does, so a newer request posted in
|
||||||
|
the meantime stays for the display to take.
|
||||||
|
"""
|
||||||
|
cache = _cache_manager()
|
||||||
|
try:
|
||||||
|
current = cache.get('display_on_demand_request', max_age=3600, memory_ttl=0)
|
||||||
|
if isinstance(current, dict) and current.get('request_id') == request_id:
|
||||||
|
cache.delete('display_on_demand_request')
|
||||||
|
except Exception: # the route is answering an error already
|
||||||
|
logger.warning("Could not withdraw on-demand request %s from the mailbox",
|
||||||
|
request_id, exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
@api_v3.route('/display/current', methods=['GET'])
|
@api_v3.route('/display/current', methods=['GET'])
|
||||||
def get_display_current():
|
def get_display_current():
|
||||||
"""The latest display preview, as the /stream/display SSE stream sends it.
|
"""The latest display preview, as the /stream/display SSE stream sends it.
|
||||||
@@ -259,9 +279,26 @@ def start_on_demand_display():
|
|||||||
}
|
}
|
||||||
transport, socket_error = _deliver_on_demand(request_payload)
|
transport, socket_error = _deliver_on_demand(request_payload)
|
||||||
|
|
||||||
|
# A socket acknowledgement is the display itself answering: it is
|
||||||
|
# running and has the request queued, whatever systemd says (a display
|
||||||
|
# run by hand or in the emulator has no active unit). So nothing is
|
||||||
|
# checked or started for it -- that answered "not running" for a request
|
||||||
|
# that had already taken effect. The service is still reported the way
|
||||||
|
# _ensure_display_service_running reports a running one.
|
||||||
|
if transport == 'socket':
|
||||||
|
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)
|
||||||
|
|
||||||
service_status = _get_display_service_status()
|
service_status = _get_display_service_status()
|
||||||
|
|
||||||
if not service_status.get('active') and not start_service:
|
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({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': 'Display service is not running. Please start the display service or enable "Start Service" option.',
|
'message': 'Display service is not running. Please start the display service or enable "Start Service" option.',
|
||||||
@@ -285,16 +322,25 @@ def start_on_demand_display():
|
|||||||
service_result = _ensure_display_service_running()
|
service_result = _ensure_display_service_running()
|
||||||
# Check if service actually started
|
# Check if service actually started
|
||||||
if service_result and not service_result.get('active'):
|
if service_result and not service_result.get('active'):
|
||||||
|
_withdraw_on_demand(request_id)
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': 'Failed to start display service. Please check service logs or start it manually.',
|
'message': 'Failed to start display service. Please check service logs or start it manually.',
|
||||||
'service_result': service_result
|
'service_result': service_result
|
||||||
}), 500
|
}), 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 = {
|
response_data = {
|
||||||
'request_id': request_id,
|
'request_id': request_id,
|
||||||
'plugin_id': resolved_plugin,
|
'plugin_id': plugin_id,
|
||||||
'mode': resolved_mode,
|
'mode': mode,
|
||||||
'duration': duration,
|
'duration': duration,
|
||||||
'pinned': pinned,
|
'pinned': pinned,
|
||||||
'service': service_result,
|
'service': service_result,
|
||||||
|
|||||||
@@ -3,8 +3,12 @@
|
|||||||
Routes decorate the shared `api_v3` Blueprint from the package `__init__`,
|
Routes decorate the shared `api_v3` Blueprint from the package `__init__`,
|
||||||
so their endpoint names do not depend on which module they live in.
|
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 (
|
from web_interface.blueprints.api_v3 import (
|
||||||
PROJECT_ROOT, Response, _plugin_directory, api_v3, datetime, hashlib,
|
PROJECT_ROOT, _plugin_directory, api_v3, datetime, hashlib,
|
||||||
json, jsonify, logger, os, request, uuid,
|
json, jsonify, logger, os, request, uuid,
|
||||||
)
|
)
|
||||||
from src.common.path_safety import (
|
from src.common.path_safety import (
|
||||||
@@ -231,8 +235,8 @@ def serve_plugin_static(plugin_id, file_path):
|
|||||||
if not requested_file.exists() or not requested_file.is_file():
|
if not requested_file.exists() or not requested_file.is_file():
|
||||||
return jsonify({'status': 'error', 'message': 'File not found'}), 404
|
return jsonify({'status': 'error', 'message': 'File not found'}), 404
|
||||||
|
|
||||||
# Determine content type
|
# Determine content type. Text keeps the types this route always set;
|
||||||
content_type = 'text/plain'
|
# anything else (an icon, a preview image) gets its own.
|
||||||
name = requested_file.name
|
name = requested_file.name
|
||||||
if name.endswith('.html'):
|
if name.endswith('.html'):
|
||||||
content_type = 'text/html'
|
content_type = 'text/html'
|
||||||
@@ -242,12 +246,14 @@ def serve_plugin_static(plugin_id, file_path):
|
|||||||
content_type = 'text/css'
|
content_type = 'text/css'
|
||||||
elif name.endswith('.json'):
|
elif name.endswith('.json'):
|
||||||
content_type = 'application/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)
|
||||||
|
|
||||||
# Read and return file
|
# Sent as bytes. Opening it as UTF-8 text failed to decode any binary
|
||||||
with open(requested_file, 'r', encoding='utf-8') as f:
|
# file, so an image answered 500 UnicodeDecodeError.
|
||||||
content = f.read()
|
return send_file(requested_file, mimetype=content_type)
|
||||||
|
|
||||||
return Response(content, mimetype=content_type)
|
|
||||||
|
|
||||||
|
|
||||||
@api_v3.route('/plugins/assets/delete', methods=['POST'])
|
@api_v3.route('/plugins/assets/delete', methods=['POST'])
|
||||||
|
|||||||
@@ -9,13 +9,15 @@ from web_interface.blueprints.api_v3 import (
|
|||||||
_enhance_schema_with_core_properties, _non_plugin_id_error,
|
_enhance_schema_with_core_properties, _non_plugin_id_error,
|
||||||
_filter_config_by_schema, _get_schema_property,
|
_filter_config_by_schema, _get_schema_property,
|
||||||
_hidden_array_item_property, _plugin_directory,
|
_hidden_array_item_property, _plugin_directory,
|
||||||
_parse_form_value_with_schema, _schema_allows_null, _schema_type_is,
|
_parse_form_value_with_schema, _redact_credentials, _schema_allows_null,
|
||||||
_set_missing_booleans_to_false, _set_nested_value, api_v3, datetime,
|
_schema_type_is, _set_missing_booleans_to_false, _set_nested_value, api_v3,
|
||||||
deep_merge, error_response, exception_error_response, find_secret_fields,
|
datetime, deep_merge, error_response, exception_error_response,
|
||||||
json, jsonify, logger, merge_secrets, os, remove_empty_secrets, request,
|
find_secret_fields, json, jsonify, logger, merge_secrets, os,
|
||||||
separate_secrets, success_response, validate_request_json,
|
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.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
|
from src.web_interface.validators import dedup_unique_arrays
|
||||||
import web_interface.blueprints.api_v3 as _pkg
|
import web_interface.blueprints.api_v3 as _pkg
|
||||||
# Read through the module rather than bound by value: tests patch these
|
# Read through the module rather than bound by value: tests patch these
|
||||||
@@ -43,6 +45,12 @@ def get_plugin_config():
|
|||||||
context={'missing_params': ['plugin_id']},
|
context={'missing_params': ['plugin_id']},
|
||||||
status_code=400
|
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
|
# Get plugin configuration from config manager
|
||||||
main_config = api_v3.config_manager.load_config()
|
main_config = api_v3.config_manager.load_config()
|
||||||
@@ -52,12 +60,13 @@ def get_plugin_config():
|
|||||||
# missing fields, reading legacy booleans as objects first: what the
|
# missing fields, reading legacy booleans as objects first: what the
|
||||||
# plugin runs with, and what posts back through the JSON save
|
# plugin runs with, and what posts back through the JSON save
|
||||||
schema_mgr = api_v3.schema_manager
|
schema_mgr = api_v3.schema_manager
|
||||||
|
schema = None
|
||||||
if schema_mgr:
|
if schema_mgr:
|
||||||
try:
|
try:
|
||||||
from src.plugin_system.schema_manager import prepare_plugin_config
|
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)
|
defaults = schema_mgr.generate_default_config(plugin_id, use_cache=True)
|
||||||
plugin_config = prepare_plugin_config(
|
plugin_config = prepare_plugin_config(plugin_config, schema, defaults)
|
||||||
plugin_config, schema_mgr.load_schema(plugin_id, use_cache=True), defaults)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Log but don't fail - defaults merge is best effort
|
# Log but don't fail - defaults merge is best effort
|
||||||
logger.warning("Could not merge defaults for %s: %s", plugin_id, e)
|
logger.warning("Could not merge defaults for %s: %s", plugin_id, e)
|
||||||
@@ -158,6 +167,17 @@ def get_plugin_config():
|
|||||||
'display_duration': 30
|
'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)
|
return success_response(data=plugin_config)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return exception_error_response(e, ErrorCode.CONFIG_LOAD_FAILED)
|
return exception_error_response(e, ErrorCode.CONFIG_LOAD_FAILED)
|
||||||
@@ -183,6 +203,12 @@ def save_plugin_config():
|
|||||||
if error:
|
if error:
|
||||||
return error
|
return error
|
||||||
plugin_id = data['plugin_id']
|
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', {})
|
submitted_config = data.get('config', {})
|
||||||
if not isinstance(submitted_config, dict):
|
if not isinstance(submitted_config, dict):
|
||||||
return error_response(
|
return error_response(
|
||||||
@@ -201,6 +227,9 @@ def save_plugin_config():
|
|||||||
'plugin_id required in query string',
|
'plugin_id required in query string',
|
||||||
status_code=400
|
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)
|
# Load existing config as base (partial form updates should merge, not replace)
|
||||||
existing_config = {}
|
existing_config = {}
|
||||||
|
|||||||
@@ -42,6 +42,29 @@ def _store_incompatibility(plugin: dict) -> Optional[str]:
|
|||||||
return reason if isinstance(reason, str) and reason else None
|
return reason if isinstance(reason, str) and reason else None
|
||||||
|
|
||||||
|
|
||||||
|
def _installed_plugin_id(plugin_id: str) -> str:
|
||||||
|
"""The id the plugin installed for store entry ``plugin_id`` declares.
|
||||||
|
|
||||||
|
A registry entry can install under another id: ``weather`` installs a
|
||||||
|
directory whose manifest says ``ledmatrix-weather``, and that is the id
|
||||||
|
the plugin list, the config section and /plugins/toggle know it by. The
|
||||||
|
install is found the way the store's update and uninstall find it (the
|
||||||
|
entry's id, ``aliases`` and ``plugin_path`` name); ``plugin_id`` itself
|
||||||
|
when its manifest can't be read.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
plugin_dir = api_v3.plugin_store_manager._find_plugin_path(plugin_id)
|
||||||
|
manifest_path = (resolve_under(plugin_dir, 'manifest.json')
|
||||||
|
if isinstance(plugin_dir, Path) else None)
|
||||||
|
if manifest_path is None or not manifest_path.is_file():
|
||||||
|
return plugin_id
|
||||||
|
with open(manifest_path, 'r', encoding='utf-8') as f:
|
||||||
|
manifest_id = json.load(f).get('id')
|
||||||
|
except Exception: # noqa: BLE001 - only names the install for the client
|
||||||
|
return plugin_id
|
||||||
|
return manifest_id if isinstance(manifest_id, str) and safe_path_component(manifest_id) else plugin_id
|
||||||
|
|
||||||
|
|
||||||
def _listed_plugin_dir(base: Path, name: str) -> Optional[Path]:
|
def _listed_plugin_dir(base: Path, name: str) -> Optional[Path]:
|
||||||
"""The entry of ``base`` called ``name``, or None.
|
"""The entry of ``base`` called ``name``, or None.
|
||||||
|
|
||||||
@@ -58,6 +81,27 @@ def _listed_plugin_dir(base: Path, name: str) -> Optional[Path]:
|
|||||||
return None
|
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'])
|
@api_v3.route('/plugins/update', methods=['POST'])
|
||||||
def update_plugin():
|
def update_plugin():
|
||||||
"""Update plugin"""
|
"""Update plugin"""
|
||||||
@@ -379,11 +423,10 @@ def uninstall_plugin():
|
|||||||
preserve_config=preserve_config)}
|
preserve_config=preserve_config)}
|
||||||
|
|
||||||
# Enqueue operation
|
# Enqueue operation
|
||||||
operation_id = api_v3.operation_queue.enqueue_operation(
|
operation_id, conflict = _enqueue_or_conflict(
|
||||||
OperationType.UNINSTALL,
|
OperationType.UNINSTALL, plugin_id, uninstall_callback)
|
||||||
plugin_id,
|
if conflict:
|
||||||
operation_callback=uninstall_callback
|
return conflict
|
||||||
)
|
|
||||||
|
|
||||||
return success_response(
|
return success_response(
|
||||||
data={'operation_id': operation_id},
|
data={'operation_id': operation_id},
|
||||||
@@ -487,8 +530,10 @@ def install_plugin():
|
|||||||
)
|
)
|
||||||
|
|
||||||
branch_msg = f" (branch: {branch})" if branch else ""
|
branch_msg = f" (branch: {branch})" if branch else ""
|
||||||
|
# plugin_id: the id to enable it by (see _installed_plugin_id).
|
||||||
return {'success': True,
|
return {'success': True,
|
||||||
'message': f'Plugin {plugin_id} installed successfully{branch_msg}',
|
'message': f'Plugin {plugin_id} installed successfully{branch_msg}',
|
||||||
|
'plugin_id': _installed_plugin_id(plugin_id),
|
||||||
**_store_restart_fields('install', _plugin_enabled_in_config(plugin_id))}
|
**_store_restart_fields('install', _plugin_enabled_in_config(plugin_id))}
|
||||||
else:
|
else:
|
||||||
error_msg = f'Failed to install plugin {plugin_id}'
|
error_msg = f'Failed to install plugin {plugin_id}'
|
||||||
@@ -513,11 +558,10 @@ def install_plugin():
|
|||||||
raise Exception(error_msg)
|
raise Exception(error_msg)
|
||||||
|
|
||||||
# Enqueue operation
|
# Enqueue operation
|
||||||
operation_id = api_v3.operation_queue.enqueue_operation(
|
operation_id, conflict = _enqueue_or_conflict(
|
||||||
OperationType.INSTALL,
|
OperationType.INSTALL, plugin_id, install_callback)
|
||||||
plugin_id,
|
if conflict:
|
||||||
operation_callback=install_callback
|
return conflict
|
||||||
)
|
|
||||||
|
|
||||||
branch_msg = f" (branch: {branch})" if branch else ""
|
branch_msg = f" (branch: {branch})" if branch else ""
|
||||||
return success_response(
|
return success_response(
|
||||||
@@ -546,7 +590,8 @@ def install_plugin():
|
|||||||
branch_msg = f" (branch: {branch})" if branch else ""
|
branch_msg = f" (branch: {branch})" if branch else ""
|
||||||
return success_response(
|
return success_response(
|
||||||
message=f'Plugin installed successfully{branch_msg}',
|
message=f'Plugin installed successfully{branch_msg}',
|
||||||
extra=_store_restart_fields('install', _plugin_enabled_in_config(plugin_id)))
|
extra={'plugin_id': _installed_plugin_id(plugin_id),
|
||||||
|
**_store_restart_fields('install', _plugin_enabled_in_config(plugin_id))})
|
||||||
else:
|
else:
|
||||||
error_msg = f'Failed to install plugin {plugin_id}'
|
error_msg = f'Failed to install plugin {plugin_id}'
|
||||||
if branch:
|
if branch:
|
||||||
|
|||||||
@@ -145,6 +145,14 @@ def get_installed_plugins():
|
|||||||
vegas_participation, vegas_participation_source = _vegas_participation(
|
vegas_participation, vegas_participation_source = _vegas_participation(
|
||||||
plugin_id, plugin_config, plugin_info)
|
plugin_id, plugin_config, plugin_info)
|
||||||
|
|
||||||
|
# The modes the manifest declares, from the catalog as /display/modes
|
||||||
|
# and on-demand/start read them. The on-demand modal offers these;
|
||||||
|
# without them it offered only the plugin id, which the display
|
||||||
|
# turns into the first mode. Strings only: a manifest is hand-edited.
|
||||||
|
declared_modes = api_v3.plugin_catalog.get_plugin_display_modes(plugin_id)
|
||||||
|
display_modes = ([m for m in declared_modes if isinstance(m, str)]
|
||||||
|
if isinstance(declared_modes, list) else [])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'id': plugin_id,
|
'id': plugin_id,
|
||||||
'name': plugin_info.get('name', plugin_id),
|
'name': plugin_info.get('name', plugin_id),
|
||||||
@@ -158,6 +166,7 @@ def get_installed_plugins():
|
|||||||
# The tab nav uses this as the <i> element's Font Awesome class
|
# The tab nav uses this as the <i> element's Font Awesome class
|
||||||
# (app-shell.js / app-early.js); only a string can be one.
|
# (app-shell.js / app-early.js); only a string can be one.
|
||||||
'icon': plugin_info.get('icon') if isinstance(plugin_info.get('icon'), str) else None,
|
'icon': plugin_info.get('icon') if isinstance(plugin_info.get('icon'), str) else None,
|
||||||
|
'display_modes': display_modes,
|
||||||
'enabled': enabled,
|
'enabled': enabled,
|
||||||
'verified': verified,
|
'verified': verified,
|
||||||
# loaded, state, error_info, loaded_version, loaded_at: the
|
# loaded, state, error_info, loaded_version, loaded_at: the
|
||||||
@@ -430,6 +439,10 @@ sys.exit(proc.returncode)
|
|||||||
import tempfile
|
import tempfile
|
||||||
import json as json_lib
|
import json as json_lib
|
||||||
|
|
||||||
|
# The params reach the wrapper on its stdin, never in
|
||||||
|
# its source: written there as `params = <JSON>`, a
|
||||||
|
# true, false or null was an undefined name and the
|
||||||
|
# wrapper died with a NameError before the script ran.
|
||||||
params_json = json_lib.dumps(action_params)
|
params_json = json_lib.dumps(action_params)
|
||||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as wrapper:
|
with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as wrapper:
|
||||||
wrapper.write(f'''import sys
|
wrapper.write(f'''import sys
|
||||||
@@ -440,6 +453,9 @@ import json
|
|||||||
# Set LEDMATRIX_ROOT
|
# Set LEDMATRIX_ROOT
|
||||||
os.environ['LEDMATRIX_ROOT'] = r"{PROJECT_ROOT}"
|
os.environ['LEDMATRIX_ROOT'] = r"{PROJECT_ROOT}"
|
||||||
|
|
||||||
|
# The params, as JSON on this wrapper's own stdin
|
||||||
|
params = json.loads(sys.stdin.read())
|
||||||
|
|
||||||
# Run the script and provide params as JSON via stdin
|
# Run the script and provide params as JSON via stdin
|
||||||
proc = subprocess.Popen(
|
proc = subprocess.Popen(
|
||||||
[sys.executable, r"{script_file}"],
|
[sys.executable, r"{script_file}"],
|
||||||
@@ -451,7 +467,6 @@ proc = subprocess.Popen(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Send params as JSON to stdin
|
# Send params as JSON to stdin
|
||||||
params = {params_json}
|
|
||||||
stdout, _ = proc.communicate(input=json.dumps(params), timeout=120)
|
stdout, _ = proc.communicate(input=json.dumps(params), timeout=120)
|
||||||
print(stdout)
|
print(stdout)
|
||||||
sys.exit(proc.returncode)
|
sys.exit(proc.returncode)
|
||||||
@@ -461,6 +476,7 @@ sys.exit(proc.returncode)
|
|||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
['python3', wrapper_path],
|
['python3', wrapper_path],
|
||||||
|
input=params_json,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=120,
|
timeout=120,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ _SAFE_PLUGIN_ID_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}$')
|
|||||||
_SAFE_WEB_UI_FILE_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}\.html$')
|
_SAFE_WEB_UI_FILE_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}\.html$')
|
||||||
_SAFE_WIDGET_NAME_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}$')
|
_SAFE_WIDGET_NAME_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}$')
|
||||||
_SAFE_WIDGET_SCRIPT_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}\.js$')
|
_SAFE_WIDGET_SCRIPT_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}\.js$')
|
||||||
from src.web_interface.secret_helpers import mask_secret_fields
|
from src.web_interface.secret_helpers import mask_all_secret_values, mask_secret_fields
|
||||||
from src.plugin_system.schema_manager import plugin_config_defaults, prepare_plugin_config
|
from src.plugin_system.schema_manager import plugin_config_defaults, prepare_plugin_config
|
||||||
from src.common.path_safety import resolve_under, safe_path_component
|
from src.common.path_safety import resolve_under, safe_path_component
|
||||||
from src.pi5_matrix_support import is_raspberry_pi_5
|
from src.pi5_matrix_support import is_raspberry_pi_5
|
||||||
@@ -623,9 +623,14 @@ def _load_raw_json_partial():
|
|||||||
main_config_data = pages_v3.config_manager.get_raw_file_content('main')
|
main_config_data = pages_v3.config_manager.get_raw_file_content('main')
|
||||||
# The web login section (password and token hashes) is managed in
|
# The web login section (password and token hashes) is managed in
|
||||||
# General > Security, never in this editor; its save keeps it.
|
# General > Security, never in this editor; its save keeps it.
|
||||||
|
# The rest is masked, as GET /api/v3/config/secrets masks it: this
|
||||||
|
# page is served to anyone who can reach the port while the web
|
||||||
|
# login is off, and it was handing them every credential in the
|
||||||
|
# file. The save strips the masks and merges onto the stored file
|
||||||
|
# (save_raw_secrets_config), so a value left masked stays as it is.
|
||||||
from web_interface.auth import strip_auth_section
|
from web_interface.auth import strip_auth_section
|
||||||
secrets_config_data = strip_auth_section(
|
secrets_config_data = mask_all_secret_values(strip_auth_section(
|
||||||
pages_v3.config_manager.get_raw_file_content('secrets'))
|
pages_v3.config_manager.get_raw_file_content('secrets')))
|
||||||
main_config_json = json.dumps(main_config_data, indent=4)
|
main_config_json = json.dumps(main_config_data, indent=4)
|
||||||
secrets_config_json = json.dumps(secrets_config_data, indent=4)
|
secrets_config_json = json.dumps(secrets_config_data, indent=4)
|
||||||
|
|
||||||
|
|||||||
@@ -48,12 +48,15 @@ const PluginInstallManager = {
|
|||||||
* @returns {Promise<Array>} Update results, one per plugin sent
|
* @returns {Promise<Array>} Update results, one per plugin sent
|
||||||
*/
|
*/
|
||||||
async updateAll(onProgress, options = {}) {
|
async updateAll(onProgress, options = {}) {
|
||||||
// Prefer PluginStateManager if populated, fall back to window.installedPlugins
|
// window.installedPlugins is the live list: plugins_manager.js
|
||||||
// (plugins_manager.js populates window.installedPlugins independently)
|
// republishes it after every install, uninstall and refresh.
|
||||||
const stateManagerPlugins = window.PluginStateManager && window.PluginStateManager.installedPlugins;
|
// PluginStateManager's copy is written only by the refresh at the end
|
||||||
const listed = (stateManagerPlugins && stateManagerPlugins.length > 0)
|
// of a run, so preferring it sent a second run the first run's
|
||||||
? stateManagerPlugins
|
// plugins -- an uninstalled one failed, a new one was skipped. It is
|
||||||
: (window.installedPlugins || []);
|
// the fallback for a page without the plugin manager.
|
||||||
|
const listed = Array.isArray(window.installedPlugins)
|
||||||
|
? window.installedPlugins
|
||||||
|
: ((window.PluginStateManager && window.PluginStateManager.installedPlugins) || []);
|
||||||
// Snapshot: the list can be replaced while this loop is awaiting.
|
// Snapshot: the list can be replaced while this loop is awaiting.
|
||||||
const plugins = this.updatablePlugins(listed);
|
const plugins = this.updatablePlugins(listed);
|
||||||
|
|
||||||
@@ -102,10 +105,18 @@ const PluginInstallManager = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Reload plugin list once at the end. A failed refresh must not
|
// Reload plugin list once at the end. A failed refresh must not
|
||||||
// lose the results: they carry the restart flags.
|
// lose the results: they carry the restart flags. The plugin
|
||||||
if (window.PluginStateManager) {
|
// manager's load, forced past its caches, also redraws the installed
|
||||||
|
// grid and its Updates badge; PluginStateManager's only replaced
|
||||||
|
// window.installedPlugins, so the cards kept offering "Update to vX"
|
||||||
|
// for what had just been updated.
|
||||||
|
const pluginManager = window.pluginManager;
|
||||||
|
const refresh = (pluginManager && typeof pluginManager.loadInstalledPlugins === 'function')
|
||||||
|
? () => pluginManager.loadInstalledPlugins(true)
|
||||||
|
: (window.PluginStateManager ? () => window.PluginStateManager.loadInstalledPlugins() : null);
|
||||||
|
if (refresh) {
|
||||||
try {
|
try {
|
||||||
await window.PluginStateManager.loadInstalledPlugins();
|
await refresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Could not refresh the installed plugin list after updating:', error);
|
console.warn('Could not refresh the installed plugin list after updating:', error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,9 @@
|
|||||||
* });
|
* });
|
||||||
*
|
*
|
||||||
* The container re-renders from /api/v3/plugins/installed each init; the
|
* The container re-renders from /api/v3/plugins/installed each init; the
|
||||||
* hidden input(s) must already hold the saved order/exclusions (JSON).
|
* hidden input(s) must already hold the saved order/exclusions (JSON). Saved
|
||||||
|
* ids of disabled plugins (installed, but without a row) stay in them, in
|
||||||
|
* their saved places; ids of plugins no longer installed are dropped.
|
||||||
*/
|
*/
|
||||||
(function() {
|
(function() {
|
||||||
'use strict';
|
'use strict';
|
||||||
@@ -39,17 +41,60 @@
|
|||||||
const excludedInput = options.excludedInputId ? document.getElementById(options.excludedInputId) : null;
|
const excludedInput = options.excludedInputId ? document.getElementById(options.excludedInputId) : null;
|
||||||
if (!container || !orderInput) return;
|
if (!container || !orderInput) return;
|
||||||
|
|
||||||
|
// The saved lists as the inputs held them when the rows were drawn.
|
||||||
|
// Only enabled plugins get a row, and the inputs are rewritten from
|
||||||
|
// the rows, so a disabled plugin's place and exclusion have to be
|
||||||
|
// carried over from these: dropped, the next Display or Durations
|
||||||
|
// save stored the lists without it, and once re-enabled it came back
|
||||||
|
// at the end of the rotation and scrolling in Vegas again.
|
||||||
|
let savedOrder = [];
|
||||||
|
let savedExcluded = [];
|
||||||
|
// Every installed plugin's id, enabled or not, from the same
|
||||||
|
// response. A saved id outside it belongs to an uninstalled plugin
|
||||||
|
// and is dropped, as every save used to; without the list, nothing
|
||||||
|
// is dropped.
|
||||||
|
let installedIds = null;
|
||||||
|
|
||||||
|
// Saved ids of installed plugins with no row, once each. Only
|
||||||
|
// strings: /config/main refuses a list holding anything else, which
|
||||||
|
// would block every save.
|
||||||
|
function unlisted(saved, rowIds) {
|
||||||
|
const seen = new Set(rowIds);
|
||||||
|
return saved.filter(id => {
|
||||||
|
if (typeof id !== 'string' || seen.has(id)) return false;
|
||||||
|
if (installedIds && !installedIds.has(id)) return false;
|
||||||
|
seen.add(id);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function syncInputs() {
|
function syncInputs() {
|
||||||
const order = [];
|
const rowIds = [];
|
||||||
const excluded = [];
|
const excluded = [];
|
||||||
container.querySelectorAll('.plugin-order-item').forEach(item => {
|
container.querySelectorAll('.plugin-order-item').forEach(item => {
|
||||||
const pluginId = item.dataset.pluginId;
|
const pluginId = item.dataset.pluginId;
|
||||||
order.push(pluginId);
|
rowIds.push(pluginId);
|
||||||
const checkbox = item.querySelector('.plugin-order-include');
|
const checkbox = item.querySelector('.plugin-order-include');
|
||||||
if (checkbox && !checkbox.checked) excluded.push(pluginId);
|
if (checkbox && !checkbox.checked) excluded.push(pluginId);
|
||||||
});
|
});
|
||||||
orderInput.value = JSON.stringify(order);
|
// An id without a row keeps its saved slot; the rows fill the
|
||||||
if (excludedInput) excludedInput.value = JSON.stringify(excluded);
|
// other slots in their current order, and any rows left over
|
||||||
|
// (plugins not in the saved order) go last.
|
||||||
|
const kept = new Set(unlisted(savedOrder, rowIds));
|
||||||
|
const order = [];
|
||||||
|
let next = 0;
|
||||||
|
savedOrder.forEach(id => {
|
||||||
|
if (kept.has(id)) {
|
||||||
|
order.push(id);
|
||||||
|
kept.delete(id);
|
||||||
|
} else if (rowIds.includes(id) && next < rowIds.length) {
|
||||||
|
order.push(rowIds[next++]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
orderInput.value = JSON.stringify(order.concat(rowIds.slice(next)));
|
||||||
|
if (excludedInput) {
|
||||||
|
excludedInput.value = JSON.stringify(excluded.concat(unlisted(savedExcluded, rowIds)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupDragAndDrop() {
|
function setupDragAndDrop() {
|
||||||
@@ -104,6 +149,7 @@
|
|||||||
.then(data => {
|
.then(data => {
|
||||||
const allPlugins = (data.data && data.data.plugins) || data.plugins || [];
|
const allPlugins = (data.data && data.data.plugins) || data.plugins || [];
|
||||||
const plugins = allPlugins.filter(p => p.enabled);
|
const plugins = allPlugins.filter(p => p.enabled);
|
||||||
|
installedIds = new Set(allPlugins.map(p => p && p.id));
|
||||||
if (plugins.length === 0) {
|
if (plugins.length === 0) {
|
||||||
const empty = document.createElement('p');
|
const empty = document.createElement('p');
|
||||||
empty.className = 'text-sm text-gray-500 italic';
|
empty.className = 'text-sm text-gray-500 italic';
|
||||||
@@ -125,6 +171,8 @@
|
|||||||
// (e.g. a saved value of "null"); normalize to arrays.
|
// (e.g. a saved value of "null"); normalize to arrays.
|
||||||
if (!Array.isArray(currentOrder)) currentOrder = [];
|
if (!Array.isArray(currentOrder)) currentOrder = [];
|
||||||
if (!Array.isArray(excluded)) excluded = [];
|
if (!Array.isArray(excluded)) excluded = [];
|
||||||
|
savedOrder = currentOrder;
|
||||||
|
savedExcluded = excluded;
|
||||||
|
|
||||||
// Saved order first, then any newly enabled plugins.
|
// Saved order first, then any newly enabled plugins.
|
||||||
const orderedPlugins = [];
|
const orderedPlugins = [];
|
||||||
|
|||||||
@@ -34,8 +34,8 @@
|
|||||||
*
|
*
|
||||||
* Layout: a few handlers defined up front, outside any IIFE, because the
|
* Layout: a few handlers defined up front, outside any IIFE, because the
|
||||||
* cards and other scripts call them through window (configurePlugin,
|
* cards and other scripts call them through window (configurePlugin,
|
||||||
* togglePlugin, the GitHub token helpers, handleGitHubPluginInstall,
|
* togglePlugin, the GitHub token helpers, checkGitHubAuthStatus); then the
|
||||||
* checkGitHubAuthStatus); then the plugin-manager IIFE (private state:
|
* plugin-manager IIFE (private state:
|
||||||
* installedPlugins, the store cache, the on-demand poller); then the
|
* installedPlugins, the store cache, the on-demand poller); then the
|
||||||
* Starlark IIFE.
|
* Starlark IIFE.
|
||||||
*
|
*
|
||||||
@@ -386,103 +386,6 @@ window.toggleGithubTokenContent = function(e) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Simple standalone handler for GitHub plugin installation
|
|
||||||
// Defined early and globally to ensure it's always available
|
|
||||||
debugLog('[DEFINE] Defining handleGitHubPluginInstall function...');
|
|
||||||
window.handleGitHubPluginInstall = function() {
|
|
||||||
debugLog('[handleGitHubPluginInstall] Function called!');
|
|
||||||
|
|
||||||
const urlInput = document.getElementById('github-plugin-url');
|
|
||||||
const statusDiv = document.getElementById('github-plugin-status');
|
|
||||||
const branchInput = document.getElementById('plugin-branch-input');
|
|
||||||
const installBtn = document.getElementById('install-plugin-from-url');
|
|
||||||
|
|
||||||
if (!urlInput) {
|
|
||||||
console.error('[handleGitHubPluginInstall] URL input not found');
|
|
||||||
alert('Error: Could not find URL input field');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const repoUrl = urlInput.value.trim();
|
|
||||||
debugLog('[handleGitHubPluginInstall] Repo URL:', repoUrl);
|
|
||||||
|
|
||||||
if (!repoUrl) {
|
|
||||||
if (statusDiv) {
|
|
||||||
statusDiv.innerHTML = '<span class="text-red-600"><i class="fas fa-exclamation-circle mr-1"></i>Please enter a GitHub URL</span>';
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isGithubUrl(repoUrl)) {
|
|
||||||
if (statusDiv) {
|
|
||||||
statusDiv.innerHTML = '<span class="text-red-600"><i class="fas fa-exclamation-circle mr-1"></i>Please enter a valid GitHub URL</span>';
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Disable button and show loading
|
|
||||||
if (installBtn) {
|
|
||||||
installBtn.disabled = true;
|
|
||||||
installBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Installing...';
|
|
||||||
}
|
|
||||||
if (statusDiv) {
|
|
||||||
statusDiv.innerHTML = '<span class="text-blue-600"><i class="fas fa-spinner fa-spin mr-1"></i>Installing plugin...</span>';
|
|
||||||
}
|
|
||||||
|
|
||||||
const branch = branchInput?.value?.trim() || null;
|
|
||||||
const requestBody = { repo_url: repoUrl };
|
|
||||||
if (branch) {
|
|
||||||
requestBody.branch = branch;
|
|
||||||
}
|
|
||||||
|
|
||||||
debugLog('[handleGitHubPluginInstall] Sending request:', requestBody);
|
|
||||||
|
|
||||||
fetch('/api/v3/plugins/install-from-url', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify(requestBody)
|
|
||||||
})
|
|
||||||
.then(response => {
|
|
||||||
debugLog('[handleGitHubPluginInstall] Response status:', response.status);
|
|
||||||
return response.json();
|
|
||||||
})
|
|
||||||
.then(data => {
|
|
||||||
debugLog('[handleGitHubPluginInstall] Response data:', data);
|
|
||||||
if (data.status === 'success') {
|
|
||||||
if (statusDiv) {
|
|
||||||
statusDiv.innerHTML = `<span class="text-green-600"><i class="fas fa-check-circle mr-1"></i>Successfully installed: ${window.LEDEscape.html(data.plugin_id)}</span>`;
|
|
||||||
}
|
|
||||||
urlInput.value = '';
|
|
||||||
|
|
||||||
showNotification(`Plugin ${data.plugin_id} installed successfully`, 'success');
|
|
||||||
window.noteRestartRequired(data);
|
|
||||||
|
|
||||||
setTimeout(() => window.pluginManager.loadInstalledPlugins(true).catch(() => {}), 1000);
|
|
||||||
} else {
|
|
||||||
if (statusDiv) {
|
|
||||||
statusDiv.innerHTML = `<span class="text-red-600"><i class="fas fa-times-circle mr-1"></i>${window.LEDEscape.html(data.message || 'Installation failed')}</span>`;
|
|
||||||
}
|
|
||||||
showNotification(data.message || 'Installation failed', 'error');
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error('[handleGitHubPluginInstall] Error:', error);
|
|
||||||
if (statusDiv) {
|
|
||||||
statusDiv.innerHTML = `<span class="text-red-600"><i class="fas fa-times-circle mr-1"></i>Error: ${window.LEDEscape.html(error.message)}</span>`;
|
|
||||||
}
|
|
||||||
showNotification('Error installing plugin: ' + error.message, 'error');
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (installBtn) {
|
|
||||||
installBtn.disabled = false;
|
|
||||||
installBtn.innerHTML = '<i class="fas fa-download mr-2"></i>Install';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
debugLog('[DEFINE] handleGitHubPluginInstall defined and ready');
|
|
||||||
|
|
||||||
// GitHub Authentication Status - Define early so it's available in IIFE
|
// GitHub Authentication Status - Define early so it's available in IIFE
|
||||||
// Shows warning banner only when token is missing or invalid
|
// Shows warning banner only when token is missing or invalid
|
||||||
// The token itself is never exposed to the frontend for security
|
// The token itself is never exposed to the frontend for security
|
||||||
@@ -2083,6 +1986,13 @@ window.uninstallPlugin = function(pluginId) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// How many times the store's Install polls a queued install, a second apart.
|
||||||
|
// The server allows the plugin's dependency install 300 s on its own
|
||||||
|
// (install_requirements_file in src/plugin_system/store_install.py), after a
|
||||||
|
// download that fetches the plugin a file at a time; the 60 the poller
|
||||||
|
// defaults to reported installs that then succeeded as timed out.
|
||||||
|
const INSTALL_POLL_MAX_ATTEMPTS = 600;
|
||||||
|
|
||||||
function pollOperationStatus(operationId, pluginId, pluginName, options = {}) {
|
function pollOperationStatus(operationId, pluginId, pluginName, options = {}) {
|
||||||
const maxAttempts = options.maxAttempts || 60;
|
const maxAttempts = options.maxAttempts || 60;
|
||||||
const attempt = options.attempt || 0;
|
const attempt = options.attempt || 0;
|
||||||
@@ -2114,9 +2024,10 @@ function pollOperationStatus(operationId, pluginId, pluginName, options = {}) {
|
|||||||
|
|
||||||
if (status === 'completed') {
|
if (status === 'completed') {
|
||||||
// The operation's result says whether the display picks
|
// The operation's result says whether the display picks
|
||||||
// the change up by itself or needs a restart.
|
// the change up by itself or needs a restart, and for an
|
||||||
|
// install which id the plugin was installed as.
|
||||||
window.noteRestartRequired(operation.result);
|
window.noteRestartRequired(operation.result);
|
||||||
onComplete();
|
onComplete(operation.result);
|
||||||
} else if (status === 'failed') {
|
} else if (status === 'failed') {
|
||||||
onFailed(operation.error || operation.message);
|
onFailed(operation.error || operation.message);
|
||||||
} else {
|
} else {
|
||||||
@@ -2270,10 +2181,18 @@ function showStoreLoading(show) {
|
|||||||
// ── Plugin Store: Client-Side Filter/Sort/Pagination ────────────────────────
|
// ── Plugin Store: Client-Side Filter/Sort/Pagination ────────────────────────
|
||||||
|
|
||||||
function isStorePluginInstalled(pluginIdOrPlugin) {
|
function isStorePluginInstalled(pluginIdOrPlugin) {
|
||||||
|
return Boolean(findInstalledStorePlugin(pluginIdOrPlugin));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The installed-list entry for a store plugin, or undefined. A registry entry
|
||||||
|
// can be installed under another id -- `weather` is listed as the
|
||||||
|
// `ledmatrix-weather` its manifest declares -- so its own id is tried first,
|
||||||
|
// then its plugin_path name, then its aliases.
|
||||||
|
function findInstalledStorePlugin(pluginIdOrPlugin) {
|
||||||
const installed = window.installedPlugins || installedPlugins || [];
|
const installed = window.installedPlugins || installedPlugins || [];
|
||||||
// Accept either a plain ID string or a store plugin object (which may have plugin_path)
|
// Accept either a plain ID string or a store plugin object (which may have plugin_path)
|
||||||
if (typeof pluginIdOrPlugin === 'string') {
|
if (typeof pluginIdOrPlugin === 'string') {
|
||||||
return installed.some(p => p.id === pluginIdOrPlugin);
|
return installed.find(p => p.id === pluginIdOrPlugin);
|
||||||
}
|
}
|
||||||
const storeId = pluginIdOrPlugin.id;
|
const storeId = pluginIdOrPlugin.id;
|
||||||
// Derive the actual installed directory name from plugin_path (e.g. "plugins/ledmatrix-weather" → "ledmatrix-weather")
|
// Derive the actual installed directory name from plugin_path (e.g. "plugins/ledmatrix-weather" → "ledmatrix-weather")
|
||||||
@@ -2281,8 +2200,9 @@ function isStorePluginInstalled(pluginIdOrPlugin) {
|
|||||||
const pathDerivedId = pluginPath ? pluginPath.split('/').pop() : null;
|
const pathDerivedId = pluginPath ? pluginPath.split('/').pop() : null;
|
||||||
// Newer registries also list the other ids outright (the manifest id).
|
// Newer registries also list the other ids outright (the manifest id).
|
||||||
const aliases = Array.isArray(pluginIdOrPlugin.aliases) ? pluginIdOrPlugin.aliases : [];
|
const aliases = Array.isArray(pluginIdOrPlugin.aliases) ? pluginIdOrPlugin.aliases : [];
|
||||||
return installed.some(p => p.id === storeId || (pathDerivedId && p.id === pathDerivedId)
|
return installed.find(p => p.id === storeId)
|
||||||
|| aliases.includes(p.id));
|
|| (pathDerivedId ? installed.find(p => p.id === pathDerivedId) : undefined)
|
||||||
|
|| installed.find(p => aliases.includes(p.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Plugin Store: search / filter / sort ────────────────────────────────
|
// ── Plugin Store: search / filter / sort ────────────────────────────────
|
||||||
@@ -2392,8 +2312,43 @@ function getStoreFilter() {
|
|||||||
return _storeFilter;
|
return _storeFilter;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The category filter offers the categories the store's plugins have, as the
|
||||||
|
// Starlark section does. The template ships only "All Categories": a fixed
|
||||||
|
// list offered 7 of the registry's ~20 categories, so most plugins could not
|
||||||
|
// be filtered to, and "Financial" missed the plugin filed under "finance".
|
||||||
|
// One option per category whatever its case (the filter ignores case), and
|
||||||
|
// rebuilt only when the set changes, or for a select freshly swapped in.
|
||||||
|
function syncStoreCategoryOptions() {
|
||||||
|
const select = document.getElementById('plugin-category');
|
||||||
|
if (!select) return;
|
||||||
|
const ctl = getStoreFilter();
|
||||||
|
const selected = String((ctl ? ctl.state.filterCategory : select.value) || '');
|
||||||
|
const byKey = new Map();
|
||||||
|
(pluginStoreCache || []).forEach(plugin => {
|
||||||
|
const category = plugin && typeof plugin.category === 'string' ? plugin.category : '';
|
||||||
|
if (category.trim() && !byKey.has(category.toLowerCase())) {
|
||||||
|
byKey.set(category.toLowerCase(), category);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// The current choice stays selectable even if no plugin has it any more.
|
||||||
|
if (selected && !byKey.has(selected.toLowerCase())) byKey.set(selected.toLowerCase(), selected);
|
||||||
|
const categories = [...byKey.values()].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
|
||||||
|
const key = categories.join('\n');
|
||||||
|
if (select._storeCategories === key) return;
|
||||||
|
select._storeCategories = key;
|
||||||
|
select.innerHTML = '<option value="">All Categories</option>';
|
||||||
|
categories.forEach(category => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = category;
|
||||||
|
option.textContent = category.charAt(0).toUpperCase() + category.slice(1);
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
select.value = selected;
|
||||||
|
}
|
||||||
|
|
||||||
function applyStoreFiltersAndSort(skipPageReset) {
|
function applyStoreFiltersAndSort(skipPageReset) {
|
||||||
if (!pluginStoreCache) return;
|
if (!pluginStoreCache) return;
|
||||||
|
syncStoreCategoryOptions();
|
||||||
const ctl = getStoreFilter();
|
const ctl = getStoreFilter();
|
||||||
if (ctl) {
|
if (ctl) {
|
||||||
ctl.apply(skipPageReset);
|
ctl.apply(skipPageReset);
|
||||||
@@ -2510,11 +2465,45 @@ window.installPlugin = function(pluginId, branch = null) {
|
|||||||
requestBody.branch = branch;
|
requestBody.branch = branch;
|
||||||
}
|
}
|
||||||
|
|
||||||
function enableAfterInstall() {
|
const storeEntry = (pluginStoreCache || []).find(p => p && p.id === pluginId) || { id: pluginId };
|
||||||
|
// Decided before the install changes the list, by the same match that
|
||||||
|
// labelled the button Install or Reinstall. A reinstall keeps the plugin
|
||||||
|
// as the user had it: enabling it here switched a deliberately disabled
|
||||||
|
// plugin back on.
|
||||||
|
const isReinstall = isStorePluginInstalled(storeEntry);
|
||||||
|
|
||||||
|
// The id the plugin was installed as, which can differ from the store's:
|
||||||
|
// `weather` installs as the `ledmatrix-weather` its manifest declares,
|
||||||
|
// and that is the id /plugins/toggle knows. The install answer names it
|
||||||
|
// (plugin_id); from one that doesn't, the installed entry the store
|
||||||
|
// entry matches, as for the Installed badge.
|
||||||
|
function installedPluginId(result) {
|
||||||
|
if (result && typeof result.plugin_id === 'string' && result.plugin_id) {
|
||||||
|
return result.plugin_id;
|
||||||
|
}
|
||||||
|
const match = findInstalledStorePlugin(storeEntry);
|
||||||
|
return match ? match.id : pluginId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function afterInstall(result) {
|
||||||
|
// Reload first, so the new card exists (and, without plugin_id in the
|
||||||
|
// answer, so the installed id can be found), then redraw the store's
|
||||||
|
// badges from that list.
|
||||||
|
loadInstalledPlugins(true).catch(() => {}).then(() => {
|
||||||
|
applyStoreFiltersAndSort(true);
|
||||||
|
if (isReinstall) {
|
||||||
|
showNotification(`${pluginId} reinstalled`, 'success');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
enableAfterInstall(installedPluginId(result));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function enableAfterInstall(installedId) {
|
||||||
// Enable immediately so install -> enable is one step; only nudge
|
// Enable immediately so install -> enable is one step; only nudge
|
||||||
// for a restart once enablement actually succeeded (persistent
|
// for a restart once enablement actually succeeded (persistent
|
||||||
// toast; duration 0 = stays until dismissed).
|
// toast; duration 0 = stays until dismissed).
|
||||||
Promise.resolve(window.togglePlugin(pluginId, true)).then(toggleResult => {
|
Promise.resolve(window.togglePlugin(installedId, true)).then(toggleResult => {
|
||||||
if (toggleResult && toggleResult.status === 'success') {
|
if (toggleResult && toggleResult.status === 'success') {
|
||||||
showNotification(
|
showNotification(
|
||||||
`${pluginId} installed and enabled — restart the display to show it`,
|
`${pluginId} installed and enabled — restart the display to show it`,
|
||||||
@@ -2532,9 +2521,6 @@ window.installPlugin = function(pluginId, branch = null) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// Refresh installed plugins list, then re-render store to update badges
|
|
||||||
loadInstalledPlugins().catch(() => {});
|
|
||||||
setTimeout(() => applyStoreFiltersAndSort(true), 500);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fetch('/api/v3/plugins/install', {
|
fetch('/api/v3/plugins/install', {
|
||||||
@@ -2555,14 +2541,25 @@ window.installPlugin = function(pluginId, branch = null) {
|
|||||||
// live: "installation queued" followed immediately by a failed
|
// live: "installation queued" followed immediately by a failed
|
||||||
// enable). Wait for the operation to actually finish first.
|
// enable). Wait for the operation to actually finish first.
|
||||||
pollOperationStatus(data.data.operation_id, pluginId, pluginId, {
|
pollOperationStatus(data.data.operation_id, pluginId, pluginId, {
|
||||||
onComplete: enableAfterInstall,
|
onComplete: afterInstall,
|
||||||
onFailed: (errorMsg) => showNotification(errorMsg || `Failed to install ${pluginId}`, 'error'),
|
onFailed: (errorMsg) => showNotification(errorMsg || `Failed to install ${pluginId}`, 'error'),
|
||||||
onTimeout: () => showNotification(`Install operation timed out for ${pluginId}`, 'error')
|
maxAttempts: INSTALL_POLL_MAX_ATTEMPTS,
|
||||||
|
// Out of patience is not a failure: the server may still be
|
||||||
|
// installing. Show the list as it is now and say so; nothing
|
||||||
|
// is enabled without the operation's answer.
|
||||||
|
onTimeout: () => {
|
||||||
|
showNotification(
|
||||||
|
`${pluginId} is still installing — it will appear in the installed list when it finishes`,
|
||||||
|
'warning'
|
||||||
|
);
|
||||||
|
loadInstalledPlugins(true).catch(() => {})
|
||||||
|
.then(() => applyStoreFiltersAndSort(true));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// No operation queue configured - install already completed synchronously.
|
// No operation queue configured - install already completed synchronously.
|
||||||
window.noteRestartRequired(data);
|
window.noteRestartRequired(data);
|
||||||
enableAfterInstall();
|
afterInstall(data);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
|
|||||||
@@ -884,7 +884,6 @@ With this off a live game takes over the whole display with the full-screen scor
|
|||||||
// Update brightness display
|
// Update brightness display
|
||||||
document.getElementById('brightness').addEventListener('input', function() {
|
document.getElementById('brightness').addEventListener('input', function() {
|
||||||
document.getElementById('brightness-value').textContent = this.value;
|
document.getElementById('brightness-value').textContent = this.value;
|
||||||
document.getElementById('brightness-display').textContent = this.value;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -404,6 +404,10 @@
|
|||||||
document.getElementById('web-login-new-token-value').textContent = res.d.data.token;
|
document.getElementById('web-login-new-token-value').textContent = res.d.data.token;
|
||||||
document.getElementById('web-login-new-token').classList.remove('hidden');
|
document.getElementById('web-login-new-token').classList.remove('hidden');
|
||||||
form.reset();
|
form.reset();
|
||||||
|
// app.js marks a form dirty on input and clears the mark only
|
||||||
|
// after an htmx save; this one posts with fetch, so clear it
|
||||||
|
// here or a reload asks "Leave site?" about a saved token.
|
||||||
|
form.removeAttribute('data-dirty');
|
||||||
notify(res.d.message || 'Token created', 'success');
|
notify(res.d.message || 'Token created', 'success');
|
||||||
}).catch(function(err) { notify('Request failed: ' + err.message, 'error'); });
|
}).catch(function(err) { notify('Request failed: ' + err.message, 'error'); });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -55,19 +55,40 @@
|
|||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
var DISMISS_KEY = 'ledmatrix-recon-dismissed';
|
var DISMISS_KEY = 'ledmatrix-recon-dismissed';
|
||||||
|
// Startup reconciliation is done within seconds of the web service
|
||||||
|
// starting. The route also answers done: false when its status file is
|
||||||
|
// missing (reconciliation raised before writing it, or /tmp was cleaned
|
||||||
|
// under a long-running service), which used to keep this polling every
|
||||||
|
// 2 s for as long as the page was open, on every tab. So: give up after
|
||||||
|
// a minute, and poll only while the Overview is on screen.
|
||||||
|
var POLL_MS = 2000;
|
||||||
|
var MAX_POLLS = 30;
|
||||||
var _recon_timer = null;
|
var _recon_timer = null;
|
||||||
|
var _polls = 0;
|
||||||
|
var _finished = false; // done, given up, or dismissed
|
||||||
|
var _active = false;
|
||||||
|
var _inFlight = false;
|
||||||
|
|
||||||
function checkReconciliation() {
|
function checkReconciliation() {
|
||||||
|
_recon_timer = null;
|
||||||
|
_inFlight = true;
|
||||||
fetch('/api/v3/plugins/reconciliation-status')
|
fetch('/api/v3/plugins/reconciliation-status')
|
||||||
.then(function (r) { return r.json(); })
|
.then(function (r) { return r.json(); })
|
||||||
.then(function (resp) {
|
.then(function (resp) {
|
||||||
|
_inFlight = false;
|
||||||
|
if (_finished) return;
|
||||||
var d = resp.data || {};
|
var d = resp.data || {};
|
||||||
if (!d.done) {
|
if (!d.done) {
|
||||||
// Reconciliation still running — poll again shortly
|
// Reconciliation still running (or it never wrote its
|
||||||
_recon_timer = setTimeout(checkReconciliation, 2000);
|
// status): ask again shortly, a bounded number of times.
|
||||||
|
if (++_polls >= MAX_POLLS) {
|
||||||
|
_finished = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_active) _recon_timer = setTimeout(checkReconciliation, POLL_MS);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_recon_timer = null;
|
_finished = true;
|
||||||
if (!d.unresolved || d.unresolved.length === 0) return;
|
if (!d.unresolved || d.unresolved.length === 0) return;
|
||||||
var key = d.unresolved.map(function (i) { return i.plugin_id; }).sort().join(',');
|
var key = d.unresolved.map(function (i) { return i.plugin_id; }).sort().join(',');
|
||||||
if (sessionStorage.getItem(DISMISS_KEY) === key) return;
|
if (sessionStorage.getItem(DISMISS_KEY) === key) return;
|
||||||
@@ -102,13 +123,33 @@
|
|||||||
banner.dataset.dismissKey = key;
|
banner.dataset.dismissKey = key;
|
||||||
banner.style.setProperty('display', 'flex', 'important');
|
banner.style.setProperty('display', 'flex', 'important');
|
||||||
})
|
})
|
||||||
.catch(function () {});
|
.catch(function () { _inFlight = false; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function startReconciliationPoll() {
|
||||||
|
_active = true;
|
||||||
|
if (!_finished && _recon_timer === null && !_inFlight) checkReconciliation();
|
||||||
|
}
|
||||||
|
function stopReconciliationPoll() {
|
||||||
|
_active = false;
|
||||||
|
if (_recon_timer !== null) {
|
||||||
|
clearTimeout(_recon_timer);
|
||||||
|
_recon_timer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Keyed apart from any other Overview registration, which a shared key
|
||||||
|
// would replace.
|
||||||
|
if (window.LEDVisibility) {
|
||||||
|
window.LEDVisibility.onActive('overview', startReconciliationPoll,
|
||||||
|
stopReconciliationPoll, 'overview-reconciliation');
|
||||||
|
} else {
|
||||||
|
startReconciliationPoll();
|
||||||
}
|
}
|
||||||
checkReconciliation();
|
|
||||||
|
|
||||||
window.dismissReconciliationBanner = function () {
|
window.dismissReconciliationBanner = function () {
|
||||||
var banner = document.getElementById('reconciliation-banner');
|
var banner = document.getElementById('reconciliation-banner');
|
||||||
banner.style.setProperty('display', 'none', 'important');
|
banner.style.setProperty('display', 'none', 'important');
|
||||||
|
_finished = true;
|
||||||
if (_recon_timer !== null) {
|
if (_recon_timer !== null) {
|
||||||
clearTimeout(_recon_timer);
|
clearTimeout(_recon_timer);
|
||||||
_recon_timer = null;
|
_recon_timer = null;
|
||||||
|
|||||||
@@ -250,13 +250,7 @@
|
|||||||
<input type="text" id="plugin-search" placeholder="Search plugins by name, description, or tags..." aria-label="Search the Plugin Store" class="form-control text-sm flex-[3] min-w-0 px-4 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:shadow-md transition-shadow">
|
<input type="text" id="plugin-search" placeholder="Search plugins by name, description, or tags..." aria-label="Search the Plugin Store" class="form-control text-sm flex-[3] min-w-0 px-4 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:shadow-md transition-shadow">
|
||||||
<select id="plugin-category" aria-label="Filter the Plugin Store by category" class="form-control text-sm flex-1 px-3 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:shadow-md transition-shadow">
|
<select id="plugin-category" aria-label="Filter the Plugin Store by category" class="form-control text-sm flex-1 px-3 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:shadow-md transition-shadow">
|
||||||
<option value="">All Categories</option>
|
<option value="">All Categories</option>
|
||||||
<option value="sports">Sports</option>
|
<!-- The rest come from the store's plugins (plugins_manager.js, syncStoreCategoryOptions). -->
|
||||||
<option value="content">Content</option>
|
|
||||||
<option value="time">Time</option>
|
|
||||||
<option value="weather">Weather</option>
|
|
||||||
<option value="financial">Financial</option>
|
|
||||||
<option value="media">Media</option>
|
|
||||||
<option value="demo">Demo</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -467,8 +461,9 @@
|
|||||||
<input type="text" id="github-plugin-url" aria-label="Plugin GitHub repository URL"
|
<input type="text" id="github-plugin-url" aria-label="Plugin GitHub repository URL"
|
||||||
placeholder="https://github.com/user/ledmatrix-plugin-name"
|
placeholder="https://github.com/user/ledmatrix-plugin-name"
|
||||||
class="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
class="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||||||
<button type="button" id="install-plugin-from-url"
|
<!-- Wired by attachInstallButtonHandler (plugins_manager.js); an inline
|
||||||
onclick="if(window.handleGitHubPluginInstall){window.handleGitHubPluginInstall()}else{alert('Function not loaded yet, please refresh the page')}"
|
onclick here ran a second install handler on every click. -->
|
||||||
|
<button type="button" id="install-plugin-from-url"
|
||||||
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded-md whitespace-nowrap">
|
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded-md whitespace-nowrap">
|
||||||
<i class="fas fa-download mr-2"></i>Install
|
<i class="fas fa-download mr-2"></i>Install
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1093,6 +1093,12 @@
|
|||||||
Use TLS
|
Use TLS
|
||||||
<span class="text-xs text-gray-500">(a password without TLS crosses the network in the clear)</span>
|
<span class="text-xs text-gray-500">(a password without TLS crosses the network in the clear)</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label id="mqtt-allow-insecure-row" class="${c.mqtt_tls ? 'hidden' : 'flex'} items-center gap-2 text-sm text-gray-700">
|
||||||
|
<input id="mqtt-allow-insecure" type="checkbox" ${c.allow_insecure_mqtt ? 'checked' : ''}
|
||||||
|
class="rounded border-gray-300">
|
||||||
|
Allow without TLS (trusted network)
|
||||||
|
<span class="text-xs text-gray-500">(needed to save a password while TLS is off)</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
<div class="flex items-center justify-between gap-4 pt-2">
|
<div class="flex items-center justify-between gap-4 pt-2">
|
||||||
<p class="text-xs text-gray-500">
|
<p class="text-xs text-gray-500">
|
||||||
@@ -1132,6 +1138,15 @@
|
|||||||
if (clearBtn) clearBtn.addEventListener('click', () => clearMqttPassword());
|
if (clearBtn) clearBtn.addEventListener('click', () => clearMqttPassword());
|
||||||
const clearTokenBtn = document.getElementById('mqtt-clear-api-token');
|
const clearTokenBtn = document.getElementById('mqtt-clear-api-token');
|
||||||
if (clearTokenBtn) clearTokenBtn.addEventListener('click', () => clearMqttApiToken());
|
if (clearTokenBtn) clearTokenBtn.addEventListener('click', () => clearMqttApiToken());
|
||||||
|
// The cleartext opt-in only means something while TLS is off.
|
||||||
|
const tlsBox = document.getElementById('mqtt-tls');
|
||||||
|
const allowRow = document.getElementById('mqtt-allow-insecure-row');
|
||||||
|
if (tlsBox && allowRow) {
|
||||||
|
tlsBox.addEventListener('change', () => {
|
||||||
|
allowRow.classList.toggle('hidden', tlsBox.checked);
|
||||||
|
allowRow.classList.toggle('flex', !tlsBox.checked);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.loadMqttBridge = function() {
|
window.loadMqttBridge = function() {
|
||||||
@@ -1160,6 +1175,9 @@
|
|||||||
on_demand_duration: val('mqtt-duration') === '' ? null : val('mqtt-duration'),
|
on_demand_duration: val('mqtt-duration') === '' ? null : val('mqtt-duration'),
|
||||||
log_level: val('mqtt-log-level'),
|
log_level: val('mqtt-log-level'),
|
||||||
mqtt_tls: !!(document.getElementById('mqtt-tls') || {}).checked,
|
mqtt_tls: !!(document.getElementById('mqtt-tls') || {}).checked,
|
||||||
|
// The server refuses a password with TLS off unless this is set
|
||||||
|
// (CWE-319); it is off until the user ticks it.
|
||||||
|
allow_insecure_mqtt: !!(document.getElementById('mqtt-allow-insecure') || {}).checked,
|
||||||
};
|
};
|
||||||
// Only send a password when one was typed; blank means "leave it alone".
|
// Only send a password when one was typed; blank means "leave it alone".
|
||||||
const pw = val('mqtt-password');
|
const pw = val('mqtt-password');
|
||||||
|
|||||||
Reference in New Issue
Block a user