mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-06 23:35:08 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19549fc320 | ||
|
|
7d2db5eb06 | ||
|
|
f5e7f2fc6d | ||
|
|
09a846a717 | ||
|
|
dd749f59ed | ||
|
|
11cfa639ee | ||
|
|
ec95340d4a | ||
|
|
c59a779381 | ||
|
|
fd0a4b50f9 | ||
|
|
c461f4efdb |
+54
-90
@@ -19,79 +19,62 @@ accepts both, but the store flags the old spelling as deprecated
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Tooling
|
||||
### Removed: the cache-key mailboxes (control socket stage 5) -- breaking
|
||||
|
||||
- `test/test_sports_helpers.py`'s parity tests pass again with
|
||||
`LEDMATRIX_PLUGINS` set. The scoreboards deleted their copies of the
|
||||
`sports_helpers` bodies and constants when they adopted `SportsHelpersMixin`
|
||||
(ledmatrix-plugins #563/#564), and the 19 tests still expected them. A copy
|
||||
that is gone now counts as adopted when the plugin imports
|
||||
`src.common.sports_helpers`, as the stage 3/4 and game-over parity tests
|
||||
already do; a copy that remains must still match.
|
||||
The control socket (`docs/IPC_CONTROL_SOCKET.md`) is now the only way the
|
||||
web interface sends the display a command. The file mailboxes it fell back
|
||||
to for one release are gone.
|
||||
|
||||
### Dead code removed, unused plugin APIs deprecated
|
||||
|
||||
An over-engineering audit of the whole tree. Every symbol below was checked
|
||||
against core, the plugin monorepo and all eight third-party plugins in
|
||||
`plugins.json` before it went. Nothing a plugin imports was removed;
|
||||
plugin-facing methods only get `@deprecated` (see below).
|
||||
|
||||
- **Deprecated for removal in 3.10.0** (warn once per process, in
|
||||
`journalctl -u ledmatrix`). No plugin in core, the monorepo or the registry
|
||||
calls them. `docs/DEPRECATIONS_3.8.md` is the regenerated scan, which
|
||||
`scripts/plugin_api_usage.py` now runs for these owners too:
|
||||
- `LogoDownloader`: the bulk-download and RGBA-conversion methods
|
||||
(`fetch_teams_data`, `extract_teams_from_data`,
|
||||
`download_missing_logos_for_league`, `download_all_ncaa_football_logos`,
|
||||
`download_all_missing_logos`, `convert_image_to_rgba`,
|
||||
`convert_all_logos_to_rgba`). `download_missing_logo()` stays.
|
||||
- `ConfigManager`: `rollback_config`, `list_backups`,
|
||||
`validate_config_file`, `get_secret`, `cleanup_orphaned_plugin_configs`,
|
||||
`validate_all_plugin_configs`.
|
||||
- `APIHelper`: `fetch_espn_scoreboard`/`_standings`/`_rankings`,
|
||||
`set_cache`, `get_cache`, `set_rate_limit`, `get_request_stats`. `get()`
|
||||
stays.
|
||||
- `BackgroundDataService`: `get_result`, `is_request_complete`,
|
||||
`get_request_status` (pass `callback=` to `submit_fetch_request()`).
|
||||
- `PluginManager`: `get_all_plugins`, `get_plugin_info`,
|
||||
`get_all_plugin_info`, `get_plugin_display_modes`, `find_plugin_for_mode`.
|
||||
`PluginStateManager`: `is_loaded`, `is_running`, `is_error`,
|
||||
`get_last_update`, `get_error_info`, `get_state_info`.
|
||||
- `CacheManager.load_cache`, `CacheManager.generate_sport_cache_key`,
|
||||
`FontManager.measure_text`, `FontManager.get_native_bdf_size`,
|
||||
`BaseOddsManager.get_odds_for_games`, `BaseOddsManager.format_odds_summary`,
|
||||
`DynamicTeamResolver.get_available_dynamic_teams`,
|
||||
`DynamicTeamResolver.is_dynamic_team`, `PluginTestCase`.
|
||||
- **Removed (core-internal, no caller):**
|
||||
- `src/cache/cache_metrics.py`
|
||||
- Vegas status/stats plumbing that nothing read (`get_status`,
|
||||
`get_current_scroll_info`, `get_buffer_status`, `VegasModeConfig.to_dict`)
|
||||
- the sync "new cycle" message, which no follower ever handled (followers
|
||||
now ignore any message type they don't know)
|
||||
- unused `OperationType` members, `PluginOperation.from_dict`,
|
||||
`cancel_operation`
|
||||
- the test-only `PluginCatalog` readers
|
||||
- `IPC *Args.to_dict` and `client.ping()`
|
||||
- `_parse_form_value`
|
||||
- `CacheStrategyProtocol`
|
||||
- `ErrorAggregator.on_pattern_detected` and `clear_old_records`
|
||||
- the duplicate `create_error_response`/`create_success_response`
|
||||
- **Web UI:**
|
||||
- `json-file-manager.js` was never mounted: the schema widget renders the
|
||||
plugin's own file manager in an iframe.
|
||||
- `example-color-picker.js` was a docs example; `utils/error_handler.js` had
|
||||
one fallback caller.
|
||||
- The 29 one-line `escapeHtml` shims now call `window.LEDEscape` directly.
|
||||
- Four uncalled `PluginAPI` methods are gone.
|
||||
- `window.escapeHtml`, `BaseWidget` and every widget name are unchanged.
|
||||
- **Scripts and dependencies:**
|
||||
- One-off scripts removed: `add_defaults_to_schemas.py`,
|
||||
`analyze_plugin_schemas.py`, `test_captive_portal.sh`,
|
||||
`verify_wifi_before_testing.sh`, `dev/run_emulator.sh` (use
|
||||
`python3 run.py -e`), `update_plugin_repos.py` (use
|
||||
`git -C ../ledmatrix-plugins pull`).
|
||||
- Unused pins dropped: `markupsafe` (Flask still installs it) and
|
||||
`pytest-mock`.
|
||||
- **`display_on_demand_request` and `plugin_error_clear_request` are no
|
||||
longer written or read.** The display stops polling the on-demand mailbox
|
||||
(`MailboxWatch`, `MAILBOX_POLL_INTERVAL_WITH_SOCKET`,
|
||||
`_consume_on_demand_request` and the persisted
|
||||
`display_on_demand_processed_id` guard are removed), and the error
|
||||
publisher stops reading the clear request. `CacheManager.file_signature`,
|
||||
`src.cache_manager.MailboxWatch`, `src.ipc.client.should_fall_back` and
|
||||
`src.error_aggregator.ERROR_CLEAR_REQUEST_KEY` are removed.
|
||||
- **A write to either key is dropped, with one warning per writer.**
|
||||
`CacheManager.save_cache` (and so `set`) refuses `RETIRED_MAILBOX_KEYS`
|
||||
and logs `Ignored a write to the retired '<key>' cache key by plugin
|
||||
'<id>'`, naming the plugin from the call stack or the request. Plugins
|
||||
must use `BasePlugin.request_on_demand()` / `end_on_demand()` (3.8.1).
|
||||
In the official monorepo, birdnet-go, mqtt-notifications, on-air and
|
||||
pomodoro-timer still write the mailbox, but only as their fallback when
|
||||
those methods are missing or answer `None`.
|
||||
- **On-demand routes without a listening display.** `POST
|
||||
/api/v3/display/on-demand/start` with no display listening starts the
|
||||
service (when `start_service`, the default) and answers **`202`** with
|
||||
`status: "starting"` at once; a single background worker in the web
|
||||
process (`web_interface/on_demand_dispatch.py`) sends the request until
|
||||
the display acknowledges it, for up to 45 s (10 s for a service that is
|
||||
running but has no socket yet). `GET /display/on-demand/status` reports it
|
||||
(`starting`; still `starting` with `delivered: true` after the display
|
||||
acknowledges it, until the display publishes the state for that
|
||||
`request_id`, now part of its on-demand state, for at most 30 s; then the
|
||||
display's state, or `error` / `start-timeout`), and
|
||||
`/display/current-status` adds `on_demand_pending`. A newer start replaces
|
||||
a pending one and a stop cancels it (`cancelled_request_id`). The web UI
|
||||
and the MQTT bridge treat `202` as taken. With the service stopped and
|
||||
`start_service` false it answers `400`. Every other socket failure (`unknown_command`
|
||||
from an older display, `disabled`/`unsupported`, `busy`, a timeout) is a
|
||||
`503`. `/stop` answers `503` when no display is listening, unless
|
||||
`stop_service` stops the service. `transport` is always `"socket"`; the
|
||||
`"mailbox"` value is gone.
|
||||
- **`POST /api/v3/errors/clear` without the socket answers `503`** (with
|
||||
`context.socket_error` and a message saying why) instead of recording a
|
||||
request. `clear_pending` in the error routes is now always `false`;
|
||||
`src.error_aggregator.read_error_report()` returns only the snapshot, and
|
||||
`error_summary_from_report()` / `plugin_health_from_report()` /
|
||||
`request_error_clear()` lose their clear-request arguments.
|
||||
- **Kept:** the display still writes `display_current_state`,
|
||||
`display_on_demand_state`, `plugin_runtime_snapshot` and the heartbeat
|
||||
file, because the web interface reads them whenever the socket cannot
|
||||
answer (a stopped or starting display, a web user not yet in the socket's
|
||||
group, Windows), and `display_on_demand_config`, its own record for
|
||||
resuming a session after a restart.
|
||||
- **Windows and `LEDMATRIX_CONTROL_SOCKET=off`:** with no socket, the web
|
||||
interface can no longer start or stop on-demand sessions or clear errors
|
||||
on a running display (the mailbox used to carry them).
|
||||
|
||||
## 3.8.2
|
||||
|
||||
@@ -1348,25 +1331,6 @@ policies are unchanged.
|
||||
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.
|
||||
- A plugin that pauses the Vegas scroll gets its pause when its display
|
||||
duration is not a plain number. Several plugins (clock-simple, calendar,
|
||||
countdown) return `display_duration` as it is in config.json, so a value
|
||||
saved as `"20"` or `null` (the raw config editor, a hand edit) reached the
|
||||
pause as a string or None; comparing it with the clock raised, and the
|
||||
plugin flashed up and the scroll went straight on, at every one of its
|
||||
turns. `inf` held the pause until something interrupted it, and 0, a
|
||||
negative number or NaN ended it at once. The pause now reads the duration
|
||||
as the rotation does (`finite_seconds()` in `base_plugin`): a numeric
|
||||
string counts, anything else that is not a finite number (or a
|
||||
`get_display_duration()` that raises) pauses for 30 s, and a number at or
|
||||
below zero for 15 s, with one warning per plugin.
|
||||
- Reinstalling Weather, Music, Stocks or Leaderboard from the Plugin Store
|
||||
while it is enabled asks for a display restart, as reinstalling any other
|
||||
enabled plugin does. `POST /api/v3/plugins/install` looked for the
|
||||
plugin's `enabled` flag under the store id (`weather`), but its config
|
||||
section is under the id its manifest declares (`ledmatrix-weather`), so
|
||||
`restart_required` was always false and the display kept running the
|
||||
copy it had loaded. The check now uses the installed id.
|
||||
|
||||
### Scrolling
|
||||
|
||||
|
||||
+16
-43
@@ -650,11 +650,10 @@ When nothing is running on demand, `data.state` is
|
||||
> above (or the web UI buttons). The API handlers
|
||||
> (`start_on_demand_display()` / `stop_on_demand_display()` in
|
||||
> `web_interface/blueprints/api_v3/display.py`) send the request over the
|
||||
> display's control socket ([IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md)).
|
||||
> Only when the socket cannot carry it do they write it into the cache
|
||||
> manager under the `display_on_demand_request` key, which
|
||||
> `DisplayController._poll_on_demand_requests()`
|
||||
> (`src/display_controller.py`) picks up. A separate
|
||||
> display's control socket ([IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md)),
|
||||
> the only way in (the `display_on_demand_request` cache-key mailbox is
|
||||
> gone). A plugin asks for the screen with `BasePlugin.request_on_demand()`
|
||||
> (see [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md)). A separate
|
||||
> `display_on_demand_config` key is used by the controller itself
|
||||
> during activation (`_activate_on_demand()`) to track what's
|
||||
> currently running, and is cleared by `_clear_on_demand()`.
|
||||
@@ -737,29 +736,12 @@ keys helps troubleshoot stuck states.
|
||||
|
||||
### Cache Keys
|
||||
|
||||
**1. display_on_demand_request** (TTL: 1 hour)
|
||||
```json
|
||||
{
|
||||
"request_id": "uuid-string",
|
||||
"action": "start|stop",
|
||||
"plugin_id": "plugin-name",
|
||||
"mode": "mode-name",
|
||||
"duration": 30.0,
|
||||
"pinned": true,
|
||||
"timestamp": 1234567890.123
|
||||
}
|
||||
```
|
||||
**Purpose:** Communication from web interface to display controller, as the
|
||||
fallback when the control socket cannot carry the request (deprecated; it
|
||||
will be removed in a later release)
|
||||
**When Set:** API endpoint receives a request and the display's control
|
||||
socket is unavailable (display stopped, or older than the socket or the
|
||||
command); some plugins also write it directly
|
||||
**Read:** once a second while the display serves the control socket (0.25 s
|
||||
without it), and only when the file changed since the last look
|
||||
**Auto-Cleared:** After processing or 1 hour TTL
|
||||
Requests are not cache keys: they go over the control socket. The
|
||||
`display_on_demand_request` and `display_on_demand_processed_id` keys of
|
||||
earlier releases are no longer written or read; a leftover file is
|
||||
harmless and can be deleted.
|
||||
|
||||
**2. display_on_demand_config** (No TTL)
|
||||
**1. display_on_demand_config** (No TTL)
|
||||
```json
|
||||
{
|
||||
"mode": "mode-name",
|
||||
@@ -771,7 +753,7 @@ without it), and only when the file changed since the last look
|
||||
**When Set:** Controller processes start request
|
||||
**Auto-Cleared:** When on-demand stops
|
||||
|
||||
**3. display_on_demand_state** (Continuously updated)
|
||||
**2. display_on_demand_state** (Continuously updated)
|
||||
```json
|
||||
{
|
||||
"active": true,
|
||||
@@ -785,31 +767,24 @@ without it), and only when the file changed since the last look
|
||||
**When Set:** Every display loop iteration
|
||||
**Auto-Cleared:** Never (continuously updated)
|
||||
|
||||
**4. display_on_demand_processed_id** (TTL: 1 hour)
|
||||
```text
|
||||
"uuid-string-of-last-processed-request"
|
||||
```
|
||||
**Purpose:** Prevents duplicate request processing
|
||||
**When Set:** After processing request
|
||||
**Auto-Cleared:** After 1 hour TTL
|
||||
|
||||
### When Manual Clearing is Needed
|
||||
|
||||
**Scenario 1: Stuck in On-Demand State**
|
||||
- Symptom: Display stays on one plugin, won't return to rotation
|
||||
- Clear: `config`, `state`, `request`
|
||||
- Clear: `config`, `state`
|
||||
|
||||
**Scenario 2: Mode Switching Issues**
|
||||
- Symptom: Can't change to different plugin
|
||||
- Clear: `request`, `processed_id`, `state`
|
||||
- Clear: `state`, then restart the display
|
||||
|
||||
**Scenario 3: On-Demand Not Activating**
|
||||
- Symptom: Button click does nothing
|
||||
- Clear: `processed_id`, `request`
|
||||
- Symptom: Button click does nothing, or answers an error
|
||||
- Check the error's `socket_error` (see [IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md),
|
||||
"Checking it on a device"); no cache key is involved
|
||||
|
||||
**Scenario 4: After Service Crash**
|
||||
- Symptom: Strange behavior after crash/restart
|
||||
- Clear: All four keys
|
||||
- Clear: both keys
|
||||
|
||||
### Manual Recovery Procedures
|
||||
|
||||
@@ -846,8 +821,6 @@ from src.cache_manager import CacheManager
|
||||
cache = CacheManager()
|
||||
cache.clear_cache('display_on_demand_config')
|
||||
cache.clear_cache('display_on_demand_state')
|
||||
cache.clear_cache('display_on_demand_request')
|
||||
cache.clear_cache('display_on_demand_processed_id')
|
||||
```
|
||||
|
||||
> `CacheManager` also has a `delete(key)` method — a thin wrapper over
|
||||
|
||||
+20
-26
@@ -42,11 +42,11 @@ each other. They share three things:
|
||||
| State | Where | Written by | Read by |
|
||||
|---|---|---|---|
|
||||
| On-demand command | control socket `/run/ledmatrix/control.sock` ([IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md)) | web: `start_on_demand_display()` / `stop_on_demand_display()` in [`api_v3/display.py`](../web_interface/blueprints/api_v3/display.py), via [`src/ipc/client.py`](../src/ipc/client.py) | display: [`src/ipc/server.py`](../src/ipc/server.py) acks; the render thread applies it in `_poll_on_demand_requests()` |
|
||||
| On-demand request (fallback) | cache `display_on_demand_request` | web, only when the socket could not carry the request (`should_fall_back`); four plugins write it directly | display: `_poll_on_demand_requests()`, a `stat()` every 1 s while the socket is up (0.25 s without), read only when the file changed |
|
||||
| On-demand request from a plugin | in memory: `BasePlugin.request_on_demand()` / `end_on_demand()` | a plugin in the display process | display: `submit_plugin_on_demand()` queues it; the render thread applies it in `_poll_on_demand_requests()` |
|
||||
| On-demand state | cache `display_on_demand_state` | display: `_publish_on_demand_state()` | web: `/api/v3/display/on-demand/status` |
|
||||
| Current screen | cache `display_current_state` | display | web: `/api/v3/display/current-status` |
|
||||
| Plugin errors | cache `plugin_error_snapshot` | display: `ErrorSnapshotPublisher` ([`src/error_aggregator.py`](../src/error_aggregator.py)) | web: `read_error_report()` for `/api/v3/errors/*` |
|
||||
| Error clear | control socket `errors.clear`; cache `plugin_error_clear_request` as the fallback | web: `POST /api/v3/errors/clear` | display: applied before the socket answers; the mailbox on the error publisher's 5 s tick, read only when the file changed |
|
||||
| Error clear | control socket `errors.clear` | web: `POST /api/v3/errors/clear` | display: applied before the socket answers |
|
||||
| Font usage | cache `font_usage_snapshot` | display: `FontUsagePublisher` ([`src/font_usage.py`](../src/font_usage.py)) | web: Fonts tab |
|
||||
| Fetch statistics (requests per plugin and host) | cache `fetch_stats_snapshot` | display: `FetchStatsPublisher` ([`src/common/fetch_service.py`](../src/common/fetch_service.py)), at most once a minute on change | web: `read_fetch_stats()` for `/api/v3/plugins/fetch-stats` |
|
||||
| Plugin health | cache `plugin_health:<id>` | display (web writes on reset) | web: `/api/v3/plugins/health` |
|
||||
@@ -58,28 +58,27 @@ each other. They share three things:
|
||||
|
||||
The on-demand start route starts `ledmatrix.service` when it is not running
|
||||
(`start_service`, on by default) but never restarts a running one. The routes
|
||||
send the command over the display's control socket and get an ack; only when
|
||||
the socket could not carry it (a stopped display, one older than the socket
|
||||
or the command) do they write the mailbox instead. A display that had the
|
||||
request and refused it is answered with the error, not posted a mailbox
|
||||
copy. The display looks at the mailbox every
|
||||
`MAILBOX_POLL_INTERVAL_WITH_SOCKET` (1 s) while it serves the socket, and
|
||||
every `ON_DEMAND_POLL_INTERVAL` (0.25 s) without one, from its dwell sleep,
|
||||
its render loops and Vegas's interrupt check as well as the main loop; a
|
||||
look is one `stat()` unless the file changed. Both ways end in the same
|
||||
handler, `_handle_on_demand_request()`.
|
||||
send the command over the display's control socket and get an ack. That is
|
||||
the only way in: the cache-key mailboxes (`display_on_demand_request`,
|
||||
`plugin_error_clear_request`) are gone, and a write to either is dropped
|
||||
with a warning. When no display is listening yet, the start route starts the
|
||||
service (if asked) and answers `202`; the web process's dispatcher
|
||||
([`on_demand_dispatch.py`](../web_interface/on_demand_dispatch.py)) sends the
|
||||
request once the socket is up, and the status routes report the outcome.
|
||||
Any other failure is answered as an error. Socket commands and plugins'
|
||||
in-process requests end in the same handler, `_handle_on_demand_request()`.
|
||||
The socket's handlers only queue; see [IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md)
|
||||
for the protocol, the permission model and the plan to retire the mailboxes.
|
||||
for the protocol and the permission model.
|
||||
|
||||
### Web and display processes: who runs plugins
|
||||
|
||||
Only the display process imports plugin code, instantiates plugins and calls
|
||||
their lifecycle hooks (`update`, `display`, `on_config_change`, `on_enable`,
|
||||
`on_disable`). The web process is metadata-only: it reads plugins as files
|
||||
-- manifests and directories through `PluginCatalog`
|
||||
([`src/plugin_system/plugin_catalog.py`](../src/plugin_system/plugin_catalog.py)),
|
||||
config schemas through `SchemaManager`, and each plugin's section of
|
||||
`config.json` through `ConfigManager`. The catalog keeps the
|
||||
through `PluginCatalog`
|
||||
([`src/plugin_system/plugin_catalog.py`](../src/plugin_system/plugin_catalog.py))
|
||||
-- manifests, config schemas (through `SchemaManager`), each plugin's
|
||||
section of `config.json`, and installed versions. The catalog keeps the
|
||||
read-only method names of `PluginManager` and has nothing that can run a
|
||||
plugin (no `load_plugin`, `get_plugin` or `plugins`).
|
||||
|
||||
@@ -118,8 +117,8 @@ other web-UI action runs its script as a subprocess. A later, explicit
|
||||
|
||||
The **control socket** from the web process to the display
|
||||
([IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md)) carries on-demand
|
||||
commands and reloads an updated plugin; its next stages stream the
|
||||
display's state and retire the cache-key mailboxes. The plugin web-entry
|
||||
commands, reloads an updated plugin and streams the display's state; it
|
||||
replaced the cache-key mailboxes. The plugin web-entry
|
||||
contract above is still to come.
|
||||
|
||||
### Plugin state: desired, observed, and who owns it
|
||||
@@ -143,9 +142,7 @@ loaded and when. Nothing else keeps plugin state:
|
||||
`DisplayController` right after it creates the `PluginManager`, writes the
|
||||
cache key `plugin_runtime_snapshot`: per plugin `loaded`, `state`, `error`
|
||||
(type, a redacted message of at most 200 characters, when, recoverable),
|
||||
`version`, `loaded_at` and `modes` (the display modes `DisplayController`
|
||||
registered -- `plugin.modes` when the plugin computes them, else the
|
||||
manifest's), plus `published_at`, `stale_after` and `running`.
|
||||
`version` and `loaded_at`, plus `published_at`, `stale_after` and `running`.
|
||||
The cache is on disk, usually the SD card, so it writes when something a
|
||||
reader sees changes -- throttled to once per 10 s -- and otherwise once a
|
||||
minute as a heartbeat. RUNNING, which every `update()` passes through, is
|
||||
@@ -161,9 +158,6 @@ truth cannot leak into a response. `/api/v3/plugins/installed` returns
|
||||
`loaded`, `state`, `error_info`, `loaded_version` and `loaded_at` per
|
||||
plugin and `data.runtime` (`status`, `published_at`, `age_seconds`);
|
||||
`/api/v3/plugins/state` returns the same beside the desired state.
|
||||
`PluginCatalog.get_plugin_display_modes` and `find_plugin_for_mode` prefer a
|
||||
live view's `modes` to the manifest's `display_modes`, so `/display/modes`
|
||||
and on-demand see modes a plugin generates from its config (#668).
|
||||
|
||||
**Reconciliation**
|
||||
([`state_reconciliation.py`](../src/plugin_system/state_reconciliation.py))
|
||||
@@ -299,7 +293,7 @@ and must not vouch for it.
|
||||
| Base class plugins implement | [`base_plugin.py`](../src/plugin_system/base_plugin.py) (`BasePlugin`, `VegasDisplayMode`) |
|
||||
| Finding a plugin's directory | [`plugin_dirs.py`](../src/plugin_system/plugin_dirs.py): manifest `id` first, then directory `<id>` or `ledmatrix-<id>` |
|
||||
| Discovery, load, unload, scheduled updates (display process) | [`plugin_manager.py`](../src/plugin_system/plugin_manager.py) (`PluginManager`) |
|
||||
| Manifest reads (web process) | [`plugin_catalog.py`](../src/plugin_system/plugin_catalog.py) (`PluginCatalog`; see [who runs plugins](#web-and-display-processes-who-runs-plugins)) |
|
||||
| Manifest, schema, config and version reads (web process) | [`plugin_catalog.py`](../src/plugin_system/plugin_catalog.py) (`PluginCatalog`; see [who runs plugins](#web-and-display-processes-who-runs-plugins)) |
|
||||
| Import and instantiate | [`plugin_loader.py`](../src/plugin_system/plugin_loader.py) (`PluginLoader.load_plugin()`: dependencies, module, class) |
|
||||
| Timeouts | [`plugin_executor.py`](../src/plugin_system/plugin_executor.py) (`PluginExecutor`, 30 s default; a timed-out thread is abandoned, not killed) |
|
||||
| Circuit breaker | [`plugin_health.py`](../src/plugin_system/plugin_health.py) (`PluginHealthTracker`: 3 consecutive failures open the circuit for 300 s) |
|
||||
|
||||
+101
-273
@@ -2,73 +2,61 @@
|
||||
|
||||
Generated by `scripts/plugin_api_usage.py` — do not edit by hand; re-run it (see [How to re-run](#how-to-re-run)).
|
||||
|
||||
- Scanned: 2026-10-05, core 3.8.2
|
||||
- Monorepo: [ChuckBuilds/ledmatrix-plugins](https://github.com/ChuckBuilds/ledmatrix-plugins) (main @ 662fb86f), 46 plugins
|
||||
- Scanned: 2026-10-01, core 3.7.0
|
||||
- Monorepo: [ChuckBuilds/ledmatrix-plugins](https://github.com/ChuckBuilds/ledmatrix-plugins) (main @ 4de1d134), 46 plugins
|
||||
- Third-party plugins: 8 with their own repo in `plugins.json` (f1-live, gif-player, pga-tour-leaderboard, plex-marquee, ledmatrix-dresden-departures, tidbyt-baseball-scoreboard, sleeper-fantasy, ledmatrix-nascar)
|
||||
|
||||
**45 deprecated methods: 31 unused, 3 still used, 11 need review.**
|
||||
**37 deprecated methods: 36 unused, 1 still used, 0 need review.**
|
||||
|
||||
Counted per plugin: a *call* is `<receiver>.method` on an object named like the owner (`cache_manager`, `display_manager`, `font_manager`, `plugin_manager`), or on `self`/`super()` in a subclass; an *override* is `def method` in a subclass of the owner. *Review* hits are `.method` on a receiver whose type the scan cannot tell. *Internal* hits sit inside another deprecated core method and go with it. *Unrelated* hits are a different class's own method with the same name (a name collision), and never block removal; neither do hits in test files.
|
||||
|
||||
| Method | Removal | Core | Plugins (calls / overrides) | Name collisions & tests | Verdict |
|
||||
|---|---|---|---|---|---|
|
||||
| `BackgroundDataService.get_result` | 3.10.0 | core tests (15 test reviews) | — | — | unused — safe to remove in 3.10.0 |
|
||||
| `BackgroundDataService.is_request_complete` | 3.10.0 | core tests (11 test reviews) | — | — | unused — safe to remove in 3.10.0 |
|
||||
| `BackgroundDataService.get_request_status` | 3.10.0 | core tests (2 test reviews) | — | — | unused — safe to remove in 3.10.0 |
|
||||
| `BaseOddsManager.get_odds_for_games` | 3.10.0 | core tests (3 test reviews) | — | — | unused — safe to remove in 3.10.0 |
|
||||
| `BaseOddsManager.format_odds_summary` | 3.10.0 | core tests (5 test reviews) | — | — | unused — safe to remove in 3.10.0 |
|
||||
| `CacheManager.load_cache` | 3.10.0 | core tests (1 test review) | — | — | unused — safe to remove in 3.10.0 |
|
||||
| `CacheManager.generate_sport_cache_key` | 3.10.0 | core tests (2 test calls, 1 test review) | — | — | unused — safe to remove in 3.10.0 |
|
||||
| `APIHelper.fetch_espn_scoreboard` | 3.10.0 | core tests (1 test call, 4 test reviews) | — | football-scoreboard (1 test review); hockey-scoreboard (1 test review); ufc-scoreboard (3 test reviews) | unused — safe to remove in 3.10.0 |
|
||||
| `APIHelper.fetch_espn_standings` | 3.10.0 | core tests (1 test review) | — | — | unused — safe to remove in 3.10.0 |
|
||||
| `APIHelper.fetch_espn_rankings` | 3.10.0 | core tests (1 test review) | — | — | unused — safe to remove in 3.10.0 |
|
||||
| `APIHelper.set_cache` | 3.10.0 | core tests (1 test call, 1 test review) | — | — | unused — safe to remove in 3.10.0 |
|
||||
| `APIHelper.get_cache` | 3.10.0 | core tests (1 test call) | — | — | unused — safe to remove in 3.10.0 |
|
||||
| `APIHelper.set_rate_limit` | 3.10.0 | core tests (10 test calls, 3 test reviews) | — | — | unused — safe to remove in 3.10.0 |
|
||||
| `APIHelper.get_request_stats` | 3.10.0 | — | — | — | unused — safe to remove in 3.10.0 |
|
||||
| `ConfigManager.rollback_config` | 3.10.0 | core (1 internal); core tests (1 test review) | — | — | unused — safe to remove in 3.10.0 |
|
||||
| `ConfigManager.list_backups` | 3.10.0 | core (1 internal); core tests (1 test review) | — | — | unused — safe to remove in 3.10.0 |
|
||||
| `ConfigManager.validate_config_file` | 3.10.0 | core (1 internal) | — | — | unused — safe to remove in 3.10.0 |
|
||||
| `ConfigManager.get_secret` | 3.10.0 | core tests (5 test calls, 1 test review) | — | — | unused — safe to remove in 3.10.0 |
|
||||
| `ConfigManager.cleanup_orphaned_plugin_configs` | 3.10.0 | core tests (2 test calls, 1 test review) | — | — | unused — safe to remove in 3.10.0 |
|
||||
| `ConfigManager.validate_all_plugin_configs` | 3.10.0 | core tests (1 test call, 1 test review) | — | — | unused — safe to remove in 3.10.0 |
|
||||
| `DynamicTeamResolver.get_available_dynamic_teams` | 3.10.0 | — | — | — | unused — safe to remove in 3.10.0 |
|
||||
| `DynamicTeamResolver.is_dynamic_team` | 3.10.0 | — | — | — | unused — safe to remove in 3.10.0 |
|
||||
| `FontManager.get_native_bdf_size` | 3.10.0 | core tests (3 test calls) | — | — | unused — safe to remove in 3.10.0 |
|
||||
| `FontManager.measure_text` | 3.10.0 | core tests (5 test calls) | — | — | unused — safe to remove in 3.10.0 |
|
||||
| `LogoDownloader.fetch_teams_data` | 3.10.0 | core (2 internals) | — | — | still used by core — keep or migrate first |
|
||||
| `LogoDownloader.extract_teams_from_data` | 3.10.0 | core (2 internals) | — | — | still used by core — keep or migrate first |
|
||||
| `LogoDownloader.download_missing_logos_for_league` | 3.10.0 | core (1 call, 1 internal); core tests (2 test calls) | — | — | still used by core — keep or migrate first |
|
||||
| `LogoDownloader.download_all_ncaa_football_logos` | 3.10.0 | core tests (2 test calls) | — | — | unused — safe to remove in 3.10.0 |
|
||||
| `LogoDownloader.download_all_missing_logos` | 3.10.0 | — | — | — | unused — safe to remove in 3.10.0 |
|
||||
| `LogoDownloader.convert_image_to_rgba` | 3.10.0 | core (1 internal) | — | — | unused — safe to remove in 3.10.0 |
|
||||
| `LogoDownloader.convert_all_logos_to_rgba` | 3.10.0 | — | — | — | unused — safe to remove in 3.10.0 |
|
||||
| `BasePlugin.get_supported_vegas_modes` | 3.9.0 | core tests (2 test reviews) | — | — | unused — safe to remove in 3.9.0 |
|
||||
| `CacheManager.has_data_changed` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `CacheManager.update_cache` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `CacheManager.setup_persistent_cache` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `CacheManager.get_sport_live_interval` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `CacheManager.get_sport_key_from_cache_key` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `CacheManager.get_background_cached_data` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `CacheManager.is_background_data_available` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `CacheManager.record_cache_hit` | 3.8.0 | core (1 internal) | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `CacheManager.record_cache_miss` | 3.8.0 | core (1 internal) | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `CacheManager.record_fetch_time` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `CacheManager.get_cache_metrics` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `CacheManager.log_cache_metrics` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `CacheManager.get_memory_cache_stats` | 3.8.0 | core tests (3 test calls) | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `DisplayManager.draw_sun` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `DisplayManager.draw_cloud` | 3.8.0 | core (2 internals) | — | ledmatrix-weather (1 unrelated) | unused — safe to remove in 3.8.0 |
|
||||
| `DisplayManager.draw_rain` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `DisplayManager.draw_snow` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `DisplayManager.draw_weather_icon` | 3.8.0 | core (1 internal) | — | ledmatrix-weather (5 unrelateds) | unused — safe to remove in 3.8.0 |
|
||||
| `DisplayManager.draw_text_with_icons` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `DisplayManager.get_scrolling_stats` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `FontManager.get_manager_fonts` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `FontManager.get_detected_fonts` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `FontManager.unregister_plugin_fonts` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `FontManager.get_plugin_fonts` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `FontManager.set_override` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `FontManager.remove_override` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `FontManager.get_overrides` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `FontManager.get_available_fonts` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `FontManager.get_size_tokens` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `FontManager.get_performance_stats` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `FontManager.get_font_catalog` | 3.8.0 | core tests (1 test call) | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `FontManager.add_font` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `FontManager.remove_font` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `FontManager.validate_font` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||
| `BasePlugin.get_supported_vegas_modes` | 3.9.0 | core tests (2 test reviews) | blackjack (1 call, 1 override); calendar (1 override); olympics (1 override) | — | still used by blackjack, calendar, olympics — keep or migrate first |
|
||||
| `BasePlugin.get_vegas_segment_width` | 3.9.0 | core tests (1 test review) | — | — | unused — safe to remove in 3.9.0 |
|
||||
| `PluginManager.get_all_plugins` | 3.10.0 | — | football-scoreboard (1 review) | football-scoreboard (2 test reviews); hockey-scoreboard (1 test review) | needs review: possible use in football-scoreboard |
|
||||
| `PluginManager.get_plugin_info` | 3.10.0 | core (12 reviews, 1 internal); core tests (6 test calls, 9 test reviews) | — | — | needs review: possible use in core |
|
||||
| `PluginManager.get_all_plugin_info` | 3.10.0 | core (3 reviews); core tests (2 test calls, 11 test reviews) | — | — | needs review: possible use in core |
|
||||
| `PluginManager.get_plugin_display_modes` | 3.10.0 | core (4 reviews); core tests (3 test calls, 5 test reviews) | — | — | needs review: possible use in core |
|
||||
| `PluginManager.find_plugin_for_mode` | 3.10.0 | core (2 reviews) | — | — | needs review: possible use in core |
|
||||
| `PluginStateManager.is_loaded` | 3.10.0 | core (1 internal) | — | — | needs review: possible use in core |
|
||||
| `PluginStateManager.is_running` | 3.10.0 | core (1 internal) | — | — | needs review: possible use in core |
|
||||
| `PluginStateManager.is_error` | 3.10.0 | core (1 internal) | — | — | needs review: possible use in core |
|
||||
| `PluginStateManager.get_error_info` | 3.10.0 | core (1 internal); core tests (4 test calls) | — | — | needs review: possible use in core |
|
||||
| `PluginStateManager.get_last_update` | 3.10.0 | core (1 internal) | — | — | needs review: possible use in core |
|
||||
| `PluginStateManager.get_state_info` | 3.10.0 | core (1 internal); core tests (7 test reviews) | — | — | needs review: possible use in core |
|
||||
| `PluginTestCase.setUp` | 3.10.0 | — | — | basketball-scoreboard (2 test unrelateds); cricket-scoreboard (1 test unrelated); hockey-scoreboard (1 test unrelated); nrl-scoreboard (1 test unrelated) | unused — safe to remove in 3.10.0 |
|
||||
| `PluginManager.get_enabled_plugins` | 3.8.0 | — | — | — | unused — safe to remove in 3.8.0 |
|
||||
|
||||
## Unused — safe to remove (31)
|
||||
## Unused — safe to remove (36)
|
||||
|
||||
`BackgroundDataService.get_result`, `BackgroundDataService.is_request_complete`, `BackgroundDataService.get_request_status`, `BaseOddsManager.get_odds_for_games`, `BaseOddsManager.format_odds_summary`, `CacheManager.load_cache`, `CacheManager.generate_sport_cache_key`, `APIHelper.fetch_espn_scoreboard`, `APIHelper.fetch_espn_standings`, `APIHelper.fetch_espn_rankings`, `APIHelper.set_cache`, `APIHelper.get_cache`, `APIHelper.set_rate_limit`, `APIHelper.get_request_stats`, `ConfigManager.rollback_config`, `ConfigManager.list_backups`, `ConfigManager.validate_config_file`, `ConfigManager.get_secret`, `ConfigManager.cleanup_orphaned_plugin_configs`, `ConfigManager.validate_all_plugin_configs`, `DynamicTeamResolver.get_available_dynamic_teams`, `DynamicTeamResolver.is_dynamic_team`, `FontManager.get_native_bdf_size`, `FontManager.measure_text`, `LogoDownloader.download_all_ncaa_football_logos`, `LogoDownloader.download_all_missing_logos`, `LogoDownloader.convert_image_to_rgba`, `LogoDownloader.convert_all_logos_to_rgba`, `BasePlugin.get_supported_vegas_modes`, `BasePlugin.get_vegas_segment_width`, `PluginTestCase.setUp`
|
||||
`CacheManager.has_data_changed`, `CacheManager.update_cache`, `CacheManager.setup_persistent_cache`, `CacheManager.get_sport_live_interval`, `CacheManager.get_sport_key_from_cache_key`, `CacheManager.get_background_cached_data`, `CacheManager.is_background_data_available`, `CacheManager.record_cache_hit`, `CacheManager.record_cache_miss`, `CacheManager.record_fetch_time`, `CacheManager.get_cache_metrics`, `CacheManager.log_cache_metrics`, `CacheManager.get_memory_cache_stats`, `DisplayManager.draw_sun`, `DisplayManager.draw_cloud`, `DisplayManager.draw_rain`, `DisplayManager.draw_snow`, `DisplayManager.draw_weather_icon`, `DisplayManager.draw_text_with_icons`, `DisplayManager.get_scrolling_stats`, `FontManager.get_manager_fonts`, `FontManager.get_detected_fonts`, `FontManager.unregister_plugin_fonts`, `FontManager.get_plugin_fonts`, `FontManager.set_override`, `FontManager.remove_override`, `FontManager.get_overrides`, `FontManager.get_available_fonts`, `FontManager.get_size_tokens`, `FontManager.get_performance_stats`, `FontManager.get_font_catalog`, `FontManager.add_font`, `FontManager.remove_font`, `FontManager.validate_font`, `BasePlugin.get_vegas_segment_width`, `PluginManager.get_enabled_plugins`
|
||||
|
||||
## Still used — keep or migrate first (3)
|
||||
## Still used — keep or migrate first (1)
|
||||
|
||||
`LogoDownloader.fetch_teams_data`, `LogoDownloader.extract_teams_from_data`, `LogoDownloader.download_missing_logos_for_league`
|
||||
|
||||
## Needs review (11)
|
||||
|
||||
`PluginManager.get_all_plugins`, `PluginManager.get_plugin_info`, `PluginManager.get_all_plugin_info`, `PluginManager.get_plugin_display_modes`, `PluginManager.find_plugin_for_mode`, `PluginStateManager.is_loaded`, `PluginStateManager.is_running`, `PluginStateManager.is_error`, `PluginStateManager.get_error_info`, `PluginStateManager.get_last_update`, `PluginStateManager.get_state_info`
|
||||
`BasePlugin.get_supported_vegas_modes`
|
||||
|
||||
## Every hit
|
||||
|
||||
@@ -76,254 +64,94 @@ File paths are relative to the plugin's directory (core: the repo root).
|
||||
|
||||
| Method | Where | File:line | Kind | Code |
|
||||
|---|---|---|---|---|
|
||||
| `BackgroundDataService.get_result` | core tests | test/test_background_data_service.py:85 | test review | `result = service.get_result(req_id)` |
|
||||
| `BackgroundDataService.get_result` | core tests | test/test_background_data_service.py:107 | test review | `seen["filed"] = service.get_result(result.request_id) is result` |
|
||||
| `BackgroundDataService.get_result` | core tests | test/test_background_data_service.py:148 | test review | `result = service.get_result(req_id)` |
|
||||
| `BackgroundDataService.get_result` | core tests | test/test_background_data_service.py:165 | test review | `result = service.get_result(req_id)` |
|
||||
| `BackgroundDataService.get_result` | core tests | test/test_background_data_service.py:254 | test review | `assert service.get_result("unknown") is None` |
|
||||
| `BackgroundDataService.get_result` | core tests | test/test_background_data_service.py:391 | test review | `assert service.get_result(rid).cached is True` |
|
||||
| `BackgroundDataService.get_result` | core tests | test/test_background_fetch_dedupe.py:198 | test review | `assert service.get_result(req).success is True` |
|
||||
| `BackgroundDataService.get_result` | core tests | test/test_background_payload_release.py:85 | test review | `result = service.get_result(req_id)` |
|
||||
| `BackgroundDataService.get_result` | core tests | test/test_background_payload_release.py:117 | test review | `stored = service.get_result(req_id)` |
|
||||
| `BackgroundDataService.get_result` | core tests | test/test_background_payload_release.py:144 | test review | `assert service.get_result(req_id).data == PAYLOAD` |
|
||||
| `BackgroundDataService.get_result` | core tests | test/test_background_payload_release.py:154 | test review | `stored = service.get_result(req_id)` |
|
||||
| `BackgroundDataService.get_result` | core tests | test/test_background_payload_release.py:171 | test review | `assert service.get_result(req_id).data is None` |
|
||||
| `BackgroundDataService.get_result` | core tests | test/test_background_payload_release.py:193 | test review | `assert service.get_result(req_id).data == PAYLOAD` |
|
||||
| `BackgroundDataService.get_result` | core tests | test/test_background_payload_release.py:278 | test review | `stored = service.get_result(first)` |
|
||||
| `BackgroundDataService.get_result` | core tests | test/test_fetch_service.py:703 | test review | `assert bds.get_result(request_id).success` |
|
||||
| `BackgroundDataService.is_request_complete` | core tests | test/test_background_data_service.py:145 | test review | `while not service.is_request_complete(req_id) and time.time() < deadline:` |
|
||||
| `BackgroundDataService.is_request_complete` | core tests | test/test_background_data_service.py:162 | test review | `while not service.is_request_complete(req_id) and time.time() < deadline:` |
|
||||
| `BackgroundDataService.is_request_complete` | core tests | test/test_background_data_service.py:194 | test review | `while not service.is_request_complete(req_id) and time.time() < deadline:` |
|
||||
| `BackgroundDataService.is_request_complete` | core tests | test/test_background_data_service.py:211 | test review | `while not service.is_request_complete(req_id) and time.time() < deadline:` |
|
||||
| `BackgroundDataService.is_request_complete` | core tests | test/test_background_data_service.py:246 | test review | `assert service.is_request_complete("r2") is False` |
|
||||
| `BackgroundDataService.is_request_complete` | core tests | test/test_background_data_service.py:251 | test review | `assert service.is_request_complete("r3") is True` |
|
||||
| `BackgroundDataService.is_request_complete` | core tests | test/test_background_data_service_espn_ranges.py:91 | test review | `while not service.is_request_complete(request_id) and time.time() < deadline:` |
|
||||
| `BackgroundDataService.is_request_complete` | core tests | test/test_background_data_service_espn_ranges.py:154 | test review | `while not service.is_request_complete(request_id) and time.time() < deadline:` |
|
||||
| `BackgroundDataService.is_request_complete` | core tests | test/test_background_fetch_dedupe.py:55 | test review | `while not service.is_request_complete(req_id) and time.time() < deadline:` |
|
||||
| `BackgroundDataService.is_request_complete` | core tests | test/test_background_payload_release.py:67 | test review | `while not service.is_request_complete(req_id) and time.time() < deadline:` |
|
||||
| `BackgroundDataService.is_request_complete` | core tests | test/test_fetch_service.py:701 | test review | `while not bds.is_request_complete(request_id) and time.monotonic() < deadline:` |
|
||||
| `BackgroundDataService.get_request_status` | core tests | test/test_background_data_service.py:223 | test review | `assert service.get_request_status("nonexistent") is None` |
|
||||
| `BackgroundDataService.get_request_status` | core tests | test/test_background_fetch_dedupe.py:448 | test review | `assert service.get_request_status(rid) is FetchStatus.CANCELLED, (` |
|
||||
| `BaseOddsManager.get_odds_for_games` | core tests | test/test_base_odds_manager.py:356 | test review | `result = manager.get_odds_for_games(games)` |
|
||||
| `BaseOddsManager.get_odds_for_games` | core tests | test/test_base_odds_manager.py:374 | test review | `result = manager.get_odds_for_games(games)` |
|
||||
| `BaseOddsManager.get_odds_for_games` | core tests | test/test_base_odds_manager.py:385 | test review | `result = manager.get_odds_for_games([game])` |
|
||||
| `BaseOddsManager.format_odds_summary` | core tests | test/test_base_odds_manager.py:322 | test review | `result = manager.format_odds_summary({` |
|
||||
| `BaseOddsManager.format_odds_summary` | core tests | test/test_base_odds_manager.py:329 | test review | `result = manager.format_odds_summary(FULL_EXTRACTED)` |
|
||||
| `BaseOddsManager.format_odds_summary` | core tests | test/test_base_odds_manager.py:333 | test review | `assert manager.format_odds_summary(None) == 'No odds available'` |
|
||||
| `BaseOddsManager.format_odds_summary` | core tests | test/test_base_odds_manager.py:336 | test review | `assert manager.format_odds_summary({}) == 'No odds available'` |
|
||||
| `BaseOddsManager.format_odds_summary` | core tests | test/test_base_odds_manager.py:339 | test review | `assert manager.format_odds_summary(` |
|
||||
| `CacheManager.load_cache` | core tests | test/conftest.py:219 | test review | `mock.load_cache = Mock(side_effect=mock_get)` |
|
||||
| `CacheManager.generate_sport_cache_key` | core tests | test/test_background_data_service.py:42 | test review | `m.generate_sport_cache_key.return_value = "test_key"` |
|
||||
| `CacheManager.generate_sport_cache_key` | core tests | test/test_background_data_service.py:356 | test call | `expected = CacheManager.generate_sport_cache_key(None, sport, date_str)` |
|
||||
| `CacheManager.generate_sport_cache_key` | core tests | test/test_background_data_service.py:367 | test call | `theirs = cm_module.CacheManager.generate_sport_cache_key(None, "nba")` |
|
||||
| `APIHelper.fetch_espn_scoreboard` | core tests | test/test_api_helper.py:214 | test review | `result = helper.fetch_espn_scoreboard('football', 'nfl')` |
|
||||
| `APIHelper.fetch_espn_scoreboard` | core tests | test/test_api_helper.py:230 | test review | `helper.fetch_espn_scoreboard('basketball', 'nba', date='20250115')` |
|
||||
| `APIHelper.fetch_espn_scoreboard` | core tests | test/test_api_helper.py:239 | test review | `helper.fetch_espn_scoreboard('basketball', 'nba', date='20250115', cache_key='mine')` |
|
||||
| `APIHelper.fetch_espn_scoreboard` | core tests | test/test_api_helper.py:251 | test review | `assert helper.fetch_espn_scoreboard('basketball', 'nba', date='20250115') == {` |
|
||||
| `APIHelper.fetch_espn_scoreboard` | core tests | test/test_espn_scoreboard_cache.py:199 | test call | `helper.fetch_espn_scoreboard("football", "nfl", date=self.DAY)` |
|
||||
| `APIHelper.fetch_espn_scoreboard` | football-scoreboard | test_espn_date_ranges.py:119 | test review | `helper = sys.modules[sports.fetch_espn_scoreboard.__module__]` |
|
||||
| `APIHelper.fetch_espn_scoreboard` | hockey-scoreboard | test_espn_date_ranges.py:106 | test review | `helper = sys.modules[sports.fetch_espn_scoreboard.__module__]` |
|
||||
| `APIHelper.fetch_espn_scoreboard` | ufc-scoreboard | test_every_bout_is_its_own_fight.py:103 | test review | `_real_fetch = sports.fetch_espn_scoreboard` |
|
||||
| `APIHelper.fetch_espn_scoreboard` | ufc-scoreboard | test_every_bout_is_its_own_fight.py:108 | test review | `sports.fetch_espn_scoreboard = lambda *a, **k: board(name)` |
|
||||
| `APIHelper.fetch_espn_scoreboard` | ufc-scoreboard | test_every_bout_is_its_own_fight.py:113 | test review | `sports.fetch_espn_scoreboard = _real_fetch` |
|
||||
| `APIHelper.fetch_espn_standings` | core tests | test/test_api_helper.py:258 | test review | `helper.fetch_espn_standings('football', 'nfl')` |
|
||||
| `APIHelper.fetch_espn_rankings` | core tests | test/test_api_helper.py:269 | test review | `helper.fetch_espn_rankings('football', 'college-football')` |
|
||||
| `APIHelper.set_cache` | core tests | test/test_api_helper.py:128 | test review | `helper.set_cache('k', {'a': 1}, ttl=42)` |
|
||||
| `APIHelper.set_cache` | core tests | test/test_api_helper.py:350 | test call | `assert helper.set_cache('k', {'a': 1}) is None` |
|
||||
| `APIHelper.get_cache` | core tests | test/test_api_helper.py:348 | test call | `assert helper.get_cache('k') is None` |
|
||||
| `APIHelper.set_rate_limit` | core tests | test/test_api_helper.py:42 | test call | `helper.set_rate_limit(0)` |
|
||||
| `APIHelper.set_rate_limit` | core tests | test/test_api_helper.py:57 | test review | `helper.set_rate_limit(5)` |
|
||||
| `APIHelper.set_rate_limit` | core tests | test/test_api_helper.py:72 | test review | `helper.set_rate_limit(5)` |
|
||||
| `APIHelper.set_rate_limit` | core tests | test/test_api_helper.py:91 | test review | `helper.set_rate_limit(5)` |
|
||||
| `APIHelper.set_rate_limit` | core tests | test/test_api_helper.py:150 | test call | `helper.set_rate_limit(0)` |
|
||||
| `APIHelper.set_rate_limit` | core tests | test/test_api_helper.py:305 | test call | `helper.set_rate_limit(0)` |
|
||||
| `APIHelper.set_rate_limit` | core tests | test/test_api_helper.py:313 | test call | `helper.set_rate_limit(0)` |
|
||||
| `APIHelper.set_rate_limit` | core tests | test/test_api_helper.py:326 | test call | `helper.set_rate_limit(0)` |
|
||||
| `APIHelper.set_rate_limit` | core tests | test/test_api_helper.py:334 | test call | `helper.set_rate_limit(0)` |
|
||||
| `APIHelper.set_rate_limit` | core tests | test/test_api_helper.py:346 | test call | `helper.set_rate_limit(0)` |
|
||||
| `APIHelper.set_rate_limit` | core tests | test/test_espn_scoreboard_cache.py:197 | test call | `helper.set_rate_limit(0)` |
|
||||
| `APIHelper.set_rate_limit` | core tests | test/test_fetch_service.py:726 | test call | `helper.set_rate_limit(0)` |
|
||||
| `APIHelper.set_rate_limit` | core tests | test/test_fetch_service.py:1143 | test call | `helper.set_rate_limit(0)` |
|
||||
| `ConfigManager.rollback_config` | core | src/config_manager.py:191 | internal (in `ConfigManager.rollback_config`) | `success = atomic_mgr.rollback_config(backup_version)` |
|
||||
| `ConfigManager.rollback_config` | core | src/config_manager_atomic.py:297 | unrelated | `def rollback_config(self, backup_version: Optional[str] = None) -> bool:` |
|
||||
| `ConfigManager.rollback_config` | core tests | test/test_config_durable_writes.py:345 | test review | `assert manager.rollback_config()` |
|
||||
| `ConfigManager.list_backups` | core | src/config_manager.py:212 | internal (in `ConfigManager.list_backups`) | `return atomic_mgr.list_backups()` |
|
||||
| `ConfigManager.list_backups` | core | src/config_manager_atomic.py:334 | unrelated | `def list_backups(self) -> List[BackupInfo]:` |
|
||||
| `ConfigManager.list_backups` | core | src/config_manager_atomic.py:309 | unrelated | `backups = self.list_backups()` |
|
||||
| `ConfigManager.list_backups` | core tests | test/test_config_durable_writes.py:323 | test review | `assert [b.path for b in manager.list_backups()] == [` |
|
||||
| `ConfigManager.validate_config_file` | core | src/config_manager.py:226 | internal (in `ConfigManager.validate_config_file`) | `return atomic_mgr.validate_config_file(config_path)` |
|
||||
| `ConfigManager.validate_config_file` | core | src/config_manager_atomic.py:426 | unrelated | `def validate_config_file(self, config_path: Optional[str] = None) -> ValidationResult:` |
|
||||
| `ConfigManager.get_secret` | core tests | test/conftest.py:246 | test review | `mock.get_secret = Mock(side_effect=mock_get_secret)` |
|
||||
| `ConfigManager.get_secret` | core tests | test/test_config_manager.py:317 | test call | `assert manager.get_secret("api_key") == "secret123"` |
|
||||
| `ConfigManager.get_secret` | core tests | test/test_config_manager.py:318 | test call | `assert manager.get_secret("token") == "token456"` |
|
||||
| `ConfigManager.get_secret` | core tests | test/test_config_manager.py:319 | test call | `assert manager.get_secret("nonexistent") is None` |
|
||||
| `ConfigManager.get_secret` | core tests | test/test_config_manager.py:325 | test call | `assert manager.get_secret("api_key") is None` |
|
||||
| `ConfigManager.get_secret` | core tests | test/test_config_manager.py:337 | test call | `assert manager.get_secret("api_key") is None` |
|
||||
| `ConfigManager.cleanup_orphaned_plugin_configs` | core tests | test/test_config_manager.py:447 | test call | `removed = manager.cleanup_orphaned_plugin_configs(["plugin1", "plugin2"])` |
|
||||
| `ConfigManager.cleanup_orphaned_plugin_configs` | core tests | test/test_core_config_key_adopters.py:80 | test review | `removed = manager.cleanup_orphaned_plugin_configs(['installed'])` |
|
||||
| `ConfigManager.cleanup_orphaned_plugin_configs` | core tests | test/test_web_auth.py:616 | test call | `config_manager.cleanup_orphaned_plugin_configs([])` |
|
||||
| `ConfigManager.validate_all_plugin_configs` | core tests | test/test_core_config_key_adopters.py:91 | test review | `results = manager.validate_all_plugin_configs(schema_manager)` |
|
||||
| `ConfigManager.validate_all_plugin_configs` | core tests | test/test_retired_plugin_config_keys.py:112 | test call | `results = config_manager.validate_all_plugin_configs(schema_manager)` |
|
||||
| `FontManager.get_native_bdf_size` | core tests | test/test_font_manager.py:104 | test call | `assert fm.get_native_bdf_size("five_by_seven") == 7` |
|
||||
| `FontManager.get_native_bdf_size` | core tests | test/test_font_manager.py:107 | test call | `assert fm.get_native_bdf_size("press_start") is None` |
|
||||
| `FontManager.get_native_bdf_size` | core tests | test/test_font_manager.py:110 | test call | `assert fm.get_native_bdf_size("no-such-family") is None` |
|
||||
| `FontManager.measure_text` | core tests | test/test_font_manager.py:116 | test call | `width, height, baseline = fm.measure_text("SCORE", font)` |
|
||||
| `FontManager.measure_text` | core tests | test/test_font_manager.py:119 | test call | `assert fm.measure_text("SCORE", font) == (width, height, baseline)` |
|
||||
| `FontManager.measure_text` | core tests | test/test_font_manager.py:124 | test call | `short, _, _ = fm.measure_text("AB", font)` |
|
||||
| `FontManager.measure_text` | core tests | test/test_font_manager.py:125 | test call | `long, _, _ = fm.measure_text("ABCD", font)` |
|
||||
| `FontManager.measure_text` | core tests | test/test_font_manager.py:132 | test call | `fm.measure_text("X", font)` |
|
||||
| `LogoDownloader.fetch_teams_data` | core | src/logo_downloader.py:639 | internal (in `LogoDownloader.download_missing_logos_for_league`) | `data = self.fetch_teams_data(league)` |
|
||||
| `LogoDownloader.fetch_teams_data` | core | src/logo_downloader.py:695 | internal (in `LogoDownloader.download_all_ncaa_football_logos`) | `data = self.fetch_teams_data(league)` |
|
||||
| `LogoDownloader.extract_teams_from_data` | core | src/logo_downloader.py:645 | internal (in `LogoDownloader.download_missing_logos_for_league`) | `teams = self.extract_teams_from_data(data, league)` |
|
||||
| `LogoDownloader.extract_teams_from_data` | core | src/logo_downloader.py:701 | internal (in `LogoDownloader.download_all_ncaa_football_logos`) | `teams = self.extract_teams_from_data(data, league)` |
|
||||
| `LogoDownloader.download_missing_logos_for_league` | core | src/logo_downloader.py:786 | internal (in `LogoDownloader.download_all_missing_logos`) | `downloaded, failed = self.download_missing_logos_for_league(league, force_download)` |
|
||||
| `LogoDownloader.download_missing_logos_for_league` | core | src/logo_downloader.py:1034 | call | `return downloader.download_missing_logos_for_league(league, force_download)` |
|
||||
| `LogoDownloader.download_missing_logos_for_league` | core tests | test/test_logo_downloader.py:292 | test call | `downloader.download_missing_logos_for_league("nfl")` |
|
||||
| `LogoDownloader.download_missing_logos_for_league` | core tests | test/test_logo_downloader.py:304 | test call | `downloader.download_missing_logos_for_league("nfl")` |
|
||||
| `LogoDownloader.download_all_ncaa_football_logos` | core tests | test/test_logo_downloader.py:319 | test call | `downloader.download_all_ncaa_football_logos()` |
|
||||
| `LogoDownloader.download_all_ncaa_football_logos` | core tests | test/test_logo_downloader.py:332 | test call | `downloader.download_all_ncaa_football_logos()` |
|
||||
| `LogoDownloader.convert_image_to_rgba` | core | src/logo_downloader.py:897 | internal (in `LogoDownloader.convert_all_logos_to_rgba`) | `if self.convert_image_to_rgba(logo_file):` |
|
||||
| `CacheManager.get_sport_live_interval` | core | src/cache/cache_strategy.py:28 | unrelated | `def get_sport_live_interval(self, sport_key: str) -> int:` |
|
||||
| `CacheManager.get_sport_live_interval` | core | src/cache/cache_strategy.py:60 | unrelated | `live_interval = self.get_sport_live_interval(sport_key)` |
|
||||
| `CacheManager.get_sport_live_interval` | core | src/cache_manager.py:785 | unrelated | `return self._strategy_component.get_sport_live_interval(sport_key)` |
|
||||
| `CacheManager.get_sport_key_from_cache_key` | core | src/cache/cache_strategy.py:214 | unrelated | `def get_sport_key_from_cache_key(self, key: str) -> Optional[str]:` |
|
||||
| `CacheManager.get_sport_key_from_cache_key` | core | src/cache_manager.py:806 | unrelated | `return self._strategy_component.get_sport_key_from_cache_key(key)` |
|
||||
| `CacheManager.get_sport_key_from_cache_key` | core | src/cache_manager.py:816 | unrelated | `sport_key = self._strategy_component.get_sport_key_from_cache_key(key)` |
|
||||
| `CacheManager.record_cache_hit` | core | src/cache_manager.py:869 | internal (in `CacheManager.get_background_cached_data`) | `self.record_cache_hit('background')` |
|
||||
| `CacheManager.record_cache_miss` | core | src/cache_manager.py:876 | internal (in `CacheManager.get_background_cached_data`) | `self.record_cache_miss('background')` |
|
||||
| `CacheManager.record_fetch_time` | core | src/cache/cache_metrics.py:67 | unrelated | `def record_fetch_time(self, duration: float) -> None:` |
|
||||
| `CacheManager.record_fetch_time` | core | src/cache_manager.py:922 | unrelated | `self._metrics_component.record_fetch_time(duration)` |
|
||||
| `CacheManager.get_memory_cache_stats` | core tests | test/test_cache_manager_memory_tier.py:43 | test call | `stats = cm.get_memory_cache_stats()` |
|
||||
| `CacheManager.get_memory_cache_stats` | core tests | test/test_cache_manager_memory_tier.py:63 | test call | `assert cm.get_memory_cache_stats()["last_cleanup"] >= before` |
|
||||
| `CacheManager.get_memory_cache_stats` | core tests | test/test_cache_manager_memory_tier.py:68 | test call | `stats = cm.get_memory_cache_stats()` |
|
||||
| `DisplayManager.draw_sun` | core | src/plugin_system/testing/visual_display_manager.py:417 | unrelated | `def draw_sun(self, x: int, y: int, size: int = 16):` |
|
||||
| `DisplayManager.draw_cloud` | core | src/display_manager.py:1359 | internal (in `DisplayManager.draw_rain`) | `self.draw_cloud(x, y, size)` |
|
||||
| `DisplayManager.draw_cloud` | core | src/display_manager.py:1374 | internal (in `DisplayManager.draw_snow`) | `self.draw_cloud(x, y, size)` |
|
||||
| `DisplayManager.draw_cloud` | core | src/plugin_system/testing/visual_display_manager.py:421 | unrelated | `def draw_cloud(self, x: int, y: int, size: int = 16, color: Tuple[int, int, int] = (200, 200, 200)):` |
|
||||
| `DisplayManager.draw_cloud` | ledmatrix-weather | weather_icons.py:184 | unrelated | `def draw_cloud(draw: ImageDraw, x: int, y: int, size: int = 16, color: tuple = (200, 200, 200)):` |
|
||||
| `DisplayManager.draw_rain` | core | src/plugin_system/testing/visual_display_manager.py:425 | unrelated | `def draw_rain(self, x: int, y: int, size: int = 16):` |
|
||||
| `DisplayManager.draw_snow` | core | src/plugin_system/testing/visual_display_manager.py:429 | unrelated | `def draw_snow(self, x: int, y: int, size: int = 16):` |
|
||||
| `DisplayManager.draw_weather_icon` | core | src/display_manager.py:1518 | internal (in `DisplayManager.draw_text_with_icons`) | `self.draw_weather_icon(icon_type, icon_x, icon_y)` |
|
||||
| `DisplayManager.draw_weather_icon` | core | src/plugin_system/testing/visual_display_manager.py:510 | unrelated | `def draw_weather_icon(self, condition: str, x: int, y: int, size: int = 16) -> None:` |
|
||||
| `DisplayManager.draw_weather_icon` | core | src/plugin_system/testing/visual_display_manager.py:533 | unrelated | `self.draw_weather_icon(icon_type, icon_x, icon_y)` |
|
||||
| `DisplayManager.draw_weather_icon` | ledmatrix-weather | manager.py:84 | unrelated | `def draw_weather_icon(image, icon_code, x, y, size):` |
|
||||
| `DisplayManager.draw_weather_icon` | ledmatrix-weather | manager.py:1280 | unrelated | `WeatherIcons.draw_weather_icon(img, icon_code, icon_x, icon_y,` |
|
||||
| `DisplayManager.draw_weather_icon` | ledmatrix-weather | manager.py:1559 | unrelated | `WeatherIcons.draw_weather_icon(img, forecast['icon'], icon_x, icon_y, icon_size)` |
|
||||
| `DisplayManager.draw_weather_icon` | ledmatrix-weather | manager.py:1650 | unrelated | `WeatherIcons.draw_weather_icon(img, forecast['icon'], icon_x, icon_y, icon_size)` |
|
||||
| `DisplayManager.draw_weather_icon` | ledmatrix-weather | weather_icons.py:168 | unrelated | `def draw_weather_icon(image: Image.Image, icon_code: str, x: int, y: int, size: int = DEFAULT_SIZE):` |
|
||||
| `DisplayManager.draw_text_with_icons` | core | src/plugin_system/testing/visual_display_manager.py:526 | unrelated | `def draw_text_with_icons(self, text: str, icons: List[tuple] = None,` |
|
||||
| `FontManager.get_font_catalog` | core tests | test/test_deprecation.py:229 | test call | `assert fm.get_font_catalog() == fm.font_catalog` |
|
||||
| `BasePlugin.get_supported_vegas_modes` | core tests | test/test_vegas_participation.py:356 | test review | `assert plugin.get_supported_vegas_modes() == [` |
|
||||
| `BasePlugin.get_supported_vegas_modes` | core tests | test/test_vegas_participation.py:358 | test review | `assert plugin.get_supported_vegas_modes()` |
|
||||
| `BasePlugin.get_supported_vegas_modes` | blackjack | manager.py:732 | override | `def get_supported_vegas_modes(self):` |
|
||||
| `BasePlugin.get_supported_vegas_modes` | blackjack | manager.py:695 | call | `if mode in self.get_supported_vegas_modes():` |
|
||||
| `BasePlugin.get_supported_vegas_modes` | calendar | manager.py:875 | override | `def get_supported_vegas_modes(self) -> List[VegasDisplayMode]:` |
|
||||
| `BasePlugin.get_supported_vegas_modes` | olympics | manager.py:624 | override | `def get_supported_vegas_modes(self) -> List[VegasDisplayMode]:` |
|
||||
| `BasePlugin.get_vegas_segment_width` | core tests | test/test_vegas_participation.py:359 | test review | `assert plugin.get_vegas_segment_width() == 2` |
|
||||
| `PluginManager.get_all_plugins` | core | src/plugin_system/testing/mocks.py:218 | unrelated | `def get_all_plugins(self) -> Dict[str, Any]:` |
|
||||
| `PluginManager.get_all_plugins` | football-scoreboard | emulator_demo.py:68 | review | `mock_plugin_manager.get_all_plugins = Mock(return_value=[])` |
|
||||
| `PluginManager.get_all_plugins` | football-scoreboard | test_dynamic_duration.py:64 | test review | `mock_plugin_manager.get_all_plugins = Mock(return_value=[])` |
|
||||
| `PluginManager.get_all_plugins` | football-scoreboard | test_football_plugin.py:74 | test review | `mock_plugin_manager.get_all_plugins = Mock(return_value=[])` |
|
||||
| `PluginManager.get_all_plugins` | hockey-scoreboard | test_hockey_emulator.py:99 | test review | `mock_plugin_manager.get_all_plugins = Mock(return_value=[])` |
|
||||
| `PluginManager.get_plugin_info` | core | src/plugin_system/plugin_catalog.py:120 | unrelated | `def get_plugin_info(self, plugin_id: str) -> Optional[Dict[str, Any]]:` |
|
||||
| `PluginManager.get_plugin_info` | core | src/plugin_system/plugin_catalog.py:133 | unrelated | `return [info for info in (self.get_plugin_info(pid) for pid in ids) if info]` |
|
||||
| `PluginManager.get_plugin_info` | core | src/plugin_system/plugin_manager.py:1001 | internal (in `PluginManager.get_all_plugin_info`) | `return [info for info in [self.get_plugin_info(pid) for pid in pids] if info]` |
|
||||
| `PluginManager.get_plugin_info` | core | src/plugin_system/store_install.py:208 | review | `plugin_info = self.get_plugin_info(plugin_id, fetch_latest_from_github=True, force_refresh=True)` |
|
||||
| `PluginManager.get_plugin_info` | core | src/plugin_system/store_registry.py:801 | unrelated | `def get_plugin_info(self, plugin_id: str, fetch_latest_from_github: bool = True, force_refresh: bool = False) -> Optional[Dict]:` |
|
||||
| `PluginManager.get_plugin_info` | core | src/plugin_system/store_update.py:357 | review | `plugin_info_remote = self.get_plugin_info(plugin_id, fetch_latest_from_github=True, force_refresh=True)` |
|
||||
| `PluginManager.get_plugin_info` | core | src/plugin_system/store_update.py:362 | review | `plugin_info_remote = self.get_plugin_info(alt_id, fetch_latest_from_github=True, force_refresh=True)` |
|
||||
| `PluginManager.get_plugin_info` | core | src/plugin_system/store_update.py:685 | review | `plugin_info_remote = self.get_plugin_info(plugin_id, fetch_latest_from_github=True, force_refresh=True)` |
|
||||
| `PluginManager.get_plugin_info` | core | src/plugin_system/store_update.py:691 | review | `plugin_info_remote = self.get_plugin_info(alt_id, fetch_latest_from_github=True, force_refresh=True)` |
|
||||
| `PluginManager.get_plugin_info` | core | src/plugin_system/testing/mocks.py:223 | unrelated | `def get_plugin_info(self, plugin_id: str) -> Optional[Dict[str, Any]]:` |
|
||||
| `PluginManager.get_plugin_info` | core | web_interface/blueprints/api_v3/plugin_store.py:216 | review | `remote_info = api_v3.plugin_store_manager.get_plugin_info(plugin_id, fetch_latest_from_github=True)` |
|
||||
| `PluginManager.get_plugin_info` | core | web_interface/blueprints/api_v3/plugin_store.py:334 | review | `plugin_info = api_v3.plugin_store_manager.get_plugin_info(plugin_id)` |
|
||||
| `PluginManager.get_plugin_info` | core | web_interface/blueprints/api_v3/plugin_store.py:545 | review | `elif not api_v3.plugin_store_manager.get_plugin_info(plugin_id):` |
|
||||
| `PluginManager.get_plugin_info` | core | web_interface/blueprints/api_v3/plugin_store.py:602 | review | `elif not api_v3.plugin_store_manager.get_plugin_info(plugin_id):` |
|
||||
| `PluginManager.get_plugin_info` | core | web_interface/blueprints/pages_v3.py:224 | review | `info = pages_v3.plugin_catalog.get_plugin_info(pid) or {}` |
|
||||
| `PluginManager.get_plugin_info` | core | web_interface/blueprints/pages_v3.py:722 | review | `plugin_info = pages_v3.plugin_catalog.get_plugin_info(plugin_id)` |
|
||||
| `PluginManager.get_plugin_info` | core | web_interface/blueprints/pages_v3.py:727 | review | `plugin_info = pages_v3.plugin_catalog.get_plugin_info(plugin_id)` |
|
||||
| `PluginManager.get_plugin_info` | core tests | test/test_api_v3_plugin_install_endpoints.py:111 | test review | `manager.get_plugin_info.return_value = None` |
|
||||
| `PluginManager.get_plugin_info` | core tests | test/test_api_v3_plugin_install_endpoints.py:119 | test review | `manager.get_plugin_info.return_value = {"id": "clock"}` |
|
||||
| `PluginManager.get_plugin_info` | core tests | test/test_pages_v3_path_guards.py:47 | test call | `plugin_manager.get_plugin_info.return_value = {"name": "Weather", "version": "1.0.0"}` |
|
||||
| `PluginManager.get_plugin_info` | core tests | test/test_registry_id_resolution.py:81 | test review | `_ids(store.get_plugin_info("ledmatrix-weather", fetch_latest_from_github=False))` |
|
||||
| `PluginManager.get_plugin_info` | core tests | test/test_store_manager_caches.py:612 | test review | `info = self.sm.get_plugin_info("foo", fetch_latest_from_github=True, force_refresh=True)` |
|
||||
| `PluginManager.get_plugin_info` | core tests | test/test_store_non_plugin_entries.py:37 | test review | `store.get_plugin_info = MagicMock(return_value=dict(SKIN))` |
|
||||
| `PluginManager.get_plugin_info` | core tests | test/test_web_plugin_dir_resolution.py:86 | test review | `api.plugin_store_manager.get_plugin_info = MagicMock(return_value=None)` |
|
||||
| `PluginManager.get_plugin_info` | core tests | test/test_web_plugin_dir_resolution.py:118 | test review | `api.plugin_store_manager.get_plugin_info = MagicMock(return_value=None)` |
|
||||
| `PluginManager.get_plugin_info` | core tests | test/test_web_plugin_dir_resolution.py:178 | test call | `plugin_manager.get_plugin_info.return_value = {"id": "weather", "name": "Weather"}` |
|
||||
| `PluginManager.get_plugin_info` | core tests | test/web_interface/test_plugin_config_form_defaults.py:119 | test call | `pm.get_plugin_info.return_value = {"name": "Demo", "version": "1.0.0"}` |
|
||||
| `PluginManager.get_plugin_info` | core tests | test/web_interface/test_plugin_config_schema_expansion.py:88 | test call | `pm.get_plugin_info.return_value = {"id": plugin_id, "name": plugin_id}` |
|
||||
| `PluginManager.get_plugin_info` | core tests | test/web_interface/test_plugin_widget_route.py:226 | test call | `pm.get_plugin_info.return_value = {"id": plugin_id, "name": plugin_id}` |
|
||||
| `PluginManager.get_plugin_info` | core tests | test/web_interface/test_plugin_widget_route.py:228 | test call | `pm.get_plugin_info.return_value["version"] = version` |
|
||||
| `PluginManager.get_plugin_info` | core tests | test/web_interface/test_update_all_plugins.py:41 | test review | `sm.get_plugin_info.return_value = None` |
|
||||
| `PluginManager.get_plugin_info` | core tests | test/web_interface/test_web_process_runs_no_plugin_code.py:132 | test review | `store.get_plugin_info.return_value = None` |
|
||||
| `PluginManager.get_all_plugin_info` | core | src/plugin_system/plugin_catalog.py:129 | unrelated | `def get_all_plugin_info(self) -> List[Dict[str, Any]]:` |
|
||||
| `PluginManager.get_all_plugin_info` | core | web_interface/blueprints/api_v3/plugins.py:68 | review | `all_plugin_info = api_v3.plugin_catalog.get_all_plugin_info()` |
|
||||
| `PluginManager.get_all_plugin_info` | core | web_interface/blueprints/pages_v3.py:209 | review | `pi.get('id') for pi in pages_v3.plugin_catalog.get_all_plugin_info()` |
|
||||
| `PluginManager.get_all_plugin_info` | core | web_interface/blueprints/pages_v3.py:559 | review | `infos = sorted(pages_v3.plugin_catalog.get_all_plugin_info(),` |
|
||||
| `PluginManager.get_all_plugin_info` | core tests | test/test_api_v3_installed_display_modes.py:30 | test review | `api.plugin_catalog.get_all_plugin_info = MagicMock(return_value=[info])` |
|
||||
| `PluginManager.get_all_plugin_info` | core tests | test/test_api_v3_installed_plugin_icon.py:24 | test review | `api.plugin_catalog.get_all_plugin_info = MagicMock(return_value=[info])` |
|
||||
| `PluginManager.get_all_plugin_info` | core tests | test/test_installed_list_registry_offline.py:71 | test review | `api.plugin_catalog.get_all_plugin_info = MagicMock(return_value=[` |
|
||||
| `PluginManager.get_all_plugin_info` | core tests | test/test_onboarding_checklist.py:68 | test review | `mock_pm.get_all_plugin_info.return_value = []` |
|
||||
| `PluginManager.get_all_plugin_info` | core tests | test/test_plugin_manager_load_failures.py:78 | test call | `infos = {i["id"]: i for i in pm.get_all_plugin_info()}` |
|
||||
| `PluginManager.get_all_plugin_info` | core tests | test/test_plugin_runtime_snapshot.py:422 | test review | `api.plugin_catalog.get_all_plugin_info = MagicMock(return_value=[` |
|
||||
| `PluginManager.get_all_plugin_info` | core tests | test/test_vegas_participation.py:447 | test review | `api.plugin_catalog.get_all_plugin_info = MagicMock(return_value=[info])` |
|
||||
| `PluginManager.get_all_plugin_info` | core tests | test/test_web_api.py:551 | test review | `mock_plugin_catalog.get_all_plugin_info.return_value = [` |
|
||||
| `PluginManager.get_all_plugin_info` | core tests | test/test_web_api.py:570 | test review | `mock_plugin_catalog.get_all_plugin_info.return_value = [` |
|
||||
| `PluginManager.get_all_plugin_info` | core tests | test/test_web_api.py:592 | test review | `mock_plugin_catalog.get_all_plugin_info.return_value = [` |
|
||||
| `PluginManager.get_all_plugin_info` | core tests | test/test_web_plugin_dir_resolution.py:61 | test review | `api.plugin_catalog.get_all_plugin_info = MagicMock(return_value=[info])` |
|
||||
| `PluginManager.get_all_plugin_info` | core tests | test/test_web_plugin_dir_resolution.py:195 | test call | `plugin_manager.get_all_plugin_info.assert_not_called()` |
|
||||
| `PluginManager.get_all_plugin_info` | core tests | test/test_web_smoke.py:95 | test review | `mock_pm.get_all_plugin_info.return_value = [` |
|
||||
| `PluginManager.get_plugin_display_modes` | core | src/plugin_system/plugin_catalog.py:175 | unrelated | `def get_plugin_display_modes(self, plugin_id: str) -> List[str]:` |
|
||||
| `PluginManager.get_plugin_display_modes` | core | web_interface/blueprints/api_v3/display.py:197 | review | `plugin_modes = api_v3.plugin_catalog.get_plugin_display_modes(plugin_id) or [plugin_id]` |
|
||||
| `PluginManager.get_plugin_display_modes` | core | web_interface/blueprints/api_v3/display.py:267 | review | `modes = api_v3.plugin_catalog.get_plugin_display_modes(resolved_plugin)` |
|
||||
| `PluginManager.get_plugin_display_modes` | core | web_interface/blueprints/api_v3/plugins.py:157 | review | `declared_modes = api_v3.plugin_catalog.get_plugin_display_modes(plugin_id)` |
|
||||
| `PluginManager.get_plugin_display_modes` | core | web_interface/blueprints/pages_v3.py:565 | review | `modes = pages_v3.plugin_catalog.get_plugin_display_modes(pid) or [pid]` |
|
||||
| `PluginManager.get_plugin_display_modes` | core tests | test/test_api_v3_display_modes.py:40 | test call | `pm.get_plugin_display_modes = MagicMock(` |
|
||||
| `PluginManager.get_plugin_display_modes` | core tests | test/test_api_v3_display_modes.py:100 | test call | `pm.get_plugin_display_modes = MagicMock(return_value=[])` |
|
||||
| `PluginManager.get_plugin_display_modes` | core tests | test/test_api_v3_display_modes.py:122 | test call | `pm.get_plugin_display_modes = MagicMock(` |
|
||||
| `PluginManager.get_plugin_display_modes` | core tests | test/test_api_v3_installed_display_modes.py:31 | test review | `api.plugin_catalog.get_plugin_display_modes = MagicMock(return_value=declared_modes)` |
|
||||
| `PluginManager.get_plugin_display_modes` | core tests | test/test_api_v3_installed_display_modes.py:39 | test review | `api.plugin_catalog.get_plugin_display_modes.assert_any_call('football-scoreboard')` |
|
||||
| `PluginManager.get_plugin_display_modes` | core tests | test/test_installed_list_registry_offline.py:75 | test review | `api.plugin_catalog.get_plugin_display_modes = MagicMock(return_value=[])` |
|
||||
| `PluginManager.get_plugin_display_modes` | core tests | test/test_onboarding_checklist.py:69 | test review | `mock_pm.get_plugin_display_modes.side_effect = lambda pid: []` |
|
||||
| `PluginManager.get_plugin_display_modes` | core tests | test/test_web_smoke.py:99 | test review | `mock_pm.get_plugin_display_modes.side_effect = (` |
|
||||
| `PluginManager.find_plugin_for_mode` | core | src/plugin_system/plugin_catalog.py:186 | unrelated | `def find_plugin_for_mode(self, mode: str) -> Optional[str]:` |
|
||||
| `PluginManager.find_plugin_for_mode` | core | web_interface/blueprints/api_v3/display.py:271 | review | `resolved_plugin = api_v3.plugin_catalog.find_plugin_for_mode(resolved_mode)` |
|
||||
| `PluginManager.find_plugin_for_mode` | core | web_interface/blueprints/api_v3/display.py:276 | review | `resolved_plugin = api_v3.plugin_catalog.find_plugin_for_mode(resolved_mode)` |
|
||||
| `PluginStateManager.is_loaded` | core | src/plugin_system/plugin_state.py:299 | internal (in `PluginStateManager.get_state_info`) | `'is_loaded': self.is_loaded(plugin_id),` |
|
||||
| `PluginStateManager.is_running` | core | src/plugin_system/plugin_state.py:301 | internal (in `PluginStateManager.get_state_info`) | `'is_running': self.is_running(plugin_id),` |
|
||||
| `PluginStateManager.is_error` | core | src/plugin_system/plugin_state.py:302 | internal (in `PluginStateManager.get_state_info`) | `'is_error': self.is_error(plugin_id),` |
|
||||
| `PluginStateManager.get_error_info` | core | src/plugin_system/plugin_state.py:305 | internal (in `PluginStateManager.get_state_info`) | `'error_info': self.get_error_info(plugin_id),` |
|
||||
| `PluginStateManager.get_error_info` | core tests | test/test_async_plugin_updates.py:236 | test call | `error = pm.state_manager.get_error_info(plugin_id)` |
|
||||
| `PluginStateManager.get_error_info` | core tests | test/test_plugin_hang_containment.py:191 | test call | `error_info = pm.state_manager.get_error_info('hung')` |
|
||||
| `PluginStateManager.get_error_info` | core tests | test/test_sports_sunset_matrix.py:212 | test call | `f"{manager.state_manager.get_error_info(plugin_id)}"` |
|
||||
| `PluginStateManager.get_error_info` | core tests | test/test_sports_sunset_matrix.py:285 | test call | `info = manager.state_manager.get_error_info(plugin_id)` |
|
||||
| `PluginStateManager.get_last_update` | core | src/plugin_system/plugin_state.py:304 | internal (in `PluginStateManager.get_state_info`) | `'last_update': self.get_last_update(plugin_id),` |
|
||||
| `PluginStateManager.get_state_info` | core | src/plugin_system/plugin_manager.py:987 | internal (in `PluginManager.get_plugin_info`) | `info['state'] = self.state_manager.get_state_info(plugin_id)` |
|
||||
| `PluginStateManager.get_state_info` | core tests | test/test_plugin_state_transition_count.py:39 | test review | `info = manager.get_state_info("clock")` |
|
||||
| `PluginStateManager.get_state_info` | core tests | test/test_plugin_state_transition_count.py:51 | test review | `info = manager.get_state_info("clock")` |
|
||||
| `PluginStateManager.get_state_info` | core tests | test/test_plugin_state_transition_count.py:62 | test review | `assert manager.get_state_info("clock")["state_history_count"] == 101` |
|
||||
| `PluginStateManager.get_state_info` | core tests | test/test_plugin_state_transition_count.py:63 | test review | `assert manager.get_state_info("weather")["state_history_count"] == 1` |
|
||||
| `PluginStateManager.get_state_info` | core tests | test/test_plugin_state_transition_count.py:74 | test review | `info = manager.get_state_info("clock")` |
|
||||
| `PluginStateManager.get_state_info` | core tests | test/test_plugin_state_transition_count.py:95 | test review | `info = m.get_state_info("clock")` |
|
||||
| `PluginStateManager.get_state_info` | core tests | test/test_plugin_state_transition_count.py:130 | test review | `info = manager.get_state_info("clock")` |
|
||||
|
||||
## Sources scanned
|
||||
|
||||
| Source | Group | Python files | Hits |
|
||||
|---|---|---|---|
|
||||
| core | core | 188 | 50 |
|
||||
| core tests | core-tests | 416 | 176 |
|
||||
| core | core | 172 | 20 |
|
||||
| core tests | core-tests | 347 | 17 |
|
||||
| 7-segment-clock | monorepo | 3 | 0 |
|
||||
| afl-scoreboard | monorepo | 36 | 0 |
|
||||
| baseball-scoreboard | monorepo | 71 | 0 |
|
||||
| basketball-scoreboard | monorepo | 51 | 2 |
|
||||
| birdnet-go | monorepo | 3 | 0 |
|
||||
| blackjack | monorepo | 7 | 0 |
|
||||
| calendar | monorepo | 5 | 0 |
|
||||
| afl-scoreboard | monorepo | 35 | 0 |
|
||||
| baseball-scoreboard | monorepo | 61 | 0 |
|
||||
| basketball-scoreboard | monorepo | 49 | 0 |
|
||||
| birdnet-go | monorepo | 2 | 0 |
|
||||
| blackjack | monorepo | 7 | 2 |
|
||||
| calendar | monorepo | 5 | 1 |
|
||||
| christmas-countdown | monorepo | 3 | 0 |
|
||||
| clock-simple | monorepo | 2 | 0 |
|
||||
| countdown | monorepo | 5 | 0 |
|
||||
| cricket-scoreboard | monorepo | 8 | 1 |
|
||||
| cricket-scoreboard | monorepo | 8 | 0 |
|
||||
| f1-scoreboard | monorepo | 15 | 0 |
|
||||
| fantasy-blitz | monorepo | 13 | 0 |
|
||||
| football-scoreboard | monorepo | 78 | 4 |
|
||||
| football-scoreboard | monorepo | 74 | 0 |
|
||||
| geochron | monorepo | 10 | 0 |
|
||||
| hello-world | monorepo | 2 | 0 |
|
||||
| hockey-scoreboard | monorepo | 57 | 3 |
|
||||
| hockey-scoreboard | monorepo | 52 | 0 |
|
||||
| incoming-packages | monorepo | 8 | 0 |
|
||||
| jellyfin-now-playing | monorepo | 4 | 0 |
|
||||
| lacrosse-scoreboard | monorepo | 41 | 0 |
|
||||
| lacrosse-scoreboard | monorepo | 40 | 0 |
|
||||
| ledmatrix-elections | monorepo | 12 | 0 |
|
||||
| ledmatrix-flights | monorepo | 48 | 0 |
|
||||
| ledmatrix-leaderboard | monorepo | 10 | 0 |
|
||||
| ledmatrix-music | monorepo | 12 | 0 |
|
||||
| ledmatrix-leaderboard | monorepo | 9 | 0 |
|
||||
| ledmatrix-music | monorepo | 11 | 0 |
|
||||
| ledmatrix-stocks | monorepo | 7 | 0 |
|
||||
| ledmatrix-weather | monorepo | 15 | 0 |
|
||||
| ledmatrix-weather | monorepo | 15 | 6 |
|
||||
| march-madness | monorepo | 4 | 0 |
|
||||
| masters-tournament | monorepo | 10 | 0 |
|
||||
| mqtt-notifications | monorepo | 4 | 0 |
|
||||
| news | monorepo | 7 | 0 |
|
||||
| news | monorepo | 6 | 0 |
|
||||
| nfl-draft | monorepo | 3 | 0 |
|
||||
| nfl-stat-leaders | monorepo | 8 | 0 |
|
||||
| nrl-scoreboard | monorepo | 31 | 1 |
|
||||
| odds-ticker | monorepo | 10 | 0 |
|
||||
| nrl-scoreboard | monorepo | 30 | 0 |
|
||||
| odds-ticker | monorepo | 9 | 0 |
|
||||
| of-the-day | monorepo | 14 | 0 |
|
||||
| olympics | monorepo | 16 | 0 |
|
||||
| on-air | monorepo | 3 | 0 |
|
||||
| olympics | monorepo | 16 | 1 |
|
||||
| on-air | monorepo | 2 | 0 |
|
||||
| pomodoro-timer | monorepo | 3 | 0 |
|
||||
| soccer-scoreboard | monorepo | 50 | 0 |
|
||||
| static-image | monorepo | 4 | 0 |
|
||||
| stock-news | monorepo | 4 | 0 |
|
||||
| soccer-scoreboard | monorepo | 47 | 0 |
|
||||
| static-image | monorepo | 3 | 0 |
|
||||
| stock-news | monorepo | 3 | 0 |
|
||||
| text-display | monorepo | 4 | 0 |
|
||||
| tide-display | monorepo | 3 | 0 |
|
||||
| ufc-scoreboard | monorepo | 40 | 3 |
|
||||
| ufc-scoreboard | monorepo | 38 | 0 |
|
||||
| web-ui-info | monorepo | 2 | 0 |
|
||||
| youtube-stats | monorepo | 5 | 0 |
|
||||
| f1-live | third-party | 10 | 0 |
|
||||
|
||||
+119
-90
@@ -1,18 +1,18 @@
|
||||
# Control socket (web → display)
|
||||
|
||||
The display process serves a Unix socket that the web interface uses to send
|
||||
it commands and get an answer back. It replaces the cache-file "mailboxes" on
|
||||
it commands and get an answer back. It replaced the cache-file "mailboxes" on
|
||||
the SD card one command at a time. Stage 1 carries on-demand start, stop and
|
||||
status. Stage 2 makes those commands land within a frame on every kind of
|
||||
screen, and adds `brightness.set` and `plugin.reload`. Stage 3 adds a state
|
||||
stream (`state.get`, `state.subscribe`), so the web interface reads what the
|
||||
display is doing from the socket instead of from cache files the display
|
||||
wrote to the SD card. Stage 4 makes the socket the only way a command goes
|
||||
while it works: the web interface writes a mailbox only when the socket
|
||||
cannot carry the request, `errors.clear` replaces the last command that
|
||||
always went through a mailbox, and the display looks at the mailboxes once
|
||||
a second, with a `stat()`. The file mailboxes and the cache keys stay as a
|
||||
fallback for one release.
|
||||
display wrote to the SD card. Stage 4 makes the socket the only way a command goes
|
||||
while it works, and `errors.clear` replaces the last command that always
|
||||
went through a mailbox. Stage 5 removes the mailboxes: the web interface no
|
||||
longer writes them and the display no longer reads them, so the socket is
|
||||
the only way a command reaches the display (see "Without the socket"). The
|
||||
cache keys the display writes for readers stay as their fallback.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
@@ -145,14 +145,17 @@ than the display's wait, so `pending` arrives before the client gives up.
|
||||
`v` the display does not speak gets `unsupported_version`. `hello` is checked
|
||||
by its `versions` list instead, and its result names the highest version both
|
||||
sides share, so a client can find out what a display supports before it
|
||||
relies on anything newer. The client sends `v: 1` and falls back to the
|
||||
mailbox when the display refuses it. It does not send `hello` first, which
|
||||
relies on anything newer. The client sends `v: 1`, and a route answers an
|
||||
error when the display refuses it. It does not send `hello` first, which
|
||||
saves a round trip.
|
||||
|
||||
New commands are added within a version, so stage 2 is still version 1. A
|
||||
display that does not know a command answers `unknown_command`, which the
|
||||
web interface treats like any other socket failure and falls back from, and
|
||||
`hello` lists the commands a display knows. The version changes only when the
|
||||
`hello` lists the commands a display knows.
|
||||
(Since stage 5, a command the display does not know is an error the route
|
||||
reports, telling the user to restart the display; there is no mailbox left
|
||||
to fall back to.) The version changes only when the
|
||||
envelope or the meaning of an existing command changes.
|
||||
|
||||
**Events.** `state.subscribe` is the one command with more than one message
|
||||
@@ -217,7 +220,8 @@ socket.
|
||||
}}
|
||||
```
|
||||
|
||||
- `display` and `on_demand` are the dicts the cache keys hold, `plugins` is
|
||||
- `display` and `on_demand` are the dicts the cache keys hold (`on_demand`
|
||||
includes `request_id`, the request it answers), `plugins` is
|
||||
the runtime snapshot (`build_runtime_snapshot`), and `brightness` is the
|
||||
configured level, what the panel shows now, and whether the dim schedule
|
||||
has it dimmed. A section not published yet is `null`.
|
||||
@@ -368,13 +372,12 @@ request, validates it against the contract, and then does one of two things:
|
||||
- For a query, it answers from a status snapshot the display provides
|
||||
(`DisplayController._control_status`). The snapshot only reads attributes.
|
||||
|
||||
The render thread drains the queue in `_poll_on_demand_requests()`, the same
|
||||
place it reads the mailbox:
|
||||
The render thread drains the queue in `_poll_on_demand_requests()`:
|
||||
|
||||
- An on-demand command goes to `_handle_on_demand_request()`, which is the
|
||||
mailbox's own handler. The two paths share all of their code: activation,
|
||||
the processed-id guard, error publishing, and resuming the rotation
|
||||
afterwards.
|
||||
- An on-demand command goes to `_handle_on_demand_request()`, which also
|
||||
handles plugins' own requests. The two paths share all of their code:
|
||||
activation, the request-id guard, error publishing, and resuming the
|
||||
rotation afterwards.
|
||||
- `brightness.set` is applied there and then (`_apply_control_brightness`),
|
||||
and the current frame is pushed again so the panel shows it.
|
||||
- `plugin.reload` starts at the top of the next loop pass, the place where
|
||||
@@ -407,14 +410,13 @@ place it reads the mailbox:
|
||||
unloads it mid-load; a disable saved meanwhile is applied once the
|
||||
reload is done. A second reload of the same plugin runs after the first.
|
||||
|
||||
The floor on the mailbox read (0.25 s, 1 s since stage 4) does not apply to the queue, because
|
||||
draining it costs no disk read. A queued command also lets
|
||||
`_service_pending_changes()` skip its own floor.
|
||||
Draining the queue costs no disk read, so it has no floor. A queued command
|
||||
also lets `_service_pending_changes()` skip its own 0.25 s floor.
|
||||
|
||||
### Waking the render thread (stage 2)
|
||||
|
||||
Stage 1 made the socket answer, but not land sooner: a queued command waited
|
||||
for the same polls the mailbox does. Measured on ledpi (Pi 4, 24 fps Vegas),
|
||||
for the same polls the mailbox did. Measured on ledpi (Pi 4, 24 fps Vegas),
|
||||
a start took 1.02 s on a static screen and about 0.4 s in Vegas either way.
|
||||
Now the queue wakes the render thread:
|
||||
|
||||
@@ -434,8 +436,7 @@ Now the queue wakes the render thread:
|
||||
- **Scrolling screens** already service pending changes every frame.
|
||||
|
||||
So a command lands within a millisecond or so on a static screen and in a
|
||||
dwell, and within one frame in Vegas and on a scrolling screen. The mailbox
|
||||
is slower on purpose (see "The mailboxes now"). Commands still run only on the render thread: the
|
||||
dwell, and within one frame in Vegas and on a scrolling screen. Commands still run only on the render thread: the
|
||||
connection threads only queue them and set the event. The one exception is
|
||||
the slow half of `plugin.reload` (tearing down and loading the plugin),
|
||||
which runs on its own thread. Every change to the display's state still
|
||||
@@ -453,74 +454,101 @@ bookkeeping. A client's send to the render thread waking took 0.72 ms median
|
||||
Without a socket (Windows, `LEDMATRIX_CONTROL_SOCKET=off`) the waits are the
|
||||
plain sleeps they were.
|
||||
|
||||
**Exactly once.** Since stage 4 the web interface writes the mailbox only
|
||||
when the display never had the request (see "When the web interface falls
|
||||
back"), so a request goes one way or the other, never both. A command and a
|
||||
mailbox write for the same request still share one `request_id`, and the
|
||||
`on_demand_request_id` and processed-id checks still drop a second copy: an
|
||||
older web interface (before stage 4) wrote the mailbox after a reply timed
|
||||
out, too. The display takes such a copy out of the mailbox when it drops it.
|
||||
**Exactly once.** A request goes over the socket once. The web route sends
|
||||
it again only while no display is listening (see "Without the socket"), so
|
||||
a display gets it at most once; a start with a request id the display has
|
||||
just processed is dropped anyway (`on_demand_request_id`). The persisted
|
||||
`display_on_demand_processed_id` guard against a mailbox replayed after a
|
||||
restart went with the mailbox.
|
||||
|
||||
## When the web interface falls back (stage 4)
|
||||
## Without the socket (stage 5)
|
||||
|
||||
The client tells a request the display never had from one it had and then
|
||||
failed. `ControlError.sent` is True once the whole request was written to a
|
||||
connected display; a refusal the display sends before reading anything
|
||||
(`forbidden`, too many connections) carries no request id, and leaves it
|
||||
False. `src.ipc.client.should_fall_back()` is the one rule every route uses:
|
||||
False. `src.ipc.client.display_not_listening()` picks out the one case a
|
||||
later retry can fix: nothing is listening (`no_socket`, `refused`) and the
|
||||
request was never sent.
|
||||
|
||||
| What happened | Example reasons | Mailbox? | The route answers |
|
||||
|---|---|---|---|
|
||||
| The display never had it | `no_socket`, `refused`, `disabled`, `unsupported`, a connect or send that timed out, `forbidden` / `busy` at the door, `invalid_request` (refused by the client itself) | yes | success, `transport: "mailbox"`, `socket_error` |
|
||||
| A display too old to know it (the upgrade case) | `unknown_command`, `unsupported_version` | yes | as above |
|
||||
| The display had it and failed | `busy` (queue full), `invalid_args`, `internal`, a timeout or hang-up after the send, `bad_response` | no | `503` (`400` for `invalid_args`), `socket_error` |
|
||||
| What happened | Example reasons | On-demand start | On-demand stop | `errors.clear` |
|
||||
|---|---|---|---|---|
|
||||
| No display listening | `no_socket`, `refused` | service stopped: `400` without `start_service`; with it, start the service and answer `202` (`status: "starting"`) at once; the dispatcher sends the request until the display takes it (up to 45 s), else `start-timeout`. Service running (still starting): the same `202`, sent for up to 10 s | `503` ("not running" / "may still be starting"); with `stop_service` the service is stopped and the route succeeds | `503` ("not running"; its errors are the last run's, and the next run starts with none) |
|
||||
| A display too old to know the command | `unknown_command`, `unsupported_version` | `503` | `503` | `503`, "restart it" |
|
||||
| No socket in this process | `disabled`, `unsupported` (Windows, `LEDMATRIX_CONTROL_SOCKET=off`) | `503` | `503` | `503` |
|
||||
| The display had it and failed, turned it away, or never answered | `busy`, `invalid_args`, `internal`, a timeout, a hang-up, `bad_response`, `forbidden` | `503` (`400` for `invalid_args`) | `503` (with `stop_service`: stopped anyway) | `503` |
|
||||
|
||||
A display that had the request may have applied it (a reply that timed out),
|
||||
or would refuse the mailbox copy as well (bad arguments), or is stuck and
|
||||
would not read the mailbox either (a full queue). Writing the copy anyway
|
||||
only turned that into a "success". An on-demand stop with `stop_service`
|
||||
still stops the service, which ends on-demand whatever happened.
|
||||
Every error answer carries `socket_error` (a reason code, or `other`).
|
||||
Nothing is written to the cache in any of these cases.
|
||||
|
||||
**Waiting for a display that is starting.** The socket comes up when the
|
||||
display's run loop starts, after every plugin has loaded, which can take
|
||||
longer than a client waits (the MQTT bridge gives up after 15 s). So the
|
||||
start route never waits: it answers `202` with `status: "starting"`, and
|
||||
hands the request to the web process's one dispatcher
|
||||
([`web_interface/on_demand_dispatch.py`](../web_interface/on_demand_dispatch.py)).
|
||||
Its worker thread sends the request every 0.5 s while nothing is listening,
|
||||
until the display acknowledges it or the wait runs out (45 s after a cold
|
||||
start, `START_WAIT_SECONDS`; 10 s for a service that was already running,
|
||||
`ON_DEMAND_SOCKET_WAIT_RUNNING_SECONDS`). Any other failure ends it at once.
|
||||
One start is pending at a time: a newer start replaces it, and a stop
|
||||
cancels it (the stop then succeeds even with no display listening, and
|
||||
reports `cancelled_request_id`).
|
||||
|
||||
The outcome is reported where clients already look:
|
||||
`GET /display/on-demand/status` answers the pending start's state
|
||||
(`source: "web"`, `status: "starting"`, or `status: "error"` with `error:
|
||||
"start-timeout"` or the socket's reason) until the display publishes
|
||||
something newer, and `GET /display/current-status` adds it as
|
||||
`on_demand_pending`.
|
||||
|
||||
A delivered start keeps reading as `status: "starting"`, now with
|
||||
`delivered: true`, until the display publishes the state that answers it.
|
||||
The display acknowledges a start as soon as its socket opens, but its run
|
||||
loop acts on it only after the first screen is built (about 5 s on ledpi,
|
||||
while Vegas renders its first strip), and meanwhile it publishes its own
|
||||
idle state. The display's on-demand state names the request it answers
|
||||
(`request_id`), so "answers it" means the id matches. A display older than
|
||||
that field answers with any state published after the delivery. Either
|
||||
way the delivered start is reported for at most 30 s
|
||||
(`DELIVERED_SHOWN_SECONDS`).
|
||||
|
||||
Brightness and plugin reload never had a mailbox: without the socket, the
|
||||
config watcher applies the saved brightness and a reload becomes the
|
||||
restart banner, as before.
|
||||
|
||||
### The mailboxes now
|
||||
### The mailboxes are gone
|
||||
|
||||
| Mailbox | Written by | Read by the display | While the socket is up |
|
||||
|---|---|---|---|
|
||||
| `display_on_demand_request` | the web interface, only on fallback; plugins that predate `BasePlugin.request_on_demand()`, or run on a core without it | the render thread, `_poll_on_demand_requests()` | looked at every 1 s (`MAILBOX_POLL_INTERVAL_WITH_SOCKET`), 0.25 s without a socket |
|
||||
| `plugin_error_clear_request` | the web interface, only on fallback | the error publisher's thread, every 5 s tick | unchanged rate |
|
||||
| Former mailbox | Last written by | Now |
|
||||
|---|---|---|
|
||||
| `display_on_demand_request` | the web interface on fallback (stage 4); plugins that predate `BasePlugin.request_on_demand()` | not read. A write is dropped by `CacheManager.save_cache` (`RETIRED_MAILBOX_KEYS`) and logged once per writer as a warning that names the plugin when it can be told (the plugin instance on the call stack, else the `plugin_id` in the request) |
|
||||
| `plugin_error_clear_request` | the web interface on fallback (stage 4) | not read; a write is dropped and logged the same way |
|
||||
|
||||
A look is one `stat()` of the mailbox file (`CacheManager.file_signature`):
|
||||
`(inode, mtime, size)`, and every write renames a new file into place, so a
|
||||
new write always looks different. `MailboxWatch` reads the file only when
|
||||
that changed since the last look, so a mailbox that holds nothing new, or
|
||||
nothing at all, costs no open and no parse. A socket command never reads or
|
||||
deletes the on-demand mailbox. A start already processed is taken out of
|
||||
the mailbox instead of being re-read until it expires.
|
||||
A file left on the SD card by an older version is never read again, so it
|
||||
is harmless. `MailboxWatch` and
|
||||
`CacheManager.file_signature`, which made a look at a mailbox one `stat()`,
|
||||
went with them.
|
||||
|
||||
A request that comes through the on-demand mailbox while the socket is up
|
||||
is logged once per writer (`came through the file mailbox although the
|
||||
control socket is up`), which names the plugins that still write it.
|
||||
The four plugins that wrote `display_on_demand_request` (birdnet-go,
|
||||
mqtt-notifications, on-air, pomodoro-timer) use `request_on_demand()` on a
|
||||
core that has it, and fall back to the mailbox only when that method is
|
||||
missing or answers `None` (no display in the process, or a full queue).
|
||||
On a stage-5 core such a fallback write is dropped with the warning above.
|
||||
|
||||
### Plugins in the display process
|
||||
|
||||
A plugin asks for the screen with `BasePlugin.request_on_demand()` and gives
|
||||
it back with `end_on_demand()` (see "On-demand display" in
|
||||
[PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md)). Neither goes through
|
||||
the socket or a file: `PluginManager` hands the mailbox-shaped request,
|
||||
the socket or a file: `PluginManager` hands the on-demand request,
|
||||
marked `source: 'plugin'`, to `DisplayController.submit_plugin_on_demand`,
|
||||
which queues it in memory (at most `PLUGIN_ON_DEMAND_QUEUE_SIZE`, 32) from
|
||||
whatever thread the plugin called on, and wakes the render thread through
|
||||
the socket's queue flag (`ControlServer.wake()`). The render thread applies
|
||||
it in `_drain_control_commands`, after the socket's commands, through the
|
||||
same `_handle_on_demand_request`, so it lands within a frame like a socket
|
||||
command. Without a socket it lands on the next pending-changes pass (typically
|
||||
within 0.25 s). A plugin's stop ends only a session that plugin owns. The four
|
||||
plugins that wrote the mailbox (birdnet-go, mqtt-notifications, on-air,
|
||||
pomodoro-timer) use it where the core has it and write the mailbox
|
||||
otherwise.
|
||||
command. Without a socket it lands on the next pending-changes pass. A
|
||||
plugin's stop ends only a session that plugin owns.
|
||||
|
||||
## Robustness
|
||||
|
||||
@@ -538,8 +566,7 @@ block the render loop or crash it:
|
||||
found. A client that disconnects mid-message is dropped silently. No
|
||||
exception from a handler leaves the connection thread.
|
||||
- **Full queue.** When the queue is full, the client gets `busy`, and the web
|
||||
interface answers `503` rather than write the mailbox, which the stuck
|
||||
render thread would not read either. A full queue means the render thread
|
||||
interface answers `503`. A full queue means the render thread
|
||||
is stuck, and the systemd watchdog deals with that.
|
||||
- **Awaited commands.** The wait for an awaited command's outcome happens on
|
||||
its connection thread and is bounded (`AWAIT_SECONDS`), so a stuck render
|
||||
@@ -554,8 +581,8 @@ block the render loop or crash it:
|
||||
process created.
|
||||
- **Never fatal.** If the server cannot start (Windows, no `AF_UNIX`, a bind
|
||||
failure, `LEDMATRIX_CONTROL_SOCKET=off`), it logs that and the display runs
|
||||
as before. The web interface then uses the mailbox, and reads the cache
|
||||
keys and the heartbeat file.
|
||||
as before. The web interface then cannot send it commands (the routes
|
||||
answer `503`), and reads the cache keys and the heartbeat file.
|
||||
- **Subscribers (stage 3).** A `state.subscribe` connection gives its request
|
||||
slot back and takes one of 4 subscriber slots (`MAX_SUBSCRIBERS`). A fifth
|
||||
gets `busy`. So a few browsers' web processes holding streams can never
|
||||
@@ -613,11 +640,9 @@ device never touches the live display.
|
||||
## Stage plan
|
||||
|
||||
1. **On-demand, with acks (done, #706).** Contract, server, client.
|
||||
`on_demand.start`/`stop`/`status`, `hello`, `ping`. The REST routes try the
|
||||
socket first and report `transport: "socket" | "mailbox"` (plus
|
||||
`socket_error` on fallback). The mailbox is unchanged, and the plugins that
|
||||
write it directly (birdnet-go, mqtt-notifications, on-air, pomodoro-timer)
|
||||
keep working.
|
||||
`on_demand.start`/`stop`/`status`, `hello`, `ping`. The REST routes tried
|
||||
the socket first and reported `transport: "socket" | "mailbox"` (plus
|
||||
`socket_error` on fallback).
|
||||
2. **Commands that were restarts or polls (done).**
|
||||
- The render thread waits on the queue instead of sleeping, and Vegas
|
||||
checks it every frame, so a command lands within a frame on every kind
|
||||
@@ -660,12 +685,12 @@ device never touches the live display.
|
||||
4. **The mailboxes become a fallback (done).**
|
||||
- The web interface writes a mailbox only when the socket could not carry
|
||||
the request (`should_fall_back`); a display that had it and failed is
|
||||
answered as that (see "When the web interface falls back").
|
||||
answered as that.
|
||||
- `errors.clear` replaces `plugin_error_clear_request` as the way a clear
|
||||
reaches the display.
|
||||
- The display looks at the on-demand mailbox once a second while the
|
||||
socket is up, reads either mailbox only when its file changed, and logs
|
||||
who still writes the on-demand one (see "The mailboxes now").
|
||||
who still writes the on-demand one.
|
||||
- Not changed, deliberately: config saves (the schedule, the dim
|
||||
schedule, plugin settings) still reach the display through
|
||||
`config.json` and its watcher, which is the setting itself rather than
|
||||
@@ -674,15 +699,20 @@ device never touches the live display.
|
||||
already stats at most once a second. Plugin health and metrics resets
|
||||
write the persisted record the display publishes and do not reach the
|
||||
running display (their routes say so); they are not mailboxes.
|
||||
5. **Remove the mailboxes (next release).** Once every device has run a
|
||||
display with stage 4, the web interface stops writing both mailboxes and
|
||||
the display stops reading them. The four plugins that wrote
|
||||
`display_on_demand_request` now have an in-process way to ask for the
|
||||
screen (`BasePlugin.request_on_demand()` / `end_on_demand()`, see
|
||||
"Plugins in the display process"); they keep the mailbox write only as
|
||||
their fallback on older cores. The display also stops writing `display_current_state`,
|
||||
`display_on_demand_state` and `plugin_runtime_snapshot` once the web
|
||||
interface no longer falls back to them.
|
||||
5. **Remove the mailboxes (done, the release after 3.8.1).** The web
|
||||
interface no longer writes `display_on_demand_request` or
|
||||
`plugin_error_clear_request`, and the display no longer reads them (see
|
||||
"Without the socket"). When no display is listening, the start route
|
||||
starts the service if asked, answers `202`, and the web process's
|
||||
dispatcher sends the request once the socket is up; every other failure
|
||||
is an error the route reports. A write to
|
||||
either key is dropped with a one-time warning naming the writer. The
|
||||
display still writes `display_current_state`, `display_on_demand_state`
|
||||
and `plugin_runtime_snapshot`: the web interface reads them whenever the
|
||||
socket cannot answer (a stopped or starting display, a web user not yet
|
||||
in the socket's group, a platform without Unix sockets), and
|
||||
`display_on_demand_config` is the display's own record for resuming a
|
||||
session after a restart. Retiring those keys is left for later.
|
||||
|
||||
## Checking it on a device
|
||||
|
||||
@@ -694,12 +724,11 @@ curl -s -X POST localhost:5000/api/v3/display/on-demand/start \
|
||||
# ... "transport": "socket"
|
||||
```
|
||||
|
||||
If the response says `"transport": "mailbox"`, `socket_error` gives the
|
||||
reason. `no_socket` means the display is stopped or predates the socket.
|
||||
`refused` usually means the web user is not in the socket's group, which
|
||||
takes effect when the web service restarts after the user is added. A `503`
|
||||
with `"transport": "socket"` means the display had the request and did not
|
||||
take it (`busy`, `timeout`, ...): nothing was written to the mailbox.
|
||||
On an error, `socket_error` gives the reason. `no_socket` means the display
|
||||
is stopped or still starting. `refused` usually means the web user is not in
|
||||
the socket's group, which takes effect when the web service restarts after
|
||||
the user is added. `busy`, `timeout` and the like mean the display had the
|
||||
request and did not take it. Nothing is ever written to a mailbox.
|
||||
|
||||
An error clear:
|
||||
|
||||
@@ -707,7 +736,7 @@ An error clear:
|
||||
curl -s -X POST localhost:5000/api/v3/errors/clear \
|
||||
-H 'Content-Type: application/json' -d '{"all":true}'
|
||||
# ... "applied": true, "transport": "socket"
|
||||
sudo journalctl -u ledmatrix | grep -E "Cleared .* plugin error|file mailbox"
|
||||
sudo journalctl -u ledmatrix | grep -E "Cleared .* plugin error|retired"
|
||||
```
|
||||
|
||||
Brightness and a plugin reload:
|
||||
|
||||
@@ -44,8 +44,8 @@ and symlink the plugin directories you are working on into LEDMatrix's
|
||||
### 1. The plugin monorepo
|
||||
|
||||
Clone ledmatrix-plugins into the same parent directory as LEDMatrix (the
|
||||
workspace file looks for `../ledmatrix-plugins` relative to the LEDMatrix
|
||||
root):
|
||||
workspace file and `scripts/update_plugin_repos.py` look for
|
||||
`../ledmatrix-plugins` relative to the LEDMatrix root):
|
||||
|
||||
```bash
|
||||
cd ~/Github
|
||||
@@ -86,7 +86,7 @@ the plugin from there. See the
|
||||
|
||||
```bash
|
||||
cd ~/Github/LEDMatrix
|
||||
git -C ../ledmatrix-plugins pull # the sibling monorepo checkout
|
||||
python3 scripts/update_plugin_repos.py # git pull in ../ledmatrix-plugins
|
||||
# or
|
||||
./scripts/dev/dev_plugin_setup.sh update # git pull in every linked checkout
|
||||
```
|
||||
|
||||
@@ -522,42 +522,52 @@ Returns the request id once queued, or `None` as above.
|
||||
|
||||
These methods are new after core 3.8.0 (see `CHANGELOG.md`). Before them,
|
||||
plugins wrote the `display_on_demand_request` cache key (the "mailbox")
|
||||
themselves. The display reads it only once a second while the control
|
||||
socket is up, and it will be removed in a future release (see
|
||||
[IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md), stage 5). A plugin that
|
||||
must keep working on older cores checks for the method, and writes the
|
||||
mailbox only when the method is missing or answers `None`:
|
||||
themselves. **The mailbox is gone** (see
|
||||
[IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md), stage 5): the display no
|
||||
longer reads it, and a write to it is dropped with a warning in the log,
|
||||
once per plugin:
|
||||
|
||||
```
|
||||
Ignored a write to the retired 'display_on_demand_request' cache key by plugin 'my-plugin': ...
|
||||
```
|
||||
|
||||
A plugin that only needs to run on cores with these methods calls them and
|
||||
treats `None` as "no display took it":
|
||||
|
||||
```python
|
||||
def _show_alert(self):
|
||||
if self.request_on_demand(mode="my_alert", duration=15) is None:
|
||||
self.logger.info("No display to show the alert on")
|
||||
```
|
||||
|
||||
A plugin that must also work on cores before them can keep the mailbox
|
||||
write as its fallback, guarded by `hasattr`: on those cores the display
|
||||
still reads it, and on a current core the write is only dropped and logged
|
||||
(when the method is missing it never runs at all). Raise
|
||||
`ledmatrix_min_version` to the release that added the methods once you no
|
||||
longer need it.
|
||||
|
||||
```python
|
||||
import time, uuid
|
||||
|
||||
def _show_alert(self):
|
||||
if hasattr(self, "request_on_demand") and self.request_on_demand(
|
||||
mode="my_alert", duration=15):
|
||||
if hasattr(self, "request_on_demand"):
|
||||
self.request_on_demand(mode="my_alert", duration=15)
|
||||
return
|
||||
# Older core, or no display in this process: the mailbox, as before.
|
||||
# A core older than request_on_demand(): the mailbox it still reads.
|
||||
self.cache_manager.set("display_on_demand_request", {
|
||||
"request_id": str(uuid.uuid4()), "action": "start",
|
||||
"plugin_id": self.plugin_id, "mode": "my_alert",
|
||||
"duration": 15, "pinned": False, "timestamp": time.time(),
|
||||
})
|
||||
|
||||
def _release(self):
|
||||
if hasattr(self, "end_on_demand") and self.end_on_demand():
|
||||
return
|
||||
self.cache_manager.set("display_on_demand_request", {
|
||||
"request_id": str(uuid.uuid4()), "action": "stop",
|
||||
"plugin_id": self.plugin_id, "timestamp": time.time(),
|
||||
})
|
||||
```
|
||||
|
||||
Keep `ledmatrix_min_version` where it is: the fallback is what keeps the
|
||||
plugin working on older cores. A mailbox stop ends any on-demand session,
|
||||
whoever started it; `end_on_demand()` ends only the plugin's own.
|
||||
`end_on_demand()` ends only the plugin's own session; a stop written to the
|
||||
mailbox on an older core ends any session, whoever started it.
|
||||
|
||||
Both methods answer a request id only when the plugin manager returned a
|
||||
string, so a test that gives the plugin a `MagicMock()` plugin manager gets
|
||||
`None` and exercises the mailbox path. To test the new path, set
|
||||
`None`. To test the path where the display takes the request, set
|
||||
`plugin_manager.request_on_demand.return_value = "some-id"`.
|
||||
|
||||
> The full source for `BasePlugin` lives in
|
||||
|
||||
+63
-39
@@ -363,11 +363,9 @@ it. This is the list the force-display dialog offers.
|
||||
|
||||
Send the reported `plugin_id` alongside `mode` when starting an on-demand
|
||||
display: `/display/on-demand/start` falls back to `find_plugin_for_mode` when
|
||||
`plugin_id` is omitted. While the display is running, this list and that
|
||||
lookup use the modes the display registered, including ones a plugin generates
|
||||
from its config (each installed Starlark app, each soccer `custom_leagues`
|
||||
entry). With the display stopped, or for a plugin it has not loaded, both see
|
||||
only the modes its manifest declares.
|
||||
`plugin_id` is omitted, and that lookup only sees modes declared in a static
|
||||
manifest — a plugin whose modes are generated (each installed Starlark app is
|
||||
one) returns 404 there.
|
||||
|
||||
Triggers plugin discovery, which is otherwise lazy — so a caller that never
|
||||
opens the dashboard still gets the full list.
|
||||
@@ -466,7 +464,7 @@ Request a specific plugin to display on-demand.
|
||||
- `mode` (string, optional): Display mode name (plugin_id inferred if not provided)
|
||||
- `duration` (number, optional): Duration in seconds (0 = until stopped)
|
||||
- `pinned` (boolean, optional): Pin display (pause rotation)
|
||||
- `start_service` (boolean, optional): Start the display service if it is not running (default: true). A running service is never restarted: it picks the request up within a frame over its control socket (within about a second through the mailbox fallback). When false and the service is stopped, the route returns 400.
|
||||
- `start_service` (boolean, optional): Start the display service if it is not running (default: true). A running service is never restarted: it picks the request up within a frame over its control socket. A stopped one is started, and the route answers `202` at once (see below); the request is sent once the display's socket is up, which can take as long as the display takes to load its plugins. When false and the service is stopped, the route returns 400.
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
@@ -486,24 +484,54 @@ Request a specific plugin to display on-demand.
|
||||
|
||||
`service` is `null` when `start_service` is false.
|
||||
|
||||
`transport` says how the request reached the display: `"socket"` means the
|
||||
display's control socket acknowledged it (it is queued for the render thread,
|
||||
which wakes for it and applies it within a frame; see
|
||||
[IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md)), `"mailbox"` means it was
|
||||
written to the cache mailbox the display polls, as before the socket existed.
|
||||
The mailbox is used only when the socket could not carry the request. With
|
||||
`"mailbox"`, `socket_error` gives the reason (`no_socket` when the display is
|
||||
stopped or predates the socket, `refused`, a connect `timeout`,
|
||||
`unknown_command` from a display too old for the command, ...). Either way
|
||||
the request is applied the same way; `request_id` is the same id in both.
|
||||
`transport` is always `"socket"`: the display's control socket acknowledged
|
||||
the request (it is queued for the render thread, which wakes for it and
|
||||
applies it within a frame; see [IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md)).
|
||||
The `"mailbox"` value earlier releases could answer is gone with the
|
||||
mailbox: nothing is written to the cache.
|
||||
|
||||
When the display had the request and did not take it -- a full queue
|
||||
(`busy`), bad arguments (`invalid_args`), no answer after the request was
|
||||
sent (`timeout`, `closed`) -- the route answers `503` (`400` for
|
||||
`invalid_args`) with `status: "error"` and `data: {request_id, transport:
|
||||
"socket", socket_error}`, and writes nothing to the mailbox. The stop route
|
||||
does the same, except that with `stop_service: true` it still stops the
|
||||
service and answers success.
|
||||
**No display listening yet** (`no_socket`, `refused`: the service is
|
||||
stopped, or still loading its plugins). With the service stopped and
|
||||
`start_service` false, `400`. Otherwise the route starts the service if
|
||||
needed and answers at once:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "starting",
|
||||
"message": "The display service is starting; ...",
|
||||
"data": {"request_id": "uuid-here", "plugin_id": "football-scoreboard", "mode": "nfl_live",
|
||||
"duration": 45, "pinned": true, "service": {"active": true, "started": true},
|
||||
"transport": "socket", "socket_error": "no_socket",
|
||||
"pending": true, "wait_seconds": 45.0}
|
||||
}
|
||||
```
|
||||
|
||||
with HTTP `202`. The web process sends the request until the display takes
|
||||
it, for up to `wait_seconds` (45 after a cold start, 10 when the service was
|
||||
already running). Follow it with `GET /api/v3/display/on-demand/status`:
|
||||
its `state` is `{status: "starting", source: "web", request_id, ...}` while
|
||||
it waits, and still `starting` with `delivered: true` once the display has
|
||||
acknowledged it but not yet published the state for that `request_id` (at
|
||||
most 30 s), then the display's own state, or `{status: "error",
|
||||
error: "start-timeout"}` (or the socket's reason) if it never was;
|
||||
`GET /api/v3/display/current-status` carries the same as
|
||||
`on_demand_pending`. A newer start replaces a pending one; a stop cancels it.
|
||||
|
||||
Otherwise, when the display did not take the request, the route answers an
|
||||
error with `status: "error"` and `data: {request_id, transport: "socket",
|
||||
socket_error}`:
|
||||
|
||||
- a full queue (`busy`), no answer after the request was sent (`timeout`,
|
||||
`closed`), a display older than the command (`unknown_command`), no
|
||||
socket in the web process (`disabled`, `unsupported`): `503` at once;
|
||||
- bad arguments (`invalid_args`): `400`.
|
||||
|
||||
The stop route answers the same errors (`503` when no display is
|
||||
listening, with a message saying whether the service is stopped), except
|
||||
that with `stop_service: true` it still stops the service and answers
|
||||
success, with `socket_error` set, and that a stop which cancelled a pending
|
||||
start succeeds with `cancelled_request_id` even when no display is
|
||||
listening.
|
||||
|
||||
### Stop On-Demand Display
|
||||
|
||||
@@ -533,7 +561,7 @@ Stop the current on-demand display.
|
||||
}
|
||||
```
|
||||
|
||||
`transport` and `socket_error` are as for start.
|
||||
`transport` and the errors are as for start; a stop is never retried.
|
||||
|
||||
---
|
||||
|
||||
@@ -2199,7 +2227,7 @@ Every response below adds three fields to the shape it always had:
|
||||
|---|---|
|
||||
| `snapshot_available` | `false` until the display service has reported (for example, it is not running). Counts are then zero. |
|
||||
| `generated_at` | When the display service produced the snapshot (ISO, the Pi's local time), or `null`. |
|
||||
| `clear_pending` | A clear has been requested and the display service has not applied it yet. |
|
||||
| `clear_pending` | Always `false`: a clear is applied before its route answers. Kept for compatibility. |
|
||||
|
||||
### Get Error Summary
|
||||
|
||||
@@ -2280,21 +2308,17 @@ before it answers: `applied` is `true`, `transport` is `"socket"`, and
|
||||
}
|
||||
```
|
||||
|
||||
When the socket cannot carry it (the display is stopped, or older than
|
||||
`errors.clear`) the clear is asynchronous, as before: the web interface
|
||||
records a request (`plugin_error_clear_request` in the shared cache),
|
||||
`applied` is `false` and `transport` is `"mailbox"`, and the display service
|
||||
applies it within about 5 seconds. Reads hide the cleared errors from the
|
||||
moment the request is recorded. Until the display service applies an
|
||||
age-based clear, `recent_errors` and `active_patterns` are already filtered
|
||||
but the counts are the old ones, and `clear_pending` is `true`. Then
|
||||
`cleared_count` is how many of the reported errors the clear hides, and
|
||||
`null` when that cannot be known before the display service applies it (an
|
||||
age-based clear over more errors than the report lists).
|
||||
`clear_requested`, `applied` and `transport` are always `true`, `true` and
|
||||
`"socket"`, and are kept for compatibility.
|
||||
|
||||
A request that could not be written to the shared cache answers `500`. A
|
||||
display that had the request and failed it (`internal`, a timeout after the
|
||||
request was sent) answers `503`, with `context.socket_error`.
|
||||
When the socket cannot carry the clear, the route answers `503` with
|
||||
`context.socket_error`, and nothing is cleared or recorded: the
|
||||
`plugin_error_clear_request` mailbox earlier releases fell back to is gone.
|
||||
The message says why: the display service is not running (its errors are
|
||||
then the last run's, and its next run starts with none), it is too old for
|
||||
`errors.clear` (restart it), the web process has no socket, or the display
|
||||
had the request and failed it (`internal`, a timeout after the request was
|
||||
sent).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -101,10 +101,7 @@ python3 --version
|
||||
Imager, choosing Raspberry Pi OS Lite (64-bit). Trixie is recommended;
|
||||
Bookworm (Legacy) also works. An in-place upgrade from Bullseye is not
|
||||
supported by Raspberry Pi and is not worth the risk.
|
||||
- "A desktop is running": use the Lite image, not the desktop one, or boot
|
||||
to the console with `sudo systemctl set-default multi-user.target` and
|
||||
reboot. Desktop packages that are installed but not running only produce a
|
||||
warning, and the install continues.
|
||||
- "Desktop environment detected": use the Lite image, not the desktop one.
|
||||
- "python3 is Python 3.x; LEDMatrix needs Python 3.11 or newer": something
|
||||
has replaced the system `python3`. Point it back at the OS's own Python
|
||||
(`/usr/bin/python3` should be 3.11 on Bookworm, 3.13 on Trixie).
|
||||
|
||||
+13
-32
@@ -86,44 +86,25 @@ if [ -r "$LM_OS_RELEASE_FILE" ]; then
|
||||
OS_CHECK_FAILED=1
|
||||
fi
|
||||
|
||||
# Check for a desktop. A desktop only competes with the panel for CPU while
|
||||
# it runs, so a running display manager stops the install; desktop packages
|
||||
# or session files on a Pi that boots to the console are only a warning.
|
||||
DESKTOP_RUNNING=0
|
||||
DESKTOP_INSTALLED=0
|
||||
# display-manager is the alias every Debian display manager registers.
|
||||
for dm in display-manager lightdm gdm gdm3 sddm lxdm; do
|
||||
if systemctl is-active --quiet "$dm" 2>/dev/null; then
|
||||
DESKTOP_RUNNING=1
|
||||
fi
|
||||
done
|
||||
# Check if it's the Lite version (no desktop environment)
|
||||
# Check for desktop packages or desktop services
|
||||
DESKTOP_DETECTED=0
|
||||
# grep without -q: -q exits at the first match, dpkg then dies of SIGPIPE,
|
||||
# and pipefail turns a found desktop into "not found".
|
||||
# Desktop metapackages and session managers, matched as whole installed
|
||||
# package names: an unanchored ".*kde" matched libblockdev-* ("bloc-kde-v"),
|
||||
# and a "gnome" prefix matched standalone parts such as gnome-keyring.
|
||||
# Trixie replaced raspberrypi-ui-mods with the rpd-*-core metapackages.
|
||||
DESKTOP_PACKAGES='raspberrypi-ui-mods|rpd-wayland-core|rpd-x-core'
|
||||
DESKTOP_PACKAGES+='|lxde|lxde-core|lxsession|xfce4|xfce4-session'
|
||||
DESKTOP_PACKAGES+='|gnome-shell|gnome-session|kde-plasma-desktop|plasma-desktop'
|
||||
DESKTOP_PACKAGES+='|plasma-workspace|task-desktop|task-[a-z0-9]+-desktop'
|
||||
if dpkg-query -W -f='${db:Status-Abbrev} ${binary:Package}\n' 2>/dev/null \
|
||||
| grep -E "^ii +(${DESKTOP_PACKAGES})(:[a-z0-9]+)?$" >/dev/null; then
|
||||
DESKTOP_INSTALLED=1
|
||||
if dpkg -l | grep -E "^ii.*raspberrypi-ui-mods|^ii.*lxde|^ii.*xfce|^ii.*gnome|^ii.*kde" >/dev/null; then
|
||||
DESKTOP_DETECTED=1
|
||||
fi
|
||||
if systemctl list-units --type=service --state=running 2>/dev/null | grep -qE "lightdm|gdm3|sddm|lxdm"; then
|
||||
DESKTOP_DETECTED=1
|
||||
fi
|
||||
if [ -d /usr/share/raspberrypi-ui-mods ] || [ -d /usr/share/xsessions ]; then
|
||||
DESKTOP_INSTALLED=1
|
||||
DESKTOP_DETECTED=1
|
||||
fi
|
||||
|
||||
if [ "$DESKTOP_RUNNING" -eq 1 ]; then
|
||||
echo "✗ ERROR: A desktop is running - this script requires Raspberry Pi OS Lite"
|
||||
echo " Please use Raspberry Pi OS Lite (not the full desktop version), or boot"
|
||||
echo " to the console: sudo systemctl set-default multi-user.target && sudo reboot"
|
||||
|
||||
if [ "$DESKTOP_DETECTED" -eq 1 ]; then
|
||||
echo "✗ ERROR: Desktop environment detected - this script requires Raspberry Pi OS Lite"
|
||||
echo " Please use Raspberry Pi OS Lite (not the full desktop version)"
|
||||
OS_CHECK_FAILED=1
|
||||
elif [ "$DESKTOP_INSTALLED" -eq 1 ]; then
|
||||
echo "⚠ WARNING: Desktop packages are installed, but no desktop is running."
|
||||
echo " Continuing. Keep the Pi booting to the console: a running desktop"
|
||||
echo " competes with the LED panel for CPU and can make it flicker."
|
||||
else
|
||||
echo "✓ Lite version confirmed (no desktop environment)"
|
||||
fi
|
||||
|
||||
@@ -14,6 +14,7 @@ src/auto_update_setup.py
|
||||
src/backup_manager.py
|
||||
src/base_odds_manager.py
|
||||
src/cache/__init__.py
|
||||
src/cache/cache_metrics.py
|
||||
src/cache/cache_strategy.py
|
||||
src/cache/memory_cache.py
|
||||
src/common/__init__.py
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
# Install alongside requirements.txt: pip install -r requirements.txt -r requirements-test.txt
|
||||
pytest>=9.0.3,<10.0.0
|
||||
pytest-cov>=4.1.0,<8.0.0
|
||||
pytest-mock>=3.11.0,<4.0.0
|
||||
freezegun>=1.2,<2 # deterministic time for golden-image tests
|
||||
psutil>=6.0.0,<7.0.0 # optional at runtime; installed for tests so the
|
||||
# /system/status endpoint's real path is exercised
|
||||
|
||||
+8
-4
@@ -15,7 +15,7 @@ display; **diagnostic** — run by hand on a Pi when something is wrong.
|
||||
| [`install/`](install/README.md) | keep | The installers: one-shot, services, sudoers/WiFi permissions, cache setup, and the shared `lib_*.sh` helpers `first_time_install.sh` sources |
|
||||
| [`fix_perms/`](fix_perms/README.md) | keep | Permission repair scripts, plus the two root helpers the web interface runs through sudo (`safe_plugin_rm.sh`, `safe_pip_install.sh`) |
|
||||
| [`utils/`](utils/README.md) | keep | Scripts run by systemd units or the web interface (conditional web start, WiFi monitor, update verify, DNS fix, Pixlet config editor, cache clearing) |
|
||||
| [`dev/`](dev/README.md) | dev-only | Plugin linking, Vegas density audit, Pillow smoke test |
|
||||
| [`dev/`](dev/README.md) | dev-only | Plugin linking, emulator runner, Vegas density audit, Pillow smoke test |
|
||||
| `templates/` | dev-only | `dev_preview.html`, the page `dev_server.py` serves |
|
||||
|
||||
## Top-level scripts
|
||||
@@ -43,18 +43,22 @@ display; **diagnostic** — run by hand on a Pi when something is wrong.
|
||||
| `scroll_speeds.py` | keep | Shows and tries the scroll speeds your panel can display cleanly |
|
||||
| `sports_drift_report.py` | keep | Counts the different bodies of each method across the nine scoreboards in a `ledmatrix-plugins` checkout (report-only CI job; docs/SPORTS_UNIFICATION.md) |
|
||||
| `troubleshoot_captive_portal.sh` | diagnostic | Troubleshoots captive-portal WiFi setup after you can SSH back in |
|
||||
| `update_plugin_repos.py` | dev-only | Pulls the latest `ledmatrix-plugins` monorepo |
|
||||
| `verify_installation.sh` | diagnostic | Checks that an installation completed correctly |
|
||||
| `verify_wifi_setup.sh` | diagnostic | Health check of the WiFi management setup |
|
||||
|
||||
## Hand-run tools nothing else references
|
||||
## Candidates for removal
|
||||
|
||||
Nothing in the repo (docs, CI, tests, other scripts or code) refers to these.
|
||||
They are run by hand and were kept by owner decision (October 2026); the
|
||||
old one-off schema fixers and WiFi test scripts listed here were removed.
|
||||
They are kept for now; each one needs an owner decision before it goes.
|
||||
|
||||
| Script | What it does |
|
||||
|---|---|
|
||||
| `add_defaults_to_schemas.py` | One-off: adds missing `default` values to plugin config schemas |
|
||||
| `analyze_plugin_schemas.py` | One-off: reports duplicate/inconsistent fields across plugin schemas |
|
||||
| `audit_plugins.py` | AST security audit of plugin code; says it is "designed to run in CI" but no workflow runs it |
|
||||
| `audit_render_path.py` | Finds blocking calls reachable from a plugin's `display()` |
|
||||
| `sports_scroll_check.py` | Drives a sports scoreboard scroll on the panel and reports its pacing |
|
||||
| `test_captive_portal.sh` | Tests the captive portal from a device connected to the AP |
|
||||
| `verify_wifi_before_testing.sh` | Pre-flight check before unplugging Ethernet to test WiFi |
|
||||
| `dev/test_pillow_compat.py` | Pillow API smoke test to run after upgrading Pillow |
|
||||
|
||||
Executable
+231
@@ -0,0 +1,231 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script to add default values to plugin config schemas where missing.
|
||||
|
||||
This ensures that configs never start with None values, improving user experience
|
||||
and preventing validation errors.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
|
||||
def get_default_for_field(prop: Dict[str, Any]) -> Any:
|
||||
"""
|
||||
Determine a sensible default value for a field based on its type and constraints.
|
||||
|
||||
Args:
|
||||
prop: Field property schema
|
||||
|
||||
Returns:
|
||||
Default value or None if no default should be added
|
||||
"""
|
||||
prop_type = prop.get('type')
|
||||
|
||||
# Handle union types (array with multiple types)
|
||||
if isinstance(prop_type, list):
|
||||
# Use the first non-null type
|
||||
prop_type = next((t for t in prop_type if t != 'null'), prop_type[0] if prop_type else 'string')
|
||||
|
||||
if prop_type == 'boolean':
|
||||
return False
|
||||
|
||||
elif prop_type == 'number':
|
||||
# For numbers, use minimum if available, or a sensible default
|
||||
minimum = prop.get('minimum')
|
||||
maximum = prop.get('maximum')
|
||||
|
||||
if minimum is not None:
|
||||
return minimum
|
||||
elif maximum is not None:
|
||||
# Use a reasonable fraction of max (like 30% or minimum 1)
|
||||
return max(1, int(maximum * 0.3))
|
||||
else:
|
||||
# No constraints, use 0
|
||||
return 0
|
||||
|
||||
elif prop_type == 'integer':
|
||||
# Similar to number
|
||||
minimum = prop.get('minimum')
|
||||
maximum = prop.get('maximum')
|
||||
|
||||
if minimum is not None:
|
||||
return minimum
|
||||
elif maximum is not None:
|
||||
return max(1, int(maximum * 0.3))
|
||||
else:
|
||||
return 0
|
||||
|
||||
elif prop_type == 'string':
|
||||
# Only add default for strings if it makes sense
|
||||
# Check if there's an enum - use first value
|
||||
enum_values = prop.get('enum')
|
||||
if enum_values:
|
||||
return enum_values[0]
|
||||
|
||||
# For optional string fields, empty string might be okay, but be cautious
|
||||
# We'll skip adding defaults for strings unless explicitly needed
|
||||
return None
|
||||
|
||||
elif prop_type == 'array':
|
||||
# Empty array as default
|
||||
return []
|
||||
|
||||
elif prop_type == 'object':
|
||||
# Empty object - but we'll handle nested objects separately
|
||||
return {}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def should_add_default(prop: Dict[str, Any], field_path: str) -> bool:
|
||||
"""
|
||||
Determine if we should add a default value to this field.
|
||||
|
||||
Args:
|
||||
prop: Field property schema
|
||||
field_path: Dot-separated path to the field
|
||||
|
||||
Returns:
|
||||
True if default should be added
|
||||
"""
|
||||
# Skip if already has a default
|
||||
if 'default' in prop:
|
||||
return False
|
||||
|
||||
# Skip secret fields (they should be user-provided)
|
||||
if prop.get('x-secret', False):
|
||||
return False
|
||||
|
||||
# Skip API keys and similar sensitive fields
|
||||
field_name = field_path.split('.')[-1].lower()
|
||||
sensitive_keywords = ['key', 'password', 'secret', 'token', 'auth', 'credential']
|
||||
if any(keyword in field_name for keyword in sensitive_keywords):
|
||||
return False
|
||||
|
||||
prop_type = prop.get('type')
|
||||
if isinstance(prop_type, list):
|
||||
prop_type = next((t for t in prop_type if t != 'null'), prop_type[0] if prop_type else None)
|
||||
|
||||
# Only add defaults for certain types
|
||||
if prop_type in ('boolean', 'number', 'integer', 'array'):
|
||||
return True
|
||||
|
||||
# For strings, only if there's an enum
|
||||
if prop_type == 'string' and 'enum' in prop:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def add_defaults_recursive(schema: Dict[str, Any], path: str = "", modified: List[str] = None) -> bool:
|
||||
"""
|
||||
Recursively add default values to schema fields.
|
||||
|
||||
Args:
|
||||
schema: Schema dictionary to modify
|
||||
path: Current path in the schema (for logging)
|
||||
modified: List to track which fields were modified
|
||||
|
||||
Returns:
|
||||
True if any modifications were made
|
||||
"""
|
||||
if modified is None:
|
||||
modified = []
|
||||
|
||||
if not isinstance(schema, dict) or 'properties' not in schema:
|
||||
return False
|
||||
|
||||
changes_made = False
|
||||
|
||||
for key, prop in schema['properties'].items():
|
||||
if not isinstance(prop, dict):
|
||||
continue
|
||||
|
||||
current_path = f"{path}.{key}" if path else key
|
||||
|
||||
# Check nested objects
|
||||
if prop.get('type') == 'object' and 'properties' in prop:
|
||||
if add_defaults_recursive(prop, current_path, modified):
|
||||
changes_made = True
|
||||
|
||||
# Add default if appropriate
|
||||
if should_add_default(prop, current_path):
|
||||
default_value = get_default_for_field(prop)
|
||||
if default_value is not None:
|
||||
prop['default'] = default_value
|
||||
modified.append(current_path)
|
||||
changes_made = True
|
||||
print(f" Added default to {current_path}: {default_value} (type: {prop.get('type')})")
|
||||
|
||||
return changes_made
|
||||
|
||||
|
||||
def process_schema_file(schema_path: Path) -> bool:
|
||||
"""
|
||||
Process a single schema file to add defaults.
|
||||
|
||||
Args:
|
||||
schema_path: Path to the schema file
|
||||
|
||||
Returns:
|
||||
True if file was modified
|
||||
"""
|
||||
print(f"\nProcessing: {schema_path}")
|
||||
|
||||
try:
|
||||
with open(schema_path, 'r', encoding='utf-8') as f:
|
||||
schema = json.load(f)
|
||||
except Exception as e:
|
||||
print(f" Error reading schema: {e}")
|
||||
return False
|
||||
|
||||
modified_fields = []
|
||||
changes_made = add_defaults_recursive(schema, modified=modified_fields)
|
||||
|
||||
if changes_made:
|
||||
# Write back with pretty formatting
|
||||
with open(schema_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(schema, f, indent=2, ensure_ascii=False)
|
||||
f.write('\n') # Add trailing newline
|
||||
|
||||
print(f" ✓ Modified {len(modified_fields)} fields")
|
||||
return True
|
||||
else:
|
||||
print(" ✓ No changes needed")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
project_root = Path(__file__).parent.parent
|
||||
plugins_dir = project_root / 'plugin-repos'
|
||||
|
||||
if not plugins_dir.exists():
|
||||
print(f"Error: Plugins directory not found: {plugins_dir}")
|
||||
sys.exit(1)
|
||||
|
||||
# Find all config_schema.json files
|
||||
schema_files = list(plugins_dir.rglob('config_schema.json'))
|
||||
|
||||
if not schema_files:
|
||||
print("No config_schema.json files found")
|
||||
sys.exit(0)
|
||||
|
||||
print(f"Found {len(schema_files)} schema files")
|
||||
|
||||
modified_count = 0
|
||||
for schema_file in sorted(schema_files):
|
||||
if process_schema_file(schema_file):
|
||||
modified_count += 1
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f"Summary: Modified {modified_count} out of {len(schema_files)} schema files")
|
||||
print(f"{'='*60}")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
Executable
+279
@@ -0,0 +1,279 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Analyze all plugin config schemas to identify issues:
|
||||
- Duplicate fields
|
||||
- Inconsistencies
|
||||
- Missing common fields
|
||||
- Naming variations
|
||||
- Formatting issues
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Any
|
||||
import jsonschema
|
||||
from jsonschema import Draft7Validator
|
||||
|
||||
# Standard common fields that should be in all plugins
|
||||
STANDARD_COMMON_FIELDS = {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": "Enable or disable this plugin",
|
||||
"required": True,
|
||||
"order": 1
|
||||
},
|
||||
"display_duration": {
|
||||
"type": "number",
|
||||
"default": 15,
|
||||
"minimum": 1,
|
||||
"maximum": 300,
|
||||
"description": "How long to display this plugin in seconds",
|
||||
"order": 2
|
||||
},
|
||||
"live_priority": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": "Enable live priority takeover when plugin has live content",
|
||||
"order": 3
|
||||
},
|
||||
"high_performance_transitions": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": "Use high-performance transitions (120 FPS) instead of standard (30 FPS)",
|
||||
"order": 4
|
||||
},
|
||||
"update_interval": {
|
||||
"type": "integer",
|
||||
"default": 60,
|
||||
"minimum": 1,
|
||||
"description": "How often to refresh data in seconds",
|
||||
"order": 5
|
||||
},
|
||||
"transition": {
|
||||
"type": "object",
|
||||
"order": 6
|
||||
}
|
||||
}
|
||||
|
||||
def find_duplicate_fields(schema: Dict[str, Any], path: str = "") -> List[str]:
|
||||
"""Find duplicate field definitions within a schema."""
|
||||
duplicates = []
|
||||
seen_fields = {}
|
||||
|
||||
def check_properties(props: Dict[str, Any], current_path: str):
|
||||
if not isinstance(props, dict):
|
||||
return
|
||||
|
||||
for key, value in props.items():
|
||||
full_path = f"{current_path}.{key}" if current_path else key
|
||||
if key in seen_fields:
|
||||
duplicates.append(f"Duplicate field '{key}' at {full_path} (also at {seen_fields[key]})")
|
||||
else:
|
||||
seen_fields[key] = full_path
|
||||
|
||||
# Recursively check nested objects
|
||||
if isinstance(value, dict):
|
||||
if "properties" in value:
|
||||
check_properties(value["properties"], full_path)
|
||||
elif "items" in value and isinstance(value["items"], dict):
|
||||
if "properties" in value["items"]:
|
||||
check_properties(value["items"]["properties"], f"{full_path}[items]")
|
||||
|
||||
if "properties" in schema:
|
||||
check_properties(schema["properties"], "")
|
||||
|
||||
return duplicates
|
||||
|
||||
def validate_schema_syntax(schema_path: Path) -> tuple[bool, List[str]]:
|
||||
"""Validate JSON Schema syntax."""
|
||||
try:
|
||||
with open(schema_path, 'r', encoding='utf-8') as f:
|
||||
schema = json.load(f)
|
||||
|
||||
# Validate schema structure
|
||||
Draft7Validator.check_schema(schema)
|
||||
return True, []
|
||||
except json.JSONDecodeError as e:
|
||||
return False, [f"JSON syntax error: {str(e)}"]
|
||||
except jsonschema.SchemaError as e:
|
||||
return False, [f"Schema validation error: {str(e)}"]
|
||||
except Exception as e:
|
||||
return False, [f"Error: {str(e)}"]
|
||||
|
||||
def analyze_schema(schema_path: Path) -> Dict[str, Any]:
|
||||
"""Analyze a single schema file."""
|
||||
plugin_id = schema_path.parent.name
|
||||
analysis = {
|
||||
"plugin_id": plugin_id,
|
||||
"path": str(schema_path),
|
||||
"valid": False,
|
||||
"errors": [],
|
||||
"warnings": [],
|
||||
"has_title": False,
|
||||
"has_description": False,
|
||||
"common_fields": {},
|
||||
"missing_common_fields": [],
|
||||
"naming_issues": [],
|
||||
"duplicates": [],
|
||||
"property_order": [],
|
||||
"update_interval_variant": None
|
||||
}
|
||||
|
||||
try:
|
||||
with open(schema_path, 'r', encoding='utf-8') as f:
|
||||
schema = json.load(f)
|
||||
|
||||
# Check for title and description
|
||||
analysis["has_title"] = "title" in schema
|
||||
analysis["has_description"] = "description" in schema
|
||||
|
||||
if not analysis["has_title"]:
|
||||
analysis["warnings"].append("Missing 'title' field at root level")
|
||||
if not analysis["has_description"]:
|
||||
analysis["warnings"].append("Missing 'description' field at root level")
|
||||
|
||||
# Validate schema syntax
|
||||
is_valid, errors = validate_schema_syntax(schema_path)
|
||||
analysis["valid"] = is_valid
|
||||
analysis["errors"].extend(errors)
|
||||
|
||||
if not is_valid:
|
||||
return analysis
|
||||
|
||||
# Check for duplicate fields
|
||||
duplicates = find_duplicate_fields(schema)
|
||||
analysis["duplicates"] = duplicates
|
||||
|
||||
# Check properties
|
||||
if "properties" not in schema:
|
||||
analysis["errors"].append("Missing 'properties' field")
|
||||
return analysis
|
||||
|
||||
properties = schema["properties"]
|
||||
|
||||
# Check common fields
|
||||
for field_name, field_spec in STANDARD_COMMON_FIELDS.items():
|
||||
if field_name in properties:
|
||||
analysis["common_fields"][field_name] = properties[field_name]
|
||||
else:
|
||||
# Check for variants
|
||||
if field_name == "update_interval":
|
||||
# Check for update_interval_seconds variant
|
||||
if "update_interval_seconds" in properties:
|
||||
analysis["update_interval_variant"] = "update_interval_seconds"
|
||||
analysis["naming_issues"].append(
|
||||
"Uses 'update_interval_seconds' instead of 'update_interval'"
|
||||
)
|
||||
else:
|
||||
analysis["missing_common_fields"].append(field_name)
|
||||
else:
|
||||
analysis["missing_common_fields"].append(field_name)
|
||||
|
||||
# Check property order (enabled should be first)
|
||||
prop_keys = list(properties.keys())
|
||||
analysis["property_order"] = prop_keys
|
||||
|
||||
if prop_keys and prop_keys[0] != "enabled":
|
||||
analysis["warnings"].append(
|
||||
f"'enabled' is not first property. First property is '{prop_keys[0]}'"
|
||||
)
|
||||
|
||||
# Check for required fields
|
||||
required = schema.get("required", [])
|
||||
if "enabled" not in required:
|
||||
analysis["warnings"].append("'enabled' is not in required fields")
|
||||
|
||||
except Exception as e:
|
||||
analysis["errors"].append(f"Failed to analyze schema: {str(e)}")
|
||||
|
||||
return analysis
|
||||
|
||||
def main():
|
||||
"""Main analysis function."""
|
||||
project_root = Path(__file__).parent.parent
|
||||
plugins_dir = project_root / "plugin-repos"
|
||||
|
||||
if not plugins_dir.exists():
|
||||
print(f"Plugins directory not found: {plugins_dir}")
|
||||
return
|
||||
|
||||
results = []
|
||||
|
||||
# Find all config_schema.json files
|
||||
schema_files = list(plugins_dir.glob("*/config_schema.json"))
|
||||
|
||||
print(f"Found {len(schema_files)} plugin schemas to analyze\n")
|
||||
|
||||
for schema_path in sorted(schema_files):
|
||||
print(f"Analyzing {schema_path.parent.name}...")
|
||||
analysis = analyze_schema(schema_path)
|
||||
results.append(analysis)
|
||||
|
||||
# Print summary
|
||||
print("\n" + "="*80)
|
||||
print("ANALYSIS SUMMARY")
|
||||
print("="*80)
|
||||
|
||||
for result in results:
|
||||
print(f"\n{result['plugin_id']}:")
|
||||
print(f" Valid: {result['valid']}")
|
||||
|
||||
if result['errors']:
|
||||
print(f" Errors ({len(result['errors'])}):")
|
||||
for error in result['errors']:
|
||||
print(f" - {error}")
|
||||
|
||||
if result['warnings']:
|
||||
print(f" Warnings ({len(result['warnings'])}):")
|
||||
for warning in result['warnings']:
|
||||
print(f" - {warning}")
|
||||
|
||||
if result['duplicates']:
|
||||
print(f" Duplicates ({len(result['duplicates'])}):")
|
||||
for dup in result['duplicates']:
|
||||
print(f" - {dup}")
|
||||
|
||||
if result['missing_common_fields']:
|
||||
print(f" Missing common fields: {', '.join(result['missing_common_fields'])}")
|
||||
|
||||
if result['naming_issues']:
|
||||
print(" Naming issues:")
|
||||
for issue in result['naming_issues']:
|
||||
print(f" - {issue}")
|
||||
|
||||
if result['property_order'] and result['property_order'][0] != 'enabled':
|
||||
print(f" Property order: First is '{result['property_order'][0]}' (should be 'enabled')")
|
||||
|
||||
# Overall statistics
|
||||
print("\n" + "="*80)
|
||||
print("OVERALL STATISTICS")
|
||||
print("="*80)
|
||||
|
||||
valid_count = sum(1 for r in results if r['valid'])
|
||||
has_title_count = sum(1 for r in results if r['has_title'])
|
||||
has_description_count = sum(1 for r in results if r['has_description'])
|
||||
enabled_first_count = sum(1 for r in results if r['property_order'] and r['property_order'][0] == 'enabled')
|
||||
total_errors = sum(len(r['errors']) for r in results)
|
||||
total_warnings = sum(len(r['warnings']) for r in results)
|
||||
total_duplicates = sum(len(r['duplicates']) for r in results)
|
||||
|
||||
print(f"Total plugins: {len(results)}")
|
||||
print(f"Valid schemas: {valid_count}/{len(results)}")
|
||||
print(f"Has title: {has_title_count}/{len(results)}")
|
||||
print(f"Has description: {has_description_count}/{len(results)}")
|
||||
print(f"'enabled' first: {enabled_first_count}/{len(results)}")
|
||||
print(f"Total errors: {total_errors}")
|
||||
print(f"Total warnings: {total_warnings}")
|
||||
print(f"Total duplicates: {total_duplicates}")
|
||||
|
||||
# Save detailed report
|
||||
report_path = project_root / "plugin_schema_analysis.json"
|
||||
with open(report_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(results, f, indent=2)
|
||||
|
||||
print(f"\nDetailed report saved to: {report_path}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -5,6 +5,7 @@ This directory contains scripts and utilities for development and testing.
|
||||
## Scripts
|
||||
|
||||
- **`dev_plugin_setup.sh`** - Sets up plugin development environment by linking plugin repositories
|
||||
- **`run_emulator.sh`** - Runs the LED Matrix display in emulator mode (for development without hardware)
|
||||
- **`vegas_audit.py`** - Measures how much of the Vegas ticker strip actually shows content (dead-frame ratio)
|
||||
- **`test_pillow_compat.py`** - Pillow API smoke test to run after upgrading Pillow (`python3 scripts/dev/test_pillow_compat.py`)
|
||||
|
||||
@@ -27,6 +28,6 @@ links. To use a fork or another clone location, copy
|
||||
|
||||
### Running Emulator
|
||||
```bash
|
||||
python3 run.py -e
|
||||
./scripts/dev/run_emulator.sh
|
||||
```
|
||||
|
||||
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
#!/bin/bash
|
||||
# LEDMatrix Emulator Runner
|
||||
# This script runs the LEDMatrix system in emulator mode for development and testing
|
||||
|
||||
echo "Starting LEDMatrix Emulator..."
|
||||
echo "Press Ctrl+C to stop"
|
||||
echo ""
|
||||
|
||||
# Set emulator mode
|
||||
export EMULATOR=true
|
||||
|
||||
# Run the main application
|
||||
python3 run.py
|
||||
@@ -75,14 +75,6 @@ OWNER_RECEIVERS: Dict[str, Tuple[str, ...]] = {
|
||||
"DisplayManager": ("display_manager", "display_mgr", "displaymanager", "display", "dm"),
|
||||
"FontManager": ("font_manager", "font_mgr", "fontmanager", "fonts", "fm"),
|
||||
"PluginManager": ("plugin_manager", "plugin_mgr", "pluginmanager", "pm"),
|
||||
"PluginStateManager": ("state_manager", "plugin_state", "state_mgr"),
|
||||
"ConfigManager": ("config_manager", "config_mgr", "configmanager"),
|
||||
"LogoDownloader": ("logo_downloader", "downloader", "logodownloader"),
|
||||
"APIHelper": ("api_helper", "apihelper", "api"),
|
||||
"BackgroundDataService": ("background_service", "background_data_service", "bg_service",
|
||||
"data_service"),
|
||||
"BaseOddsManager": ("odds_manager", "oddsmanager", "odds"),
|
||||
"DynamicTeamResolver": ("dynamic_resolver", "team_resolver", "resolver"),
|
||||
}
|
||||
|
||||
#: Directories never scanned (vendored environments, VCS metadata, caches).
|
||||
@@ -338,7 +330,8 @@ class _Scanner(ast.NodeVisitor):
|
||||
return "call"
|
||||
definers = self.local_definers.get(m.method, ())
|
||||
if name in definers or self.built.get(name or "") in definers:
|
||||
# e.g. the weather plugin's WeatherIcons.draw_sun
|
||||
# e.g. the weather plugin's WeatherIcons.draw_sun, or
|
||||
# self._strategy_component = CacheStrategy(); ...get_sport_live_interval()
|
||||
return "unrelated"
|
||||
return "review"
|
||||
|
||||
|
||||
Executable
+149
@@ -0,0 +1,149 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Test script for captive portal functionality
|
||||
# This script tests the captive portal from a device connected to the AP network
|
||||
|
||||
set -e
|
||||
|
||||
PI_IP="192.168.4.1"
|
||||
PI_PORT="5000"
|
||||
BASE_URL="http://${PI_IP}:${PI_PORT}"
|
||||
|
||||
echo "=========================================="
|
||||
echo "Captive Portal Functionality Test"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "Make sure you're connected to 'LEDMatrix-Setup' network"
|
||||
echo "Pi IP: ${PI_IP}"
|
||||
echo "Web Interface Port: ${PI_PORT}"
|
||||
echo ""
|
||||
|
||||
# Colors for output
|
||||
GREEN='\033[0;32m'
|
||||
RED='\033[0;31m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Test counter
|
||||
PASSED=0
|
||||
FAILED=0
|
||||
|
||||
test_result() {
|
||||
if [ $1 -eq 0 ]; then
|
||||
echo -e "${GREEN}✓${NC} $2"
|
||||
((PASSED++))
|
||||
else
|
||||
echo -e "${RED}✗${NC} $2"
|
||||
((FAILED++))
|
||||
fi
|
||||
}
|
||||
|
||||
# Test 1: Check if Pi is reachable
|
||||
echo "1. Testing Pi connectivity..."
|
||||
if ping -c 1 -W 2 ${PI_IP} > /dev/null 2>&1; then
|
||||
test_result 0 "Pi is reachable at ${PI_IP}"
|
||||
else
|
||||
test_result 1 "Pi is NOT reachable at ${PI_IP}"
|
||||
echo " Make sure you're connected to LEDMatrix-Setup network"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Test 2: DNS Redirection
|
||||
echo ""
|
||||
echo "2. Testing DNS redirection..."
|
||||
DNS_RESULT=$(nslookup google.com 2>/dev/null | grep -i "address" | tail -1 | awk '{print $2}')
|
||||
if [ "$DNS_RESULT" = "${PI_IP}" ]; then
|
||||
test_result 0 "DNS redirection works (google.com resolves to ${PI_IP})"
|
||||
else
|
||||
test_result 1 "DNS redirection failed (got ${DNS_RESULT}, expected ${PI_IP})"
|
||||
fi
|
||||
|
||||
# Test 3: HTTP Redirect
|
||||
echo ""
|
||||
echo "3. Testing HTTP redirect..."
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -L --max-time 5 "${BASE_URL}/google.com" 2>/dev/null || echo "000")
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
test_result 0 "HTTP redirect works (got 200, redirected to setup page)"
|
||||
else
|
||||
test_result 1 "HTTP redirect failed (got ${HTTP_CODE})"
|
||||
fi
|
||||
|
||||
# Test 4: Captive Portal Detection Endpoints
|
||||
echo ""
|
||||
echo "4. Testing captive portal detection endpoints..."
|
||||
|
||||
# iOS/macOS
|
||||
IOS_RESPONSE=$(curl -s --max-time 5 "${BASE_URL}/hotspot-detect.html" 2>/dev/null || echo "")
|
||||
if echo "$IOS_RESPONSE" | grep -qi "success"; then
|
||||
test_result 0 "iOS/macOS endpoint works"
|
||||
else
|
||||
test_result 1 "iOS/macOS endpoint failed"
|
||||
fi
|
||||
|
||||
# Android
|
||||
ANDROID_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "${BASE_URL}/generate_204" 2>/dev/null || echo "000")
|
||||
if [ "$ANDROID_CODE" = "204" ]; then
|
||||
test_result 0 "Android endpoint works"
|
||||
else
|
||||
test_result 1 "Android endpoint failed (got ${ANDROID_CODE})"
|
||||
fi
|
||||
|
||||
# Windows
|
||||
WIN_RESPONSE=$(curl -s --max-time 5 "${BASE_URL}/connecttest.txt" 2>/dev/null || echo "")
|
||||
if echo "$WIN_RESPONSE" | grep -qi "microsoft"; then
|
||||
test_result 0 "Windows endpoint works"
|
||||
else
|
||||
test_result 1 "Windows endpoint failed"
|
||||
fi
|
||||
|
||||
# Firefox
|
||||
FF_RESPONSE=$(curl -s --max-time 5 "${BASE_URL}/success.txt" 2>/dev/null || echo "")
|
||||
if echo "$FF_RESPONSE" | grep -qi "success"; then
|
||||
test_result 0 "Firefox endpoint works"
|
||||
else
|
||||
test_result 1 "Firefox endpoint failed"
|
||||
fi
|
||||
|
||||
# Test 5: API Endpoints (should NOT redirect)
|
||||
echo ""
|
||||
echo "5. Testing API endpoints (should work normally)..."
|
||||
API_RESPONSE=$(curl -s --max-time 5 "${BASE_URL}/api/v3/wifi/status" 2>/dev/null || echo "")
|
||||
if echo "$API_RESPONSE" | grep -qi "status"; then
|
||||
test_result 0 "API endpoints work (not redirected)"
|
||||
else
|
||||
test_result 1 "API endpoints failed or were redirected"
|
||||
fi
|
||||
|
||||
# Test 6: Main Interface (should be accessible)
|
||||
echo ""
|
||||
echo "6. Testing main interface accessibility..."
|
||||
MAIN_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "${BASE_URL}/v3" 2>/dev/null || echo "000")
|
||||
if [ "$MAIN_CODE" = "200" ]; then
|
||||
test_result 0 "Main interface is accessible"
|
||||
else
|
||||
test_result 1 "Main interface failed (got ${MAIN_CODE})"
|
||||
fi
|
||||
|
||||
# Summary
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "Test Summary"
|
||||
echo "=========================================="
|
||||
echo -e "${GREEN}Passed: ${PASSED}${NC}"
|
||||
echo -e "${RED}Failed: ${FAILED}${NC}"
|
||||
echo ""
|
||||
|
||||
if [ $FAILED -eq 0 ]; then
|
||||
echo -e "${GREEN}All tests passed! Captive portal is working correctly.${NC}"
|
||||
exit 0
|
||||
else
|
||||
echo -e "${YELLOW}Some tests failed. Check the output above for details.${NC}"
|
||||
echo ""
|
||||
echo "Troubleshooting tips:"
|
||||
echo "1. Verify AP mode is active: sudo systemctl status hostapd"
|
||||
echo "2. Check dnsmasq config: sudo cat /etc/dnsmasq.conf"
|
||||
echo "3. Check web interface logs: sudo journalctl -u ledmatrix-web -n 50"
|
||||
echo "4. Verify you're connected to LEDMatrix-Setup network"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
Executable
+43
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Update the ledmatrix-plugins monorepo by pulling latest changes.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
MONOREPO_DIR = Path(__file__).parent.parent.parent / "ledmatrix-plugins"
|
||||
|
||||
|
||||
def main():
|
||||
if not MONOREPO_DIR.exists():
|
||||
print(f"Error: Monorepo not found: {MONOREPO_DIR}")
|
||||
return 1
|
||||
|
||||
if not (MONOREPO_DIR / ".git").exists():
|
||||
print(f"Error: {MONOREPO_DIR} is not a git repository")
|
||||
return 1
|
||||
|
||||
print(f"Updating {MONOREPO_DIR}...")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(MONOREPO_DIR), "pull"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
print(f"Error: git pull timed out after 120 seconds for {MONOREPO_DIR}")
|
||||
return 1
|
||||
|
||||
if result.returncode == 0:
|
||||
print(result.stdout.strip())
|
||||
return 0
|
||||
else:
|
||||
print(f"Error: {result.stderr.strip()}")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+225
@@ -0,0 +1,225 @@
|
||||
#!/bin/bash
|
||||
# Pre-Testing WiFi Verification Script
|
||||
# Run this BEFORE disconnecting Ethernet to ensure WiFi is ready
|
||||
|
||||
# Don't use set -e as it can cause premature exits with arithmetic operations
|
||||
# Instead, we'll check return codes explicitly where needed
|
||||
set -u # Fail on undefined variables
|
||||
|
||||
echo "=========================================="
|
||||
echo "WiFi Pre-Testing Verification"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "This script verifies WiFi is enabled and working"
|
||||
echo "before you disconnect Ethernet for captive portal testing."
|
||||
echo ""
|
||||
|
||||
# Colors
|
||||
GREEN='\033[0;32m'
|
||||
RED='\033[0;31m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
# Check counter
|
||||
PASSED=0
|
||||
FAILED=0
|
||||
WARNINGS=0
|
||||
|
||||
check_result() {
|
||||
local result=$1
|
||||
local message=$2
|
||||
if [ $result -eq 0 ]; then
|
||||
echo -e "${GREEN}✓${NC} $message"
|
||||
PASSED=$((PASSED + 1))
|
||||
else
|
||||
echo -e "${RED}✗${NC} $message"
|
||||
FAILED=$((FAILED + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
warn_result() {
|
||||
local message=$2
|
||||
echo -e "${YELLOW}⚠${NC} $message"
|
||||
WARNINGS=$((WARNINGS + 1))
|
||||
}
|
||||
|
||||
# Check 1: WiFi interface exists
|
||||
echo "1. Checking WiFi interface..."
|
||||
if ip link show wlan0 > /dev/null 2>&1; then
|
||||
check_result 0 "WiFi interface wlan0 exists"
|
||||
else
|
||||
check_result 1 "WiFi interface wlan0 NOT found"
|
||||
echo " → Check if WiFi adapter is connected"
|
||||
echo " → Run: lsusb (for USB WiFi) or check built-in WiFi"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check 2: WiFi radio is enabled
|
||||
echo ""
|
||||
echo "2. Checking WiFi radio status..."
|
||||
WIFI_STATUS=$(nmcli radio wifi 2>/dev/null || echo "unknown")
|
||||
if echo "$WIFI_STATUS" | grep -qi "enabled"; then
|
||||
check_result 0 "WiFi radio is enabled"
|
||||
elif echo "$WIFI_STATUS" | grep -qi "disabled"; then
|
||||
check_result 1 "WiFi radio is DISABLED"
|
||||
echo " → Enabling WiFi..."
|
||||
sudo nmcli radio wifi on
|
||||
sleep 2
|
||||
if nmcli radio wifi | grep -qi "enabled"; then
|
||||
check_result 0 "WiFi radio enabled successfully"
|
||||
else
|
||||
check_result 1 "Failed to enable WiFi radio"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
warn_result 1 "Could not determine WiFi radio status"
|
||||
fi
|
||||
|
||||
# Check 3: WiFi can scan for networks
|
||||
echo ""
|
||||
echo "3. Testing WiFi scanning capability..."
|
||||
SCAN_RESULT=$(timeout 10 nmcli device wifi list 2>&1 | head -5)
|
||||
if [ $? -eq 0 ] && [ -n "$SCAN_RESULT" ]; then
|
||||
NETWORK_COUNT=$(echo "$SCAN_RESULT" | wc -l)
|
||||
if [ "$NETWORK_COUNT" -gt 1 ]; then
|
||||
check_result 0 "WiFi scanning works (found networks)"
|
||||
echo " Sample networks found:"
|
||||
echo "$SCAN_RESULT" | head -3 | sed 's/^/ /'
|
||||
else
|
||||
warn_result 1 "WiFi scanning works but no networks found"
|
||||
echo " → This might be okay if you're in a remote location"
|
||||
echo " → Make sure you can see networks when you need to connect"
|
||||
fi
|
||||
else
|
||||
check_result 1 "WiFi scanning FAILED"
|
||||
echo " → WiFi adapter may not be working properly"
|
||||
echo " → Check: dmesg | grep -i wifi"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check 4: Current network connections
|
||||
echo ""
|
||||
echo "4. Checking current network status..."
|
||||
ETH_STATUS=$(nmcli device status | grep "ethernet" | grep -v "unavailable" | head -1 || echo "")
|
||||
WIFI_STATUS=$(nmcli device status | grep "wifi" | head -1 || echo "")
|
||||
|
||||
if echo "$ETH_STATUS" | grep -q "connected"; then
|
||||
ETH_NAME=$(echo "$ETH_STATUS" | awk '{print $1}')
|
||||
ETH_IP=$(ip addr show $ETH_NAME 2>/dev/null | grep "inet " | awk '{print $2}' | cut -d/ -f1 | head -1)
|
||||
check_result 0 "Ethernet is connected ($ETH_NAME)"
|
||||
if [ -n "$ETH_IP" ]; then
|
||||
echo " Ethernet IP: $ETH_IP"
|
||||
fi
|
||||
else
|
||||
warn_result 1 "Ethernet is NOT connected"
|
||||
echo " → You may already be on WiFi only"
|
||||
fi
|
||||
|
||||
if echo "$WIFI_STATUS" | grep -q "connected"; then
|
||||
WIFI_NAME=$(echo "$WIFI_STATUS" | awk '{print $1}')
|
||||
WIFI_IP=$(ip addr show $WIFI_NAME 2>/dev/null | grep "inet " | awk '{print $2}' | cut -d/ -f1 | head -1)
|
||||
WIFI_SSID=$(nmcli -t -f active,ssid dev wifi | grep "^yes:" | cut -d: -f2 | head -1)
|
||||
check_result 0 "WiFi is connected ($WIFI_NAME)"
|
||||
if [ -n "$WIFI_SSID" ]; then
|
||||
echo " Connected to: $WIFI_SSID"
|
||||
fi
|
||||
if [ -n "$WIFI_IP" ]; then
|
||||
echo " WiFi IP: $WIFI_IP"
|
||||
fi
|
||||
echo ""
|
||||
echo " ⚠ You are already connected via WiFi!"
|
||||
echo " → You may want to disconnect WiFi first to test captive portal"
|
||||
echo " → Or test from a different device"
|
||||
else
|
||||
if echo "$WIFI_STATUS" | grep -q "disconnected"; then
|
||||
check_result 0 "WiFi is disconnected (ready for AP mode)"
|
||||
else
|
||||
warn_result 1 "WiFi status unclear"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check 5: Internet connectivity test
|
||||
echo ""
|
||||
echo "5. Testing internet connectivity..."
|
||||
if ping -c 2 -W 3 8.8.8.8 > /dev/null 2>&1; then
|
||||
check_result 0 "Internet connectivity working"
|
||||
echo " → You have internet access via current connection"
|
||||
else
|
||||
warn_result 1 "No internet connectivity detected"
|
||||
echo " → This might be okay if you're testing in isolation"
|
||||
echo " → But you won't be able to download packages if needed"
|
||||
fi
|
||||
|
||||
# Check 6: Saved WiFi connections
|
||||
echo ""
|
||||
echo "6. Checking saved WiFi connections..."
|
||||
SAVED_CONNECTIONS=$(nmcli connection show | grep -i wifi | wc -l)
|
||||
if [ "$SAVED_CONNECTIONS" -gt 0 ]; then
|
||||
check_result 0 "Found $SAVED_CONNECTIONS saved WiFi connection(s)"
|
||||
echo " Saved connections:"
|
||||
nmcli connection show | grep -i wifi | awk '{print " - " $1}' | head -5
|
||||
echo ""
|
||||
echo " → You can reconnect using: sudo nmcli connection up <name>"
|
||||
else
|
||||
warn_result 1 "No saved WiFi connections found"
|
||||
echo " → Make sure you know your WiFi SSID and password"
|
||||
echo " → You'll need them to reconnect after testing"
|
||||
fi
|
||||
|
||||
# Check 7: Required services
|
||||
echo ""
|
||||
echo "7. Checking required services..."
|
||||
if systemctl is-active --quiet hostapd 2>/dev/null; then
|
||||
warn_result 1 "hostapd is already running (AP mode may be active)"
|
||||
else
|
||||
check_result 0 "hostapd service is stopped (normal)"
|
||||
fi
|
||||
|
||||
if systemctl is-active --quiet dnsmasq 2>/dev/null; then
|
||||
warn_result 1 "dnsmasq is already running (AP mode may be active)"
|
||||
else
|
||||
check_result 0 "dnsmasq service is stopped (normal)"
|
||||
fi
|
||||
|
||||
# Check 8: WiFi monitor service
|
||||
echo ""
|
||||
echo "8. Checking WiFi monitor service..."
|
||||
if systemctl is-active --quiet ledmatrix-wifi-monitor 2>/dev/null; then
|
||||
check_result 0 "WiFi monitor service is running"
|
||||
else
|
||||
warn_result 1 "WiFi monitor service is NOT running"
|
||||
echo " → Start with: sudo systemctl start ledmatrix-wifi-monitor"
|
||||
fi
|
||||
|
||||
# Summary
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "Verification Summary"
|
||||
echo "=========================================="
|
||||
echo -e "${GREEN}Passed: ${PASSED}${NC}"
|
||||
echo -e "${YELLOW}Warnings: ${WARNINGS}${NC}"
|
||||
echo -e "${RED}Failed: ${FAILED}${NC}"
|
||||
echo ""
|
||||
|
||||
if [ $FAILED -eq 0 ]; then
|
||||
if [ $WARNINGS -eq 0 ]; then
|
||||
echo -e "${GREEN}✓ All checks passed! WiFi is ready for testing.${NC}"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo "1. You can safely disconnect Ethernet"
|
||||
echo "2. Enable AP mode to test captive portal"
|
||||
echo "3. Use emergency_reconnect.sh if you need to reconnect"
|
||||
else
|
||||
echo -e "${YELLOW}⚠ Checks passed with warnings.${NC}"
|
||||
echo ""
|
||||
echo "WiFi appears ready, but review warnings above."
|
||||
echo "You can proceed with testing, but be aware of the warnings."
|
||||
fi
|
||||
exit 0
|
||||
else
|
||||
echo -e "${RED}✗ Some checks failed. Please fix issues before testing.${NC}"
|
||||
echo ""
|
||||
echo "Do NOT disconnect Ethernet until all issues are resolved!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -34,7 +34,6 @@ from src.common.fetch_service import (
|
||||
plugin_scope,
|
||||
share_connection_pool,
|
||||
)
|
||||
from src.common.espn_payload import is_espn_scoreboard_url, slim_scoreboard_payload
|
||||
from src.common.espn_dates import (
|
||||
RANGE_RETRY_SECONDS,
|
||||
_note_range_rejected,
|
||||
@@ -43,7 +42,6 @@ from src.common.espn_dates import (
|
||||
fetch_espn_date_chunks,
|
||||
parse_espn_date_range,
|
||||
)
|
||||
from src.deprecation import deprecated
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -85,10 +83,6 @@ class FetchRequest:
|
||||
# the cache with the callbacks suppressed -- joiners waiting forever for a
|
||||
# fetch that did, in fact, succeed.
|
||||
commit_claimed: bool = False
|
||||
# Trim an ESPN scoreboard response before it is cached and delivered
|
||||
# (src/common/espn_payload.py). Set by whoever created the request; a
|
||||
# submitter that joins the fetch gets the same payload.
|
||||
slim_payload: bool = True
|
||||
result: Optional[Any] = None
|
||||
error: Optional[str] = None
|
||||
# The plugin that submitted the request, so the fetch service counts the
|
||||
@@ -255,8 +249,7 @@ class BackgroundDataService:
|
||||
timeout: Optional[int] = None,
|
||||
max_retries: int = 3,
|
||||
priority: int = 1,
|
||||
callback: Optional[Callable] = None,
|
||||
slim_payload: bool = True) -> str:
|
||||
callback: Optional[Callable] = None) -> str:
|
||||
"""
|
||||
Submit a background fetch request.
|
||||
|
||||
@@ -272,11 +265,6 @@ class BackgroundDataService:
|
||||
priority: Accepted for compatibility and ignored; requests run in
|
||||
submission order.
|
||||
callback: Optional callback function when request completes
|
||||
slim_payload: Drop the parts of an ESPN scoreboard response no
|
||||
scoreboard reads (stat leaders, athlete cards, links,
|
||||
headlines, highlights) before caching it; see
|
||||
src/common/espn_payload.py. Only ESPN /scoreboard URLs are
|
||||
touched. Pass False to cache the response whole.
|
||||
|
||||
Returns:
|
||||
Request ID for tracking the fetch operation
|
||||
@@ -348,7 +336,6 @@ class BackgroundDataService:
|
||||
priority=priority,
|
||||
callback=callback,
|
||||
owner=owner,
|
||||
slim_payload=slim_payload,
|
||||
)
|
||||
|
||||
with self._lock:
|
||||
@@ -510,13 +497,6 @@ class BackgroundDataService:
|
||||
)
|
||||
return result
|
||||
|
||||
# Most of an ESPN scoreboard response is never drawn, and the
|
||||
# cached copy stays parsed in the memory tier while it is fresh.
|
||||
# Trimmed before the write so the cache, request.result and the
|
||||
# callbacks all see the same payload. See src/common/espn_payload.py.
|
||||
if request.slim_payload and is_espn_scoreboard_url(request.url):
|
||||
slim_scoreboard_payload(data)
|
||||
|
||||
# Cache the data
|
||||
self.cache_manager.set(request.cache_key, data)
|
||||
|
||||
@@ -699,7 +679,6 @@ class BackgroundDataService:
|
||||
|
||||
raise last_exception
|
||||
|
||||
@deprecated("3.10.0", "pass callback= to submit_fetch_request()")
|
||||
def get_result(self, request_id: str) -> Optional[FetchResult]:
|
||||
"""
|
||||
Get the result of a fetch request.
|
||||
@@ -716,7 +695,6 @@ class BackgroundDataService:
|
||||
with self._lock:
|
||||
return self.completed_requests.get(request_id)
|
||||
|
||||
@deprecated("3.10.0", "pass callback= to submit_fetch_request()")
|
||||
def is_request_complete(self, request_id: str) -> bool:
|
||||
"""
|
||||
Check if a request has completed.
|
||||
@@ -733,7 +711,6 @@ class BackgroundDataService:
|
||||
with self._lock:
|
||||
return request_id in self.completed_requests
|
||||
|
||||
@deprecated("3.10.0", "pass callback= to submit_fetch_request()")
|
||||
def get_request_status(self, request_id: str) -> Optional[FetchStatus]:
|
||||
"""
|
||||
Get the status of a fetch request.
|
||||
|
||||
@@ -21,7 +21,6 @@ from typing import Dict, Any, Optional, List, cast
|
||||
from src.common.api_helper import DEFAULT_HTTP_HEADERS
|
||||
from src.common.fetch_service import fetch_get, share_connection_pool
|
||||
from src.common.json_body import response_json
|
||||
from src.deprecation import deprecated
|
||||
|
||||
|
||||
|
||||
@@ -278,7 +277,6 @@ class BaseOddsManager:
|
||||
self.logger.warning(f"Unexpected response structure: {json.dumps(data, indent=2)}")
|
||||
return None
|
||||
|
||||
@deprecated("3.10.0", "call get_odds() for each game")
|
||||
def get_odds_for_games(self, games: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Fetch odds for multiple games efficiently.
|
||||
@@ -337,7 +335,6 @@ class BaseOddsManager:
|
||||
|
||||
return False
|
||||
|
||||
@deprecated("3.10.0")
|
||||
def format_odds_summary(self, odds_data: Optional[Dict[str, Any]]) -> str:
|
||||
"""
|
||||
Format odds data into a human-readable summary.
|
||||
|
||||
Vendored
+1
@@ -5,5 +5,6 @@ Provides specialized cache components:
|
||||
- MemoryCache: In-memory caching
|
||||
- DiskCache: Persistent disk caching
|
||||
- CacheStrategy: Cache strategy management
|
||||
- CacheMetrics: Performance metrics tracking
|
||||
"""
|
||||
|
||||
|
||||
Vendored
+134
@@ -0,0 +1,134 @@
|
||||
"""
|
||||
Cache Metrics
|
||||
|
||||
Tracks cache performance metrics including hit rates, miss rates, and fetch times.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
import logging
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
|
||||
class CacheMetrics:
|
||||
"""Tracks cache performance metrics."""
|
||||
|
||||
def __init__(self, logger: Optional[logging.Logger] = None) -> None:
|
||||
"""
|
||||
Initialize cache metrics tracker.
|
||||
|
||||
Args:
|
||||
logger: Optional logger instance
|
||||
"""
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
self._lock = threading.Lock()
|
||||
self._metrics: Dict[str, Any] = {
|
||||
'hits': 0,
|
||||
'misses': 0,
|
||||
'api_calls_saved': 0,
|
||||
'background_hits': 0,
|
||||
'background_misses': 0,
|
||||
'total_fetch_time': 0.0,
|
||||
'fetch_count': 0,
|
||||
# Disk cleanup metrics
|
||||
'last_disk_cleanup': 0.0,
|
||||
'total_files_cleaned': 0,
|
||||
'total_space_freed_mb': 0.0,
|
||||
'last_cleanup_duration_sec': 0.0
|
||||
}
|
||||
|
||||
def record_hit(self, cache_type: str = 'regular') -> None:
|
||||
"""
|
||||
Record a cache hit.
|
||||
|
||||
Args:
|
||||
cache_type: Type of cache hit ('regular' or 'background')
|
||||
"""
|
||||
with self._lock:
|
||||
if cache_type == 'background':
|
||||
self._metrics['background_hits'] += 1
|
||||
else:
|
||||
self._metrics['hits'] += 1
|
||||
|
||||
def record_miss(self, cache_type: str = 'regular') -> None:
|
||||
"""
|
||||
Record a cache miss.
|
||||
|
||||
Args:
|
||||
cache_type: Type of cache miss ('regular' or 'background')
|
||||
"""
|
||||
with self._lock:
|
||||
if cache_type == 'background':
|
||||
self._metrics['background_misses'] += 1
|
||||
else:
|
||||
self._metrics['misses'] += 1
|
||||
self._metrics['api_calls_saved'] += 1
|
||||
|
||||
def record_fetch_time(self, duration: float) -> None:
|
||||
"""
|
||||
Record fetch operation duration.
|
||||
|
||||
Args:
|
||||
duration: Duration in seconds
|
||||
"""
|
||||
with self._lock:
|
||||
self._metrics['total_fetch_time'] += duration
|
||||
self._metrics['fetch_count'] += 1
|
||||
|
||||
def record_disk_cleanup(self, files_cleaned: int, space_freed_mb: float, duration_sec: float) -> None:
|
||||
"""
|
||||
Record disk cleanup operation results.
|
||||
|
||||
Args:
|
||||
files_cleaned: Number of files deleted
|
||||
space_freed_mb: Space freed in megabytes
|
||||
duration_sec: Duration of cleanup operation in seconds
|
||||
"""
|
||||
with self._lock:
|
||||
self._metrics['last_disk_cleanup'] = time.time()
|
||||
self._metrics['total_files_cleaned'] += files_cleaned
|
||||
self._metrics['total_space_freed_mb'] += space_freed_mb
|
||||
self._metrics['last_cleanup_duration_sec'] = duration_sec
|
||||
|
||||
def get_metrics(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get current cache performance metrics.
|
||||
|
||||
Returns:
|
||||
Dictionary with cache metrics
|
||||
"""
|
||||
with self._lock:
|
||||
total_hits = self._metrics['hits'] + self._metrics['background_hits']
|
||||
total_misses = self._metrics['misses'] + self._metrics['background_misses']
|
||||
total_requests = total_hits + total_misses
|
||||
|
||||
avg_fetch_time = (self._metrics['total_fetch_time'] /
|
||||
self._metrics['fetch_count']) if self._metrics['fetch_count'] > 0 else 0.0
|
||||
|
||||
return {
|
||||
'total_requests': total_requests,
|
||||
'cache_hit_rate': total_hits / total_requests if total_requests > 0 else 0.0,
|
||||
'background_hit_rate': (self._metrics['background_hits'] /
|
||||
(self._metrics['background_hits'] + self._metrics['background_misses'])
|
||||
if (self._metrics['background_hits'] + self._metrics['background_misses']) > 0 else 0.0),
|
||||
'api_calls_saved': self._metrics['api_calls_saved'],
|
||||
'average_fetch_time': avg_fetch_time,
|
||||
'total_fetch_time': self._metrics['total_fetch_time'],
|
||||
'fetch_count': self._metrics['fetch_count'],
|
||||
# Disk cleanup metrics
|
||||
'last_disk_cleanup': self._metrics['last_disk_cleanup'],
|
||||
'total_files_cleaned': self._metrics['total_files_cleaned'],
|
||||
'total_space_freed_mb': self._metrics['total_space_freed_mb'],
|
||||
'last_cleanup_duration_sec': self._metrics['last_cleanup_duration_sec']
|
||||
}
|
||||
|
||||
def log_metrics(self) -> None:
|
||||
"""Log current cache performance metrics."""
|
||||
metrics = self.get_metrics()
|
||||
self.logger.info("Cache Performance - Hit Rate: %.2f%%, Background Hit Rate: %.2f%%, "
|
||||
"API Calls Saved: %d, Avg Fetch Time: %.2fs",
|
||||
metrics['cache_hit_rate'] * 100,
|
||||
metrics['background_hit_rate'] * 100,
|
||||
metrics['api_calls_saved'],
|
||||
metrics['average_fetch_time'])
|
||||
|
||||
Vendored
+34
-7
@@ -4,6 +4,7 @@ Cache Strategy
|
||||
Manages cache strategies (TTLs) for different data types.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, Any, Optional
|
||||
from datetime import datetime
|
||||
import pytz
|
||||
@@ -12,25 +13,51 @@ import pytz
|
||||
class CacheStrategy:
|
||||
"""Manages cache strategies for different data types."""
|
||||
|
||||
def __init__(self, config_manager: Optional[Any] = None, logger: Optional[logging.Logger] = None) -> None:
|
||||
"""
|
||||
Initialize cache strategy manager.
|
||||
|
||||
Args:
|
||||
config_manager: Optional ConfigManager instance. Kept for callers
|
||||
that pass one; no strategy currently reads it.
|
||||
logger: Optional logger instance
|
||||
"""
|
||||
self.config_manager = config_manager
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
|
||||
def get_sport_live_interval(self, sport_key: str) -> int:
|
||||
"""
|
||||
Live-data cache interval, in seconds, for a sport: 60 for every sport.
|
||||
|
||||
This used to read ``live_update_interval`` from a ``<sport>_scoreboard``
|
||||
config section. Those sections belonged to the built-in scoreboards
|
||||
that the plugin system replaced; plugin config is keyed by plugin id
|
||||
(``football-scoreboard``), so the lookup always fell back to 60.
|
||||
|
||||
Args:
|
||||
sport_key: Sport identifier (e.g., 'nba', 'nfl')
|
||||
|
||||
Returns:
|
||||
Live update interval in seconds
|
||||
"""
|
||||
return 60
|
||||
|
||||
def get_cache_strategy(self, data_type: str, sport_key: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Get cache strategy for different data types.
|
||||
|
||||
Args:
|
||||
data_type: Type of data (e.g., 'live_scores', 'stocks', 'weather_current')
|
||||
sport_key: Optional sport key; for live data any sport key
|
||||
selects a 60s interval instead of the generic live default.
|
||||
(That used to be a per-sport ``live_update_interval`` from
|
||||
``<sport>_scoreboard`` config sections, which belonged to the
|
||||
built-in scoreboards the plugin system replaced, so every
|
||||
lookup fell back to 60.)
|
||||
sport_key: Optional sport key; for live data it selects the
|
||||
per-sport interval from :meth:`get_sport_live_interval`
|
||||
instead of the generic live default.
|
||||
|
||||
Returns:
|
||||
Dictionary with cache strategy (max_age, memory_ttl, etc.)
|
||||
"""
|
||||
live_interval = None
|
||||
if sport_key and data_type in ['sports_live', 'live_scores']:
|
||||
live_interval = 60
|
||||
live_interval = self.get_sport_live_interval(sport_key)
|
||||
|
||||
strategies = {
|
||||
# Ultra time-sensitive data (live scores, current weather)
|
||||
|
||||
Vendored
+20
-6
@@ -15,14 +15,11 @@ import tempfile
|
||||
import logging
|
||||
import threading
|
||||
import zlib
|
||||
from typing import TYPE_CHECKING, Dict, Any, Optional, Tuple
|
||||
from typing import Dict, Any, Optional, Protocol, Tuple
|
||||
from datetime import datetime
|
||||
|
||||
from src.common.path_safety import safe_path_component
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.cache.cache_strategy import CacheStrategy
|
||||
|
||||
try: # optional: large speedup on the cache write path, see _dumps below
|
||||
import orjson
|
||||
except ImportError: # pragma: no cover - exercised on hosts without the wheel
|
||||
@@ -65,6 +62,23 @@ def _filename_stem(key: str) -> str:
|
||||
return f"{prefix}-{digest}"
|
||||
|
||||
|
||||
|
||||
class CacheStrategyProtocol(Protocol):
|
||||
"""Protocol for cache strategy objects that categorize cache keys."""
|
||||
|
||||
def get_data_type_from_key(self, key: str) -> str:
|
||||
"""
|
||||
Determine the data type from a cache key.
|
||||
|
||||
Args:
|
||||
key: Cache key
|
||||
|
||||
Returns:
|
||||
Data type string for strategy lookup
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class DateTimeEncoder(json.JSONEncoder):
|
||||
"""JSON encoder that handles datetime objects.
|
||||
|
||||
@@ -802,12 +816,12 @@ class DiskCache:
|
||||
# mkstemp's random component.
|
||||
return bool(sep) and len(head) > 1 and bool(suffix)
|
||||
|
||||
def cleanup_expired_files(self, cache_strategy: 'CacheStrategy', retention_policies: Dict[str, int]) -> Dict[str, Any]:
|
||||
def cleanup_expired_files(self, cache_strategy: CacheStrategyProtocol, retention_policies: Dict[str, int]) -> Dict[str, Any]:
|
||||
"""
|
||||
Clean up expired cache files based on retention policies.
|
||||
|
||||
Args:
|
||||
cache_strategy: Categorizes files by key (get_data_type_from_key)
|
||||
cache_strategy: Object implementing CacheStrategyProtocol for categorizing files
|
||||
retention_policies: Dict mapping data types to retention days
|
||||
|
||||
Returns:
|
||||
|
||||
+66
-52
@@ -25,23 +25,24 @@ Typical plugin usage::
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
import pytz
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from typing import Any, Dict, List, Optional
|
||||
import logging
|
||||
import threading
|
||||
import tempfile
|
||||
from src.cache.memory_cache import MemoryCache, default_max_size
|
||||
from src.cache.disk_cache import DiskCache
|
||||
from src.cache.cache_strategy import CacheStrategy
|
||||
from src.cache.cache_metrics import CacheMetrics
|
||||
from src.logging_config import get_logger
|
||||
|
||||
# Canonical implementation lives in src.cache.disk_cache; re-exported here
|
||||
# because this module's docstring documents it and external code may import
|
||||
# it from either path.
|
||||
from src.cache.disk_cache import DateTimeEncoder # noqa: F401 - deliberate re-export
|
||||
from src.deprecation import deprecated
|
||||
|
||||
# CacheManager.config_manager not built yet (None means "not available").
|
||||
_UNSET: Any = object()
|
||||
@@ -72,41 +73,60 @@ def _outlived(record: Any, max_age: Optional[float], now: float) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
_NOT_SEEN: Any = object()
|
||||
#: Cache keys that were file "mailboxes" from the web interface (and some
|
||||
#: plugins) to the display. The control socket replaced them, and nothing
|
||||
#: reads them any more, so a write is refused rather than left on the SD card
|
||||
#: for nobody: see :func:`_refuse_retired_mailbox_write`.
|
||||
RETIRED_MAILBOX_KEYS = frozenset({'display_on_demand_request', 'plugin_error_clear_request'})
|
||||
|
||||
#: (key, writer) pairs already warned about, so a plugin that writes on every
|
||||
#: event logs once per process, not once per write.
|
||||
_retired_writers_warned: set = set()
|
||||
_retired_writers_lock = threading.Lock()
|
||||
|
||||
|
||||
class MailboxWatch:
|
||||
"""Tells the poller of a mailbox key whether its file changed since the
|
||||
last look, from one stat() (:meth:`CacheManager.file_signature`).
|
||||
def _retired_mailbox_writer(data: Any) -> str:
|
||||
"""Name whoever is writing a retired mailbox key, as well as can be told.
|
||||
|
||||
The display polls the mailboxes the web interface falls back to. Reading
|
||||
one is an open and a JSON parse; with this a poll that finds the same file
|
||||
(or none) costs a stat, and the file is read only after a new write. A
|
||||
cache without ``file_signature`` (a test double) is read every time.
|
||||
The plugin instance on the call stack when there is one (a ``self`` with
|
||||
a string ``plugin_id`` and a ``cache_manager``: what BasePlugin gives
|
||||
every plugin), else the ``plugin_id`` the request itself names, else
|
||||
``'unknown'``.
|
||||
"""
|
||||
frame = sys._getframe(2) # pylint: disable=protected-access
|
||||
depth = 0
|
||||
while frame is not None and depth < 25:
|
||||
owner = frame.f_locals.get('self')
|
||||
plugin_id = getattr(owner, 'plugin_id', None) if owner is not None else None
|
||||
if isinstance(plugin_id, str) and plugin_id and hasattr(owner, 'cache_manager'):
|
||||
return f"plugin '{plugin_id}'"
|
||||
frame = frame.f_back
|
||||
depth += 1
|
||||
payload = data.get('data', data) if isinstance(data, dict) else None
|
||||
named = payload.get('plugin_id') if isinstance(payload, dict) else None
|
||||
if isinstance(named, str) and named:
|
||||
return f"plugin '{named}' (named in the request)"
|
||||
return 'unknown'
|
||||
|
||||
def __init__(self, key: str):
|
||||
self.key = key
|
||||
self._seen: Any = _NOT_SEEN
|
||||
|
||||
def changed(self, cache_manager: Any) -> bool:
|
||||
"""True when the poller should read the key now."""
|
||||
signature = getattr(cache_manager, 'file_signature', None)
|
||||
sig = signature(self.key) if callable(signature) else _NOT_SEEN
|
||||
if sig is not None and not isinstance(sig, tuple):
|
||||
return True # cannot tell: read it
|
||||
if sig is None:
|
||||
self._seen = None
|
||||
return False # no file, nothing to read
|
||||
if sig == self._seen:
|
||||
return False
|
||||
self._seen = sig
|
||||
return True
|
||||
|
||||
def forget(self) -> None:
|
||||
"""Read the key on the next poll even if its file has not changed
|
||||
(the last read failed)."""
|
||||
self._seen = _NOT_SEEN
|
||||
def _refuse_retired_mailbox_write(logger: logging.Logger, key: str, data: Any) -> None:
|
||||
"""Warn, once per writer, that a write to a retired mailbox key was dropped."""
|
||||
try:
|
||||
writer = _retired_mailbox_writer(data)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
writer = 'unknown'
|
||||
with _retired_writers_lock:
|
||||
if (key, writer) in _retired_writers_warned:
|
||||
return
|
||||
_retired_writers_warned.add((key, writer))
|
||||
if key == 'display_on_demand_request':
|
||||
hint = ("call self.request_on_demand() / self.end_on_demand() instead "
|
||||
"(BasePlugin, LEDMatrix 3.8.1 and later)")
|
||||
else:
|
||||
hint = "clear errors through POST /api/v3/errors/clear instead"
|
||||
logger.warning("Ignored a write to the retired '%s' cache key by %s: the display no "
|
||||
"longer reads this file mailbox. Update it to %s. (Logged once per writer.)",
|
||||
key, writer, hint)
|
||||
|
||||
|
||||
class CacheManager:
|
||||
@@ -149,7 +169,10 @@ class CacheManager:
|
||||
max_size=default_max_size(), cleanup_interval=300.0
|
||||
)
|
||||
self._disk_cache_component = DiskCache(cache_dir=self.cache_dir, logger=self.logger)
|
||||
self._strategy_component = CacheStrategy()
|
||||
# No config manager: CacheStrategy keeps the parameter for callers but
|
||||
# reads nothing from it, and passing ours would build it eagerly.
|
||||
self._strategy_component = CacheStrategy(logger=self.logger)
|
||||
self._metrics_component = CacheMetrics(logger=self.logger)
|
||||
|
||||
# Disk cleanup configuration
|
||||
self._disk_cleanup_interval_hours = 24 # Run cleanup every 24 hours
|
||||
@@ -330,24 +353,6 @@ class CacheManager:
|
||||
"""Get the path for a cache file."""
|
||||
return self._disk_cache_component.get_cache_path(key)
|
||||
|
||||
def file_signature(self, key: str) -> Optional[Tuple[int, int, int]]:
|
||||
"""``(st_ino, st_mtime_ns, st_size)`` of ``key``'s file, or None when
|
||||
there is no file (the key is absent, or this cache has no disk tier).
|
||||
|
||||
One stat(), no read: a poller of a mailbox another process writes
|
||||
compares it with the last one it saw and reads the file only when it
|
||||
changed. Every write replaces the file (a temp file renamed into
|
||||
place), so a new write always has a new inode, however fast it came.
|
||||
"""
|
||||
path = self._get_cache_path(key)
|
||||
if not path:
|
||||
return None
|
||||
try:
|
||||
st = os.stat(path)
|
||||
except OSError:
|
||||
return None
|
||||
return (st.st_ino, st.st_mtime_ns, st.st_size)
|
||||
|
||||
def get_cached_data(self, key: str, max_age: int = 300, memory_ttl: Optional[int] = None) -> Optional[Dict[str, Any]]:
|
||||
"""Get data from cache (memory first, then disk) honoring TTLs.
|
||||
|
||||
@@ -385,6 +390,10 @@ class CacheManager:
|
||||
key: Cache key
|
||||
data: Data to cache
|
||||
"""
|
||||
if key in RETIRED_MAILBOX_KEYS:
|
||||
_refuse_retired_mailbox_write(self.logger, key, data)
|
||||
return
|
||||
|
||||
# Periodic cleanup before adding new entries
|
||||
self._cleanup_memory_cache()
|
||||
|
||||
@@ -395,7 +404,6 @@ class CacheManager:
|
||||
# caller gets as is.
|
||||
self._disk_cache_component.set(key, data)
|
||||
|
||||
@deprecated("3.10.0", "use get(key, max_age=3600)")
|
||||
def load_cache(self, key: str) -> Optional[Dict[str, Any]]:
|
||||
"""Load data from cache with memory caching."""
|
||||
# Check memory cache first (1 minute TTL)
|
||||
@@ -605,6 +613,13 @@ class CacheManager:
|
||||
duration = time.time() - start_time
|
||||
space_freed_mb = stats['space_freed_bytes'] / (1024 * 1024)
|
||||
|
||||
# Record metrics
|
||||
self._metrics_component.record_disk_cleanup(
|
||||
files_cleaned=stats['files_deleted'],
|
||||
space_freed_mb=space_freed_mb,
|
||||
duration_sec=duration
|
||||
)
|
||||
|
||||
# Log summary
|
||||
if stats['files_deleted'] > 0:
|
||||
self.logger.info(
|
||||
@@ -787,7 +802,6 @@ class CacheManager:
|
||||
data_type = self.get_data_type_from_key(key)
|
||||
return self.get_cached_data_with_strategy(key, data_type)
|
||||
|
||||
@deprecated("3.10.0")
|
||||
def generate_sport_cache_key(self, sport: str, date_str: Optional[str] = None) -> str:
|
||||
"""
|
||||
Centralized cache key generation for sports data.
|
||||
|
||||
@@ -28,7 +28,6 @@ Rules for the package:
|
||||
| [`api_helper`](#api_helper) | HTTP GET/POST with caching and rate limiting | Yes | — |
|
||||
| [`bdf_font`](#bdf_font) | Load and draw BDF bitmap fonts | Yes, if drawing BDF text directly | 3.5.0 |
|
||||
| [`espn_dates`](#espn_dates) | Fetch ESPN scoreboards across a date range | Yes (scoreboards) | 3.5.0 |
|
||||
| [`espn_payload`](#espn_payload) | Drop the parts of an ESPN scoreboard payload no scoreboard reads | No, core-internal (used by `BackgroundDataService`) | n/a |
|
||||
| [`favorite_team_check`](#favorite_team_check) | Log why a favourite team code shows nothing | Yes (scoreboards) | 3.6.0 |
|
||||
| [`fetch_service`](#fetch_service) | Pooled, merged, budgeted and counted HTTP for core fetch paths | No, core-internal (reached through `api_helper` and `espn_dates`) | n/a |
|
||||
| [`font_layout`](#font_layout) | Reproducible TrueType loading, crisp sizes | Yes | 3.4.0 |
|
||||
@@ -121,18 +120,6 @@ Every request goes through [`fetch_service`](#fetch_service), the chunks
|
||||
counted against the plugin that asked. Scoreboard plugins also bundle a copy
|
||||
for older cores.
|
||||
|
||||
### espn_payload
|
||||
|
||||
[`espn_payload.py`](espn_payload.py). Core-internal. ESPN scoreboard
|
||||
responses carry stat leaders, athlete cards, links, headlines and highlights
|
||||
that no scoreboard draws. `slim_scoreboard_payload(payload)` removes exactly
|
||||
those keys, in place, and leaves everything it does not know about alone;
|
||||
`is_espn_scoreboard_url(url)` says whether a URL is an ESPN site-API
|
||||
scoreboard. `BackgroundDataService` slims each scoreboard window before
|
||||
caching it, which cuts the five sports windows from ~40MB to ~12MB of parsed
|
||||
objects. Adding a key to the drop lists means first checking that nothing
|
||||
reads it.
|
||||
|
||||
### favorite_team_check
|
||||
|
||||
[`favorite_team_check.py`](favorite_team_check.py).
|
||||
|
||||
@@ -22,7 +22,6 @@ from typing import TYPE_CHECKING, Any, Dict, Mapping, Optional, cast
|
||||
|
||||
import requests
|
||||
from urllib3.util.retry import Retry
|
||||
from src.deprecation import deprecated
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# What Session() puts in .headers; the stubs only promise a MutableMapping.
|
||||
@@ -172,7 +171,6 @@ class APIHelper:
|
||||
self.logger.error(f"Request failed for {url}: {e}")
|
||||
return None
|
||||
|
||||
@deprecated("3.10.0", "use src.common.espn_dates.fetch_espn_scoreboard()")
|
||||
def fetch_espn_scoreboard(self, sport: str, league: str,
|
||||
date: Optional[str] = None,
|
||||
cache_key: Optional[str] = None,
|
||||
@@ -229,7 +227,6 @@ class APIHelper:
|
||||
store_espn_scoreboard_cache(self.cache_manager, shared_key, data)
|
||||
return data
|
||||
|
||||
@deprecated("3.10.0", "call get() with the ESPN URL")
|
||||
def fetch_espn_standings(self, sport: str, league: str,
|
||||
cache_key: Optional[str] = None,
|
||||
cache_ttl: int = 3600) -> Optional[Dict]:
|
||||
@@ -252,7 +249,6 @@ class APIHelper:
|
||||
|
||||
return self.get(url, cache_key=cache_key, cache_ttl=cache_ttl)
|
||||
|
||||
@deprecated("3.10.0", "call get() with the ESPN URL")
|
||||
def fetch_espn_rankings(self, sport: str, league: str,
|
||||
cache_key: Optional[str] = None,
|
||||
cache_ttl: int = 3600) -> Optional[Dict]:
|
||||
@@ -315,7 +311,6 @@ class APIHelper:
|
||||
self.logger.error(f"POST request failed for {url}: {e}")
|
||||
return None
|
||||
|
||||
@deprecated("3.10.0", "use the plugin's cache_manager")
|
||||
def set_cache(self, key: str, data: Any, ttl: int = 3600) -> None:
|
||||
"""
|
||||
Set cache data.
|
||||
@@ -328,7 +323,6 @@ class APIHelper:
|
||||
"""
|
||||
self._set_cache(key, data, ttl)
|
||||
|
||||
@deprecated("3.10.0", "use the plugin's cache_manager")
|
||||
def get_cache(self, key: str) -> Optional[Any]:
|
||||
"""
|
||||
Get cached data.
|
||||
@@ -398,7 +392,6 @@ class APIHelper:
|
||||
self._last_request_monotonic = time.monotonic()
|
||||
self._last_request_time = time.time()
|
||||
|
||||
@deprecated("3.10.0")
|
||||
def set_rate_limit(self, min_interval: float) -> None:
|
||||
"""
|
||||
Set minimum interval between requests.
|
||||
@@ -409,7 +402,6 @@ class APIHelper:
|
||||
self._min_request_interval = min_interval
|
||||
self.logger.debug(f"Rate limit set to {min_interval} seconds")
|
||||
|
||||
@deprecated("3.10.0")
|
||||
def get_request_stats(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get request statistics.
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
"""Drop the parts of an ESPN scoreboard payload no scoreboard reads.
|
||||
|
||||
The sports scoreboards cache their Recent/Upcoming window (14 days back, 7
|
||||
ahead) as the raw ESPN response, and that record stays parsed in the memory
|
||||
cache for as long as it is fresh. Most of it is never drawn. Measured on hdpi
|
||||
(2026-10-02) the MLB window was 3.35MB of JSON and 13.5MB of Python objects,
|
||||
and the five windows together ~40MB, mostly in:
|
||||
|
||||
* ``competitors[].leaders`` / ``competitions[].leaders`` -- per-team and
|
||||
per-game stat leaders (28% of the MLB window)
|
||||
* ``competitors[].team.links`` / ``event.links`` -- web and app URLs
|
||||
* ``status.featuredAthletes`` and ``competitors[].probables`` -- athlete
|
||||
cards with headshots and season stats
|
||||
* ``competitions[].headlines`` / ``highlights`` -- article and video blurbs
|
||||
(28% of the college-football window)
|
||||
* ``competitions[].geoBroadcasts``
|
||||
|
||||
None of those keys is read by core or by any plugin in ledmatrix-plugins
|
||||
(checked 2026-10-02 across every scoreboard, the odds ticker and the
|
||||
leaderboard), while everything that is read -- odds, records, linescores,
|
||||
situation, statistics, notes, broadcasts, venue -- is kept. Dropping them
|
||||
takes the five windows from ~40MB to ~12MB of parsed objects and the files from
|
||||
10.6MB to 3.0MB, so the reads that parse an expired window on the render
|
||||
thread get 3-4x cheaper too.
|
||||
|
||||
:func:`slim_scoreboard_payload` changes the payload in place, and only ever
|
||||
removes the keys listed here: anything it does not know about is left alone.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
# Per level of the payload, the keys removed. Kept deliberately explicit:
|
||||
# adding a key here means checking that nothing reads it first.
|
||||
_EVENT_DROP = ("links",)
|
||||
_COMPETITION_DROP = ("leaders", "headlines", "highlights", "geoBroadcasts")
|
||||
_STATUS_DROP = ("featuredAthletes",)
|
||||
_COMPETITOR_DROP = ("leaders", "probables")
|
||||
_TEAM_DROP = ("links",)
|
||||
|
||||
|
||||
def is_espn_scoreboard_url(url: Any) -> bool:
|
||||
"""Whether ``url`` is an ESPN site-API scoreboard endpoint."""
|
||||
if not isinstance(url, str):
|
||||
return False
|
||||
try:
|
||||
parts = urlsplit(url)
|
||||
except ValueError:
|
||||
return False
|
||||
host = (parts.hostname or "").lower()
|
||||
if host != "espn.com" and not host.endswith(".espn.com"):
|
||||
return False
|
||||
return parts.path.rstrip("/").endswith("/scoreboard")
|
||||
|
||||
|
||||
def _drop(obj: Any, keys) -> None:
|
||||
if isinstance(obj, dict):
|
||||
for key in keys:
|
||||
obj.pop(key, None)
|
||||
|
||||
|
||||
def slim_scoreboard_payload(payload: Any) -> Any:
|
||||
"""Remove the unread parts of an ESPN scoreboard payload, in place.
|
||||
|
||||
Returns ``payload`` for convenience. Anything that is not shaped like a
|
||||
scoreboard (not a dict, no ``events`` list, odd entries) is passed over
|
||||
untouched rather than raising.
|
||||
"""
|
||||
if not isinstance(payload, dict):
|
||||
return payload
|
||||
events = payload.get("events")
|
||||
if not isinstance(events, list):
|
||||
return payload
|
||||
for event in events:
|
||||
if not isinstance(event, dict):
|
||||
continue
|
||||
_drop(event, _EVENT_DROP)
|
||||
competitions = event.get("competitions")
|
||||
if not isinstance(competitions, list):
|
||||
continue
|
||||
for competition in competitions:
|
||||
if not isinstance(competition, dict):
|
||||
continue
|
||||
_drop(competition, _COMPETITION_DROP)
|
||||
_drop(competition.get("status"), _STATUS_DROP)
|
||||
competitors = competition.get("competitors")
|
||||
if not isinstance(competitors, list):
|
||||
continue
|
||||
for competitor in competitors:
|
||||
if not isinstance(competitor, dict):
|
||||
continue
|
||||
_drop(competitor, _COMPETITOR_DROP)
|
||||
_drop(competitor.get("team"), _TEAM_DROP)
|
||||
return payload
|
||||
|
||||
|
||||
__all__ = ["is_espn_scoreboard_url", "slim_scoreboard_payload"]
|
||||
@@ -141,6 +141,7 @@ class DisplaySyncManager:
|
||||
self._last_leader_frame_time: float = 0.0
|
||||
self._frame_lock = threading.Lock()
|
||||
self._leader_ip: Optional[str] = None
|
||||
self._on_new_cycle: Optional[Callable[[], None]] = None # called when leader starts new cycle
|
||||
self._on_scroll_image: Optional[Callable[[Image.Image], None]] = None # called with Image when received
|
||||
self._pending_scroll_image: Optional[Image.Image] = None # image received before callback set
|
||||
self._scroll_image_lock = threading.Lock() # guards _on_scroll_image / _pending_scroll_image
|
||||
@@ -411,6 +412,17 @@ class DisplaySyncManager:
|
||||
except Exception as exc:
|
||||
self.logger.debug("Sync: scroll_x send error: %s", exc)
|
||||
|
||||
def send_new_cycle(self) -> None:
|
||||
"""Leader: signal that a new scroll cycle has started so follower rebuilds its image."""
|
||||
if self.role != SyncRole.LEADER:
|
||||
return
|
||||
if self._leader_state != LeaderState.CONNECTED or not self._peer_ip:
|
||||
return
|
||||
try:
|
||||
self._send_sock.sendto(b'{"t":"nc"}', (self._peer_ip, self.port))
|
||||
except Exception as exc:
|
||||
self.logger.debug("Sync: new_cycle send error: %s", exc)
|
||||
|
||||
def send_frame(self, image: Image.Image) -> None:
|
||||
"""Leader: send a rendered frame to the follower as raw RGB bytes.
|
||||
Raw format is orders of magnitude faster than PNG on Pi hardware —
|
||||
@@ -494,19 +506,21 @@ class DisplaySyncManager:
|
||||
self._latest_frame = img
|
||||
self._enter_follower_mode(sender_ip)
|
||||
|
||||
def _enter_follower_mode(self, sender_ip: str) -> None:
|
||||
def _enter_follower_mode(self, sender_ip: str) -> bool:
|
||||
"""Note that the leader at ``sender_ip`` just sent something, and
|
||||
switch from standalone to follower mode if not already following."""
|
||||
switch from standalone to follower mode if not already following.
|
||||
Returns True if this call made the switch."""
|
||||
self._last_leader_frame_time = time.monotonic()
|
||||
self._leader_ip = sender_ip
|
||||
if self._follower_state != FollowerState.STANDALONE:
|
||||
return
|
||||
return False
|
||||
self._follower_state = FollowerState.FOLLOWER
|
||||
self.logger.info(
|
||||
"Sync: leader active at %s — switching to follower mode",
|
||||
sender_ip,
|
||||
)
|
||||
self.write_status_file()
|
||||
return True
|
||||
|
||||
def _follower_recv_loop(self) -> None:
|
||||
while self._running:
|
||||
@@ -556,9 +570,12 @@ class DisplaySyncManager:
|
||||
# frame. Read and validate its fields under a guard —
|
||||
# a UDP payload is attacker-shaped, so a non-object
|
||||
# body makes .get() raise AttributeError and an "sx"
|
||||
# carrying a non-numeric x raises ValueError/TypeError.
|
||||
# Any other "t" is ignored, including the "nc" (new
|
||||
# cycle) that older leaders send and no follower used.
|
||||
# carrying a non-numeric x raises ValueError/TypeError
|
||||
# — but dispatch the callback *outside* it. Running
|
||||
# the callback in here would let a fault in someone
|
||||
# else's code read as a malformed packet and be
|
||||
# logged as one.
|
||||
fire_new_cycle = False
|
||||
try:
|
||||
t = msg.get("t")
|
||||
if t == "hello_ack":
|
||||
@@ -584,11 +601,18 @@ class DisplaySyncManager:
|
||||
# back from. Treat it as malformed.
|
||||
raise ValueError(f"non-finite scroll x: {msg['x']!r}")
|
||||
self._latest_scroll_x = scroll_x
|
||||
self._enter_follower_mode(sender_ip)
|
||||
if self._enter_follower_mode(sender_ip):
|
||||
fire_new_cycle = True # build initial scroll image
|
||||
elif t == "nc":
|
||||
# Leader started a new scroll cycle — rebuild local image
|
||||
fire_new_cycle = True
|
||||
except (KeyError, AttributeError, TypeError, ValueError) as exc:
|
||||
self.logger.debug("Sync: malformed control message: %s", exc)
|
||||
continue
|
||||
|
||||
if fire_new_cycle and self._on_new_cycle:
|
||||
self._on_new_cycle()
|
||||
|
||||
except socket.timeout:
|
||||
continue
|
||||
except Exception as exc:
|
||||
@@ -655,6 +679,15 @@ class DisplaySyncManager:
|
||||
"""Follower: return the most recently received Vegas scroll position, or None."""
|
||||
return self._latest_scroll_x
|
||||
|
||||
def set_on_new_cycle(self, callback: Callable[[], None]) -> None:
|
||||
"""Follower: register a callback fired when the leader starts a new scroll cycle.
|
||||
|
||||
Nothing in core registers one: display_controller follows the leader
|
||||
through set_on_scroll_image() and the scroll position instead of
|
||||
rebuilding locally. The hook stays for callers that want the signal.
|
||||
"""
|
||||
self._on_new_cycle = callback
|
||||
|
||||
def get_latest_frame(self) -> Optional[Image.Image]:
|
||||
"""Follower: return the most recently received pixel frame (non-Vegas fallback)."""
|
||||
with self._frame_lock:
|
||||
|
||||
@@ -45,7 +45,6 @@ from src.common.permission_utils import (
|
||||
ensure_shared_group_ownership,
|
||||
get_config_dir_mode
|
||||
)
|
||||
from src.deprecation import deprecated
|
||||
|
||||
|
||||
def _private_copy(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
@@ -175,7 +174,6 @@ class ConfigManager:
|
||||
|
||||
return result
|
||||
|
||||
@deprecated("3.10.0", "backups are handled by src.backup_manager")
|
||||
def rollback_config(self, backup_version: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Rollback configuration to a previous backup.
|
||||
@@ -200,7 +198,6 @@ class ConfigManager:
|
||||
|
||||
return success
|
||||
|
||||
@deprecated("3.10.0", "backups are handled by src.backup_manager")
|
||||
def list_backups(self) -> List[BackupInfo]:
|
||||
"""
|
||||
List all available configuration backups.
|
||||
@@ -211,7 +208,6 @@ class ConfigManager:
|
||||
atomic_mgr = self._get_atomic_manager()
|
||||
return atomic_mgr.list_backups()
|
||||
|
||||
@deprecated("3.10.0")
|
||||
def validate_config_file(self, config_path: Optional[str] = None) -> ValidationResult:
|
||||
"""
|
||||
Validate a configuration file.
|
||||
@@ -408,7 +404,6 @@ class ConfigManager:
|
||||
self.logger.error(error_msg, exc_info=True)
|
||||
raise ConfigError(error_msg, config_path=self.config_path) from e
|
||||
|
||||
@deprecated("3.10.0", "secrets are merged into each plugin's config; read them with config.get()")
|
||||
def get_secret(self, key: str) -> Optional[Any]:
|
||||
"""Get a secret value by key."""
|
||||
try:
|
||||
@@ -762,7 +757,6 @@ class ConfigManager:
|
||||
self.logger.error(error_msg, exc_info=True)
|
||||
raise ConfigError(error_msg, config_path=self.config_path, field=plugin_id) from e
|
||||
|
||||
@deprecated("3.10.0")
|
||||
def cleanup_orphaned_plugin_configs(self, valid_plugin_ids: List[str]) -> List[str]:
|
||||
"""
|
||||
Remove configuration sections for plugins that are no longer installed.
|
||||
@@ -816,7 +810,6 @@ class ConfigManager:
|
||||
self.logger.error(f"Error cleaning up orphaned plugin configs: {e}")
|
||||
return removed
|
||||
|
||||
@deprecated("3.10.0")
|
||||
def validate_all_plugin_configs(self, plugin_schema_manager=None) -> Dict[str, Dict[str, Any]]:
|
||||
"""
|
||||
Validate all plugin configurations against their schemas.
|
||||
|
||||
+50
-193
@@ -25,6 +25,7 @@ import os
|
||||
import inspect
|
||||
import signal
|
||||
import json
|
||||
import math
|
||||
import threading
|
||||
import types
|
||||
from collections import deque
|
||||
@@ -48,7 +49,7 @@ from src.screen_runner import (
|
||||
from src.display_manager import DisplayManager
|
||||
from src.config_manager import ConfigManager
|
||||
from src.config_service import ConfigService
|
||||
from src.cache_manager import CacheManager, MailboxWatch
|
||||
from src.cache_manager import CacheManager
|
||||
from src.font_manager import FontManager
|
||||
from src.logging_config import get_logger
|
||||
from src.exceptions import PluginError
|
||||
@@ -63,16 +64,11 @@ from src.ipc.contract import (
|
||||
PluginReloadResult,
|
||||
)
|
||||
from src.ipc.server import ControlServer, QueuedCommand, StateHub, start_control_server
|
||||
from src.plugin_system.base_plugin import finite_seconds
|
||||
from src.vegas_mode.render_pipeline import SYNC_SEND_INTERVAL
|
||||
|
||||
# Get logger with consistent configuration
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# The on-demand file mailbox: the fallback for a web interface that cannot
|
||||
# reach the control socket, and how some plugins still ask for the screen.
|
||||
ON_DEMAND_MAILBOX_KEY = 'display_on_demand_request'
|
||||
|
||||
# How often the unchanged current mode is republished for the web UI, which
|
||||
# treats display_current_state older than 120 s as unknown.
|
||||
CURRENT_STATE_REFRESH_SECONDS = 30
|
||||
@@ -101,6 +97,19 @@ _MIN_INITIAL_UPDATE_TIMEOUT_SECONDS = 2.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:
|
||||
"""A ``plugin.reload`` whose slow half runs off the render thread.
|
||||
|
||||
@@ -412,19 +421,15 @@ class DisplayController:
|
||||
# coordinator exists (Vegas was off at startup). The render thread
|
||||
# creates it in _is_vegas_mode_active(), never the watcher thread.
|
||||
self._pending_vegas_init = False
|
||||
# Monotonic stamp of the last mailbox disk read; see
|
||||
# _poll_on_demand_requests. None means "never polled", so the first
|
||||
# call always goes through.
|
||||
self._last_on_demand_poll: Optional[float] = None
|
||||
# Monotonic stamp of the last _service_pending_changes pass; same
|
||||
# "None means never" convention as _last_on_demand_poll.
|
||||
# Monotonic stamp of the last _service_pending_changes pass. None
|
||||
# means "never", so the first call always goes through.
|
||||
self._last_pending_service: Optional[float] = None
|
||||
# Monotonic stamp of the last scheduled-update pass; see
|
||||
# _tick_plugin_updates_if_due. Same "None means never" convention.
|
||||
self._last_plugin_update_tick: Optional[float] = None
|
||||
# The control socket (src/ipc), started by run(). None when it is not
|
||||
# served (Windows, LEDMATRIX_CONTROL_SOCKET=off, a bind failure);
|
||||
# the file mailbox works either way.
|
||||
# then only plugins in this process can start on-demand sessions.
|
||||
self._control_server = None
|
||||
# A brightness set_brightness() refused, so the periodic service pass
|
||||
# doesn't retry (and log) the same failure several times a second.
|
||||
@@ -1556,7 +1561,7 @@ class DisplayController:
|
||||
except Exception as err: # pylint: disable=broad-except
|
||||
problem = f"get_display_duration() raised {type(err).__name__}: {err}"
|
||||
else:
|
||||
seconds = finite_seconds(value)
|
||||
seconds = _finite_seconds(value)
|
||||
if seconds is not None:
|
||||
return seconds
|
||||
problem = f"display duration {value!r} is not a number"
|
||||
@@ -1786,6 +1791,9 @@ class DisplayController:
|
||||
'status': self.on_demand_status,
|
||||
'error': self.on_demand_last_error,
|
||||
'last_event': self.on_demand_last_event,
|
||||
# The request this state answers: lets the web interface tell
|
||||
# the outcome of a start it delivered from an older state.
|
||||
'request_id': self.on_demand_request_id,
|
||||
'remaining': self._get_on_demand_remaining(),
|
||||
'last_updated': time.time()
|
||||
}
|
||||
@@ -1854,34 +1862,16 @@ class DisplayController:
|
||||
self.force_change = True
|
||||
self._publish_on_demand_state()
|
||||
|
||||
#: Shortest gap between mailbox disk reads. This is called after every
|
||||
#: frame -- about 125 times a second on a scrolling mode -- and the read
|
||||
#: below is deliberately uncached, so without a floor it was 125 disk reads
|
||||
#: per second to find nothing. An on-demand request comes from a person
|
||||
#: clicking in the web UI, so a quarter second of latency is not
|
||||
#: perceptible, and it cuts the read rate by 30x.
|
||||
ON_DEMAND_POLL_INTERVAL = 0.25
|
||||
|
||||
#: The mailbox poll while the control socket is up. The web interface then
|
||||
#: writes the mailbox only when it could not reach the socket (a display
|
||||
#: being restarted, a web user not yet in the socket's group), and the
|
||||
#: plugins that still write it get the screen within this long. A look is
|
||||
#: one stat() of the mailbox file (MailboxWatch).
|
||||
MAILBOX_POLL_INTERVAL_WITH_SOCKET = 1.0
|
||||
|
||||
#: Shortest gap between _service_pending_changes passes. The same floor
|
||||
#: the mailbox poll had before the socket, since that read was the only
|
||||
#: real cost in the pass: the schedule checks are gated to once per clock
|
||||
#: minute and the rest is attribute compares. Callers run at frame rate,
|
||||
#: Shortest gap between _service_pending_changes passes. The schedule
|
||||
#: checks are gated to once per clock minute and the rest is attribute
|
||||
#: compares. Callers run at frame rate,
|
||||
#: so between passes the whole cost is one monotonic-clock compare.
|
||||
PENDING_CHANGES_INTERVAL = 0.25
|
||||
|
||||
#: Class-level defaults for controllers built without __init__ (tests).
|
||||
_control_server: Optional[ControlServer] = None
|
||||
#: Created on the first poll; see _poll_on_demand_requests.
|
||||
_on_demand_mailbox: Optional[MailboxWatch] = None
|
||||
#: Writers whose mailbox requests have been logged (_note_mailbox_request).
|
||||
_mailbox_writers_logged: FrozenSet[str] = frozenset()
|
||||
#: The last on-demand request handled; published in _on_demand_state.
|
||||
on_demand_request_id: Optional[str] = None
|
||||
#: Most plugin on-demand requests waiting for the render thread at once.
|
||||
#: A plugin that asks faster than the display drains (four times a
|
||||
#: second at worst) is refused, not queued without end.
|
||||
@@ -2024,44 +2014,11 @@ class DisplayController:
|
||||
on_demand_plugin_id, len(enabled_plugins))
|
||||
return enabled_plugins
|
||||
|
||||
def _consume_on_demand_request(self, request_id: str) -> None:
|
||||
"""Remove the request we just handled from the mailbox.
|
||||
|
||||
Leaving it on disk meant a restart replayed the previous request: the
|
||||
fresh controller read it, activated it and cached it, so the request
|
||||
the caller had just made was ignored and the panel silently showed the
|
||||
earlier plugin.
|
||||
|
||||
Compare before deleting. The web process can post a newer request
|
||||
between the read and this delete; an unconditional delete threw that
|
||||
one away and it was never processed -- the user's second click did
|
||||
nothing. Re-reading uncached and only deleting our own request_id
|
||||
leaves a newer request in the mailbox for the next poll instead.
|
||||
|
||||
This narrows the window rather than closing it: a request landing
|
||||
between the re-read and the delete is still lost. Closing it properly
|
||||
needs an atomic claim (a rename, or a compare-and-delete primitive)
|
||||
that the cache layer does not currently offer, so the honest fix is a
|
||||
smaller window plus this note, not a bigger lock. For start requests
|
||||
processed_id still guards against reprocessing if the delete fails.
|
||||
"""
|
||||
try:
|
||||
current = self.cache_manager.get(ON_DEMAND_MAILBOX_KEY,
|
||||
max_age=3600, memory_ttl=0)
|
||||
if not current or current.get('request_id') == request_id:
|
||||
self.cache_manager.delete(ON_DEMAND_MAILBOX_KEY)
|
||||
else:
|
||||
logger.debug("Newer on-demand request %s arrived while processing "
|
||||
"%s; leaving it in the mailbox",
|
||||
current.get('request_id'), request_id)
|
||||
except (OSError, AttributeError, KeyError) as err:
|
||||
logger.debug("Could not clear the on-demand request mailbox: %s", err)
|
||||
|
||||
def _start_control_server(self) -> None:
|
||||
"""Serve the control socket (src/ipc/server.py). Never raises.
|
||||
|
||||
Its handlers only queue commands; _drain_control_commands applies
|
||||
them on the render thread, where the mailbox is read.
|
||||
them on the render thread.
|
||||
"""
|
||||
if self._control_server is not None:
|
||||
return
|
||||
@@ -2074,7 +2031,8 @@ class DisplayController:
|
||||
state_hub=hub,
|
||||
handlers={ControlCommand.ERRORS_CLEAR: apply_error_clear})
|
||||
except Exception: # pylint: disable=broad-except
|
||||
logger.exception("Control socket not started; using the file mailbox only")
|
||||
logger.exception("Control socket not started; the web interface cannot "
|
||||
"send this display commands")
|
||||
if self._control_server is not None:
|
||||
self._start_state_stream(hub)
|
||||
|
||||
@@ -2102,10 +2060,8 @@ class DisplayController:
|
||||
def _drain_control_commands(self) -> None:
|
||||
"""Apply the commands that arrived over the control socket.
|
||||
|
||||
On-demand commands go through _handle_on_demand_request, the
|
||||
mailbox's own handler, so both ways in behave the same, and a
|
||||
request that came both ways (a client that timed out and fell back)
|
||||
has one request id and is processed once. A brightness is applied
|
||||
On-demand commands go through _handle_on_demand_request, as plugins'
|
||||
own requests do, so both ways in behave the same. A brightness is applied
|
||||
here. A plugin reload waits for the top of the next loop pass, where
|
||||
no plugin is on the stack (_apply_pending_plugin_reloads); until
|
||||
then the current screen ends early (_plugin_reload_pending).
|
||||
@@ -2138,7 +2094,7 @@ class DisplayController:
|
||||
|
||||
``PluginManager.request_on_demand`` / ``end_on_demand`` (which
|
||||
BasePlugin's methods of the same names call) build ``request``: the
|
||||
mailbox's shape, with ``source: 'plugin'`` and the asking plugin's
|
||||
on-demand request shape, with ``source: 'plugin'`` and the asking plugin's
|
||||
id. Nothing here touches the panel or the on-demand state; the render
|
||||
thread applies the request where it applies a socket command
|
||||
(_drain_control_commands), through _handle_on_demand_request, and is
|
||||
@@ -2433,105 +2389,35 @@ class DisplayController:
|
||||
}
|
||||
command.succeed(dict(result))
|
||||
|
||||
def _mailbox_poll_interval(self) -> float:
|
||||
"""How often the on-demand mailbox is looked at: its old 0.25 s when
|
||||
it is the only way in, MAILBOX_POLL_INTERVAL_WITH_SOCKET while the
|
||||
control socket carries the web interface's commands."""
|
||||
if self._control_server is not None:
|
||||
return self.MAILBOX_POLL_INTERVAL_WITH_SOCKET
|
||||
return self.ON_DEMAND_POLL_INTERVAL
|
||||
|
||||
def _poll_on_demand_requests(self) -> None:
|
||||
"""Apply on-demand requests: the control socket's, then the mailbox's.
|
||||
"""Apply on-demand requests: the control socket's, then plugins' own.
|
||||
|
||||
Socket commands are in memory and are applied at once. The file
|
||||
mailbox (``display_on_demand_request``) is the fallback for a web
|
||||
interface that could not reach the socket, and the way four plugins
|
||||
still ask for the screen. It is looked at once per poll interval
|
||||
(_mailbox_poll_interval), and read only when its file changed
|
||||
(MailboxWatch): a look that finds nothing new is one stat().
|
||||
Both are in memory (no disk read), so this has no floor and is
|
||||
cheap to call every frame. The file mailbox
|
||||
(``display_on_demand_request``) is gone: nothing reads it, and a
|
||||
write to it is dropped with a warning (CacheManager).
|
||||
"""
|
||||
# Socket commands are already in memory: no disk read, so no floor.
|
||||
self._drain_control_commands()
|
||||
now = time.monotonic()
|
||||
if (self._last_on_demand_poll is not None
|
||||
and now - self._last_on_demand_poll < self._mailbox_poll_interval()):
|
||||
return
|
||||
self._last_on_demand_poll = now
|
||||
|
||||
watch = self._on_demand_mailbox
|
||||
if watch is None:
|
||||
watch = self._on_demand_mailbox = MailboxWatch(ON_DEMAND_MAILBOX_KEY)
|
||||
if not watch.changed(self.cache_manager):
|
||||
return
|
||||
|
||||
try:
|
||||
# Use a long max_age (1 hour) to ensure requests aren't expired before processing
|
||||
# The request_id check prevents duplicate processing.
|
||||
#
|
||||
# memory_ttl=0 is required, not optional: this key is a mailbox the
|
||||
# web process writes and this process reads. get() defaults the
|
||||
# in-memory TTL to max_age, so without it the first request read was
|
||||
# pinned in memory for the full hour and every later poll returned
|
||||
# that stale copy -- meaning no second on-demand request was honoured
|
||||
# for an hour, while the API still reported success.
|
||||
request = self.cache_manager.get(ON_DEMAND_MAILBOX_KEY,
|
||||
max_age=3600, memory_ttl=0)
|
||||
except (OSError, RuntimeError, ValueError, TypeError) as err:
|
||||
watch.forget() # read it again next time
|
||||
logger.error("Failed to read on-demand request: %s", err, exc_info=True)
|
||||
return
|
||||
|
||||
if not isinstance(request, dict):
|
||||
return
|
||||
self._note_mailbox_request(request)
|
||||
self._handle_on_demand_request(request)
|
||||
|
||||
def _note_mailbox_request(self, request: Dict[str, Any]) -> None:
|
||||
"""Log, once per writer, an on-demand request that came through the
|
||||
mailbox while the control socket is up.
|
||||
|
||||
The web interface writes the mailbox only when the socket fails, so
|
||||
this is mostly a plugin that writes ``display_on_demand_request``
|
||||
itself. The mailbox is going away; the log says who still uses it.
|
||||
"""
|
||||
if self._control_server is None:
|
||||
return
|
||||
writer = request.get('plugin_id') or request.get('mode') or 'unknown'
|
||||
if not isinstance(writer, str):
|
||||
writer = 'unknown'
|
||||
if writer in self._mailbox_writers_logged:
|
||||
return
|
||||
self._mailbox_writers_logged = self._mailbox_writers_logged | {writer}
|
||||
logger.info("On-demand %s request %s (for %s) came through the file mailbox "
|
||||
"although the control socket is up. The mailbox is deprecated: it "
|
||||
"is read every %.1fs and will be removed in a future release.",
|
||||
request.get('action'), request.get('request_id'), writer,
|
||||
self.MAILBOX_POLL_INTERVAL_WITH_SOCKET)
|
||||
|
||||
def _handle_on_demand_request(self, request: Dict[str, Any]) -> None:
|
||||
"""Process one on-demand request, from the mailbox or the control socket.
|
||||
"""Process one on-demand request, from the control socket or a plugin.
|
||||
|
||||
A socket command carries ``source: 'socket'``, and a plugin's own
|
||||
request (submit_plugin_on_demand) ``source: 'plugin'``. Only a
|
||||
mailbox request is removed from the mailbox afterwards: the others
|
||||
never put anything there, so that would be a disk read and maybe a
|
||||
delete for nothing.
|
||||
request (submit_plugin_on_demand) ``source: 'plugin'``.
|
||||
|
||||
A plugin's stop ends only that plugin's own session: a plugin
|
||||
releasing the screen must not end one the user started for
|
||||
another plugin. (A stop through the mailbox ends any session, as it
|
||||
always has.)
|
||||
another plugin. A stop from the socket (the web interface) ends any
|
||||
session.
|
||||
"""
|
||||
request_id = request.get('request_id')
|
||||
if not request_id:
|
||||
return
|
||||
source = request.get('source')
|
||||
from_mailbox = source not in ('socket', 'plugin')
|
||||
|
||||
action = request.get('action')
|
||||
|
||||
# For stop requests, always process them (don't check processed_id)
|
||||
# For stop requests, always process them (no request-id guard)
|
||||
# This allows stopping even if the same stop request was sent before
|
||||
if action == 'stop':
|
||||
if source == 'plugin' and not (
|
||||
@@ -2557,42 +2443,22 @@ class DisplayController:
|
||||
# without this the status route kept reporting it until
|
||||
# the state aged out (120s) or another request came in.
|
||||
self._clear_on_demand(reason='requested-stop')
|
||||
# Stop requests are deliberately exempt from the request_id/
|
||||
# processed_id guards above, so that a second click stops a mode
|
||||
# that a race left running. Consuming the mailbox is therefore the
|
||||
# only thing that ends the request: without it the same stop was
|
||||
# re-read and re-processed on every poll, forever, logging at
|
||||
# ON_DEMAND_POLL_INTERVAL for the life of the process.
|
||||
if from_mailbox:
|
||||
self._consume_on_demand_request(request_id)
|
||||
# Stop requests are deliberately exempt from the request_id
|
||||
# guard below, so that a second click stops a mode that a race
|
||||
# left running.
|
||||
return
|
||||
|
||||
# For start requests, check if already processed. A duplicate in the
|
||||
# mailbox (a copy of a socket command, or one read before a restart)
|
||||
# is taken out of it too, so it is not read again.
|
||||
# A start already processed (a client that sent the same request
|
||||
# id twice) is not applied a second time.
|
||||
if request_id == self.on_demand_request_id:
|
||||
logger.debug("On-demand start request %s already processed (instance check)", request_id)
|
||||
if from_mailbox:
|
||||
self._consume_on_demand_request(request_id)
|
||||
return
|
||||
|
||||
# Also check persistent processed_id (for restart scenarios)
|
||||
processed_request_id = self.cache_manager.get('display_on_demand_processed_id', max_age=3600)
|
||||
if request_id == processed_request_id:
|
||||
logger.debug("On-demand start request %s already processed (persisted check)", request_id)
|
||||
if from_mailbox:
|
||||
self._consume_on_demand_request(request_id)
|
||||
logger.debug("On-demand start request %s already processed", request_id)
|
||||
return
|
||||
|
||||
logger.info("Received on-demand request %s: %s (plugin_id=%s, mode=%s, via %s)",
|
||||
request_id, action, request.get('plugin_id'), request.get('mode'),
|
||||
'mailbox' if from_mailbox else source)
|
||||
source or 'unknown')
|
||||
|
||||
# Mark as processed BEFORE processing (to prevent duplicate processing)
|
||||
self.cache_manager.set('display_on_demand_processed_id', request_id, ttl=3600)
|
||||
self.on_demand_request_id = request_id
|
||||
if from_mailbox:
|
||||
self._consume_on_demand_request(request_id)
|
||||
|
||||
if action == 'start':
|
||||
logger.info("Processing on-demand start request for plugin: %s", request.get('plugin_id'))
|
||||
@@ -4611,15 +4477,6 @@ class DisplayController:
|
||||
display_modes = [plugin_id]
|
||||
with self._plugin_modes_lock:
|
||||
self.plugin_display_modes[plugin_id] = list(display_modes)
|
||||
# Into the runtime snapshot the web interface reads, so its mode
|
||||
# lists and on-demand lookups see computed modes too (#668).
|
||||
state_manager = getattr(self.plugin_manager, 'state_manager', None)
|
||||
record_modes = getattr(state_manager, 'record_modes', None)
|
||||
if callable(record_modes):
|
||||
try:
|
||||
record_modes(plugin_id, list(display_modes))
|
||||
except Exception as e: # reporting must never break registration
|
||||
logger.debug("Could not record display modes for %s: %s", plugin_id, e)
|
||||
|
||||
# Subscribe to config changes for per-plugin hot-reload. Bind plugin_id
|
||||
# and instance as defaults so each plugin's callback targets its own
|
||||
|
||||
@@ -24,7 +24,6 @@ from typing import Any, Dict, List
|
||||
|
||||
from src.common.api_helper import DEFAULT_HTTP_HEADERS
|
||||
from src.common.json_body import response_json
|
||||
from src.deprecation import deprecated
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -202,7 +201,6 @@ class DynamicTeamResolver:
|
||||
DynamicTeamResolver._failure_timestamp = current_time
|
||||
return {}
|
||||
|
||||
@deprecated("3.10.0", "use resolve_teams()")
|
||||
def get_available_dynamic_teams(self) -> List[str]:
|
||||
"""
|
||||
Get list of available dynamic team names.
|
||||
@@ -212,7 +210,6 @@ class DynamicTeamResolver:
|
||||
"""
|
||||
return list(self.DYNAMIC_PATTERNS.keys())
|
||||
|
||||
@deprecated("3.10.0", "use resolve_teams()")
|
||||
def is_dynamic_team(self, team_name: str) -> bool:
|
||||
"""
|
||||
Check if a team name is a dynamic team.
|
||||
|
||||
+94
-177
@@ -19,7 +19,7 @@ import uuid
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional, Any, Callable, Tuple
|
||||
from typing import Dict, List, Optional, Any, Callable
|
||||
import logging
|
||||
|
||||
from src.exceptions import LEDMatrixError
|
||||
@@ -123,7 +123,8 @@ class ErrorAggregator:
|
||||
self._error_counts: Dict[str, int] = defaultdict(int)
|
||||
self._plugin_error_counts: Dict[str, Dict[str, int]] = defaultdict(lambda: defaultdict(int))
|
||||
self._patterns: Dict[str, ErrorPattern] = {}
|
||||
self._lock = threading.RLock() # RLock: build_snapshot re-enters
|
||||
self._pattern_callbacks: List[Callable[[ErrorPattern], None]] = []
|
||||
self._lock = threading.RLock() # RLock: build_snapshot and pattern callbacks re-enter
|
||||
|
||||
# Track session start for relative timing
|
||||
self._session_start = datetime.now()
|
||||
@@ -237,6 +238,13 @@ class ErrorAggregator:
|
||||
f"{count} times in last {self.pattern_window}. "
|
||||
f"Affected plugins: {set(affected_plugins) or 'unknown'}"
|
||||
)
|
||||
|
||||
# Notify callbacks
|
||||
for callback in self._pattern_callbacks:
|
||||
try:
|
||||
callback(pattern)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Pattern callback failed: {e}")
|
||||
else:
|
||||
# Update existing pattern
|
||||
self._patterns[pattern_key].count = count
|
||||
@@ -245,6 +253,15 @@ class ErrorAggregator:
|
||||
known = self._patterns[pattern_key].affected_plugins
|
||||
known.extend(p for p in affected_plugins if p not in known)
|
||||
|
||||
def on_pattern_detected(self, callback: Callable[[ErrorPattern], None]) -> None:
|
||||
"""
|
||||
Register a callback to be called when a new error pattern is detected.
|
||||
|
||||
Args:
|
||||
callback: Function that takes an ErrorPattern as argument
|
||||
"""
|
||||
self._pattern_callbacks.append(callback)
|
||||
|
||||
def get_error_summary(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get summary of all errors for reporting.
|
||||
@@ -308,6 +325,27 @@ class ErrorAggregator:
|
||||
"last_error": recent_plugin_errors[-1].to_dict() if recent_plugin_errors else None
|
||||
}
|
||||
|
||||
def clear_old_records(self, max_age_hours: int = 24) -> int:
|
||||
"""
|
||||
Clear records older than specified age.
|
||||
|
||||
Args:
|
||||
max_age_hours: Maximum age in hours
|
||||
|
||||
Returns:
|
||||
Number of records cleared
|
||||
"""
|
||||
with self._lock:
|
||||
cutoff = datetime.now() - timedelta(hours=max_age_hours)
|
||||
original_count = len(self._records)
|
||||
self._records = [r for r in self._records if r.timestamp > cutoff]
|
||||
cleared = original_count - len(self._records)
|
||||
|
||||
if cleared > 0:
|
||||
self.logger.info(f"Cleared {cleared} old error records")
|
||||
|
||||
return cleared
|
||||
|
||||
@property
|
||||
def version(self) -> int:
|
||||
"""Changes whenever the recorded errors do (see ErrorSnapshotPublisher)."""
|
||||
@@ -316,7 +354,7 @@ class ErrorAggregator:
|
||||
def clear_before(self, cutoff: datetime) -> int:
|
||||
"""Forget every error recorded at or before ``cutoff``.
|
||||
|
||||
This also resets what the summary reports:
|
||||
Unlike clear_old_records, this also resets what the summary reports:
|
||||
the per-type and per-plugin counts are rebuilt from the records that
|
||||
remain, and detected patterns that began before the cutoff are dropped
|
||||
(one that is still happening is detected again on its next
|
||||
@@ -449,33 +487,28 @@ def record_error(
|
||||
# service publishes to the shared cache directory -- the same channel, and the
|
||||
# same file permissions, as display_current_state and plugin_metrics_snapshot: files
|
||||
# are 0660 and carry the cache directory's group, so root writes and the web
|
||||
# user reads, and the other way round for the clear request.
|
||||
# user reads.
|
||||
#
|
||||
# ERROR_SNAPSHOT_KEY written by the display service only
|
||||
# ERROR_CLEAR_REQUEST_KEY written by the web interface only, as a fallback
|
||||
#
|
||||
# A clear goes over the control socket (``errors.clear``): the display applies
|
||||
# it (clear_before) and republishes the snapshot before it answers. Only when
|
||||
# the socket cannot carry it (no socket, or a display older than the command)
|
||||
# does the web interface record a request in the mailbox, which the display
|
||||
# applies on its next tick; its tick reads that file only when it changed.
|
||||
# Until it has, the web interface hides whatever the snapshot shows from
|
||||
# before the cutoff, so a clear takes effect for readers immediately and a
|
||||
# snapshot published just before the request cannot bring old errors back.
|
||||
# The web interface never writes the snapshot itself: two writers would race,
|
||||
# and a snapshot owned by the web user is one more file root's write has to
|
||||
# replace.
|
||||
# it (clear_before) and republishes the snapshot before it answers. When the
|
||||
# socket cannot carry it, the clear fails and the route says so: the
|
||||
# ``plugin_error_clear_request`` file mailbox it used to fall back to is gone.
|
||||
# A stopped display's errors go anyway: its next run publishes an empty
|
||||
# snapshot over the old one. The web interface never writes the snapshot
|
||||
# itself: two writers would race, and a snapshot owned by the web user is one
|
||||
# more file root's write has to replace.
|
||||
|
||||
ERROR_SNAPSHOT_KEY = "plugin_error_snapshot"
|
||||
ERROR_CLEAR_REQUEST_KEY = "plugin_error_clear_request"
|
||||
|
||||
#: Shortest gap between two snapshot writes, in seconds. A plugin failing in
|
||||
#: a tight loop changes the aggregator many times a second; the snapshot is
|
||||
#: rewritten at most this often, and only when something changed.
|
||||
SNAPSHOT_MIN_INTERVAL = 10.0
|
||||
|
||||
#: How often the display service checks for changes and clear requests. A
|
||||
#: check is an in-memory comparison plus reading one small file.
|
||||
#: How often the display service checks for changes. A check is an
|
||||
#: in-memory comparison.
|
||||
SNAPSHOT_TICK_INTERVAL = 5.0
|
||||
|
||||
_SNAPSHOT_RECENT_ERRORS = 20
|
||||
@@ -544,8 +577,8 @@ class ErrorSnapshotPublisher:
|
||||
Runs in the display service only. tick() is the whole job; start() just
|
||||
calls it from a daemon thread every SNAPSHOT_TICK_INTERVAL seconds, which
|
||||
also means errors recorded while a write was being throttled still reach
|
||||
the cache once the interval has passed, and a clear request is applied
|
||||
even when no new error arrives to trigger a publish.
|
||||
the cache once the interval has passed. A clear (``errors.clear`` over
|
||||
the control socket) is applied by :meth:`clear_now`.
|
||||
|
||||
Nothing here raises: a failure to read or write the cache is logged at
|
||||
debug and retried on a later tick.
|
||||
@@ -563,11 +596,6 @@ class ErrorSnapshotPublisher:
|
||||
self._published_version: Optional[int] = None
|
||||
self._last_attempt: Optional[float] = None
|
||||
self._applied_clear_id: Optional[str] = None
|
||||
# The widest cutoff applied in this process: a clear request at or
|
||||
# before it has nothing left to clear (see _pending_cutoff).
|
||||
self._applied_clear_cutoff: Optional[float] = None
|
||||
from src.cache_manager import MailboxWatch # the display's cache, loaded already
|
||||
self._mailbox = MailboxWatch(ERROR_CLEAR_REQUEST_KEY)
|
||||
self._tick_lock = threading.Lock()
|
||||
self._stop = threading.Event()
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
@@ -579,39 +607,9 @@ class ErrorSnapshotPublisher:
|
||||
cleared = self.aggregator.clear_before(datetime.fromtimestamp(cutoff))
|
||||
_snapshot_logger.info("Cleared %d plugin error record(s) as requested (%s)",
|
||||
cleared, request_id)
|
||||
if self._applied_clear_cutoff is None or cutoff > self._applied_clear_cutoff:
|
||||
self._applied_clear_cutoff = cutoff
|
||||
# A malformed request is acknowledged too, so it is not retried forever.
|
||||
self._applied_clear_id = request_id
|
||||
return cleared
|
||||
|
||||
def _apply_clear_request(self) -> bool:
|
||||
"""Honour a mailbox clear request we have not applied yet. True if one was.
|
||||
|
||||
The mailbox is the fallback for a web interface that could not use
|
||||
the control socket (``errors.clear``, :meth:`clear_now`). It is read
|
||||
only when its file changed since the last tick; otherwise a tick
|
||||
costs one stat().
|
||||
"""
|
||||
if not self._mailbox.changed(self.cache_manager):
|
||||
return False
|
||||
try:
|
||||
request = self.cache_manager.get(ERROR_CLEAR_REQUEST_KEY, max_age=None, memory_ttl=0)
|
||||
except Exception:
|
||||
self._mailbox.forget()
|
||||
raise
|
||||
if not isinstance(request, dict):
|
||||
return False
|
||||
request_id = request.get("request_id")
|
||||
if not isinstance(request_id, str) or not request_id or request_id == self._applied_clear_id:
|
||||
return False
|
||||
try:
|
||||
cutoff = float(request.get("cutoff"))
|
||||
except (TypeError, ValueError):
|
||||
cutoff = float("nan")
|
||||
self._clear(request_id, cutoff)
|
||||
return True
|
||||
|
||||
def clear_now(self, request_id: str, cutoff: float) -> int:
|
||||
"""``errors.clear`` over the control socket: apply a clear at once and
|
||||
republish the snapshot, so the web interface's next read has it.
|
||||
@@ -629,23 +627,20 @@ class ErrorSnapshotPublisher:
|
||||
self._last_attempt = now
|
||||
snapshot = self.aggregator.build_snapshot()
|
||||
snapshot["applied_clear_id"] = self._applied_clear_id
|
||||
snapshot["applied_clear_cutoff"] = self._applied_clear_cutoff
|
||||
self.cache_manager.set(ERROR_SNAPSHOT_KEY, snapshot)
|
||||
self._published_version = version
|
||||
|
||||
def tick(self) -> bool:
|
||||
"""Apply a pending clear and publish if due. True if a snapshot was written."""
|
||||
"""Publish if due. True if a snapshot was written."""
|
||||
with self._tick_lock:
|
||||
try:
|
||||
cleared = self._apply_clear_request()
|
||||
version = self.aggregator.version
|
||||
now = self._clock()
|
||||
if not cleared:
|
||||
if version == self._published_version:
|
||||
return False
|
||||
if (self._last_attempt is not None
|
||||
and now - self._last_attempt < self.min_interval):
|
||||
return False
|
||||
if version == self._published_version:
|
||||
return False
|
||||
if (self._last_attempt is not None
|
||||
and now - self._last_attempt < self.min_interval):
|
||||
return False
|
||||
self._publish(version, now)
|
||||
return True
|
||||
except Exception as err: # never let reporting break the display
|
||||
@@ -716,16 +711,13 @@ def apply_error_clear(request_id: str, args: Any) -> Dict[str, Any]:
|
||||
|
||||
# --- Reading side (web interface) -------------------------------------------
|
||||
|
||||
def read_error_report(cache_manager: Any) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
||||
"""The display service's latest snapshot and the latest clear request.
|
||||
def read_error_report(cache_manager: Any) -> Optional[Dict[str, Any]]:
|
||||
"""The display service's latest snapshot, or None before it has published.
|
||||
|
||||
memory_ttl=0: both keys are written by the other process, so only the
|
||||
file is current.
|
||||
memory_ttl=0: the display writes the key, so only the file is current.
|
||||
"""
|
||||
snapshot = cache_manager.get(ERROR_SNAPSHOT_KEY, max_age=None, memory_ttl=0)
|
||||
clear_request = cache_manager.get(ERROR_CLEAR_REQUEST_KEY, max_age=None, memory_ttl=0)
|
||||
return (snapshot if isinstance(snapshot, dict) else None,
|
||||
clear_request if isinstance(clear_request, dict) else None)
|
||||
return snapshot if isinstance(snapshot, dict) else None
|
||||
|
||||
|
||||
def _epoch(iso: Any) -> Optional[float]:
|
||||
@@ -738,36 +730,6 @@ def _epoch(iso: Any) -> Optional[float]:
|
||||
return None
|
||||
|
||||
|
||||
def _pending_cutoff(snapshot: Optional[Dict[str, Any]],
|
||||
clear_request: Optional[Dict[str, Any]]) -> Optional[float]:
|
||||
"""The cutoff of a clear the snapshot has not applied yet, if any."""
|
||||
if not clear_request:
|
||||
return None
|
||||
request_id = clear_request.get("request_id")
|
||||
if not request_id:
|
||||
return None
|
||||
if snapshot is not None and snapshot.get("applied_clear_id") == request_id:
|
||||
return None
|
||||
try:
|
||||
cutoff = float(clear_request.get("cutoff"))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if not math.isfinite(cutoff):
|
||||
return None
|
||||
# A wider clear has been applied since (over the control socket): this
|
||||
# older request has nothing left to hide.
|
||||
applied = snapshot.get("applied_clear_cutoff") if snapshot is not None else None
|
||||
if (isinstance(applied, (int, float)) and not isinstance(applied, bool)
|
||||
and applied >= cutoff):
|
||||
return None
|
||||
return cutoff
|
||||
|
||||
|
||||
def _is_after(item: Any, field_name: str, cutoff: float) -> bool:
|
||||
when = _epoch(item.get(field_name)) if isinstance(item, dict) else None
|
||||
return when is not None and when > cutoff
|
||||
|
||||
|
||||
def _empty_summary(snapshot: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
return {
|
||||
"session_start": snapshot.get("session_start") if snapshot else None,
|
||||
@@ -780,11 +742,11 @@ def _empty_summary(snapshot: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def error_summary_from_report(snapshot: Optional[Dict[str, Any]],
|
||||
clear_request: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
def error_summary_from_report(snapshot: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"""The /errors/summary payload: get_error_summary()'s shape plus
|
||||
``generated_at``, ``snapshot_available`` and ``clear_pending``."""
|
||||
cutoff = _pending_cutoff(snapshot, clear_request)
|
||||
``generated_at``, ``snapshot_available`` and ``clear_pending`` (always
|
||||
False now: a clear is applied before its route answers; kept for API
|
||||
compatibility)."""
|
||||
summary = _empty_summary(snapshot)
|
||||
if snapshot is not None:
|
||||
for name, default in summary.items():
|
||||
@@ -793,32 +755,17 @@ def error_summary_from_report(snapshot: Optional[Dict[str, Any]],
|
||||
isinstance(default, float) and isinstance(value, int)) or (
|
||||
name == "session_start" and isinstance(value, str)):
|
||||
summary[name] = value
|
||||
if cutoff is not None:
|
||||
recent = summary["recent_errors"]
|
||||
newest = _epoch(recent[-1].get("timestamp")) if recent and isinstance(recent[-1], dict) else None
|
||||
if newest is None or newest <= cutoff:
|
||||
# Everything the display has reported predates the clear.
|
||||
summary = _empty_summary(snapshot)
|
||||
else:
|
||||
# Only part of it does. The lists can be filtered exactly; the
|
||||
# counts cannot, and stay as reported until the display
|
||||
# applies the clear (clear_pending says so).
|
||||
summary["recent_errors"] = [r for r in recent if _is_after(r, "timestamp", cutoff)]
|
||||
summary["active_patterns"] = {
|
||||
k: p for k, p in summary["active_patterns"].items()
|
||||
if _is_after(p, "last_seen", cutoff)
|
||||
}
|
||||
summary["generated_at"] = snapshot.get("generated_at") if snapshot else None
|
||||
summary["snapshot_available"] = snapshot is not None
|
||||
summary["clear_pending"] = cutoff is not None
|
||||
summary["clear_pending"] = False
|
||||
return summary
|
||||
|
||||
|
||||
def plugin_health_from_report(snapshot: Optional[Dict[str, Any]],
|
||||
clear_request: Optional[Dict[str, Any]],
|
||||
plugin_id: str) -> Dict[str, Any]:
|
||||
"""The /errors/plugin/<id> payload: get_plugin_health()'s shape plus
|
||||
``generated_at``, ``snapshot_available`` and ``clear_pending``."""
|
||||
``generated_at``, ``snapshot_available`` and ``clear_pending`` (always
|
||||
False, as in error_summary_from_report)."""
|
||||
health: Dict[str, Any] = {
|
||||
"plugin_id": plugin_id,
|
||||
"status": "healthy",
|
||||
@@ -827,19 +774,15 @@ def plugin_health_from_report(snapshot: Optional[Dict[str, Any]],
|
||||
"recent_error_count": 0,
|
||||
"last_error": None,
|
||||
}
|
||||
cutoff = _pending_cutoff(snapshot, clear_request)
|
||||
table = snapshot.get("plugin_health") if snapshot else None
|
||||
entry = table.get(plugin_id) if isinstance(table, dict) else None
|
||||
if isinstance(entry, dict):
|
||||
# last_error is the plugin's newest error: if even that predates a
|
||||
# pending clear, so does everything else the display reported for it.
|
||||
if cutoff is None or _is_after(entry.get("last_error"), "timestamp", cutoff):
|
||||
for name in ("status", "total_errors", "error_types", "recent_error_count", "last_error"):
|
||||
if name in entry:
|
||||
health[name] = entry[name]
|
||||
for name in ("status", "total_errors", "error_types", "recent_error_count", "last_error"):
|
||||
if name in entry:
|
||||
health[name] = entry[name]
|
||||
health["generated_at"] = snapshot.get("generated_at") if snapshot else None
|
||||
health["snapshot_available"] = snapshot is not None
|
||||
health["clear_pending"] = cutoff is not None
|
||||
health["clear_pending"] = False
|
||||
return health
|
||||
|
||||
|
||||
@@ -861,61 +804,35 @@ def _count_cleared(summary: Dict[str, Any], cutoff: float) -> Optional[int]:
|
||||
|
||||
|
||||
#: ``send(request_id, cutoff)`` hands a clear to the display over the control
|
||||
#: socket and returns its ErrorsClearResult, or None when the socket could
|
||||
#: not carry it and the mailbox should be written instead. Any exception it
|
||||
#: raises reaches the caller: the display had the request and failed it.
|
||||
ClearSender = Callable[[str, float], Optional[Dict[str, Any]]]
|
||||
#: socket and returns its ErrorsClearResult. It raises when the socket could
|
||||
#: not carry it or the display failed it (``src.ipc.client.ControlError``).
|
||||
ClearSender = Callable[[str, float], Dict[str, Any]]
|
||||
|
||||
|
||||
def request_error_clear(cache_manager: Any, cutoff: float,
|
||||
send: Optional[ClearSender] = None) -> Dict[str, Any]:
|
||||
send: ClearSender) -> Dict[str, Any]:
|
||||
"""Ask the display service to forget errors recorded at or before ``cutoff``.
|
||||
|
||||
Over the control socket when ``send`` is given and carries it: the
|
||||
display applies the clear and republishes its snapshot before it
|
||||
answers, so nothing is written here. Otherwise (no socket, or a display
|
||||
older than ``errors.clear``) a request is written to the
|
||||
``plugin_error_clear_request`` mailbox, which the display applies on
|
||||
its next tick, and readers hide the cleared errors until then.
|
||||
Over the control socket: the display applies the clear and republishes
|
||||
its snapshot before it answers, so nothing is written here. Whatever
|
||||
``send`` raises reaches the caller.
|
||||
|
||||
Returns ``request_id``, ``cutoff`` (ISO, local time), ``cleared_count``,
|
||||
``clear_requested``, ``applied`` (the display has already cleared them)
|
||||
and ``transport`` (``socket`` or ``mailbox``). ``cleared_count`` is the
|
||||
display's own count over the socket, else an estimate from the snapshot
|
||||
(see _count_cleared). Raises OSError when a mailbox request did not reach
|
||||
the shared cache, since a cache without a usable directory accepts set()
|
||||
and keeps nothing.
|
||||
|
||||
A request the display has not applied yet is only ever widened: a later,
|
||||
narrower one ("older than 24 hours" after "everything") overwriting it
|
||||
would otherwise bring back the errors the first one hid.
|
||||
Returns ``request_id``, ``cutoff`` (ISO, local time), ``cleared_count``
|
||||
(the display's own count, else an estimate from the snapshot; see
|
||||
_count_cleared), and ``clear_requested``, ``applied`` and ``transport``,
|
||||
which are always True, True and ``"socket"`` now and are kept for API
|
||||
compatibility.
|
||||
"""
|
||||
snapshot, clear_request = read_error_report(cache_manager)
|
||||
pending = _pending_cutoff(snapshot, clear_request)
|
||||
if pending is not None:
|
||||
cutoff = max(cutoff, pending)
|
||||
before = error_summary_from_report(snapshot, clear_request)
|
||||
before = error_summary_from_report(read_error_report(cache_manager))
|
||||
request_id = uuid.uuid4().hex
|
||||
answer = {
|
||||
result = send(request_id, cutoff)
|
||||
count = result.get("cleared") if isinstance(result, dict) else None
|
||||
return {
|
||||
"clear_requested": True,
|
||||
"request_id": request_id,
|
||||
"cutoff": datetime.fromtimestamp(cutoff).isoformat(),
|
||||
"applied": True,
|
||||
"transport": "socket",
|
||||
"cleared_count": (count if isinstance(count, int) and not isinstance(count, bool)
|
||||
else _count_cleared(before, cutoff)),
|
||||
}
|
||||
if send is not None:
|
||||
result = send(request_id, cutoff)
|
||||
if result is not None:
|
||||
count = result.get("cleared")
|
||||
return dict(answer, applied=True, transport="socket",
|
||||
cleared_count=count if isinstance(count, int) and not isinstance(count, bool)
|
||||
else _count_cleared(before, cutoff))
|
||||
request = {
|
||||
"request_id": request_id,
|
||||
"cutoff": cutoff,
|
||||
"requested_at": time.time(),
|
||||
}
|
||||
cache_manager.set(ERROR_CLEAR_REQUEST_KEY, request)
|
||||
stored = cache_manager.get(ERROR_CLEAR_REQUEST_KEY, max_age=None, memory_ttl=0)
|
||||
if not isinstance(stored, dict) or stored.get("request_id") != request["request_id"]:
|
||||
raise OSError("the clear request was not stored in the shared cache")
|
||||
return dict(answer, applied=False, transport="mailbox",
|
||||
cleared_count=_count_cleared(before, cutoff))
|
||||
|
||||
@@ -41,7 +41,6 @@ from PIL import ImageFont
|
||||
from src.common.bdf_font import load_bdf_face, read_bdf_native_size
|
||||
from src.common.font_layout import load_truetype, resolve_asset_path
|
||||
from typing import Dict, Tuple, Optional, Union, Any
|
||||
from src.deprecation import deprecated
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -534,7 +533,6 @@ class FontManager:
|
||||
"""
|
||||
return load_bdf_face(font_path, size_px)[0]
|
||||
|
||||
@deprecated("3.10.0", "use src.common.bdf_font.read_bdf_native_size()")
|
||||
def get_native_bdf_size(self, family: str) -> Optional[int]:
|
||||
"""The one true pixel size of a BDF family in the catalog, or None
|
||||
for scalable (TTF) families / unknown families."""
|
||||
@@ -555,7 +553,6 @@ class FontManager:
|
||||
|
||||
# ==================== Font Measurement ====================
|
||||
|
||||
@deprecated("3.10.0", "use src.adaptive_layout.measure_ink()")
|
||||
def measure_text(self, text: str, font: Union[ImageFont.FreeTypeFont, freetype.Face]) -> Tuple[int, int, int]:
|
||||
"""
|
||||
Measure text dimensions and baseline.
|
||||
|
||||
+28
-28
@@ -5,11 +5,11 @@ a refused or timed-out connection, a reply that breaks the contract, or an
|
||||
error the display returned -- raises :class:`ControlError` with a short
|
||||
``reason``. Nothing here blocks for longer than ``timeout`` in total.
|
||||
|
||||
Whether the caller may then write the file mailbox instead is
|
||||
:func:`should_fall_back`: only when the display never took the request (it
|
||||
could not be reached, or it is too old to know the command). A display that
|
||||
took the request and then failed, refused or went quiet is answered as
|
||||
that, not posted a second time through the mailbox.
|
||||
There is no other way to reach the display: the file mailboxes the web
|
||||
interface used to fall back to are gone. :func:`display_not_listening` tells
|
||||
a caller when no display is listening yet (it is stopped, or still
|
||||
starting), the one case where sending the same request again later, once a
|
||||
display is up, can work.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -43,8 +43,7 @@ from src.ipc.contract import (
|
||||
|
||||
#: Total budget for one request: connect, send and the reply. The display
|
||||
#: answers from a thread that does no rendering, normally within a few
|
||||
#: milliseconds; this only bounds a wedged one. The web route then falls back
|
||||
#: to the mailbox, so a timeout costs this much latency and nothing else.
|
||||
#: milliseconds; this only bounds a wedged one.
|
||||
DEFAULT_TIMEOUT_SECONDS = 1.0
|
||||
|
||||
|
||||
@@ -74,28 +73,25 @@ class ControlError(Exception):
|
||||
|
||||
#: Answers from a display that read the request but does not speak it: one
|
||||
#: older than the command (an upgrade in progress) or the protocol version.
|
||||
#: It did nothing, so the mailbox is the way to reach it.
|
||||
UPGRADE_REASONS = frozenset({ErrorCode.UNKNOWN_COMMAND, ErrorCode.UNSUPPORTED_VERSION})
|
||||
|
||||
#: Transport reasons that mean nothing is listening at the socket: no socket
|
||||
#: file (the display is stopped, or has not reached its run loop), or a file
|
||||
#: nobody accepts on (a stale socket) or that this user may not open.
|
||||
NOT_LISTENING_REASONS = frozenset({'no_socket', 'refused'})
|
||||
|
||||
def should_fall_back(error: BaseException) -> bool:
|
||||
"""May the caller write the file mailbox after ``error``?
|
||||
|
||||
Yes when the display never took the request: there is no socket (the
|
||||
display is stopped, predates the socket, or it is switched off), the
|
||||
connection was refused or timed out, the display turned the connection
|
||||
away before reading it, or it is too old to know the command
|
||||
(:data:`UPGRADE_REASONS`). Also for an error that is not a
|
||||
:class:`ControlError` (a bug in the client), as before.
|
||||
|
||||
No once the display had the request: a ``busy`` queue, ``invalid_args``,
|
||||
an ``internal`` error, or a timeout or hang-up after the request was
|
||||
sent. The display may have applied it, or would refuse it from the
|
||||
mailbox too, so a second copy there only hides the failure.
|
||||
def display_not_listening(error: BaseException) -> bool:
|
||||
"""True when ``error`` says no display took the request because none is
|
||||
listening: it never reached one (``sent`` is False) and the reason is in
|
||||
:data:`NOT_LISTENING_REASONS`. A display that is started, or finishes
|
||||
starting, may take the same request later. False for everything else:
|
||||
a display that had the request and failed it, one too old to know the
|
||||
command, a client that cannot use the socket at all (``disabled``,
|
||||
``unsupported``), and an exception that is not a :class:`ControlError`.
|
||||
"""
|
||||
if not isinstance(error, ControlError):
|
||||
return True
|
||||
return not error.sent or error.reason in UPGRADE_REASONS
|
||||
return (isinstance(error, ControlError) and not error.sent
|
||||
and error.reason in NOT_LISTENING_REASONS)
|
||||
|
||||
|
||||
def request(cmd: str, args: Optional[Mapping[str, Any]] = None, *,
|
||||
@@ -218,8 +214,8 @@ def on_demand_start(request_id: str, plugin_id: Optional[str], mode: Optional[st
|
||||
paths: Optional[Sequence[str]] = None) -> Dict[str, Any]:
|
||||
"""Ask the display to show a plugin now. Returns the ack; raises :class:`ControlError`.
|
||||
|
||||
``request_id`` doubles as the on-demand request id, so a request that a
|
||||
timed-out caller then also writes to the mailbox is processed only once.
|
||||
``request_id`` doubles as the on-demand request id, so a request sent
|
||||
twice with the same id is processed only once.
|
||||
"""
|
||||
args = {'plugin_id': plugin_id, 'mode': mode, 'duration': duration, 'pinned': pinned}
|
||||
return request(Command.ON_DEMAND_START, args, request_id=request_id,
|
||||
@@ -281,13 +277,17 @@ def errors_clear(request_id: str, cutoff: float, *,
|
||||
|
||||
Returns :class:`~src.ipc.contract.ErrorsClearResult` once it is done.
|
||||
Raises :class:`ControlError`: ``unknown_command`` from a display older
|
||||
than the command, which still reads the ``plugin_error_clear_request``
|
||||
mailbox.
|
||||
than the command.
|
||||
"""
|
||||
return request(Command.ERRORS_CLEAR, {'cutoff': cutoff}, request_id=request_id,
|
||||
timeout=timeout, paths=paths)
|
||||
|
||||
|
||||
def ping(*, timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
||||
paths: Optional[Sequence[str]] = None) -> Dict[str, Any]:
|
||||
return request(Command.PING, {}, timeout=timeout, paths=paths)
|
||||
|
||||
|
||||
def hello(client: str = 'web', *, timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
||||
paths: Optional[Sequence[str]] = None) -> Dict[str, Any]:
|
||||
"""Version negotiation: the result's ``version`` is the one both sides speak."""
|
||||
|
||||
+38
-11
@@ -85,8 +85,8 @@ DEFAULT_SOCKET_PATH = DEFAULT_SOCKET_DIR + '/' + SOCKET_NAME
|
||||
|
||||
#: Overrides the socket path for both processes (a dev checkout, a second
|
||||
#: instance, tests). One of :data:`DISABLED_VALUES` turns the socket off: the
|
||||
#: display does not serve it and the web interface goes straight to the
|
||||
#: file mailbox.
|
||||
#: display does not serve it, and the web interface cannot send it commands
|
||||
#: (it still reads the state the display writes to the cache).
|
||||
SOCKET_PATH_ENV = 'LEDMATRIX_CONTROL_SOCKET'
|
||||
DISABLED_VALUES = frozenset({'off', '0', 'false', 'no', 'none', 'disabled'})
|
||||
|
||||
@@ -375,8 +375,8 @@ def _optional_name(args: Mapping[str, Any], key: str) -> Optional[str]:
|
||||
def _optional_duration(value: Any) -> Optional[float]:
|
||||
"""Seconds, or None for "until stopped". 0 means the same as None.
|
||||
|
||||
Numbers and numeric strings are accepted, the same as the REST route and
|
||||
the file mailbox take them; anything else is refused rather than guessed.
|
||||
Numbers and numeric strings are accepted, the same as the REST route
|
||||
takes them; anything else is refused rather than guessed.
|
||||
"""
|
||||
if value is None or value == '':
|
||||
return None
|
||||
@@ -399,6 +399,9 @@ class HelloArgs:
|
||||
versions: Tuple[int, ...] = (PROTOCOL_VERSION,)
|
||||
client: str = ''
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {'versions': list(self.versions), 'client': self.client}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, args: Mapping[str, Any]) -> 'HelloArgs':
|
||||
versions = args.get('versions', [PROTOCOL_VERSION])
|
||||
@@ -415,7 +418,7 @@ class HelloArgs:
|
||||
class OnDemandStartArgs:
|
||||
"""``on_demand.start``: show a plugin (or one of its modes) now.
|
||||
|
||||
The same fields the file mailbox carries. At least one of ``plugin_id``
|
||||
The same fields as the REST route's body. At least one of ``plugin_id``
|
||||
and ``mode`` is required; the display resolves the other.
|
||||
"""
|
||||
plugin_id: Optional[str] = None
|
||||
@@ -423,6 +426,10 @@ class OnDemandStartArgs:
|
||||
duration: Optional[float] = None
|
||||
pinned: bool = False
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {'plugin_id': self.plugin_id, 'mode': self.mode,
|
||||
'duration': self.duration, 'pinned': self.pinned}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, args: Mapping[str, Any]) -> 'OnDemandStartArgs':
|
||||
plugin_id = _optional_name(args, 'plugin_id')
|
||||
@@ -442,6 +449,9 @@ class OnDemandStartArgs:
|
||||
class OnDemandStopArgs:
|
||||
"""``on_demand.stop``: end the on-demand session and resume rotation."""
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, args: Mapping[str, Any]) -> 'OnDemandStopArgs':
|
||||
return cls()
|
||||
@@ -451,6 +461,9 @@ class OnDemandStopArgs:
|
||||
class NoArgs:
|
||||
"""``ping`` and ``on_demand.status`` take no arguments (extra ones are ignored)."""
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, args: Mapping[str, Any]) -> 'NoArgs':
|
||||
return cls()
|
||||
@@ -467,6 +480,9 @@ class BrightnessSetArgs:
|
||||
"""
|
||||
brightness: int
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {'brightness': self.brightness}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, args: Mapping[str, Any]) -> 'BrightnessSetArgs':
|
||||
value = args.get('brightness')
|
||||
@@ -487,6 +503,9 @@ class PluginReloadArgs:
|
||||
"""
|
||||
plugin_id: str
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {'plugin_id': self.plugin_id}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, args: Mapping[str, Any]) -> 'PluginReloadArgs':
|
||||
plugin_id = _optional_name(args, 'plugin_id')
|
||||
@@ -527,6 +546,9 @@ class StateGetArgs:
|
||||
since: Optional[int] = None
|
||||
epoch: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {'since': self.since, 'epoch': self.epoch}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, args: Mapping[str, Any]) -> 'StateGetArgs':
|
||||
return cls(since=_optional_version(args, 'since'), epoch=_optional_epoch(args))
|
||||
@@ -543,6 +565,9 @@ class StateSubscribeArgs:
|
||||
and a ``tick`` at least every :data:`SUBSCRIBE_KEEPALIVE_SECONDS`.
|
||||
"""
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, args: Mapping[str, Any]) -> 'StateSubscribeArgs':
|
||||
return cls()
|
||||
@@ -557,6 +582,9 @@ class ErrorsClearArgs:
|
||||
"""
|
||||
cutoff: float
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {'cutoff': self.cutoff}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, args: Mapping[str, Any]) -> 'ErrorsClearArgs':
|
||||
value = args.get('cutoff')
|
||||
@@ -600,13 +628,12 @@ def parse_args(cmd: str, args: Mapping[str, Any]) -> CommandArgs:
|
||||
|
||||
def on_demand_request(request_id: str, args: Union[OnDemandStartArgs, OnDemandStopArgs],
|
||||
timestamp: float) -> Dict[str, Any]:
|
||||
"""The file-mailbox payload for a queued on-demand command.
|
||||
"""The on-demand request dict for a queued on-demand command.
|
||||
|
||||
The display hands socket commands to the same code that handles the
|
||||
mailbox (``DisplayController._handle_on_demand_request``), so a command
|
||||
behaves identically whichever way it arrived, and a request that came
|
||||
both ways (a client that timed out and fell back) is processed once: the
|
||||
request id is the same.
|
||||
The display hands socket commands to the same code that handles
|
||||
plugins' own requests (``DisplayController._handle_on_demand_request``),
|
||||
so a command behaves identically whichever way it arrived. (This was
|
||||
the file mailbox's payload, which the display no longer reads.)
|
||||
"""
|
||||
if isinstance(args, OnDemandStartArgs):
|
||||
return {'request_id': request_id, 'action': 'start', 'plugin_id': args.plugin_id,
|
||||
|
||||
+13
-12
@@ -3,9 +3,9 @@
|
||||
A small threaded server on a Unix stream socket (``/run/ledmatrix/control.sock``
|
||||
by default; see :mod:`src.ipc.contract` for the protocol). It never touches
|
||||
rendering: a command that changes the panel is validated, put on a bounded
|
||||
queue and acknowledged, and the render thread drains that queue at the point
|
||||
where it reads the file mailbox (``DisplayController._poll_on_demand_requests``),
|
||||
handing each command to the same code. Queries (``on_demand.status``) are
|
||||
queue and acknowledged, and the render thread drains that queue
|
||||
(``DisplayController._poll_on_demand_requests``), handing each on-demand
|
||||
command to the code that handles plugins' own requests. Queries (``on_demand.status``) are
|
||||
answered from a snapshot callable the display provides, and the few commands
|
||||
that touch nothing the render thread owns (``errors.clear``) by a handler the
|
||||
display registers, on the connection thread.
|
||||
@@ -110,8 +110,8 @@ MAX_CLIENTS = 8
|
||||
|
||||
#: Commands waiting for the render thread. It drains them at least every
|
||||
#: 0.25 s, so a full queue means the render thread is stuck, and the client
|
||||
#: is told ``busy`` instead of piling up work. The mailbox would not be read
|
||||
#: either, so the web interface reports the failure rather than fall back.
|
||||
#: is told ``busy`` instead of piling up work, and the web interface reports
|
||||
#: the failure.
|
||||
QUEUE_SIZE = 16
|
||||
|
||||
#: Timeout for one recv()/send() on a connection.
|
||||
@@ -185,7 +185,7 @@ class QueuedCommand:
|
||||
outcome: Optional[CommandOutcome] = field(default=None, compare=False, repr=False)
|
||||
|
||||
def as_on_demand_request(self) -> Dict[str, Any]:
|
||||
"""The mailbox-shaped payload the display's on-demand handler takes."""
|
||||
"""The on-demand request dict the display's on-demand handler takes."""
|
||||
if not isinstance(self.args, (OnDemandStartArgs, OnDemandStopArgs)):
|
||||
raise TypeError(f'{self.cmd} is not an on-demand command')
|
||||
return on_demand_request(self.request_id, self.args, self.received_at)
|
||||
@@ -619,8 +619,8 @@ class ControlServer:
|
||||
def start(self) -> bool:
|
||||
"""Bind and start serving. False (logged) when the socket cannot be served.
|
||||
|
||||
Never raises: without the socket the web interface uses the file
|
||||
mailbox, exactly as before.
|
||||
Never raises: without the socket the display runs, but the web
|
||||
interface cannot send it commands.
|
||||
"""
|
||||
if not socket_supported():
|
||||
logger.debug("Control socket not started: no Unix sockets on this platform")
|
||||
@@ -632,7 +632,7 @@ class ControlServer:
|
||||
self._bind()
|
||||
except OSError as e:
|
||||
logger.warning("Control socket not started at %s (%s); the web interface "
|
||||
"will use the file mailbox", self.path, e)
|
||||
"cannot send this display commands", self.path, e)
|
||||
self._close_socket()
|
||||
return False
|
||||
self._stopping.clear()
|
||||
@@ -1134,12 +1134,13 @@ def start_control_server(status_provider: Optional[StatusProvider] = None,
|
||||
"""Start the display's control socket, or return None when it can't run.
|
||||
|
||||
None covers Windows, ``LEDMATRIX_CONTROL_SOCKET=off`` and any failure to
|
||||
bind; in every case the web interface falls back to the file mailbox
|
||||
and to the cache keys the display still writes.
|
||||
bind; in every case the web interface cannot send the display commands,
|
||||
and reads the cache keys the display still writes.
|
||||
"""
|
||||
path = server_socket_path(environ)
|
||||
if path is None:
|
||||
logger.debug("Control socket disabled or unsupported here; using the file mailbox only")
|
||||
logger.debug("Control socket disabled or unsupported here; the web interface "
|
||||
"cannot send this display commands")
|
||||
return None
|
||||
server = ControlServer(path, status_provider, resolve_socket_group(cache_dir),
|
||||
state_hub=state_hub, handlers=handlers)
|
||||
|
||||
@@ -29,7 +29,6 @@ from src.common.permission_utils import (
|
||||
get_assets_dir_mode,
|
||||
get_assets_file_mode
|
||||
)
|
||||
from src.deprecation import deprecated
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -472,7 +471,6 @@ class LogoDownloader:
|
||||
logger.info(f"Using dynamic ESPN endpoint for custom soccer league: {league}")
|
||||
return api_url
|
||||
|
||||
@deprecated("3.10.0", "download logos one at a time with download_missing_logo()")
|
||||
def fetch_teams_data(self, league: str) -> Optional[Dict]:
|
||||
"""Fetch team data from ESPN API for a specific league."""
|
||||
api_url = self._resolve_api_url(league)
|
||||
@@ -520,7 +518,6 @@ class LogoDownloader:
|
||||
logger.error(f"Error parsing JSON response for {team_id} in {league}: {e}")
|
||||
return None
|
||||
|
||||
@deprecated("3.10.0", "download logos one at a time with download_missing_logo()")
|
||||
def extract_teams_from_data(self, data: Dict, league: str) -> List[Dict[str, str]]:
|
||||
"""Extract team information from ESPN API response."""
|
||||
teams = []
|
||||
@@ -624,7 +621,6 @@ class LogoDownloader:
|
||||
# Default to FBS for unknown conferences
|
||||
return 'FBS'
|
||||
|
||||
@deprecated("3.10.0", "download logos one at a time with download_missing_logo()")
|
||||
def download_missing_logos_for_league(self, league: str, force_download: bool = False) -> Tuple[int, int]:
|
||||
"""Download missing logos for a specific league."""
|
||||
logger.info(f"Starting logo download for league: {league}")
|
||||
@@ -679,7 +675,6 @@ class LogoDownloader:
|
||||
logger.info(f"Logo download complete for {league}: {downloaded_count} downloaded, {failed_count} failed")
|
||||
return downloaded_count, failed_count
|
||||
|
||||
@deprecated("3.10.0", "download logos one at a time with download_missing_logo()")
|
||||
def download_all_ncaa_football_logos(self, include_fcs: bool = True, force_download: bool = False) -> Tuple[int, int]:
|
||||
"""Download all NCAA football team logos including FCS teams."""
|
||||
logger.info(f"Starting comprehensive NCAA football logo download (FCS: {include_fcs})")
|
||||
@@ -768,7 +763,6 @@ class LogoDownloader:
|
||||
time.sleep(0.1) # Small delay
|
||||
return success
|
||||
|
||||
@deprecated("3.10.0", "download logos one at a time with download_missing_logo()")
|
||||
def download_all_missing_logos(self, leagues: List[str] | None = None, force_download: bool = False) -> Dict[str, Tuple[int, int]]:
|
||||
"""Download missing logos for all specified leagues."""
|
||||
if leagues is None:
|
||||
@@ -864,7 +858,6 @@ class LogoDownloader:
|
||||
logger.error(f"Failed to create placeholder logo for {team_abbreviation}: {e}")
|
||||
return False
|
||||
|
||||
@deprecated("3.10.0")
|
||||
def convert_image_to_rgba(self, filepath: Path) -> bool:
|
||||
"""Convert an image file to RGBA format to avoid PIL warnings."""
|
||||
try:
|
||||
@@ -882,7 +875,6 @@ class LogoDownloader:
|
||||
logger.error(f"Failed to convert {filepath.name} to RGBA: {e}")
|
||||
return False
|
||||
|
||||
@deprecated("3.10.0")
|
||||
def convert_all_logos_to_rgba(self, league: str) -> Tuple[int, int]:
|
||||
"""Convert all logos in a league directory to RGBA format."""
|
||||
logo_dir = Path(self.get_logo_directory(league))
|
||||
|
||||
@@ -11,7 +11,6 @@ Stability: Stable - maintains backward compatibility
|
||||
from abc import ABC, abstractmethod
|
||||
from enum import Enum
|
||||
from typing import Dict, Any, Optional, List
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
from src.deprecation import deprecated, warn_deprecated
|
||||
@@ -241,26 +240,6 @@ def resolve_vegas_participation(plugin: Any, plugin_id: Optional[str] = None) ->
|
||||
return legacy_vegas_participation(plugin)
|
||||
|
||||
|
||||
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.
|
||||
|
||||
How the core reads a plugin's get_display_duration() -- the rotation
|
||||
(DisplayController._get_display_duration) and the Vegas static pause --
|
||||
which several plugins answer straight from config.json, so a value saved
|
||||
as "20" or null arrives as a string or None. A number at or below zero is
|
||||
returned as it is; each caller has its own rule for that.
|
||||
"""
|
||||
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 BasePlugin(ABC):
|
||||
"""
|
||||
Base class that all plugins must inherit from.
|
||||
@@ -1114,16 +1093,16 @@ class BasePlugin(ABC):
|
||||
Returns:
|
||||
The request id once the display has queued it, or None when
|
||||
there is no display in this process to ask (the web interface,
|
||||
scripts/check_plugin.py) or its queue is full. A plugin that
|
||||
also runs on cores without this method writes the
|
||||
``display_on_demand_request`` mailbox on None, as before; see
|
||||
"On-demand display" in docs/PLUGIN_API_REFERENCE.md.
|
||||
scripts/check_plugin.py) or its queue is full. Nothing else can
|
||||
take the request then: the ``display_on_demand_request`` file
|
||||
mailbox older cores read is gone, and a write to it is dropped
|
||||
with a warning. See "On-demand display" in
|
||||
docs/PLUGIN_API_REFERENCE.md.
|
||||
|
||||
Example::
|
||||
|
||||
if not (hasattr(self, 'request_on_demand')
|
||||
and self.request_on_demand(mode='my_alert', duration=15)):
|
||||
self._write_on_demand_mailbox(...) # older cores
|
||||
if self.request_on_demand(mode='my_alert', duration=15) is None:
|
||||
self.logger.info("No display to show the alert on")
|
||||
"""
|
||||
request = getattr(getattr(self, 'plugin_manager', None), 'request_on_demand', None)
|
||||
if not callable(request):
|
||||
|
||||
@@ -51,36 +51,44 @@ class OperationHistory:
|
||||
def __init__(
|
||||
self,
|
||||
history_file: Optional[str] = None,
|
||||
max_records: int = 1000
|
||||
max_records: int = 1000,
|
||||
lazy_load: bool = False
|
||||
):
|
||||
"""
|
||||
Initialize operation history. The history file is read on first use,
|
||||
not here, so constructing this costs the web app's startup nothing.
|
||||
|
||||
Initialize operation history.
|
||||
|
||||
Args:
|
||||
history_file: Path to file for persisting history
|
||||
max_records: Maximum number of records to keep
|
||||
lazy_load: If True, defer loading history file until first access
|
||||
"""
|
||||
self.logger = get_logger(__name__)
|
||||
self.history_file = Path(history_file) if history_file else None
|
||||
self.max_records = max_records
|
||||
self._lazy_load = lazy_load
|
||||
self._history_loaded = False
|
||||
|
||||
|
||||
# In-memory history
|
||||
self._history: List[OperationRecord] = []
|
||||
self._lock = threading.RLock()
|
||||
|
||||
|
||||
# Load history from file if it exists (unless lazy loading)
|
||||
if not self._lazy_load and self.history_file and self.history_file.exists():
|
||||
self._load_history()
|
||||
self._history_loaded = True
|
||||
|
||||
def _ensure_loaded(self) -> None:
|
||||
"""Load the history file on first use."""
|
||||
"""Ensure history is loaded (for lazy loading)."""
|
||||
if not self._history_loaded and self.history_file and self.history_file.exists():
|
||||
self._load_history()
|
||||
self._history_loaded = True
|
||||
|
||||
|
||||
def record_operation(
|
||||
self,
|
||||
operation_type: str,
|
||||
plugin_id: Optional[str] = None,
|
||||
status: str = "completed",
|
||||
user: Optional[str] = None,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
error: Optional[str] = None,
|
||||
operation_id: Optional[str] = None
|
||||
@@ -92,6 +100,7 @@ class OperationHistory:
|
||||
operation_type: Type of operation (install, update, uninstall, etc.)
|
||||
plugin_id: Plugin identifier
|
||||
status: Operation status
|
||||
user: User who performed operation
|
||||
details: Optional operation details
|
||||
error: Optional error message
|
||||
operation_id: Optional operation ID
|
||||
@@ -109,6 +118,7 @@ class OperationHistory:
|
||||
plugin_id=plugin_id,
|
||||
timestamp=datetime.now(),
|
||||
status=status,
|
||||
user=user,
|
||||
details=details,
|
||||
error=error
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Plugin operation queue manager.
|
||||
|
||||
Serializes plugin operations to prevent conflicts and provides
|
||||
status tracking.
|
||||
status tracking and cancellation support.
|
||||
"""
|
||||
|
||||
import threading
|
||||
@@ -25,8 +25,8 @@ class PluginOperationQueue:
|
||||
- Serialized execution (one operation at a time)
|
||||
- Prevents concurrent operations on same plugin
|
||||
- Operation status tracking
|
||||
- A bounded in-memory history of finished operations, which also caps
|
||||
how many finished operations get_operation_status() remembers
|
||||
- Operation cancellation
|
||||
- In-memory history of finished operations
|
||||
|
||||
The history is not persisted. The web UI's operation history comes from
|
||||
OperationHistory (operation_history.py), which has its own file; a copy
|
||||
@@ -133,6 +133,56 @@ class PluginOperationQueue:
|
||||
with self._lock:
|
||||
return self._operations.get(operation_id)
|
||||
|
||||
def cancel_operation(self, operation_id: str) -> bool:
|
||||
"""
|
||||
Cancel a pending operation.
|
||||
|
||||
Args:
|
||||
operation_id: Operation identifier
|
||||
|
||||
Returns:
|
||||
True if operation was cancelled, False if not found or already running
|
||||
"""
|
||||
with self._lock:
|
||||
operation = self._operations.get(operation_id)
|
||||
if not operation:
|
||||
return False
|
||||
|
||||
if operation.status == OperationStatus.RUNNING:
|
||||
self.logger.warning(
|
||||
f"Cannot cancel running operation {operation_id}"
|
||||
)
|
||||
return False
|
||||
|
||||
if operation.status == OperationStatus.PENDING:
|
||||
operation.status = OperationStatus.CANCELLED
|
||||
operation.completed_at = datetime.now()
|
||||
operation.message = "Operation cancelled by user"
|
||||
self._add_to_history(operation)
|
||||
self.logger.info(f"Cancelled operation {operation_id}")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def get_operation_history(self, limit: int = 50) -> List[PluginOperation]:
|
||||
"""
|
||||
Get operation history.
|
||||
|
||||
Args:
|
||||
limit: Maximum number of operations to return
|
||||
|
||||
Returns:
|
||||
List of operations, sorted by creation time (newest first)
|
||||
"""
|
||||
with self._lock:
|
||||
# Sort by creation time (newest first)
|
||||
history = sorted(
|
||||
self._operation_history,
|
||||
key=lambda op: op.created_at,
|
||||
reverse=True
|
||||
)
|
||||
return history[:limit]
|
||||
|
||||
def _start_worker(self) -> None:
|
||||
"""Start the worker thread that processes operations."""
|
||||
if self._worker_thread and self._worker_thread.is_alive():
|
||||
@@ -157,6 +207,11 @@ class PluginOperationQueue:
|
||||
except queue.Empty:
|
||||
continue
|
||||
|
||||
# Check if operation was cancelled
|
||||
if operation.status == OperationStatus.CANCELLED:
|
||||
self._operation_queue.task_done()
|
||||
continue
|
||||
|
||||
# Execute operation
|
||||
self._execute_operation(operation)
|
||||
|
||||
|
||||
@@ -15,7 +15,11 @@ import uuid
|
||||
class OperationType(Enum):
|
||||
"""Types of plugin operations."""
|
||||
INSTALL = "install"
|
||||
UPDATE = "update"
|
||||
UNINSTALL = "uninstall"
|
||||
ENABLE = "enable"
|
||||
DISABLE = "disable"
|
||||
CONFIGURE = "configure"
|
||||
|
||||
|
||||
class OperationStatus(Enum):
|
||||
@@ -24,6 +28,7 @@ class OperationStatus(Enum):
|
||||
RUNNING = "running"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -65,3 +70,29 @@ class PluginOperation:
|
||||
'started_at': self.started_at.isoformat() if self.started_at else None,
|
||||
'completed_at': self.completed_at.isoformat() if self.completed_at else None,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'PluginOperation':
|
||||
"""Create operation from dictionary."""
|
||||
op = cls(
|
||||
operation_type=OperationType(data['operation_type']),
|
||||
plugin_id=data['plugin_id'],
|
||||
operation_id=data.get('operation_id', str(uuid.uuid4())),
|
||||
parameters=data.get('parameters', {}),
|
||||
status=OperationStatus(data.get('status', 'pending')),
|
||||
progress=data.get('progress', 0.0),
|
||||
message=data.get('message', ''),
|
||||
error=data.get('error'),
|
||||
result=data.get('result'),
|
||||
)
|
||||
|
||||
# Parse datetime fields
|
||||
if data.get('created_at'):
|
||||
op.created_at = datetime.fromisoformat(data['created_at'])
|
||||
if data.get('started_at'):
|
||||
op.started_at = datetime.fromisoformat(data['started_at'])
|
||||
if data.get('completed_at'):
|
||||
op.completed_at = datetime.fromisoformat(data['completed_at'])
|
||||
|
||||
return op
|
||||
|
||||
|
||||
@@ -3,10 +3,9 @@ Plugin catalog: what the web process knows about installed plugins.
|
||||
|
||||
The web interface and the display run as two processes. Only the display
|
||||
imports plugin code and runs it; the web process reads plugins as files --
|
||||
manifest, config schema, the plugin's section of config.json -- and never
|
||||
imports a plugin module, instantiates a plugin class or calls a plugin
|
||||
lifecycle hook. This class is the manifest side of that; schemas come from
|
||||
SchemaManager and config from ConfigManager.
|
||||
manifest, config schema, the plugin's section of config.json, the installed
|
||||
version -- and never imports a plugin module, instantiates a plugin class or
|
||||
calls a plugin lifecycle hook. This class is that read side.
|
||||
|
||||
It keeps the method names of the read-only part of :class:`PluginManager`
|
||||
(``discover_plugins``, ``plugin_manifests``, ``get_plugin_info``,
|
||||
@@ -16,8 +15,7 @@ reads through a catalog unchanged. It has nothing that runs a plugin: no
|
||||
``load_plugin``, ``get_plugin`` or ``plugins``.
|
||||
|
||||
Runtime state -- whether the display has a plugin loaded, its health, its
|
||||
errors -- is not here either, with one exception: given a ``runtime_source``,
|
||||
the mode lookups prefer the modes the running display registered. The display process publishes what it knows to
|
||||
errors -- is not here either. The display process publishes what it knows to
|
||||
the shared cache (health and resource metrics, the current mode, the error
|
||||
aggregator snapshot), and the web routes read those publications. What the
|
||||
display does not publish (which plugins it has loaded, its plugin state
|
||||
@@ -26,10 +24,10 @@ machine) the web cannot know, and reports as unknown.
|
||||
See docs/ARCHITECTURE.md ("Web and display processes").
|
||||
"""
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional, Union
|
||||
from typing import Any, Dict, List, Optional, Union, cast
|
||||
|
||||
from src.common.permission_utils import (
|
||||
ensure_directory_permissions, get_plugin_dir_mode,
|
||||
@@ -41,28 +39,20 @@ from src.plugin_system.plugin_dirs import (
|
||||
|
||||
PathLike = Union[str, Path]
|
||||
|
||||
#: How long one read of the display's runtime view answers mode lookups. A
|
||||
#: listing asks once per plugin; the cache copy is a file read each time.
|
||||
_RUNTIME_VIEW_TTL_SECONDS = 1.0
|
||||
|
||||
|
||||
class PluginCatalog:
|
||||
"""Manifests and directories of the installed plugins.
|
||||
"""Manifests, schemas, config and versions of the installed plugins.
|
||||
|
||||
Discovery is explicit and cheap to repeat: :meth:`discover_plugins`
|
||||
rescans the plugins directory and replaces the manifest map, so an
|
||||
uninstalled plugin disappears and a new one appears.
|
||||
"""
|
||||
|
||||
def __init__(self, plugins_dir: PathLike,
|
||||
runtime_source: Optional[Callable[[], Any]] = None) -> None:
|
||||
def __init__(self, plugins_dir: PathLike, config_manager: Optional[Any] = None,
|
||||
schema_manager: Optional[Any] = None) -> None:
|
||||
self.plugins_dir: Path = Path(plugins_dir)
|
||||
# Returns the display's PluginRuntimeView
|
||||
# (src/plugin_system/plugin_runtime.py). Its live view carries the
|
||||
# modes the display registered, which the mode lookups below prefer
|
||||
# to the manifest's. None: manifests only.
|
||||
self.runtime_source = runtime_source
|
||||
self._runtime_view_memo: Optional[tuple] = None
|
||||
self.config_manager = config_manager
|
||||
self.schema_manager = schema_manager
|
||||
self.logger = get_logger(__name__)
|
||||
|
||||
# Guards plugin_manifests/plugin_directories: request threads read
|
||||
@@ -142,6 +132,30 @@ class PluginCatalog:
|
||||
ids = list(self.plugin_manifests)
|
||||
return [info for info in (self.get_plugin_info(pid) for pid in ids) if info]
|
||||
|
||||
def read_manifest(self, plugin_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""The manifest as it is on disk now, not as discovery last saw it.
|
||||
|
||||
For reads that must reflect a change made since the last scan -- the
|
||||
version just after an update, say. None when the plugin has no
|
||||
directory or its manifest is missing, unreadable or not an object.
|
||||
"""
|
||||
plugin_dir = self.get_plugin_directory(plugin_id)
|
||||
if plugin_dir is None:
|
||||
return None
|
||||
try:
|
||||
with open(Path(plugin_dir) / 'manifest.json', 'r', encoding='utf-8') as f:
|
||||
manifest = json.load(f)
|
||||
except (OSError, ValueError) as exc:
|
||||
self.logger.debug("Could not read manifest for %s: %s", plugin_id, exc)
|
||||
return None
|
||||
return manifest if isinstance(manifest, dict) else None
|
||||
|
||||
def get_installed_version(self, plugin_id: str) -> str:
|
||||
"""The installed version from the on-disk manifest, or ''."""
|
||||
manifest = self.read_manifest(plugin_id) or {}
|
||||
version = manifest.get('version', '')
|
||||
return version if isinstance(version, str) else str(version)
|
||||
|
||||
def get_plugin_directory(self, plugin_id: str) -> Optional[str]:
|
||||
"""Where ``plugin_id`` is installed, or None.
|
||||
|
||||
@@ -158,73 +172,54 @@ class PluginCatalog:
|
||||
by_manifest=False)
|
||||
return str(plugin_dir) if plugin_dir is not None else None
|
||||
|
||||
def _runtime_view(self) -> Any:
|
||||
"""The display's runtime view, read at most once a second; None
|
||||
without a source or when reading it fails."""
|
||||
if self.runtime_source is None:
|
||||
return None
|
||||
now = time.monotonic()
|
||||
memo = self._runtime_view_memo
|
||||
if memo is not None and now - memo[0] < _RUNTIME_VIEW_TTL_SECONDS:
|
||||
return memo[1]
|
||||
try:
|
||||
view = self.runtime_source()
|
||||
except Exception as exc: # a lookup must still answer from manifests
|
||||
self.logger.debug("Could not read the display's runtime view: %s", exc)
|
||||
view = None
|
||||
self._runtime_view_memo = (now, view)
|
||||
return view
|
||||
|
||||
def _live_display_modes(self, plugin_id: str) -> Optional[List[str]]:
|
||||
"""The modes the running display registered for ``plugin_id``, or None."""
|
||||
view = self._runtime_view()
|
||||
if view is None:
|
||||
return None
|
||||
try:
|
||||
modes = view.display_modes(plugin_id)
|
||||
except Exception as exc: # includes a source returning something else
|
||||
self.logger.debug("Could not read display modes for %s: %s", plugin_id, exc)
|
||||
return None
|
||||
return list(modes) if isinstance(modes, list) and modes else None
|
||||
|
||||
def get_plugin_display_modes(self, plugin_id: str) -> List[str]:
|
||||
"""The modes the display registered for the plugin, else the
|
||||
manifest's ``display_modes``, else [].
|
||||
"""The manifest's ``display_modes``, or [].
|
||||
|
||||
A plugin may compute its modes at run time (``plugin.modes``): each
|
||||
league soccer-scoreboard's ``custom_leagues`` adds is a mode no
|
||||
manifest can list ahead of time (#668). The running display
|
||||
publishes what it registered, and that wins while the display is
|
||||
live and has the plugin loaded. Otherwise -- display stopped, plugin
|
||||
disabled -- the declared list is the best answer there is.
|
||||
What the display actually rotates can differ: a plugin may compute
|
||||
its modes at run time (``plugin.modes``). This is the declared list.
|
||||
"""
|
||||
live = self._live_display_modes(plugin_id)
|
||||
if live is not None:
|
||||
return live
|
||||
with self._lock:
|
||||
manifest = self.plugin_manifests.get(plugin_id)
|
||||
modes = (manifest or {}).get('display_modes', [])
|
||||
return list(modes) if isinstance(modes, list) else []
|
||||
|
||||
def find_plugin_for_mode(self, mode: str) -> Optional[str]:
|
||||
"""The plugin that registered ``mode`` on the running display, else
|
||||
the one whose manifest declares it (case-insensitive both ways)."""
|
||||
"""The plugin whose manifest declares ``mode`` (case-insensitive)."""
|
||||
wanted = mode.strip().lower()
|
||||
with self._lock:
|
||||
manifests = dict(self.plugin_manifests)
|
||||
for plugin_id in manifests:
|
||||
live = self._live_display_modes(plugin_id)
|
||||
if live and any(m.lower() == wanted for m in live):
|
||||
return plugin_id
|
||||
for plugin_id, manifest in manifests.items():
|
||||
if self._live_display_modes(plugin_id):
|
||||
continue # the display's list is the truth for this plugin
|
||||
modes = manifest.get('display_modes')
|
||||
if isinstance(modes, list) and any(
|
||||
isinstance(m, str) and m.lower() == wanted for m in modes):
|
||||
return plugin_id
|
||||
return None
|
||||
|
||||
# -- schema and config ------------------------------------------------
|
||||
|
||||
def get_schema(self, plugin_id: str, use_cache: bool = True) -> Optional[Dict[str, Any]]:
|
||||
"""The plugin's config schema through SchemaManager, or None."""
|
||||
if self.schema_manager is None:
|
||||
return None
|
||||
schema = self.schema_manager.load_schema(plugin_id, use_cache=use_cache)
|
||||
return cast(Optional[Dict[str, Any]], schema)
|
||||
|
||||
def get_config(self, plugin_id: str) -> Dict[str, Any]:
|
||||
"""The plugin's section of config.json (secrets merged), or {}."""
|
||||
if self.config_manager is None:
|
||||
return {}
|
||||
section = (self.config_manager.load_config() or {}).get(plugin_id)
|
||||
return section if isinstance(section, dict) else {}
|
||||
|
||||
def is_enabled(self, plugin_id: str) -> bool:
|
||||
"""Whether config.json enables the plugin, by the display's rule.
|
||||
|
||||
The display loads a plugin only when its section says
|
||||
``"enabled": true``; a missing flag or section means disabled
|
||||
(``DisplayController._reconcile_enabled_plugins``).
|
||||
"""
|
||||
return bool(self.get_config(plugin_id).get('enabled', False))
|
||||
|
||||
|
||||
def display_restart_required(action: str, plugin_enabled: bool, *,
|
||||
changed: bool = True,
|
||||
|
||||
@@ -38,7 +38,6 @@ from src.common.permission_utils import (
|
||||
ensure_directory_permissions,
|
||||
get_plugin_dir_mode
|
||||
)
|
||||
from src.deprecation import deprecated
|
||||
|
||||
|
||||
class _DeferredConfigChange(NamedTuple):
|
||||
@@ -940,7 +939,6 @@ class PluginManager:
|
||||
"""
|
||||
return self.plugins.get(plugin_id)
|
||||
|
||||
@deprecated("3.10.0", "use get_plugin(plugin_id)")
|
||||
def get_all_plugins(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get all loaded plugins.
|
||||
@@ -950,7 +948,6 @@ class PluginManager:
|
||||
"""
|
||||
return self.plugins.copy()
|
||||
|
||||
@deprecated("3.10.0", "read the manifest with src.plugin_system.plugin_catalog.PluginCatalog")
|
||||
def get_plugin_info(self, plugin_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get information about a plugin (manifest + runtime info).
|
||||
@@ -988,7 +985,6 @@ class PluginManager:
|
||||
|
||||
return info
|
||||
|
||||
@deprecated("3.10.0", "read manifests with src.plugin_system.plugin_catalog.PluginCatalog")
|
||||
def get_all_plugin_info(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get information about all plugins.
|
||||
@@ -1029,7 +1025,6 @@ class PluginManager:
|
||||
by_manifest=False)
|
||||
return str(plugin_dir) if plugin_dir is not None else None
|
||||
|
||||
@deprecated("3.10.0", "read manifests with src.plugin_system.plugin_catalog.PluginCatalog")
|
||||
def get_plugin_display_modes(self, plugin_id: str) -> List[str]:
|
||||
"""
|
||||
Get display modes provided by a plugin.
|
||||
@@ -1050,7 +1045,6 @@ class PluginManager:
|
||||
return display_modes
|
||||
return []
|
||||
|
||||
@deprecated("3.10.0", "read manifests with src.plugin_system.plugin_catalog.PluginCatalog")
|
||||
def find_plugin_for_mode(self, mode: str) -> Optional[str]:
|
||||
"""
|
||||
Find which plugin provides a given display mode.
|
||||
@@ -1869,7 +1863,7 @@ class PluginManager:
|
||||
"""Route plugins' on-demand requests to ``handler`` (None: nowhere).
|
||||
|
||||
The display controller sets its ``submit_plugin_on_demand`` here
|
||||
before any plugin loads. The handler takes a mailbox-shaped request
|
||||
before any plugin loads. The handler takes an on-demand request dict
|
||||
from any thread, queues it for the render thread and returns True,
|
||||
or False when it could not. A plugin manager with no handler (the
|
||||
web interface's, a test's, scripts/check_plugin.py's) has no screen
|
||||
|
||||
@@ -56,7 +56,7 @@ import os
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field, replace
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
|
||||
from src import display_watchdog
|
||||
from src.logging_config import get_logger
|
||||
@@ -100,9 +100,6 @@ _ERROR_MESSAGE_CHARS = 200
|
||||
_ERROR_TYPE_CHARS = 80
|
||||
_ID_CHARS = 100
|
||||
_VERSION_CHARS = 40
|
||||
#: Bounds on a plugin's published ``modes``: a plugin computes them, so a
|
||||
#: runaway list must not bloat a file written to the SD card.
|
||||
_MAX_MODES = 200
|
||||
|
||||
#: Reader statuses. Only LIVE carries runtime facts.
|
||||
LIVE = "live"
|
||||
@@ -157,15 +154,6 @@ def summarize_error(error_info: Optional[Dict[str, Any]]) -> Optional[Dict[str,
|
||||
}
|
||||
|
||||
|
||||
def _published_modes(modes: Any) -> Optional[List[str]]:
|
||||
"""The registered display modes as a snapshot carries them, or None."""
|
||||
if not isinstance(modes, list):
|
||||
return None
|
||||
# A name is a key the display matches exactly: drop one too long to
|
||||
# carry whole rather than clip it into a different name.
|
||||
return [m for m in modes if isinstance(m, str) and len(m) <= _ID_CHARS][:_MAX_MODES]
|
||||
|
||||
|
||||
def build_runtime_snapshot(state_manager: Any, *, started_at: float,
|
||||
now: Optional[float] = None,
|
||||
running: bool = True,
|
||||
@@ -185,7 +173,6 @@ def build_runtime_snapshot(state_manager: Any, *, started_at: float,
|
||||
"error": summarize_error(record.get("error_info")),
|
||||
"version": _clip(version, _VERSION_CHARS) if version else None,
|
||||
"loaded_at": _epoch(record.get("loaded_at")),
|
||||
"modes": _published_modes(record.get("modes")),
|
||||
}
|
||||
return {
|
||||
"schema": SNAPSHOT_SCHEMA,
|
||||
@@ -429,21 +416,6 @@ class PluginRuntimeView:
|
||||
"loaded_at": record.get("loaded_at"),
|
||||
}
|
||||
|
||||
def display_modes(self, plugin_id: str) -> Optional[List[str]]:
|
||||
"""The display modes the display registered for ``plugin_id``: what
|
||||
it rotates and accepts on-demand, including modes a plugin computes
|
||||
from its config. None unless the view is live and the plugin is
|
||||
loaded with its modes registered -- the caller then falls back to
|
||||
the manifest's ``display_modes``."""
|
||||
if not self.live:
|
||||
return None
|
||||
record = self.plugins.get(plugin_id)
|
||||
modes = record.get("modes") if isinstance(record, dict) else None
|
||||
if not isinstance(modes, list):
|
||||
return None
|
||||
modes = [m for m in modes if isinstance(m, str)]
|
||||
return modes or None
|
||||
|
||||
def describe(self) -> Dict[str, Any]:
|
||||
"""The view's own status, for a response to carry beside the facts."""
|
||||
return {
|
||||
|
||||
@@ -10,12 +10,11 @@ snapshot ``plugin_runtime.PluginRuntimePublisher`` publishes from it.
|
||||
import threading
|
||||
import time
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
from src.logging_config import get_logger
|
||||
from src.deprecation import deprecated
|
||||
|
||||
|
||||
class PluginState(Enum):
|
||||
@@ -139,7 +138,6 @@ class PluginStateManager:
|
||||
"""
|
||||
return self._states.get(plugin_id, PluginState.UNLOADED)
|
||||
|
||||
@deprecated("3.10.0", "use get_state()")
|
||||
def is_loaded(self, plugin_id: str) -> bool:
|
||||
"""Check if plugin is loaded."""
|
||||
state = self.get_state(plugin_id)
|
||||
@@ -150,13 +148,11 @@ class PluginStateManager:
|
||||
state = self.get_state(plugin_id)
|
||||
return state == PluginState.ENABLED
|
||||
|
||||
@deprecated("3.10.0", "use get_state()")
|
||||
def is_running(self, plugin_id: str) -> bool:
|
||||
"""Check if plugin is currently running."""
|
||||
state = self.get_state(plugin_id)
|
||||
return state == PluginState.RUNNING
|
||||
|
||||
@deprecated("3.10.0", "use get_state()")
|
||||
def is_error(self, plugin_id: str) -> bool:
|
||||
"""Check if plugin is in error state."""
|
||||
state = self.get_state(plugin_id)
|
||||
@@ -201,7 +197,6 @@ class PluginStateManager:
|
||||
state.value,
|
||||
)
|
||||
|
||||
@deprecated("3.10.0")
|
||||
def get_error_info(self, plugin_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get error information for a plugin.
|
||||
@@ -236,26 +231,6 @@ class PluginStateManager:
|
||||
}
|
||||
self._note_change()
|
||||
|
||||
def record_modes(self, plugin_id: str, modes: List[str]) -> None:
|
||||
"""Record the display modes the display registered for ``plugin_id``.
|
||||
|
||||
Called by the DisplayController each time it registers the plugin.
|
||||
These are the modes it actually rotates and accepts on-demand --
|
||||
``plugin.modes`` when the plugin computes them (a soccer league the
|
||||
user added under ``custom_leagues``), else the manifest's list -- and
|
||||
the web interface has no other way to learn them (#668). Kept on the
|
||||
loaded record, so an unload or a reload's fresh record_loaded()
|
||||
forgets them until the plugin is registered again.
|
||||
"""
|
||||
with self._lock:
|
||||
loaded = self._loaded.get(plugin_id)
|
||||
if loaded is None:
|
||||
return
|
||||
modes = [str(m) for m in modes]
|
||||
if loaded.get('modes') != modes:
|
||||
loaded['modes'] = modes
|
||||
self._note_change()
|
||||
|
||||
def record_unloaded(self, plugin_id: str) -> None:
|
||||
"""Forget the loaded record alone, keeping state and error info: for
|
||||
an unload that failed after the instance was already dropped."""
|
||||
@@ -268,8 +243,7 @@ class PluginStateManager:
|
||||
section so a concurrent load or unload is seen whole or not at all.
|
||||
|
||||
Per plugin: ``state`` (published_state()'s value), ``loaded``,
|
||||
``version``, ``loaded_at`` and ``modes`` (None unless loaded; ``modes``
|
||||
also None until the display registers it) and ``error_info``
|
||||
``version`` and ``loaded_at`` (None unless loaded) and ``error_info``
|
||||
(a copy, or None).
|
||||
"""
|
||||
with self._lock:
|
||||
@@ -283,7 +257,6 @@ class PluginStateManager:
|
||||
'loaded': loaded is not None,
|
||||
'version': loaded['version'] if loaded else None,
|
||||
'loaded_at': loaded['loaded_at'] if loaded else None,
|
||||
'modes': list(loaded['modes']) if loaded and 'modes' in loaded else None,
|
||||
'error_info': dict(info) if info is not None else None,
|
||||
}
|
||||
return records
|
||||
@@ -292,12 +265,10 @@ class PluginStateManager:
|
||||
"""Record that plugin update() was called."""
|
||||
self._last_update[plugin_id] = datetime.now()
|
||||
|
||||
@deprecated("3.10.0")
|
||||
def get_last_update(self, plugin_id: str) -> Optional[datetime]:
|
||||
"""Get timestamp of last update() call."""
|
||||
return self._last_update.get(plugin_id)
|
||||
|
||||
@deprecated("3.10.0", "use get_state()")
|
||||
def get_state_info(self, plugin_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get comprehensive state information for a plugin.
|
||||
|
||||
@@ -38,6 +38,7 @@ class InconsistencyType(Enum):
|
||||
PLUGIN_MISSING_ON_DISK = "plugin_missing_on_disk"
|
||||
PLUGIN_ENABLED_MISMATCH = "plugin_enabled_mismatch"
|
||||
PLUGIN_VERSION_MISMATCH = "plugin_version_mismatch"
|
||||
PLUGIN_STATE_CORRUPTED = "plugin_state_corrupted"
|
||||
|
||||
|
||||
class FixAction(Enum):
|
||||
@@ -56,6 +57,7 @@ class Inconsistency:
|
||||
fix_action: FixAction
|
||||
current_state: Dict[str, Any]
|
||||
expected_state: Dict[str, Any]
|
||||
can_auto_fix: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -268,7 +270,7 @@ class StateReconciliation:
|
||||
|
||||
# Attempt to fix auto-fixable inconsistencies
|
||||
for inconsistency in inconsistencies:
|
||||
if inconsistency.fix_action == FixAction.AUTO_FIX:
|
||||
if inconsistency.can_auto_fix and inconsistency.fix_action == FixAction.AUTO_FIX:
|
||||
if self._fix_inconsistency(inconsistency):
|
||||
fixed.append(inconsistency)
|
||||
else:
|
||||
@@ -426,6 +428,7 @@ class StateReconciliation:
|
||||
fix_action=FixAction.AUTO_FIX,
|
||||
current_state={'exists_in_config': False},
|
||||
expected_state={'exists_in_config': True, 'enabled': False},
|
||||
can_auto_fix=True
|
||||
))
|
||||
|
||||
# Check: Plugin in config but not on disk
|
||||
@@ -456,6 +459,7 @@ class StateReconciliation:
|
||||
fix_action=FixAction.AUTO_FIX if can_repair else FixAction.MANUAL_FIX_REQUIRED,
|
||||
current_state={'exists_on_disk': False},
|
||||
expected_state={'exists_on_disk': True},
|
||||
can_auto_fix=can_repair
|
||||
))
|
||||
|
||||
# Observed checks: only against a live snapshot, and only for a plugin
|
||||
@@ -482,6 +486,7 @@ class StateReconciliation:
|
||||
fix_action=FixAction.NO_ACTION,
|
||||
current_state={'loaded': loaded, 'state': runtime.get('state')},
|
||||
expected_state={'loaded': config_enabled},
|
||||
can_auto_fix=False
|
||||
))
|
||||
loaded_version = runtime.get('loaded_version')
|
||||
disk_version = disk.get('version')
|
||||
@@ -495,6 +500,7 @@ class StateReconciliation:
|
||||
fix_action=FixAction.NO_ACTION,
|
||||
current_state={'version': loaded_version},
|
||||
expected_state={'version': disk_version},
|
||||
can_auto_fix=False
|
||||
))
|
||||
|
||||
return inconsistencies
|
||||
|
||||
@@ -183,7 +183,16 @@ class _RegistryMixin:
|
||||
@staticmethod
|
||||
def _distinct_sequence(values: List[str]) -> List[str]:
|
||||
"""Return list preserving order while removing duplicates and falsey entries."""
|
||||
return list(dict.fromkeys(v for v in values if v))
|
||||
seen = set()
|
||||
ordered = []
|
||||
for value in values:
|
||||
if not value:
|
||||
continue
|
||||
if value in seen:
|
||||
continue
|
||||
seen.add(value)
|
||||
ordered.append(value)
|
||||
return ordered
|
||||
|
||||
def _validate_manifest_version_fields(self, manifest: Dict[str, Any]) -> List[str]:
|
||||
"""
|
||||
|
||||
@@ -25,7 +25,6 @@ from src.plugin_system.testing.mocks import (
|
||||
MockConfigManager,
|
||||
MockPluginManager
|
||||
)
|
||||
from src.deprecation import deprecated
|
||||
|
||||
|
||||
class PluginTestCase(unittest.TestCase):
|
||||
@@ -35,7 +34,6 @@ class PluginTestCase(unittest.TestCase):
|
||||
Provides common fixtures and helper methods.
|
||||
"""
|
||||
|
||||
@deprecated("3.10.0", "use src.plugin_system.testing.harness and the mocks directly")
|
||||
def setUp(self):
|
||||
"""Set up test fixtures."""
|
||||
# Create mock managers
|
||||
|
||||
@@ -291,6 +291,49 @@ class VegasModeConfig:
|
||||
max_cycle_duration=int(get('max_cycle_duration', d.max_cycle_duration)),
|
||||
)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert config to dictionary for serialization."""
|
||||
return {
|
||||
'enabled': self.enabled,
|
||||
'scroll_speed': self.scroll_speed,
|
||||
'separator_width': self.separator_width,
|
||||
'intra_plugin_gap': self.intra_plugin_gap,
|
||||
'render_width_pct': self.render_width_pct,
|
||||
'min_content_separation': self.min_content_separation,
|
||||
'min_cut_gap': self.min_cut_gap,
|
||||
'smooth_scroll': self.smooth_scroll,
|
||||
'sub_pixel_blend': self.sub_pixel_blend,
|
||||
'continuous_scroll': self.continuous_scroll,
|
||||
'offscreen_prefetch': self.offscreen_prefetch,
|
||||
'switch_interval_ms': self.switch_interval_ms,
|
||||
'prefetch_gate': self.prefetch_gate,
|
||||
'live_refresh': self.live_refresh,
|
||||
'live_max_hz': self.live_max_hz,
|
||||
'live_min_interval': self.live_min_interval,
|
||||
'live_lead_screens': self.live_lead_screens,
|
||||
'extend_threshold_screens': self.extend_threshold_screens,
|
||||
'auto_trim': self.auto_trim,
|
||||
'trim_threshold': self.trim_threshold,
|
||||
'content_padding': self.content_padding,
|
||||
'min_plugin_width': self.min_plugin_width,
|
||||
'lead_in_width': self.lead_in_width,
|
||||
'plugins_per_cycle': self.plugins_per_cycle,
|
||||
'max_plugin_width_ratio': self.max_plugin_width_ratio,
|
||||
'live_in_ticker': self.live_in_ticker,
|
||||
'live_weight': self.live_weight,
|
||||
'favorite_live_weight': self.favorite_live_weight,
|
||||
'overflow_mode': self.overflow_mode,
|
||||
'plugin_order': self.plugin_order,
|
||||
'excluded_plugins': list(self.excluded_plugins),
|
||||
'target_fps': self.target_fps,
|
||||
'buffer_ahead': self.buffer_ahead,
|
||||
'frame_based_scrolling': self.frame_based_scrolling,
|
||||
'scroll_delay': self.scroll_delay,
|
||||
'dynamic_duration_enabled': self.dynamic_duration_enabled,
|
||||
'min_cycle_duration': self.min_cycle_duration,
|
||||
'max_cycle_duration': self.max_cycle_duration,
|
||||
}
|
||||
|
||||
def get_frame_interval(self) -> float:
|
||||
"""Get the frame interval in seconds for target FPS."""
|
||||
return 1.0 / max(1, self.target_fps)
|
||||
|
||||
@@ -18,11 +18,10 @@ import math
|
||||
import sys
|
||||
import time
|
||||
import threading
|
||||
from typing import Optional, Dict, Any, FrozenSet, List, Callable, TYPE_CHECKING
|
||||
from typing import Optional, Dict, Any, List, Callable, TYPE_CHECKING
|
||||
|
||||
from src import display_watchdog
|
||||
from src.common import render_gate
|
||||
from src.plugin_system.base_plugin import finite_seconds
|
||||
from src.vegas_mode.config import VegasModeConfig
|
||||
from src.vegas_mode.elements import LiveEpochs
|
||||
from src.vegas_mode.plugin_adapter import PluginAdapter
|
||||
@@ -54,14 +53,6 @@ _FPS_HEARTBEAT_INTERVAL = 300.0
|
||||
#: every plugin. Game state doesn't change within a quarter second.
|
||||
_LIVE_PRIORITY_CHECK_INTERVAL = 0.25
|
||||
|
||||
#: Seconds a static pause shows a plugin whose display duration can't be
|
||||
#: used, as long as the rotation shows it: 30 when get_display_duration()
|
||||
#: raises or answers something that is not a number
|
||||
#: (DisplayController._get_display_duration), 15 when it answers a number at
|
||||
#: or below zero (DisplayController._resolve_durations).
|
||||
_UNREADABLE_DURATION = 30.0
|
||||
_NOT_POSITIVE_DURATION = 15.0
|
||||
|
||||
|
||||
def _percentile(ordered: List[float], fraction: float) -> float:
|
||||
"""Nearest-rank percentile of an already-sorted list.
|
||||
@@ -101,9 +92,6 @@ class VegasModeCoordinator:
|
||||
_live_reason: Optional[str] = None
|
||||
# Set only while Vegas has changed the GIL switch interval; read with getattr.
|
||||
_saved_switch_interval: Optional[float]
|
||||
#: Plugins already warned about a display duration the pause can't use,
|
||||
#: so a bad setting logs once, not at every turn. Replaced, not mutated.
|
||||
_duration_warned: FrozenSet[str] = frozenset()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -182,6 +170,16 @@ class VegasModeCoordinator:
|
||||
self._static_pause_active = False
|
||||
self._saved_scroll_position: Optional[int] = None
|
||||
|
||||
# Statistics
|
||||
self.stats = {
|
||||
'total_runtime_seconds': 0.0,
|
||||
'cycles_completed': 0,
|
||||
'interruptions': 0,
|
||||
'config_updates': 0,
|
||||
'static_pauses': 0,
|
||||
}
|
||||
self._start_time: Optional[float] = None
|
||||
|
||||
logger.info(
|
||||
"VegasModeCoordinator initialized: enabled=%s, fps=%d, buffer_ahead=%d",
|
||||
self.vegas_config.enabled,
|
||||
@@ -316,6 +314,7 @@ class VegasModeCoordinator:
|
||||
# new run would have run_frame() refuse every frame.
|
||||
self._is_paused = False
|
||||
self._live_priority_active = False
|
||||
self._start_time = time.time()
|
||||
# A fresh run starts with a clean health slate: no stale
|
||||
# "was degraded" from the previous run, and a heartbeat that is
|
||||
# due immediately so the first sample confirms the marquee is up.
|
||||
@@ -346,6 +345,10 @@ class VegasModeCoordinator:
|
||||
self._is_paused = False
|
||||
self._live_priority_active = False
|
||||
|
||||
if self._start_time:
|
||||
self.stats['total_runtime_seconds'] += time.time() - self._start_time
|
||||
self._start_time = None
|
||||
|
||||
self._restore_switch_interval()
|
||||
self._remove_render_gate()
|
||||
self._set_live(False, None)
|
||||
@@ -470,6 +473,7 @@ class VegasModeCoordinator:
|
||||
if not self._is_active:
|
||||
return
|
||||
self._is_paused = True
|
||||
self.stats['interruptions'] += 1
|
||||
|
||||
self.display_manager.set_scrolling_state(False)
|
||||
logger.info("Vegas mode paused")
|
||||
@@ -539,8 +543,9 @@ class VegasModeCoordinator:
|
||||
if self.render_pipeline.has_deferred():
|
||||
self.render_pipeline.drain_deferred()
|
||||
elif self.render_pipeline.needs_extension():
|
||||
if (not self.render_pipeline.extend_scroll_content()
|
||||
and self.render_pipeline.is_cycle_complete()):
|
||||
if self.render_pipeline.extend_scroll_content():
|
||||
self.stats['cycles_completed'] += 1
|
||||
elif self.render_pipeline.is_cycle_complete():
|
||||
# Extension failed and the strip has run out: fall back to
|
||||
# the swap rather than sitting on a dead frame.
|
||||
self.render_pipeline.start_new_cycle()
|
||||
@@ -550,6 +555,7 @@ class VegasModeCoordinator:
|
||||
if not self.render_pipeline.start_new_cycle():
|
||||
logger.warning("Failed to start new Vegas cycle")
|
||||
return False
|
||||
self.stats['cycles_completed'] += 1
|
||||
|
||||
# Check for hot-swap opportunities
|
||||
if self.render_pipeline.should_recompose():
|
||||
@@ -829,6 +835,7 @@ class VegasModeCoordinator:
|
||||
self._pending_config_update = True
|
||||
self._pending_config = new_config
|
||||
self._config_version += 1
|
||||
self.stats['config_updates'] += 1
|
||||
|
||||
logger.debug("Config update queued (version %d)", self._config_version)
|
||||
|
||||
@@ -903,6 +910,23 @@ class VegasModeCoordinator:
|
||||
self.stream_manager.mark_plugin_updated(plugin_id)
|
||||
self.plugin_adapter.invalidate_cache(plugin_id)
|
||||
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
"""Get comprehensive Vegas mode status."""
|
||||
status = {
|
||||
'enabled': self.vegas_config.enabled,
|
||||
'active': self._is_active,
|
||||
'paused': self._is_paused,
|
||||
'live_priority_active': self._live_priority_active,
|
||||
'config': self.vegas_config.to_dict(),
|
||||
'stats': self.stats.copy(),
|
||||
}
|
||||
|
||||
if self._is_active:
|
||||
status['render_info'] = self.render_pipeline.get_current_scroll_info()
|
||||
status['stream_status'] = self.stream_manager.get_buffer_status()
|
||||
|
||||
return status
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Static pause handling (for STATIC display mode)
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -956,6 +980,7 @@ class VegasModeCoordinator:
|
||||
# Save current scroll position for smooth resume
|
||||
self._saved_scroll_position = self.render_pipeline.get_scroll_position()
|
||||
self._static_pause_active = True
|
||||
self.stats['static_pauses'] += 1
|
||||
|
||||
logger.info("Static pause started for plugin: %s", plugin_id)
|
||||
|
||||
@@ -985,7 +1010,7 @@ class VegasModeCoordinator:
|
||||
# Wait for the plugin's display duration. Monotonic, like the
|
||||
# iteration clock: an NTP step on an RTC-less Pi would otherwise
|
||||
# end the pause at once or stretch it by the correction.
|
||||
duration = self._static_pause_duration(plugin)
|
||||
duration = plugin.get_display_duration()
|
||||
start = time.monotonic()
|
||||
|
||||
while time.monotonic() - start < duration:
|
||||
@@ -1021,42 +1046,6 @@ class VegasModeCoordinator:
|
||||
|
||||
return True
|
||||
|
||||
def _static_pause_duration(self, plugin: 'BasePlugin') -> float:
|
||||
"""Seconds a static pause shows ``plugin``: its display duration,
|
||||
read the way the rotation reads it.
|
||||
|
||||
Several plugins return their display_duration setting straight from
|
||||
config.json, so one saved as "20" or null came back as a string or
|
||||
None; comparing it with the clock raised, and the pause's broad
|
||||
except ended the pause at every one of the plugin's turns. inf
|
||||
paused until something interrupted it, and NaN, False, 0 or a
|
||||
negative number ended the pause at once. A numeric string counts
|
||||
(finite_seconds); anything else, or a raise, gets
|
||||
_UNREADABLE_DURATION, and a number at or below zero
|
||||
_NOT_POSITIVE_DURATION, logged once per plugin.
|
||||
"""
|
||||
try:
|
||||
value = plugin.get_display_duration()
|
||||
except Exception as err: # pylint: disable=broad-except
|
||||
problem = f"get_display_duration() raised {type(err).__name__}: {err}"
|
||||
fallback = _UNREADABLE_DURATION
|
||||
else:
|
||||
seconds = finite_seconds(value)
|
||||
if seconds is not None and seconds > 0:
|
||||
return seconds
|
||||
if seconds is None:
|
||||
problem = f"display duration {value!r} is not a number"
|
||||
fallback = _UNREADABLE_DURATION
|
||||
else:
|
||||
problem = f"display duration {value!r} is not above zero"
|
||||
fallback = _NOT_POSITIVE_DURATION
|
||||
plugin_id = plugin.plugin_id
|
||||
if plugin_id not in self._duration_warned:
|
||||
self._duration_warned = self._duration_warned | {plugin_id}
|
||||
logger.warning("[%s] %s; its static pause lasts %.0fs (logged once)",
|
||||
plugin_id, problem, fallback)
|
||||
return fallback
|
||||
|
||||
def _end_static_pause(self) -> None:
|
||||
"""End static pause and restore scroll state."""
|
||||
should_resume_scrolling = False
|
||||
|
||||
@@ -218,6 +218,7 @@ class RenderPipeline:
|
||||
|
||||
# Render state
|
||||
self._cycle_complete = False
|
||||
self._segments_in_scroll: List[str] = [] # Plugin IDs in current scroll
|
||||
self._record_by_seq: Dict[int, ElementRecord] = {}
|
||||
# Live updates. _applied: per record, the (epoch, digest) of the
|
||||
# pixels the strip holds. _live_slots / _live_ready: the worker's
|
||||
@@ -234,9 +235,15 @@ class RenderPipeline:
|
||||
self._frame_interval = config.get_frame_interval()
|
||||
self._cycle_start_time = 0.0
|
||||
|
||||
# Read by _measure_refresh (warm-up) and the live integration test.
|
||||
self.frames_rendered = 0
|
||||
self.extensions = 0
|
||||
# Statistics
|
||||
self.stats = {
|
||||
'frames_rendered': 0,
|
||||
'scroll_cycles': 0,
|
||||
'composition_count': 0,
|
||||
'hot_swaps': 0,
|
||||
'avg_frame_time_ms': 0.0,
|
||||
}
|
||||
self._frame_times: Deque[float] = deque(maxlen=100) # Efficient fixed-size buffer
|
||||
|
||||
logger.info(
|
||||
"RenderPipeline initialized: %dx%d @ %d FPS",
|
||||
@@ -335,7 +342,7 @@ class RenderPipeline:
|
||||
return
|
||||
if getattr(self.display_manager, 'matrix', None) is None:
|
||||
return # No hardware: nothing blocks, so there is nothing to time.
|
||||
if self.frames_rendered < self.REFRESH_WARMUP_FRAMES:
|
||||
if self.stats['frames_rendered'] < self.REFRESH_WARMUP_FRAMES:
|
||||
return
|
||||
self._swap_times.append(time.monotonic())
|
||||
if len(self._swap_times) <= self.REFRESH_SAMPLES:
|
||||
@@ -445,6 +452,10 @@ class RenderPipeline:
|
||||
layouts)
|
||||
self._note_op('compose', self._strip_nbytes())
|
||||
|
||||
# Track which plugins are in this scroll (get safely via buffer status)
|
||||
self._segments_in_scroll = self.stream_manager.get_active_plugin_ids()
|
||||
|
||||
self.stats['composition_count'] += 1
|
||||
self._cycle_start_time = time.time()
|
||||
self._cycle_complete = False
|
||||
|
||||
@@ -854,7 +865,9 @@ class RenderPipeline:
|
||||
# trim's copy, if it made one.
|
||||
self._note_op('extend', moved + (self._copied_bytes() if cut else 0))
|
||||
|
||||
self.extensions += 1
|
||||
self._segments_in_scroll = [pid for pid, _ in grouped]
|
||||
self.stats['composition_count'] += 1
|
||||
self.stats['extensions'] = self.stats.get('extensions', 0) + 1
|
||||
|
||||
logger.info(
|
||||
"Extended scroll strip with %d plugin block(s), %d rows: "
|
||||
@@ -1137,6 +1150,8 @@ class RenderPipeline:
|
||||
Returns:
|
||||
True if frame was rendered, False if no content
|
||||
"""
|
||||
frame_start = time.time()
|
||||
|
||||
try:
|
||||
if not self.scroll_helper.has_strip():
|
||||
return False
|
||||
@@ -1185,6 +1200,7 @@ class RenderPipeline:
|
||||
if at_wrap_point or self.scroll_helper.is_scroll_complete():
|
||||
if not self._cycle_complete:
|
||||
self._cycle_complete = True
|
||||
self.stats['scroll_cycles'] += 1
|
||||
logger.info(
|
||||
"Scroll cycle complete after %.1fs",
|
||||
time.time() - self._cycle_start_time
|
||||
@@ -1223,8 +1239,11 @@ class RenderPipeline:
|
||||
# Update scrolling state
|
||||
self.display_manager.set_scrolling_state(True, self._frame_hold)
|
||||
|
||||
self.frames_rendered += 1
|
||||
# Track statistics
|
||||
self.stats['frames_rendered'] += 1
|
||||
self._measure_refresh()
|
||||
frame_time = time.time() - frame_start
|
||||
self._track_frame_time(frame_time)
|
||||
|
||||
return True
|
||||
|
||||
@@ -1233,6 +1252,15 @@ class RenderPipeline:
|
||||
logger.exception("Error rendering frame")
|
||||
return False
|
||||
|
||||
def _track_frame_time(self, frame_time: float) -> None:
|
||||
"""Track frame timing for statistics."""
|
||||
self._frame_times.append(frame_time) # deque with maxlen auto-removes old entries
|
||||
|
||||
if self._frame_times:
|
||||
self.stats['avg_frame_time_ms'] = (
|
||||
sum(self._frame_times) / len(self._frame_times) * 1000
|
||||
)
|
||||
|
||||
def is_cycle_complete(self) -> bool:
|
||||
"""Check if current scroll cycle is complete."""
|
||||
return self._cycle_complete
|
||||
@@ -1319,6 +1347,7 @@ class RenderPipeline:
|
||||
else:
|
||||
self.scroll_helper.scroll_position = 0.0
|
||||
|
||||
self.stats['hot_swaps'] += 1
|
||||
logger.debug(
|
||||
"Hot-swap completed: scroll repositioned %.0f→%.0f (%.1f%% of new %dpx image)",
|
||||
old_pos, self.scroll_helper.scroll_position,
|
||||
@@ -1367,6 +1396,8 @@ class RenderPipeline:
|
||||
# transition rather than near-end content wrapping around.
|
||||
self.scroll_helper.scroll_position = float(self.config.lead_in_width)
|
||||
|
||||
# Signal follower that a new cycle started (triggers its own rebuild)
|
||||
self.sync_manager.send_new_cycle()
|
||||
# Push the actual scroll image over TCP so follower has identical pixels.
|
||||
# Done in a background thread to not block the render loop (~15ms transfer).
|
||||
image = self.scroll_helper.cached_image
|
||||
@@ -1379,6 +1410,16 @@ class RenderPipeline:
|
||||
|
||||
return result
|
||||
|
||||
def get_current_scroll_info(self) -> Dict[str, Any]:
|
||||
"""Get current scroll state information."""
|
||||
scroll_info = self.scroll_helper.get_scroll_info()
|
||||
return {
|
||||
**scroll_info,
|
||||
'cycle_complete': self._cycle_complete,
|
||||
'plugins_in_scroll': self._segments_in_scroll,
|
||||
'stats': self.stats.copy(),
|
||||
}
|
||||
|
||||
def get_scroll_position(self) -> int:
|
||||
"""
|
||||
Get current scroll position.
|
||||
@@ -1424,6 +1465,8 @@ class RenderPipeline:
|
||||
self.scroll_helper.clear_cache()
|
||||
|
||||
self._cycle_complete = False
|
||||
self._segments_in_scroll = []
|
||||
self._frame_times = deque(maxlen=100)
|
||||
|
||||
# Content lined up for the old run belongs to it. Left in place, the
|
||||
# first extension after Vegas is switched back on appended that stale
|
||||
|
||||
@@ -16,7 +16,7 @@ BasePlugin.get_vegas_participation):
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional, List, Dict, Deque, Tuple, TYPE_CHECKING
|
||||
from typing import Optional, List, Dict, Any, Deque, Tuple, TYPE_CHECKING
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from PIL import Image
|
||||
@@ -94,6 +94,13 @@ class StreamManager:
|
||||
self._last_refresh: float = 0.0
|
||||
self._refresh_interval: float = 30.0 # Refresh plugin list every 30s
|
||||
|
||||
# Statistics
|
||||
self.stats = {
|
||||
'segments_fetched': 0,
|
||||
'segments_served': 0,
|
||||
'fetch_errors': 0,
|
||||
}
|
||||
|
||||
logger.info("StreamManager initialized with buffer_ahead=%d", config.buffer_ahead)
|
||||
|
||||
def initialize(self) -> bool:
|
||||
@@ -136,12 +143,47 @@ class StreamManager:
|
||||
return None
|
||||
|
||||
segment = self._active_buffer.popleft()
|
||||
self.stats['segments_served'] += 1
|
||||
|
||||
# Trigger prefetch to maintain buffer
|
||||
self._ensure_buffer_filled()
|
||||
|
||||
return segment
|
||||
|
||||
def peek_next_segment(self) -> Optional[ContentSegment]:
|
||||
"""
|
||||
Peek at the next segment without removing it.
|
||||
|
||||
Returns:
|
||||
ContentSegment or None if buffer is empty
|
||||
"""
|
||||
with self._buffer_lock:
|
||||
if self._active_buffer:
|
||||
return self._active_buffer[0]
|
||||
return None
|
||||
|
||||
def get_buffer_status(self) -> Dict[str, Any]:
|
||||
"""Get current buffer status for monitoring."""
|
||||
with self._buffer_lock:
|
||||
return {
|
||||
'active_count': len(self._active_buffer),
|
||||
'total_plugins': len(self._ordered_plugins),
|
||||
'prefetch_index': self._prefetch_index,
|
||||
'stats': self.stats.copy(),
|
||||
}
|
||||
|
||||
def get_active_plugin_ids(self) -> List[str]:
|
||||
"""
|
||||
Get list of plugin IDs currently in the active buffer.
|
||||
|
||||
Thread-safe accessor for render pipeline.
|
||||
|
||||
Returns:
|
||||
List of plugin IDs in buffer order
|
||||
"""
|
||||
with self._buffer_lock:
|
||||
return [seg.plugin_id for seg in self._active_buffer]
|
||||
|
||||
def mark_plugin_updated(self, plugin_id: str) -> None:
|
||||
"""
|
||||
Mark a plugin as having updated data.
|
||||
@@ -545,6 +587,7 @@ class StreamManager:
|
||||
images=[], # No images needed for static pause
|
||||
display_mode=VegasDisplayMode.STATIC
|
||||
)
|
||||
self.stats['segments_fetched'] += 1
|
||||
logger.debug(
|
||||
"[%s] Created STATIC placeholder (pause trigger)",
|
||||
plugin_id
|
||||
@@ -567,6 +610,7 @@ class StreamManager:
|
||||
display_mode=VegasDisplayMode.SCROLL
|
||||
)
|
||||
|
||||
self.stats['segments_fetched'] += 1
|
||||
logger.debug(
|
||||
"[%s] Segment: %d image(s), %dpx",
|
||||
plugin_id, len(images), total_width
|
||||
@@ -575,6 +619,7 @@ class StreamManager:
|
||||
|
||||
except Exception:
|
||||
logger.exception("[%s] ERROR fetching content", plugin_id)
|
||||
self.stats['fetch_errors'] += 1
|
||||
return None
|
||||
|
||||
def _ensure_buffer_filled(self) -> None:
|
||||
@@ -725,8 +770,10 @@ class StreamManager:
|
||||
plugin, plugin_id, offscreen_only=offscreen_only)
|
||||
except Exception:
|
||||
logger.exception("[%s] ERROR fetching content", plugin_id)
|
||||
self.stats['fetch_errors'] += 1
|
||||
return None
|
||||
if images:
|
||||
self.stats['segments_fetched'] += 1
|
||||
return (plugin_id, images)
|
||||
# Only the old contract hands anything back to the render thread.
|
||||
defer_empty = offscreen_only and not getattr(
|
||||
|
||||
@@ -8,6 +8,7 @@ import time
|
||||
from typing import Any, Optional, Dict, Tuple
|
||||
from flask import jsonify, request
|
||||
|
||||
from src.web_interface.error_handler import create_error_response, create_success_response
|
||||
from src.web_interface.errors import ErrorCode, WebInterfaceError
|
||||
|
||||
|
||||
@@ -30,15 +31,7 @@ def success_response(
|
||||
Returns:
|
||||
Flask jsonify response
|
||||
"""
|
||||
response_data: Dict[str, Any] = {'status': 'success'}
|
||||
# `is not None` rather than truthiness: "" and {} are values a caller
|
||||
# chose to send, and dropping them would make the shape depend on the data.
|
||||
if data is not None:
|
||||
response_data['data'] = data
|
||||
if message is not None:
|
||||
response_data['message'] = message
|
||||
if metadata is not None:
|
||||
response_data['metadata'] = metadata
|
||||
response_data = create_success_response(data, message, metadata)
|
||||
for key, value in (extra or {}).items():
|
||||
response_data.setdefault(key, value)
|
||||
|
||||
@@ -77,14 +70,14 @@ def error_response(
|
||||
Returns:
|
||||
Flask jsonify response with status code
|
||||
"""
|
||||
error = WebInterfaceError(
|
||||
return create_error_response(
|
||||
error_code=error_code,
|
||||
message=message,
|
||||
details=details,
|
||||
context=context or {},
|
||||
suggested_fixes=suggested_fixes
|
||||
context=context,
|
||||
suggested_fixes=suggested_fixes,
|
||||
status_code=status_code
|
||||
)
|
||||
return jsonify(error.to_dict()), status_code
|
||||
|
||||
|
||||
def exception_error_response(
|
||||
|
||||
@@ -12,8 +12,8 @@ from typing import Any, Dict
|
||||
def _schema_type_is(prop: Any, wanted: str) -> bool:
|
||||
"""Whether a schema property is of ``wanted`` type, unions included.
|
||||
|
||||
The one copy: ``web_interface/blueprints/api_v3`` imports it from here
|
||||
(src/ must not import the Flask blueprint). A union such as
|
||||
Mirrors ``_schema_type_is`` in ``web_interface/blueprints/api_v3`` (kept
|
||||
here so src/ doesn't import the Flask blueprint). A union such as
|
||||
``["array", "null"]`` -- the per-element style overrides, where null means
|
||||
"inherit" -- is still an array for recombining position-keyed inputs.
|
||||
"""
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
"""
|
||||
Error text and payloads for web interface responses.
|
||||
Centralized error handling for web interface.
|
||||
|
||||
Safe exception descriptions and the bodies for exceptions no route handled.
|
||||
The standard success/error responses are in api_helpers.
|
||||
Provides helpers for consistent error responses across API endpoints.
|
||||
"""
|
||||
|
||||
from typing import Any, Optional
|
||||
from flask import jsonify
|
||||
|
||||
from src.web_interface.errors import WebInterfaceError, ErrorCode
|
||||
from src.logging_config import get_logger
|
||||
from src.redaction import redact_credentials
|
||||
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
# Long enough for an errno string with a path, short enough not to dump a
|
||||
# parser's worth of context into a JSON field.
|
||||
_MAX_DETAIL_LENGTH = 400
|
||||
@@ -94,3 +101,72 @@ def http_exception_payload(error) -> dict:
|
||||
'error_code': (error.name or 'HTTP_ERROR').upper().replace(' ', '_'),
|
||||
'message': error.description,
|
||||
}
|
||||
|
||||
|
||||
def create_error_response(
|
||||
error_code: ErrorCode,
|
||||
message: str,
|
||||
details: Optional[str] = None,
|
||||
context: Optional[dict] = None,
|
||||
suggested_fixes: Optional[list] = None,
|
||||
status_code: int = 500
|
||||
) -> tuple:
|
||||
"""
|
||||
Create a standardized error response.
|
||||
|
||||
Args:
|
||||
error_code: Error code
|
||||
message: Error message
|
||||
details: Optional detailed error information
|
||||
context: Optional context dictionary
|
||||
suggested_fixes: Optional list of suggested fixes
|
||||
status_code: HTTP status code
|
||||
|
||||
Returns:
|
||||
Tuple of (jsonify response, status_code)
|
||||
"""
|
||||
error = WebInterfaceError(
|
||||
error_code=error_code,
|
||||
message=message,
|
||||
details=details,
|
||||
context=context or {},
|
||||
suggested_fixes=suggested_fixes
|
||||
)
|
||||
|
||||
return jsonify(error.to_dict()), status_code
|
||||
|
||||
|
||||
def create_success_response(
|
||||
data: Any = None,
|
||||
message: Optional[str] = None,
|
||||
metadata: Optional[dict] = None
|
||||
) -> dict:
|
||||
"""
|
||||
Create a standardized success response.
|
||||
|
||||
Args:
|
||||
data: Response data
|
||||
message: Optional success message
|
||||
metadata: Optional metadata (timing, version, etc.)
|
||||
|
||||
Returns:
|
||||
Dictionary for jsonify
|
||||
"""
|
||||
response: dict[str, Any] = {
|
||||
"status": "success"
|
||||
}
|
||||
|
||||
# All three use `is not None` rather than truthiness: "" and {} are
|
||||
# values a caller chose to send, and dropping them silently would make
|
||||
# the response shape depend on the data.
|
||||
if data is not None:
|
||||
response["data"] = data
|
||||
|
||||
if message is not None:
|
||||
response["message"] = message
|
||||
|
||||
if metadata is not None:
|
||||
response["metadata"] = metadata
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@@ -61,14 +61,27 @@ class WebInterfaceError:
|
||||
context: Optional[Dict[str, Any]] = None
|
||||
suggested_fixes: Optional[List[str]] = None
|
||||
original_error: Optional[Exception] = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.context = self.context or {}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
error_code: ErrorCode,
|
||||
message: str,
|
||||
details: Optional[str] = None,
|
||||
context: Optional[Dict[str, Any]] = None,
|
||||
suggested_fixes: Optional[List[str]] = None,
|
||||
original_error: Optional[Exception] = None
|
||||
):
|
||||
self.error_code = error_code
|
||||
self.message = message
|
||||
self.details = details
|
||||
self.context = context or {}
|
||||
# `is None`, not truthiness: an explicit [] means "this caller has
|
||||
# no suggestions to offer", which the default list would override.
|
||||
if self.suggested_fixes is None:
|
||||
self.suggested_fixes = self._get_default_suggestions(self.error_code)
|
||||
|
||||
self.suggested_fixes = (
|
||||
suggested_fixes if suggested_fixes is not None
|
||||
else self._get_default_suggestions(error_code))
|
||||
self.original_error = original_error
|
||||
|
||||
def _get_default_suggestions(self, error_code: ErrorCode) -> List[str]:
|
||||
"""Get default suggested fixes for error code."""
|
||||
suggestions_map = {
|
||||
|
||||
@@ -41,7 +41,8 @@ def mock_plugin_catalog():
|
||||
"""
|
||||
from src.plugin_system.plugin_catalog import PluginCatalog
|
||||
catalog = MagicMock(spec=PluginCatalog)
|
||||
for name in ('plugins_dir', 'plugin_manifests', 'plugin_directories'):
|
||||
for name in ('plugins_dir', 'config_manager', 'schema_manager',
|
||||
'plugin_manifests', 'plugin_directories'):
|
||||
setattr(catalog, name, MagicMock())
|
||||
return catalog
|
||||
|
||||
|
||||
@@ -155,13 +155,6 @@ class FakeCache:
|
||||
self._writes += 1
|
||||
self._written[key] = self._writes
|
||||
|
||||
def file_signature(self, key):
|
||||
"""CacheManager.file_signature: None without a file, else a value
|
||||
that changes with every write."""
|
||||
if key not in self.data:
|
||||
return None
|
||||
return (self._written.get(key, 0), 0, 0)
|
||||
|
||||
def delete(self, key):
|
||||
self.data.pop(key, None)
|
||||
|
||||
@@ -729,10 +722,23 @@ class RunLoopHarness:
|
||||
self.controller.available_modes.append(mode)
|
||||
|
||||
def on_demand_request(self, t: float, request_id: str, action: str = "start", **fields):
|
||||
"""An on-demand start or stop from the web interface at ``t``: a
|
||||
command on the control socket (served for the run if no test did),
|
||||
which is the only way the web interface reaches the display."""
|
||||
from src.ipc.contract import Command, parse_args
|
||||
from src.ipc.server import QueuedCommand
|
||||
|
||||
server = self.controller._control_server
|
||||
if not isinstance(server, FakeControlServer):
|
||||
server = self.control_socket()
|
||||
cmd = Command.ON_DEMAND_START if action == "start" else Command.ON_DEMAND_STOP
|
||||
command = QueuedCommand(request_id=request_id, cmd=cmd,
|
||||
args=parse_args(cmd, fields if action == "start" else {}),
|
||||
received_at=0.0)
|
||||
|
||||
def post():
|
||||
self.log("request", f"{action}:{request_id}")
|
||||
self.cache.set("display_on_demand_request",
|
||||
{"request_id": request_id, "action": action, **fields})
|
||||
server.queue.append(command)
|
||||
self.clock.at(t, post)
|
||||
|
||||
def restore_on_demand(self, plugin_id: str, mode: Optional[str] = None,
|
||||
|
||||
@@ -328,6 +328,20 @@ def _hermetic_control_socket(monkeypatch):
|
||||
monkeypatch.setenv(SOCKET_PATH_ENV, 'off')
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_pending_on_demand_dispatch():
|
||||
"""Drop the web process's on-demand dispatcher after each test, so a
|
||||
start one test left pending is not still being sent in the next."""
|
||||
yield
|
||||
module = sys.modules.get('web_interface.on_demand_dispatch')
|
||||
if module is None:
|
||||
return
|
||||
dispatcher = module.current()
|
||||
if dispatcher is not None:
|
||||
dispatcher.cancel('test-teardown')
|
||||
module.reset_for_tests()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _hermetic_unit_refresh(monkeypatch, tmp_path_factory):
|
||||
"""Keep updates' systemd unit refresh off the host.
|
||||
|
||||
+3
-3
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"screens": [
|
||||
[0.0, "clock", 20.0, "duration", 20, false],
|
||||
[20.0, "weather", 5.0, "on-demand-start", 6, true],
|
||||
[20.0, "weather", 5.0, "on-demand-start", 5, true],
|
||||
[25.0, "sports_recent", 15.0, "duration", 15, true],
|
||||
[40.0, "sports_upcoming", 15.0, "duration", 15, true],
|
||||
[55.0, "sports_recent", 15.0, "duration", 15, true],
|
||||
[70.0, "sports_upcoming", 15.0, "duration", 15, true],
|
||||
[85.0, "sports_recent", 10.0, "on-demand-requested-stop", 11, true],
|
||||
[85.0, "sports_recent", 10.0, "on-demand-requested-stop", 10, true],
|
||||
[95.0, "weather", 20.0, "duration", 20, true],
|
||||
[115.0, "sports_recent", 15.0, "duration", 15, true],
|
||||
[130.0, "sports_upcoming", 15.0, "duration", 15, true],
|
||||
[145.0, "clock", 5.0, "on-demand-start", 6, true],
|
||||
[145.0, "clock", 5.0, "on-demand-start", 5, true],
|
||||
[150.0, "weather", 20.0, "duration", 20, true],
|
||||
[170.0, "weather", 10.0, "on-demand-expired", 10, true],
|
||||
[180.0, "clock", 20.0, "duration", 20, true],
|
||||
|
||||
+4
-4
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"screens": [
|
||||
[0.0, "clock", 5.0, "on-demand-start", 6, false],
|
||||
[0.0, "clock", 5.0, "on-demand-start", 5, false],
|
||||
[5.0, "sports_live", 15.0, "duration", 15, true],
|
||||
[20.0, "sports_recent", 15.0, "duration", 15, true],
|
||||
[35.0, "sports_upcoming", 5.0, "on-demand-requested-stop", 6, true],
|
||||
[35.0, "sports_upcoming", 5.0, "on-demand-requested-stop", 5, true],
|
||||
[40.0, "clock", 20.0, "duration", 20, true],
|
||||
[60.0, "sports_live", 15.0, "display-false", 11, true],
|
||||
[75.0, "sports_recent", 15.0, "duration", 15, true],
|
||||
[90.0, "sports_upcoming", 10.0, "on-demand-start", 11, true],
|
||||
[90.0, "sports_upcoming", 10.0, "on-demand-start", 10, true],
|
||||
[100.0, "sports_live", 0.0, "empty", 1, true],
|
||||
[100.0, "sports_recent", 15.0, "duration", 15, true],
|
||||
[115.0, "sports_upcoming", 15.0, "duration", 15, true],
|
||||
[130.0, "sports_live", 0.0, "empty", 1, true],
|
||||
[130.0, "sports_recent", 10.0, "on-demand-requested-stop", 11, true],
|
||||
[130.0, "sports_recent", 10.0, "on-demand-requested-stop", 10, true],
|
||||
[140.0, "sports_upcoming", 15.0, "duration", 15, true],
|
||||
[155.0, "clock", 5.0, "horizon", 5, true]
|
||||
],
|
||||
|
||||
+2
-2
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"screens": [
|
||||
[0.0, "clock", 12.0, "on-demand-start", 13, false],
|
||||
[0.0, "clock", 12.0, "on-demand-start", 12, false],
|
||||
[12.0, "sports_upcoming", 15.0, "duration", 15, true],
|
||||
[27.0, "sports_upcoming", 15.0, "duration", 15, true],
|
||||
[42.0, "sports_upcoming", 15.0, "duration", 15, true],
|
||||
[57.0, "sports_upcoming", 15.0, "duration", 15, true],
|
||||
[72.0, "sports_upcoming", 8.0, "on-demand-start", 9, true],
|
||||
[72.0, "sports_upcoming", 8.0, "on-demand-start", 8, true],
|
||||
[80.0, "app_a", 0.0, "empty", 1, true],
|
||||
[80.0, "app_b", 10.0, "duration", 10, true],
|
||||
[90.0, "app_a", 0.0, "empty", 1, true],
|
||||
|
||||
+11
-11
@@ -6,22 +6,22 @@
|
||||
[70.255, "sports_live", 20.0, "duration", 20, true],
|
||||
[90.255, "sports_live", 20.0, "display-false", 11, false],
|
||||
[110.255, "<vegas>", 30.008, "duration", 3751, null],
|
||||
[140.263, "<vegas>", 10.0, "on-demand-start", 1250, null],
|
||||
[150.263, "clock", 20.0, "duration", 20, true],
|
||||
[170.263, "clock", 5.0, "on-demand-expired", 5, true],
|
||||
[175.263, "<vegas>", 24.959, "vegas-interrupt", 3120, null],
|
||||
[200.222, "<wifi>", 3.0, "duration", 6, null],
|
||||
[203.222, "<vegas>", 30.008, "duration", 3751, null],
|
||||
[233.23, "<vegas>", 26.77, "horizon", 3347, null]
|
||||
[140.263, "<vegas>", 9.744, "on-demand-start", 1218, null],
|
||||
[150.007, "clock", 20.0, "duration", 20, true],
|
||||
[170.007, "clock", 5.0, "on-demand-expired", 5, true],
|
||||
[175.007, "<vegas>", 25.999, "vegas-interrupt", 3250, null],
|
||||
[201.006, "<wifi>", 2.0, "duration", 4, null],
|
||||
[203.006, "<vegas>", 30.008, "duration", 3751, null],
|
||||
[233.014, "<vegas>", 26.986, "horizon", 3374, null]
|
||||
],
|
||||
"events": [
|
||||
[70.255, "vegas-live"],
|
||||
[70.255, "live", "sports_live"],
|
||||
[150.0, "request", "start:v1"],
|
||||
[150.263, "on-demand-start", "clock"],
|
||||
[150.263, "vegas-interrupt"],
|
||||
[175.263, "on-demand-expired"],
|
||||
[150.007, "on-demand-start", "clock"],
|
||||
[150.007, "vegas-interrupt"],
|
||||
[175.007, "on-demand-expired"],
|
||||
[200.0, "wifi-file", "Connected to HomeNet"],
|
||||
[200.222, "vegas-interrupt"]
|
||||
[201.006, "vegas-interrupt"]
|
||||
]
|
||||
}
|
||||
|
||||
+2
-1
@@ -32,7 +32,8 @@ const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.js',
|
||||
'unit/test_page_registry.js', 'unit/test_core_modules.js',
|
||||
'unit/test_overview_reconciliation_poll.js',
|
||||
'unit/test_display_partial_ids.js',
|
||||
'unit/test_general_web_login_token.js'];
|
||||
'unit/test_general_web_login_token.js',
|
||||
'unit/test_on_demand_starting.js'];
|
||||
const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js',
|
||||
'dom/test_tools_sections.js', 'dom/test_cache_page.js',
|
||||
'dom/test_durations_page.js', 'dom/test_operation_history_page.js',
|
||||
|
||||
@@ -12,10 +12,9 @@
|
||||
//
|
||||
// CodeQL reported 83 js/incomplete-html-attribute-sanitization alerts for
|
||||
// exactly this. The web UI now has one implementation, window.LEDEscape in
|
||||
// app-early.js, which every page and widget calls directly (BaseWidget keeps
|
||||
// an escapeHtml method for plugin widgets). This suite runs LEDEscape and that
|
||||
// method as shipped, and fails if a hand-rolled escaper appears anywhere else
|
||||
// in web_interface/.
|
||||
// app-early.js, and the old per-file escapers are one-line names for it. This
|
||||
// suite runs LEDEscape and every one of those names as shipped, and fails if a
|
||||
// hand-rolled escaper appears anywhere else in web_interface/.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
@@ -86,6 +85,30 @@ const ESCAPERS = [
|
||||
['app-early.js (LEDEscape.attr)', null, null, 'attr'],
|
||||
['base-widget.js (BaseWidget.escapeHtml)',
|
||||
'static/v3/js/widgets/base-widget.js', 'escapeHtml(text) {', 'escapeHtml', true],
|
||||
['plugins_manager.js (top-level escapeHtml)',
|
||||
'static/v3/plugins_manager.js', 'function escapeHtml(text) {', 'escapeHtml', false],
|
||||
['plugins_manager.js (starlark escapeHtml)',
|
||||
'static/v3/plugins_manager.js', 'function escapeHtml(str) {', 'escapeHtml', false],
|
||||
['json-file-manager.js (_esc)',
|
||||
'static/v3/js/widgets/json-file-manager.js', '_esc(str) {', '_esc', true],
|
||||
['plugin-file-manager.js (escHtml)',
|
||||
'static/v3/js/widgets/plugin-file-manager.js', 'function escHtml(s) {', 'escHtml', false],
|
||||
['plugins_manager.js (escapeAttribute)',
|
||||
'static/v3/plugins_manager.js', 'function escapeAttribute(text) {', 'escapeAttribute', false],
|
||||
['notification.js (escapeHtml)',
|
||||
'static/v3/js/widgets/notification.js', 'function escapeHtml(text) {', 'escapeHtml', false],
|
||||
['google-calendar-picker.js (escapeHtml)',
|
||||
'static/v3/js/widgets/google-calendar-picker.js', 'function escapeHtml(str) {', 'escapeHtml', false],
|
||||
['text-input.js (escapeHtml)',
|
||||
'static/v3/js/widgets/text-input.js', 'function escapeHtml(text) {', 'escapeHtml', false],
|
||||
['slider.js (escapeAttr)',
|
||||
'static/v3/js/widgets/slider.js', 'function escapeAttr(text) {', 'escapeAttr', false],
|
||||
['tools.html (escHtml)',
|
||||
'templates/v3/partials/tools.html', 'function escHtml(s) {', 'escHtml', false],
|
||||
['tools.html (phEscape)',
|
||||
'templates/v3/partials/tools.html', 'function phEscape(s) {', 'phEscape', false],
|
||||
['logs.html (escapeHtml)',
|
||||
'templates/v3/partials/logs.html', 'function escapeHtml(text) {', 'escapeHtml', false],
|
||||
// cache.html, backup_restore.html, operation_history.html and display.html
|
||||
// have no script any more (display.html's two escapers were never called):
|
||||
// their js/pages/ modules draw server data with textContent, and each
|
||||
@@ -146,7 +169,9 @@ console.log('\n4b. LEDEscape.jsStringAttr: a JS string literal that survives an
|
||||
|
||||
console.log('\n4c. no hand-rolled escaper outside app-early.js');
|
||||
{
|
||||
const skip = new Set(['static/v3/js/app-early.js']);
|
||||
const skip = new Set(['static/v3/js/app-early.js',
|
||||
// documentation example, kept self-contained on purpose
|
||||
'static/v3/js/widgets/example-color-picker.js']);
|
||||
const found = [];
|
||||
const walk = dir => fs.readdirSync(dir, { withFileTypes: true }).forEach(e => {
|
||||
const p = path.join(dir, e.name);
|
||||
@@ -283,7 +308,7 @@ console.log('\n6. url-input onInput: previewLink.href is guarded at the sink');
|
||||
|
||||
// ── plugin-file-manager: cell edits travel via data-*, not inline handlers ──
|
||||
// A JSON key/day from an uploaded file used to be spliced, HTML-escaped,
|
||||
// into an oninput="...('${escHtml(col)}'...)" attribute. Escaping neutralises
|
||||
// into an oninput="...('${escHtml(col)}'...)" attribute. escHtml neutralises
|
||||
// a quote for an ordinary attribute, but here the value also has to survive
|
||||
// as a *JS string literal* -- the browser HTML-decodes the attribute before
|
||||
// running it as script, which turns the escaped quote back into a real one
|
||||
@@ -307,10 +332,11 @@ console.log("\n7. plugin-file-manager: cell edits never go through an inline han
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const escHtmlFn = loadFn('static/v3/js/widgets/plugin-file-manager.js', 'function escHtml(s) {', 'escHtml', false);
|
||||
const renderEntryTableSrc = extractFn('function renderEntryTable(fieldId, container, content) {');
|
||||
|
||||
const calls = [];
|
||||
const fakeWindow = { LEDEscape, _pfmCellEdit: (fieldId, day, col, value) => calls.push({ fieldId, day, col, value }) };
|
||||
const fakeWindow = { _pfmCellEdit: (fieldId, day, col, value) => calls.push({ fieldId, day, col, value }) };
|
||||
|
||||
class FakeContainer {
|
||||
constructor() { this._html = ''; this._listeners = {}; }
|
||||
@@ -328,11 +354,12 @@ console.log("\n7. plugin-file-manager: cell edits never go through an inline han
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-eval
|
||||
const renderEntryTable = eval(`(function(getState, safeSetHTML, window){
|
||||
const renderEntryTable = eval(`(function(getState, escHtml, safeSetHTML, window){
|
||||
${renderEntryTableSrc}
|
||||
return renderEntryTable;
|
||||
})`)(
|
||||
() => ({ entriesPerPage: 20, _tablePage: 1 }),
|
||||
escHtmlFn,
|
||||
(target, html) => { target.innerHTML = html; },
|
||||
fakeWindow
|
||||
);
|
||||
|
||||
@@ -62,12 +62,12 @@ global.setGridHtmlIfChanged = (container, html) => { container.innerHTML = html;
|
||||
|
||||
// eslint-disable-next-line no-eval
|
||||
eval([
|
||||
'function jsStringAttr(value) {',
|
||||
'function escapeHtml(text) {', 'function escapeAttribute(text) {', 'function jsStringAttr(value) {',
|
||||
'function renderPluginStore(plugins) {', 'function renderSavedRepositories(repositories) {',
|
||||
'function renderCustomRegistryPlugins(plugins, registryUrl) {',
|
||||
].map(extract).join('\n') + '\nglobal.jsStringAttr = jsStringAttr;'
|
||||
].map(extract).join('\n') + '\nglobal.jsStringAttr = jsStringAttr; global.escapeHtml = escapeHtml;'
|
||||
+ '\nglobal.renderPluginStore = renderPluginStore; global.renderSavedRepositories = renderSavedRepositories;'
|
||||
+ '\nglobal.renderCustomRegistryPlugins = renderCustomRegistryPlugins;');
|
||||
+ '\nglobal.renderCustomRegistryPlugins = renderCustomRegistryPlugins; global.escapeAttribute = escapeAttribute;');
|
||||
|
||||
// ── minimal HTML start-tag tokenizer ───────────────────────────────────────
|
||||
function decodeEntities(s) {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// POST /api/v3/display/on-demand/start answers 202 with status "starting"
|
||||
// when the display service has to be started first: the request is taken,
|
||||
// and the web process sends it once the display listens. "Preview on
|
||||
// display" (app.js) must read that as taken -- an info toast and the
|
||||
// floating preview opened -- not as a failure. Runs the shipped app.js in a
|
||||
// vm with a minimal fake DOM, as test_restart_banner.js does.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
const V3 = path.resolve(__dirname, '../../../web_interface/static/v3');
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
const ok = (label, cond, extra) => cond
|
||||
? (pass++, console.log(' ok ' + label))
|
||||
: (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' ' + JSON.stringify(extra) : '')));
|
||||
|
||||
function load(answer) {
|
||||
const notes = [];
|
||||
const opened = [];
|
||||
const noop = () => {};
|
||||
const document = {
|
||||
body: { addEventListener: noop },
|
||||
addEventListener: noop,
|
||||
getElementById: () => null,
|
||||
querySelector: () => null,
|
||||
querySelectorAll: () => [],
|
||||
};
|
||||
const window = { addEventListener: noop, getApp: () => null };
|
||||
const context = {
|
||||
window, document, console,
|
||||
sessionStorage: { setItem: noop, removeItem: noop, getItem: () => null },
|
||||
showNotification: (m, t) => notes.push([m, t]),
|
||||
setTimeout: () => 0,
|
||||
fetch: () => Promise.resolve({ json: () => Promise.resolve(answer) }),
|
||||
};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(fs.readFileSync(path.join(V3, 'app.js'), 'utf8'), context);
|
||||
window.toggleFloatingPreview = (open) => opened.push(open);
|
||||
return { window, notes, opened };
|
||||
}
|
||||
|
||||
async function preview(answer) {
|
||||
const t = load(answer);
|
||||
t.window.previewPluginNow('weather');
|
||||
for (let i = 0; i < 5; i++) await Promise.resolve();
|
||||
return t;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
console.log('\npreviewPluginNow');
|
||||
{
|
||||
const t = await preview({ status: 'starting', message: 'The display service is starting',
|
||||
data: { request_id: 'r1', pending: true } });
|
||||
ok('a 202 "starting" answer is an info toast, not an error',
|
||||
t.notes.length === 1 && t.notes[0][1] === 'info', t.notes);
|
||||
ok('and the preview opens', t.opened.length === 1 && t.opened[0] === true, t.opened);
|
||||
}
|
||||
{
|
||||
const t = await preview({ status: 'success', data: { request_id: 'r1' } });
|
||||
ok('a 200 success still opens it', t.opened.length === 1 && t.notes[0][1] === 'success', t.notes);
|
||||
}
|
||||
{
|
||||
const t = await preview({ status: 'error', message: 'no display' });
|
||||
ok('an error does not', t.opened.length === 0 && t.notes[0][1] === 'error', t.notes);
|
||||
}
|
||||
console.log(`\n${pass} passed, ${fail} failed`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
})();
|
||||
@@ -16,7 +16,7 @@ const container = {
|
||||
innerHTML: '',
|
||||
querySelectorAll: () => [], // no skeletons in this harness
|
||||
};
|
||||
// LEDEscape.html() escapes via a detached element, so mirror what a browser does
|
||||
// escapeHtml() escapes via a detached element, so mirror what a browser does
|
||||
// when you read innerHTML back off textContent: & < > are escaped, quotes are not.
|
||||
class FakeEl {
|
||||
set textContent(v) { this._t = String(v == null ? '' : v); }
|
||||
@@ -36,7 +36,7 @@ global.PLUGIN_DEBUG = false;
|
||||
global.debugLog = () => {};
|
||||
function setupInstalledEventDelegation() {} // stubbed; tested separately
|
||||
|
||||
eval(slice('function jsStringAttr(value)', '\nfunction isNewPlugin'));
|
||||
eval(slice('function escapeHtml(text)', '\nfunction isNewPlugin'));
|
||||
eval(slice('function renderInstalledCards(plugins, total)',
|
||||
'// Set up event delegation for plugin action buttons'));
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ global.installedPlugins = [];
|
||||
|
||||
// eslint-disable-next-line no-eval
|
||||
eval([
|
||||
'function jsStringAttr(value) {',
|
||||
'function escapeHtml(text) {', 'function escapeAttribute(text) {', 'function jsStringAttr(value) {',
|
||||
'function isStorePluginInstalled(pluginIdOrPlugin) {',
|
||||
'function findInstalledStorePlugin(pluginIdOrPlugin) {', 'function renderPluginStore(plugins) {',
|
||||
].map(extract).join('\n') + '\nglobal.renderPluginStore = renderPluginStore;'
|
||||
|
||||
@@ -253,10 +253,9 @@ const noSleep = { sleep: async () => {} };
|
||||
for (const endpoint of bad) codes.push(await refusal(endpoint));
|
||||
ok('an endpoint that could leave the API path is refused before fetch()',
|
||||
codes.every(c => c === 'INVALID_ENDPOINT') && urls.length === 0, { codes, urls });
|
||||
global.debugLog = () => {}; // GETs go through the throttler, which logs
|
||||
await PluginAPI.getPluginHealth('a/../b&x=1');
|
||||
await PluginAPI.resetPluginConfig('a/../b&x=1');
|
||||
ok('a plugin id is encoded into the URL, not spliced into it',
|
||||
urls[0] === '/api/v3/plugins/health/a%2F..%2Fb%26x%3D1', urls);
|
||||
urls[0] === '/api/v3/plugins/config/reset?plugin_id=a%2F..%2Fb%26x%3D1', urls);
|
||||
delete global.fetch;
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +54,10 @@ class TestOnDemandStart:
|
||||
def service(self, api_v3_module):
|
||||
api_v3_module.api_v3.plugin_catalog = None
|
||||
api_v3_module.api_v3.config_manager = None
|
||||
with patch("web_interface.blueprints.api_v3.display._get_display_service_status",
|
||||
# The display answers the control socket (the only way in).
|
||||
with patch("web_interface.blueprints.api_v3.display.control_client.on_demand_start",
|
||||
side_effect=lambda request_id, *a: {"accepted": True}), \
|
||||
patch("web_interface.blueprints.api_v3.display._get_display_service_status",
|
||||
return_value={"active": True}), \
|
||||
patch("web_interface.blueprints.api_v3.display._stop_display_service") as stop, \
|
||||
patch("web_interface.blueprints.api_v3.display._ensure_display_service_running",
|
||||
|
||||
@@ -7,7 +7,7 @@ manifest.json off disk and reimplemented PluginManager's own fallbacks.
|
||||
"""
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -155,14 +155,3 @@ class TestOneBadConfigSectionDoesNotBlankTheList:
|
||||
side_effect=RuntimeError("GET https://x/y?api_key=SEC123 failed"))
|
||||
body = api_v3_client.get('/api/v3/display/modes').get_json()
|
||||
assert 'SEC123' not in json.dumps(body)
|
||||
|
||||
|
||||
class TestOnDemandUsesTheRegisteredSpelling:
|
||||
def test_a_mode_differing_in_case_is_sent_as_registered(self, client):
|
||||
with patch('web_interface.blueprints.api_v3.display._deliver_on_demand',
|
||||
return_value=('socket', None)) as deliver:
|
||||
response = client.post('/api/v3/display/on-demand/start',
|
||||
json={'plugin_id': 'football-scoreboard',
|
||||
'mode': 'NFL_LIVE', 'start_service': False})
|
||||
assert response.status_code == 200, response.get_json()
|
||||
assert deliver.call_args.args[0]['mode'] == 'nfl_live'
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
"""POST /plugins/install asks for a restart by the id the plugin installed as.
|
||||
|
||||
A store install needs a display restart when config.json already enables the
|
||||
plugin (a reinstall, or a config carried over): the display loads a plugin
|
||||
when its ``enabled`` flag changes, and this flag did not. The route read the
|
||||
flag under the registry id it was given. An aliased entry installs under
|
||||
another id -- ``weather`` installs a directory whose manifest declares
|
||||
``ledmatrix-weather``, and its config section is ``ledmatrix-weather`` -- so
|
||||
reinstalling an enabled Weather never reported that a restart was needed,
|
||||
and the display kept running the old copy.
|
||||
"""
|
||||
|
||||
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):
|
||||
"""The store installs registry entry ``weather`` as ``installed_id``."""
|
||||
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 installs_as(installed_id):
|
||||
path = tmp_path / installed_id
|
||||
path.mkdir()
|
||||
(path / "manifest.json").write_text(json.dumps({"id": installed_id}),
|
||||
encoding="utf-8")
|
||||
manager._find_plugin_path.side_effect = (
|
||||
lambda pid: path if pid == "weather" else None)
|
||||
|
||||
manager.installs_as = installs_as
|
||||
return manager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config(api_v3_module):
|
||||
"""config.json with an ``enabled`` flag for each plugin id given."""
|
||||
def sections(enabled):
|
||||
api_v3_module.api_v3.config_manager.load_config.return_value = {
|
||||
plugin_id: {"enabled": flag} for plugin_id, flag in enabled.items()}
|
||||
return sections
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
def _direct(client):
|
||||
return client.post(INSTALL, json={"plugin_id": "weather"}).get_json()
|
||||
|
||||
|
||||
def _queued(client, queue):
|
||||
client.post(INSTALL, json={"plugin_id": "weather"})
|
||||
return queue.callback_result
|
||||
|
||||
|
||||
class TestDirectInstall:
|
||||
def test_an_aliased_install_enabled_under_its_installed_id_asks_for_a_restart(
|
||||
self, api_v3_client, store, config):
|
||||
store.installs_as("ledmatrix-weather")
|
||||
config({"ledmatrix-weather": True})
|
||||
body = _direct(api_v3_client)
|
||||
assert body["status"] == "success"
|
||||
assert body["restart_required"] is True
|
||||
assert body["restart_message"]
|
||||
|
||||
def test_an_enabled_section_under_the_registry_id_alone_does_not(
|
||||
self, api_v3_client, store, config):
|
||||
"""The display knows the plugin as ledmatrix-weather; nothing runs
|
||||
under a section called weather."""
|
||||
store.installs_as("ledmatrix-weather")
|
||||
config({"weather": True})
|
||||
assert _direct(api_v3_client)["restart_required"] is False
|
||||
|
||||
def test_an_aliased_install_that_is_not_enabled_needs_no_restart(
|
||||
self, api_v3_client, store, config):
|
||||
store.installs_as("ledmatrix-weather")
|
||||
config({"ledmatrix-weather": False})
|
||||
assert _direct(api_v3_client)["restart_required"] is False
|
||||
|
||||
def test_an_install_under_its_own_id_is_unchanged(self, api_v3_client, store, config):
|
||||
store.installs_as("weather")
|
||||
config({"weather": True})
|
||||
assert _direct(api_v3_client)["restart_required"] is True
|
||||
|
||||
def test_an_install_that_cannot_be_found_uses_the_requested_id(
|
||||
self, api_v3_client, store, config):
|
||||
config({"weather": True})
|
||||
assert _direct(api_v3_client)["restart_required"] is True
|
||||
|
||||
|
||||
class TestQueuedInstall:
|
||||
def test_an_aliased_install_enabled_under_its_installed_id_asks_for_a_restart(
|
||||
self, api_v3_client, store, config, queued):
|
||||
store.installs_as("ledmatrix-weather")
|
||||
config({"ledmatrix-weather": True})
|
||||
result = _queued(api_v3_client, queued)
|
||||
assert result["success"] is True
|
||||
assert result["restart_required"] is True
|
||||
assert result["restart_message"]
|
||||
|
||||
def test_an_enabled_section_under_the_registry_id_alone_does_not(
|
||||
self, api_v3_client, store, config, queued):
|
||||
store.installs_as("ledmatrix-weather")
|
||||
config({"weather": True})
|
||||
assert _queued(api_v3_client, queued)["restart_required"] is False
|
||||
@@ -77,11 +77,22 @@ def fresh_web_process(api_v3_module, plugins_dir):
|
||||
|
||||
@pytest.fixture
|
||||
def display_service(api_v3_module):
|
||||
"""Keep on-demand start away from systemctl and the real cache."""
|
||||
cache = api_v3_module.api_v3.cache_manager = MagicMock()
|
||||
with patch('web_interface.blueprints.api_v3.display._get_display_service_status') as status:
|
||||
"""Keep on-demand start away from systemctl and the real cache; the
|
||||
display's control socket is a mock that acks. What it was sent is
|
||||
recorded as ``.set(cmd, request)`` calls on the yielded mock."""
|
||||
api_v3_module.api_v3.cache_manager = MagicMock()
|
||||
sent = MagicMock()
|
||||
|
||||
def ack(request_id, plugin_id, mode, *a, **kw):
|
||||
sent.set('on_demand.start', {'request_id': request_id,
|
||||
'plugin_id': plugin_id, 'mode': mode})
|
||||
return {'accepted': True}
|
||||
|
||||
with patch('web_interface.blueprints.api_v3.display._get_display_service_status') as status, \
|
||||
patch('web_interface.blueprints.api_v3.display.control_client.on_demand_start',
|
||||
side_effect=ack):
|
||||
status.return_value = {'active': True}
|
||||
yield cache
|
||||
yield sent
|
||||
|
||||
|
||||
def _start(client, **body):
|
||||
|
||||
@@ -5,18 +5,20 @@ the web UI and the MQTT bridge send) as "restart": with the service running it
|
||||
ran ``systemctl stop``, slept 1.5s and started it again. Every on-demand or
|
||||
"Preview on display" click therefore cold-restarted the display process --
|
||||
every plugin reloaded, the panel blank for seconds -- to deliver a request the
|
||||
running process polls for every ON_DEMAND_POLL_INTERVAL anyway (see
|
||||
test_on_demand_mailbox.py and test_display_pending_changes.py for the display
|
||||
side: the mailbox is read mid-dwell, mid-screen and mid-Vegas-iteration).
|
||||
running process takes within a frame over its control socket anyway (see
|
||||
test_display_pending_changes.py for the display side: commands land
|
||||
mid-dwell, mid-screen and mid-Vegas-iteration).
|
||||
|
||||
The restart did not buy anything either: a freshly started display restores
|
||||
only the on-demand session it saved itself (``display_on_demand_config``), so
|
||||
the new request reached it through the same mailbox, one cold start later.
|
||||
only the on-demand session it saved itself (``display_on_demand_config``).
|
||||
|
||||
This file previously pinned that restart path (it guarded a broken
|
||||
``import _pkg.time`` inside it). The path is gone; these tests pin its
|
||||
replacement: a running service is left alone, a stopped one is started (only
|
||||
when start_service is set), and the request lands in the mailbox either way.
|
||||
when start_service is set), and the request goes over the control socket
|
||||
either way -- to a stopped display once it has started and its socket is up,
|
||||
sent by the web process's dispatcher after the route has answered 202.
|
||||
Nothing is ever written to the cache: the file mailbox is gone (stage 5).
|
||||
|
||||
The service helpers are patched where they run. display.py binds
|
||||
_get_display_service_status by value, while _ensure_display_service_running
|
||||
@@ -25,6 +27,7 @@ _run_systemctl_command is the one place a systemctl command is issued.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -34,6 +37,8 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
||||
|
||||
from src.ipc import client as control_client # noqa: E402
|
||||
|
||||
START_URL = "/api/v3/display/on-demand/start"
|
||||
STOP_URL = "/api/v3/display/on-demand/stop"
|
||||
MAILBOX = "display_on_demand_request"
|
||||
@@ -46,7 +51,10 @@ def service(api_v3_module):
|
||||
|
||||
plugin_manager and config_manager are None so the route skips plugin
|
||||
resolution (not what is under test here). The cache is the blueprint's
|
||||
MagicMock cache_manager, so mailbox writes are visible as set() calls.
|
||||
MagicMock cache_manager, so a write would be visible as a set() call.
|
||||
|
||||
The control socket answers while the service is active and is missing
|
||||
(``no_socket``) while it is not; ``sent`` records what it carried.
|
||||
"""
|
||||
api_v3_module.api_v3.plugin_catalog = None
|
||||
api_v3_module.api_v3.config_manager = None
|
||||
@@ -62,7 +70,32 @@ def service(api_v3_module):
|
||||
state["active"] = False
|
||||
return {"returncode": 0, "stdout": "", "stderr": ""}
|
||||
|
||||
with patch("web_interface.blueprints.api_v3._get_display_service_status",
|
||||
sent = []
|
||||
|
||||
def socket(action):
|
||||
def call(request_id, *args, **kwargs):
|
||||
if not state["active"]:
|
||||
raise control_client.ControlError("no_socket", "x", sent=False)
|
||||
sent.append((action, request_id) + args)
|
||||
return {"accepted": True, "request_id": request_id}
|
||||
return call
|
||||
|
||||
class Clock:
|
||||
now = 1000.0
|
||||
|
||||
def monotonic(self):
|
||||
return self.now
|
||||
|
||||
def time(self):
|
||||
return self.now
|
||||
|
||||
def sleep(self, seconds):
|
||||
self.now += seconds
|
||||
|
||||
with patch("web_interface.blueprints.api_v3.time", Clock()), \
|
||||
patch(f"{DISPLAY}.control_client.on_demand_start", side_effect=socket("start")), \
|
||||
patch(f"{DISPLAY}.control_client.on_demand_stop", side_effect=socket("stop")), \
|
||||
patch("web_interface.blueprints.api_v3._get_display_service_status",
|
||||
side_effect=status), \
|
||||
patch("web_interface.blueprints.api_v3.display._get_display_service_status",
|
||||
side_effect=status), \
|
||||
@@ -74,6 +107,7 @@ def service(api_v3_module):
|
||||
"systemctl": run_systemctl,
|
||||
"stop_service": stop_service,
|
||||
"cache": api_v3_module.api_v3.cache_manager,
|
||||
"sent": sent,
|
||||
}
|
||||
|
||||
|
||||
@@ -99,19 +133,15 @@ class TestStartWhileTheServiceIsRunning:
|
||||
assert _systemctl_verbs(service["systemctl"]) == [], (
|
||||
"a running display service was sent a systemctl command")
|
||||
|
||||
def test_the_request_is_posted_for_the_running_display(self, api_v3_client, service):
|
||||
def test_the_request_is_sent_to_the_running_display(self, api_v3_client, service):
|
||||
response = api_v3_client.post(
|
||||
START_URL, json={"plugin_id": "weather", "mode": "weather_current",
|
||||
"duration": 60, "pinned": True})
|
||||
data = response.get_json()["data"]
|
||||
writes = _mailbox_writes(service["cache"])
|
||||
assert len(writes) == 1
|
||||
assert writes[0]["action"] == "start"
|
||||
assert writes[0]["request_id"] == data["request_id"]
|
||||
assert writes[0]["plugin_id"] == "weather"
|
||||
assert writes[0]["mode"] == "weather_current"
|
||||
assert writes[0]["duration"] == 60
|
||||
assert writes[0]["pinned"] is True
|
||||
assert service["sent"] == [("start", data["request_id"], "weather",
|
||||
"weather_current", 60, True)]
|
||||
assert data["transport"] == "socket"
|
||||
assert _mailbox_writes(service["cache"]) == []
|
||||
|
||||
def test_the_response_reports_the_service_was_not_started(self, api_v3_client, service):
|
||||
data = api_v3_client.post(START_URL, json={"plugin_id": "weather"}).get_json()["data"]
|
||||
@@ -129,11 +159,20 @@ class TestStartWhileTheServiceIsStopped:
|
||||
def test_start_service_starts_it_once_and_never_stops_it(self, api_v3_client, service):
|
||||
service["state"]["active"] = False
|
||||
response = api_v3_client.post(START_URL, json={"plugin_id": "weather"})
|
||||
assert response.status_code == 200, response.get_json()
|
||||
# Answered at once: the web process sends the request in the
|
||||
# background once the started display listens.
|
||||
assert response.status_code == 202, response.get_json()
|
||||
assert response.get_json()["status"] == "starting"
|
||||
assert _systemctl_verbs(service["systemctl"]) == ["start"]
|
||||
service["stop_service"].assert_not_called()
|
||||
# Written before the start, so the new process finds it on its first poll.
|
||||
assert len(_mailbox_writes(service["cache"])) == 1
|
||||
from web_interface import on_demand_dispatch
|
||||
dispatcher = on_demand_dispatch.current()
|
||||
deadline = time.monotonic() + 5
|
||||
while dispatcher.pending() and time.monotonic() < deadline:
|
||||
time.sleep(0.01)
|
||||
assert dispatcher.status()["status"] == "delivered"
|
||||
assert [s[0] for s in service["sent"]] == ["start"]
|
||||
assert _mailbox_writes(service["cache"]) == []
|
||||
|
||||
def test_without_start_service_it_is_left_stopped(self, api_v3_client, service):
|
||||
service["state"]["active"] = False
|
||||
@@ -141,6 +180,7 @@ class TestStartWhileTheServiceIsStopped:
|
||||
START_URL, json={"plugin_id": "weather", "start_service": "false"})
|
||||
assert response.status_code == 400
|
||||
assert _systemctl_verbs(service["systemctl"]) == []
|
||||
assert service["sent"] == []
|
||||
|
||||
def test_a_start_that_fails_is_reported(self, api_v3_client, service):
|
||||
service["state"]["active"] = False
|
||||
@@ -151,31 +191,12 @@ class TestStartWhileTheServiceIsStopped:
|
||||
assert response.get_json()["status"] == "error"
|
||||
|
||||
|
||||
class _Mailbox:
|
||||
"""The CacheManager calls the routes make, over a dict."""
|
||||
|
||||
def __init__(self):
|
||||
self.entries = {}
|
||||
|
||||
def set(self, key, value, ttl=None):
|
||||
self.entries[key] = value
|
||||
|
||||
def get(self, key, max_age=300, memory_ttl=None):
|
||||
return self.entries.get(key)
|
||||
|
||||
def delete(self, key):
|
||||
self.entries.pop(key, None)
|
||||
|
||||
|
||||
class TestARefusedStartLeavesNoRequestBehind:
|
||||
"""A start the route answers with an error must not run later.
|
||||
|
||||
The request was posted (to the mailbox, with the display stopped) before
|
||||
the route refused it, and the display reads the mailbox for an hour
|
||||
without looking at a request's age. So "Display service is not running"
|
||||
(start_service off) or "Failed to start display service" left the
|
||||
request waiting, and the next time the display started -- minutes later,
|
||||
by hand -- it ran that plugin, pinned if the request said so.
|
||||
With the mailbox, the request was posted before the route refused it, and
|
||||
a display started later ran it. Now nothing is written anywhere: the
|
||||
request only ever goes over the socket, to a display that answers.
|
||||
|
||||
A socket acknowledgement is the other side of it: the display answered,
|
||||
so it is running and has the request, whatever systemd says (a display
|
||||
@@ -184,76 +205,48 @@ class TestARefusedStartLeavesNoRequestBehind:
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def mailbox(self, api_v3_module, service):
|
||||
box = _Mailbox()
|
||||
api_v3_module.api_v3.cache_manager = box
|
||||
def stopped(self, service):
|
||||
service["state"]["active"] = False
|
||||
return box
|
||||
return service
|
||||
|
||||
@pytest.mark.parametrize("body", [
|
||||
{"plugin_id": "weather", "start_service": False},
|
||||
{"plugin_id": "weather"}, # start_service defaults on
|
||||
])
|
||||
def test_a_socket_ack_is_a_success_whatever_systemd_says(
|
||||
self, api_v3_client, service, mailbox, body):
|
||||
self, api_v3_client, stopped, body):
|
||||
with patch(f"{DISPLAY}.control_client.on_demand_start",
|
||||
side_effect=lambda request_id, *a: {"accepted": True}):
|
||||
response = api_v3_client.post(START_URL, json=body)
|
||||
assert response.status_code == 200, response.get_json()
|
||||
assert response.get_json()["data"]["transport"] == "socket"
|
||||
assert MAILBOX not in mailbox.entries
|
||||
assert _systemctl_verbs(service["systemctl"]) == [], (
|
||||
assert _systemctl_verbs(stopped["systemctl"]) == [], (
|
||||
"a unit was started beside a display that answered the socket")
|
||||
|
||||
def test_without_start_service_the_request_is_taken_back(
|
||||
self, api_v3_client, service, mailbox):
|
||||
def test_without_start_service_nothing_is_left_behind(self, api_v3_client, stopped):
|
||||
response = api_v3_client.post(START_URL, json={
|
||||
"plugin_id": "weather", "pinned": True, "start_service": False})
|
||||
assert response.status_code == 400
|
||||
assert response.get_json()["status"] == "error"
|
||||
assert MAILBOX not in mailbox.entries
|
||||
assert stopped["cache"].set.call_count == 0
|
||||
assert stopped["sent"] == []
|
||||
|
||||
def test_a_start_that_fails_takes_its_request_back(self, api_v3_client, service, mailbox):
|
||||
service["systemctl"].side_effect = lambda args: {
|
||||
def test_a_start_that_fails_leaves_nothing_behind(self, api_v3_client, stopped):
|
||||
stopped["systemctl"].side_effect = lambda args: {
|
||||
"returncode": 1, "stdout": "", "stderr": "denied"}
|
||||
response = api_v3_client.post(START_URL, json={"plugin_id": "weather"})
|
||||
assert response.status_code == 500
|
||||
assert MAILBOX not in mailbox.entries
|
||||
|
||||
def test_a_newer_request_is_left_alone_on_the_400(self, api_v3_client, service, mailbox):
|
||||
newer = {"request_id": "someone-else", "action": "start", "plugin_id": "clock"}
|
||||
|
||||
def stopped_and_another_post_lands(*args):
|
||||
mailbox.entries[MAILBOX] = newer
|
||||
return {"active": False}
|
||||
|
||||
with patch(f"{DISPLAY}._get_display_service_status",
|
||||
side_effect=stopped_and_another_post_lands):
|
||||
response = api_v3_client.post(START_URL, json={
|
||||
"plugin_id": "weather", "start_service": False})
|
||||
assert response.status_code == 400
|
||||
assert mailbox.entries[MAILBOX] is newer
|
||||
|
||||
def test_a_newer_request_is_left_alone_on_the_500(self, api_v3_client, service, mailbox):
|
||||
newer = {"request_id": "someone-else", "action": "start", "plugin_id": "clock"}
|
||||
|
||||
def start_fails_after_another_post(args):
|
||||
mailbox.entries[MAILBOX] = newer
|
||||
return {"returncode": 1, "stdout": "", "stderr": "denied"}
|
||||
|
||||
service["systemctl"].side_effect = start_fails_after_another_post
|
||||
response = api_v3_client.post(START_URL, json={"plugin_id": "weather"})
|
||||
assert response.status_code == 500
|
||||
assert mailbox.entries[MAILBOX] is newer
|
||||
assert stopped["cache"].set.call_count == 0
|
||||
assert stopped["sent"] == []
|
||||
|
||||
|
||||
class TestStop:
|
||||
def test_stop_posts_a_stop_request_and_leaves_the_service_running(
|
||||
def test_stop_sends_a_stop_request_and_leaves_the_service_running(
|
||||
self, api_v3_client, service):
|
||||
response = api_v3_client.post(STOP_URL, json={})
|
||||
assert response.status_code == 200, response.get_json()
|
||||
writes = _mailbox_writes(service["cache"])
|
||||
assert [w["action"] for w in writes] == ["stop"]
|
||||
assert [s[0] for s in service["sent"]] == ["stop"]
|
||||
assert _mailbox_writes(service["cache"]) == []
|
||||
service["stop_service"].assert_not_called()
|
||||
assert _systemctl_verbs(service["systemctl"]) == []
|
||||
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
"""POST /display/on-demand/start and /stop: control socket first, mailbox fallback.
|
||||
"""POST /display/on-demand/start and /stop: the control socket is the only way.
|
||||
|
||||
The routes hand the request to the display over the control socket
|
||||
(src/ipc) and get an acknowledgement. When the socket could not carry the
|
||||
request -- no socket (a stopped display, or one older than the socket), a
|
||||
refused or timed-out connect, a display too old to know the command, a bug
|
||||
in the client -- they write the file mailbox exactly as they did before the
|
||||
socket existed. When the display had the request and failed it (a full
|
||||
queue, bad arguments, no answer in time) the route says so and writes
|
||||
nothing. These tests pin each path, that at most one of them is used, that
|
||||
the response says which, and that the request id is the same either way (the
|
||||
display deduplicates on it).
|
||||
(src/ipc) and get an acknowledgement. The file mailbox they used to fall
|
||||
back to (``display_on_demand_request``) is gone (stage 5), so nothing is
|
||||
ever written to the cache. When no display is listening (a stopped one, or
|
||||
one still starting) the start route starts the service if asked to and
|
||||
sends the request again once the socket is up; every other failure is
|
||||
answered as what it is.
|
||||
|
||||
The socket client is patched at the route's module attribute; the last class
|
||||
runs a real server on a temp socket (Linux/macOS only).
|
||||
@@ -17,6 +14,8 @@ runs a real server on a temp socket (Linux/macOS only).
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import types
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -33,11 +32,12 @@ START_URL = "/api/v3/display/on-demand/start"
|
||||
STOP_URL = "/api/v3/display/on-demand/stop"
|
||||
MAILBOX = "display_on_demand_request"
|
||||
CLIENT = "web_interface.blueprints.api_v3.display.control_client"
|
||||
DISPLAY = "web_interface.blueprints.api_v3.display"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service(api_v3_module):
|
||||
"""A running display service; records systemctl calls and mailbox writes."""
|
||||
"""A running display service; records systemctl calls and cache writes."""
|
||||
api_v3_module.api_v3.plugin_catalog = None
|
||||
api_v3_module.api_v3.config_manager = None
|
||||
state = {"active": True}
|
||||
@@ -101,85 +101,398 @@ class TestSocketPath:
|
||||
assert _mailbox_writes(service["cache"]) == []
|
||||
|
||||
|
||||
class TestMailboxFallback:
|
||||
@pytest.mark.parametrize("reason", [
|
||||
"no_socket", "refused", "timeout", "closed", "bad_response", "invalid_request",
|
||||
"busy", "forbidden", "unknown_command", "unsupported_version", "disabled",
|
||||
"unsupported",
|
||||
])
|
||||
def test_a_request_the_socket_never_carried_writes_the_mailbox(
|
||||
self, api_v3_client, service, reason):
|
||||
# sent=False: the display never had it (no socket, a refused or
|
||||
# timed-out connect, turned away at the door).
|
||||
with patch(f"{CLIENT}.on_demand_start",
|
||||
side_effect=control_client.ControlError(reason, "x", sent=False)):
|
||||
resp = api_v3_client.post(START_URL, json={
|
||||
"plugin_id": "weather", "mode": "weather_current",
|
||||
"duration": 60, "pinned": True})
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()["data"]
|
||||
assert data["transport"] == "mailbox"
|
||||
assert data["socket_error"] == reason
|
||||
[write] = _mailbox_writes(service["cache"])
|
||||
assert write["request_id"] == data["request_id"]
|
||||
assert write["action"] == "start"
|
||||
assert (write["plugin_id"], write["mode"], write["duration"], write["pinned"]) == \
|
||||
("weather", "weather_current", 60, True)
|
||||
class FakeTime:
|
||||
"""Stands in for the route's ``time`` module: sleep() moves the clock."""
|
||||
|
||||
def test_a_client_bug_still_falls_back(self, api_v3_client, service):
|
||||
def __init__(self):
|
||||
self.now = 1000.0
|
||||
self.sleeps = 0
|
||||
|
||||
def monotonic(self):
|
||||
return self.now
|
||||
|
||||
def time(self):
|
||||
return self.now
|
||||
|
||||
def sleep(self, seconds):
|
||||
self.sleeps += 1
|
||||
self.now += seconds
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clock():
|
||||
fake = FakeTime()
|
||||
with patch("web_interface.blueprints.api_v3.time", fake):
|
||||
yield fake
|
||||
|
||||
|
||||
def _attempts(*outcomes, calls=None, clock=None):
|
||||
"""A fake on_demand_start/stop that answers ``outcomes`` in turn (an
|
||||
exception is raised, anything else acks); the last one repeats."""
|
||||
outcomes = list(outcomes)
|
||||
seen = calls if calls is not None else []
|
||||
|
||||
def attempt(request_id, *a, **kw):
|
||||
seen.append(clock.now if clock is not None else None)
|
||||
outcome = outcomes.pop(0) if len(outcomes) > 1 else outcomes[0]
|
||||
if isinstance(outcome, BaseException):
|
||||
raise outcome
|
||||
return _ack(request_id)
|
||||
return attempt
|
||||
|
||||
|
||||
def _until(predicate, timeout=5.0):
|
||||
end = time.monotonic() + timeout
|
||||
while time.monotonic() < end:
|
||||
if predicate():
|
||||
return True
|
||||
time.sleep(0.005)
|
||||
return False
|
||||
|
||||
|
||||
def _no_socket():
|
||||
return control_client.ControlError("no_socket", "x", sent=False)
|
||||
|
||||
|
||||
class TestNoDisplayListening:
|
||||
"""No socket to talk to: the display is stopped, or still starting.
|
||||
|
||||
The route answers at once (202, ``status: "starting"``) and the web
|
||||
process's dispatcher (web_interface/on_demand_dispatch.py) sends the
|
||||
request until the display acknowledges it; the status routes report the
|
||||
outcome. The dispatcher runs on real time with short waits here.
|
||||
"""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def quick(self, monkeypatch, service):
|
||||
from web_interface import on_demand_dispatch
|
||||
monkeypatch.setattr(on_demand_dispatch, "RETRY_INTERVAL", 0.01)
|
||||
monkeypatch.setattr(on_demand_dispatch, "START_WAIT_SECONDS", 2.0)
|
||||
monkeypatch.setattr(f"{DISPLAY}.ON_DEMAND_SOCKET_WAIT_RUNNING_SECONDS", 1.0)
|
||||
service["cache"].get.return_value = None # the display has published nothing
|
||||
|
||||
@staticmethod
|
||||
def _outcome(status=None):
|
||||
from web_interface import on_demand_dispatch
|
||||
d = on_demand_dispatch.current()
|
||||
assert d is not None
|
||||
assert _until(lambda: not d.pending()), "the dispatcher never finished"
|
||||
return d.status()
|
||||
|
||||
def test_a_display_still_starting_gets_the_request_once_it_listens(
|
||||
self, api_v3_client, service):
|
||||
calls = []
|
||||
with patch(f"{CLIENT}.on_demand_start",
|
||||
side_effect=_attempts(_no_socket(), _no_socket(), "ack", calls=calls)):
|
||||
resp = api_v3_client.post(START_URL, json={"plugin_id": "weather"})
|
||||
assert resp.status_code == 202, resp.get_json()
|
||||
body = resp.get_json()
|
||||
assert body["status"] == "starting"
|
||||
data = body["data"]
|
||||
assert data["pending"] is True and data["socket_error"] == "no_socket"
|
||||
assert data["service"]["started"] is False
|
||||
outcome = self._outcome()
|
||||
assert outcome["status"] == "delivered"
|
||||
assert outcome["request_id"] == data["request_id"]
|
||||
assert len(calls) == 3
|
||||
assert not [c for c in service["calls"] if c[0] == "systemctl"]
|
||||
assert _mailbox_writes(service["cache"]) == []
|
||||
|
||||
def test_a_stopped_display_is_started_and_answered_at_once(self, api_v3_client, service):
|
||||
service["state"]["active"] = False
|
||||
with patch(f"{CLIENT}.on_demand_start",
|
||||
side_effect=_attempts(*[_no_socket()] * 6, "ack")) as start:
|
||||
started = time.monotonic()
|
||||
resp = api_v3_client.post(START_URL, json={"plugin_id": "weather",
|
||||
"duration": 30})
|
||||
answered = time.monotonic() - started
|
||||
assert resp.status_code == 202, resp.get_json()
|
||||
data = resp.get_json()["data"]
|
||||
assert data["service"]["started"] is True
|
||||
from web_interface import on_demand_dispatch
|
||||
assert data["wait_seconds"] == on_demand_dispatch.START_WAIT_SECONDS
|
||||
outcome = self._outcome()
|
||||
assert answered < 1.0, "the route waited for the display"
|
||||
assert service["calls"] == [("systemctl", "start")]
|
||||
assert outcome["status"] == "delivered"
|
||||
# The same request, the same id, every time.
|
||||
assert {call.args[0] for call in start.call_args_list} == {data["request_id"]}
|
||||
assert _mailbox_writes(service["cache"]) == []
|
||||
|
||||
def test_while_pending_the_status_routes_say_starting(self, api_v3_client, service):
|
||||
service["state"]["active"] = False
|
||||
with patch(f"{CLIENT}.on_demand_start", side_effect=_no_socket()):
|
||||
rid = api_v3_client.post(START_URL, json={"plugin_id": "weather"}) \
|
||||
.get_json()["data"]["request_id"]
|
||||
status = api_v3_client.get("/api/v3/display/on-demand/status").get_json()["data"]
|
||||
assert status["source"] == "web"
|
||||
assert status["state"]["status"] == "starting"
|
||||
assert status["state"]["request_id"] == rid
|
||||
assert status["state"]["plugin_id"] == "weather"
|
||||
current = api_v3_client.get("/api/v3/display/current-status").get_json()["data"]
|
||||
assert current["on_demand_pending"]["status"] == "starting"
|
||||
|
||||
def test_a_display_that_never_comes_up_is_a_start_timeout(self, api_v3_client, service):
|
||||
service["state"]["active"] = False
|
||||
with patch(f"{CLIENT}.on_demand_start", side_effect=_no_socket()):
|
||||
resp = api_v3_client.post(START_URL, json={"plugin_id": "weather"})
|
||||
assert resp.status_code == 202
|
||||
outcome = self._outcome()
|
||||
assert outcome["status"] == "error" and outcome["error"] == "start-timeout"
|
||||
status = api_v3_client.get("/api/v3/display/on-demand/status").get_json()["data"]
|
||||
assert status["state"]["status"] == "error"
|
||||
assert status["state"]["error"] == "start-timeout"
|
||||
current = api_v3_client.get("/api/v3/display/current-status").get_json()["data"]
|
||||
assert current["on_demand_pending"]["error"] == "start-timeout"
|
||||
|
||||
def test_a_later_display_state_replaces_the_failure(self, api_v3_client, service):
|
||||
service["state"]["active"] = False
|
||||
with patch(f"{CLIENT}.on_demand_start", side_effect=_no_socket()):
|
||||
api_v3_client.post(START_URL, json={"plugin_id": "weather"})
|
||||
failed_at = self._outcome()["last_updated"]
|
||||
later = {"active": True, "status": "active", "plugin_id": "clock",
|
||||
"last_updated": failed_at + 5}
|
||||
service["cache"].get.return_value = later
|
||||
status = api_v3_client.get("/api/v3/display/on-demand/status").get_json()["data"]
|
||||
assert status["state"]["plugin_id"] == "clock" and status["source"] == "cache"
|
||||
|
||||
def test_a_running_service_without_a_socket_is_waited_for_less(
|
||||
self, api_v3_client, service):
|
||||
with patch(f"{CLIENT}.on_demand_start", side_effect=_no_socket()):
|
||||
resp = api_v3_client.post(START_URL, json={"plugin_id": "weather"})
|
||||
assert resp.status_code == 202
|
||||
assert resp.get_json()["data"]["wait_seconds"] == 1.0
|
||||
assert self._outcome()["error"] == "start-timeout"
|
||||
assert not [c for c in service["calls"] if c[0] == "systemctl"]
|
||||
|
||||
def test_a_different_failure_while_waiting_ends_it(self, api_v3_client, service):
|
||||
calls = []
|
||||
busy = control_client.ControlError("busy", "x", sent=True)
|
||||
with patch(f"{CLIENT}.on_demand_start",
|
||||
side_effect=_attempts(_no_socket(), _no_socket(), busy, calls=calls)):
|
||||
assert api_v3_client.post(START_URL, json={"plugin_id": "weather"}).status_code == 202
|
||||
outcome = self._outcome()
|
||||
assert outcome["status"] == "error" and outcome["error"] == "busy"
|
||||
assert len(calls) == 3
|
||||
|
||||
def test_a_stop_while_pending_cancels_it(self, api_v3_client, service):
|
||||
service["state"]["active"] = False
|
||||
calls = []
|
||||
with patch(f"{CLIENT}.on_demand_start",
|
||||
side_effect=_attempts(_no_socket(), calls=calls)), \
|
||||
patch(f"{CLIENT}.on_demand_stop", side_effect=_no_socket()):
|
||||
rid = api_v3_client.post(START_URL, json={"plugin_id": "weather"}) \
|
||||
.get_json()["data"]["request_id"]
|
||||
resp = api_v3_client.post(STOP_URL, json={})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
data = resp.get_json()["data"]
|
||||
assert data["cancelled_request_id"] == rid
|
||||
outcome = self._outcome()
|
||||
n = len(calls)
|
||||
time.sleep(0.05)
|
||||
assert len(calls) == n, "the cancelled start was still being sent"
|
||||
assert outcome["status"] == "idle" and outcome["last_event"] == "requested-stop"
|
||||
status = api_v3_client.get("/api/v3/display/on-demand/status").get_json()["data"]
|
||||
assert status["state"]["status"] == "idle" and status["source"] != "web"
|
||||
|
||||
# -- delivered, but the display has not acted on it yet ----------------
|
||||
# The display acknowledges a start when its socket opens and acts on it
|
||||
# seconds later (Vegas builds its first strip first: ~5 s on ledpi);
|
||||
# meanwhile it publishes its own idle state, which must not flash.
|
||||
|
||||
def _delivered(self, api_v3_client, service):
|
||||
with patch(f"{CLIENT}.on_demand_start",
|
||||
side_effect=_attempts(_no_socket(), "ack")):
|
||||
data = api_v3_client.post(START_URL, json={"plugin_id": "weather"}) .get_json()["data"]
|
||||
outcome = self._outcome()
|
||||
assert outcome["status"] == "delivered"
|
||||
return data["request_id"], outcome["last_updated"]
|
||||
|
||||
@staticmethod
|
||||
def _status(api_v3_client):
|
||||
return api_v3_client.get("/api/v3/display/on-demand/status").get_json()["data"]
|
||||
|
||||
def test_a_delivered_start_stays_starting_until_the_display_answers(
|
||||
self, api_v3_client, service):
|
||||
rid, delivered_at = self._delivered(api_v3_client, service)
|
||||
# The display's startup state: idle, published after the ack, for
|
||||
# no request (or an older one).
|
||||
for older in (None, "an-older-request"):
|
||||
service["cache"].get.return_value = {
|
||||
"active": False, "status": "idle", "request_id": older,
|
||||
"last_updated": delivered_at + 1}
|
||||
data = self._status(api_v3_client)
|
||||
assert data["source"] == "web", older
|
||||
assert data["state"]["status"] == "starting"
|
||||
assert data["state"]["delivered"] is True
|
||||
assert data["state"]["request_id"] == rid
|
||||
current = api_v3_client.get("/api/v3/display/current-status").get_json()["data"]
|
||||
assert current["on_demand_pending"]["delivered"] is True
|
||||
|
||||
def test_the_display_state_for_the_request_takes_over(self, api_v3_client, service):
|
||||
rid, delivered_at = self._delivered(api_v3_client, service)
|
||||
service["cache"].get.return_value = {
|
||||
"active": True, "status": "active", "plugin_id": "weather",
|
||||
"request_id": rid, "last_updated": delivered_at + 5}
|
||||
data = self._status(api_v3_client)
|
||||
assert data["source"] == "cache" and data["state"]["status"] == "active"
|
||||
current = api_v3_client.get("/api/v3/display/current-status").get_json()["data"]
|
||||
assert "on_demand_pending" not in current
|
||||
|
||||
def test_its_error_takes_over_too(self, api_v3_client, service):
|
||||
rid, delivered_at = self._delivered(api_v3_client, service)
|
||||
service["cache"].get.return_value = {
|
||||
"active": False, "status": "error", "error": "load-failed",
|
||||
"request_id": rid, "last_updated": delivered_at + 5}
|
||||
assert self._status(api_v3_client)["state"]["error"] == "load-failed"
|
||||
|
||||
def test_an_older_display_answers_with_any_newer_state(self, api_v3_client, service):
|
||||
# A display before request_id was published: timestamps decide.
|
||||
_, delivered_at = self._delivered(api_v3_client, service)
|
||||
service["cache"].get.return_value = {"active": False, "status": "idle",
|
||||
"last_updated": delivered_at - 1}
|
||||
assert self._status(api_v3_client)["source"] == "web"
|
||||
service["cache"].get.return_value = {"active": True, "status": "active",
|
||||
"last_updated": delivered_at + 1}
|
||||
assert self._status(api_v3_client)["source"] == "cache"
|
||||
|
||||
def test_a_delivered_start_is_shown_for_a_limited_time(
|
||||
self, api_v3_client, service, monkeypatch):
|
||||
from web_interface import on_demand_dispatch
|
||||
_, delivered_at = self._delivered(api_v3_client, service)
|
||||
service["cache"].get.return_value = {"active": False, "status": "idle",
|
||||
"request_id": None,
|
||||
"last_updated": delivered_at + 1}
|
||||
cap = on_demand_dispatch.DELIVERED_SHOWN_SECONDS
|
||||
assert cap == 30.0
|
||||
clock = types.SimpleNamespace(time=lambda: delivered_at + cap - 1,
|
||||
monotonic=time.monotonic, sleep=time.sleep)
|
||||
monkeypatch.setattr("web_interface.blueprints.api_v3.time", clock)
|
||||
assert self._status(api_v3_client)["source"] == "web"
|
||||
clock.time = lambda: delivered_at + cap + 1
|
||||
data = self._status(api_v3_client)
|
||||
assert data["source"] == "cache" and data["state"]["status"] == "idle"
|
||||
current = api_v3_client.get("/api/v3/display/current-status").get_json()["data"]
|
||||
assert "on_demand_pending" not in current
|
||||
|
||||
def test_a_new_start_supersedes_the_pending_one(self, api_v3_client, service):
|
||||
service["state"]["active"] = False
|
||||
with patch(f"{CLIENT}.on_demand_start", side_effect=_no_socket()):
|
||||
old = api_v3_client.post(START_URL, json={"plugin_id": "weather"}) \
|
||||
.get_json()["data"]["request_id"]
|
||||
sent = []
|
||||
service["state"]["active"] = True
|
||||
with patch(f"{CLIENT}.on_demand_start",
|
||||
side_effect=lambda rid, *a: sent.append(rid) or {"accepted": True}):
|
||||
resp = api_v3_client.post(START_URL, json={"plugin_id": "clock"})
|
||||
assert resp.status_code == 200
|
||||
outcome = self._outcome()
|
||||
# The old start is cancelled before the new one is sent, and
|
||||
# cancel() waits out a send of it in flight: nothing of it lands
|
||||
# after the new one.
|
||||
new = resp.get_json()["data"]["request_id"]
|
||||
assert sent[-1] == new and sent.count(new) == 1
|
||||
assert outcome["status"] == "idle" and outcome["last_event"] == "superseded"
|
||||
|
||||
def test_a_service_that_will_not_start_is_an_error(self, api_v3_client, service, clock):
|
||||
service["state"]["active"] = False
|
||||
with patch("web_interface.blueprints.api_v3._run_systemctl_command",
|
||||
return_value={"returncode": 1, "stdout": "", "stderr": "nope"}), \
|
||||
patch(f"{CLIENT}.on_demand_start", side_effect=_no_socket()) as start:
|
||||
resp = api_v3_client.post(START_URL, json={"plugin_id": "weather"})
|
||||
assert resp.status_code == 500
|
||||
assert "Failed to start display service" in resp.get_json()["message"]
|
||||
assert start.call_count == 1
|
||||
|
||||
def test_stop_with_no_display_running_is_an_error(self, api_v3_client, service, clock):
|
||||
service["state"]["active"] = False
|
||||
with patch(f"{CLIENT}.on_demand_stop", side_effect=_no_socket()) as stop:
|
||||
resp = api_v3_client.post(STOP_URL, json={})
|
||||
assert resp.status_code == 503
|
||||
body = resp.get_json()
|
||||
assert "not running" in body["message"]
|
||||
assert body["data"]["socket_error"] == "no_socket"
|
||||
assert stop.call_count == 1 and clock.sleeps == 0
|
||||
assert service["calls"] == []
|
||||
assert _mailbox_writes(service["cache"]) == []
|
||||
|
||||
def test_stop_with_a_running_service_but_no_socket_is_an_error(
|
||||
self, api_v3_client, service, clock):
|
||||
with patch(f"{CLIENT}.on_demand_stop",
|
||||
side_effect=control_client.ControlError("refused", "x")):
|
||||
resp = api_v3_client.post(STOP_URL, json={})
|
||||
assert resp.status_code == 503
|
||||
assert "still be starting" in resp.get_json()["message"]
|
||||
|
||||
def test_stop_with_stop_service_stops_it_anyway(self, api_v3_client, service, clock):
|
||||
with patch(f"{CLIENT}.on_demand_stop", side_effect=_no_socket()), \
|
||||
patch(f"{DISPLAY}._stop_display_service",
|
||||
return_value={"active": False}) as stop:
|
||||
resp = api_v3_client.post(STOP_URL, json={"stop_service": True})
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()["data"]["socket_error"] == "no_socket"
|
||||
stop.assert_called_once()
|
||||
assert _mailbox_writes(service["cache"]) == []
|
||||
|
||||
def test_the_socket_is_off_in_the_test_suite(self, api_v3_client, service):
|
||||
# conftest's _hermetic_control_socket: a suite run on a device must
|
||||
# not drive the live display. Nothing is retried or started for it.
|
||||
assert os.environ[c.SOCKET_PATH_ENV] == "off"
|
||||
service["state"]["active"] = False
|
||||
resp = api_v3_client.post(START_URL, json={"plugin_id": "weather"})
|
||||
assert resp.status_code == 503
|
||||
assert resp.get_json()["data"]["socket_error"] in ("disabled", "unsupported")
|
||||
assert service["calls"] == []
|
||||
|
||||
|
||||
class TestNothingElseIsRetried:
|
||||
@pytest.mark.parametrize("reason,sent,status", [
|
||||
("timeout", False, 503), ("busy", False, 503), ("forbidden", False, 503),
|
||||
("invalid_request", False, 503), ("disabled", False, 503),
|
||||
("unsupported", False, 503), ("unknown_command", True, 503),
|
||||
("unsupported_version", True, 503),
|
||||
])
|
||||
def test_answered_at_once_and_nothing_written(self, api_v3_client, service, clock,
|
||||
reason, sent, status):
|
||||
service["state"]["active"] = False
|
||||
with patch(f"{CLIENT}.on_demand_start",
|
||||
side_effect=control_client.ControlError(reason, "x", sent=sent)) as start:
|
||||
resp = api_v3_client.post(START_URL, json={"plugin_id": "weather"})
|
||||
assert resp.status_code == status
|
||||
body = resp.get_json()
|
||||
assert body["status"] == "error"
|
||||
assert body["data"]["socket_error"] == reason
|
||||
assert start.call_count == 1 and clock.sleeps == 0
|
||||
assert service["calls"] == []
|
||||
|
||||
def test_a_client_bug_is_an_error(self, api_v3_client, service):
|
||||
with patch(f"{CLIENT}.on_demand_start", side_effect=RuntimeError("boom")):
|
||||
resp = api_v3_client.post(START_URL, json={"plugin_id": "weather"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.status_code == 503
|
||||
assert resp.get_json()["data"]["socket_error"] == "internal"
|
||||
assert len(_mailbox_writes(service["cache"])) == 1
|
||||
assert _mailbox_writes(service["cache"]) == []
|
||||
|
||||
def test_an_unknown_reason_is_reported_as_other(self, api_v3_client, service):
|
||||
# Only known codes are echoed back; anything else stays server-side.
|
||||
with patch(f"{CLIENT}.on_demand_start",
|
||||
side_effect=control_client.ControlError("/run/secret/path", "x")):
|
||||
data = api_v3_client.post(START_URL, json={"plugin_id": "weather"}).get_json()["data"]
|
||||
assert data["transport"] == "mailbox"
|
||||
assert data["socket_error"] == "other"
|
||||
assert len(_mailbox_writes(service["cache"])) == 1
|
||||
resp = api_v3_client.post(START_URL, json={"plugin_id": "weather"})
|
||||
assert resp.status_code == 503
|
||||
assert resp.get_json()["data"]["socket_error"] == "other"
|
||||
assert "/run/secret" not in resp.get_data(as_text=True)
|
||||
|
||||
def test_every_display_error_code_is_reportable(self):
|
||||
from web_interface.blueprints.api_v3 import display
|
||||
codes = {v for k, v in vars(c.ErrorCode).items() if not k.startswith("_")}
|
||||
assert codes <= set(display._REPORTABLE_SOCKET_REASONS)
|
||||
|
||||
def test_stop_falls_back(self, api_v3_client, service):
|
||||
with patch(f"{CLIENT}.on_demand_stop",
|
||||
side_effect=control_client.ControlError("timeout")):
|
||||
data = api_v3_client.post(STOP_URL, json={}).get_json()["data"]
|
||||
assert data["transport"] == "mailbox"
|
||||
[write] = _mailbox_writes(service["cache"])
|
||||
assert write == {"request_id": data["request_id"], "action": "stop",
|
||||
"timestamp": write["timestamp"]}
|
||||
|
||||
def test_a_stopped_display_gets_the_mailbox_before_it_is_started(
|
||||
self, api_v3_client, service):
|
||||
service["state"]["active"] = False
|
||||
with patch(f"{CLIENT}.on_demand_start",
|
||||
side_effect=control_client.ControlError("no_socket")):
|
||||
resp = api_v3_client.post(START_URL, json={"plugin_id": "weather"})
|
||||
assert resp.status_code == 200
|
||||
assert service["calls"] == [("cache", MAILBOX), ("systemctl", "start")]
|
||||
|
||||
def test_the_socket_is_off_in_the_test_suite(self, api_v3_client, service):
|
||||
# conftest's _hermetic_control_socket: a suite run on a device must
|
||||
# not drive the live display.
|
||||
assert os.environ[c.SOCKET_PATH_ENV] == "off"
|
||||
data = api_v3_client.post(START_URL, json={"plugin_id": "weather"}).get_json()["data"]
|
||||
assert data["transport"] == "mailbox"
|
||||
assert data["socket_error"] in ("disabled", "unsupported") # Linux, Windows
|
||||
|
||||
|
||||
class TestTheDisplayHadIt:
|
||||
"""Once the display has the request, its answer stands: no mailbox copy.
|
||||
"""Once the display has the request, its answer stands.
|
||||
|
||||
A busy queue, a refusal or silence after the request was sent mean the
|
||||
display may have applied it, or would refuse the mailbox copy too, so
|
||||
the route reports the failure instead of posting it a second time.
|
||||
display may have applied it, so the route reports the failure and does
|
||||
not send it again.
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize("reason,status", [
|
||||
@@ -217,16 +530,6 @@ class TestTheDisplayHadIt:
|
||||
stop.assert_called_once()
|
||||
assert _mailbox_writes(service["cache"]) == []
|
||||
|
||||
@pytest.mark.parametrize("reason", ["unknown_command", "unsupported_version"])
|
||||
def test_an_older_display_that_does_not_speak_it_gets_the_mailbox(
|
||||
self, api_v3_client, service, reason):
|
||||
# The upgrade case: new web interface, display still on an old build.
|
||||
with patch(f"{CLIENT}.on_demand_start",
|
||||
side_effect=control_client.ControlError(reason, "x", sent=True)):
|
||||
data = api_v3_client.post(START_URL, json={"plugin_id": "weather"}).get_json()["data"]
|
||||
assert data["transport"] == "mailbox" and data["socket_error"] == reason
|
||||
assert len(_mailbox_writes(service["cache"])) == 1
|
||||
|
||||
|
||||
@pytest.mark.skipif(not c.socket_supported(), reason="AF_UNIX sockets are Linux/macOS only")
|
||||
class TestRealSocket:
|
||||
@@ -259,11 +562,21 @@ class TestRealSocket:
|
||||
assert data["transport"] == "socket"
|
||||
assert [x.request_id for x in live.drain()] == [data["request_id"]]
|
||||
|
||||
def test_a_display_that_went_away_falls_back(self, api_v3_client, service, live):
|
||||
def test_a_display_that_went_away_is_given_up_on(self, api_v3_client, service, live,
|
||||
monkeypatch):
|
||||
from web_interface import on_demand_dispatch
|
||||
monkeypatch.setattr(f"{DISPLAY}.ON_DEMAND_SOCKET_WAIT_RUNNING_SECONDS", 0.3)
|
||||
monkeypatch.setattr(on_demand_dispatch, "RETRY_INTERVAL", 0.01)
|
||||
live.close()
|
||||
data = api_v3_client.post(START_URL, json={"plugin_id": "weather"}).get_json()["data"]
|
||||
assert data["transport"] == "mailbox" and data["socket_error"] == "no_socket"
|
||||
assert len(_mailbox_writes(service["cache"])) == 1
|
||||
resp = api_v3_client.post(START_URL, json={"plugin_id": "weather"})
|
||||
# The service still reads as running: answered at once, and the
|
||||
# web process gives up once the wait is over.
|
||||
assert resp.status_code == 202
|
||||
assert resp.get_json()["data"]["socket_error"] == "no_socket"
|
||||
d = on_demand_dispatch.current()
|
||||
assert _until(lambda: not d.pending())
|
||||
assert d.status()["error"] == "start-timeout"
|
||||
assert _mailbox_writes(service["cache"]) == []
|
||||
|
||||
def test_a_full_queue_is_reported_not_mailed(self, api_v3_client, service, monkeypatch):
|
||||
import shutil
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Tests for CacheManager and cache components.
|
||||
|
||||
Tests cache functionality including memory cache, disk cache, and strategy.
|
||||
Tests cache functionality including memory cache, disk cache, strategy, and metrics.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
@@ -11,6 +11,7 @@ from src.cache_manager import CacheManager
|
||||
from src.cache.memory_cache import MemoryCache
|
||||
from src.cache.disk_cache import DiskCache
|
||||
from src.cache.cache_strategy import CacheStrategy
|
||||
from src.cache.cache_metrics import CacheMetrics
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
@@ -25,6 +26,7 @@ class TestCacheManager:
|
||||
assert hasattr(cm, '_memory_cache_component')
|
||||
assert hasattr(cm, '_disk_cache_component')
|
||||
assert hasattr(cm, '_strategy_component')
|
||||
assert hasattr(cm, '_metrics_component')
|
||||
|
||||
def test_set_and_get(self, tmp_path):
|
||||
"""Test basic set and get operations."""
|
||||
@@ -194,6 +196,50 @@ class TestMemoryCache:
|
||||
assert stats["max_size"] == 1000 # default
|
||||
|
||||
|
||||
class TestCacheMetrics:
|
||||
"""Test CacheMetrics functionality."""
|
||||
|
||||
def test_record_hit(self):
|
||||
"""Test recording cache hit."""
|
||||
metrics = CacheMetrics()
|
||||
metrics.record_hit()
|
||||
stats = metrics.get_metrics()
|
||||
|
||||
# get_metrics() returns calculated values, not raw hits/misses
|
||||
assert stats['total_requests'] == 1
|
||||
assert stats['cache_hit_rate'] == 1.0 # 1 hit out of 1 request
|
||||
|
||||
def test_record_miss(self):
|
||||
"""Test recording cache miss."""
|
||||
metrics = CacheMetrics()
|
||||
metrics.record_miss()
|
||||
stats = metrics.get_metrics()
|
||||
|
||||
# get_metrics() returns calculated values, not raw hits/misses
|
||||
assert stats['total_requests'] == 1
|
||||
assert stats['cache_hit_rate'] == 0.0 # 0 hits out of 1 request
|
||||
|
||||
def test_record_fetch_time(self):
|
||||
"""Test recording fetch time."""
|
||||
metrics = CacheMetrics()
|
||||
metrics.record_fetch_time(0.5)
|
||||
stats = metrics.get_metrics()
|
||||
|
||||
assert stats['fetch_count'] == 1
|
||||
assert stats['total_fetch_time'] == 0.5
|
||||
assert stats['average_fetch_time'] == 0.5
|
||||
|
||||
def test_cache_hit_rate(self):
|
||||
"""Test cache hit rate calculation."""
|
||||
metrics = CacheMetrics()
|
||||
metrics.record_hit()
|
||||
metrics.record_hit()
|
||||
metrics.record_miss()
|
||||
|
||||
stats = metrics.get_metrics()
|
||||
assert stats['cache_hit_rate'] == pytest.approx(0.666, abs=0.01)
|
||||
|
||||
|
||||
class TestDiskCache:
|
||||
"""Test DiskCache functionality."""
|
||||
|
||||
@@ -324,6 +370,36 @@ class TestDiskCache:
|
||||
|
||||
# Should handle gracefully
|
||||
assert result is None or isinstance(result, dict)
|
||||
|
||||
def test_record_background_hit(self):
|
||||
"""Test recording background cache hit."""
|
||||
metrics = CacheMetrics()
|
||||
metrics.record_hit(cache_type='background')
|
||||
stats = metrics.get_metrics()
|
||||
|
||||
assert stats['total_requests'] == 1
|
||||
assert stats['background_hit_rate'] == 1.0
|
||||
|
||||
def test_record_background_miss(self):
|
||||
"""Test recording background cache miss."""
|
||||
metrics = CacheMetrics()
|
||||
metrics.record_miss(cache_type='background')
|
||||
stats = metrics.get_metrics()
|
||||
|
||||
assert stats['total_requests'] == 1
|
||||
assert stats['background_hit_rate'] == 0.0
|
||||
|
||||
def test_multiple_fetch_times(self):
|
||||
"""Test recording multiple fetch times."""
|
||||
metrics = CacheMetrics()
|
||||
metrics.record_fetch_time(0.5)
|
||||
metrics.record_fetch_time(1.0)
|
||||
metrics.record_fetch_time(0.3)
|
||||
|
||||
stats = metrics.get_metrics()
|
||||
assert stats['fetch_count'] == 3
|
||||
assert stats['total_fetch_time'] == 1.8
|
||||
assert stats['average_fetch_time'] == pytest.approx(0.6, abs=0.01)
|
||||
|
||||
|
||||
class TestDiskCacheWriteEconomy:
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""CacheStrategy intervals, pinned across the whole input grid.
|
||||
|
||||
The strategy table used to carry a per-sport defaults dict whose every value
|
||||
was 60, a soccer branch identical to its else, and a config lookup of
|
||||
`<sport>_scoreboard` sections that only the replaced built-in scoreboards
|
||||
had. These tests pin the returned strategy for every data type x sport key,
|
||||
so simplifying the lookup cannot change what any caller gets back.
|
||||
was 60, and a soccer branch identical to its else. These tests pin the
|
||||
returned strategy for every data type x sport key x config shape, so
|
||||
simplifying the lookup cannot change what any caller gets back. They were
|
||||
written against the pre-cleanup code and pass on it unchanged, except for
|
||||
the legacy `<sport>_scoreboard` config shape (see below), which that code
|
||||
still read.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
@@ -12,6 +14,46 @@ import pytest
|
||||
from src.cache.cache_strategy import CacheStrategy
|
||||
|
||||
|
||||
class _Cfg:
|
||||
def __init__(self, config):
|
||||
self.config = config
|
||||
|
||||
|
||||
class _NoConfigAttr:
|
||||
pass
|
||||
|
||||
|
||||
# Plugin config sections are keyed by plugin id. Their intervals belong to the
|
||||
# plugin, and the strategy table has never read them.
|
||||
_PLUGIN_ID_CONFIG = {
|
||||
pid: {"live_update_interval": 5, "recent_update_interval": 7,
|
||||
"upcoming_update_interval": 9}
|
||||
for pid in ("football-scoreboard", "basketball-scoreboard",
|
||||
"baseball-scoreboard", "hockey-scoreboard", "soccer-scoreboard")
|
||||
}
|
||||
|
||||
# `<sport>_scoreboard` sections come from the built-in scoreboards the plugin
|
||||
# system replaced. An install upgraded from that era can still carry them in
|
||||
# config.json (nothing deletes them). No current caller passes a sport key to
|
||||
# the strategy, but a stale section must not steer cache TTLs if one does.
|
||||
_LEGACY_SCOREBOARD_CONFIG = {
|
||||
f"{sport}_scoreboard": {"live_update_interval": 5,
|
||||
"recent_update_interval": 7,
|
||||
"upcoming_update_interval": 9}
|
||||
for sport in ("nfl", "nba", "mlb", "nhl", "soccer", "ncaa_fb",
|
||||
"ncaa_baseball", "ncaam_basketball", "milb")
|
||||
}
|
||||
|
||||
CONFIG_MANAGERS = {
|
||||
"no_config_manager": None,
|
||||
"empty_config": _Cfg({}),
|
||||
"plugin_id_config": _Cfg(_PLUGIN_ID_CONFIG),
|
||||
"legacy_scoreboard_config": _Cfg(_LEGACY_SCOREBOARD_CONFIG),
|
||||
"config_is_none": _Cfg(None),
|
||||
"config_is_not_a_dict": _Cfg("x"),
|
||||
"config_manager_without_config": _NoConfigAttr(),
|
||||
}
|
||||
|
||||
SPORT_KEYS = [None, "", "nfl", "nba", "mlb", "nhl", "soccer", "ncaa_fb",
|
||||
"ncaa_baseball", "ncaam_basketball", "milb",
|
||||
"football-scoreboard", "curling"]
|
||||
@@ -51,8 +93,16 @@ def _expected(data_type, sport_key):
|
||||
return FIXED.get(data_type, DEFAULT)
|
||||
|
||||
|
||||
def test_strategy_table_for_every_data_type_and_sport():
|
||||
strategy = CacheStrategy()
|
||||
@pytest.mark.parametrize("cm_name", sorted(CONFIG_MANAGERS))
|
||||
def test_live_interval_is_60_for_every_sport(cm_name):
|
||||
strategy = CacheStrategy(config_manager=CONFIG_MANAGERS[cm_name])
|
||||
for sport_key in SPORT_KEYS:
|
||||
assert strategy.get_sport_live_interval(sport_key) == 60, sport_key
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cm_name", sorted(CONFIG_MANAGERS))
|
||||
def test_strategy_table_for_every_data_type_and_sport(cm_name):
|
||||
strategy = CacheStrategy(config_manager=CONFIG_MANAGERS[cm_name])
|
||||
data_types = ["live_scores", "sports_live", *FIXED, "unknown", ""]
|
||||
for data_type in data_types:
|
||||
for sport_key in SPORT_KEYS:
|
||||
|
||||
@@ -32,46 +32,9 @@ DEPRECATED_3_9 = {
|
||||
],
|
||||
}
|
||||
|
||||
#: Deprecated after the October 2026 over-engineering audit, for removal in
|
||||
#: 3.10.0: nothing in core, the monorepo or the registry's third-party plugins
|
||||
#: calls them.
|
||||
DEPRECATED_3_10 = {
|
||||
"src.logo_downloader.LogoDownloader": [
|
||||
"fetch_teams_data", "extract_teams_from_data", "download_missing_logos_for_league",
|
||||
"download_all_ncaa_football_logos", "download_all_missing_logos",
|
||||
"convert_image_to_rgba", "convert_all_logos_to_rgba",
|
||||
],
|
||||
"src.config_manager.ConfigManager": [
|
||||
"rollback_config", "list_backups", "validate_config_file", "get_secret",
|
||||
"cleanup_orphaned_plugin_configs", "validate_all_plugin_configs",
|
||||
],
|
||||
"src.common.api_helper.APIHelper": [
|
||||
"fetch_espn_scoreboard", "fetch_espn_standings", "fetch_espn_rankings",
|
||||
"set_cache", "get_cache", "set_rate_limit", "get_request_stats",
|
||||
],
|
||||
"src.plugin_system.testing.plugin_test_base.PluginTestCase": ["setUp"],
|
||||
"src.background_data_service.BackgroundDataService": [
|
||||
"get_result", "is_request_complete", "get_request_status",
|
||||
],
|
||||
"src.plugin_system.plugin_manager.PluginManager": [
|
||||
"get_all_plugins", "get_plugin_info", "get_all_plugin_info",
|
||||
"get_plugin_display_modes", "find_plugin_for_mode",
|
||||
],
|
||||
"src.plugin_system.plugin_state.PluginStateManager": [
|
||||
"is_loaded", "is_running", "is_error", "get_last_update", "get_error_info",
|
||||
"get_state_info",
|
||||
],
|
||||
"src.cache_manager.CacheManager": ["load_cache", "generate_sport_cache_key"],
|
||||
"src.font_manager.FontManager": ["measure_text", "get_native_bdf_size"],
|
||||
"src.base_odds_manager.BaseOddsManager": ["get_odds_for_games", "format_odds_summary"],
|
||||
"src.dynamic_team_resolver.DynamicTeamResolver": [
|
||||
"get_available_dynamic_teams", "is_dynamic_team",
|
||||
],
|
||||
}
|
||||
|
||||
#: Every pinned marker: (class path, method) -> the release that removes it.
|
||||
PINNED = {(path, name): removal
|
||||
for removal, table in (("3.9.0", DEPRECATED_3_9), ("3.10.0", DEPRECATED_3_10))
|
||||
for removal, table in (("3.9.0", DEPRECATED_3_9),)
|
||||
for path, names in table.items() for name in names}
|
||||
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ a Vegas iteration runs for vegas_scroll.max_cycle_duration (240s here). On a
|
||||
real Pi on 2026-09-23:
|
||||
|
||||
* an on-demand request posted at 10:54:27 was activated at 10:57:24, when the
|
||||
Vegas iteration it arrived during finally ended -- nothing read the mailbox
|
||||
Vegas iteration it arrived during finally ended -- nothing read the request
|
||||
in between, because _check_vegas_interrupt only looked at a flag that the
|
||||
main-loop read sets;
|
||||
* two brightness saves 12s apart inside one 30s screen never showed at all.
|
||||
@@ -15,6 +15,11 @@ _service_pending_changes is the fix: a throttled pass the dwell sleep, the
|
||||
render loops and the Vegas interrupt check all call. These tests drive those
|
||||
long stretches on a fake clock and check a change lands within one throttle
|
||||
interval -- and that the throttle holds, since the callers run at frame rate.
|
||||
|
||||
The on-demand requests here come from a plugin in the display process
|
||||
(submit_plugin_on_demand), the way in that needs no socket; the file mailbox
|
||||
these tests used to write is gone (stage 5). A queued request skips the
|
||||
throttle, so it lands at the next pass's call, not the next interval.
|
||||
"""
|
||||
|
||||
import threading
|
||||
@@ -28,7 +33,6 @@ from src.vegas_mode.config import VegasModeConfig
|
||||
from src.vegas_mode.coordinator import VegasModeCoordinator
|
||||
|
||||
VEGAS_ITERATION_SECONDS = 240
|
||||
REQUEST_KEY = 'display_on_demand_request'
|
||||
|
||||
|
||||
class FakeClock:
|
||||
@@ -75,26 +79,15 @@ def clock(monkeypatch):
|
||||
|
||||
@pytest.fixture
|
||||
def controller(test_display_controller, clock):
|
||||
"""A controller at rest: no schedule, full brightness, empty mailbox."""
|
||||
"""A controller at rest: no schedule, full brightness, nothing queued."""
|
||||
c = test_display_controller
|
||||
c._refresh_config_cache({'display': {'hardware': {'brightness': 90}}})
|
||||
c.current_brightness = 90
|
||||
c.is_display_active = True
|
||||
c._check_wifi_status_message = MagicMock(return_value=None)
|
||||
|
||||
c.mailbox = {} # what the web process has written
|
||||
|
||||
def cache_get(key, *args, **kwargs):
|
||||
if key == REQUEST_KEY:
|
||||
return c.mailbox.get('request')
|
||||
return None
|
||||
|
||||
def cache_delete(key):
|
||||
if key == REQUEST_KEY:
|
||||
c.mailbox.pop('request', None)
|
||||
|
||||
c.cache_manager.get = MagicMock(side_effect=cache_get)
|
||||
c.cache_manager.delete = MagicMock(side_effect=cache_delete)
|
||||
c.cache_manager.get = MagicMock(return_value=None)
|
||||
c.cache_manager.delete = MagicMock()
|
||||
c.cache_manager.set = MagicMock()
|
||||
c.display_manager.set_brightness = MagicMock(return_value=True)
|
||||
c.display_manager.update_display = MagicMock()
|
||||
@@ -110,11 +103,12 @@ def controller(test_display_controller, clock):
|
||||
return c
|
||||
|
||||
|
||||
def post_request(controller, request_id='r1', mode='clock'):
|
||||
controller.mailbox['request'] = {
|
||||
'request_id': request_id, 'action': 'start',
|
||||
'plugin_id': mode, 'mode': mode,
|
||||
}
|
||||
def post_request(controller, request_id='r1', mode='clock', action='start'):
|
||||
"""A plugin asking for the screen (or giving it back) from its thread."""
|
||||
assert controller.submit_plugin_on_demand({
|
||||
'request_id': request_id, 'action': action,
|
||||
'plugin_id': mode, 'mode': mode, 'source': 'plugin',
|
||||
})
|
||||
|
||||
|
||||
def save_brightness(controller, brightness):
|
||||
@@ -124,9 +118,11 @@ def save_brightness(controller, brightness):
|
||||
{'display': {'hardware': {'brightness': brightness}}})
|
||||
|
||||
|
||||
def mailbox_reads(controller):
|
||||
return sum(1 for call in controller.cache_manager.get.call_args_list
|
||||
if call.args and call.args[0] == REQUEST_KEY)
|
||||
def count_passes(controller):
|
||||
"""Count the pending-changes passes that got past the throttle."""
|
||||
controller._poll_on_demand_requests = MagicMock(
|
||||
wraps=controller._poll_on_demand_requests)
|
||||
return controller._poll_on_demand_requests
|
||||
|
||||
|
||||
def vegas_coordinator(controller):
|
||||
@@ -145,6 +141,7 @@ def vegas_coordinator(controller):
|
||||
coord.render_pipeline.target_fps = float(coord.vegas_config.target_fps)
|
||||
coord.stream_manager = MagicMock()
|
||||
coord.display_manager = controller.display_manager
|
||||
coord.stats = {'cycles_completed': 0, 'interruptions': 0}
|
||||
coord._state_lock = threading.Lock()
|
||||
coord._is_active = True
|
||||
coord._is_paused = False
|
||||
@@ -206,15 +203,16 @@ class TestOnDemandDuringVegas:
|
||||
"interrupt checker never saw the request")
|
||||
assert controller.on_demand_active
|
||||
|
||||
def test_an_empty_mailbox_lets_the_iteration_run_out(self, controller, clock):
|
||||
def test_a_quiet_iteration_runs_out(self, controller, clock):
|
||||
coord = vegas_coordinator(controller)
|
||||
passes = count_passes(controller)
|
||||
assert coord.run_iteration() is True
|
||||
assert not controller.on_demand_active
|
||||
# And the read is throttled: at most one per interval, not per check.
|
||||
max_reads = VEGAS_ITERATION_SECONDS / controller.PENDING_CHANGES_INTERVAL + 1
|
||||
assert 0 < mailbox_reads(controller) <= max_reads
|
||||
# And the pass is throttled: at most one per interval, not per check.
|
||||
max_passes = VEGAS_ITERATION_SECONDS / controller.PENDING_CHANGES_INTERVAL + 1
|
||||
assert 0 < passes.call_count <= max_passes
|
||||
checks = coord.frames // coord._interrupt_check_interval
|
||||
assert mailbox_reads(controller) < checks / 2
|
||||
assert passes.call_count < checks / 2
|
||||
|
||||
|
||||
class TestBrightnessIsAppliedMidScreen:
|
||||
@@ -294,23 +292,25 @@ class TestBrightnessIsAppliedMidScreen:
|
||||
|
||||
|
||||
class TestThrottle:
|
||||
def test_no_reads_or_brightness_calls_between_passes(self, controller, clock):
|
||||
def test_no_passes_or_brightness_calls_between_passes(self, controller, clock):
|
||||
controller._service_pending_changes()
|
||||
reads = mailbox_reads(controller)
|
||||
passes = count_passes(controller)
|
||||
save_brightness(controller, 40)
|
||||
post_request(controller)
|
||||
for _ in range(500): # a few seconds of frames, all inside one interval
|
||||
controller._service_pending_changes()
|
||||
clock.t += controller.PENDING_CHANGES_INTERVAL / 1000
|
||||
assert mailbox_reads(controller) == reads
|
||||
assert passes.call_count == 0
|
||||
controller.display_manager.set_brightness.assert_not_called()
|
||||
assert not controller.on_demand_active
|
||||
|
||||
clock.t += controller.PENDING_CHANGES_INTERVAL
|
||||
controller._service_pending_changes()
|
||||
# The poll, plus the consume step's re-read of the request it acted on.
|
||||
assert mailbox_reads(controller) > reads
|
||||
assert passes.call_count == 1
|
||||
controller.display_manager.set_brightness.assert_called_once_with(40)
|
||||
|
||||
def test_a_queued_request_skips_the_throttle(self, controller, clock):
|
||||
controller._service_pending_changes()
|
||||
post_request(controller)
|
||||
controller._service_pending_changes() # inside the interval
|
||||
assert controller.on_demand_active
|
||||
|
||||
def test_between_passes_it_does_no_work_at_all(self, controller, clock):
|
||||
@@ -397,7 +397,7 @@ class TestScheduleAndDwells:
|
||||
controller._service_pending_changes()
|
||||
assert controller.on_demand_active
|
||||
clock.t += controller.PENDING_CHANGES_INTERVAL
|
||||
controller.mailbox['request'] = {'request_id': 'r2', 'action': 'stop'}
|
||||
post_request(controller, request_id='r2', action='stop')
|
||||
start = clock.t
|
||||
controller._sleep_with_plugin_updates(30)
|
||||
assert not controller.on_demand_active
|
||||
|
||||
@@ -218,6 +218,24 @@ class TestPatternDetection:
|
||||
assert pattern is not None
|
||||
assert pattern.severity in ["error", "critical"]
|
||||
|
||||
def test_pattern_callback_called(self):
|
||||
"""Pattern detection callback should be called."""
|
||||
aggregator = ErrorAggregator(pattern_threshold=2)
|
||||
|
||||
callback_called = []
|
||||
|
||||
def callback(pattern):
|
||||
callback_called.append(pattern)
|
||||
|
||||
aggregator.on_pattern_detected(callback)
|
||||
|
||||
# Trigger pattern
|
||||
for _ in range(3):
|
||||
aggregator.record_error(error=ValueError("Pattern trigger"))
|
||||
|
||||
assert len(callback_called) == 1
|
||||
assert callback_called[0].error_type == "ValueError"
|
||||
|
||||
|
||||
class TestErrorSummary:
|
||||
"""Test error summary generation."""
|
||||
@@ -300,6 +318,37 @@ class TestPluginHealth:
|
||||
assert health["recent_error_count"] == 10
|
||||
|
||||
|
||||
class TestRecordClearing:
|
||||
"""Test clearing old records."""
|
||||
|
||||
def test_clear_old_records(self):
|
||||
"""Old records should be cleared."""
|
||||
aggregator = ErrorAggregator()
|
||||
|
||||
# Add a record
|
||||
aggregator.record_error(error=ValueError("Old error"))
|
||||
|
||||
# Manually age the record
|
||||
aggregator._records[0].timestamp = datetime.now() - timedelta(hours=48)
|
||||
|
||||
# Clear records older than 24 hours
|
||||
cleared = aggregator.clear_old_records(max_age_hours=24)
|
||||
|
||||
assert cleared == 1
|
||||
assert len(aggregator._records) == 0
|
||||
|
||||
def test_recent_records_not_cleared(self):
|
||||
"""Recent records should not be cleared."""
|
||||
aggregator = ErrorAggregator()
|
||||
|
||||
aggregator.record_error(error=ValueError("Recent error"))
|
||||
|
||||
cleared = aggregator.clear_old_records(max_age_hours=24)
|
||||
|
||||
assert cleared == 0
|
||||
assert len(aggregator._records) == 1
|
||||
|
||||
|
||||
class TestThreadSafety:
|
||||
"""Test thread safety of error aggregator."""
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from src.cache_manager import CacheManager # noqa: E402
|
||||
from src import error_aggregator as errors # noqa: E402
|
||||
from src.error_aggregator import ( # noqa: E402
|
||||
ERROR_CLEAR_REQUEST_KEY, ERROR_SNAPSHOT_KEY, ErrorAggregator,
|
||||
ERROR_SNAPSHOT_KEY, ErrorAggregator,
|
||||
ErrorSnapshotPublisher,
|
||||
)
|
||||
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
||||
@@ -314,151 +314,36 @@ class TestRoutes:
|
||||
assert secret not in published, secret
|
||||
|
||||
|
||||
class TestClear:
|
||||
def test_clear_is_applied_by_the_display_and_republished(self, web, display):
|
||||
aggregator, publisher, _ = display
|
||||
_fail(aggregator)
|
||||
_fail(aggregator)
|
||||
publisher.tick()
|
||||
response = web.post("/api/v3/errors/clear", json={"all": True})
|
||||
assert response.status_code == 200
|
||||
body = response.get_json()["data"]
|
||||
assert body["clear_requested"] is True
|
||||
assert body["cleared_count"] == 2
|
||||
# The display applies it on its next tick, throttle or not...
|
||||
assert publisher.tick() is True
|
||||
assert aggregator.get_error_summary()["total_errors"] == 0
|
||||
data = _summary(web)
|
||||
assert data["total_errors"] == 0 and data["clear_pending"] is False
|
||||
# ...and only once.
|
||||
assert publisher.tick() is False
|
||||
|
||||
def test_summary_hides_cleared_errors_before_the_display_applies_it(self, web, display):
|
||||
aggregator, publisher, _ = display
|
||||
for _ in range(5):
|
||||
_fail(aggregator)
|
||||
publisher.tick()
|
||||
web.post("/api/v3/errors/clear", json={"all": True})
|
||||
# No display tick yet.
|
||||
data = _summary(web)
|
||||
assert data["clear_pending"] is True
|
||||
assert data["total_errors"] == 0
|
||||
assert data["recent_errors"] == [] and data["active_patterns"] == {}
|
||||
assert data["plugin_error_counts"] == {}
|
||||
plugin = web.get("/api/v3/errors/plugin/p1").get_json()["data"]
|
||||
assert plugin["status"] == "healthy" and plugin["total_errors"] == 0
|
||||
|
||||
def test_a_snapshot_written_just_before_the_clear_cannot_bring_errors_back(
|
||||
self, web, display, shared_cache):
|
||||
# The race: the display builds a snapshot, the user clicks Clear, and
|
||||
# the display's write lands after the request.
|
||||
aggregator, publisher, _ = display
|
||||
display_cache, _, _ = shared_cache
|
||||
for _ in range(3):
|
||||
_fail(aggregator)
|
||||
stale = aggregator.build_snapshot()
|
||||
web.post("/api/v3/errors/clear", json={"all": True})
|
||||
stale["applied_clear_id"] = None
|
||||
display_cache.set(ERROR_SNAPSHOT_KEY, stale)
|
||||
assert _summary(web)["total_errors"] == 0
|
||||
|
||||
def test_errors_after_the_clear_are_kept(self, web, display):
|
||||
aggregator, publisher, clock = display
|
||||
_fail(aggregator, plugin_id="before")
|
||||
publisher.tick()
|
||||
web.post("/api/v3/errors/clear", json={"all": True})
|
||||
# An error lands after the request but before the display applies it.
|
||||
for record in aggregator._records:
|
||||
record.timestamp -= timedelta(seconds=5)
|
||||
_fail(aggregator, plugin_id="after")
|
||||
publisher.tick()
|
||||
data = _summary(web)
|
||||
assert data["plugin_error_counts"] == {"after": {"ValueError": 1}}
|
||||
assert data["clear_pending"] is False
|
||||
|
||||
def test_age_based_clear(self, web, display):
|
||||
aggregator, publisher, _ = display
|
||||
_fail(aggregator, plugin_id="old")
|
||||
_fail(aggregator, plugin_id="old")
|
||||
for record in aggregator._records:
|
||||
record.timestamp -= timedelta(hours=3)
|
||||
_fail(aggregator, plugin_id="new")
|
||||
publisher.tick()
|
||||
body = web.post("/api/v3/errors/clear", json={"max_age_hours": 1}).get_json()["data"]
|
||||
assert body["cleared_count"] == 2
|
||||
pending = _summary(web)
|
||||
assert pending["clear_pending"] is True
|
||||
assert [r["plugin_id"] for r in pending["recent_errors"]] == ["new"]
|
||||
publisher.tick()
|
||||
data = _summary(web)
|
||||
assert data["plugin_error_counts"] == {"new": {"ValueError": 1}}
|
||||
assert data["total_errors"] == 1
|
||||
|
||||
def test_a_narrower_clear_does_not_undo_a_pending_wider_one(self, web, display):
|
||||
aggregator, publisher, _ = display
|
||||
for _ in range(3):
|
||||
_fail(aggregator)
|
||||
publisher.tick()
|
||||
web.post("/api/v3/errors/clear", json={"all": True})
|
||||
web.post("/api/v3/errors/clear", json={"max_age_hours": 24})
|
||||
assert _summary(web)["total_errors"] == 0
|
||||
publisher.tick()
|
||||
assert aggregator.get_error_summary()["total_errors"] == 0
|
||||
|
||||
def test_default_body_still_means_older_than_24_hours(self, web, display):
|
||||
aggregator, publisher, _ = display
|
||||
_fail(aggregator)
|
||||
publisher.tick()
|
||||
response = web.post("/api/v3/errors/clear")
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()["data"]["cleared_count"] == 0
|
||||
publisher.tick()
|
||||
assert _summary(web)["total_errors"] == 1
|
||||
|
||||
def test_validation_is_unchanged_but_all_skips_it(self, web):
|
||||
assert web.post("/api/v3/errors/clear", json={"max_age_hours": 0}).status_code == 400
|
||||
assert web.post("/api/v3/errors/clear", json={"max_age_hours": 9000}).status_code == 400
|
||||
assert web.post("/api/v3/errors/clear",
|
||||
json={"all": True, "max_age_hours": "junk"}).status_code == 200
|
||||
|
||||
def test_a_request_that_did_not_reach_the_cache_is_an_error(self, web, api_v3_module): # noqa: F811
|
||||
cache = MagicMock()
|
||||
cache.get.return_value = None
|
||||
api_v3_module.api_v3.cache_manager = cache
|
||||
response = web.post("/api/v3/errors/clear", json={"all": True})
|
||||
assert response.status_code == 500
|
||||
assert "clear request" in response.get_json()["message"]
|
||||
|
||||
|
||||
CLIENT = "web_interface.blueprints.api_v3.control_client"
|
||||
RETIRED_CLEAR_KEY = "plugin_error_clear_request"
|
||||
|
||||
|
||||
def _mailbox_file(shared_cache):
|
||||
_, _, directory = shared_cache
|
||||
return directory / f"{ERROR_CLEAR_REQUEST_KEY}.json"
|
||||
return directory / f"{RETIRED_CLEAR_KEY}.json"
|
||||
|
||||
|
||||
class TestClearOverTheSocket:
|
||||
"""``errors.clear``: the display applies the clear before it answers, and
|
||||
the mailbox is written only when the socket could not carry it."""
|
||||
@pytest.fixture
|
||||
def socket_up(display, monkeypatch):
|
||||
"""The control socket, as the display serves it: errors_clear runs the
|
||||
display's own handler against its publisher."""
|
||||
from src.ipc import client as control_client
|
||||
from src.ipc.contract import ErrorsClearArgs
|
||||
_, publisher, _ = display
|
||||
monkeypatch.setattr(errors, "_snapshot_publisher", publisher)
|
||||
calls = []
|
||||
|
||||
@pytest.fixture
|
||||
def socket_up(self, display, monkeypatch):
|
||||
"""The control socket, as the display serves it: errors_clear runs
|
||||
the display's own handler against its publisher."""
|
||||
from src.ipc import client as control_client
|
||||
from src.ipc.contract import ErrorsClearArgs
|
||||
_, publisher, _ = display
|
||||
monkeypatch.setattr(errors, "_snapshot_publisher", publisher)
|
||||
calls = []
|
||||
def errors_clear(request_id, cutoff, **kw):
|
||||
calls.append((request_id, cutoff))
|
||||
return errors.apply_error_clear(request_id, ErrorsClearArgs(cutoff=cutoff))
|
||||
|
||||
def errors_clear(request_id, cutoff, **kw):
|
||||
calls.append((request_id, cutoff))
|
||||
return errors.apply_error_clear(request_id, ErrorsClearArgs(cutoff=cutoff))
|
||||
monkeypatch.setattr(f"{CLIENT}.errors_clear", errors_clear)
|
||||
assert control_client.errors_clear is errors_clear
|
||||
return calls
|
||||
|
||||
monkeypatch.setattr(f"{CLIENT}.errors_clear", errors_clear)
|
||||
assert control_client.errors_clear is errors_clear
|
||||
return calls
|
||||
|
||||
class TestClear:
|
||||
"""``errors.clear``: the display applies the clear before it answers."""
|
||||
|
||||
def test_socket_clear_is_applied_before_the_answer(self, web, display, socket_up,
|
||||
shared_cache):
|
||||
@@ -471,6 +356,7 @@ class TestClearOverTheSocket:
|
||||
body = response.get_json()
|
||||
data = body["data"]
|
||||
assert data["transport"] == "socket" and data["applied"] is True
|
||||
assert data["clear_requested"] is True
|
||||
assert data["cleared_count"] == 3
|
||||
assert body["message"] == "Cleared all errors"
|
||||
[(request_id, _)] = socket_up
|
||||
@@ -479,90 +365,134 @@ class TestClearOverTheSocket:
|
||||
assert aggregator.get_error_summary()["total_errors"] == 0
|
||||
summary = _summary(web)
|
||||
assert summary["total_errors"] == 0 and summary["clear_pending"] is False
|
||||
# And no mailbox file.
|
||||
assert not _mailbox_file(shared_cache).exists()
|
||||
# Nothing left for a tick to do.
|
||||
assert publisher.tick() is False
|
||||
|
||||
def test_errors_after_the_clear_are_kept(self, web, display, socket_up):
|
||||
aggregator, publisher, _ = display
|
||||
_fail(aggregator, plugin_id="before")
|
||||
for record in aggregator._records:
|
||||
record.timestamp -= timedelta(seconds=5)
|
||||
publisher.tick()
|
||||
web.post("/api/v3/errors/clear", json={"all": True})
|
||||
_fail(aggregator, plugin_id="after")
|
||||
publisher.min_interval = 0
|
||||
publisher.tick()
|
||||
data = _summary(web)
|
||||
assert data["plugin_error_counts"] == {"after": {"ValueError": 1}}
|
||||
assert data["clear_pending"] is False
|
||||
|
||||
def test_age_based_clear(self, web, display, socket_up):
|
||||
aggregator, publisher, _ = display
|
||||
_fail(aggregator, plugin_id="old")
|
||||
_fail(aggregator, plugin_id="old")
|
||||
for record in aggregator._records:
|
||||
record.timestamp -= timedelta(hours=3)
|
||||
_fail(aggregator, plugin_id="new")
|
||||
publisher.tick()
|
||||
body = web.post("/api/v3/errors/clear", json={"max_age_hours": 1}).get_json()["data"]
|
||||
assert body["cleared_count"] == 2
|
||||
data = _summary(web)
|
||||
assert data["plugin_error_counts"] == {"new": {"ValueError": 1}}
|
||||
assert data["total_errors"] == 1
|
||||
|
||||
def test_default_body_still_means_older_than_24_hours(self, web, display, socket_up):
|
||||
aggregator, publisher, _ = display
|
||||
_fail(aggregator)
|
||||
publisher.tick()
|
||||
response = web.post("/api/v3/errors/clear")
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()["data"]["cleared_count"] == 0
|
||||
assert _summary(web)["total_errors"] == 1
|
||||
|
||||
def test_validation_is_unchanged_but_all_skips_it(self, web, socket_up):
|
||||
assert web.post("/api/v3/errors/clear", json={"max_age_hours": 0}).status_code == 400
|
||||
assert web.post("/api/v3/errors/clear", json={"max_age_hours": 9000}).status_code == 400
|
||||
assert web.post("/api/v3/errors/clear",
|
||||
json={"all": True, "max_age_hours": "junk"}).status_code == 200
|
||||
|
||||
|
||||
class TestClearWithoutTheSocket:
|
||||
"""Stage 5: no mailbox to fall back to. The route says why it failed and
|
||||
writes nothing; the errors stay as the display last reported them."""
|
||||
|
||||
def _post(self, web, display, monkeypatch, error):
|
||||
monkeypatch.setattr(f"{CLIENT}.errors_clear", MagicMock(side_effect=error))
|
||||
aggregator, publisher, _ = display
|
||||
_fail(aggregator)
|
||||
publisher.tick()
|
||||
return web.post("/api/v3/errors/clear", json={"all": True})
|
||||
|
||||
@pytest.mark.parametrize("reason", ["no_socket", "refused"])
|
||||
def test_a_stopped_display_is_an_error(self, web, display, shared_cache, monkeypatch,
|
||||
reason):
|
||||
from src.ipc import client as control_client
|
||||
response = self._post(web, display, monkeypatch,
|
||||
control_client.ControlError(reason, sent=False))
|
||||
assert response.status_code == 503
|
||||
body = response.get_json()
|
||||
assert body["context"]["socket_error"] == reason
|
||||
assert "not running" in body["message"]
|
||||
assert not _mailbox_file(shared_cache).exists()
|
||||
# Nothing hides them: they are still the display's last report.
|
||||
summary = _summary(web)
|
||||
assert summary["total_errors"] == 1 and summary["clear_pending"] is False
|
||||
|
||||
@pytest.mark.parametrize("reason", ["disabled", "unsupported"])
|
||||
def test_no_socket_here_is_an_error(self, web, display, shared_cache, monkeypatch, reason):
|
||||
from src.ipc import client as control_client
|
||||
response = self._post(web, display, monkeypatch,
|
||||
control_client.ControlError(reason, sent=False))
|
||||
assert response.status_code == 503
|
||||
assert "not available" in response.get_json()["message"]
|
||||
assert not _mailbox_file(shared_cache).exists()
|
||||
|
||||
def test_an_older_mailbox_request_does_not_read_as_pending(self, web, display, socket_up,
|
||||
shared_cache, monkeypatch):
|
||||
# A clear that went to the mailbox while the socket was down, then a
|
||||
# wider one over the socket: the old request has nothing left to hide.
|
||||
from unittest.mock import patch
|
||||
def test_an_older_display_is_told_to_restart(self, web, display, shared_cache,
|
||||
monkeypatch):
|
||||
from src.ipc import client as control_client
|
||||
aggregator, publisher, _ = display
|
||||
_fail(aggregator)
|
||||
publisher.tick()
|
||||
with patch(f"{CLIENT}.errors_clear",
|
||||
side_effect=control_client.ControlError("no_socket")):
|
||||
data = web.post("/api/v3/errors/clear", json={"max_age_hours": 1}).get_json()["data"]
|
||||
assert data["transport"] == "mailbox"
|
||||
assert _mailbox_file(shared_cache).exists()
|
||||
data = web.post("/api/v3/errors/clear", json={"all": True}).get_json()["data"]
|
||||
assert data["transport"] == "socket"
|
||||
assert _summary(web)["clear_pending"] is False
|
||||
|
||||
@pytest.mark.parametrize("reason", ["no_socket", "refused", "disabled", "unsupported"])
|
||||
def test_no_socket_writes_the_mailbox(self, web, display, shared_cache, monkeypatch, reason):
|
||||
from src.ipc import client as control_client
|
||||
monkeypatch.setattr(f"{CLIENT}.errors_clear", MagicMock(
|
||||
side_effect=control_client.ControlError(reason, sent=False)))
|
||||
aggregator, publisher, _ = display
|
||||
_fail(aggregator)
|
||||
publisher.tick()
|
||||
data = web.post("/api/v3/errors/clear", json={"all": True}).get_json()["data"]
|
||||
assert data["transport"] == "mailbox" and data["applied"] is False
|
||||
assert _mailbox_file(shared_cache).exists()
|
||||
assert _summary(web)["clear_pending"] is True
|
||||
publisher.tick()
|
||||
assert aggregator.get_error_summary()["total_errors"] == 0
|
||||
|
||||
def test_an_older_display_gets_the_mailbox(self, web, display, shared_cache, monkeypatch):
|
||||
# The upgrade case: new web interface, a display from before errors.clear.
|
||||
from src.ipc import client as control_client
|
||||
monkeypatch.setattr(f"{CLIENT}.errors_clear", MagicMock(
|
||||
side_effect=control_client.ControlError("unknown_command", sent=True)))
|
||||
aggregator, publisher, _ = display
|
||||
_fail(aggregator)
|
||||
publisher.tick()
|
||||
data = web.post("/api/v3/errors/clear", json={"all": True}).get_json()["data"]
|
||||
assert data["transport"] == "mailbox"
|
||||
publisher.tick()
|
||||
assert aggregator.get_error_summary()["total_errors"] == 0
|
||||
response = self._post(web, display, monkeypatch,
|
||||
control_client.ControlError("unknown_command", sent=True))
|
||||
assert response.status_code == 503
|
||||
assert "restart" in response.get_json()["message"]
|
||||
assert not _mailbox_file(shared_cache).exists()
|
||||
|
||||
@pytest.mark.parametrize("reason", ["internal", "timeout", "busy", "invalid_args"])
|
||||
def test_a_display_that_had_it_and_failed_is_an_error(self, web, display, shared_cache,
|
||||
monkeypatch, reason):
|
||||
from src.ipc import client as control_client
|
||||
monkeypatch.setattr(f"{CLIENT}.errors_clear", MagicMock(
|
||||
side_effect=control_client.ControlError(reason, sent=True)))
|
||||
response = web.post("/api/v3/errors/clear", json={"all": True})
|
||||
response = self._post(web, display, monkeypatch,
|
||||
control_client.ControlError(reason, sent=True))
|
||||
assert response.status_code == 503
|
||||
assert response.get_json()["context"]["socket_error"] == reason
|
||||
body = response.get_json()
|
||||
assert body["context"]["socket_error"] == reason
|
||||
assert body["message"] == "The display service did not apply the clear"
|
||||
assert not _mailbox_file(shared_cache).exists()
|
||||
|
||||
|
||||
class TestPublisherMailboxPoll:
|
||||
def test_the_mailbox_is_read_only_when_its_file_changed(self, display, shared_cache):
|
||||
_, publisher, _ = display
|
||||
_, web_cache, _ = shared_cache
|
||||
def test_the_default_test_setup_has_no_socket(self, web, display, shared_cache):
|
||||
# conftest turns the socket off: the real client answers "disabled".
|
||||
aggregator, publisher, _ = display
|
||||
_fail(aggregator)
|
||||
publisher.tick()
|
||||
response = web.post("/api/v3/errors/clear", json={"all": True})
|
||||
assert response.status_code == 503
|
||||
assert response.get_json()["context"]["socket_error"] in ("disabled", "unsupported")
|
||||
|
||||
|
||||
class TestPublisher:
|
||||
def test_a_tick_reads_no_clear_request(self, display, shared_cache):
|
||||
_, publisher, clock = display
|
||||
_, web_cache, directory = shared_cache
|
||||
# An old web interface's leftover request file is ignored.
|
||||
(directory / f"{RETIRED_CLEAR_KEY}.json").write_text(
|
||||
'{"timestamp": 1, "data": {"request_id": "old", "cutoff": 9e9}}')
|
||||
publisher.cache_manager = MagicMock(wraps=publisher.cache_manager)
|
||||
|
||||
def reads():
|
||||
return [c for c in publisher.cache_manager.get.call_args_list
|
||||
if c.args[0] == ERROR_CLEAR_REQUEST_KEY]
|
||||
|
||||
for _ in range(5):
|
||||
clock.now += 60
|
||||
publisher.tick()
|
||||
assert reads() == [] # no file: a stat per tick, no read
|
||||
errors.request_error_clear(web_cache, 1.0)
|
||||
publisher.tick()
|
||||
assert len(reads()) == 1
|
||||
for _ in range(5):
|
||||
publisher.tick()
|
||||
assert len(reads()) == 1 # unchanged file: not read again
|
||||
errors.request_error_clear(web_cache, 2.0)
|
||||
publisher.tick()
|
||||
assert len(reads()) == 2
|
||||
publisher.cache_manager.get.assert_not_called()
|
||||
snapshot = web_cache.get(ERROR_SNAPSHOT_KEY, max_age=None, memory_ttl=0)
|
||||
assert snapshot["applied_clear_id"] is None
|
||||
|
||||
def test_clear_now_publishes_what_it_applied(self, display, shared_cache):
|
||||
aggregator, publisher, _ = display
|
||||
@@ -572,7 +502,6 @@ class TestPublisherMailboxPoll:
|
||||
snapshot = web_cache.get(ERROR_SNAPSHOT_KEY, max_age=None, memory_ttl=0)
|
||||
assert snapshot["applied_clear_id"] == "sock-1"
|
||||
assert snapshot["total_errors"] == 0
|
||||
assert snapshot["applied_clear_cutoff"] is not None
|
||||
|
||||
def test_the_handler_needs_a_running_publisher(self, monkeypatch):
|
||||
from src.ipc.contract import ErrorsClearArgs
|
||||
@@ -583,12 +512,10 @@ class TestPublisherMailboxPoll:
|
||||
|
||||
@pytest.mark.skipif(not hasattr(os, "fchmod") or os.name == "nt",
|
||||
reason="POSIX file modes")
|
||||
def test_both_files_are_group_readable(web, display, shared_cache):
|
||||
def test_the_snapshot_is_group_readable(web, display, shared_cache):
|
||||
aggregator, publisher, _ = display
|
||||
_, _, directory = shared_cache
|
||||
_fail(aggregator)
|
||||
publisher.tick()
|
||||
web.post("/api/v3/errors/clear", json={"all": True})
|
||||
for key in (ERROR_SNAPSHOT_KEY, ERROR_CLEAR_REQUEST_KEY):
|
||||
mode = stat.S_IMODE(os.stat(directory / f"{key}.json").st_mode)
|
||||
assert mode == 0o660, (key, oct(mode))
|
||||
mode = stat.S_IMODE(os.stat(directory / f"{ERROR_SNAPSHOT_KEY}.json").st_mode)
|
||||
assert mode == 0o660, oct(mode)
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
"""Tests for src/common/espn_payload.py and its use by BackgroundDataService."""
|
||||
|
||||
import copy
|
||||
import time
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.background_data_service import BackgroundDataService, shutdown_background_service
|
||||
from src.common.espn_payload import is_espn_scoreboard_url, slim_scoreboard_payload
|
||||
|
||||
SCOREBOARD = "https://site.api.espn.com/apis/site/v2/sports/baseball/mlb/scoreboard"
|
||||
|
||||
|
||||
def _event():
|
||||
"""One event carrying every key the slimming drops and a sample of the
|
||||
keys scoreboards read, at the depth ESPN puts them."""
|
||||
competitor = {
|
||||
"id": "10",
|
||||
"homeAway": "home",
|
||||
"score": "5",
|
||||
"team": {"abbreviation": "NYY", "logo": "https://a/l.png",
|
||||
"links": [{"href": "https://espn.com/team"}]},
|
||||
"records": [{"summary": "90-60"}],
|
||||
"linescores": [{"value": 1}],
|
||||
"statistics": [{"name": "hits", "displayValue": "9"}],
|
||||
"leaders": [{"name": "avg", "leaders": [{"athlete": {"id": "1"}}]}],
|
||||
"probables": [{"athlete": {"id": "2"}, "statistics": []}],
|
||||
}
|
||||
return {
|
||||
"id": "401",
|
||||
"date": "2026-10-01T23:05Z",
|
||||
"links": [{"href": "https://espn.com/game"}],
|
||||
"status": {"type": {"state": "post"}},
|
||||
"competitions": [{
|
||||
"status": {"type": {"state": "post", "shortDetail": "Final"},
|
||||
"featuredAthletes": [{"athlete": {"id": "3"}}]},
|
||||
"competitors": [competitor, dict(copy.deepcopy(competitor), homeAway="away")],
|
||||
"odds": [{"details": "NYY -150", "overUnder": 8.5}],
|
||||
"situation": {"outs": 2},
|
||||
"notes": [{"headline": "Game 1"}],
|
||||
"broadcasts": [{"names": ["FOX"]}],
|
||||
"venue": {"fullName": "Yankee Stadium"},
|
||||
"leaders": [{"name": "hits"}],
|
||||
"headlines": [{"description": "recap"}],
|
||||
"highlights": [{"links": {"source": {}}}],
|
||||
"geoBroadcasts": [{"media": {"shortName": "FOX"}}],
|
||||
}],
|
||||
}
|
||||
|
||||
|
||||
class TestSlimScoreboardPayload:
|
||||
def test_drops_exactly_the_listed_keys(self):
|
||||
payload = {"leagues": [{"id": "10"}], "events": [_event()]}
|
||||
slim_scoreboard_payload(payload)
|
||||
event = payload["events"][0]
|
||||
competition = event["competitions"][0]
|
||||
assert "links" not in event
|
||||
for key in ("leaders", "headlines", "highlights", "geoBroadcasts"):
|
||||
assert key not in competition
|
||||
assert "featuredAthletes" not in competition["status"]
|
||||
for competitor in competition["competitors"]:
|
||||
assert "leaders" not in competitor
|
||||
assert "probables" not in competitor
|
||||
assert "links" not in competitor["team"]
|
||||
|
||||
def test_keeps_everything_else_unchanged(self):
|
||||
"""Removing the dropped keys from the original by hand gives exactly
|
||||
the slimmed payload: nothing else moved, changed or went missing."""
|
||||
original = {"leagues": [{"id": "10"}], "events": [_event(), _event()]}
|
||||
expected = copy.deepcopy(original)
|
||||
for event in expected["events"]:
|
||||
del event["links"]
|
||||
competition = event["competitions"][0]
|
||||
for key in ("leaders", "headlines", "highlights", "geoBroadcasts"):
|
||||
del competition[key]
|
||||
del competition["status"]["featuredAthletes"]
|
||||
for competitor in competition["competitors"]:
|
||||
del competitor["leaders"], competitor["probables"]
|
||||
del competitor["team"]["links"]
|
||||
assert slim_scoreboard_payload(original) == expected
|
||||
|
||||
def test_in_place_and_returns_payload(self):
|
||||
payload = {"events": [_event()]}
|
||||
assert slim_scoreboard_payload(payload) is payload
|
||||
|
||||
@pytest.mark.parametrize("payload", [
|
||||
None, [], "x", {}, {"events": None}, {"events": "x"},
|
||||
{"events": [None, 1, "x", {"competitions": None}]},
|
||||
{"events": [{"competitions": [None, {"status": None, "competitors": None}]}]},
|
||||
{"events": [{"competitions": [{"competitors": [None, {"team": None}]}]}]},
|
||||
])
|
||||
def test_odd_shapes_pass_through(self, payload):
|
||||
before = copy.deepcopy(payload)
|
||||
assert slim_scoreboard_payload(payload) == before
|
||||
|
||||
|
||||
class TestIsEspnScoreboardUrl:
|
||||
@pytest.mark.parametrize("url", [
|
||||
SCOREBOARD,
|
||||
SCOREBOARD + "/",
|
||||
"http://site.api.espn.com/apis/site/v2/sports/football/college-football/scoreboard",
|
||||
])
|
||||
def test_scoreboards(self, url):
|
||||
assert is_espn_scoreboard_url(url)
|
||||
|
||||
@pytest.mark.parametrize("url", [
|
||||
None, "", 12,
|
||||
"https://site.api.espn.com/apis/site/v2/sports/baseball/mlb/teams",
|
||||
"https://site.api.espn.com/apis/site/v2/sports/football/nfl/summary",
|
||||
"https://example.com/scoreboard",
|
||||
"https://espn.com.evil.example/apis/x/scoreboard",
|
||||
"https://notespn.com/apis/x/scoreboard",
|
||||
])
|
||||
def test_not_scoreboards(self, url):
|
||||
assert not is_espn_scoreboard_url(url)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service():
|
||||
shutdown_background_service()
|
||||
cache = MagicMock()
|
||||
cache.get.return_value = None
|
||||
svc = BackgroundDataService(cache, max_workers=1, request_timeout=5)
|
||||
yield svc
|
||||
svc.shutdown(wait=False)
|
||||
shutdown_background_service()
|
||||
|
||||
|
||||
def _run(service, url, **kwargs):
|
||||
response = Mock(status_code=200)
|
||||
response.json.return_value = {"events": [_event()]}
|
||||
response.raise_for_status.return_value = None
|
||||
delivered = []
|
||||
with patch.object(service.session, "get", return_value=response):
|
||||
req_id = service.submit_fetch_request(
|
||||
sport="mlb", year=2026, url=url, cache_key="mlb_schedule_window_14_7",
|
||||
callback=lambda result: delivered.append(result.data), **kwargs)
|
||||
deadline = time.time() + 5
|
||||
while not service.is_request_complete(req_id) and time.time() < deadline:
|
||||
time.sleep(0.02)
|
||||
cached = service.cache_manager.set.call_args[0][1]
|
||||
return cached, delivered
|
||||
|
||||
|
||||
class TestBackgroundServiceSlims:
|
||||
def test_espn_scoreboard_is_cached_and_delivered_slimmed(self, service):
|
||||
cached, delivered = _run(service, SCOREBOARD)
|
||||
competition = cached["events"][0]["competitions"][0]
|
||||
assert "leaders" not in competition
|
||||
assert "probables" not in competition["competitors"][0]
|
||||
assert competition["odds"] and competition["situation"]
|
||||
# The callback sees the very payload that was cached.
|
||||
assert delivered and delivered[0] is cached
|
||||
|
||||
def test_opt_out_caches_whole_response(self, service):
|
||||
cached, _ = _run(service, SCOREBOARD, slim_payload=False)
|
||||
assert cached == {"events": [_event()]}
|
||||
|
||||
def test_other_urls_untouched(self, service):
|
||||
cached, _ = _run(service, "https://example.com/feed")
|
||||
assert cached == {"events": [_event()]}
|
||||
@@ -330,6 +330,9 @@ class _Stream:
|
||||
def get_grouped_content_for_composition(self):
|
||||
return self.groups[0]
|
||||
|
||||
def get_active_plugin_ids(self):
|
||||
return [pid for pid, _ in self.groups[0]]
|
||||
|
||||
def take_next_group(self, count=None, offscreen_only=False):
|
||||
if self._i >= len(self.groups):
|
||||
return []
|
||||
|
||||
@@ -50,11 +50,9 @@ def _stub(bin_dir: Path, name: str, body: str) -> None:
|
||||
path.chmod(0o755)
|
||||
|
||||
|
||||
def _stubs(tmp_path: Path, python_version="3.11", network="NetworkManager",
|
||||
active=(), packages=()) -> Path:
|
||||
def _stubs(tmp_path: Path, python_version="3.11", network="NetworkManager") -> Path:
|
||||
"""python3 reports ``python_version`` (None: not installed); systemctl
|
||||
reports ``network`` and ``active`` as the only active units; dpkg-query
|
||||
lists ``packages`` as installed (none by default, so no desktop)."""
|
||||
reports ``network`` as the only active unit; dpkg lists no desktop."""
|
||||
bin_dir = tmp_path / "bin"
|
||||
bin_dir.mkdir(exist_ok=True)
|
||||
if python_version is None:
|
||||
@@ -63,14 +61,10 @@ def _stubs(tmp_path: Path, python_version="3.11", network="NetworkManager",
|
||||
else:
|
||||
_stub(bin_dir, "python3", f'case "$*" in *"%d.%d.%d"*) echo "{python_version}.1" ;; '
|
||||
f'*) echo "{python_version}" ;; esac\n')
|
||||
units = "|".join(f'*"is-active --quiet {unit}"' for unit in (network, *active))
|
||||
_stub(bin_dir, "systemctl", f'case "$*" in {units}) exit 0 ;; esac\nexit 3\n')
|
||||
_stub(bin_dir, "systemctl",
|
||||
f'case "$*" in *"is-active --quiet {network}") exit 0 ;; esac\nexit 3\n')
|
||||
_stub(bin_dir, "dpkg", "exit 0\n")
|
||||
if packages:
|
||||
listing = "".join(f"ii {name}\\n" for name in packages)
|
||||
_stub(bin_dir, "dpkg-query", f'printf "{listing}"\n')
|
||||
else:
|
||||
_stub(bin_dir, "dpkg-query", "exit 1\n")
|
||||
_stub(bin_dir, "dpkg-query", "exit 1\n")
|
||||
_stub(bin_dir, "ping", "exit 0\n")
|
||||
return bin_dir
|
||||
|
||||
@@ -155,22 +149,20 @@ class TestLibrary:
|
||||
|
||||
# --- first_time_install.sh's OS check ------------------------------------------
|
||||
|
||||
def _os_check_section(marker_root: str = "/nonexistent") -> str:
|
||||
def _os_check_section() -> str:
|
||||
"""first_time_install.sh from the OS check up to the next section, with
|
||||
the desktop-marker directories moved under ``marker_root`` (by default
|
||||
somewhere that cannot exist)."""
|
||||
the desktop-marker directories pointed somewhere that cannot exist."""
|
||||
text = FIRST_TIME.read_text(encoding="utf-8").replace("\r\n", "\n")
|
||||
start = text.index("# Check OS version")
|
||||
end = text.index("# The user who ran the installer")
|
||||
section = text[start:end]
|
||||
for marker in ("/usr/share/raspberrypi-ui-mods", "/usr/share/xsessions"):
|
||||
assert marker in section
|
||||
section = section.replace(marker, marker_root + marker)
|
||||
section = section.replace(marker, "/nonexistent" + marker)
|
||||
return section
|
||||
|
||||
|
||||
def run_os_check(tmp_path: Path, release: str, marker_root: str = "/nonexistent",
|
||||
**stub_args) -> subprocess.CompletedProcess:
|
||||
def run_os_check(tmp_path: Path, release: str, **stub_args) -> subprocess.CompletedProcess:
|
||||
"""Run the OS check as the installer would, from a copy of the project
|
||||
layout so ``$(dirname "$0")/scripts/install/lib_os.sh`` resolves."""
|
||||
project = tmp_path / "project"
|
||||
@@ -179,7 +171,7 @@ def run_os_check(tmp_path: Path, release: str, marker_root: str = "/nonexistent"
|
||||
script = project / "first_time_install.sh"
|
||||
script.write_text("set -Eeuo pipefail\n"
|
||||
"trap 'echo ERR-TRAP line $LINENO >&2; exit 99' ERR\n"
|
||||
+ _os_check_section(marker_root) + '\necho "SECTION-DONE"\n',
|
||||
+ _os_check_section() + '\necho "SECTION-DONE"\n',
|
||||
encoding="utf-8", newline="\n")
|
||||
env = _env(tmp_path, release, _stubs(tmp_path, **stub_args))
|
||||
return subprocess.run(["bash", str(script)], capture_output=True, text=True, env=env)
|
||||
@@ -238,47 +230,6 @@ class TestInstallerOsCheck:
|
||||
result = run_os_check(tmp_path, "trixie", python_version="3.13")
|
||||
assert "✓ NetworkManager is managing the network" in result.stdout
|
||||
|
||||
# A running desktop stops the install; one that is only installed warns.
|
||||
|
||||
@pytest.mark.parametrize("unit", ["display-manager", "lightdm", "gdm", "sddm"])
|
||||
def test_running_desktop_stops(self, tmp_path, unit):
|
||||
result = run_os_check(tmp_path, "trixie", python_version="3.13", active=(unit,))
|
||||
assert result.returncode == 1, result.stdout + result.stderr
|
||||
assert "A desktop is running" in result.stdout
|
||||
assert "multi-user.target" in result.stdout
|
||||
assert "SECTION-DONE" not in result.stdout
|
||||
|
||||
@pytest.mark.parametrize("package", [
|
||||
"raspberrypi-ui-mods", "rpd-wayland-core", "rpd-x-core", "xfce4",
|
||||
"lxde-core", "gnome-shell", "kde-plasma-desktop", "plasma-workspace:arm64",
|
||||
"task-desktop", "task-mate-desktop",
|
||||
])
|
||||
def test_installed_desktop_that_is_not_running_warns(self, tmp_path, package):
|
||||
result = run_os_check(tmp_path, "trixie", python_version="3.13",
|
||||
packages=("bash", package))
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
assert "Desktop packages are installed, but no desktop is running" in result.stdout
|
||||
assert "✓ OS requirements met" in result.stdout
|
||||
|
||||
def test_desktop_session_files_warn(self, tmp_path):
|
||||
(tmp_path / "markers" / "usr" / "share" / "xsessions").mkdir(parents=True)
|
||||
result = run_os_check(tmp_path, "trixie", python_version="3.13",
|
||||
marker_root=str(tmp_path / "markers"))
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
assert "Desktop packages are installed, but no desktop is running" in result.stdout
|
||||
|
||||
@pytest.mark.parametrize("packages", [
|
||||
# libblockdev contains "kde" mid-word; the old check stopped on it.
|
||||
("libblockdev-crypto3", "libblockdev3:arm64"),
|
||||
("gnome-keyring", "xfce4-terminal", "xfconf", "lxde-icon-theme",
|
||||
"kde-cli-tools", "gnome-session-common", "task-ssh-server", "rpd-plym-splash"),
|
||||
])
|
||||
def test_lite_with_desktop_named_parts_is_lite(self, tmp_path, packages):
|
||||
result = run_os_check(tmp_path, "trixie", python_version="3.13", packages=packages)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
assert "✓ Lite version confirmed" in result.stdout
|
||||
assert "WARNING: Desktop" not in result.stdout
|
||||
|
||||
|
||||
# --- check_system_compatibility.sh ---------------------------------------------
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Pure data, so every test here runs on every platform. What they pin:
|
||||
|
||||
* a request and a response survive encode -> decode -> parse unchanged, and
|
||||
the on-demand arguments carry exactly what the file mailbox carries;
|
||||
the on-demand arguments carry exactly what the REST route sends;
|
||||
* the envelope and the arguments refuse what the display could not act on
|
||||
(missing ids, wrong types, a non-finite duration) with a stable error code;
|
||||
* framing never holds more than one message's worth of bytes, however the
|
||||
@@ -60,9 +60,7 @@ class TestRoundTrip:
|
||||
def test_start_args_round_trip(self):
|
||||
args = OnDemandStartArgs(plugin_id='clock', mode='clock_main', duration=45.0,
|
||||
pinned=True)
|
||||
wire = _wire({'plugin_id': 'clock', 'mode': 'clock_main', 'duration': 45.0,
|
||||
'pinned': True})
|
||||
assert OnDemandStartArgs.from_dict(wire) == args
|
||||
assert OnDemandStartArgs.from_dict(_wire(args.to_dict())) == args
|
||||
|
||||
def test_encoded_messages_are_ascii_single_lines(self):
|
||||
data = c.encode_message({'v': 1, 'id': 'x', 'cmd': 'ping',
|
||||
@@ -186,8 +184,8 @@ class TestOnDemandArgs:
|
||||
|
||||
|
||||
class TestMailboxShape:
|
||||
"""Socket commands are handed to the mailbox's own handler, so they must
|
||||
look exactly like what the web route writes to the mailbox."""
|
||||
"""Socket commands are handed to the display's on-demand handler, so they
|
||||
must look exactly like the request dict it takes."""
|
||||
|
||||
def test_start(self):
|
||||
args = OnDemandStartArgs(plugin_id='clock', mode='clock_main', duration=60.0,
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
"""DisplayController's side of the control socket.
|
||||
|
||||
The server's handlers only queue; the render thread drains the queue where
|
||||
it reads the file mailbox (_poll_on_demand_requests) and hands each command
|
||||
to the mailbox's own handler (_handle_on_demand_request). These tests pin
|
||||
that hook:
|
||||
The server's handlers only queue; the render thread drains the queue
|
||||
(_poll_on_demand_requests) and hands each on-demand command to
|
||||
_handle_on_demand_request, which plugins' own requests use too. These tests
|
||||
pin that hook:
|
||||
|
||||
* a socket command is applied by the same code as a mailbox request, with
|
||||
its request id, and without waiting for the mailbox's 0.25 s read floor;
|
||||
* a request that arrives both ways (a client that timed out after the
|
||||
command was queued, then wrote the mailbox) is activated once;
|
||||
* a socket command is applied with its request id, at once, and touches no
|
||||
cache key (the file mailbox and the persisted processed id are gone);
|
||||
* a start sent twice with one request id is activated once;
|
||||
* a command that fails is contained, and the ones after it still run;
|
||||
* cleanup closes the socket; a disabled socket changes nothing.
|
||||
"""
|
||||
@@ -58,28 +57,15 @@ def controller(test_display_controller):
|
||||
c_ = test_display_controller
|
||||
c_.on_demand_active = False
|
||||
c_.on_demand_request_id = None
|
||||
c_._last_on_demand_poll = None
|
||||
mailbox = {'value': None}
|
||||
|
||||
def fake_get(key, *a, **kw):
|
||||
if key == 'display_on_demand_request':
|
||||
return mailbox['value']
|
||||
return None
|
||||
|
||||
def fake_delete(key):
|
||||
if key == 'display_on_demand_request':
|
||||
mailbox['value'] = None
|
||||
|
||||
c_.cache_manager.get = MagicMock(side_effect=fake_get)
|
||||
c_.cache_manager.get = MagicMock(return_value=None)
|
||||
c_.cache_manager.set = MagicMock()
|
||||
c_.cache_manager.delete = MagicMock(side_effect=fake_delete)
|
||||
c_.cache_manager.delete = MagicMock()
|
||||
c_._activate_on_demand = MagicMock()
|
||||
c_.mailbox = mailbox
|
||||
return c_
|
||||
|
||||
|
||||
class TestDrain:
|
||||
def test_a_socket_start_goes_through_the_mailbox_handler(self, controller):
|
||||
def test_a_socket_start_is_activated_with_its_request_id(self, controller):
|
||||
controller._control_server = FakeServer(_start('sock-1', duration=30.0, pinned=True))
|
||||
controller._poll_on_demand_requests()
|
||||
controller._activate_on_demand.assert_called_once()
|
||||
@@ -89,47 +75,28 @@ class TestDrain:
|
||||
assert request['plugin_id'] == 'clock'
|
||||
assert request['duration'] == 30.0 and request['pinned'] is True
|
||||
assert controller.on_demand_request_id == 'sock-1'
|
||||
# The same restart-replay guard as a mailbox request.
|
||||
controller.cache_manager.set.assert_any_call(
|
||||
'display_on_demand_processed_id', 'sock-1', ttl=3600)
|
||||
# No mailbox, and no persisted processed id.
|
||||
keys = {call.args[0] for m in (controller.cache_manager.get,
|
||||
controller.cache_manager.set,
|
||||
controller.cache_manager.delete)
|
||||
for call in m.call_args_list}
|
||||
assert not keys & {'display_on_demand_request', 'display_on_demand_processed_id'}
|
||||
|
||||
def test_socket_commands_skip_the_mailbox_floor(self, controller):
|
||||
def test_socket_commands_land_at_once(self, controller):
|
||||
server = FakeServer()
|
||||
controller._control_server = server
|
||||
controller._poll_on_demand_requests() # reads the mailbox, sets the floor
|
||||
reads = controller.cache_manager.get.call_count
|
||||
controller._poll_on_demand_requests()
|
||||
server.commands.append(_start('quick'))
|
||||
controller._poll_on_demand_requests() # within the floor
|
||||
controller._activate_on_demand.assert_called_once()
|
||||
mailbox_reads = [call for call in controller.cache_manager.get.call_args_list[reads:]
|
||||
if call.args[0] == 'display_on_demand_request']
|
||||
# Only _consume_on_demand_request's compare-before-delete re-read.
|
||||
assert len(mailbox_reads) <= 1
|
||||
|
||||
def test_a_request_that_came_both_ways_is_activated_once(self, controller):
|
||||
controller._control_server = FakeServer(_start('dup'))
|
||||
controller.mailbox['value'] = {'request_id': 'dup', 'action': 'start',
|
||||
'plugin_id': 'clock'}
|
||||
controller._poll_on_demand_requests()
|
||||
controller._last_on_demand_poll = None
|
||||
controller._poll_on_demand_requests()
|
||||
controller._activate_on_demand.assert_called_once()
|
||||
assert controller.mailbox['value'] is None, "the duplicate was left in the mailbox"
|
||||
|
||||
def test_a_fallback_write_landing_later_is_ignored(self, controller):
|
||||
controller._control_server = FakeServer(_start('late'))
|
||||
controller._poll_on_demand_requests()
|
||||
controller.mailbox['value'] = {'request_id': 'late', 'action': 'start',
|
||||
'plugin_id': 'clock'}
|
||||
controller._last_on_demand_poll = None
|
||||
controller._poll_on_demand_requests()
|
||||
controller._activate_on_demand.assert_called_once()
|
||||
|
||||
def test_the_mailbox_still_works_alongside(self, controller):
|
||||
controller._control_server = FakeServer()
|
||||
controller.mailbox['value'] = {'request_id': 'mb', 'action': 'start', 'plugin_id': 'p'}
|
||||
def test_a_start_sent_twice_is_activated_once(self, controller):
|
||||
server = FakeServer(_start('dup'))
|
||||
controller._control_server = server
|
||||
controller._poll_on_demand_requests()
|
||||
assert controller._activate_on_demand.call_args.args[0]['request_id'] == 'mb'
|
||||
server.commands.append(_start('dup'))
|
||||
controller._poll_on_demand_requests()
|
||||
controller._activate_on_demand.assert_called_once()
|
||||
|
||||
def test_a_socket_stop_ends_on_demand(self, controller):
|
||||
controller.on_demand_active = True
|
||||
@@ -159,10 +126,11 @@ class TestDrain:
|
||||
controller._poll_on_demand_requests()
|
||||
assert calls == ['bad', 'good']
|
||||
|
||||
def test_no_server_means_mailbox_only(self, controller):
|
||||
def test_no_server_means_nothing_to_apply(self, controller):
|
||||
controller._control_server = None
|
||||
controller._poll_on_demand_requests()
|
||||
controller._activate_on_demand.assert_not_called()
|
||||
controller.cache_manager.get.assert_not_called()
|
||||
|
||||
|
||||
class TestPendingChangesFloor:
|
||||
@@ -194,6 +162,14 @@ class TestLifecycle:
|
||||
assert status['on_demand']['plugin_id'] == 'clock'
|
||||
c.encode_message(status) # it has to fit on the wire
|
||||
|
||||
def test_the_state_names_the_request_it_answers(self, controller):
|
||||
# The web interface keeps reporting a delivered start as "starting"
|
||||
# until the display publishes state for that request id.
|
||||
assert controller._on_demand_state()['request_id'] is None
|
||||
controller._control_server = FakeServer(_start('sock-7'))
|
||||
controller._poll_on_demand_requests()
|
||||
assert controller._on_demand_state()['request_id'] == 'sock-7'
|
||||
|
||||
def test_cleanup_closes_the_socket(self, controller):
|
||||
server = FakeServer()
|
||||
controller._control_server = server
|
||||
|
||||
@@ -349,6 +349,7 @@ class TestVegasChecksEveryFrame:
|
||||
coord.render_pipeline.frame_interval = 0.0
|
||||
coord.render_pipeline.target_fps = 90
|
||||
coord.display_manager = MagicMock()
|
||||
coord.stats = {'cycles_completed': 0, 'interruptions': 0}
|
||||
coord._state_lock = threading.Lock()
|
||||
coord._is_active = True
|
||||
coord._is_paused = False
|
||||
|
||||
+14
-14
@@ -215,7 +215,7 @@ class TestWhereTheServerListens:
|
||||
assert srv.server_socket_path({}) is None
|
||||
assert ControlServer('x.sock').start() is False
|
||||
with pytest.raises(client.ControlError) as e:
|
||||
client.request(Command.PING, paths=['x.sock'])
|
||||
client.ping(paths=['x.sock'])
|
||||
assert e.value.reason == 'unsupported'
|
||||
|
||||
|
||||
@@ -271,7 +271,7 @@ def _read_line(s):
|
||||
class TestLiveSocket:
|
||||
def test_client_round_trip(self, live, sock_path):
|
||||
live()
|
||||
assert client.request(Command.PING, paths=[sock_path]) == {'pong': True}
|
||||
assert client.ping(paths=[sock_path]) == {'pong': True}
|
||||
assert client.hello(paths=[sock_path])['version'] == 1
|
||||
assert client.on_demand_status(paths=[sock_path])['current_mode'] == 'clock'
|
||||
|
||||
@@ -335,7 +335,7 @@ class TestLiveSocket:
|
||||
assert s.recv(10) == b'' # and hung up
|
||||
finally:
|
||||
s.close()
|
||||
assert client.request(Command.PING, paths=[sock_path]) == {'pong': True}
|
||||
assert client.ping(paths=[sock_path]) == {'pong': True}
|
||||
|
||||
def test_a_client_that_hangs_up_mid_message(self, live, sock_path):
|
||||
server = live()
|
||||
@@ -343,7 +343,7 @@ class TestLiveSocket:
|
||||
s.sendall(b'{"v":1,"id":"half","cmd":"on_demand.st')
|
||||
s.close()
|
||||
time.sleep(0.2)
|
||||
assert client.request(Command.PING, paths=[sock_path]) == {'pong': True}
|
||||
assert client.ping(paths=[sock_path]) == {'pong': True}
|
||||
assert server.drain() == []
|
||||
|
||||
def test_a_slow_client_is_dropped_and_blocks_nobody(self, live, sock_path):
|
||||
@@ -352,7 +352,7 @@ class TestLiveSocket:
|
||||
try:
|
||||
slow.sendall(b'{"v":1,') # ...and never finishes
|
||||
t0 = time.monotonic()
|
||||
assert client.request(Command.PING, paths=[sock_path]) == {'pong': True}
|
||||
assert client.ping(paths=[sock_path]) == {'pong': True}
|
||||
assert time.monotonic() - t0 < 0.5, 'a slow client held up another'
|
||||
assert slow.recv(100) == b'' # hung up on, not answered
|
||||
finally:
|
||||
@@ -380,7 +380,7 @@ class TestLiveSocket:
|
||||
for s in held:
|
||||
s.close()
|
||||
time.sleep(0.3)
|
||||
assert client.request(Command.PING, paths=[sock_path]) == {'pong': True}
|
||||
assert client.ping(paths=[sock_path]) == {'pong': True}
|
||||
|
||||
def test_many_concurrent_clients(self, live, sock_path):
|
||||
server = live(queue_size=64)
|
||||
@@ -408,7 +408,7 @@ class TestLiveSocket:
|
||||
s = ControlServer(sock_path, status_provider=lambda: status)
|
||||
try:
|
||||
assert s.start()
|
||||
assert client.request(Command.PING, paths=[sock_path]) == {'pong': True}
|
||||
assert client.ping(paths=[sock_path]) == {'pong': True}
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
@@ -416,7 +416,7 @@ class TestLiveSocket:
|
||||
live()
|
||||
second = ControlServer(sock_path, status_provider=lambda: status)
|
||||
assert second.start() is False
|
||||
assert client.request(Command.PING, paths=[sock_path]) == {'pong': True}
|
||||
assert client.ping(paths=[sock_path]) == {'pong': True}
|
||||
|
||||
def test_a_regular_file_is_never_removed(self, sock_path):
|
||||
with open(sock_path, 'w') as f:
|
||||
@@ -434,24 +434,24 @@ class TestLiveSocket:
|
||||
assert second.start()
|
||||
try:
|
||||
first.close() # must not unlink second's file
|
||||
assert client.request(Command.PING, paths=[sock_path]) == {'pong': True}
|
||||
assert client.ping(paths=[sock_path]) == {'pong': True}
|
||||
finally:
|
||||
second.close()
|
||||
|
||||
def test_client_reasons(self, sock_path, tmp_path):
|
||||
with pytest.raises(client.ControlError) as e:
|
||||
client.request(Command.PING, paths=[sock_path])
|
||||
client.ping(paths=[sock_path])
|
||||
assert e.value.reason == 'no_socket'
|
||||
dead = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
dead.bind(sock_path)
|
||||
try:
|
||||
with pytest.raises(client.ControlError) as e:
|
||||
client.request(Command.PING, paths=[sock_path])
|
||||
client.ping(paths=[sock_path])
|
||||
assert e.value.reason == 'refused'
|
||||
finally:
|
||||
dead.close()
|
||||
with pytest.raises(client.ControlError) as e:
|
||||
client.request(Command.PING, paths=[])
|
||||
client.ping(paths=[])
|
||||
assert e.value.reason == 'disabled'
|
||||
with pytest.raises(client.ControlError) as e:
|
||||
client.on_demand_start('x', None, None, paths=[sock_path])
|
||||
@@ -464,7 +464,7 @@ class TestLiveSocket:
|
||||
try:
|
||||
t0 = time.monotonic()
|
||||
with pytest.raises(client.ControlError) as e:
|
||||
client.request(Command.PING, paths=[sock_path], timeout=0.3)
|
||||
client.ping(paths=[sock_path], timeout=0.3)
|
||||
assert e.value.reason == 'timeout'
|
||||
assert time.monotonic() - t0 < 1.0
|
||||
finally:
|
||||
@@ -542,7 +542,7 @@ class TestPermissions:
|
||||
os.setgroups([])
|
||||
os.setgid(gid)
|
||||
os.setuid(nobody.pw_uid)
|
||||
result = json.dumps(client.request(Command.PING, paths=[sock_path]))
|
||||
result = json.dumps(client.ping(paths=[sock_path]))
|
||||
except client.ControlError as e:
|
||||
result = 'error:' + e.reason
|
||||
except Exception as e: # report anything else to the parent
|
||||
|
||||
+141
-206
@@ -1,14 +1,13 @@
|
||||
"""Stage 4 of the control socket: the file mailboxes are only a fallback.
|
||||
"""Stages 4 and 5 of the control socket: the socket is the only way in.
|
||||
|
||||
* The client knows whether the display had the request (``ControlError.sent``)
|
||||
and ``should_fall_back`` allows a mailbox write only when it did not, or
|
||||
when the display is too old to know the command (the upgrade case).
|
||||
and ``display_not_listening`` says when no display was there to take it
|
||||
(stopped or still starting), the one case a later retry can fix.
|
||||
* ``errors.clear`` is answered on the connection thread by a handler the
|
||||
display registers; a display without one answers like an older display.
|
||||
* The display looks at the on-demand mailbox once a second while the socket
|
||||
is up (0.25 s without it), reads it only when its file changed, never
|
||||
touches it for a socket command, and logs who still writes it.
|
||||
* ``CacheManager.file_signature`` / ``MailboxWatch`` make a look one stat().
|
||||
* Stage 5: the display no longer reads the ``display_on_demand_request``
|
||||
mailbox at all, and the cache refuses writes to the retired mailbox keys,
|
||||
warning once per writer.
|
||||
|
||||
The web routes are covered in test_api_v3_on_demand_socket.py and
|
||||
test_error_snapshot_cross_process.py.
|
||||
@@ -23,7 +22,8 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.cache_manager import CacheManager, MailboxWatch
|
||||
from src import cache_manager as cache_module
|
||||
from src.cache_manager import CacheManager
|
||||
from src.ipc import client
|
||||
from src.ipc import contract as c
|
||||
from src.ipc.contract import Command, ErrorsClearArgs, OnDemandStartArgs, ProtocolError
|
||||
@@ -81,38 +81,41 @@ class TestSent:
|
||||
def test_an_answer_is_returned(self):
|
||||
assert _call(FakeSock([_reply('rid-1', ok=True, result={'pong': True})])) == {'pong': True}
|
||||
|
||||
@pytest.mark.parametrize('reason', ['no_socket', 'refused', 'timeout', 'busy'])
|
||||
def test_a_failed_connect_was_not_sent(self, reason):
|
||||
@pytest.mark.parametrize('reason,listening', [('no_socket', False), ('refused', False),
|
||||
('timeout', True), ('busy', True)])
|
||||
def test_a_failed_connect_was_not_sent(self, reason, listening):
|
||||
e = _error(connect_error=client.ControlError(reason))
|
||||
assert e.reason == reason and e.sent is False
|
||||
assert client.should_fall_back(e)
|
||||
# Only "nothing there" is worth waiting for: a timeout or a full
|
||||
# backlog is a display that is there and stuck.
|
||||
assert client.display_not_listening(e) is not listening
|
||||
|
||||
def test_a_send_that_timed_out_was_not_sent(self):
|
||||
e = _error(sock=FakeSock(send_error=socket.timeout()))
|
||||
assert e.reason == 'timeout' and e.sent is False
|
||||
assert client.should_fall_back(e)
|
||||
assert not client.display_not_listening(e)
|
||||
|
||||
def test_silence_after_the_request_was_sent(self):
|
||||
e = _error(sock=FakeSock(recv_error=socket.timeout()))
|
||||
assert e.reason == 'timeout' and e.sent is True
|
||||
assert not client.should_fall_back(e)
|
||||
assert not client.display_not_listening(e)
|
||||
|
||||
def test_a_hang_up_after_the_request_was_sent(self):
|
||||
e = _error(sock=FakeSock([]))
|
||||
assert e.reason == 'closed' and e.sent is True
|
||||
assert not client.should_fall_back(e)
|
||||
assert not client.display_not_listening(e)
|
||||
|
||||
def test_a_garbled_reply(self):
|
||||
e = _error(sock=FakeSock([b'not json\n']))
|
||||
assert e.reason == 'bad_response' and e.sent is True
|
||||
assert not client.should_fall_back(e)
|
||||
assert not client.display_not_listening(e)
|
||||
|
||||
@pytest.mark.parametrize('code', ['busy', 'invalid_args', 'internal', 'pending', 'failed'])
|
||||
def test_a_display_error_with_an_id_was_sent(self, code):
|
||||
sock = FakeSock([_reply('rid-1', ok=False, error={'code': code, 'message': 'x'})])
|
||||
e = _error(sock=sock)
|
||||
assert e.reason == code and e.sent is True
|
||||
assert not client.should_fall_back(e)
|
||||
assert not client.display_not_listening(e)
|
||||
|
||||
@pytest.mark.parametrize('code', ['forbidden', 'busy'])
|
||||
def test_a_refusal_at_the_door_was_not_sent(self, code):
|
||||
@@ -122,23 +125,33 @@ class TestSent:
|
||||
'error': {'code': code, 'message': 'x'}}) + '\n').encode()])
|
||||
e = _error(sock=sock)
|
||||
assert e.reason == code and e.sent is False
|
||||
assert client.should_fall_back(e)
|
||||
assert not client.display_not_listening(e)
|
||||
|
||||
@pytest.mark.parametrize('code', ['unknown_command', 'unsupported_version'])
|
||||
def test_an_older_display_is_fallen_back_from(self, code):
|
||||
def test_an_older_display_is_listening(self, code):
|
||||
sock = FakeSock([_reply('rid-1', ok=False, error={'code': code, 'message': 'x'})])
|
||||
e = _error(sock=sock)
|
||||
assert e.sent is True
|
||||
assert client.should_fall_back(e)
|
||||
assert not client.display_not_listening(e)
|
||||
|
||||
def test_a_request_refused_locally_never_left(self):
|
||||
with pytest.raises(client.ControlError) as e:
|
||||
client.request(Command.ERRORS_CLEAR, {'cutoff': 'soon'}, paths=['/x.sock'])
|
||||
assert e.value.reason == 'invalid_request' and e.value.sent is False
|
||||
assert client.should_fall_back(e.value)
|
||||
assert not client.display_not_listening(e.value)
|
||||
|
||||
def test_a_client_bug_falls_back(self):
|
||||
assert client.should_fall_back(RuntimeError('boom'))
|
||||
@pytest.mark.parametrize('reason', ['disabled', 'unsupported'])
|
||||
def test_a_client_without_the_socket_is_not_waiting_for_a_display(self, reason):
|
||||
assert not client.display_not_listening(client.ControlError(reason))
|
||||
|
||||
@pytest.mark.parametrize('reason', sorted(client.NOT_LISTENING_REASONS))
|
||||
def test_a_request_that_was_sent_had_a_display(self, reason):
|
||||
# Only a request that never left can be waited on and sent again: one
|
||||
# that was sent may have been applied.
|
||||
assert not client.display_not_listening(client.ControlError(reason, sent=True))
|
||||
|
||||
def test_a_client_bug_is_not_a_missing_display(self):
|
||||
assert not client.display_not_listening(RuntimeError('boom'))
|
||||
|
||||
|
||||
# -- errors.clear on the server ------------------------------------------------------
|
||||
@@ -204,37 +217,28 @@ class TestErrorsClearOnTheServer:
|
||||
assert Command.ERRORS_CLEAR in result['commands']
|
||||
|
||||
|
||||
# -- the display's mailbox poll ------------------------------------------------------
|
||||
# -- stage 5: the display reads no mailbox --------------------------------------------
|
||||
|
||||
class SignedCache:
|
||||
"""The slice of CacheManager the poll uses, counting what it costs."""
|
||||
class CountingCache:
|
||||
"""The slice of CacheManager the on-demand path uses, counting every call."""
|
||||
|
||||
def __init__(self):
|
||||
self.data = {}
|
||||
self.writes = 0
|
||||
self.version = {}
|
||||
self.reads = []
|
||||
self.stats = 0
|
||||
self.deletes = []
|
||||
self.sets = []
|
||||
self.calls = []
|
||||
|
||||
def file_signature(self, key):
|
||||
self.stats += 1
|
||||
return (self.version[key], 0, 0) if key in self.data else None
|
||||
def __getattr__(self, name):
|
||||
# Any other method (file_signature, delete, ...) is recorded too.
|
||||
def call(*a, **kw):
|
||||
self.calls.append((name, a[0] if a else None))
|
||||
return call
|
||||
|
||||
def get(self, key, *a, **kw):
|
||||
self.reads.append(key)
|
||||
self.calls.append(('get', key))
|
||||
return self.data.get(key)
|
||||
|
||||
def set(self, key, value, *a, **kw):
|
||||
self.sets.append(key)
|
||||
self.calls.append(('set', key))
|
||||
self.data[key] = value
|
||||
self.writes += 1
|
||||
self.version[key] = self.writes
|
||||
|
||||
def delete(self, key):
|
||||
self.deletes.append(key)
|
||||
self.data.pop(key, None)
|
||||
|
||||
|
||||
class FakeServer:
|
||||
@@ -250,209 +254,140 @@ class FakeServer:
|
||||
return out
|
||||
|
||||
|
||||
class Clock:
|
||||
def __init__(self):
|
||||
self.t = 1000.0
|
||||
|
||||
def __call__(self):
|
||||
return self.t
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def controller(test_display_controller, monkeypatch):
|
||||
def controller(test_display_controller):
|
||||
dc = test_display_controller
|
||||
dc.cache_manager = SignedCache()
|
||||
dc.cache_manager = CountingCache()
|
||||
dc._activate_on_demand = MagicMock()
|
||||
dc.on_demand_active = False
|
||||
dc.on_demand_request_id = None
|
||||
dc._last_on_demand_poll = None
|
||||
dc._on_demand_mailbox = None
|
||||
dc._mailbox_writers_logged = frozenset()
|
||||
clock = Clock()
|
||||
monkeypatch.setattr('src.display_controller.time.monotonic', clock)
|
||||
dc.clock = clock
|
||||
return dc
|
||||
|
||||
|
||||
def _post(dc, rid, action='start', **fields):
|
||||
dc.cache_manager.set(MAILBOX, dict({'request_id': rid, 'action': action}, **fields))
|
||||
|
||||
|
||||
def _poll_for(dc, seconds, step=1 / 16): # exact in binary: no drift past a floor
|
||||
end = dc.clock.t + seconds
|
||||
while dc.clock.t < end:
|
||||
dc._poll_on_demand_requests()
|
||||
dc.clock.t += step
|
||||
|
||||
|
||||
class TestMailboxCadence:
|
||||
def test_without_a_socket_it_is_looked_at_every_quarter_second(self, controller):
|
||||
controller._control_server = None
|
||||
_poll_for(controller, 10.0)
|
||||
assert 38 <= controller.cache_manager.stats <= 42
|
||||
|
||||
def test_with_a_socket_it_is_looked_at_once_a_second(self, controller):
|
||||
controller._control_server = FakeServer()
|
||||
_poll_for(controller, 10.0)
|
||||
assert 9 <= controller.cache_manager.stats <= 11
|
||||
|
||||
def test_a_look_that_finds_nothing_reads_nothing(self, controller):
|
||||
controller._control_server = FakeServer()
|
||||
_poll_for(controller, 10.0)
|
||||
assert controller.cache_manager.reads == []
|
||||
|
||||
def test_an_unchanged_mailbox_is_not_read_again(self, controller):
|
||||
# An already-processed start the delete could not remove, say.
|
||||
controller._control_server = FakeServer()
|
||||
controller.cache_manager.delete = MagicMock() # the file stays
|
||||
_post(controller, 'once', plugin_id='clock')
|
||||
_poll_for(controller, 10.0)
|
||||
assert controller.cache_manager.reads.count(MAILBOX) <= 2 # the read + the re-check
|
||||
controller._activate_on_demand.assert_called_once()
|
||||
|
||||
def test_a_mailbox_request_lands_within_a_second_with_the_socket_up(self, controller):
|
||||
# The upgrade case the other way round: a new display, and a web
|
||||
# interface (or a plugin) that still writes the mailbox.
|
||||
controller._control_server = FakeServer()
|
||||
controller._poll_on_demand_requests()
|
||||
controller.clock.t += 0.1
|
||||
_post(controller, 'old-web', plugin_id='clock')
|
||||
posted = controller.clock.t
|
||||
while not controller._activate_on_demand.called:
|
||||
class TestNoMailbox:
|
||||
@pytest.mark.parametrize('with_socket', [False, True])
|
||||
def test_polling_touches_no_cache_key(self, controller, with_socket):
|
||||
controller._control_server = FakeServer() if with_socket else None
|
||||
controller.cache_manager.data[MAILBOX] = {'request_id': 'left', 'action': 'start',
|
||||
'plugin_id': 'clock'}
|
||||
for _ in range(200):
|
||||
controller._poll_on_demand_requests()
|
||||
controller.clock.t += 0.05
|
||||
assert controller.clock.t - posted < 1.5
|
||||
assert controller.clock.t - posted <= controller.MAILBOX_POLL_INTERVAL_WITH_SOCKET + 0.06
|
||||
assert MAILBOX in controller.cache_manager.deletes # consumed
|
||||
assert controller.cache_manager.calls == []
|
||||
controller._activate_on_demand.assert_not_called()
|
||||
|
||||
def test_socket_commands_still_land_at_once(self, controller):
|
||||
def test_socket_commands_land_at_once(self, controller):
|
||||
server = controller._control_server = FakeServer()
|
||||
controller._poll_on_demand_requests()
|
||||
server.commands.append(QueuedCommand('sock', Command.ON_DEMAND_START,
|
||||
OnDemandStartArgs(plugin_id='clock'), time.time()))
|
||||
controller._poll_on_demand_requests() # inside the mailbox interval
|
||||
controller._poll_on_demand_requests()
|
||||
controller._activate_on_demand.assert_called_once()
|
||||
|
||||
|
||||
class TestSocketCommandsLeaveTheMailboxAlone:
|
||||
def test_a_socket_start_reads_and_deletes_no_mailbox(self, controller):
|
||||
def test_a_start_is_applied_once_and_writes_no_processed_id(self, controller):
|
||||
server = controller._control_server = FakeServer()
|
||||
controller._poll_on_demand_requests()
|
||||
before = list(controller.cache_manager.reads)
|
||||
server.commands.append(QueuedCommand('s1', Command.ON_DEMAND_START,
|
||||
OnDemandStartArgs(plugin_id='clock'), time.time()))
|
||||
controller._poll_on_demand_requests()
|
||||
for _ in range(2):
|
||||
server.commands.append(QueuedCommand('same', Command.ON_DEMAND_START,
|
||||
OnDemandStartArgs(plugin_id='clock'), time.time()))
|
||||
controller._poll_on_demand_requests()
|
||||
controller._activate_on_demand.assert_called_once()
|
||||
assert MAILBOX not in controller.cache_manager.reads[len(before):]
|
||||
assert controller.cache_manager.deletes == []
|
||||
assert controller.cache_manager.calls == []
|
||||
|
||||
def test_a_socket_stop_reads_and_deletes_no_mailbox(self, controller):
|
||||
def test_a_socket_stop_touches_no_cache_key(self, controller):
|
||||
from src.ipc.contract import OnDemandStopArgs
|
||||
controller.on_demand_active = True
|
||||
controller._clear_on_demand = MagicMock()
|
||||
server = controller._control_server = FakeServer()
|
||||
server.commands.append(QueuedCommand('s2', Command.ON_DEMAND_STOP,
|
||||
OnDemandStopArgs(), time.time()))
|
||||
controller.clock.t += 5
|
||||
controller._drain_control_commands()
|
||||
controller._clear_on_demand.assert_called_once()
|
||||
assert MAILBOX not in controller.cache_manager.reads
|
||||
assert controller.cache_manager.deletes == []
|
||||
assert controller.cache_manager.calls == []
|
||||
|
||||
def test_a_mailbox_copy_of_a_socket_command_is_dropped(self, controller):
|
||||
# An older web interface timed out after the display queued the
|
||||
# command, then wrote the mailbox too.
|
||||
server = controller._control_server = FakeServer()
|
||||
server.commands.append(QueuedCommand('both', Command.ON_DEMAND_START,
|
||||
OnDemandStartArgs(plugin_id='clock'), time.time()))
|
||||
controller._poll_on_demand_requests()
|
||||
_post(controller, 'both', plugin_id='clock')
|
||||
_poll_for(controller, 2.0)
|
||||
controller._activate_on_demand.assert_called_once()
|
||||
assert MAILBOX not in controller.cache_manager.data
|
||||
def test_the_mailbox_helpers_are_gone(self):
|
||||
from src import display_controller as dcm
|
||||
from src import error_aggregator as ea
|
||||
for name in ('ON_DEMAND_MAILBOX_KEY', 'MailboxWatch'):
|
||||
assert not hasattr(dcm, name)
|
||||
assert not hasattr(ea, 'ERROR_CLEAR_REQUEST_KEY')
|
||||
assert not hasattr(cache_module, 'MailboxWatch')
|
||||
assert not hasattr(CacheManager, 'file_signature')
|
||||
assert not hasattr(client, 'should_fall_back')
|
||||
for name in ('_consume_on_demand_request', '_note_mailbox_request',
|
||||
'_mailbox_poll_interval', 'MAILBOX_POLL_INTERVAL_WITH_SOCKET'):
|
||||
assert not hasattr(dcm.DisplayController, name)
|
||||
|
||||
|
||||
class TestDeprecationLog:
|
||||
def test_each_mailbox_writer_is_logged_once(self, controller, caplog):
|
||||
controller._control_server = FakeServer()
|
||||
caplog.set_level(logging.INFO, logger='src.display_controller')
|
||||
for i, plugin in enumerate(['on-air', 'on-air', 'pomodoro-timer']):
|
||||
_post(controller, f'r{i}', plugin_id=plugin)
|
||||
_poll_for(controller, 1.2)
|
||||
lines = [r.getMessage() for r in caplog.records if 'file mailbox' in r.getMessage()]
|
||||
assert len(lines) == 2
|
||||
assert 'on-air' in lines[0] and 'pomodoro-timer' in lines[1]
|
||||
|
||||
def test_nothing_is_logged_without_a_socket(self, controller, caplog):
|
||||
controller._control_server = None
|
||||
caplog.set_level(logging.INFO, logger='src.display_controller')
|
||||
_post(controller, 'r', plugin_id='on-air')
|
||||
_poll_for(controller, 1.0)
|
||||
controller._activate_on_demand.assert_called_once()
|
||||
assert not [r for r in caplog.records if 'file mailbox' in r.getMessage()]
|
||||
|
||||
|
||||
# -- file_signature and MailboxWatch -------------------------------------------------
|
||||
# -- stage 5: writes to the retired keys are refused, with one warning per writer -----
|
||||
|
||||
@pytest.fixture
|
||||
def real_cache(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(CacheManager, '_get_writable_cache_dir', lambda self: str(tmp_path))
|
||||
monkeypatch.setattr(cache_module, '_retired_writers_warned', set())
|
||||
cache = CacheManager()
|
||||
yield cache
|
||||
cache.stop_cleanup_thread()
|
||||
|
||||
|
||||
class TestFileSignature:
|
||||
def test_absent_key(self, real_cache):
|
||||
assert real_cache.file_signature('nothing') is None
|
||||
class OldPlugin:
|
||||
"""What an old plugin looks like on the stack: BasePlugin gives every
|
||||
plugin ``plugin_id`` and ``cache_manager``."""
|
||||
|
||||
def test_every_write_is_a_new_signature(self, real_cache):
|
||||
seen = set()
|
||||
for i in range(20):
|
||||
# Same size each time, written as fast as possible.
|
||||
real_cache.set(MAILBOX, {'request_id': f'r{i:02d}'})
|
||||
sig = real_cache.file_signature(MAILBOX)
|
||||
assert isinstance(sig, tuple)
|
||||
seen.add(sig)
|
||||
assert len(seen) == 20
|
||||
def __init__(self, plugin_id, cache):
|
||||
self.plugin_id = plugin_id
|
||||
self.cache_manager = cache
|
||||
|
||||
def test_gone_after_a_delete(self, real_cache):
|
||||
real_cache.set(MAILBOX, {'a': 1})
|
||||
real_cache.delete(MAILBOX)
|
||||
assert real_cache.file_signature(MAILBOX) is None
|
||||
def trigger(self, target=None):
|
||||
self.cache_manager.set(MAILBOX, {'request_id': 'r', 'action': 'start',
|
||||
'plugin_id': target or self.plugin_id})
|
||||
|
||||
|
||||
class TestMailboxWatch:
|
||||
def test_reads_once_per_write(self, real_cache):
|
||||
watch = MailboxWatch(MAILBOX)
|
||||
assert watch.changed(real_cache) is False # no file
|
||||
real_cache.set(MAILBOX, {'request_id': 'a'})
|
||||
assert watch.changed(real_cache) is True
|
||||
assert watch.changed(real_cache) is False
|
||||
real_cache.set(MAILBOX, {'request_id': 'b'})
|
||||
assert watch.changed(real_cache) is True
|
||||
def _warnings(caplog):
|
||||
return [r.getMessage() for r in caplog.records
|
||||
if r.levelno == logging.WARNING and 'retired' in r.getMessage()]
|
||||
|
||||
def test_forget_reads_again(self, real_cache):
|
||||
watch = MailboxWatch(MAILBOX)
|
||||
real_cache.set(MAILBOX, {'request_id': 'a'})
|
||||
assert watch.changed(real_cache) is True
|
||||
watch.forget()
|
||||
assert watch.changed(real_cache) is True
|
||||
|
||||
def test_a_rewrite_after_a_delete_is_seen(self, real_cache):
|
||||
watch = MailboxWatch(MAILBOX)
|
||||
real_cache.set(MAILBOX, {'request_id': 'a'})
|
||||
assert watch.changed(real_cache)
|
||||
real_cache.delete(MAILBOX)
|
||||
assert watch.changed(real_cache) is False
|
||||
real_cache.set(MAILBOX, {'request_id': 'a'})
|
||||
assert watch.changed(real_cache) is True
|
||||
class TestRetiredKeys:
|
||||
@pytest.mark.parametrize('key', sorted(cache_module.RETIRED_MAILBOX_KEYS))
|
||||
def test_a_write_stores_nothing(self, real_cache, key):
|
||||
real_cache.set(key, {'request_id': 'r'})
|
||||
real_cache.save_cache(key, {'request_id': 'r2'})
|
||||
assert real_cache.get(key, max_age=None, memory_ttl=0) is None
|
||||
assert not [f for f in os.listdir(real_cache.cache_dir) if key in f]
|
||||
|
||||
def test_a_cache_that_cannot_tell_is_read_every_time(self):
|
||||
watch = MailboxWatch(MAILBOX)
|
||||
assert watch.changed(MagicMock()) is True
|
||||
assert watch.changed(MagicMock()) is True
|
||||
assert watch.changed(object()) is True
|
||||
def test_other_keys_are_unaffected(self, real_cache):
|
||||
real_cache.set('display_on_demand_state', {'active': True})
|
||||
assert real_cache.get('display_on_demand_state', max_age=None,
|
||||
memory_ttl=0) == {'active': True}
|
||||
|
||||
def test_the_writing_plugin_is_named_once(self, real_cache, caplog):
|
||||
caplog.set_level(logging.WARNING)
|
||||
on_air = OldPlugin('on-air', real_cache)
|
||||
for _ in range(3):
|
||||
on_air.trigger()
|
||||
OldPlugin('pomodoro-timer', real_cache).trigger()
|
||||
lines = _warnings(caplog)
|
||||
assert len(lines) == 2
|
||||
assert "plugin 'on-air'" in lines[0] and 'display_on_demand_request' in lines[0]
|
||||
assert 'request_on_demand' in lines[0]
|
||||
assert "plugin 'pomodoro-timer'" in lines[1]
|
||||
|
||||
def test_the_writer_is_the_caller_not_the_target(self, real_cache, caplog):
|
||||
caplog.set_level(logging.WARNING)
|
||||
OldPlugin('mqtt-notifications', real_cache).trigger(target='clock')
|
||||
(line,) = _warnings(caplog)
|
||||
assert "plugin 'mqtt-notifications'" in line and 'clock' not in line
|
||||
|
||||
def test_without_a_plugin_on_the_stack_the_request_names_it(self, real_cache, caplog):
|
||||
caplog.set_level(logging.WARNING)
|
||||
real_cache.set(MAILBOX, {'request_id': 'r', 'action': 'start', 'plugin_id': 'gif-player'})
|
||||
(line,) = _warnings(caplog)
|
||||
assert "plugin 'gif-player' (named in the request)" in line
|
||||
|
||||
def test_otherwise_unknown(self, real_cache, caplog):
|
||||
caplog.set_level(logging.WARNING)
|
||||
real_cache.set('plugin_error_clear_request', {'request_id': 'r', 'cutoff': 1.0})
|
||||
real_cache.set('plugin_error_clear_request', {'request_id': 'r2', 'cutoff': 2.0})
|
||||
(line,) = _warnings(caplog)
|
||||
assert 'by unknown' in line and '/api/v3/errors/clear' in line
|
||||
|
||||
|
||||
# -- end to end over a real socket ---------------------------------------------------
|
||||
@@ -479,24 +414,24 @@ class TestOverTheSocket:
|
||||
finally:
|
||||
server.close()
|
||||
|
||||
def test_an_older_display_is_an_upgrade_fallback(self, sock_path):
|
||||
def test_an_older_display_is_listening(self, sock_path):
|
||||
server = ControlServer(sock_path) # no errors.clear handler
|
||||
assert server.start()
|
||||
try:
|
||||
with pytest.raises(client.ControlError) as e:
|
||||
client.errors_clear('clr-2', 1.0, paths=[sock_path])
|
||||
assert e.value.reason == 'unknown_command' and e.value.sent is True
|
||||
assert client.should_fall_back(e.value)
|
||||
assert not client.display_not_listening(e.value)
|
||||
finally:
|
||||
server.close()
|
||||
|
||||
def test_no_display_is_a_fallback(self, sock_path):
|
||||
def test_no_display_is_not_listening(self, sock_path):
|
||||
with pytest.raises(client.ControlError) as e:
|
||||
client.errors_clear('clr-3', 1.0, paths=[sock_path])
|
||||
assert e.value.reason == 'no_socket' and e.value.sent is False
|
||||
assert client.should_fall_back(e.value)
|
||||
assert client.display_not_listening(e.value)
|
||||
|
||||
def test_a_full_queue_is_not_a_fallback(self, sock_path):
|
||||
def test_a_full_queue_is_listening(self, sock_path):
|
||||
server = ControlServer(sock_path, queue_size=1)
|
||||
assert server.start()
|
||||
try:
|
||||
@@ -504,6 +439,6 @@ class TestOverTheSocket:
|
||||
with pytest.raises(client.ControlError) as e:
|
||||
client.on_demand_start('q2', 'clock', None, paths=[sock_path])
|
||||
assert e.value.reason == 'busy' and e.value.sent is True
|
||||
assert not client.should_fall_back(e.value)
|
||||
assert not client.display_not_listening(e.value)
|
||||
finally:
|
||||
server.close()
|
||||
|
||||
@@ -522,7 +522,7 @@ class TestLiveStream:
|
||||
# max_clients is 2 and three streams are open: subscribers gave
|
||||
# their request slots back.
|
||||
for _ in range(4):
|
||||
assert client.request(Command.PING, paths=[path]) == {'pong': True}
|
||||
assert client.ping(paths=[path]) == {'pong': True}
|
||||
hub.publish('display', _display(mode='weather'), volatile=('last_updated',))
|
||||
assert _wait_until(lambda: all(
|
||||
s.latest()['state']['display']['mode'] == 'weather' for s in subs))
|
||||
|
||||
@@ -1,265 +0,0 @@
|
||||
"""The web interface sees the display modes the display actually registered (#668).
|
||||
|
||||
A plugin may compute its modes from its config: soccer-scoreboard registers
|
||||
``soccer_<league>_live/recent/upcoming`` for every league the user adds under
|
||||
``custom_leagues``, and no manifest can list those ahead of time. The display
|
||||
always rotated them -- DisplayController._register_loaded_plugin prefers
|
||||
``plugin.modes`` -- but the web process reads plugins as files, so its mode
|
||||
listing (/display/modes, the on-demand dialog) and find_plugin_for_mode
|
||||
(/display/on-demand/start with a mode and no plugin_id) saw only manifests.
|
||||
|
||||
The display now records each plugin's registered modes in its plugin state,
|
||||
the runtime snapshot carries them, and PluginCatalog prefers them while the
|
||||
snapshot is live, falling back to the manifest when it is not.
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from src.cache_manager import CacheManager # noqa: E402
|
||||
from src.plugin_system import plugin_runtime as rt # noqa: E402
|
||||
from src.plugin_system.plugin_catalog import PluginCatalog # noqa: E402
|
||||
from src.plugin_system.plugin_runtime import ( # noqa: E402
|
||||
PluginRuntimePublisher, build_runtime_snapshot, read_plugin_runtime,
|
||||
view_from_snapshot,
|
||||
)
|
||||
from src.plugin_system.plugin_state import PluginState, PluginStateManager # noqa: E402
|
||||
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
||||
|
||||
DECLARED = ["soccer_eng.1_live", "soccer_eng.1_recent", "soccer_eng.1_upcoming"]
|
||||
CUSTOM = ["soccer_sco.1_live", "soccer_sco.1_recent", "soccer_sco.1_upcoming"]
|
||||
REGISTERED = DECLARED + CUSTOM
|
||||
|
||||
|
||||
def _loaded_states(modes=None):
|
||||
states = PluginStateManager()
|
||||
states.set_state("soccer-scoreboard", PluginState.ENABLED)
|
||||
states.record_loaded("soccer-scoreboard", "2.24.1")
|
||||
if modes is not None:
|
||||
states.record_modes("soccer-scoreboard", modes)
|
||||
return states
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def shared_cache(tmp_path, monkeypatch):
|
||||
"""Two cache managers over one directory: the display's and the web's."""
|
||||
monkeypatch.setattr(CacheManager, "_get_writable_cache_dir",
|
||||
lambda self: str(tmp_path / "cache"))
|
||||
(tmp_path / "cache").mkdir()
|
||||
display_cache, web_cache = CacheManager(), CacheManager()
|
||||
yield display_cache, web_cache
|
||||
display_cache.stop_cleanup_thread()
|
||||
web_cache.stop_cleanup_thread()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plugins_dir(tmp_path):
|
||||
root = tmp_path / "plugins"
|
||||
for plugin_id, modes in (("soccer-scoreboard", DECLARED), ("clock-simple", ["clock"])):
|
||||
(root / plugin_id).mkdir(parents=True)
|
||||
(root / plugin_id / "manifest.json").write_text(json.dumps({
|
||||
"id": plugin_id, "name": plugin_id, "version": "1.0.0",
|
||||
"class_name": "P", "display_modes": modes}), encoding="utf-8")
|
||||
return root
|
||||
|
||||
|
||||
# --- The display records what it registered ---------------------------------
|
||||
|
||||
class TestStateManagerRecordsModes:
|
||||
def test_runtime_records_carry_them(self):
|
||||
assert _loaded_states(REGISTERED).runtime_records()[
|
||||
"soccer-scoreboard"]["modes"] == REGISTERED
|
||||
|
||||
def test_none_until_registered(self):
|
||||
assert _loaded_states().runtime_records()["soccer-scoreboard"]["modes"] is None
|
||||
|
||||
def test_a_new_list_is_a_change_the_same_one_is_not(self):
|
||||
"""change_count drives the publisher: re-registering an unchanged
|
||||
plugin must not cost an SD-card write."""
|
||||
states = _loaded_states(DECLARED)
|
||||
before = states.change_count
|
||||
states.record_modes("soccer-scoreboard", list(DECLARED))
|
||||
assert states.change_count == before
|
||||
states.record_modes("soccer-scoreboard", REGISTERED)
|
||||
assert states.change_count == before + 1
|
||||
|
||||
def test_ignored_for_a_plugin_that_is_not_loaded(self):
|
||||
states = PluginStateManager()
|
||||
states.record_modes("ghost", ["ghost"])
|
||||
assert "ghost" not in states.runtime_records()
|
||||
|
||||
def test_unload_forgets_them(self):
|
||||
states = _loaded_states(REGISTERED)
|
||||
states.clear_state("soccer-scoreboard")
|
||||
assert "soccer-scoreboard" not in states.runtime_records()
|
||||
|
||||
def test_a_reload_starts_without_them_until_registered_again(self):
|
||||
states = _loaded_states(REGISTERED)
|
||||
states.record_loaded("soccer-scoreboard", "2.25.0")
|
||||
assert states.runtime_records()["soccer-scoreboard"]["modes"] is None
|
||||
|
||||
|
||||
class TestControllerRecordsOnRegistration:
|
||||
def test_plugin_modes_reach_the_state_manager(self, test_display_controller):
|
||||
"""_register_loaded_plugin is the one path every load, enable and
|
||||
reload goes through."""
|
||||
c = test_display_controller
|
||||
states = _loaded_states()
|
||||
plugin = MagicMock()
|
||||
plugin.modes = list(REGISTERED)
|
||||
c.plugin_manager.state_manager = states
|
||||
c.plugin_manager.get_plugin = MagicMock(return_value=plugin)
|
||||
c.plugin_manager.plugin_manifests = {"soccer-scoreboard": {"display_modes": DECLARED}}
|
||||
|
||||
c._register_loaded_plugin("soccer-scoreboard")
|
||||
|
||||
assert states.runtime_records()["soccer-scoreboard"]["modes"] == REGISTERED
|
||||
|
||||
def test_a_failing_state_manager_does_not_break_registration(self, test_display_controller):
|
||||
c = test_display_controller
|
||||
plugin = MagicMock()
|
||||
plugin.modes = ["clock"]
|
||||
c.plugin_manager.state_manager.record_modes = MagicMock(side_effect=RuntimeError("x"))
|
||||
c.plugin_manager.get_plugin = MagicMock(return_value=plugin)
|
||||
c.plugin_manager.plugin_manifests = {}
|
||||
|
||||
assert c._register_loaded_plugin("clock-simple") == ["clock"]
|
||||
assert c.mode_to_plugin_id["clock"] == "clock-simple"
|
||||
|
||||
|
||||
# --- The snapshot carries them; only a live view reports them ---------------
|
||||
|
||||
class TestSnapshotAndView:
|
||||
NOW = 1_800_000_000.0
|
||||
|
||||
def _view(self, states, running=True, published_at=None):
|
||||
snapshot = build_runtime_snapshot(states, started_at=1.0, now=self.NOW,
|
||||
running=running)
|
||||
if published_at is not None:
|
||||
snapshot["published_at"] = published_at
|
||||
return view_from_snapshot(snapshot, now=self.NOW)
|
||||
|
||||
def test_live_view_reports_the_registered_modes(self):
|
||||
assert self._view(_loaded_states(REGISTERED)).display_modes(
|
||||
"soccer-scoreboard") == REGISTERED
|
||||
|
||||
def test_stale_and_stopped_views_report_nothing(self):
|
||||
states = _loaded_states(REGISTERED)
|
||||
assert self._view(states, published_at=self.NOW - 10_000).display_modes(
|
||||
"soccer-scoreboard") is None
|
||||
assert self._view(states, running=False).display_modes("soccer-scoreboard") is None
|
||||
|
||||
def test_unregistered_or_unknown_plugins_report_nothing(self):
|
||||
view = self._view(_loaded_states())
|
||||
assert view.display_modes("soccer-scoreboard") is None
|
||||
assert view.display_modes("not-loaded") is None
|
||||
|
||||
def test_a_runaway_list_is_bounded(self):
|
||||
modes = [f"m{i}" for i in range(1000)] + ["x" * 500]
|
||||
snapshot = build_runtime_snapshot(_loaded_states(modes), started_at=1.0, now=self.NOW)
|
||||
published = snapshot["plugins"]["soccer-scoreboard"]["modes"]
|
||||
assert len(published) == rt._MAX_MODES
|
||||
|
||||
def test_a_mode_name_is_kept_whole_or_dropped(self):
|
||||
long_mode = "x" * (rt._ID_CHARS + 1)
|
||||
snapshot = build_runtime_snapshot(_loaded_states(["ok", long_mode]),
|
||||
started_at=1.0, now=self.NOW)
|
||||
assert snapshot["plugins"]["soccer-scoreboard"]["modes"] == ["ok"]
|
||||
|
||||
def test_non_strings_from_a_hand_made_snapshot_are_dropped(self):
|
||||
snapshot = {"schema": rt.SNAPSHOT_SCHEMA, "running": True,
|
||||
"published_at": self.NOW, "plugins": {
|
||||
"p": {"loaded": True, "modes": ["a", 3, None]}}}
|
||||
assert view_from_snapshot(snapshot, now=self.NOW).display_modes("p") == ["a"]
|
||||
|
||||
|
||||
# --- The web's catalog prefers them -------------------------------------------
|
||||
|
||||
class TestCatalog:
|
||||
def _catalog(self, plugins_dir, web_cache):
|
||||
catalog = PluginCatalog(plugins_dir,
|
||||
runtime_source=lambda: read_plugin_runtime(web_cache))
|
||||
catalog.discover_plugins()
|
||||
return catalog
|
||||
|
||||
def test_live_display_modes_win_over_the_manifest(self, plugins_dir, shared_cache):
|
||||
display_cache, web_cache = shared_cache
|
||||
PluginRuntimePublisher(display_cache, _loaded_states(REGISTERED)).tick()
|
||||
catalog = self._catalog(plugins_dir, web_cache)
|
||||
assert catalog.get_plugin_display_modes("soccer-scoreboard") == REGISTERED
|
||||
|
||||
def test_a_custom_league_mode_resolves_to_its_plugin(self, plugins_dir, shared_cache):
|
||||
"""What /display/on-demand/start does with a mode and no plugin_id."""
|
||||
display_cache, web_cache = shared_cache
|
||||
PluginRuntimePublisher(display_cache, _loaded_states(REGISTERED)).tick()
|
||||
catalog = self._catalog(plugins_dir, web_cache)
|
||||
assert catalog.find_plugin_for_mode("SOCCER_SCO.1_LIVE") == "soccer-scoreboard"
|
||||
|
||||
def test_a_plugin_the_display_has_not_loaded_falls_back_to_its_manifest(
|
||||
self, plugins_dir, shared_cache):
|
||||
display_cache, web_cache = shared_cache
|
||||
PluginRuntimePublisher(display_cache, _loaded_states(REGISTERED)).tick()
|
||||
catalog = self._catalog(plugins_dir, web_cache)
|
||||
assert catalog.get_plugin_display_modes("clock-simple") == ["clock"]
|
||||
assert catalog.find_plugin_for_mode("clock") == "clock-simple"
|
||||
|
||||
def test_a_mode_the_display_dropped_does_not_resolve_by_manifest(
|
||||
self, plugins_dir, shared_cache):
|
||||
display_cache, web_cache = shared_cache
|
||||
PluginRuntimePublisher(display_cache, _loaded_states(CUSTOM)).tick()
|
||||
catalog = self._catalog(plugins_dir, web_cache)
|
||||
assert catalog.find_plugin_for_mode("soccer_eng.1_live") is None
|
||||
|
||||
def test_a_stopped_display_falls_back_to_manifests(self, plugins_dir, shared_cache):
|
||||
display_cache, web_cache = shared_cache
|
||||
publisher = PluginRuntimePublisher(display_cache, _loaded_states(REGISTERED))
|
||||
publisher.tick()
|
||||
publisher.stop()
|
||||
catalog = self._catalog(plugins_dir, web_cache)
|
||||
assert catalog.get_plugin_display_modes("soccer-scoreboard") == DECLARED
|
||||
assert catalog.find_plugin_for_mode("soccer_sco.1_live") is None
|
||||
|
||||
def test_no_runtime_source_is_manifests_only(self, plugins_dir):
|
||||
catalog = PluginCatalog(plugins_dir)
|
||||
catalog.discover_plugins()
|
||||
assert catalog.get_plugin_display_modes("soccer-scoreboard") == DECLARED
|
||||
|
||||
def test_a_failing_runtime_source_is_manifests_only(self, plugins_dir):
|
||||
def broken():
|
||||
raise OSError("cache gone")
|
||||
catalog = PluginCatalog(plugins_dir, runtime_source=broken)
|
||||
catalog.discover_plugins()
|
||||
assert catalog.get_plugin_display_modes("soccer-scoreboard") == DECLARED
|
||||
|
||||
def test_one_listing_reads_the_view_once(self, plugins_dir):
|
||||
source = MagicMock(return_value=None)
|
||||
catalog = PluginCatalog(plugins_dir, runtime_source=source)
|
||||
catalog.discover_plugins()
|
||||
for _ in range(10):
|
||||
catalog.get_plugin_display_modes("soccer-scoreboard")
|
||||
catalog.find_plugin_for_mode("clock")
|
||||
assert source.call_count == 1
|
||||
|
||||
|
||||
class TestDisplayModesRoute:
|
||||
def test_lists_the_custom_league_modes(self, api_v3_module, api_v3_client, # noqa: F811
|
||||
plugins_dir, shared_cache):
|
||||
display_cache, web_cache = shared_cache
|
||||
PluginRuntimePublisher(display_cache, _loaded_states(REGISTERED)).tick()
|
||||
api = api_v3_module.api_v3
|
||||
api.plugin_catalog = PluginCatalog(
|
||||
plugins_dir, runtime_source=lambda: read_plugin_runtime(web_cache))
|
||||
api.config_manager.load_config = MagicMock(return_value={
|
||||
"soccer-scoreboard": {"enabled": True}})
|
||||
|
||||
response = api_v3_client.get("/api/v3/display/modes")
|
||||
|
||||
assert response.status_code == 200, response.get_data(as_text=True)
|
||||
modes = {m["mode"]: m for m in response.get_json()["data"]["modes"]}
|
||||
assert set(modes) == set(REGISTERED)
|
||||
assert modes["soccer_sco.1_live"]["plugin_id"] == "soccer-scoreboard"
|
||||
@@ -327,3 +327,27 @@ class TestCleartextIsCalledOut:
|
||||
def test_tls_on_does_not_warn(self, bridge_module):
|
||||
assert bridge_module.warn_if_cleartext(
|
||||
{"mqtt_tls": True, "mqtt_password": "hunter2"}) is False
|
||||
|
||||
|
||||
class TestTheApiClientReadsStartingAsTaken:
|
||||
"""A cold start answers 202 with ``status: "starting"``: the request is
|
||||
taken and the web process delivers it once the display listens. The
|
||||
bridge must report that as success, not as a failure."""
|
||||
|
||||
def _client(self, bridge_module, status_code, body):
|
||||
response = MagicMock(status_code=status_code)
|
||||
response.json.return_value = body
|
||||
session = MagicMock()
|
||||
session.request.return_value = response
|
||||
return bridge_module.LEDMatrixClient("http://pi:5000", session=session)
|
||||
|
||||
def test_202_starting_is_returned_not_raised(self, bridge_module):
|
||||
api = self._client(bridge_module, 202, {
|
||||
"status": "starting", "message": "starting",
|
||||
"data": {"request_id": "r1", "pending": True}})
|
||||
assert api.start_on_demand(mode="clock") == {"request_id": "r1", "pending": True}
|
||||
|
||||
def test_an_error_is_still_raised(self, bridge_module):
|
||||
api = self._client(bridge_module, 503, {"status": "error", "message": "no display"})
|
||||
with pytest.raises(RuntimeError, match="no display"):
|
||||
api.start_on_demand(mode="clock")
|
||||
|
||||
@@ -337,13 +337,8 @@ class TestResumingAfterTheSession:
|
||||
|
||||
class TestStopClearsAnError:
|
||||
def _post_stop(self, c):
|
||||
stop = {'request_id': 'S1', 'action': 'stop'}
|
||||
c._last_on_demand_poll = None
|
||||
c.cache_manager.get = MagicMock(
|
||||
side_effect=lambda key, *a, **kw:
|
||||
stop if key == 'display_on_demand_request' else None)
|
||||
c.cache_manager.delete = MagicMock()
|
||||
c._poll_on_demand_requests()
|
||||
c._handle_on_demand_request({'request_id': 'S1', 'action': 'stop',
|
||||
'source': 'socket'})
|
||||
|
||||
def test_a_stop_after_a_failed_request_clears_the_error(self, controller):
|
||||
_start(controller, plugin_id='uninstalled')
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
"""The web process's on-demand dispatcher (web_interface/on_demand_dispatch.py).
|
||||
|
||||
A start that finds no display listening -- the service was just started, or
|
||||
is still loading its plugins -- is answered at once (202), and the
|
||||
dispatcher's one worker thread sends it again until the display
|
||||
acknowledges it or the wait runs out. These tests drive the worker with a
|
||||
fake ``send`` and short waits:
|
||||
|
||||
* acknowledged: delivered once, and reported as such;
|
||||
* nothing listening for the whole wait: ``start-timeout``;
|
||||
* any other failure: reported at once, not retried;
|
||||
* a newer start supersedes the pending one; a stop cancels it;
|
||||
* the outcome is reported for a while, then forgotten.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from src.ipc import client as control_client
|
||||
from web_interface import on_demand_dispatch
|
||||
from web_interface.on_demand_dispatch import OnDemandDispatcher
|
||||
|
||||
|
||||
def _not_listening():
|
||||
return control_client.ControlError("no_socket", "x", sent=False)
|
||||
|
||||
|
||||
def _payload(rid, plugin_id="weather"):
|
||||
return {"request_id": rid, "action": "start", "plugin_id": plugin_id,
|
||||
"mode": plugin_id, "duration": 30, "pinned": False}
|
||||
|
||||
|
||||
class FakeSend:
|
||||
"""Answers with ``outcomes`` in turn (an exception is raised, anything
|
||||
else acks); the last one repeats. Records the request ids it was sent."""
|
||||
|
||||
def __init__(self, *outcomes):
|
||||
self.outcomes = list(outcomes) or ["ack"]
|
||||
self.sent = []
|
||||
self.lock = threading.Lock()
|
||||
|
||||
def __call__(self, payload):
|
||||
with self.lock:
|
||||
self.sent.append(payload["request_id"])
|
||||
outcome = self.outcomes.pop(0) if len(self.outcomes) > 1 else self.outcomes[0]
|
||||
if isinstance(outcome, BaseException):
|
||||
raise outcome
|
||||
if callable(outcome):
|
||||
return outcome(payload)
|
||||
return {"accepted": True}
|
||||
|
||||
|
||||
def _until(predicate, timeout=5.0):
|
||||
end = time.monotonic() + timeout
|
||||
while time.monotonic() < end:
|
||||
if predicate():
|
||||
return True
|
||||
time.sleep(0.005)
|
||||
return False
|
||||
|
||||
|
||||
def _settled(d):
|
||||
return _until(lambda: not d.pending() and d._thread is None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make():
|
||||
made = []
|
||||
|
||||
def build(send, wait_seconds=2.0, retry_interval=0.01):
|
||||
d = OnDemandDispatcher(send, wait_seconds=wait_seconds, retry_interval=retry_interval)
|
||||
made.append(d)
|
||||
return d
|
||||
yield build
|
||||
for d in made:
|
||||
d.cancel("teardown")
|
||||
|
||||
|
||||
class TestDelivery:
|
||||
def test_an_acknowledged_start_is_delivered_once(self, make):
|
||||
send = FakeSend("ack")
|
||||
d = make(send)
|
||||
d.submit(_payload("r1"))
|
||||
assert _settled(d)
|
||||
assert send.sent == ["r1"]
|
||||
status = d.status()
|
||||
assert status["status"] == "delivered" and status["request_id"] == "r1"
|
||||
assert status["source"] == "web"
|
||||
|
||||
def test_it_is_sent_again_until_the_display_listens(self, make):
|
||||
send = FakeSend(_not_listening(), _not_listening(), "ack")
|
||||
d = make(send)
|
||||
d.submit(_payload("r1"))
|
||||
assert _settled(d)
|
||||
assert send.sent == ["r1", "r1", "r1"]
|
||||
assert d.status()["status"] == "delivered"
|
||||
|
||||
def test_while_it_waits_it_reports_starting(self, make):
|
||||
gate = threading.Event()
|
||||
|
||||
def blocked(payload):
|
||||
gate.wait(5)
|
||||
raise _not_listening()
|
||||
|
||||
send = FakeSend(blocked, "ack")
|
||||
d = make(send)
|
||||
d.submit(_payload("r1"))
|
||||
status = d.status()
|
||||
assert status["status"] == "starting" and status["active"] is False
|
||||
assert (status["plugin_id"], status["mode"], status["duration"]) == ("weather", "weather", 30)
|
||||
gate.set()
|
||||
assert _settled(d)
|
||||
assert d.status()["status"] == "delivered"
|
||||
|
||||
|
||||
class TestGivingUp:
|
||||
def test_nothing_listening_for_the_whole_wait_is_a_start_timeout(self, make):
|
||||
send = FakeSend(_not_listening())
|
||||
d = make(send, wait_seconds=0.2)
|
||||
started = time.monotonic()
|
||||
d.submit(_payload("r1"))
|
||||
assert _settled(d)
|
||||
waited = time.monotonic() - started
|
||||
status = d.status()
|
||||
assert status["status"] == "error" and status["error"] == "start-timeout"
|
||||
assert 0.15 <= waited < 2.0
|
||||
assert len(send.sent) > 3 # it kept trying in between
|
||||
|
||||
def test_a_start_can_carry_its_own_wait(self, make):
|
||||
# The route passes a shorter wait for a service that was already
|
||||
# running; the dispatcher's default must not override it.
|
||||
d = make(FakeSend(_not_listening()), wait_seconds=30.0)
|
||||
d.submit(_payload("r1"), wait_seconds=0.1)
|
||||
assert _until(lambda: not d.pending(), timeout=3.0), "it waited the default"
|
||||
assert d.status()["error"] == "start-timeout"
|
||||
|
||||
@pytest.mark.parametrize("reason,sent", [("busy", True), ("unknown_command", True),
|
||||
("timeout", True), ("forbidden", False)])
|
||||
def test_any_other_failure_is_reported_at_once(self, make, reason, sent):
|
||||
send = FakeSend(control_client.ControlError(reason, "x", sent=sent))
|
||||
d = make(send)
|
||||
d.submit(_payload("r1"))
|
||||
assert _settled(d)
|
||||
assert send.sent == ["r1"]
|
||||
status = d.status()
|
||||
assert status["status"] == "error" and status["error"] == reason
|
||||
|
||||
def test_a_client_bug_is_internal(self, make):
|
||||
d = make(FakeSend(RuntimeError("boom")))
|
||||
d.submit(_payload("r1"))
|
||||
assert _settled(d)
|
||||
assert d.status()["error"] == "internal"
|
||||
|
||||
|
||||
class TestOneAtATime:
|
||||
def test_a_newer_start_supersedes_the_pending_one(self, make):
|
||||
send = FakeSend(_not_listening())
|
||||
d = make(send)
|
||||
d.submit(_payload("old"))
|
||||
assert _until(lambda: "old" in send.sent)
|
||||
d.submit(_payload("new", plugin_id="clock"))
|
||||
assert d.status()["request_id"] == "new"
|
||||
n = len(send.sent)
|
||||
send.outcomes = ["ack"]
|
||||
assert _settled(d)
|
||||
assert send.sent[n:] and set(send.sent[n + 1:]) <= {"new"}
|
||||
assert send.sent[-1] == "new"
|
||||
status = d.status()
|
||||
assert status["status"] == "delivered" and status["plugin_id"] == "clock"
|
||||
|
||||
def test_an_answer_for_a_superseded_start_is_not_reported(self, make):
|
||||
# The old start's send is in flight when the new one arrives; its
|
||||
# ack must not mark the new one delivered.
|
||||
in_flight, release = threading.Event(), threading.Event()
|
||||
|
||||
def slow(payload):
|
||||
in_flight.set()
|
||||
release.wait(5)
|
||||
return {"accepted": True}
|
||||
|
||||
send = FakeSend(slow, _not_listening())
|
||||
d = make(send, wait_seconds=0.3)
|
||||
d.submit(_payload("old"))
|
||||
assert in_flight.wait(5)
|
||||
d.submit(_payload("new"))
|
||||
release.set()
|
||||
assert _settled(d)
|
||||
status = d.status()
|
||||
assert status["request_id"] == "new"
|
||||
assert status["status"] == "error" and status["error"] == "start-timeout"
|
||||
|
||||
def test_a_stop_cancels_the_pending_start(self, make):
|
||||
send = FakeSend(_not_listening())
|
||||
d = make(send)
|
||||
d.submit(_payload("r1"))
|
||||
assert _until(lambda: send.sent)
|
||||
assert d.cancel("requested-stop") == "r1"
|
||||
assert _settled(d)
|
||||
n = len(send.sent)
|
||||
time.sleep(0.05)
|
||||
assert len(send.sent) == n, "it kept sending a cancelled start"
|
||||
status = d.status()
|
||||
assert status["status"] == "idle" and status["last_event"] == "requested-stop"
|
||||
|
||||
def test_a_cancel_waits_for_a_send_in_flight(self, make):
|
||||
# Whatever the caller sends after cancel() must land after the
|
||||
# cancelled start, not race it to the display.
|
||||
in_flight, release = threading.Event(), threading.Event()
|
||||
|
||||
def slow(payload):
|
||||
in_flight.set()
|
||||
release.wait(5)
|
||||
raise _not_listening()
|
||||
|
||||
send = FakeSend(slow)
|
||||
d = make(send)
|
||||
d.submit(_payload("old"))
|
||||
assert in_flight.wait(5)
|
||||
done = threading.Event()
|
||||
threading.Thread(target=lambda: (d.cancel("superseded"), done.set()),
|
||||
daemon=True).start()
|
||||
assert not done.wait(0.1), "cancel returned while the old send was in flight"
|
||||
release.set()
|
||||
assert done.wait(5)
|
||||
assert _settled(d)
|
||||
assert send.sent == ["old"]
|
||||
|
||||
def test_a_cancel_with_nothing_pending_does_nothing(self, make):
|
||||
d = make(FakeSend("ack"))
|
||||
assert d.cancel() is None
|
||||
assert d.status() is None
|
||||
|
||||
def test_a_cancel_after_delivery_leaves_the_outcome(self, make):
|
||||
d = make(FakeSend("ack"))
|
||||
d.submit(_payload("r1"))
|
||||
assert _settled(d)
|
||||
assert d.cancel() is None
|
||||
assert d.status()["status"] == "delivered"
|
||||
|
||||
|
||||
class TestOutcomeLifetime:
|
||||
def test_an_outcome_is_forgotten_after_a_while(self, make, monkeypatch):
|
||||
d = make(FakeSend("ack"))
|
||||
d.submit(_payload("r1"))
|
||||
assert _settled(d)
|
||||
assert d.status() is not None
|
||||
monkeypatch.setattr(on_demand_dispatch, "OUTCOME_SECONDS", 0.0)
|
||||
time.sleep(0.01)
|
||||
assert d.status() is None
|
||||
|
||||
def test_a_pending_start_never_expires(self, make, monkeypatch):
|
||||
monkeypatch.setattr(on_demand_dispatch, "OUTCOME_SECONDS", 0.0)
|
||||
gate = threading.Event()
|
||||
d = make(FakeSend(lambda p: gate.wait(5) and {"accepted": True}))
|
||||
d.submit(_payload("r1"))
|
||||
time.sleep(0.01)
|
||||
assert d.status()["status"] == "starting"
|
||||
gate.set()
|
||||
assert _settled(d)
|
||||
|
||||
|
||||
def test_the_process_has_one_dispatcher():
|
||||
on_demand_dispatch.reset_for_tests()
|
||||
try:
|
||||
assert on_demand_dispatch.current() is None
|
||||
first = on_demand_dispatch.get_dispatcher(FakeSend())
|
||||
assert on_demand_dispatch.get_dispatcher(FakeSend()) is first
|
||||
assert on_demand_dispatch.current() is first
|
||||
finally:
|
||||
on_demand_dispatch.reset_for_tests()
|
||||
@@ -1,106 +0,0 @@
|
||||
"""The on-demand request mailbox: how often it is read, and how it is consumed.
|
||||
|
||||
The mailbox is a cache key the web process writes and the display process
|
||||
reads. Two properties matter and neither is obvious from the call site:
|
||||
|
||||
* it is polled after every rendered frame, so an uncached read here is a
|
||||
disk read at frame rate;
|
||||
* consuming it must not throw away a request that arrived while the previous
|
||||
one was being processed.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestPollingIsBounded:
|
||||
"""_poll_on_demand_requests runs ~125x/second on a scrolling mode.
|
||||
|
||||
The read is deliberately uncached (memory_ttl=0) because a cached one
|
||||
pinned the first request for an hour. That makes the call a real disk read,
|
||||
so it needs a floor -- without one it was ~125 reads per second to find
|
||||
nothing at all.
|
||||
"""
|
||||
|
||||
def test_first_call_always_reads(self, test_display_controller):
|
||||
c = test_display_controller
|
||||
c.cache_manager.get = MagicMock(return_value=None)
|
||||
c._poll_on_demand_requests()
|
||||
assert c.cache_manager.get.call_count == 1
|
||||
|
||||
def test_immediate_second_call_does_not_read(self, test_display_controller):
|
||||
c = test_display_controller
|
||||
c.cache_manager.get = MagicMock(return_value=None)
|
||||
c._poll_on_demand_requests()
|
||||
for _ in range(50):
|
||||
c._poll_on_demand_requests()
|
||||
assert c.cache_manager.get.call_count == 1, "polling was not bounded"
|
||||
|
||||
def test_reads_again_once_the_interval_has_passed(self, test_display_controller, monkeypatch):
|
||||
c = test_display_controller
|
||||
c.cache_manager.get = MagicMock(return_value=None)
|
||||
clock = {"t": 1000.0}
|
||||
monkeypatch.setattr("src.display_controller.time.monotonic", lambda: clock["t"])
|
||||
|
||||
c._poll_on_demand_requests()
|
||||
clock["t"] += c.ON_DEMAND_POLL_INTERVAL / 2
|
||||
c._poll_on_demand_requests()
|
||||
assert c.cache_manager.get.call_count == 1, "read before the interval elapsed"
|
||||
|
||||
clock["t"] += c.ON_DEMAND_POLL_INTERVAL
|
||||
c._poll_on_demand_requests()
|
||||
assert c.cache_manager.get.call_count == 2
|
||||
|
||||
def test_the_interval_is_short_enough_to_feel_instant(self, test_display_controller):
|
||||
# A person clicking in the web UI must not notice the floor.
|
||||
assert test_display_controller.ON_DEMAND_POLL_INTERVAL <= 0.5
|
||||
|
||||
|
||||
class TestMailboxIsConsumedByIdentity:
|
||||
"""Deleting whatever is in the mailbox loses a request that raced in."""
|
||||
|
||||
def _arrange(self, controller, first, later):
|
||||
"""Mailbox returns `first`, then `later` on the pre-delete re-read."""
|
||||
controller.on_demand_active = False
|
||||
controller.on_demand_request_id = None
|
||||
controller._last_on_demand_poll = None
|
||||
reads = iter([first, later])
|
||||
|
||||
def fake_get(key, *a, **kw):
|
||||
if key == 'display_on_demand_request':
|
||||
return next(reads, later)
|
||||
return None # processed-id lookup
|
||||
|
||||
controller.cache_manager.get = MagicMock(side_effect=fake_get)
|
||||
controller.cache_manager.set = MagicMock()
|
||||
controller.cache_manager.delete = MagicMock()
|
||||
controller._activate_on_demand = MagicMock()
|
||||
|
||||
REQ_A = {'request_id': 'A', 'action': 'start', 'plugin_id': 'p', 'mode': 'm'}
|
||||
REQ_B = {'request_id': 'B', 'action': 'start', 'plugin_id': 'p', 'mode': 'm'}
|
||||
|
||||
def test_own_request_is_deleted(self, test_display_controller):
|
||||
c = test_display_controller
|
||||
self._arrange(c, self.REQ_A, self.REQ_A)
|
||||
c._poll_on_demand_requests()
|
||||
c.cache_manager.delete.assert_called_once_with('display_on_demand_request')
|
||||
|
||||
def test_a_newer_request_is_left_for_the_next_poll(self, test_display_controller):
|
||||
c = test_display_controller
|
||||
self._arrange(c, self.REQ_A, self.REQ_B)
|
||||
c._poll_on_demand_requests()
|
||||
assert c.cache_manager.delete.call_count == 0, \
|
||||
"request B was deleted without ever being processed"
|
||||
|
||||
def test_an_already_empty_mailbox_is_still_cleared(self, test_display_controller):
|
||||
c = test_display_controller
|
||||
self._arrange(c, self.REQ_A, None)
|
||||
c._poll_on_demand_requests()
|
||||
c.cache_manager.delete.assert_called_once_with('display_on_demand_request')
|
||||
|
||||
def test_the_request_is_still_processed(self, test_display_controller):
|
||||
c = test_display_controller
|
||||
self._arrange(c, self.REQ_A, self.REQ_B)
|
||||
c._poll_on_demand_requests()
|
||||
c._activate_on_demand.assert_called_once()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user