mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-29 17:29:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
185d5f0aa0 | ||
|
|
9234332c5a | ||
|
|
4b9fc3c755 |
-208
@@ -19,199 +19,6 @@ accepts both, but the store flags the old spelling as deprecated
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
### Added: plugin schedules
|
|
||||||
|
|
||||||
Schedule → Plugin Schedules sets when each plugin may show, per day or with
|
|
||||||
one time range, and can hide a plugin whenever any game is live ("no news or
|
|
||||||
stocks on gameday"). A hidden plugin is skipped in the rotation, the live
|
|
||||||
takeover and Vegas mode, but stays loaded. New core config key
|
|
||||||
`plugin_schedules`, new `GET/POST /api/v3/config/plugin-schedules`, new
|
|
||||||
module `src/plugin_schedule.py` (the display schedule's window rule now
|
|
||||||
lives there too).
|
|
||||||
|
|
||||||
### Removed: the cache-key mailboxes (control socket stage 5) -- breaking
|
|
||||||
|
|
||||||
The control socket (`docs/IPC_CONTROL_SOCKET.md`) is now the only way the
|
|
||||||
web interface sends the display a command. The file mailboxes it fell back
|
|
||||||
to for one release are gone.
|
|
||||||
|
|
||||||
- **`display_on_demand_request` and `plugin_error_clear_request` are no
|
|
||||||
longer written or read.** The display stops polling the on-demand mailbox
|
|
||||||
(`MailboxWatch`, `MAILBOX_POLL_INTERVAL_WITH_SOCKET`,
|
|
||||||
`_consume_on_demand_request` and the persisted
|
|
||||||
`display_on_demand_processed_id` guard are removed), and the error
|
|
||||||
publisher stops reading the clear request. `CacheManager.file_signature`,
|
|
||||||
`src.cache_manager.MailboxWatch`, `src.ipc.client.should_fall_back` and
|
|
||||||
`src.error_aggregator.ERROR_CLEAR_REQUEST_KEY` are removed.
|
|
||||||
- **A write to either key is dropped, with one warning per writer.**
|
|
||||||
`CacheManager.save_cache` (and so `set`) refuses `RETIRED_MAILBOX_KEYS`
|
|
||||||
and logs `Ignored a write to the retired '<key>' cache key by plugin
|
|
||||||
'<id>'`, naming the plugin from the call stack or the request. Plugins
|
|
||||||
must use `BasePlugin.request_on_demand()` / `end_on_demand()` (3.8.1).
|
|
||||||
In the official monorepo, birdnet-go, mqtt-notifications, on-air and
|
|
||||||
pomodoro-timer still write the mailbox, but only as their fallback when
|
|
||||||
those methods are missing or answer `None`.
|
|
||||||
- **On-demand routes without a listening display.** `POST
|
|
||||||
/api/v3/display/on-demand/start` with no display listening starts the
|
|
||||||
service (when `start_service`, the default) and answers **`202`** with
|
|
||||||
`status: "starting"` at once; a single background worker in the web
|
|
||||||
process (`web_interface/on_demand_dispatch.py`) sends the request until
|
|
||||||
the display acknowledges it, for up to 45 s (10 s for a service that is
|
|
||||||
running but has no socket yet). `GET /display/on-demand/status` reports it
|
|
||||||
(`starting`; 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).
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
|
|
||||||
- Vegas: a `pause` (STATIC) turn redraws its plugin for the whole pause,
|
|
||||||
once a second, or at the high-FPS rate for a plugin that declares
|
|
||||||
`needs_high_fps` (or scrolls) -- the rate the rotation uses. The pause used
|
|
||||||
to call `display()` once and sleep out the duration, so a plugin that
|
|
||||||
rotates its screens or animates was frozen on its first frame. The first
|
|
||||||
draw still passes `force_clear=True`; later ones pass nothing. A redraw
|
|
||||||
that finds the plugin's `update()` holding its lock is skipped; the pause
|
|
||||||
carries on.
|
|
||||||
- Update Code no longer fails forever with `fatal: Unable to create
|
|
||||||
'.../.git/index.lock': File exists`. A git killed mid-command (a pull or
|
|
||||||
checkout that hit its timeout is SIGKILLed; a power cut does the same)
|
|
||||||
leaves that lock behind. Update Code and the automatic update's rollback
|
|
||||||
now remove it when no git process is running in the checkout and the
|
|
||||||
file is more than a few seconds old (new `web_interface/git_index_lock.py`);
|
|
||||||
while another git is running, Update Code says so instead of failing.
|
|
||||||
|
|
||||||
## 3.8.7
|
|
||||||
|
|
||||||
Sports consolidation family 10: `src.common.sports_view_model`, the game
|
|
||||||
dict every scoreboard screen draws, which the scoreboards adopt by flooring
|
|
||||||
on 3.8.7 (#796).
|
|
||||||
|
|
||||||
### New modules
|
|
||||||
|
|
||||||
- `src/common/sports_view_model.py` -- sports consolidation family 10, once
|
|
||||||
the plugins made `_extract_game_details_common` (nine bodies) one:
|
|
||||||
`SportsViewModelMixin` (`SportsCore`), the game dict every scoreboard screen
|
|
||||||
draws from one ESPN event, with `_status_is_final`, `_score_text`,
|
|
||||||
`_team_logo_url`, `_extract_team_record` and the `_logo_filename` seam
|
|
||||||
beside it. One rule for a final (state "post", not `completed: false`, not
|
|
||||||
postponed, canceled, suspended, abandoned, delayed or pending; a forfeit is
|
|
||||||
final); a called-off game is never live; scores are integer text; a time
|
|
||||||
without an offset is UTC. Keys are additive only; the module docstring
|
|
||||||
lists every key, its type and default. A plugin may inherit the mixin once
|
|
||||||
it floors on 3.8.7, and deletes its copies then. (#796)
|
|
||||||
|
|
||||||
## 3.8.6
|
|
||||||
|
|
||||||
A **Start Display on Boot** toggle on the General tab (#792), two fixes to
|
|
||||||
the shared rankings code (#791), `get_espn_scoreboard()` no longer caching a
|
|
||||||
null-events answer, and sports consolidation family 9:
|
|
||||||
`src.common.sports_live_fetch`, which the scoreboards adopt by flooring on
|
|
||||||
3.8.6 (#793).
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
- General tab: a **Start Display on Boot** toggle turns
|
|
||||||
`ledmatrix.service`'s autostart on or off right away (no Save needed),
|
|
||||||
through the existing `enable_autostart` / `disable_autostart` system
|
|
||||||
actions. It shows systemd's own state, so nothing is stored in
|
|
||||||
`config.json`; the default stays on (the installer enables the unit). The
|
|
||||||
web interface still starts on boot either way. Hidden when the unit's state
|
|
||||||
cannot be read (not installed, or no systemd).
|
|
||||||
|
|
||||||
### New modules
|
|
||||||
|
|
||||||
- `src/common/sports_live_fetch.py` -- sports consolidation family 9, once the
|
|
||||||
plugins made `_fetch_todays_games` (seven bodies) and `_fetch_odds` (three)
|
|
||||||
one each: `SportsLiveFetchMixin` (`SportsCore`), with the odds worker
|
|
||||||
`_drain_odds_queue` beside them. The live poll asks ESPN on every poll
|
|
||||||
(a copy another reader fetched within the last second is reused) and
|
|
||||||
leaves the answer under the shared `espn_scoreboard_cache_key` for its
|
|
||||||
dates; college basketball is asked by date like every other league; an
|
|
||||||
answer whose `events` is not a list counts as no games. Odds never block
|
|
||||||
`update()`: one daemon worker per manager answers a queue and attaches each
|
|
||||||
line in place under `_games_lock`, one batch in flight at a time. A plugin
|
|
||||||
may inherit the mixin once it floors on 3.8.6, and deletes its copies then.
|
|
||||||
(#793)
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
|
|
||||||
- `get_espn_scoreboard()` no longer caches an answer whose `events` is not a
|
|
||||||
list (ESPN has sent `"events": null`): every reader of the shared scoreboard
|
|
||||||
key -- odds-ticker, the other scoreboards -- inherited it until it aged out.
|
|
||||||
The answer is still returned to the caller as it came.
|
|
||||||
|
|
||||||
- `SportsRankingsMixin` (#789's deferred review points, now that it is the
|
|
||||||
only copy): a poll entry whose `current` cannot be converted to an
|
|
||||||
integer (`None`, a non-numeric string) is skipped instead of throwing
|
|
||||||
away the whole poll -- which left
|
|
||||||
every team unranked and re-fetched the standings on every update -- and
|
|
||||||
`_ranked_positions()` decides id-or-abbreviation per side, so one side
|
|
||||||
without an id no longer drops the other side's id and lets a shared
|
|
||||||
abbreviation rank the wrong school. Well-formed ESPN data ranks as before.
|
|
||||||
|
|
||||||
## 3.8.5
|
|
||||||
|
|
||||||
Fewer ESPN requests on the hourly schedule refresh (#754), and sports
|
|
||||||
consolidation family 8: `src.common.sports_rankings`, which the scoreboards
|
|
||||||
adopt by flooring on 3.8.5 (#789).
|
|
||||||
|
|
||||||
### ESPN: settled days are not fetched again
|
|
||||||
|
|
||||||
- A scoreboard's hourly Recent/Upcoming refresh asks ESPN for its window one
|
|
||||||
day at a time, and most of those days can no longer change. A chunk whose
|
|
||||||
last day is three or more days back is now kept in memory for 24 hours
|
|
||||||
(compressed, at most 512 entries / 8 MB) and answered from there, for
|
|
||||||
`fetch_espn_scoreboard` and the background service alike. Failed and
|
|
||||||
capped chunks are never kept, and each hit is parsed afresh. Against live
|
|
||||||
ESPN, a second refresh of five scoreboard windows went from 111 requests
|
|
||||||
and 10.2 MB to 50 requests and 3.2 MB with identical events. (#754)
|
|
||||||
|
|
||||||
### New modules
|
|
||||||
|
|
||||||
- `src/common/sports_rankings.py` -- sports consolidation family 8, once the
|
|
||||||
plugins made `_fetch_team_rankings`, `_choose_poll` (three bodies each),
|
|
||||||
`_load_division_team_ids`, `_passes_other_filters`, `_best_rank` and
|
|
||||||
`_is_ranked_game` (two each) one each: `SportsRankingsMixin` (`SportsCore`),
|
|
||||||
with `_ranked_positions` and `_poll_season_ended` beside them, and
|
|
||||||
`choose_top_division_poll()` for the dynamic team resolvers. Ranks are kept
|
|
||||||
by ESPN team id as well as abbreviation and a game with ids is ranked by id;
|
|
||||||
a poll from a season that has ended (its `endDate` has passed) loads
|
|
||||||
nothing, so no rank badge shows between seasons; the college football
|
|
||||||
division rosters are asked for the season year. A plugin may inherit the
|
|
||||||
mixin once it floors on 3.8.5, and deletes its copies then. (#789)
|
|
||||||
|
|
||||||
## 3.8.4
|
|
||||||
|
|
||||||
A panel that cannot reach its refresh cap is reported with a cap it can hold
|
|
||||||
(#759); a failed on-demand request ends its own session (#779); exception
|
|
||||||
messages stay out of API responses (#778); and sports consolidation family 7,
|
|
||||||
`src.common.sports_rotation`, which the scoreboards adopt by flooring on
|
|
||||||
3.8.4 (#786).
|
|
||||||
|
|
||||||
### Scroll speed: a panel slower than its refresh cap is reported
|
### Scroll speed: a panel slower than its refresh cap is reported
|
||||||
|
|
||||||
- Scroll speeds are solved against `limit_refresh_rate_hz`, so a panel that
|
- Scroll speeds are solved against `limit_refresh_rate_hz`, so a panel that
|
||||||
@@ -243,21 +50,6 @@ messages stay out of API responses (#778); and sports consolidation family 7,
|
|||||||
but drop systemctl's `stdout`/`stderr`; WiFi, unit-refresh and
|
but drop systemctl's `stdout`/`stderr`; WiFi, unit-refresh and
|
||||||
config-save failures say what failed and point at the log.
|
config-save failures say what failed and point at the log.
|
||||||
|
|
||||||
### New modules
|
|
||||||
|
|
||||||
- `src/common/sports_rotation.py` -- sports consolidation family 7, once the
|
|
||||||
plugins made `_by_importance`, `_other_games_window`,
|
|
||||||
`_advance_other_games_if_due` (two bodies each), `_rotate_other_games_on_display`
|
|
||||||
(two) and `_attach_odds_to_rotated_games` (three; ufc had none) one each:
|
|
||||||
`SportsRotationMixin` (`SportsCore`), the other-games rotation. The window
|
|
||||||
advances under `_games_lock`, so `update()` and `display()` advancing it at
|
|
||||||
once no longer skip a window; the display path's due-check reads the pool
|
|
||||||
`_compose_selection` will cut, unfiltered fallback included; rotated-in
|
|
||||||
games get odds when `show_odds` is on. `_rankings_loaded()` is the seam
|
|
||||||
`_by_importance` asks (default: the abbreviation table is non-empty;
|
|
||||||
football also counts its by-id table). A plugin may inherit the mixin once
|
|
||||||
it floors on 3.8.4, and deletes its copies then. (#786)
|
|
||||||
|
|
||||||
## 3.8.3
|
## 3.8.3
|
||||||
|
|
||||||
Fresh installs on Raspberry Pi OS Lite work again: 3.8.2's installer reported
|
Fresh installs on Raspberry Pi OS Lite work again: 3.8.2's installer reported
|
||||||
|
|||||||
@@ -47,7 +47,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"plugin_schedules": {},
|
|
||||||
"dim_schedule": {
|
"dim_schedule": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"dim_brightness": 30,
|
"dim_brightness": 30,
|
||||||
|
|||||||
+45
-21
@@ -21,10 +21,7 @@ Each plugin has a *Vegas participation*:
|
|||||||
**`pause`:**
|
**`pause`:**
|
||||||
- The scroll stops when the plugin's turn comes round
|
- The scroll stops when the plugin's turn comes round
|
||||||
- The plugin draws the whole panel for its display duration, then the
|
- The plugin draws the whole panel for its display duration, then the
|
||||||
scroll resumes. `display()` is called again through the pause at the
|
scroll resumes
|
||||||
rotation's rate (once a second, or the high-FPS rate for a plugin that
|
|
||||||
declares `needs_high_fps`), so a plugin can rotate screens or animate in
|
|
||||||
it; only the first call passes `force_clear=True`
|
|
||||||
- Best for content that needs to be read in full, or alerts
|
- Best for content that needs to be read in full, or alerts
|
||||||
|
|
||||||
**`exclude`:**
|
**`exclude`:**
|
||||||
@@ -365,7 +362,7 @@ Vegas mode consists of four core components working together to provide smooth 1
|
|||||||
|
|
||||||
**Main Loop:**
|
**Main Loop:**
|
||||||
1. Check for interrupts (live priority, on-demand, config updates)
|
1. Check for interrupts (live priority, on-demand, config updates)
|
||||||
2. If a static pause is due, redraw that plugin for its duration
|
2. If static pause active, wait for duration
|
||||||
3. Otherwise, delegate to render pipeline for frame rendering
|
3. Otherwise, delegate to render pipeline for frame rendering
|
||||||
4. Sleep to maintain target FPS
|
4. Sleep to maintain target FPS
|
||||||
|
|
||||||
@@ -653,10 +650,11 @@ When nothing is running on demand, `data.state` is
|
|||||||
> above (or the web UI buttons). The API handlers
|
> above (or the web UI buttons). The API handlers
|
||||||
> (`start_on_demand_display()` / `stop_on_demand_display()` in
|
> (`start_on_demand_display()` / `stop_on_demand_display()` in
|
||||||
> `web_interface/blueprints/api_v3/display.py`) send the request over the
|
> `web_interface/blueprints/api_v3/display.py`) send the request over the
|
||||||
> display's control socket ([IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md)),
|
> 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
|
> Only when the socket cannot carry it do they write it into the cache
|
||||||
> gone). A plugin asks for the screen with `BasePlugin.request_on_demand()`
|
> manager under the `display_on_demand_request` key, which
|
||||||
> (see [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md)). A separate
|
> `DisplayController._poll_on_demand_requests()`
|
||||||
|
> (`src/display_controller.py`) picks up. A separate
|
||||||
> `display_on_demand_config` key is used by the controller itself
|
> `display_on_demand_config` key is used by the controller itself
|
||||||
> during activation (`_activate_on_demand()`) to track what's
|
> during activation (`_activate_on_demand()`) to track what's
|
||||||
> currently running, and is cleared by `_clear_on_demand()`.
|
> currently running, and is cleared by `_clear_on_demand()`.
|
||||||
@@ -739,12 +737,29 @@ keys helps troubleshoot stuck states.
|
|||||||
|
|
||||||
### Cache Keys
|
### Cache Keys
|
||||||
|
|
||||||
Requests are not cache keys: they go over the control socket. The
|
**1. display_on_demand_request** (TTL: 1 hour)
|
||||||
`display_on_demand_request` and `display_on_demand_processed_id` keys of
|
```json
|
||||||
earlier releases are no longer written or read; a leftover file is
|
{
|
||||||
harmless and can be deleted.
|
"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
|
||||||
|
|
||||||
**1. display_on_demand_config** (No TTL)
|
**2. display_on_demand_config** (No TTL)
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"mode": "mode-name",
|
"mode": "mode-name",
|
||||||
@@ -756,7 +771,7 @@ harmless and can be deleted.
|
|||||||
**When Set:** Controller processes start request
|
**When Set:** Controller processes start request
|
||||||
**Auto-Cleared:** When on-demand stops
|
**Auto-Cleared:** When on-demand stops
|
||||||
|
|
||||||
**2. display_on_demand_state** (Continuously updated)
|
**3. display_on_demand_state** (Continuously updated)
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"active": true,
|
"active": true,
|
||||||
@@ -770,24 +785,31 @@ harmless and can be deleted.
|
|||||||
**When Set:** Every display loop iteration
|
**When Set:** Every display loop iteration
|
||||||
**Auto-Cleared:** Never (continuously updated)
|
**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
|
### When Manual Clearing is Needed
|
||||||
|
|
||||||
**Scenario 1: Stuck in On-Demand State**
|
**Scenario 1: Stuck in On-Demand State**
|
||||||
- Symptom: Display stays on one plugin, won't return to rotation
|
- Symptom: Display stays on one plugin, won't return to rotation
|
||||||
- Clear: `config`, `state`
|
- Clear: `config`, `state`, `request`
|
||||||
|
|
||||||
**Scenario 2: Mode Switching Issues**
|
**Scenario 2: Mode Switching Issues**
|
||||||
- Symptom: Can't change to different plugin
|
- Symptom: Can't change to different plugin
|
||||||
- Clear: `state`, then restart the display
|
- Clear: `request`, `processed_id`, `state`
|
||||||
|
|
||||||
**Scenario 3: On-Demand Not Activating**
|
**Scenario 3: On-Demand Not Activating**
|
||||||
- Symptom: Button click does nothing, or answers an error
|
- Symptom: Button click does nothing
|
||||||
- Check the error's `socket_error` (see [IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md),
|
- Clear: `processed_id`, `request`
|
||||||
"Checking it on a device"); no cache key is involved
|
|
||||||
|
|
||||||
**Scenario 4: After Service Crash**
|
**Scenario 4: After Service Crash**
|
||||||
- Symptom: Strange behavior after crash/restart
|
- Symptom: Strange behavior after crash/restart
|
||||||
- Clear: both keys
|
- Clear: All four keys
|
||||||
|
|
||||||
### Manual Recovery Procedures
|
### Manual Recovery Procedures
|
||||||
|
|
||||||
@@ -824,6 +846,8 @@ from src.cache_manager import CacheManager
|
|||||||
cache = CacheManager()
|
cache = CacheManager()
|
||||||
cache.clear_cache('display_on_demand_config')
|
cache.clear_cache('display_on_demand_config')
|
||||||
cache.clear_cache('display_on_demand_state')
|
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
|
> `CacheManager` also has a `delete(key)` method — a thin wrapper over
|
||||||
|
|||||||
+15
-14
@@ -42,11 +42,11 @@ each other. They share three things:
|
|||||||
| State | Where | Written by | Read by |
|
| State | Where | Written by | Read by |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| On-demand command | control socket `/run/ledmatrix/control.sock` ([IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md)) | web: `start_on_demand_display()` / `stop_on_demand_display()` in [`api_v3/display.py`](../web_interface/blueprints/api_v3/display.py), via [`src/ipc/client.py`](../src/ipc/client.py) | display: [`src/ipc/server.py`](../src/ipc/server.py) acks; the render thread applies it in `_poll_on_demand_requests()` |
|
| On-demand command | control socket `/run/ledmatrix/control.sock` ([IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md)) | web: `start_on_demand_display()` / `stop_on_demand_display()` in [`api_v3/display.py`](../web_interface/blueprints/api_v3/display.py), via [`src/ipc/client.py`](../src/ipc/client.py) | display: [`src/ipc/server.py`](../src/ipc/server.py) acks; the render thread applies it in `_poll_on_demand_requests()` |
|
||||||
| On-demand request 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 request (fallback) | cache `display_on_demand_request` | web, only when the socket could not carry the request (`should_fall_back`); four plugins write it directly | display: `_poll_on_demand_requests()`, a `stat()` every 1 s while the socket is up (0.25 s without), read only when the file changed |
|
||||||
| On-demand state | cache `display_on_demand_state` | display: `_publish_on_demand_state()` | web: `/api/v3/display/on-demand/status` |
|
| On-demand state | cache `display_on_demand_state` | display: `_publish_on_demand_state()` | web: `/api/v3/display/on-demand/status` |
|
||||||
| Current screen | cache `display_current_state` | display | web: `/api/v3/display/current-status` |
|
| Current screen | cache `display_current_state` | display | web: `/api/v3/display/current-status` |
|
||||||
| Plugin errors | cache `plugin_error_snapshot` | display: `ErrorSnapshotPublisher` ([`src/error_aggregator.py`](../src/error_aggregator.py)) | web: `read_error_report()` for `/api/v3/errors/*` |
|
| Plugin errors | cache `plugin_error_snapshot` | display: `ErrorSnapshotPublisher` ([`src/error_aggregator.py`](../src/error_aggregator.py)) | web: `read_error_report()` for `/api/v3/errors/*` |
|
||||||
| Error clear | control socket `errors.clear` | web: `POST /api/v3/errors/clear` | display: applied before the socket answers |
|
| Error clear | control socket `errors.clear`; cache `plugin_error_clear_request` as the fallback | web: `POST /api/v3/errors/clear` | display: applied before the socket answers; the mailbox on the error publisher's 5 s tick, read only when the file changed |
|
||||||
| Font usage | cache `font_usage_snapshot` | display: `FontUsagePublisher` ([`src/font_usage.py`](../src/font_usage.py)) | web: Fonts tab |
|
| Font usage | cache `font_usage_snapshot` | display: `FontUsagePublisher` ([`src/font_usage.py`](../src/font_usage.py)) | web: Fonts tab |
|
||||||
| Fetch statistics (requests per plugin and host) | cache `fetch_stats_snapshot` | display: `FetchStatsPublisher` ([`src/common/fetch_service.py`](../src/common/fetch_service.py)), at most once a minute on change | web: `read_fetch_stats()` for `/api/v3/plugins/fetch-stats` |
|
| Fetch statistics (requests per plugin and host) | cache `fetch_stats_snapshot` | display: `FetchStatsPublisher` ([`src/common/fetch_service.py`](../src/common/fetch_service.py)), at most once a minute on change | web: `read_fetch_stats()` for `/api/v3/plugins/fetch-stats` |
|
||||||
| Plugin health | cache `plugin_health:<id>` | display (web writes on reset) | web: `/api/v3/plugins/health` |
|
| Plugin health | cache `plugin_health:<id>` | display (web writes on reset) | web: `/api/v3/plugins/health` |
|
||||||
@@ -58,17 +58,18 @@ each other. They share three things:
|
|||||||
|
|
||||||
The on-demand start route starts `ledmatrix.service` when it is not running
|
The on-demand start route starts `ledmatrix.service` when it is not running
|
||||||
(`start_service`, on by default) but never restarts a running one. The routes
|
(`start_service`, on by default) but never restarts a running one. The routes
|
||||||
send the command over the display's control socket and get an ack. That is
|
send the command over the display's control socket and get an ack; only when
|
||||||
the only way in: the cache-key mailboxes (`display_on_demand_request`,
|
the socket could not carry it (a stopped display, one older than the socket
|
||||||
`plugin_error_clear_request`) are gone, and a write to either is dropped
|
or the command) do they write the mailbox instead. A display that had the
|
||||||
with a warning. When no display is listening yet, the start route starts the
|
request and refused it is answered with the error, not posted a mailbox
|
||||||
service (if asked) and answers `202`; the web process's dispatcher
|
copy. The display looks at the mailbox every
|
||||||
([`on_demand_dispatch.py`](../web_interface/on_demand_dispatch.py)) sends the
|
`MAILBOX_POLL_INTERVAL_WITH_SOCKET` (1 s) while it serves the socket, and
|
||||||
request once the socket is up, and the status routes report the outcome.
|
every `ON_DEMAND_POLL_INTERVAL` (0.25 s) without one, from its dwell sleep,
|
||||||
Any other failure is answered as an error. Socket commands and plugins'
|
its render loops and Vegas's interrupt check as well as the main loop; a
|
||||||
in-process requests end in the same handler, `_handle_on_demand_request()`.
|
look is one `stat()` unless the file changed. Both ways end in the same
|
||||||
|
handler, `_handle_on_demand_request()`.
|
||||||
The socket's handlers only queue; see [IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md)
|
The socket's handlers only queue; see [IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md)
|
||||||
for the protocol and the permission model.
|
for the protocol, the permission model and the plan to retire the mailboxes.
|
||||||
|
|
||||||
### Web and display processes: who runs plugins
|
### Web and display processes: who runs plugins
|
||||||
|
|
||||||
@@ -117,8 +118,8 @@ other web-UI action runs its script as a subprocess. A later, explicit
|
|||||||
|
|
||||||
The **control socket** from the web process to the display
|
The **control socket** from the web process to the display
|
||||||
([IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md)) carries on-demand
|
([IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md)) carries on-demand
|
||||||
commands, reloads an updated plugin and streams the display's state; it
|
commands and reloads an updated plugin; its next stages stream the
|
||||||
replaced the cache-key mailboxes. The plugin web-entry
|
display's state and retire the cache-key mailboxes. The plugin web-entry
|
||||||
contract above is still to come.
|
contract above is still to come.
|
||||||
|
|
||||||
### Plugin state: desired, observed, and who owns it
|
### Plugin state: desired, observed, and who owns it
|
||||||
|
|||||||
@@ -55,41 +55,6 @@ saved via `POST /api/v3/config/dim-schedule`). The display returns to
|
|||||||
boundaries as `schedule`: dimmed from `start_time` up to, but not including,
|
boundaries as `schedule`: dimmed from `start_time` up to, but not including,
|
||||||
`end_time`.
|
`end_time`.
|
||||||
|
|
||||||
## `plugin_schedules` — when each plugin may show
|
|
||||||
|
|
||||||
Maps a plugin id to a schedule shaped like `schedule` (the window in which
|
|
||||||
that plugin may show), plus:
|
|
||||||
|
|
||||||
| Key | Type / default | Meaning |
|
|
||||||
|---|---|---|
|
|
||||||
| `hide_when_live` | bool, `false` | Hide the plugin whenever any plugin reports a live game (its `has_live_content()`), live priority or not |
|
|
||||||
|
|
||||||
A plugin with no entry always shows. Outside its window, or during a live
|
|
||||||
game with `hide_when_live`, a plugin is skipped in the rotation, in the live
|
|
||||||
takeover and in Vegas mode; it stays loaded, and an on-demand request still shows it. Example, news off on
|
|
||||||
Sundays and during any live game:
|
|
||||||
|
|
||||||
```json
|
|
||||||
"plugin_schedules": {
|
|
||||||
"news": {"enabled": true, "mode": "per-day", "hide_when_live": true,
|
|
||||||
"start_time": "00:00", "end_time": "23:59",
|
|
||||||
"days": {"sunday": {"enabled": false}}}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
As in `schedule`, a day missing from `days` uses the top-level times
|
|
||||||
(`07:00`–`23:00` when those are missing too). The window ends at `end_time`,
|
|
||||||
so `00:00`–`23:59` leaves the plugin out for that last minute.
|
|
||||||
|
|
||||||
A change takes effect at the next screen in the rotation or live takeover,
|
|
||||||
and in Vegas mode at its next plugin-list refresh: the next scroll group,
|
|
||||||
or at the latest the next scroll cycle (about 6 minutes on ledpi with 8
|
|
||||||
plugins). Content already composed into the ticker scrolls out first.
|
|
||||||
|
|
||||||
Read by `src/plugin_schedule.py` through `DisplayController._hidden_plugins()`.
|
|
||||||
Managed in the web UI under Schedule → Plugin Schedules, saved via
|
|
||||||
`POST /api/v3/config/plugin-schedules`.
|
|
||||||
|
|
||||||
## `display.hardware` — matrix panel hardware
|
## `display.hardware` — matrix panel hardware
|
||||||
|
|
||||||
All keys map to the corresponding `rpi-rgb-led-matrix` options and are read
|
All keys map to the corresponding `rpi-rgb-led-matrix` options and are read
|
||||||
|
|||||||
+90
-119
@@ -1,18 +1,18 @@
|
|||||||
# Control socket (web → display)
|
# Control socket (web → display)
|
||||||
|
|
||||||
The display process serves a Unix socket that the web interface uses to send
|
The display process serves a Unix socket that the web interface uses to send
|
||||||
it commands and get an answer back. It replaced the cache-file "mailboxes" on
|
it commands and get an answer back. It replaces the cache-file "mailboxes" on
|
||||||
the SD card one command at a time. Stage 1 carries on-demand start, stop and
|
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
|
status. Stage 2 makes those commands land within a frame on every kind of
|
||||||
screen, and adds `brightness.set` and `plugin.reload`. Stage 3 adds a state
|
screen, and adds `brightness.set` and `plugin.reload`. Stage 3 adds a state
|
||||||
stream (`state.get`, `state.subscribe`), so the web interface reads what the
|
stream (`state.get`, `state.subscribe`), so the web interface reads what the
|
||||||
display is doing from the socket instead of from cache files the display
|
display is doing from the socket instead of from cache files the display
|
||||||
display wrote to the SD card. Stage 4 makes the socket the only way a command goes
|
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
|
while it works: the web interface writes a mailbox only when the socket
|
||||||
went through a mailbox. Stage 5 removes the mailboxes: the web interface no
|
cannot carry the request, `errors.clear` replaces the last command that
|
||||||
longer writes them and the display no longer reads them, so the socket is
|
always went through a mailbox, and the display looks at the mailboxes once
|
||||||
the only way a command reaches the display (see "Without the socket"). The
|
a second, with a `stat()`. The file mailboxes and the cache keys stay as a
|
||||||
cache keys the display writes for readers stay as their fallback.
|
fallback for one release.
|
||||||
|
|
||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -145,17 +145,14 @@ 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
|
`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
|
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
|
sides share, so a client can find out what a display supports before it
|
||||||
relies on anything newer. The client sends `v: 1`, and a route answers an
|
relies on anything newer. The client sends `v: 1` and falls back to the
|
||||||
error when the display refuses it. It does not send `hello` first, which
|
mailbox when the display refuses it. It does not send `hello` first, which
|
||||||
saves a round trip.
|
saves a round trip.
|
||||||
|
|
||||||
New commands are added within a version, so stage 2 is still version 1. A
|
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
|
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
|
web interface treats like any other socket failure and falls back from, and
|
||||||
`hello` lists the commands a display knows.
|
`hello` lists the commands a display knows. The version changes only when the
|
||||||
(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.
|
envelope or the meaning of an existing command changes.
|
||||||
|
|
||||||
**Events.** `state.subscribe` is the one command with more than one message
|
**Events.** `state.subscribe` is the one command with more than one message
|
||||||
@@ -220,8 +217,7 @@ socket.
|
|||||||
}}
|
}}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `display` and `on_demand` are the dicts the cache keys hold (`on_demand`
|
- `display` and `on_demand` are the dicts the cache keys hold, `plugins` is
|
||||||
includes `request_id`, the request it answers), `plugins` is
|
|
||||||
the runtime snapshot (`build_runtime_snapshot`), and `brightness` is the
|
the runtime snapshot (`build_runtime_snapshot`), and `brightness` is the
|
||||||
configured level, what the panel shows now, and whether the dim schedule
|
configured level, what the panel shows now, and whether the dim schedule
|
||||||
has it dimmed. A section not published yet is `null`.
|
has it dimmed. A section not published yet is `null`.
|
||||||
@@ -372,12 +368,13 @@ 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
|
- For a query, it answers from a status snapshot the display provides
|
||||||
(`DisplayController._control_status`). The snapshot only reads attributes.
|
(`DisplayController._control_status`). The snapshot only reads attributes.
|
||||||
|
|
||||||
The render thread drains the queue in `_poll_on_demand_requests()`:
|
The render thread drains the queue in `_poll_on_demand_requests()`, the same
|
||||||
|
place it reads the mailbox:
|
||||||
|
|
||||||
- An on-demand command goes to `_handle_on_demand_request()`, which also
|
- An on-demand command goes to `_handle_on_demand_request()`, which is the
|
||||||
handles plugins' own requests. The two paths share all of their code:
|
mailbox's own handler. The two paths share all of their code: activation,
|
||||||
activation, the request-id guard, error publishing, and resuming the
|
the processed-id guard, error publishing, and resuming the rotation
|
||||||
rotation afterwards.
|
afterwards.
|
||||||
- `brightness.set` is applied there and then (`_apply_control_brightness`),
|
- `brightness.set` is applied there and then (`_apply_control_brightness`),
|
||||||
and the current frame is pushed again so the panel shows it.
|
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
|
- `plugin.reload` starts at the top of the next loop pass, the place where
|
||||||
@@ -410,13 +407,14 @@ The render thread drains the queue in `_poll_on_demand_requests()`:
|
|||||||
unloads it mid-load; a disable saved meanwhile is applied once the
|
unloads it mid-load; a disable saved meanwhile is applied once the
|
||||||
reload is done. A second reload of the same plugin runs after the first.
|
reload is done. A second reload of the same plugin runs after the first.
|
||||||
|
|
||||||
Draining the queue costs no disk read, so it has no floor. A queued command
|
The floor on the mailbox read (0.25 s, 1 s since stage 4) does not apply to the queue, because
|
||||||
also lets `_service_pending_changes()` skip its own 0.25 s floor.
|
draining it costs no disk read. A queued command also lets
|
||||||
|
`_service_pending_changes()` skip its own floor.
|
||||||
|
|
||||||
### Waking the render thread (stage 2)
|
### Waking the render thread (stage 2)
|
||||||
|
|
||||||
Stage 1 made the socket answer, but not land sooner: a queued command waited
|
Stage 1 made the socket answer, but not land sooner: a queued command waited
|
||||||
for the same polls the mailbox did. Measured on ledpi (Pi 4, 24 fps Vegas),
|
for the same polls the mailbox does. 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.
|
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:
|
Now the queue wakes the render thread:
|
||||||
|
|
||||||
@@ -436,7 +434,8 @@ Now the queue wakes the render thread:
|
|||||||
- **Scrolling screens** already service pending changes every frame.
|
- **Scrolling screens** already service pending changes every frame.
|
||||||
|
|
||||||
So a command lands within a millisecond or so on a static screen and in a
|
So a command lands within a millisecond or so on a static screen and in a
|
||||||
dwell, and within one frame in Vegas and on a scrolling screen. Commands still run only on the render thread: the
|
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
|
||||||
connection threads only queue them and set the event. The one exception is
|
connection threads only queue them and set the event. The one exception is
|
||||||
the slow half of `plugin.reload` (tearing down and loading the plugin),
|
the slow half of `plugin.reload` (tearing down and loading the plugin),
|
||||||
which runs on its own thread. Every change to the display's state still
|
which runs on its own thread. Every change to the display's state still
|
||||||
@@ -454,101 +453,74 @@ bookkeeping. A client's send to the render thread waking took 0.72 ms median
|
|||||||
Without a socket (Windows, `LEDMATRIX_CONTROL_SOCKET=off`) the waits are the
|
Without a socket (Windows, `LEDMATRIX_CONTROL_SOCKET=off`) the waits are the
|
||||||
plain sleeps they were.
|
plain sleeps they were.
|
||||||
|
|
||||||
**Exactly once.** A request goes over the socket once. The web route sends
|
**Exactly once.** Since stage 4 the web interface writes the mailbox only
|
||||||
it again only while no display is listening (see "Without the socket"), so
|
when the display never had the request (see "When the web interface falls
|
||||||
a display gets it at most once; a start with a request id the display has
|
back"), so a request goes one way or the other, never both. A command and a
|
||||||
just processed is dropped anyway (`on_demand_request_id`). The persisted
|
mailbox write for the same request still share one `request_id`, and the
|
||||||
`display_on_demand_processed_id` guard against a mailbox replayed after a
|
`on_demand_request_id` and processed-id checks still drop a second copy: an
|
||||||
restart went with the mailbox.
|
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.
|
||||||
|
|
||||||
## Without the socket (stage 5)
|
## When the web interface falls back (stage 4)
|
||||||
|
|
||||||
The client tells a request the display never had from one it had and then
|
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
|
failed. `ControlError.sent` is True once the whole request was written to a
|
||||||
connected display; a refusal the display sends before reading anything
|
connected display; a refusal the display sends before reading anything
|
||||||
(`forbidden`, too many connections) carries no request id, and leaves it
|
(`forbidden`, too many connections) carries no request id, and leaves it
|
||||||
False. `src.ipc.client.display_not_listening()` picks out the one case a
|
False. `src.ipc.client.should_fall_back()` is the one rule every route uses:
|
||||||
later retry can fix: nothing is listening (`no_socket`, `refused`) and the
|
|
||||||
request was never sent.
|
|
||||||
|
|
||||||
| What happened | Example reasons | On-demand start | On-demand stop | `errors.clear` |
|
| What happened | Example reasons | Mailbox? | The route answers |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 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) |
|
| 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 the command | `unknown_command`, `unsupported_version` | `503` | `503` | `503`, "restart it" |
|
| A display too old to know it (the upgrade case) | `unknown_command`, `unsupported_version` | yes | as above |
|
||||||
| No socket in this process | `disabled`, `unsupported` (Windows, `LEDMATRIX_CONTROL_SOCKET=off`) | `503` | `503` | `503` |
|
| 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` |
|
||||||
| The display had it and failed, turned it away, or never answered | `busy`, `invalid_args`, `internal`, a timeout, a hang-up, `bad_response`, `forbidden` | `503` (`400` for `invalid_args`) | `503` (with `stop_service`: stopped anyway) | `503` |
|
|
||||||
|
|
||||||
Every error answer carries `socket_error` (a reason code, or `other`).
|
A display that had the request may have applied it (a reply that timed out),
|
||||||
Nothing is written to the cache in any of these cases.
|
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
|
||||||
**Waiting for a display that is starting.** The socket comes up when the
|
only turned that into a "success". An on-demand stop with `stop_service`
|
||||||
display's run loop starts, after every plugin has loaded, which can take
|
still stops the service, which ends on-demand whatever happened.
|
||||||
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
|
Brightness and plugin reload never had a mailbox: without the socket, the
|
||||||
config watcher applies the saved brightness and a reload becomes the
|
config watcher applies the saved brightness and a reload becomes the
|
||||||
restart banner, as before.
|
restart banner, as before.
|
||||||
|
|
||||||
### The mailboxes are gone
|
### The mailboxes now
|
||||||
|
|
||||||
| Former mailbox | Last written by | Now |
|
| Mailbox | Written by | Read by the display | While the socket is up |
|
||||||
|---|---|---|
|
|---|---|---|---|
|
||||||
| `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) |
|
| `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 on fallback (stage 4) | not read; a write is dropped and logged the same way |
|
| `plugin_error_clear_request` | the web interface, only on fallback | the error publisher's thread, every 5 s tick | unchanged rate |
|
||||||
|
|
||||||
A file left on the SD card by an older version is never read again, so it
|
A look is one `stat()` of the mailbox file (`CacheManager.file_signature`):
|
||||||
is harmless. `MailboxWatch` and
|
`(inode, mtime, size)`, and every write renames a new file into place, so a
|
||||||
`CacheManager.file_signature`, which made a look at a mailbox one `stat()`,
|
new write always looks different. `MailboxWatch` reads the file only when
|
||||||
went with them.
|
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.
|
||||||
|
|
||||||
The four plugins that wrote `display_on_demand_request` (birdnet-go,
|
A request that comes through the on-demand mailbox while the socket is up
|
||||||
mqtt-notifications, on-air, pomodoro-timer) use `request_on_demand()` on a
|
is logged once per writer (`came through the file mailbox although the
|
||||||
core that has it, and fall back to the mailbox only when that method is
|
control socket is up`), which names the plugins that still write it.
|
||||||
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
|
### Plugins in the display process
|
||||||
|
|
||||||
A plugin asks for the screen with `BasePlugin.request_on_demand()` and gives
|
A plugin asks for the screen with `BasePlugin.request_on_demand()` and gives
|
||||||
it back with `end_on_demand()` (see "On-demand display" in
|
it back with `end_on_demand()` (see "On-demand display" in
|
||||||
[PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md)). Neither goes through
|
[PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md)). Neither goes through
|
||||||
the socket or a file: `PluginManager` hands the on-demand request,
|
the socket or a file: `PluginManager` hands the mailbox-shaped request,
|
||||||
marked `source: 'plugin'`, to `DisplayController.submit_plugin_on_demand`,
|
marked `source: 'plugin'`, to `DisplayController.submit_plugin_on_demand`,
|
||||||
which queues it in memory (at most `PLUGIN_ON_DEMAND_QUEUE_SIZE`, 32) from
|
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
|
whatever thread the plugin called on, and wakes the render thread through
|
||||||
the socket's queue flag (`ControlServer.wake()`). The render thread applies
|
the socket's queue flag (`ControlServer.wake()`). The render thread applies
|
||||||
it in `_drain_control_commands`, after the socket's commands, through the
|
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
|
same `_handle_on_demand_request`, so it lands within a frame like a socket
|
||||||
command. Without a socket it lands on the next pending-changes pass. A
|
command. Without a socket it lands on the next pending-changes pass (typically
|
||||||
plugin's stop ends only a session that plugin owns.
|
within 0.25 s). A plugin's stop ends only a session that plugin owns. The four
|
||||||
|
plugins that wrote the mailbox (birdnet-go, mqtt-notifications, on-air,
|
||||||
|
pomodoro-timer) use it where the core has it and write the mailbox
|
||||||
|
otherwise.
|
||||||
|
|
||||||
## Robustness
|
## Robustness
|
||||||
|
|
||||||
@@ -566,7 +538,8 @@ block the render loop or crash it:
|
|||||||
found. A client that disconnects mid-message is dropped silently. No
|
found. A client that disconnects mid-message is dropped silently. No
|
||||||
exception from a handler leaves the connection thread.
|
exception from a handler leaves the connection thread.
|
||||||
- **Full queue.** When the queue is full, the client gets `busy`, and the web
|
- **Full queue.** When the queue is full, the client gets `busy`, and the web
|
||||||
interface answers `503`. A full queue means the render thread
|
interface answers `503` rather than write the mailbox, which the stuck
|
||||||
|
render thread would not read either. A full queue means the render thread
|
||||||
is stuck, and the systemd watchdog deals with that.
|
is stuck, and the systemd watchdog deals with that.
|
||||||
- **Awaited commands.** The wait for an awaited command's outcome happens on
|
- **Awaited commands.** The wait for an awaited command's outcome happens on
|
||||||
its connection thread and is bounded (`AWAIT_SECONDS`), so a stuck render
|
its connection thread and is bounded (`AWAIT_SECONDS`), so a stuck render
|
||||||
@@ -581,8 +554,8 @@ block the render loop or crash it:
|
|||||||
process created.
|
process created.
|
||||||
- **Never fatal.** If the server cannot start (Windows, no `AF_UNIX`, a bind
|
- **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
|
failure, `LEDMATRIX_CONTROL_SOCKET=off`), it logs that and the display runs
|
||||||
as before. The web interface then cannot send it commands (the routes
|
as before. The web interface then uses the mailbox, and reads the cache
|
||||||
answer `503`), and reads the cache keys and the heartbeat file.
|
keys and the heartbeat file.
|
||||||
- **Subscribers (stage 3).** A `state.subscribe` connection gives its request
|
- **Subscribers (stage 3).** A `state.subscribe` connection gives its request
|
||||||
slot back and takes one of 4 subscriber slots (`MAX_SUBSCRIBERS`). A fifth
|
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
|
gets `busy`. So a few browsers' web processes holding streams can never
|
||||||
@@ -640,9 +613,11 @@ device never touches the live display.
|
|||||||
## Stage plan
|
## Stage plan
|
||||||
|
|
||||||
1. **On-demand, with acks (done, #706).** Contract, server, client.
|
1. **On-demand, with acks (done, #706).** Contract, server, client.
|
||||||
`on_demand.start`/`stop`/`status`, `hello`, `ping`. The REST routes tried
|
`on_demand.start`/`stop`/`status`, `hello`, `ping`. The REST routes try the
|
||||||
the socket first and reported `transport: "socket" | "mailbox"` (plus
|
socket first and report `transport: "socket" | "mailbox"` (plus
|
||||||
`socket_error` on fallback).
|
`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.
|
||||||
2. **Commands that were restarts or polls (done).**
|
2. **Commands that were restarts or polls (done).**
|
||||||
- The render thread waits on the queue instead of sleeping, and Vegas
|
- 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
|
checks it every frame, so a command lands within a frame on every kind
|
||||||
@@ -685,12 +660,12 @@ device never touches the live display.
|
|||||||
4. **The mailboxes become a fallback (done).**
|
4. **The mailboxes become a fallback (done).**
|
||||||
- The web interface writes a mailbox only when the socket could not carry
|
- 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
|
the request (`should_fall_back`); a display that had it and failed is
|
||||||
answered as that.
|
answered as that (see "When the web interface falls back").
|
||||||
- `errors.clear` replaces `plugin_error_clear_request` as the way a clear
|
- `errors.clear` replaces `plugin_error_clear_request` as the way a clear
|
||||||
reaches the display.
|
reaches the display.
|
||||||
- The display looks at the on-demand mailbox once a second while the
|
- 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
|
socket is up, reads either mailbox only when its file changed, and logs
|
||||||
who still writes the on-demand one.
|
who still writes the on-demand one (see "The mailboxes now").
|
||||||
- Not changed, deliberately: config saves (the schedule, the dim
|
- Not changed, deliberately: config saves (the schedule, the dim
|
||||||
schedule, plugin settings) still reach the display through
|
schedule, plugin settings) still reach the display through
|
||||||
`config.json` and its watcher, which is the setting itself rather than
|
`config.json` and its watcher, which is the setting itself rather than
|
||||||
@@ -699,20 +674,15 @@ device never touches the live display.
|
|||||||
already stats at most once a second. Plugin health and metrics resets
|
already stats at most once a second. Plugin health and metrics resets
|
||||||
write the persisted record the display publishes and do not reach the
|
write the persisted record the display publishes and do not reach the
|
||||||
running display (their routes say so); they are not mailboxes.
|
running display (their routes say so); they are not mailboxes.
|
||||||
5. **Remove the mailboxes (done, the release after 3.8.1).** The web
|
5. **Remove the mailboxes (next release).** Once every device has run a
|
||||||
interface no longer writes `display_on_demand_request` or
|
display with stage 4, the web interface stops writing both mailboxes and
|
||||||
`plugin_error_clear_request`, and the display no longer reads them (see
|
the display stops reading them. The four plugins that wrote
|
||||||
"Without the socket"). When no display is listening, the start route
|
`display_on_demand_request` now have an in-process way to ask for the
|
||||||
starts the service if asked, answers `202`, and the web process's
|
screen (`BasePlugin.request_on_demand()` / `end_on_demand()`, see
|
||||||
dispatcher sends the request once the socket is up; every other failure
|
"Plugins in the display process"); they keep the mailbox write only as
|
||||||
is an error the route reports. A write to
|
their fallback on older cores. The display also stops writing `display_current_state`,
|
||||||
either key is dropped with a one-time warning naming the writer. The
|
`display_on_demand_state` and `plugin_runtime_snapshot` once the web
|
||||||
display still writes `display_current_state`, `display_on_demand_state`
|
interface no longer falls back to them.
|
||||||
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
|
## Checking it on a device
|
||||||
|
|
||||||
@@ -724,11 +694,12 @@ curl -s -X POST localhost:5000/api/v3/display/on-demand/start \
|
|||||||
# ... "transport": "socket"
|
# ... "transport": "socket"
|
||||||
```
|
```
|
||||||
|
|
||||||
On an error, `socket_error` gives the reason. `no_socket` means the display
|
If the response says `"transport": "mailbox"`, `socket_error` gives the
|
||||||
is stopped or still starting. `refused` usually means the web user is not in
|
reason. `no_socket` means the display is stopped or predates the socket.
|
||||||
the socket's group, which takes effect when the web service restarts after
|
`refused` usually means the web user is not in the socket's group, which
|
||||||
the user is added. `busy`, `timeout` and the like mean the display had the
|
takes effect when the web service restarts after the user is added. A `503`
|
||||||
request and did not take it. Nothing is ever written to a mailbox.
|
with `"transport": "socket"` means the display had the request and did not
|
||||||
|
take it (`busy`, `timeout`, ...): nothing was written to the mailbox.
|
||||||
|
|
||||||
An error clear:
|
An error clear:
|
||||||
|
|
||||||
@@ -736,7 +707,7 @@ An error clear:
|
|||||||
curl -s -X POST localhost:5000/api/v3/errors/clear \
|
curl -s -X POST localhost:5000/api/v3/errors/clear \
|
||||||
-H 'Content-Type: application/json' -d '{"all":true}'
|
-H 'Content-Type: application/json' -d '{"all":true}'
|
||||||
# ... "applied": true, "transport": "socket"
|
# ... "applied": true, "transport": "socket"
|
||||||
sudo journalctl -u ledmatrix | grep -E "Cleared .* plugin error|retired"
|
sudo journalctl -u ledmatrix | grep -E "Cleared .* plugin error|file mailbox"
|
||||||
```
|
```
|
||||||
|
|
||||||
Brightness and a plugin reload:
|
Brightness and a plugin reload:
|
||||||
|
|||||||
@@ -328,7 +328,7 @@ Each plugin takes part in Vegas mode in one of three ways:
|
|||||||
| Participation | What Vegas does |
|
| Participation | What Vegas does |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `'scroll'` | The plugin's content (`get_vegas_content()`) scrolls by with everything else |
|
| `'scroll'` | The plugin's content (`get_vegas_content()`) scrolls by with everything else |
|
||||||
| `'pause'` | The scroll stops when the plugin's turn comes round; its `display()` draws it full screen for `get_display_duration()` seconds, then the scroll resumes. `display()` is called again through the pause, as in the rotation: once a second, or at the high-FPS rate if the plugin declares `needs_high_fps`; the first call passes `force_clear=True` |
|
| `'pause'` | The scroll stops when the plugin's turn comes round; its `display()` draws it full screen for `get_display_duration()` seconds, then the scroll resumes |
|
||||||
| `'exclude'` | The plugin is left out of Vegas mode |
|
| `'exclude'` | The plugin is left out of Vegas mode |
|
||||||
|
|
||||||
Declare the plugin's default in `manifest.json`:
|
Declare the plugin's default in `manifest.json`:
|
||||||
@@ -522,52 +522,42 @@ Returns the request id once queued, or `None` as above.
|
|||||||
|
|
||||||
These methods are new after core 3.8.0 (see `CHANGELOG.md`). Before them,
|
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")
|
plugins wrote the `display_on_demand_request` cache key (the "mailbox")
|
||||||
themselves. **The mailbox is gone** (see
|
themselves. The display reads it only once a second while the control
|
||||||
[IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md), stage 5): the display no
|
socket is up, and it will be removed in a future release (see
|
||||||
longer reads it, and a write to it is dropped with a warning in the log,
|
[IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md), stage 5). A plugin that
|
||||||
once per plugin:
|
must keep working on older cores checks for the method, and writes the
|
||||||
|
mailbox only when the method is missing or answers `None`:
|
||||||
```
|
|
||||||
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
|
```python
|
||||||
import time, uuid
|
import time, uuid
|
||||||
|
|
||||||
def _show_alert(self):
|
def _show_alert(self):
|
||||||
if hasattr(self, "request_on_demand"):
|
if hasattr(self, "request_on_demand") and self.request_on_demand(
|
||||||
self.request_on_demand(mode="my_alert", duration=15)
|
mode="my_alert", duration=15):
|
||||||
return
|
return
|
||||||
# A core older than request_on_demand(): the mailbox it still reads.
|
# Older core, or no display in this process: the mailbox, as before.
|
||||||
self.cache_manager.set("display_on_demand_request", {
|
self.cache_manager.set("display_on_demand_request", {
|
||||||
"request_id": str(uuid.uuid4()), "action": "start",
|
"request_id": str(uuid.uuid4()), "action": "start",
|
||||||
"plugin_id": self.plugin_id, "mode": "my_alert",
|
"plugin_id": self.plugin_id, "mode": "my_alert",
|
||||||
"duration": 15, "pinned": False, "timestamp": time.time(),
|
"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(),
|
||||||
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
`end_on_demand()` ends only the plugin's own session; a stop written to the
|
Keep `ledmatrix_min_version` where it is: the fallback is what keeps the
|
||||||
mailbox on an older core ends any session, whoever started it.
|
plugin working on older cores. A mailbox stop ends any on-demand session,
|
||||||
|
whoever started it; `end_on_demand()` ends only the plugin's own.
|
||||||
|
|
||||||
Both methods answer a request id only when the plugin manager returned a
|
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
|
string, so a test that gives the plugin a `MagicMock()` plugin manager gets
|
||||||
`None`. To test the path where the display takes the request, set
|
`None` and exercises the mailbox path. To test the new path, set
|
||||||
`plugin_manager.request_on_demand.return_value = "some-id"`.
|
`plugin_manager.request_on_demand.return_value = "some-id"`.
|
||||||
|
|
||||||
> The full source for `BasePlugin` lives in
|
> The full source for `BasePlugin` lives in
|
||||||
|
|||||||
+34
-74
@@ -466,7 +466,7 @@ Request a specific plugin to display on-demand.
|
|||||||
- `mode` (string, optional): Display mode name (plugin_id inferred if not provided)
|
- `mode` (string, optional): Display mode name (plugin_id inferred if not provided)
|
||||||
- `duration` (number, optional): Duration in seconds (0 = until stopped)
|
- `duration` (number, optional): Duration in seconds (0 = until stopped)
|
||||||
- `pinned` (boolean, optional): Pin display (pause rotation)
|
- `pinned` (boolean, optional): Pin display (pause rotation)
|
||||||
- `start_service` (boolean, optional): Start the display service if it is not running (default: true). A running service is never restarted: it picks the request up within 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.
|
- `start_service` (boolean, optional): Start the display service if it is not running (default: true). A running service is never restarted: it picks the request up within a frame over its control socket (within about a second through the mailbox fallback). When false and the service is stopped, the route returns 400.
|
||||||
|
|
||||||
**Response**:
|
**Response**:
|
||||||
```json
|
```json
|
||||||
@@ -486,54 +486,24 @@ Request a specific plugin to display on-demand.
|
|||||||
|
|
||||||
`service` is `null` when `start_service` is false.
|
`service` is `null` when `start_service` is false.
|
||||||
|
|
||||||
`transport` is always `"socket"`: the display's control socket acknowledged
|
`transport` says how the request reached the display: `"socket"` means the
|
||||||
the request (it is queued for the render thread, which wakes for it and
|
display's control socket acknowledged it (it is queued for the render thread,
|
||||||
applies it within a frame; see [IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md)).
|
which wakes for it and applies it within a frame; see
|
||||||
The `"mailbox"` value earlier releases could answer is gone with the
|
[IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md)), `"mailbox"` means it was
|
||||||
mailbox: nothing is written to the cache.
|
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.
|
||||||
|
|
||||||
**No display listening yet** (`no_socket`, `refused`: the service is
|
When the display had the request and did not take it -- a full queue
|
||||||
stopped, or still loading its plugins). With the service stopped and
|
(`busy`), bad arguments (`invalid_args`), no answer after the request was
|
||||||
`start_service` false, `400`. Otherwise the route starts the service if
|
sent (`timeout`, `closed`) -- the route answers `503` (`400` for
|
||||||
needed and answers at once:
|
`invalid_args`) with `status: "error"` and `data: {request_id, transport:
|
||||||
|
"socket", socket_error}`, and writes nothing to the mailbox. The stop route
|
||||||
```json
|
does the same, except that with `stop_service: true` it still stops the
|
||||||
{
|
service and answers success.
|
||||||
"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
|
### Stop On-Demand Display
|
||||||
|
|
||||||
@@ -563,7 +533,7 @@ Stop the current on-demand display.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`transport` and the errors are as for start; a stop is never retried.
|
`transport` and `socket_error` are as for start.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -2229,7 +2199,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. |
|
| `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`. |
|
| `generated_at` | When the display service produced the snapshot (ISO, the Pi's local time), or `null`. |
|
||||||
| `clear_pending` | Always `false`: a clear is applied before its route answers. Kept for compatibility. |
|
| `clear_pending` | A clear has been requested and the display service has not applied it yet. |
|
||||||
|
|
||||||
### Get Error Summary
|
### Get Error Summary
|
||||||
|
|
||||||
@@ -2310,17 +2280,21 @@ before it answers: `applied` is `true`, `transport` is `"socket"`, and
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`clear_requested`, `applied` and `transport` are always `true`, `true` and
|
When the socket cannot carry it (the display is stopped, or older than
|
||||||
`"socket"`, and are kept for compatibility.
|
`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).
|
||||||
|
|
||||||
When the socket cannot carry the clear, the route answers `503` with
|
A request that could not be written to the shared cache answers `500`. A
|
||||||
`context.socket_error`, and nothing is cleared or recorded: the
|
display that had the request and failed it (`internal`, a timeout after the
|
||||||
`plugin_error_clear_request` mailbox earlier releases fell back to is gone.
|
request was sent) answers `503`, with `context.socket_error`.
|
||||||
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).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -2409,20 +2383,6 @@ enabled with default times `20:00`-`07:00`. As for the schedule above, an
|
|||||||
enabled dim schedule needs at least one day enabled and a disabled one may
|
enabled dim schedule needs at least one day enabled and a disabled one may
|
||||||
have every day off.
|
have every day off.
|
||||||
|
|
||||||
### Plugin Schedules
|
|
||||||
|
|
||||||
**GET** `/api/v3/config/plugin-schedules`
|
|
||||||
|
|
||||||
Every plugin's schedule (`plugin_schedules` in `docs/CONFIG_REFERENCE.md`),
|
|
||||||
keyed by plugin id.
|
|
||||||
|
|
||||||
**POST** `/api/v3/config/plugin-schedules`
|
|
||||||
|
|
||||||
Save one plugin's schedule: `plugin_id`, `hide_when_live`, and the same
|
|
||||||
fields as `POST /api/v3/config/schedule`. A schedule that is off and does
|
|
||||||
not hide during live games is removed. 400 for a core section name or a bad
|
|
||||||
time, 404 for a plugin that is not installed.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Integrations
|
## Integrations
|
||||||
|
|||||||
+40
-243
@@ -93,10 +93,6 @@ more. Shared sports code lives in `src/common`:
|
|||||||
| `sports_font_path.py` | 3.8.0 | `resolve_font_path` — what the plugins' `_resolve_font_path` copies return |
|
| `sports_font_path.py` | 3.8.0 | `resolve_font_path` — what the plugins' `_resolve_font_path` copies return |
|
||||||
| `sports_game_over.py` | 3.8.1 | `SportsGameOverMixin` — `_is_game_really_over`, with the `FINAL_PERIOD` seam (family 5) |
|
| `sports_game_over.py` | 3.8.1 | `SportsGameOverMixin` — `_is_game_really_over`, with the `FINAL_PERIOD` seam (family 5) |
|
||||||
| `sports_favorites.py` | 3.8.2 | `SportsFavoritesMixin`, `SportsUpcomingFavoritesMixin`, `SportsRecentFavoritesMixin` — `_is_favorite_game` and the favourites-only picks, on the `_favorite_key` seam (family 6) |
|
| `sports_favorites.py` | 3.8.2 | `SportsFavoritesMixin`, `SportsUpcomingFavoritesMixin`, `SportsRecentFavoritesMixin` — `_is_favorite_game` and the favourites-only picks, on the `_favorite_key` seam (family 6) |
|
||||||
| `sports_rotation.py` | 3.8.4 | `SportsRotationMixin` — the other-games rotation (importance order, the window, odds on rotated-in games), on the `_rankings_loaded` seam (family 7) |
|
|
||||||
| `sports_rankings.py` | 3.8.5 | `SportsRankingsMixin` — which teams are ranked (by id and abbreviation), the poll choice, stale polls hidden, the "ranked" filter; `choose_top_division_poll()` (family 8) |
|
|
||||||
| `sports_live_fetch.py` | 3.8.6 | `SportsLiveFetchMixin` — the live poll's scoreboard request (always fresh, shared key) and odds fetched off the update path by one worker per manager (family 9) |
|
|
||||||
| `sports_view_model.py` | 3.8.7 | `SportsViewModelMixin` — the game dict every screen draws (`_extract_game_details_common`), the final and live rules, score text, logos, records; the `_logo_filename` seam (family 10) |
|
|
||||||
|
|
||||||
Each is described in [src/common/README.md](../src/common/README.md).
|
Each is described in [src/common/README.md](../src/common/README.md).
|
||||||
|
|
||||||
@@ -132,9 +128,7 @@ deprecation cycle.
|
|||||||
| `_custom_scorebug_layout(game, draw)` | Per-sport overlay on the base layout | no-op |
|
| `_custom_scorebug_layout(game, draw)` | Per-sport overlay on the base layout | no-op |
|
||||||
| `score_phrase(points, team_abbr)` | Celebration wording (`"GOOOOAAALLL!"` vs `"TOUCHDOWN!"`). `points` is the score delta, which sports with variable-value scores use to name the play | `"<abbr> SCORES!"` — only consulted when `CelebrationMixin` is present |
|
| `score_phrase(points, team_abbr)` | Celebration wording (`"GOOOOAAALLL!"` vs `"TOUCHDOWN!"`). `points` is the score delta, which sports with variable-value scores use to name the play | `"<abbr> SCORES!"` — only consulted when `CelebrationMixin` is present |
|
||||||
| `win_phrase(team_abbr)` | Win-celebration wording | `"<abbr> WINS!"` — mixin only |
|
| `win_phrase(team_abbr)` | Win-celebration wording | `"<abbr> WINS!"` — mixin only |
|
||||||
| `_rankings_loaded()` | Whether a poll loaded, so `_by_importance` orders by rank (`sports_rotation`) | `_team_rankings_cache` is non-empty. football also counts its rankings keyed by team id |
|
|
||||||
| `_favorite_key(game, side)` | Which view-model field identifies a team for favorites matching. `sports_favorites` compares it, and each `favorite_teams` entry, stripped and upper-cased; a `None` matches nothing | `game["<side>_abbr"]`. nrl returns the ESPN team id, `None` when it is missing |
|
| `_favorite_key(game, side)` | Which view-model field identifies a team for favorites matching. `sports_favorites` compares it, and each `favorite_teams` entry, stripped and upper-cased; a `None` matches nothing | `game["<side>_abbr"]`. nrl returns the ESPN team id, `None` when it is missing |
|
||||||
| `_logo_filename(abbr, team_id)` | The file under `logo_dir` a team's logo is kept in (`sports_view_model`) | `<ABBR>.png`. nrl returns `<ABBR>_<id>.png`: its abbreviations are not unique |
|
|
||||||
| `_config_schema_path()` | Plugin's `config_schema.json` — returning it routes `_get_layout_offset` through the `src.element_style` resolver (and gives it the defaults to compare against) | `None`, i.e. the classic inline `customization.layout` read |
|
| `_config_schema_path()` | Plugin's `config_schema.json` — returning it routes `_get_layout_offset` through the `src.element_style` resolver (and gives it the defaults to compare against) | `None`, i.e. the classic inline `customization.layout` read |
|
||||||
| `_font_root()` | Directory to resolve `assets/fonts` against | core install root |
|
| `_font_root()` | Directory to resolve `assets/fonts` against | core install root |
|
||||||
|
|
||||||
@@ -274,7 +268,7 @@ gave pixel-identical output for all 399 frames (192 harness screens across the
|
|||||||
nine plugins at the eight default sizes, 72 scroll/Vegas cards, 135
|
nine plugins at the eight default sizes, 72 scroll/Vegas cards, 135
|
||||||
celebration frames), with a parent-vs-parent rerun as the determinism control.
|
celebration frames), with a parent-vs-parent rerun as the determinism control.
|
||||||
|
|
||||||
### Stage 4: the identical sweep (done: core 3.8.0, adopted)
|
### Stage 4: the identical sweep (core done; adoption waits for a release)
|
||||||
|
|
||||||
Re-measured on ledmatrix-plugins `56c4f15` (2026-09-30) the report still
|
Re-measured on ledmatrix-plugins `56c4f15` (2026-09-30) the report still
|
||||||
lists 58 families identical in every copy. Stage 4 moves the ones that are
|
lists 58 families identical in every copy. Stage 4 moves the ones that are
|
||||||
@@ -309,7 +303,7 @@ Left in the plugins, though identical:
|
|||||||
renderers) is already core's, in `SportsHelpersMixin`; a renderer that
|
renderers) is already core's, in `SportsHelpersMixin`; a renderer that
|
||||||
wants it can inherit that.
|
wants it can inherit that.
|
||||||
|
|
||||||
### Family 5: the game-over check (done: core 3.8.1, adopted)
|
### Family 5: the game-over check (core done; adoption waits for a release)
|
||||||
|
|
||||||
The pilot of the method below. ledmatrix-plugins `scripts/test_game_over_check.py`
|
The pilot of the method below. ledmatrix-plugins `scripts/test_game_over_check.py`
|
||||||
(#621) pinned 3,115 answers across the nine plugins first; the reconcile
|
(#621) pinned 3,115 answers across the nine plugins first; the reconcile
|
||||||
@@ -321,10 +315,9 @@ clock rule (65 cells), baseball's dormant one (53, every one a game with a
|
|||||||
cells in hockey, basketball, football and lacrosse). The harness renders
|
cells in hockey, basketball, football and lacrosse). The harness renders
|
||||||
were pixel-identical. `src/common/sports_game_over.py` holds the body;
|
were pixel-identical. `src/common/sports_game_over.py` holds the body;
|
||||||
`test/test_sports_game_over_parity.py` compares it, and each plugin's
|
`test/test_sports_game_over_parity.py` compares it, and each plugin's
|
||||||
`FINAL_PERIOD`, with the plugin copies. Core 3.8.1 shipped it, and all nine
|
`FINAL_PERIOD`, with the plugin copies.
|
||||||
scoreboards inherit it and floor on 3.8.1 (ledmatrix-plugins #631).
|
|
||||||
|
|
||||||
### Family 6: favourite matching (done: core 3.8.2, adopted)
|
### Family 6: favourite matching (core done; adoption waits for a release)
|
||||||
|
|
||||||
ledmatrix-plugins `scripts/test_favourite_matching.py` (#634) pinned 204 rows
|
ledmatrix-plugins `scripts/test_favourite_matching.py` (#634) pinned 204 rows
|
||||||
across the nine plugins first: `_is_favorite_game` on each manager role, the
|
across the nine plugins first: `_is_favorite_game` on each manager role, the
|
||||||
@@ -344,8 +337,7 @@ each), nrl's key (6) and its "None" match (6), and the INFO line in baseball,
|
|||||||
football and ufc. The harness renders were byte-identical. `src/common/sports_favorites.py` holds the
|
football and ufc. The harness renders were byte-identical. `src/common/sports_favorites.py` holds the
|
||||||
bodies, one mixin per carrying class; `test/test_sports_favorites_parity.py`
|
bodies, one mixin per carrying class; `test/test_sports_favorites_parity.py`
|
||||||
compares them with the plugin copies and checks that only nrl overrides
|
compares them with the plugin copies and checks that only nrl overrides
|
||||||
`_favorite_key`. Core 3.8.2 shipped it, and all nine scoreboards inherit it and
|
`_favorite_key`.
|
||||||
floor on 3.8.2 (ledmatrix-plugins #637).
|
|
||||||
|
|
||||||
Left for later families: the live screens' favourites-only filter
|
Left for later families: the live screens' favourites-only filter
|
||||||
(`_classify_live_game` and its inline copies) and favourites-first sort still
|
(`_classify_live_game` and its inline copies) and favourites-first sort still
|
||||||
@@ -354,146 +346,6 @@ compare abbreviations exactly, and
|
|||||||
abbreviation (or by `_team_in` where a plugin has one) instead of through
|
abbreviation (or by `_team_in` where a plugin has one) instead of through
|
||||||
`_favorite_key`. The result-colour helpers also wait (decision above).
|
`_favorite_key`. The result-colour helpers also wait (decision above).
|
||||||
|
|
||||||
### Family 7: the other-games rotation (done: core 3.8.4, adopted)
|
|
||||||
|
|
||||||
ledmatrix-plugins `scripts/test_other_games_rotation.py` (#640) pinned 96 rows
|
|
||||||
across the nine plugins first: `_by_importance` per rankings table, core's
|
|
||||||
`_favorites_first` pools per favourites, quality, divisions and rankings, the
|
|
||||||
window over time, the real `update()` followed by `display()`'s rotation call
|
|
||||||
(the list, the card on screen, redraws and how often the list is recomposed),
|
|
||||||
`update()` and `display()` advancing the window in sequence and interleaved on
|
|
||||||
two threads, odds for rotated-in games, and `favorite_rotation_boost`'s
|
|
||||||
switch order. The reconcile (ledmatrix-plugins #641)
|
|
||||||
made `_by_importance`, `_other_games_window`, `_advance_other_games_if_due`,
|
|
||||||
`_rotate_other_games_on_display` and `_attach_odds_to_rotated_games` one body
|
|
||||||
on `SportsCore` (ufc gains the odds helper), with `_rankings_loaded` as the
|
|
||||||
seam `_by_importance` asks: the abbreviation table by default, football's
|
|
||||||
override also counts its rankings keyed by team id. Of 864 cells only those
|
|
||||||
the decisions below explain changed: the window lock (the interleaved row, in
|
|
||||||
the seven plugins with a reachable `update()` other than football), the
|
|
||||||
due-check's fallback pool (two rows in the same seven) and ufc's rotated-in
|
|
||||||
odds (one cell). The harness renders were byte-identical (208 PNGs).
|
|
||||||
`src/common/sports_rotation.py` holds the bodies in one mixin,
|
|
||||||
`SportsRotationMixin`; `test/test_sports_rotation_parity.py` compares them
|
|
||||||
with the plugin copies and checks that only football overrides
|
|
||||||
`_rankings_loaded`.
|
|
||||||
|
|
||||||
Left for later families: in eight plugins the no-favourites branch of
|
|
||||||
`update()` still picks a fixed "next N" through `_filtered_or_all` and never
|
|
||||||
builds the pools, so nothing rotates on a board with no favourites; football
|
|
||||||
routes it through `_favorites_first(games, 0, N)` (decision below, family 13).
|
|
||||||
ufc's MMA managers override `update()` and never build the pools, so the
|
|
||||||
rotation is dormant there. `_best_rank`, `_is_ranked_game` and
|
|
||||||
`_passes_other_filters` were family 8.
|
|
||||||
|
|
||||||
### Family 8: rankings (done: core 3.8.5, adopted)
|
|
||||||
|
|
||||||
ledmatrix-plugins `scripts/test_rankings.py` (#644) pinned 79 rows across the
|
|
||||||
nine plugins first, every ESPN request answered offline from recorded-shape
|
|
||||||
payloads: which endpoints `_fetch_team_rankings` asks per league and the
|
|
||||||
tables it keeps, `_choose_poll` over nine poll sets, the division rosters'
|
|
||||||
season year across six dates, the rankings and roster caches, a poll from a
|
|
||||||
finished season, malformed payloads, and `_is_ranked_game`, `_best_rank` and
|
|
||||||
`_passes_other_filters` over games whose abbreviations collide. The reconcile
|
|
||||||
(ledmatrix-plugins #645) made `_fetch_team_rankings`,
|
|
||||||
`_choose_poll` (three bodies each), `_load_division_team_ids`,
|
|
||||||
`_passes_other_filters`, `_best_rank` and `_is_ranked_game` (two each) one
|
|
||||||
body each on `SportsCore`, with football's `_ranked_positions` and a new
|
|
||||||
`_poll_season_ended` beside them, and dropped baseball's per-league
|
|
||||||
class-level rankings copy. Every changed cell is one of the decisions below:
|
|
||||||
the id table in all nine (and the games it re-ranks), /rankings first for
|
|
||||||
every college or NCAA league in afl, basketball, nrl and soccer, the season
|
|
||||||
year in the eight that asked for the calendar year, the stale-poll rows, and
|
|
||||||
baseball's shared copy. The harness renders were byte-identical.
|
|
||||||
`src/common/sports_rankings.py` holds the bodies in one mixin,
|
|
||||||
`SportsRankingsMixin`, plus `choose_top_division_poll()` for the dynamic team
|
|
||||||
resolvers; `test/test_sports_rankings_parity.py` compares them, and the
|
|
||||||
constants, with the plugin copies.
|
|
||||||
|
|
||||||
Left for later families: the rank badge (switch scorebug, scroll card and the
|
|
||||||
plugins' `manager.py`, families 11, 12 and 14) still reads the abbreviation
|
|
||||||
table; `manager.py`'s `_get_rankings_cache` merges every league's table for
|
|
||||||
the scroll cards. `_rankings_loaded` stays the family 7 seam (football counts
|
|
||||||
its id table too); the two differ only for a poll whose blocks carry ids and
|
|
||||||
no abbreviations. The data sources' `fetch_standings` (held with
|
|
||||||
`data_sources.py`) is still four bodies; only its endpoint order changed.
|
|
||||||
|
|
||||||
### Family 9: live fetch and odds (reconciled; promoted, core 3.8.6)
|
|
||||||
|
|
||||||
ledmatrix-plugins `scripts/test_live_fetch_odds.py` (#649) pinned 44 rows
|
|
||||||
across the nine plugins first, every ESPN request answered offline the way
|
|
||||||
ESPN answered on 2026-10-12 (a single date 200, a range 400): the requests,
|
|
||||||
cache keys and TTLs of `_fetch_todays_games` per league and Eastern time,
|
|
||||||
the live cache over time, empty, null and failed answers, `_fetch_odds` per
|
|
||||||
game kind and odds endpoint on a virtual clock (which thread asks, how long
|
|
||||||
the call waits, when the line lands), the real `update()` of every screen
|
|
||||||
with a 3 s odds endpoint, and the league overrides (MiLB, HockeyTech, ufc's
|
|
||||||
`MMALive`, which stay per-sport). Measuring found seven bodies of
|
|
||||||
`_fetch_todays_games`, not five: the fetch service's shared key had split
|
|
||||||
the old groups. The reconcile (ledmatrix-plugins #650)
|
|
||||||
made `_fetch_todays_games` and `_fetch_odds` one body each on `SportsCore`,
|
|
||||||
with the odds worker `_drain_odds_queue` beside them, and added a test that
|
|
||||||
runs `update()` and `display()` on two threads against a slow endpoint.
|
|
||||||
Every changed cell is one of the decisions below; the harness renders were
|
|
||||||
byte-identical once the four fixtures that seeded the old live key seed the
|
|
||||||
shared one. `src/common/sports_live_fetch.py` holds the bodies in one mixin,
|
|
||||||
`SportsLiveFetchMixin`; `test/test_sports_live_fetch_parity.py` compares them
|
|
||||||
with the plugin copies.
|
|
||||||
|
|
||||||
Measured `update()` time with a 3 s odds endpoint and four games, before and
|
|
||||||
after: Upcoming 6 s (afl, baseball, football, nrl, soccer, ufc), 12 s
|
|
||||||
(hockey, lacrosse), 0 s (basketball) to 0 s everywhere; Recent 6 s / 12 s to
|
|
||||||
0 s; Live's first pass 8 s / 12 s and next poll 4 s / 6 s to 0 s. The line
|
|
||||||
now lands when ESPN answers (3 s here) instead of at the next `update()`
|
|
||||||
(15 s for a live game, an hour for an upcoming or recent one) in the six.
|
|
||||||
|
|
||||||
Left for later: ufc's MMA Recent manager never asks for odds and its Upcoming
|
|
||||||
manager asks for every fight in the window before trimming to the headliners
|
|
||||||
(both in `mma.py`'s `update()` overrides, family 13); basketball's NCAA
|
|
||||||
Recent/Upcoming fallback still caches the undated scoreboard under
|
|
||||||
`<sport_key>_scoreboard_current` with `limit=1000`, which ESPN truncates (held
|
|
||||||
with the data fetching); `_attach_odds_to_rotated_games` still starts its own
|
|
||||||
thread per rotation and can route through `_fetch_odds` once every plugin
|
|
||||||
inherits this mixin; and ESPN lists only some college basketball games
|
|
||||||
without `groups=50` (7 of 21 on 2026-03-14), dated or not.
|
|
||||||
|
|
||||||
### Family 10: the view model (reconciled; promoted, core 3.8.7)
|
|
||||||
|
|
||||||
ledmatrix-plugins `scripts/test_view_model.py` (#653) pinned both
|
|
||||||
`_extract_game_details_common` and each plugin's final `_extract_game_details`
|
|
||||||
first, key by key, over 52 ESPN-shaped events: every state, OT, postponed,
|
|
||||||
canceled, delayed and forfeit games, a missing `completed` flag, scores as
|
|
||||||
int, "21.0", a dict, None, "" or missing, the two record shapes, missing logos
|
|
||||||
or ids, colliding abbreviations, broadcast objects, and dates with offsets,
|
|
||||||
without them, "TBD", and both DST changes. The nine bodies differed in how a
|
|
||||||
final was decided (five rules), how a score was read (raw in five), which
|
|
||||||
record and logo shapes were read, where a team id came from, how a time
|
|
||||||
without an offset was read (UTC, US Eastern in hockey and lacrosse, the host's
|
|
||||||
zone in ufc), and whether `favorite_teams` and basketball's tournament keys
|
|
||||||
were built there. The reconcile (ledmatrix-plugins #654)
|
|
||||||
made the method one body on `SportsCore`, with `_status_is_final`,
|
|
||||||
`_score_text`, `_team_logo_url`, `_extract_team_record` and the
|
|
||||||
`_logo_filename` seam beside it; basketball's tournament keys moved to its own
|
|
||||||
`_extract_game_details` (its dict keeps them), football's missing-badge
|
|
||||||
request to its own, and ufc's MMA extractor, which builds its own dict, uses the
|
|
||||||
shared helpers. Every changed cell is one of the decisions below or a fix.
|
|
||||||
All 208 harness renders of the nine are pixel-identical on core main.
|
|
||||||
`src/common/sports_view_model.py` holds the bodies in one mixin,
|
|
||||||
`SportsViewModelMixin`, whose docstring lists every key, its type and default;
|
|
||||||
`test/test_sports_view_model_parity.py` compares them with the plugin copies.
|
|
||||||
|
|
||||||
`period_text` is deliberately not given a default: the renderers read its
|
|
||||||
absence (baseball's) as "draw ESPN's status text", which is how an
|
|
||||||
extra-inning final reads "Final/10"; an empty default blanked baseball's
|
|
||||||
Recent "Final". `period` (0) and `clock` ("") are, and baseball now carries
|
|
||||||
them. `is_within_window` and `broadcast` are never read but are kept: keys are
|
|
||||||
additive only.
|
|
||||||
|
|
||||||
Left for later: the period labels stay in each sport's
|
|
||||||
`_extract_game_details` (quarters, periods, halves, extra time, rounds), and
|
|
||||||
soccer still names extra time's half-time "ETH"; ufc's MMA dict keeps its own
|
|
||||||
keys and its ordinal date (`display_manager.format_date_with_ordinal`).
|
|
||||||
|
|
||||||
### Why the method changes
|
### Why the method changes
|
||||||
|
|
||||||
Byte-identical promotion has nearly run dry. Measured on ledmatrix-plugins
|
Byte-identical promotion has nearly run dry. Measured on ledmatrix-plugins
|
||||||
@@ -577,13 +429,13 @@ release.
|
|||||||
|
|
||||||
| # | Family | Methods (variants) | Why here |
|
| # | Family | Methods (variants) | Why here |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 4 | Identical sweep | `manager.py`: `_dispatch_switch_refresh`, `_favorite_team_is_live`, `get_vegas_priority_weight`, `_game_involves`, `_favorite_scan_targets`, `_favorite_scan_games`, `_get_total_games_for_manager` (all nine, 1); the live-scroll helpers `_preserving_scroll_position`, `_refresh_live_scroll_managers`, `_live_scroll_managers`, `_note_live_scroll_built`, `_live_scroll_needs_rebuild`, `_live_scroll_fields` (eight, 1). `sports.py`: `_card_option`, `_filtered_or_all`, `_effective_live_duration`, `_recent_date_text` (eight, 1). 58 identical families in all | Nothing to decide; brings `manager.py` into core as a `SportsPluginHostMixin`. `_resolve_font_path` (identical in nine `sports.py` and eight renderers) becomes `sports_font_path.resolve_font_path`, not `font_layout.resolve_asset_path`, which skips the cwd. Done: core 3.8.0, adopted (ledmatrix-plugins #594); see [Stage 4](#stage-4-the-identical-sweep-done-core-380-adopted) |
|
| 4 | Identical sweep | `manager.py`: `_dispatch_switch_refresh`, `_favorite_team_is_live`, `get_vegas_priority_weight`, `_game_involves`, `_favorite_scan_targets`, `_favorite_scan_games`, `_get_total_games_for_manager` (all nine, 1); the live-scroll helpers `_preserving_scroll_position`, `_refresh_live_scroll_managers`, `_live_scroll_managers`, `_note_live_scroll_built`, `_live_scroll_needs_rebuild`, `_live_scroll_fields` (eight, 1). `sports.py`: `_card_option`, `_filtered_or_all`, `_effective_live_duration`, `_recent_date_text` (eight, 1). 58 identical families in all | Nothing to decide; brings `manager.py` into core as a `SportsPluginHostMixin`. `_resolve_font_path` (identical in nine `sports.py` and eight renderers) becomes `sports_font_path.resolve_font_path`, not `font_layout.resolve_asset_path`, which skips the cwd. Core side done; see [Stage 4](#stage-4-the-identical-sweep-core-done-adoption-waits-for-a-release) |
|
||||||
| 5 | Game-over check | `SportsLive._is_game_really_over` (5) | Pure logic, no pixels; one seam, `FINAL_PERIOD`. The pilot for the procedure. Reconciled to one body and promoted as `sports_game_over`; shipped in 3.8.1 and adopted. See [Family 5](#family-5-the-game-over-check-done-core-381-adopted) |
|
| 5 | Game-over check | `SportsLive._is_game_really_over` (5) | Pure logic, no pixels; one seam, `FINAL_PERIOD`. The pilot for the procedure. Reconciled to one body and promoted as `sports_game_over`; adoption waits for the release that ships it. See [Family 5](#family-5-the-game-over-check-core-done-adoption-waits-for-a-release) |
|
||||||
| 6 | Favourite matching | `_is_favorite_game` (7 across three classes), `_select_games_for_display` (2: nrl), `_select_recent_games_for_display` (3) | Everything that asks "is this a favourite" goes through the 3.5.0 `_favorite_key` seam. Reconciled to one body each and promoted as `sports_favorites`; shipped in 3.8.2 and adopted. See [Family 6](#family-6-favourite-matching-done-core-382-adopted) |
|
| 6 | Favourite matching | `_is_favorite_game` (7 across three classes), `_select_games_for_display` (2: nrl), `_select_recent_games_for_display` (3) | Everything that asks "is this a favourite" goes through the 3.5.0 `_favorite_key` seam. Reconciled to one body each and promoted as `sports_favorites`; adoption waits for the release that ships it. See [Family 6](#family-6-favourite-matching-core-done-adoption-waits-for-a-release) |
|
||||||
| 7 | Other-games rotation | `_by_importance`, `_other_games_window`, `_advance_other_games_if_due` (2 each: football), `_rotate_other_games_on_display` (2: ufc), with `_attach_odds_to_rotated_games` (3; ufc had none) | One outlier each; football carried two fixes the other eight lacked. Reconciled to one body each, on a `_rankings_loaded` seam, and promoted as `sports_rotation`; done: core 3.8.4, adopted (ledmatrix-plugins #643). See [Family 7](#family-7-the-other-games-rotation-done-core-384-adopted) |
|
| 7 | Other-games rotation | `_by_importance`, `_other_games_window`, `_advance_other_games_if_due` (2 each: football), `_rotate_other_games_on_display` (2: ufc) | One outlier each; football carries two fixes the other eight lack |
|
||||||
| 8 | Rankings | `_fetch_team_rankings` (3), `_choose_poll` (3), `_load_division_team_ids`, `_passes_other_filters`, `_best_rank`, `_is_ranked_game` (2 each: football) | Needs 7; the rank badge and the "ranked only" filter read it. Reconciled to one body each and promoted as `sports_rankings`; done: core 3.8.5, adopted (ledmatrix-plugins #648). See [Family 8](#family-8-rankings-done-core-385-adopted) |
|
| 8 | Rankings | `_fetch_team_rankings` (3), `_choose_poll` (3), `_load_division_team_ids`, `_passes_other_filters`, `_best_rank`, `_is_ranked_game` (2 each: football) | Needs 7; the rank badge and the "ranked only" filter read it |
|
||||||
| 9 | Live fetch and odds | `_fetch_todays_games` (7 when measured), `_fetch_odds` (3); `_attach_odds_to_rotated_games` went with 7 | The prerequisite for one shared ESPN poller across plugins. Reconciled to one body each and promoted as `sports_live_fetch` (core 3.8.6). See [Family 9](#family-9-live-fetch-and-odds-reconciled-promoted-core-386) |
|
| 9 | Live fetch and odds | `_fetch_todays_games` (5), `_fetch_odds` (3), `_attach_odds_to_rotated_games` (3) | The prerequisite for one shared ESPN poller across plugins |
|
||||||
| 10 | View model | `_extract_game_details_common` (9 of 9) | Every renderer reads it; its keys are additive-only, so reconcile to the superset and leave sport extras in `_extract_game_details`. Reconciled to one body and promoted as `sports_view_model` (core 3.8.7). See [Family 10](#family-10-the-view-model-reconciled-promoted-core-387) |
|
| 10 | View model | `_extract_game_details_common` (9 of 9) | Every renderer reads it; its keys are additive-only, so reconcile to the superset and leave sport extras in `_extract_game_details` |
|
||||||
| 11 | Switch scorebug | `_load_fonts` (4), `_load_custom_font_from_element_config` (6), `_get_layout_offset` (5), `_fit_score_font` (2: football), `_load_and_resize_logo` (9), `_draw_dynamic_odds` (9), then `_draw_scorebug_layout` (20: Upcoming 9, Recent 8, Live 2, ufc's Core 1) | Needs 10 and the twin decisions below. Several releases: fonts and offsets, logos, odds, then one mode's layout per release |
|
| 11 | Switch scorebug | `_load_fonts` (4), `_load_custom_font_from_element_config` (6), `_get_layout_offset` (5), `_fit_score_font` (2: football), `_load_and_resize_logo` (9), `_draw_dynamic_odds` (9), then `_draw_scorebug_layout` (20: Upcoming 9, Recent 8, Live 2, ufc's Core 1) | Needs 10 and the twin decisions below. Several releases: fonts and offsets, logos, odds, then one mode's layout per release |
|
||||||
| 12 | Scroll/Vegas card | `game_renderer.py`: `render_game_card` (6), `_load_and_resize_logo` (8), `_load_custom_font`, `preload_logos`, `__init__` (7 each), `_draw_records_or_rankings` (6), `_load_fonts`, `_draw_dynamic_odds`, `_draw_live_game_status`, `_draw_recent_game_status`, `_get_team_display_text` (5 each), `_draw_text_with_outline` (2: football) | Same decisions as 11; sequence after the scroll-performance work on pre-rendered strips lands |
|
| 12 | Scroll/Vegas card | `game_renderer.py`: `render_game_card` (6), `_load_and_resize_logo` (8), `_load_custom_font`, `preload_logos`, `__init__` (7 each), `_draw_records_or_rankings` (6), `_load_fonts`, `_draw_dynamic_odds`, `_draw_live_game_status`, `_draw_recent_game_status`, `_get_team_display_text` (5 each), `_draw_text_with_outline` (2: football) | Same decisions as 11; sequence after the scroll-performance work on pre-rendered strips lands |
|
||||||
| 13 | Mode lifecycle | `sports.py`: `update` (23), `__init__` (16), `display` (13), `_advance_live_game_if_due` (6) | Where per-sport behaviour lives; last in `sports.py`. Each difference becomes a seam or a strategy chosen by name (live rotation already has three) |
|
| 13 | Mode lifecycle | `sports.py`: `update` (23), `__init__` (16), `display` (13), `_advance_live_game_if_due` (6) | Where per-sport behaviour lives; last in `sports.py`. Each difference becomes a seam or a strategy chosen by name (live rotation already has three) |
|
||||||
@@ -634,89 +486,34 @@ suspected behaviour that needs a payload or a rig to confirm first.
|
|||||||
nine. ufc stays on the shared body, dormant: its favourites are fighters,
|
nine. ufc stays on the shared body, dormant: its favourites are fighters,
|
||||||
which its MMA managers match themselves (a follow-up). Fix ported: only a
|
which its MMA managers match themselves (a follow-up). Fix ported: only a
|
||||||
game with an id can be a duplicate in the selection methods.
|
game with an id can be a duplicate in the selection methods.
|
||||||
- **7, other-games rotation. Decided 2026-10-09, done:** two fixes ported
|
- **7, other-games rotation.** football advances the rotation window under
|
||||||
from football. The window advances under `_games_lock`: `update()` and
|
`_games_lock` (update() and display() both advance it; interleaved, a
|
||||||
`display()` both advance it, and interleaved, each added a width and a
|
window of games is skipped) and fixes a favourites-only pool that recomposed
|
||||||
window of games was never shown. The display path's due-check looks at the
|
the list on every frame. Port both. ufc does not attach odds to fights
|
||||||
pool `_compose_selection` actually cuts from, including the unfiltered
|
rotated in: decide whether rotated fights show odds.
|
||||||
fallback when nothing else survived. The other eight never rotated that
|
- **8, rankings.** (a) afl, basketball, nrl and soccer turn a *standings*
|
||||||
fallback between fetches (it moved only when `update()` ran, then several
|
payload into ranks (a pro league's standings position becomes the rank
|
||||||
windows at once); guessing it whenever the filtered pool was empty would
|
badge); baseball, hockey, lacrosse, ufc and football do not. Which is
|
||||||
recompose an identical list on every frame while a favourite played.
|
right is visible on every pro-league card with "show ranking" on.
|
||||||
Rotated-in fights in ufc follow its `show_odds` like every other fight (no
|
(b) football also keys ranks by team id, so two schools sharing an
|
||||||
separate toggle; the rotation is dormant in ufc today, so no board
|
abbreviation across divisions cannot be confused: adopt for all.
|
||||||
changes). `_rankings_loaded` is a seam (default: the abbreviation table is
|
(c) football asks for the division roster of the *season* year (July
|
||||||
non-empty; football counts its by-id table too). Kept for family 13: the
|
onward is this year's season), which is right for football and wrong for
|
||||||
no-favourites branch of `update()` keeps its fixed "next N" in the eight
|
college basketball, hockey and lacrosse, whose ESPN season is the year it
|
||||||
plugins that have it, rather than football's rotating pools.
|
ends: a per-sport seam, not football's constant. (d) baseball's
|
||||||
- **8, rankings. Decided 2026-10-12, reconciled.** Measured first, two of
|
`_choose_poll` calls `dynamic_team_resolver.choose_top_division_poll`, the
|
||||||
the questions as first written did not hold. (a) afl, basketball, nrl and
|
others inline it: one home, in core.
|
||||||
soccer could turn a *standings* payload into ranks, but the branch never
|
- **9, live fetch and odds.** (a) afl, basketball, nrl and soccer cache the
|
||||||
ran: rankings are fetched only where `_league_has_rankings()` (a college or
|
live scoreboard for 30 s under `<sport_key>_scoreboard_current`; the others
|
||||||
NCAA league), and ESPN's site-API /standings answers `{}` or a link stub,
|
do not (the harness fixtures seed that key, so the change shows up there).
|
||||||
never a table. Deleted. The same four data sources asked /standings first
|
(b) basketball fetches college games with no `dates` parameter, citing a
|
||||||
for any college league outside a fixed list, so a custom NCAA soccer
|
404 (*verify* now that `espn_dates` handles ranges). (c) odds are fetched
|
||||||
league (`usa.ncaa.m.1`) never showed its coaches poll; every college or
|
three ways: blocking (hockey, lacrosse), a thread waited on for 1.5–2 s
|
||||||
NCAA league now asks /rankings first, as in the other five (fix, visible
|
(six plugins), or fire-and-forget for upcoming games (basketball). This
|
||||||
only there). (b) Ranks are kept by ESPN team id too, in all nine, and a
|
sets how long `update()` takes and when an odds line appears. (d) nrl
|
||||||
game with ids is ranked by id: ESPN lists Ohio State and Ohio State Newark
|
guards on a missing odds manager; port it.
|
||||||
both as OSU. Changes the "ranked" filter and the other-games order only
|
- **10, view model.** Per key, whether every sport emits it. Additive only:
|
||||||
where abbreviations collide; the badge still reads the abbreviation table.
|
no key is renamed or removed.
|
||||||
(c) Only college football has division rosters (all nine carry the same
|
|
||||||
one-league table; college basketball, hockey and lacrosse publish none), so
|
|
||||||
football's July rule is promoted as is and there is no seam. In the other
|
|
||||||
eight the calendar year was unreachable. (d) One poll rule, in core:
|
|
||||||
`_choose_poll`, with `choose_top_division_poll()` for the resolvers that
|
|
||||||
expand `AP_TOP_n` (baseball's helper and the others' inline copies call it
|
|
||||||
once they adopt). (e) A poll from a season that has ended loads nothing:
|
|
||||||
ESPN keeps serving the last poll between seasons (on 2026-10-12, college
|
|
||||||
basketball's April final and college lacrosse's May poll), and each block
|
|
||||||
carries its season's `endDate`, so no calendar and no per-sport seam. No
|
|
||||||
badge shows and the "ranked" filter fails open until the new season's
|
|
||||||
first poll; visible on college basketball and lacrosse boards between
|
|
||||||
seasons. A block without a readable `endDate` counts as current. (f)
|
|
||||||
baseball's MLB, MiLB and NCAA Baseball managers drop their class-level
|
|
||||||
shared copy of the table (none of those leagues publishes a poll).
|
|
||||||
- **9, live fetch and odds. Decided 2026-10-12, reconciled.** (a) Live
|
|
||||||
scores are always fresh: every poll asks ESPN and leaves the answer under
|
|
||||||
the shared `espn_scoreboard_cache_key` for its dates (`max_age=1`: only a
|
|
||||||
copy another reader fetched within the last second, the managers of one
|
|
||||||
display tick, is reused). afl, basketball, nrl and soccer had held it 30 s
|
|
||||||
under `<sport_key>_scoreboard_current`, so with the 15 s default poll every
|
|
||||||
other poll repeated the last one; lacrosse and ufc now share their copy.
|
|
||||||
(b) College basketball is asked by date like every other league: ESPN
|
|
||||||
answered single dates for both college leagues on 2026-10-12 (the 404 no
|
|
||||||
longer happens), so the lookback now reaches last night's late games there
|
|
||||||
too. `groups=50` is a separate question (left above). (c) Odds never block
|
|
||||||
`update()`: one daemon worker per manager answers a queue and attaches each
|
|
||||||
line in place under `_games_lock`; one batch in flight per manager, games
|
|
||||||
queued meanwhile join it. A card can draw once without odds and gain them;
|
|
||||||
a pre-rendered scroll strip gains them when it is next rebuilt. (d) The
|
|
||||||
missing-odds-manager guard (nrl's, already in `_attach_odds_to_rotated_games`
|
|
||||||
since family 7) is in `_fetch_odds` too, logged at debug, not ERROR. Fix
|
|
||||||
ported: an answer with `"events": null` is no games instead of a
|
|
||||||
`TypeError` out of the fetch (all nine), and `get_espn_scoreboard` no longer
|
|
||||||
caches it under the shared key.
|
|
||||||
- **10, view model. Decided 2026-10-13, reconciled.** Additive only: no key
|
|
||||||
is renamed or removed. (a) One rule for a final, in all nine: state "post",
|
|
||||||
not `completed: false` (a payload without the flag is judged on state and
|
|
||||||
name), and not a not-played status, the union of the nine lists (postponed,
|
|
||||||
canceled, suspended, abandoned, delayed, a pending forfeit or no-contest).
|
|
||||||
A forfeit is a result (basketball and soccer had it as not played). (b)
|
|
||||||
Scores are integer text everywhere ("21" for 21, "21.0" or {"value": 21};
|
|
||||||
"0" for a missing or empty one, never "None"). (c) Halftime reads "HALF",
|
|
||||||
with no period or clock, in every sport's `period_text` (basketball,
|
|
||||||
football, hockey and lacrosse showed the period while ESPN said "in"; nrl
|
|
||||||
and soccer appended "0:00"). (d) A time without an offset is UTC (hockey and
|
|
||||||
lacrosse read US Eastern, ufc the host's zone). Fixes ported: football's
|
|
||||||
postponed or canceled game says so, not "Final"; the team-schedule record
|
|
||||||
shape is read in all nine; a logo URL is `team.logo`, else `logos[0].href`;
|
|
||||||
a team id is the competitor's, else its team's (eight dropped the game);
|
|
||||||
ufc never shows a score of "None"; a called-off game ESPN still reports as
|
|
||||||
"in" is not live (baseball's guard). Superset: ufc's fights carry
|
|
||||||
`favorite_teams` ([]), baseball's games `period` and `clock`. Per-sport:
|
|
||||||
ufc's own dict and ordinal date, nrl's id-based logo file and favourites,
|
|
||||||
the period labels.
|
|
||||||
- **11 and 12, the scorebug and the card.** The pinned divergences in
|
- **11 and 12, the scorebug and the card.** The pinned divergences in
|
||||||
`test/test_sports_twins.py`, where switch mode and scroll mode draw the
|
`test/test_sports_twins.py`, where switch mode and scroll mode draw the
|
||||||
same game differently:
|
same game differently:
|
||||||
|
|||||||
@@ -39,15 +39,11 @@ src/common/sports_favorites.py
|
|||||||
src/common/sports_fetch.py
|
src/common/sports_fetch.py
|
||||||
src/common/sports_font_path.py
|
src/common/sports_font_path.py
|
||||||
src/common/sports_game_over.py
|
src/common/sports_game_over.py
|
||||||
src/common/sports_live_fetch.py
|
|
||||||
src/common/sports_live_scroll.py
|
src/common/sports_live_scroll.py
|
||||||
src/common/sports_plugin_host.py
|
src/common/sports_plugin_host.py
|
||||||
src/common/sports_rankings.py
|
|
||||||
src/common/sports_rotation.py
|
|
||||||
src/common/sports_scroll.py
|
src/common/sports_scroll.py
|
||||||
src/common/sports_timezone.py
|
src/common/sports_timezone.py
|
||||||
src/common/sports_vegas.py
|
src/common/sports_vegas.py
|
||||||
src/common/sports_view_model.py
|
|
||||||
src/config_service.py
|
src/config_service.py
|
||||||
src/core_config_keys.py
|
src/core_config_keys.py
|
||||||
src/deprecation.py
|
src/deprecation.py
|
||||||
|
|||||||
+1
-1
@@ -4,5 +4,5 @@ LEDMatrix Display System
|
|||||||
Core source package for the LED Matrix Display project.
|
Core source package for the LED Matrix Display project.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "3.8.7"
|
__version__ = "3.8.3"
|
||||||
|
|
||||||
|
|||||||
+48
-54
@@ -25,11 +25,10 @@ Typical plugin usage::
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import pytz
|
import pytz
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -73,60 +72,41 @@ def _outlived(record: Any, max_age: Optional[float], now: float) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
#: Cache keys that were file "mailboxes" from the web interface (and some
|
_NOT_SEEN: Any = object()
|
||||||
#: plugins) to the display. The control socket replaced them, and nothing
|
|
||||||
#: reads them any more, so a write is refused rather than left on the SD card
|
|
||||||
#: for nobody: see :func:`_refuse_retired_mailbox_write`.
|
|
||||||
RETIRED_MAILBOX_KEYS = frozenset({'display_on_demand_request', 'plugin_error_clear_request'})
|
|
||||||
|
|
||||||
#: (key, writer) pairs already warned about, so a plugin that writes on every
|
|
||||||
#: event logs once per process, not once per write.
|
|
||||||
_retired_writers_warned: set = set()
|
|
||||||
_retired_writers_lock = threading.Lock()
|
|
||||||
|
|
||||||
|
|
||||||
def _retired_mailbox_writer(data: Any) -> str:
|
class MailboxWatch:
|
||||||
"""Name whoever is writing a retired mailbox key, as well as can be told.
|
"""Tells the poller of a mailbox key whether its file changed since the
|
||||||
|
last look, from one stat() (:meth:`CacheManager.file_signature`).
|
||||||
|
|
||||||
The plugin instance on the call stack when there is one (a ``self`` with
|
The display polls the mailboxes the web interface falls back to. Reading
|
||||||
a string ``plugin_id`` and a ``cache_manager``: what BasePlugin gives
|
one is an open and a JSON parse; with this a poll that finds the same file
|
||||||
every plugin), else the ``plugin_id`` the request itself names, else
|
(or none) costs a stat, and the file is read only after a new write. A
|
||||||
``'unknown'``.
|
cache without ``file_signature`` (a test double) is read every time.
|
||||||
"""
|
"""
|
||||||
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 _refuse_retired_mailbox_write(logger: logging.Logger, key: str, data: Any) -> None:
|
def changed(self, cache_manager: Any) -> bool:
|
||||||
"""Warn, once per writer, that a write to a retired mailbox key was dropped."""
|
"""True when the poller should read the key now."""
|
||||||
try:
|
signature = getattr(cache_manager, 'file_signature', None)
|
||||||
writer = _retired_mailbox_writer(data)
|
sig = signature(self.key) if callable(signature) else _NOT_SEEN
|
||||||
except Exception: # pylint: disable=broad-except
|
if sig is not None and not isinstance(sig, tuple):
|
||||||
writer = 'unknown'
|
return True # cannot tell: read it
|
||||||
with _retired_writers_lock:
|
if sig is None:
|
||||||
if (key, writer) in _retired_writers_warned:
|
self._seen = None
|
||||||
return
|
return False # no file, nothing to read
|
||||||
_retired_writers_warned.add((key, writer))
|
if sig == self._seen:
|
||||||
if key == 'display_on_demand_request':
|
return False
|
||||||
hint = ("call self.request_on_demand() / self.end_on_demand() instead "
|
self._seen = sig
|
||||||
"(BasePlugin, LEDMatrix 3.8.1 and later)")
|
return True
|
||||||
else:
|
|
||||||
hint = "clear errors through POST /api/v3/errors/clear instead"
|
def forget(self) -> None:
|
||||||
logger.warning("Ignored a write to the retired '%s' cache key by %s: the display no "
|
"""Read the key on the next poll even if its file has not changed
|
||||||
"longer reads this file mailbox. Update it to %s. (Logged once per writer.)",
|
(the last read failed)."""
|
||||||
key, writer, hint)
|
self._seen = _NOT_SEEN
|
||||||
|
|
||||||
|
|
||||||
class CacheManager:
|
class CacheManager:
|
||||||
@@ -350,6 +330,24 @@ class CacheManager:
|
|||||||
"""Get the path for a cache file."""
|
"""Get the path for a cache file."""
|
||||||
return self._disk_cache_component.get_cache_path(key)
|
return self._disk_cache_component.get_cache_path(key)
|
||||||
|
|
||||||
|
def file_signature(self, key: str) -> Optional[Tuple[int, int, int]]:
|
||||||
|
"""``(st_ino, st_mtime_ns, st_size)`` of ``key``'s file, or None when
|
||||||
|
there is no file (the key is absent, or this cache has no disk tier).
|
||||||
|
|
||||||
|
One stat(), no read: a poller of a mailbox another process writes
|
||||||
|
compares it with the last one it saw and reads the file only when it
|
||||||
|
changed. Every write replaces the file (a temp file renamed into
|
||||||
|
place), so a new write always has a new inode, however fast it came.
|
||||||
|
"""
|
||||||
|
path = self._get_cache_path(key)
|
||||||
|
if not path:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
st = os.stat(path)
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
return (st.st_ino, st.st_mtime_ns, st.st_size)
|
||||||
|
|
||||||
def get_cached_data(self, key: str, max_age: int = 300, memory_ttl: Optional[int] = None) -> Optional[Dict[str, Any]]:
|
def get_cached_data(self, key: str, max_age: int = 300, memory_ttl: Optional[int] = None) -> Optional[Dict[str, Any]]:
|
||||||
"""Get data from cache (memory first, then disk) honoring TTLs.
|
"""Get data from cache (memory first, then disk) honoring TTLs.
|
||||||
|
|
||||||
@@ -387,10 +385,6 @@ class CacheManager:
|
|||||||
key: Cache key
|
key: Cache key
|
||||||
data: Data to cache
|
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
|
# Periodic cleanup before adding new entries
|
||||||
self._cleanup_memory_cache()
|
self._cleanup_memory_cache()
|
||||||
|
|
||||||
|
|||||||
@@ -51,16 +51,12 @@ Rules for the package:
|
|||||||
| [`sports_game_over`](#sports_game_over) | Whether a game ESPN still lists as live has ended | Yes (scoreboards) | 3.8.1 |
|
| [`sports_game_over`](#sports_game_over) | Whether a game ESPN still lists as live has ended | Yes (scoreboards) | 3.8.1 |
|
||||||
| [`sports_game_renderer`](#sports_game_renderer) | Scoreboard scroll/Vegas card geometry | Yes (scoreboards) | 3.3.0 |
|
| [`sports_game_renderer`](#sports_game_renderer) | Scoreboard scroll/Vegas card geometry | Yes (scoreboards) | 3.3.0 |
|
||||||
| [`sports_helpers`](#sports_helpers) | Small helpers every scoreboard `sports.py` copies | Yes (scoreboards) | 3.5.0 |
|
| [`sports_helpers`](#sports_helpers) | Small helpers every scoreboard `sports.py` copies | Yes (scoreboards) | 3.5.0 |
|
||||||
| [`sports_live_fetch`](#sports_live_fetch) | The live poll's scoreboard request, and odds off the update path | Yes (scoreboards) | 3.8.6 |
|
|
||||||
| [`sports_live_scroll`](#sports_live_scroll) | Rebuild a live scroll strip mid-cycle without moving it | Yes (scoreboards) | 3.8.0 |
|
| [`sports_live_scroll`](#sports_live_scroll) | Rebuild a live scroll strip mid-cycle without moving it | Yes (scoreboards) | 3.8.0 |
|
||||||
| [`sports_plugin_host`](#sports_plugin_host) | Helpers of a scoreboard's plugin class (`manager.py`) | Yes (scoreboards) | 3.8.0 |
|
| [`sports_plugin_host`](#sports_plugin_host) | Helpers of a scoreboard's plugin class (`manager.py`) | Yes (scoreboards) | 3.8.0 |
|
||||||
| [`sports_rankings`](#sports_rankings) | Which teams are ranked, in which poll, and the "ranked" filter | Yes (scoreboards) | 3.8.5 |
|
|
||||||
| [`sports_rotation`](#sports_rotation) | Which non-favourite games a scoreboard shows, and when the slice moves | Yes (scoreboards) | 3.8.4 |
|
|
||||||
| [`sports_scroll`](#sports_scroll) | Scoreboard scroll-display orchestration | Yes (scoreboards) | 3.2.0 |
|
| [`sports_scroll`](#sports_scroll) | Scoreboard scroll-display orchestration | Yes (scoreboards) | 3.2.0 |
|
||||||
| [`sports_shared`](#sports_shared) | Sport-independent `sports.py` methods | Yes (scoreboards) | 3.3.0 |
|
| [`sports_shared`](#sports_shared) | Sport-independent `sports.py` methods | Yes (scoreboards) | 3.3.0 |
|
||||||
| [`sports_vegas`](#sports_vegas) | Live Vegas cards: keys, card cache, sticky odds, finished games | Yes (scoreboards) | 3.8.0 |
|
| [`sports_vegas`](#sports_vegas) | Live Vegas cards: keys, card cache, sticky odds, finished games | Yes (scoreboards) | 3.8.0 |
|
||||||
| [`sports_timezone`](#sports_timezone) | Which timezone a scoreboard draws start times in | Yes (scoreboards) | 3.6.0 |
|
| [`sports_timezone`](#sports_timezone) | Which timezone a scoreboard draws start times in | Yes (scoreboards) | 3.6.0 |
|
||||||
| [`sports_view_model`](#sports_view_model) | The game dict every scoreboard screen draws, built from one ESPN event | Yes (scoreboards) | 3.8.7 |
|
|
||||||
| [`sync_manager`](#sync_manager) | Leader/follower sync between two displays | No, core-internal | n/a |
|
| [`sync_manager`](#sync_manager) | Leader/follower sync between two displays | No, core-internal | n/a |
|
||||||
| [`text_helper`](#text_helper) | Outlined text, wrapping, measurement | Yes | — |
|
| [`text_helper`](#text_helper) | Outlined text, wrapping, measurement | Yes | — |
|
||||||
|
|
||||||
@@ -372,50 +368,6 @@ shape), `_dispatch_switch_refresh()` (a manager refresh on a daemon thread, so
|
|||||||
`display()` never waits on the network), `get_vegas_content_type()` and small
|
`display()` never waits on the network), `get_vegas_content_type()` and small
|
||||||
dynamic-duration helpers. List it before `BasePlugin`.
|
dynamic-duration helpers. List it before `BasePlugin`.
|
||||||
|
|
||||||
### sports_live_fetch
|
|
||||||
|
|
||||||
[`sports_live_fetch.py`](sports_live_fetch.py). Sports family 9:
|
|
||||||
`SportsLiveFetchMixin` (`SportsCore`). `_fetch_todays_games` asks ESPN for
|
|
||||||
today's scoreboard (and yesterday's while `_needs_previous_day()` says it can
|
|
||||||
still hold a live game) on every live poll through `get_espn_scoreboard`, and
|
|
||||||
leaves it under the shared `espn_scoreboard_cache_key` for odds-ticker and the
|
|
||||||
other scoreboards; an answer without an `events` list is no games.
|
|
||||||
`_fetch_odds` queues a game and returns at once; one daemon worker per manager
|
|
||||||
(`_drain_odds_queue`) asks the odds manager for each queued game and attaches
|
|
||||||
the line in place under `_games_lock`, so a card can draw once without odds and
|
|
||||||
gain them. Games queued while the worker is busy join its batch.
|
|
||||||
|
|
||||||
### sports_rankings
|
|
||||||
|
|
||||||
[`sports_rankings.py`](sports_rankings.py). Sports family 8:
|
|
||||||
`SportsRankingsMixin` (`SportsCore`), the rankings. `_fetch_team_rankings`
|
|
||||||
loads the top-division poll hourly (only where `_league_has_rankings()`) as an
|
|
||||||
abbreviation table, which the rank badge draws, and an ESPN team id table,
|
|
||||||
which `_ranked_positions` reads first, so a school sharing a ranked team's
|
|
||||||
abbreviation is not ranked. `_choose_poll` steps over seedings and lower
|
|
||||||
divisions' polls and otherwise keeps ESPN's order;
|
|
||||||
`choose_top_division_poll()` is the same rule for code without a scoreboard
|
|
||||||
(the dynamic team resolvers). A poll whose season's `endDate` has passed loads
|
|
||||||
nothing (`_poll_season_ended`), so between seasons no badge shows and the
|
|
||||||
"ranked" filter fails open. `_load_division_team_ids` reads college football's
|
|
||||||
FBS and FCS rosters for the season year; `_is_ranked_game`, `_best_rank` and
|
|
||||||
`_passes_other_filters` serve the other-games selection.
|
|
||||||
|
|
||||||
### sports_rotation
|
|
||||||
|
|
||||||
[`sports_rotation.py`](sports_rotation.py). Sports family 7:
|
|
||||||
`SportsRotationMixin` (`SportsCore`), the other-games rotation.
|
|
||||||
`_by_importance` orders the non-favourite pool best matchup first, one game per
|
|
||||||
team, when `_rankings_loaded()` says a poll loaded (football overrides that to
|
|
||||||
count its by-id rankings). `_other_games_window` cuts the slice on screen,
|
|
||||||
advancing by its width every `other_rotation_interval_seconds` under
|
|
||||||
`_games_lock`, catching up on missed intervals and wrapping.
|
|
||||||
`_rotate_other_games_on_display` (with `_advance_other_games_if_due`) re-cuts
|
|
||||||
it from `display()` between fetches, looking at the pool `_compose_selection`
|
|
||||||
will cut, unfiltered fallback included, and keeps the card on screen when it
|
|
||||||
survives; `_attach_odds_to_rotated_games` fetches odds for the games it brought
|
|
||||||
in when `show_odds` is on.
|
|
||||||
|
|
||||||
### sports_scroll
|
### sports_scroll
|
||||||
|
|
||||||
[`sports_scroll.py`](sports_scroll.py). `SportsScrollDisplay` and
|
[`sports_scroll.py`](sports_scroll.py). `SportsScrollDisplay` and
|
||||||
@@ -448,20 +400,6 @@ through a live poll that left them out), and `finished_games()` /
|
|||||||
FINAL). `SportsScrollDisplay.build_vegas_elements()` in `sports_scroll` puts
|
FINAL). `SportsScrollDisplay.build_vegas_elements()` in `sports_scroll` puts
|
||||||
them together; a scoreboard not built on it (UFC) uses them directly.
|
them together; a scoreboard not built on it (UFC) uses them directly.
|
||||||
|
|
||||||
### sports_view_model
|
|
||||||
|
|
||||||
[`sports_view_model.py`](sports_view_model.py). Sports family 10:
|
|
||||||
`SportsViewModelMixin` (`SportsCore`). `_extract_game_details_common` turns
|
|
||||||
one ESPN scoreboard event into the game dict every renderer reads (the key
|
|
||||||
table is in the module docstring; keys are additive only) and hands back the
|
|
||||||
two competitors, the status and the situation for the sport's own
|
|
||||||
`_extract_game_details`. One rule decides a final (`_status_is_final`: state
|
|
||||||
"post", not `completed: false`, not a postponed, canceled, suspended,
|
|
||||||
abandoned, delayed or pending status; a forfeit is final); a called-off game
|
|
||||||
is never live; scores are integer text (`_score_text`); a time without an
|
|
||||||
offset is UTC. `_logo_filename(abbr, team_id)` is the seam for the logo file
|
|
||||||
under `logo_dir` (NRL names it by team id too).
|
|
||||||
|
|
||||||
### sports_timezone
|
### sports_timezone
|
||||||
|
|
||||||
[`sports_timezone.py`](sports_timezone.py).
|
[`sports_timezone.py`](sports_timezone.py).
|
||||||
|
|||||||
+16
-161
@@ -66,39 +66,19 @@ entry older than the reader's own ``max_age``, whoever wrote it and whatever
|
|||||||
ttl they stored with it. Old keys are passed as ``legacy_keys`` and read
|
ttl they stored with it. Old keys are passed as ``legacy_keys`` and read
|
||||||
after the canonical one, so an upgrade does not refetch everything at once;
|
after the canonical one, so an upgrade does not refetch everything at once;
|
||||||
they can go one release after the one that added this.
|
they can go one release after the one that added this.
|
||||||
|
|
||||||
Chunks whose days are long over are kept in memory between fetches. The
|
|
||||||
scoreboards re-fetch their whole Recent/Upcoming window (14 days back, 7
|
|
||||||
ahead) every hour, and since ranges went away that is 22 day requests per
|
|
||||||
league. Measured on hdpi on 2026-10-02 (NFL, college football, MLB, college
|
|
||||||
baseball, NHL): the hourly window refresh was ~270 of 321 ESPN requests and
|
|
||||||
~21 of 24.6MB in the hour, and the 12 days that ended three or more days ago
|
|
||||||
were 68% of those bytes (6.9 of 10.2MB per copy of the five windows). A
|
|
||||||
settled chunk is answered from memory for ``SETTLED_CHUNK_TTL_SECONDS``,
|
|
||||||
stored as zlib-compressed JSON (~13x smaller than the body, and far smaller
|
|
||||||
than the parsed objects), so the hourly refresh only goes to ESPN for the
|
|
||||||
days that can still change.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import contextvars
|
import contextvars
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
import re
|
import re
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import zlib
|
|
||||||
from collections import OrderedDict
|
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from datetime import date, datetime, timedelta, timezone, tzinfo
|
from datetime import date, datetime, timedelta, tzinfo
|
||||||
from functools import partial
|
from functools import partial
|
||||||
from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple, cast
|
from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple, cast
|
||||||
|
|
||||||
try:
|
|
||||||
import orjson
|
|
||||||
except ImportError: # optional; the stdlib parser gives the same objects
|
|
||||||
orjson = None
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from src.common.json_body import response_json
|
from src.common.json_body import response_json
|
||||||
except ImportError:
|
except ImportError:
|
||||||
@@ -182,38 +162,12 @@ _range_lock = threading.Lock()
|
|||||||
# answered -- to learn what every start learns.
|
# answered -- to learn what every start learns.
|
||||||
_ranges_rejected_until = time.monotonic() + RANGE_RETRY_SECONDS
|
_ranges_rejected_until = time.monotonic() + RANGE_RETRY_SECONDS
|
||||||
|
|
||||||
# A chunk is "settled" once its last day is this many UTC days back. ESPN
|
|
||||||
# files games under the US Eastern date, and a late West-coast game ends after
|
|
||||||
# midnight UTC; three days leaves a full day of margin past both, so nothing
|
|
||||||
# still being played, finalised or rescheduled is ever served from memory.
|
|
||||||
SETTLED_AFTER_DAYS = 3
|
|
||||||
|
|
||||||
# How long a settled chunk is trusted. A day's finals do not change, but a
|
|
||||||
# rare correction (or an empty answer during an ESPN outage) should not live
|
|
||||||
# forever: once a day is plenty, and still skips 23 of every 24 hourly asks.
|
|
||||||
SETTLED_CHUNK_TTL_SECONDS = 24 * 60 * 60
|
|
||||||
|
|
||||||
# Bounds on the settled-chunk memory. A settled day measured 90KB (NHL) to
|
|
||||||
# 990KB (a college-football Saturday) of JSON and 9-74KB compressed; the five
|
|
||||||
# windows on hdpi need 60 entries and ~0.55MB. The caps only matter for a
|
|
||||||
# board fetching whole past seasons.
|
|
||||||
SETTLED_CACHE_MAX_ENTRIES = 512
|
|
||||||
SETTLED_CACHE_MAX_BYTES = 8 * 1024 * 1024
|
|
||||||
|
|
||||||
_settled_lock = threading.Lock()
|
|
||||||
# key -> (stored_at monotonic, compressed JSON)
|
|
||||||
_settled_chunks: "OrderedDict[Any, Tuple[float, bytes]]" = OrderedDict()
|
|
||||||
_settled_bytes = 0
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ESPN_MAX_LIMIT",
|
"ESPN_MAX_LIMIT",
|
||||||
"ESPN_CHUNK_WORKERS",
|
"ESPN_CHUNK_WORKERS",
|
||||||
"ESPN_MONTH_COVER_MIN_DAYS",
|
"ESPN_MONTH_COVER_MIN_DAYS",
|
||||||
"RANGE_RETRY_SECONDS",
|
"RANGE_RETRY_SECONDS",
|
||||||
"SETTLED_AFTER_DAYS",
|
|
||||||
"SETTLED_CHUNK_TTL_SECONDS",
|
|
||||||
"clamp_espn_limit",
|
"clamp_espn_limit",
|
||||||
"clear_settled_chunk_cache",
|
|
||||||
"parse_espn_date_range",
|
"parse_espn_date_range",
|
||||||
"espn_date_chunks",
|
"espn_date_chunks",
|
||||||
"espn_request_chunks",
|
"espn_request_chunks",
|
||||||
@@ -306,88 +260,6 @@ def _days_of_month(chunk: str) -> List[str]:
|
|||||||
return days
|
return days
|
||||||
|
|
||||||
|
|
||||||
def _utc_today() -> date:
|
|
||||||
return datetime.now(timezone.utc).date()
|
|
||||||
|
|
||||||
|
|
||||||
def _chunk_last_day(chunk: str) -> Optional[date]:
|
|
||||||
try:
|
|
||||||
if len(chunk) == 8:
|
|
||||||
return date(int(chunk[:4]), int(chunk[4:6]), int(chunk[6:]))
|
|
||||||
if len(chunk) == 6:
|
|
||||||
first = date(int(chunk[:4]), int(chunk[4:6]), 1)
|
|
||||||
return _first_of_next_month(first) - timedelta(days=1)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _settled_key(url: str, params: Dict[str, Any], chunk: str) -> Optional[Any]:
|
|
||||||
"""Memory key for a chunk that can no longer change, else None."""
|
|
||||||
last_day = _chunk_last_day(chunk)
|
|
||||||
if last_day is None:
|
|
||||||
return None
|
|
||||||
if last_day > _utc_today() - timedelta(days=SETTLED_AFTER_DAYS):
|
|
||||||
return None
|
|
||||||
# dates is the chunk itself and limit is always ESPN_MAX_LIMIT here;
|
|
||||||
# anything else (groups=80 for FBS, a team filter) changes the answer.
|
|
||||||
rest = tuple(sorted(
|
|
||||||
(str(k), str(v)) for k, v in params.items() if k not in ("dates", "limit")
|
|
||||||
))
|
|
||||||
return (url, rest, chunk)
|
|
||||||
|
|
||||||
|
|
||||||
def _settled_get(key: Any) -> Optional[Dict[str, Any]]:
|
|
||||||
with _settled_lock:
|
|
||||||
entry = _settled_chunks.get(key)
|
|
||||||
if entry is None:
|
|
||||||
return None
|
|
||||||
if time.monotonic() - entry[0] > SETTLED_CHUNK_TTL_SECONDS:
|
|
||||||
_settled_drop(key)
|
|
||||||
return None
|
|
||||||
_settled_chunks.move_to_end(key)
|
|
||||||
blob = entry[1]
|
|
||||||
# Decompress and parse outside the lock: every hit gets its own objects,
|
|
||||||
# so a caller mutating its payload cannot reach another caller's.
|
|
||||||
body = zlib.decompress(blob)
|
|
||||||
return cast(Dict[str, Any], orjson.loads(body) if orjson else json.loads(body))
|
|
||||||
|
|
||||||
|
|
||||||
def _settled_drop(key: Any) -> None:
|
|
||||||
"""Remove one entry. Caller holds _settled_lock."""
|
|
||||||
global _settled_bytes
|
|
||||||
entry = _settled_chunks.pop(key, None)
|
|
||||||
if entry is not None:
|
|
||||||
_settled_bytes -= len(entry[1])
|
|
||||||
|
|
||||||
|
|
||||||
def _settled_put(key: Any, response: Any, payload: Dict[str, Any]) -> None:
|
|
||||||
global _settled_bytes
|
|
||||||
body = getattr(response, "content", None)
|
|
||||||
if not isinstance(body, (bytes, bytearray)):
|
|
||||||
body = json.dumps(payload).encode("utf-8")
|
|
||||||
blob = zlib.compress(bytes(body), 6)
|
|
||||||
if len(blob) > SETTLED_CACHE_MAX_BYTES:
|
|
||||||
return
|
|
||||||
with _settled_lock:
|
|
||||||
_settled_drop(key)
|
|
||||||
_settled_chunks[key] = (time.monotonic(), blob)
|
|
||||||
_settled_bytes += len(blob)
|
|
||||||
while _settled_chunks and (
|
|
||||||
len(_settled_chunks) > SETTLED_CACHE_MAX_ENTRIES
|
|
||||||
or _settled_bytes > SETTLED_CACHE_MAX_BYTES
|
|
||||||
):
|
|
||||||
_settled_drop(next(iter(_settled_chunks)))
|
|
||||||
|
|
||||||
|
|
||||||
def clear_settled_chunk_cache() -> None:
|
|
||||||
"""Forget every remembered settled chunk (tests, or a manual refresh)."""
|
|
||||||
global _settled_bytes
|
|
||||||
with _settled_lock:
|
|
||||||
_settled_chunks.clear()
|
|
||||||
_settled_bytes = 0
|
|
||||||
|
|
||||||
|
|
||||||
def espn_date_chunks(start: date, end: date) -> List[str]:
|
def espn_date_chunks(start: date, end: date) -> List[str]:
|
||||||
"""Cover ``[start, end]`` inclusive with ``dates=`` values ESPN accepts.
|
"""Cover ``[start, end]`` inclusive with ``dates=`` values ESPN accepts.
|
||||||
|
|
||||||
@@ -519,10 +391,6 @@ def _fetch_one_chunk(
|
|||||||
One bad chunk must not sink the rest of the season, so every error is
|
One bad chunk must not sink the rest of the season, so every error is
|
||||||
logged and swallowed here rather than raised to the gather below.
|
logged and swallowed here rather than raised to the gather below.
|
||||||
|
|
||||||
A chunk whose days are settled (see ``SETTLED_AFTER_DAYS``) is answered
|
|
||||||
from memory when it was fetched in the last day. What is remembered is
|
|
||||||
the month as ESPN sent it, so each window still trims it to its own days.
|
|
||||||
|
|
||||||
A month that comes back at the cap is truncated: it returns ``_CAPPED``,
|
A month that comes back at the cap is truncated: it returns ``_CAPPED``,
|
||||||
its payload dropped here before it is ever held beside the others. A
|
its payload dropped here before it is ever held beside the others. A
|
||||||
month in ``trims`` loses its events outside the days given there.
|
month in ``trims`` loses its events outside the days given there.
|
||||||
@@ -530,34 +398,24 @@ def _fetch_one_chunk(
|
|||||||
The request holds one of the process-wide ``_chunk_slots`` while it runs.
|
The request holds one of the process-wide ``_chunk_slots`` while it runs.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
settled = _settled_key(url, params, chunk)
|
with _chunk_slots:
|
||||||
payload = _settled_get(settled) if settled is not None else None
|
response = fetch_get(
|
||||||
if payload is None:
|
session,
|
||||||
with _chunk_slots:
|
url,
|
||||||
response = fetch_get(
|
params=dict(params, dates=chunk, limit=ESPN_MAX_LIMIT),
|
||||||
session,
|
headers=headers,
|
||||||
url,
|
timeout=timeout,
|
||||||
params=dict(params, dates=chunk, limit=ESPN_MAX_LIMIT),
|
**_memo_kwargs(cache_max_age),
|
||||||
headers=headers,
|
)
|
||||||
timeout=timeout,
|
response.raise_for_status()
|
||||||
**_memo_kwargs(cache_max_age),
|
payload = response_json(response)
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
payload = response_json(response)
|
|
||||||
if len(chunk) == 6 and isinstance(payload, dict):
|
|
||||||
if len(payload.get("events") or []) >= ESPN_MAX_LIMIT:
|
|
||||||
return _CAPPED
|
|
||||||
if settled is not None and isinstance(payload, dict):
|
|
||||||
events = payload.get("events")
|
|
||||||
# A capped month never gets here; a day at the cap is
|
|
||||||
# truncated too, and remembering it would only cost memory.
|
|
||||||
if isinstance(events, list) and len(events) < ESPN_MAX_LIMIT:
|
|
||||||
_settled_put(settled, response, payload)
|
|
||||||
except Exception as exc: # noqa: BLE001 - see docstring
|
except Exception as exc: # noqa: BLE001 - see docstring
|
||||||
if logger:
|
if logger:
|
||||||
logger.warning("ESPN chunk %s failed, skipping it: %s", chunk, exc)
|
logger.warning("ESPN chunk %s failed, skipping it: %s", chunk, exc)
|
||||||
return None
|
return None
|
||||||
if len(chunk) == 6 and isinstance(payload, dict):
|
if len(chunk) == 6 and isinstance(payload, dict):
|
||||||
|
if len(payload.get("events") or []) >= ESPN_MAX_LIMIT:
|
||||||
|
return _CAPPED
|
||||||
trim = (trims or {}).get(chunk)
|
trim = (trims or {}).get(chunk)
|
||||||
if trim is not None:
|
if trim is not None:
|
||||||
payload = _trim_to_days(payload, *trim)
|
payload = _trim_to_days(payload, *trim)
|
||||||
@@ -947,8 +805,7 @@ def get_espn_scoreboard(
|
|||||||
split as ESPN needs -- caches the result under the canonical key and
|
split as ESPN needs -- caches the result under the canonical key and
|
||||||
returns it. ``max_age=0`` always fetches (and still caches, for other
|
returns it. ``max_age=0`` always fetches (and still caches, for other
|
||||||
readers). Errors raise exactly as :func:`fetch_espn_scoreboard` does,
|
readers). Errors raise exactly as :func:`fetch_espn_scoreboard` does,
|
||||||
and nothing is cached then; nor is an answer whose ``events`` is not a
|
and nothing is cached then. ``session=None`` uses the fetch service's
|
||||||
list (ESPN has sent ``"events": null``), which is returned as it came. ``session=None`` uses the fetch service's
|
|
||||||
pooled session for the ESPN host.
|
pooled session for the ESPN host.
|
||||||
"""
|
"""
|
||||||
key = espn_scoreboard_cache_key(sport, league, dates)
|
key = espn_scoreboard_cache_key(sport, league, dates)
|
||||||
@@ -970,7 +827,5 @@ def get_espn_scoreboard(
|
|||||||
# cache read above would have accepted.
|
# cache read above would have accepted.
|
||||||
cache_max_age=None if max_age is None else max(0.0, float(max_age)),
|
cache_max_age=None if max_age is None else max(0.0, float(max_age)),
|
||||||
)
|
)
|
||||||
# Every reader of the shared key would inherit a malformed answer.
|
store_espn_scoreboard_cache(cache_manager, key, data)
|
||||||
if isinstance(data, dict) and isinstance(data.get("events"), list):
|
|
||||||
store_espn_scoreboard_cache(cache_manager, key, data)
|
|
||||||
return data
|
return data
|
||||||
|
|||||||
@@ -1,200 +0,0 @@
|
|||||||
"""The live poll's scoreboard request and the odds requests (sports family 9).
|
|
||||||
|
|
||||||
The scoreboards' live fetch and odds code, reconciled in ledmatrix-plugins
|
|
||||||
(family 9) from seven and three bodies into one each, and copied here under
|
|
||||||
the existing names. All of it lives on the plugins' ``SportsCore``, so there
|
|
||||||
is one mixin, ``SportsLiveFetchMixin``:
|
|
||||||
|
|
||||||
- ``_fetch_todays_games()``: today's scoreboard for the live poll, with
|
|
||||||
yesterday's while it can still hold a live game (``_needs_previous_day``),
|
|
||||||
as ``{"events": [...]}``; None when ESPN cannot be reached.
|
|
||||||
- ``_fetch_odds(game)``: queue one game for an odds request; returns at once.
|
|
||||||
- ``_drain_odds_queue()``: the odds worker that answers the queue.
|
|
||||||
|
|
||||||
THE RULES (decided 2026-10-12)
|
|
||||||
------------------------------
|
|
||||||
Live scores are always fresh. Every poll asks ESPN (``get_espn_scoreboard``
|
|
||||||
with ``max_age=1``: only a copy another reader fetched within the last second,
|
|
||||||
the managers of one display tick, is reused) and leaves the answer under the
|
|
||||||
shared ``espn_scoreboard_cache_key`` for its dates, so odds-ticker and every
|
|
||||||
other reader of the same day share it. The plugins' old 30 s cache under
|
|
||||||
``<sport_key>_scoreboard_current`` is gone. College basketball is asked by
|
|
||||||
date like every other league; ESPN answered single dates for it on 2026-10-12
|
|
||||||
(the old no-dates request cited a 404 that no longer happens). An answer whose
|
|
||||||
``events`` is not a list counts as no games.
|
|
||||||
|
|
||||||
Odds never block ``update()``. ``_fetch_odds`` puts the game on the manager's
|
|
||||||
queue and starts one daemon worker if none is running; the worker asks the
|
|
||||||
odds manager for each queued game in turn and attaches the line to every
|
|
||||||
dict queued for that id, in place, under ``_games_lock`` (display() reads the
|
|
||||||
games under it), so a card can draw once without odds and gain them when
|
|
||||||
ESPN answers. One batch in flight per manager: a game queued while the worker
|
|
||||||
is busy joins its batch, so a slow endpoint never stacks threads or requests.
|
|
||||||
The odds manager bounds each request (core's ``BaseOddsManager``: 5 s, then a
|
|
||||||
60 s hold-off). A live game's line is cached ``live_odds_update_interval``
|
|
||||||
(60 s), any other ``odds_update_interval`` (3600 s). Without ``show_odds``, a
|
|
||||||
game id or an odds manager nothing is asked (the last logged at debug).
|
|
||||||
|
|
||||||
Before the reconcile, six plugins waited on a worker for 1.5 s (2 s for a
|
|
||||||
live game) per game and hockey and lacrosse asked on the calling thread: with
|
|
||||||
a 3 s endpoint, ``update()`` of four upcoming games took 6 s and 12 s, and in
|
|
||||||
the six the line appeared only at the next ``update()`` (an hour later for an
|
|
||||||
upcoming game).
|
|
||||||
|
|
||||||
A new module rather than more methods on ``sports_fetch``, for the reason
|
|
||||||
``sports_helpers`` gives: a missing module fails at load, where the version
|
|
||||||
checks see it; a missing method fails mid-update.
|
|
||||||
|
|
||||||
WHAT A HOST MUST PROVIDE
|
|
||||||
------------------------
|
|
||||||
Derived by walking every ``self.<attr>`` the mixin reads; the host-contract
|
|
||||||
test in ``test/test_sports_live_fetch.py`` fails if a read is added without
|
|
||||||
being listed here. Every scoreboard ``SportsCore`` supplies all of them.
|
|
||||||
|
|
||||||
- ``session``, ``headers``, ``cache_manager``, ``logger``, ``sport``,
|
|
||||||
``league`` -- the scoreboard request.
|
|
||||||
- ``_needs_previous_day`` -- from ``SportsFetchMixin``.
|
|
||||||
- ``show_odds``, ``mode_config`` (the two odds intervals), ``sport_key`` (the
|
|
||||||
worker's thread name), ``_games_lock`` (a lock display() also holds).
|
|
||||||
- ``odds_manager``, read with ``getattr`` -- ``get_odds(sport=, league=,
|
|
||||||
event_id=, update_interval_seconds=)``.
|
|
||||||
- ``_odds_pending`` and ``_odds_worker_busy`` -- the queue and whether a
|
|
||||||
worker is running; created here on first use, under ``_games_lock``.
|
|
||||||
|
|
||||||
The game dict's ``id`` and ``is_live`` are read; ``odds`` is written.
|
|
||||||
|
|
||||||
BASE ORDER
|
|
||||||
----------
|
|
||||||
No other mixin defines these names, so the position in the bases does not
|
|
||||||
change which body runs; a method on the plugin's own class still wins. The
|
|
||||||
mixin has no ``__init__``.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import threading
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
||||||
|
|
||||||
import pytz
|
|
||||||
import requests
|
|
||||||
|
|
||||||
from src.common.espn_dates import get_espn_scoreboard
|
|
||||||
|
|
||||||
|
|
||||||
class SportsLiveFetchMixin:
|
|
||||||
"""``SportsCore``'s live fetch and odds. See module docstring."""
|
|
||||||
|
|
||||||
# The host contract, declared for type checking only.
|
|
||||||
session: Any
|
|
||||||
headers: Dict[str, str]
|
|
||||||
cache_manager: Any
|
|
||||||
logger: Any
|
|
||||||
sport: str
|
|
||||||
league: str
|
|
||||||
sport_key: str
|
|
||||||
show_odds: bool
|
|
||||||
mode_config: Dict[str, Any]
|
|
||||||
_games_lock: Any
|
|
||||||
_needs_previous_day: Callable[[datetime], bool]
|
|
||||||
odds_manager: Any
|
|
||||||
_odds_pending: Dict[Any, Tuple[List[Dict], Any]]
|
|
||||||
_odds_worker_busy: bool
|
|
||||||
|
|
||||||
def _fetch_todays_games(self) -> Optional[Dict]:
|
|
||||||
"""Today's scoreboard for the live poll, with yesterday's while it can
|
|
||||||
still hold a live game (``_needs_previous_day``)."""
|
|
||||||
# ESPN anchors its calendar to Eastern US time.
|
|
||||||
now = datetime.now(pytz.timezone("America/New_York"))
|
|
||||||
today = now.strftime("%Y%m%d")
|
|
||||||
dates = (
|
|
||||||
f"{(now - timedelta(days=1)).strftime('%Y%m%d')}-{today}"
|
|
||||||
if self._needs_previous_day(now) else today
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
# Asked on every poll and left under core's shared key for this
|
|
||||||
# scoreboard and date, so odds-ticker and every other reader of
|
|
||||||
# the same day reuse it. max_age=1: a copy another reader fetched
|
|
||||||
# within the last second (the managers of one display tick) is the
|
|
||||||
# same answer; anything older is asked again.
|
|
||||||
data = get_espn_scoreboard(
|
|
||||||
self.session, self.sport, self.league, dates,
|
|
||||||
cache_manager=self.cache_manager, max_age=1,
|
|
||||||
headers=self.headers, timeout=10, logger=self.logger,
|
|
||||||
)
|
|
||||||
except requests.exceptions.RequestException as e:
|
|
||||||
self.logger.error(
|
|
||||||
f"API error fetching today's games for {self.sport} - {self.league}: {e}"
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
events = data.get("events") if isinstance(data, dict) else None
|
|
||||||
if not isinstance(events, list):
|
|
||||||
events = [] # ESPN can answer "events": null
|
|
||||||
self.logger.info(
|
|
||||||
f"Fetched {len(events)} games for {dates} for {self.sport} - {self.league}"
|
|
||||||
)
|
|
||||||
return {"events": events}
|
|
||||||
|
|
||||||
def _fetch_odds(self, game: Dict) -> None:
|
|
||||||
"""Queue ``game`` for an odds request; never waits for the answer.
|
|
||||||
|
|
||||||
One daemon worker per manager asks the odds manager for each queued
|
|
||||||
game in turn and attaches the line to the game dict in place, under
|
|
||||||
``_games_lock`` (display() reads the games under it), so a card can
|
|
||||||
draw once without odds and gain them when ESPN answers. A game queued
|
|
||||||
while the worker is busy joins its batch, and every dict queued for one
|
|
||||||
id (update() rebuilds them; the list on screen may keep older ones)
|
|
||||||
gets the one answer, so a slow endpoint never stacks threads or
|
|
||||||
requests. The odds manager bounds each request (5 s, then a 60 s
|
|
||||||
hold-off).
|
|
||||||
"""
|
|
||||||
if not self.show_odds or game.get("id") is None:
|
|
||||||
return
|
|
||||||
if not getattr(self, "odds_manager", None):
|
|
||||||
self.logger.debug(f"No odds manager; no odds for game {game['id']}")
|
|
||||||
return
|
|
||||||
interval = (
|
|
||||||
self.mode_config.get("live_odds_update_interval", 60)
|
|
||||||
if game.get("is_live", False)
|
|
||||||
else self.mode_config.get("odds_update_interval", 3600)
|
|
||||||
)
|
|
||||||
with self._games_lock:
|
|
||||||
pending = getattr(self, "_odds_pending", None)
|
|
||||||
if pending is None:
|
|
||||||
pending = self._odds_pending = {}
|
|
||||||
games, _ = pending.get(game["id"], ([], None))
|
|
||||||
if not any(queued is game for queued in games):
|
|
||||||
games.append(game)
|
|
||||||
pending[game["id"]] = (games, interval)
|
|
||||||
if getattr(self, "_odds_worker_busy", False):
|
|
||||||
return
|
|
||||||
self._odds_worker_busy = True
|
|
||||||
try:
|
|
||||||
threading.Thread(target=self._drain_odds_queue, daemon=True,
|
|
||||||
name=f"{self.sport_key}-odds").start()
|
|
||||||
except RuntimeError as e: # no thread to be had; try again next update
|
|
||||||
with self._games_lock:
|
|
||||||
self._odds_worker_busy = False
|
|
||||||
self.logger.debug(f"Could not start the odds worker: {e}")
|
|
||||||
|
|
||||||
def _drain_odds_queue(self) -> None:
|
|
||||||
"""The odds worker: ask for each queued game until none is left."""
|
|
||||||
while True:
|
|
||||||
with self._games_lock:
|
|
||||||
if not self._odds_pending:
|
|
||||||
self._odds_worker_busy = False
|
|
||||||
return
|
|
||||||
game_id = next(iter(self._odds_pending))
|
|
||||||
games, interval = self._odds_pending.pop(game_id)
|
|
||||||
try:
|
|
||||||
odds = self.odds_manager.get_odds(
|
|
||||||
sport=self.sport,
|
|
||||||
league=self.league,
|
|
||||||
event_id=game_id,
|
|
||||||
update_interval_seconds=interval,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.debug(f"Odds fetch failed for game {game_id}: {e}")
|
|
||||||
continue
|
|
||||||
if odds:
|
|
||||||
with self._games_lock:
|
|
||||||
for game in games:
|
|
||||||
game["odds"] = odds
|
|
||||||
@@ -1,418 +0,0 @@
|
|||||||
"""Which teams are ranked, and in which poll (sports family 8).
|
|
||||||
|
|
||||||
The scoreboards' rankings code, reconciled in ledmatrix-plugins (family 8)
|
|
||||||
from three, three and two bodies into one each, and copied here under the
|
|
||||||
existing names. All of it lives on the plugins' ``SportsCore``, so there is
|
|
||||||
one mixin, ``SportsRankingsMixin``:
|
|
||||||
|
|
||||||
- ``_fetch_team_rankings()``: the top-division poll from the data source,
|
|
||||||
hourly, as ``_team_rankings_cache`` ({abbreviation: rank}, what the rank
|
|
||||||
badge draws) and ``_ranked_team_ids`` ({ESPN team id: rank}). Only for a
|
|
||||||
league ``_league_has_rankings()`` says publishes a poll.
|
|
||||||
- ``_choose_poll(rankings_data)``: the first block ESPN lists that is a
|
|
||||||
top-division poll; seedings and the polls of lower divisions are stepped
|
|
||||||
over, ESPN's order is otherwise kept (so the CFP rankings take over from AP
|
|
||||||
when ESPN fronts them). ``choose_top_division_poll`` below is the same rule
|
|
||||||
for code with no scoreboard to hand (the dynamic team resolvers).
|
|
||||||
- ``_poll_season_ended(poll, now)``: whether the poll's season is over.
|
|
||||||
- ``_load_division_team_ids()``: team ids per college football division
|
|
||||||
(FBS, FCS) from ESPN's group rosters, for the season year.
|
|
||||||
- ``_ranked_positions(game)``, ``_is_ranked_game(game)``, ``_best_rank(game)``:
|
|
||||||
the poll positions either side holds, by team id first, abbreviation second.
|
|
||||||
- ``_passes_other_filters(game)``: whether a non-favourite game is worth a
|
|
||||||
slot under ``other_games_min_quality`` and ``other_games_divisions``.
|
|
||||||
|
|
||||||
THE RULES (decided 2026-10-12)
|
|
||||||
------------------------------
|
|
||||||
Ranks are kept by ESPN team id as well as by abbreviation, and a game with ids
|
|
||||||
is ranked by id: abbreviations are not unique (ESPN lists Ohio State and Ohio
|
|
||||||
State Newark both as OSU) and an id cannot rank the wrong school. The badge
|
|
||||||
still draws from the abbreviation table.
|
|
||||||
|
|
||||||
A poll whose season has ended loads nothing. ESPN keeps serving the last poll
|
|
||||||
it published until the next one (on 2026-10-12 college basketball's /rankings
|
|
||||||
was still April's final poll, college lacrosse's May's), and every block names
|
|
||||||
its season with an ``endDate``. Past it the table stays empty, so no badge
|
|
||||||
shows and the "ranked" filter fails open, until the new season's first poll
|
|
||||||
arrives. Read from the payload, not a calendar, so no league needs its own
|
|
||||||
season dates; a block without a readable ``endDate`` counts as current.
|
|
||||||
|
|
||||||
The division rosters are asked for the season year (July onward is this
|
|
||||||
year's season): college football's season runs into January, and the next
|
|
||||||
season's groups do not exist then. Only college football has division
|
|
||||||
rosters (``_DIVISION_GROUPS_BY_LEAGUE``), so this is football's rule as is,
|
|
||||||
not an override point.
|
|
||||||
|
|
||||||
Everything fails open: no poll, an empty roster or a failed request allows
|
|
||||||
every game rather than blanking the board.
|
|
||||||
|
|
||||||
The data source decides the endpoint (``data_source.fetch_standings``, still
|
|
||||||
per plugin); every scoreboard asks /rankings first for a college or NCAA
|
|
||||||
league. A standings table is never turned into ranks.
|
|
||||||
|
|
||||||
A new module rather than more methods on ``sports_shared``, for the reason
|
|
||||||
``sports_helpers`` gives: a missing module fails at load, where the version
|
|
||||||
checks see it; a missing method fails mid-update.
|
|
||||||
|
|
||||||
WHAT A HOST MUST PROVIDE
|
|
||||||
------------------------
|
|
||||||
Derived by walking every ``self.<attr>`` the mixin reads; the host-contract
|
|
||||||
test in ``test/test_sports_rankings.py`` fails if a read is added without being
|
|
||||||
listed here. Every scoreboard ``SportsCore`` supplies all of them.
|
|
||||||
|
|
||||||
- ``logger``, ``sport``, ``league``.
|
|
||||||
- ``data_source`` -- ``fetch_standings(sport, league)`` returns the payload
|
|
||||||
(``{}`` on failure).
|
|
||||||
- ``session`` -- ``get(url, params=..., timeout=...)`` for the division
|
|
||||||
rosters; ``cache_manager`` (may be ``None``) -- ``get(key)`` and
|
|
||||||
``set(key, value, ttl=...)`` keep them a day.
|
|
||||||
- ``_team_rankings_cache``, ``_ranked_team_ids`` and
|
|
||||||
``_rankings_cache_timestamp`` -- the tables and when they were fetched
|
|
||||||
(0: never). Written here. ``_rankings_cache_duration`` -- seconds between
|
|
||||||
fetches.
|
|
||||||
- ``_division_team_ids`` (``None``: never loaded) and ``_division_loaded_at``
|
|
||||||
-- the rosters and the monotonic time they were loaded. Written here.
|
|
||||||
- ``other_games_min_quality`` and ``other_games_divisions`` -- the filters.
|
|
||||||
- ``_league_has_rankings`` and ``_game_divisions`` -- from
|
|
||||||
``SportsCoreSharedMixin``.
|
|
||||||
- ``_rankings_loaded`` -- from ``SportsRotationMixin`` (football-scoreboard
|
|
||||||
overrides it to count the id table too).
|
|
||||||
|
|
||||||
The methods read the game dict's ``home_id``, ``away_id``, ``home_abbr`` and
|
|
||||||
``away_abbr``; any may be missing.
|
|
||||||
|
|
||||||
BASE ORDER
|
|
||||||
----------
|
|
||||||
No other mixin defines these names, so the position in the bases does not
|
|
||||||
change which body runs; a method on the plugin's own class still wins. The
|
|
||||||
mixin has no ``__init__``; the state it writes is the host's.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import re
|
|
||||||
import time
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import Any, Callable, ClassVar, Dict, List, Optional, Tuple
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class SportsRankingsMixin:
|
|
||||||
"""``SportsCore``'s rankings. See module docstring."""
|
|
||||||
|
|
||||||
# The host contract, declared for type checking only.
|
|
||||||
logger: Any
|
|
||||||
sport: str
|
|
||||||
league: str
|
|
||||||
data_source: Any
|
|
||||||
session: Any
|
|
||||||
cache_manager: Any
|
|
||||||
_team_rankings_cache: Dict[str, int]
|
|
||||||
_ranked_team_ids: Dict[int, int]
|
|
||||||
_rankings_cache_timestamp: float
|
|
||||||
_rankings_cache_duration: float
|
|
||||||
_division_team_ids: Optional[Dict[str, set]]
|
|
||||||
_division_loaded_at: float
|
|
||||||
other_games_min_quality: str
|
|
||||||
other_games_divisions: Any
|
|
||||||
_league_has_rankings: Callable[[], bool]
|
|
||||||
_game_divisions: Callable[[Dict], Optional[set]]
|
|
||||||
_rankings_loaded: Callable[[], bool]
|
|
||||||
|
|
||||||
# Which ranking block the badge reads. ESPN answers /rankings with more
|
|
||||||
# than one block for several leagues, and the FIRST is not always a poll:
|
|
||||||
# men's and women's college hockey front "NCAA Men's/Women's Hockey
|
|
||||||
# Tournament Seedings", and college lacrosse publishes seedings beside its
|
|
||||||
# Inside Lacrosse poll. College football fronts the AP Top 25 but also
|
|
||||||
# carries the FCS and Division II polls and gains the CFP rankings in
|
|
||||||
# November. An EXCLUDE list, not an allow list, so a poll ESPN invents
|
|
||||||
# still counts; type first and name second, because the Division II block
|
|
||||||
# types as "afca" while the FBS coaches poll -- also an AFCA poll -- types
|
|
||||||
# as "usa".
|
|
||||||
_NON_TOP_POLL_TYPES: ClassVar[frozenset] = frozenset({"tournament", "fcs"})
|
|
||||||
_NON_TOP_POLL_NAMES: ClassVar[Tuple[str, ...]] = (
|
|
||||||
"tournament", "seedings", "fcs",
|
|
||||||
"division ii", "division iii", "div ii", "div iii",
|
|
||||||
)
|
|
||||||
|
|
||||||
# ESPN group ids for the college football divisions, from its own group
|
|
||||||
# rosters (disjoint: 148 FBS team ids, 130 FCS). Keyed by league because
|
|
||||||
# no other league publishes usable rosters: college baseball and lacrosse
|
|
||||||
# answer HTTP 500, college basketball and hockey an empty list.
|
|
||||||
_DIVISION_GROUPS_BY_LEAGUE: ClassVar[Dict[str, Dict[str, int]]] = {
|
|
||||||
"college-football": {"fbs": 80, "fcs": 81},
|
|
||||||
}
|
|
||||||
_DIVISION_CACHE_TTL: ClassVar[int] = 24 * 60 * 60
|
|
||||||
# A lookup that came back empty is retried on this shorter clock.
|
|
||||||
_DIVISION_RETRY_SECONDS: ClassVar[int] = 10 * 60
|
|
||||||
|
|
||||||
def _choose_poll(self, rankings_data: Optional[List[Dict]]) -> Dict:
|
|
||||||
"""The first block ESPN lists that is an actual top-division poll.
|
|
||||||
|
|
||||||
ESPN's own order is otherwise kept, so whichever poll it fronts is the
|
|
||||||
one that drives the badge.
|
|
||||||
"""
|
|
||||||
for block in rankings_data or []:
|
|
||||||
name = str(block.get("name") or "").lower()
|
|
||||||
kind = str(block.get("type") or "").lower()
|
|
||||||
if kind in self._NON_TOP_POLL_TYPES or any(
|
|
||||||
marker in name for marker in self._NON_TOP_POLL_NAMES
|
|
||||||
):
|
|
||||||
self.logger.debug(
|
|
||||||
"%s: skipping %s -- not a top-division poll",
|
|
||||||
getattr(self, "league", "?"), block.get("name") or kind)
|
|
||||||
continue
|
|
||||||
return block
|
|
||||||
return {}
|
|
||||||
|
|
||||||
def _fetch_team_rankings(self) -> Dict[str, int]:
|
|
||||||
"""Fetch team rankings using the new architecture components."""
|
|
||||||
current_time = time.time()
|
|
||||||
|
|
||||||
# No poll, no fetch. _league_has_rankings already gated the
|
|
||||||
# quality-filter call site; the show_ranking call sites did not, so
|
|
||||||
# ticking "Show Ranking" on a league that publishes no poll sent two
|
|
||||||
# requests an hour to endpoints that cannot answer. Gating here rather
|
|
||||||
# than at each call site covers every caller at once and cannot drift
|
|
||||||
# apart again.
|
|
||||||
#
|
|
||||||
# It is also the setting's only honest answer on those leagues: the
|
|
||||||
# rank badge replaces the record, so an empty table meant "Show
|
|
||||||
# Ranking" quietly erased the records "Show Records" was drawing. The
|
|
||||||
# schema now hides the toggle wherever the league has no poll, but a
|
|
||||||
# config saved before that still carries it.
|
|
||||||
if not self._league_has_rankings():
|
|
||||||
return self._team_rankings_cache
|
|
||||||
|
|
||||||
# Check if we have cached rankings that are still valid
|
|
||||||
# Gate on when the last look happened, not on whether it found
|
|
||||||
# anything. Professional leagues publish no poll, so `rankings`
|
|
||||||
# comes back empty -- and an empty dict is falsy, so this guard
|
|
||||||
# never short-circuited and the standings endpoint was re-fetched
|
|
||||||
# on every update instead of hourly. Measured 2026-09-19 on an
|
|
||||||
# MLB-only rig: 453 standings requests in a day against the 24
|
|
||||||
# the one-hour duration intends. An empty result is a result.
|
|
||||||
if (
|
|
||||||
self._rankings_cache_timestamp
|
|
||||||
and current_time - self._rankings_cache_timestamp
|
|
||||||
< self._rankings_cache_duration
|
|
||||||
):
|
|
||||||
return self._team_rankings_cache
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = self.data_source.fetch_standings(self.sport, self.league)
|
|
||||||
|
|
||||||
rankings: Dict[str, int] = {}
|
|
||||||
ranked_ids: Dict[int, int] = {}
|
|
||||||
poll = self._choose_poll(data.get("rankings", []))
|
|
||||||
if self._poll_season_ended(poll, current_time):
|
|
||||||
self.logger.info(
|
|
||||||
"%s: %s is from a season that has ended; no rank badges "
|
|
||||||
"until the new season's first poll", self.league,
|
|
||||||
poll.get("name") or "the poll",
|
|
||||||
)
|
|
||||||
poll = {}
|
|
||||||
|
|
||||||
for team_data in poll.get("ranks", []):
|
|
||||||
team_info = team_data.get("team", {})
|
|
||||||
team_abbr = team_info.get("abbreviation", "")
|
|
||||||
# One malformed entry (a None or non-numeric "current") is
|
|
||||||
# skipped, not allowed to throw away the whole poll.
|
|
||||||
try:
|
|
||||||
current_rank = int(team_data.get("current"))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
continue
|
|
||||||
if current_rank <= 0:
|
|
||||||
continue
|
|
||||||
if team_abbr:
|
|
||||||
rankings[team_abbr] = current_rank
|
|
||||||
# Kept by id as well as by abbreviation. The badge draws the
|
|
||||||
# abbreviation, but abbreviations are not unique across
|
|
||||||
# divisions and the FBS and FCS schedules arrive in the SAME
|
|
||||||
# scoreboard payload -- ESPN has SDSU for San Diego State and
|
|
||||||
# SDST for South Dakota State today, and nothing promises it
|
|
||||||
# stays that way. An id cannot rank the wrong school.
|
|
||||||
try:
|
|
||||||
ranked_ids[int(team_info.get("id"))] = current_rank
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Cache the results
|
|
||||||
self._team_rankings_cache = rankings
|
|
||||||
self._ranked_team_ids = ranked_ids
|
|
||||||
self._rankings_cache_timestamp = current_time
|
|
||||||
|
|
||||||
if rankings:
|
|
||||||
self.logger.info(
|
|
||||||
"%s: %d ranked teams loaded from %s", self.league,
|
|
||||||
len(rankings), poll.get("name") or "an unnamed poll",
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.logger.debug("Fetched rankings for 0 teams")
|
|
||||||
return rankings
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Error fetching team rankings: {e}")
|
|
||||||
return {}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _poll_season_ended(poll: Dict, now: float) -> bool:
|
|
||||||
"""Is this poll from a season that is already over?
|
|
||||||
|
|
||||||
ESPN keeps serving the last poll it published until the next one
|
|
||||||
appears: in October, college basketball's /rankings is still April's
|
|
||||||
final poll and college lacrosse's is May's. Every block names its
|
|
||||||
season with an ``endDate``; once that has passed, the badge would rank
|
|
||||||
teams on a finished season, so the table stays empty until the new
|
|
||||||
season's first poll arrives. Asked of the data rather than a calendar,
|
|
||||||
so no league needs its own season dates. A block without a readable
|
|
||||||
``endDate`` counts as current, as before.
|
|
||||||
"""
|
|
||||||
season = poll.get("season")
|
|
||||||
end = str(season.get("endDate") or "") if isinstance(season, dict) else ""
|
|
||||||
try:
|
|
||||||
ended = datetime.strptime(end[:16], "%Y-%m-%dT%H:%M")
|
|
||||||
except ValueError:
|
|
||||||
return False
|
|
||||||
return now > ended.replace(tzinfo=timezone.utc).timestamp()
|
|
||||||
|
|
||||||
def _load_division_team_ids(self) -> Dict[str, set]:
|
|
||||||
"""Team ids per college division. Two requests a day, one league.
|
|
||||||
|
|
||||||
Returns empty sets on any failure -- the caller treats "unknown" as
|
|
||||||
"allowed", because a division lookup that fails must not blank the
|
|
||||||
board.
|
|
||||||
|
|
||||||
The in-memory copy expires like the stored one. Holding it for the life
|
|
||||||
of the process meant two things, both silent: a board that happened to
|
|
||||||
be offline for the first lookup had division filtering disabled until
|
|
||||||
someone restarted the service, which on a display running for weeks is
|
|
||||||
indefinitely; and a roster that changed between seasons was never
|
|
||||||
picked up. A failed lookup is retried sooner than a good one, so a
|
|
||||||
blip costs minutes rather than a day, without retrying per frame.
|
|
||||||
"""
|
|
||||||
now = time.monotonic()
|
|
||||||
if self._division_team_ids is not None:
|
|
||||||
resolved = any(self._division_team_ids.values())
|
|
||||||
age_limit = self._DIVISION_CACHE_TTL if resolved else self._DIVISION_RETRY_SECONDS
|
|
||||||
if now - self._division_loaded_at < age_limit:
|
|
||||||
return self._division_team_ids
|
|
||||||
self._division_team_ids = {}
|
|
||||||
self._division_loaded_at = now
|
|
||||||
groups = self._DIVISION_GROUPS_BY_LEAGUE.get((self.league or "").lower())
|
|
||||||
if not groups:
|
|
||||||
return self._division_team_ids # no divisions to speak of
|
|
||||||
for name, group in groups.items():
|
|
||||||
ids = set()
|
|
||||||
key = f"{self.league}_division_teams_{group}"
|
|
||||||
try:
|
|
||||||
cached = self.cache_manager.get(key) if self.cache_manager else None
|
|
||||||
if cached:
|
|
||||||
ids = {int(i) for i in cached}
|
|
||||||
else:
|
|
||||||
# The SEASON year, not the calendar year. College
|
|
||||||
# football's 2026 season runs into January 2027, and asking
|
|
||||||
# for season 2027 in January returns groups that do not
|
|
||||||
# exist yet: the roster comes back empty, division
|
|
||||||
# filtering fails open, and it does so through the bowls
|
|
||||||
# and the playoff -- the weeks anyone is watching.
|
|
||||||
today = datetime.now()
|
|
||||||
season = today.year if today.month >= 7 else today.year - 1
|
|
||||||
url = (
|
|
||||||
"https://sports.core.api.espn.com/v2/sports/"
|
|
||||||
f"{self.sport}/leagues/{self.league}/seasons/"
|
|
||||||
f"{season}/types/2/groups/{group}/teams"
|
|
||||||
)
|
|
||||||
resp = self.session.get(url, params={"limit": 300}, timeout=15)
|
|
||||||
resp.raise_for_status()
|
|
||||||
for item in resp.json().get("items", []):
|
|
||||||
found = re.search(r"/teams/(\d+)", item.get("$ref", ""))
|
|
||||||
if found:
|
|
||||||
ids.add(int(found.group(1)))
|
|
||||||
if ids and self.cache_manager:
|
|
||||||
self.cache_manager.set(
|
|
||||||
key, sorted(ids), ttl=self._DIVISION_CACHE_TTL
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
self.logger.warning(
|
|
||||||
"Could not resolve %s teams for %s (%s); division filtering "
|
|
||||||
"will allow everything", name, self.league, exc
|
|
||||||
)
|
|
||||||
self._division_team_ids[name] = ids
|
|
||||||
return self._division_team_ids
|
|
||||||
|
|
||||||
def _ranked_positions(self, game: Dict) -> List[int]:
|
|
||||||
"""Poll positions held by either side: by id, else by abbreviation.
|
|
||||||
|
|
||||||
The id table is authoritative whenever a side carries an id and a poll
|
|
||||||
has been parsed, because it is built from a top-division poll alone --
|
|
||||||
so an FCS side cannot appear in it however it is abbreviated, and an
|
|
||||||
abbreviation shared across divisions cannot promote the wrong school.
|
|
||||||
Decided per side: a side without a usable id falls back to its
|
|
||||||
abbreviation, without costing the other side its id.
|
|
||||||
"""
|
|
||||||
by_id = getattr(self, "_ranked_team_ids", None) or {}
|
|
||||||
by_abbr = getattr(self, "_team_rankings_cache", None) or {}
|
|
||||||
found = []
|
|
||||||
for side in ("home", "away"):
|
|
||||||
rank = None
|
|
||||||
if by_id:
|
|
||||||
try:
|
|
||||||
rank = by_id.get(int(game.get(f"{side}_id")), 0) # type: ignore[arg-type] # None: caught below
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
pass
|
|
||||||
if rank is None:
|
|
||||||
rank = by_abbr.get(game.get(f"{side}_abbr"), 0)
|
|
||||||
if rank:
|
|
||||||
found.append(rank)
|
|
||||||
return found
|
|
||||||
|
|
||||||
def _is_ranked_game(self, game: Dict) -> bool:
|
|
||||||
return bool(self._ranked_positions(game))
|
|
||||||
|
|
||||||
def _best_rank(self, game: Dict) -> int:
|
|
||||||
"""The better of the two sides' poll positions, or 99 if neither ranks."""
|
|
||||||
found = self._ranked_positions(game)
|
|
||||||
return min(found) if found else 99
|
|
||||||
|
|
||||||
def _passes_other_filters(self, game: Dict) -> bool:
|
|
||||||
"""Is this non-favourite game worth one of the remaining slots?
|
|
||||||
|
|
||||||
"ranked" means ranked in the TOP division's poll -- see _choose_poll.
|
|
||||||
An FCS side ranked in its own poll does not qualify the game it is
|
|
||||||
playing in, which for college football is most often an FCS school
|
|
||||||
visiting an unranked FBS one.
|
|
||||||
|
|
||||||
Every check fails OPEN. If rankings could not be fetched or the
|
|
||||||
division rosters did not resolve, the game is allowed: a board showing
|
|
||||||
filler is a poor board, but a board showing nothing is a broken one.
|
|
||||||
"""
|
|
||||||
if self.other_games_min_quality == "ranked":
|
|
||||||
if self._rankings_loaded() and not self._is_ranked_game(game):
|
|
||||||
return False
|
|
||||||
|
|
||||||
wanted = self.other_games_divisions
|
|
||||||
if wanted:
|
|
||||||
present = self._game_divisions(game)
|
|
||||||
if present is not None and not (present & set(wanted)):
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
class _PollChooser(SportsRankingsMixin):
|
|
||||||
"""Just enough host for ``_choose_poll``."""
|
|
||||||
|
|
||||||
def __init__(self, logger: Any, league: str) -> None:
|
|
||||||
self.logger = logger
|
|
||||||
self.league = league
|
|
||||||
|
|
||||||
|
|
||||||
def choose_top_division_poll(rankings_data: Optional[List[Dict]], logger: Any = None,
|
|
||||||
label: Optional[str] = None) -> Dict:
|
|
||||||
"""``SportsRankingsMixin._choose_poll`` for code without a scoreboard.
|
|
||||||
|
|
||||||
The one rule, for the dynamic team resolvers that expand ``AP_TOP_25`` and
|
|
||||||
the like, so the badge and the favourites they resolve read the same poll.
|
|
||||||
``label`` names the league in the debug line for a skipped block.
|
|
||||||
"""
|
|
||||||
return _PollChooser(logger or _LOGGER, label or "?")._choose_poll(rankings_data)
|
|
||||||
@@ -1,342 +0,0 @@
|
|||||||
"""Which non-favourite games a scoreboard shows, and when the slice moves (sports family 7).
|
|
||||||
|
|
||||||
The scoreboards' other-games rotation, reconciled in ledmatrix-plugins
|
|
||||||
(family 7) from two bodies each into one, and copied here under the existing
|
|
||||||
names. All of it lives on the plugins' ``SportsCore``, so there is one mixin,
|
|
||||||
``SportsRotationMixin``:
|
|
||||||
|
|
||||||
- ``_by_importance(games, newest_first)``: the non-favourite pool, best
|
|
||||||
matchup first and one game per team, when a poll has loaded; kickoff order
|
|
||||||
otherwise. ``SportsCoreSharedMixin._favorites_first`` asks it for both the
|
|
||||||
filtered and the unfiltered pool.
|
|
||||||
- ``_other_games_window(others, limit)``: the slice of a pool on screen now.
|
|
||||||
It advances by its own width every ``other_rotation_interval_seconds``
|
|
||||||
(catching up on intervals that passed unseen) and wraps.
|
|
||||||
``SportsCoreSharedMixin._compose_selection`` cuts with it.
|
|
||||||
- ``_advance_other_games_if_due`` and ``_rotate_other_games_on_display``:
|
|
||||||
the display path's re-cut between fetches. The plugins' ``display()`` calls
|
|
||||||
``_rotate_other_games_on_display`` before its dwell check; the card on
|
|
||||||
screen keeps its place if it survived the cut.
|
|
||||||
- ``_attach_odds_to_rotated_games``: odds for the games a rotation
|
|
||||||
brought in, on a daemon thread, when ``show_odds`` is on and there is an
|
|
||||||
odds manager.
|
|
||||||
- ``_rankings_loaded()``: the override point (below).
|
|
||||||
|
|
||||||
THE RULES
|
|
||||||
---------
|
|
||||||
``update()`` (through ``_favorites_first``) and ``display()`` (through
|
|
||||||
``_rotate_other_games_on_display``) both advance the window, so the advance
|
|
||||||
holds ``_games_lock`` (the plugins' RLock): interleaved without it, both saw
|
|
||||||
the interval elapse and each added a width, skipping a window nobody saw.
|
|
||||||
|
|
||||||
The display path's due-check looks at the pool ``_compose_selection`` will
|
|
||||||
actually cut from: the filtered others, or, when nothing survived at all (no
|
|
||||||
favourite to show either), the unfiltered fallback. Guessing the unfiltered
|
|
||||||
pool whenever the others were empty recomposed an identical list on every
|
|
||||||
frame while a favourite was playing; never looking at it left the fallback
|
|
||||||
moving only when ``update()`` ran.
|
|
||||||
|
|
||||||
Rotated-in games get odds by the same rule as the games ``update()`` picks:
|
|
||||||
``show_odds`` on (decided 2026-10-09, which brought ufc-scoreboard in line).
|
|
||||||
|
|
||||||
OVERRIDE POINT
|
|
||||||
--------------
|
|
||||||
``_rankings_loaded()`` -- did a poll load? ``_by_importance`` keeps kickoff
|
|
||||||
order when not. The default is ``_team_rankings_cache`` being non-empty.
|
|
||||||
football-scoreboard overrides it to count its rankings keyed by ESPN team id
|
|
||||||
(``_ranked_team_ids``) as well, the table its ``_best_rank`` reads first.
|
|
||||||
|
|
||||||
A new module rather than more methods on ``sports_shared``, for the reason
|
|
||||||
``sports_helpers`` gives: a missing module fails at load, where the version
|
|
||||||
checks see it; a missing method fails mid-update.
|
|
||||||
|
|
||||||
WHAT A HOST MUST PROVIDE
|
|
||||||
------------------------
|
|
||||||
Derived by walking every ``self.<attr>`` the mixin reads; the host-contract
|
|
||||||
test in ``test/test_sports_rotation.py`` fails if a read is added without being
|
|
||||||
listed here. Every scoreboard ``SportsCore`` supplies all of them.
|
|
||||||
|
|
||||||
- ``other_rotation_interval_seconds`` -- seconds per window; 0 pins it.
|
|
||||||
- ``_other_window_start`` and ``_other_window_rotated_at`` -- the window's
|
|
||||||
position and the monotonic time it last moved (0: never cut). Written here.
|
|
||||||
- ``_games_lock`` -- the re-entrant lock around ``games_list``.
|
|
||||||
- ``_selection_pools`` -- what ``_favorites_first`` settled (``favorites``,
|
|
||||||
``others``, ``unfiltered``, ``favorite_limit``, ``other_limit``); read with
|
|
||||||
getattr, so a manager that never built it does not rotate.
|
|
||||||
- ``_compose_selection`` -- from ``SportsCoreSharedMixin``.
|
|
||||||
- ``_best_rank`` -- a game's better poll position, 99 if neither side ranks
|
|
||||||
(family 8: ``src.common.sports_rankings``).
|
|
||||||
- ``_team_rankings_cache`` -- read with getattr by the default
|
|
||||||
``_rankings_loaded``.
|
|
||||||
- ``games_list``, ``current_game``, ``current_game_index``,
|
|
||||||
``last_game_switch`` -- the switch-mode state a rotation swaps.
|
|
||||||
- ``logger`` -- one INFO line per rotation.
|
|
||||||
- ``show_odds`` and ``odds_manager`` (read with getattr), ``mode_config``
|
|
||||||
(``odds_update_interval``), ``sport``, ``league`` and ``sport_key`` -- the
|
|
||||||
rotated-in odds fetch.
|
|
||||||
|
|
||||||
The methods read the game dict's ``id``, ``start_time_utc``, ``home_abbr``,
|
|
||||||
``away_abbr`` and ``odds``; any may be missing.
|
|
||||||
|
|
||||||
BASE ORDER
|
|
||||||
----------
|
|
||||||
No other mixin defines these methods, so the position in the bases does not
|
|
||||||
change which body runs; a method on the plugin's own class (football's
|
|
||||||
``_rankings_loaded``) still wins. The mixin has no ``__init__``; the state it
|
|
||||||
writes is the host's.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import Any, Callable, Dict, List, Optional
|
|
||||||
|
|
||||||
|
|
||||||
class SportsRotationMixin:
|
|
||||||
"""``SportsCore``'s other-games rotation. See module docstring."""
|
|
||||||
|
|
||||||
# The host contract, declared for type checking only.
|
|
||||||
other_rotation_interval_seconds: int
|
|
||||||
_other_window_start: int
|
|
||||||
_other_window_rotated_at: float
|
|
||||||
_games_lock: Any
|
|
||||||
_compose_selection: Callable[[], List[Dict]]
|
|
||||||
_best_rank: Callable[[Dict], int]
|
|
||||||
games_list: List[Dict]
|
|
||||||
current_game: Optional[Dict]
|
|
||||||
current_game_index: int
|
|
||||||
last_game_switch: float
|
|
||||||
logger: Any
|
|
||||||
odds_manager: Any
|
|
||||||
mode_config: Dict[str, Any]
|
|
||||||
sport: str
|
|
||||||
league: str
|
|
||||||
sport_key: str
|
|
||||||
|
|
||||||
def _rankings_loaded(self) -> bool:
|
|
||||||
"""Did a poll load at all? The ranking reads fail open when not.
|
|
||||||
|
|
||||||
The seam ``_by_importance`` asks before ordering by rank: by default
|
|
||||||
the abbreviation table (``_team_rankings_cache``). football-scoreboard
|
|
||||||
overrides it to count its table keyed by ESPN team id as well.
|
|
||||||
"""
|
|
||||||
return bool(getattr(self, "_team_rankings_cache", None))
|
|
||||||
|
|
||||||
def _by_importance(self, games: List[Dict], newest_first: bool = False) -> List[Dict]:
|
|
||||||
"""Non-favourite games, best matchup first.
|
|
||||||
|
|
||||||
The quality filter already declares the poll to be the thing worth
|
|
||||||
showing -- and then selection ignored the number entirely. #1 against #2
|
|
||||||
and #25 against an unranked side were interchangeable, and whichever
|
|
||||||
kicked off sooner took the slot, so the biggest game of the week had no
|
|
||||||
better chance of being seen than any other.
|
|
||||||
|
|
||||||
The rotation still walks the entire pool, so nothing is lost and
|
|
||||||
coverage is unchanged; it now walks DOWN the ladder instead of along the
|
|
||||||
clock. The first window after a restart holds the best games available
|
|
||||||
rather than the earliest ones, which is the case that matters -- a board
|
|
||||||
is far more often freshly started or freshly updated than three hours
|
|
||||||
into a lap.
|
|
||||||
|
|
||||||
Ties fall back to kickoff order, and a league with no poll keeps the
|
|
||||||
chronological order it had, because there is nothing to sort on.
|
|
||||||
|
|
||||||
One game per team, which is the part rank ordering cannot do without.
|
|
||||||
The upcoming pool is not a week of fixtures -- for college football it
|
|
||||||
is the whole season, 947 games on a real board -- so ordering by rank
|
|
||||||
alone put all twelve of the #1 team's games above the #2 team's first
|
|
||||||
one, and the board walked one team's season. Measured on ledpi the
|
|
||||||
moment this shipped: KENT@OSU, ILL@OSU, then OSU@IOWA, MD@OSU. Keeping
|
|
||||||
only the soonest game per team makes the pool "what each team has
|
|
||||||
next", which is both what an upcoming board means and inherently
|
|
||||||
near-term, since a team's next game is by definition the closest one.
|
|
||||||
"""
|
|
||||||
if not self._rankings_loaded():
|
|
||||||
return games
|
|
||||||
if newest_first:
|
|
||||||
def key(game):
|
|
||||||
when = game.get("start_time_utc") or datetime.min.replace(tzinfo=timezone.utc)
|
|
||||||
return (self._best_rank(game), -when.timestamp())
|
|
||||||
else:
|
|
||||||
def key(game):
|
|
||||||
when = game.get("start_time_utc") or datetime.max.replace(tzinfo=timezone.utc)
|
|
||||||
return (self._best_rank(game), when.timestamp())
|
|
||||||
|
|
||||||
# Soonest-first so "one per team" keeps each team's NEXT game, then
|
|
||||||
# re-ordered by rank. Doing it the other way round would keep whichever
|
|
||||||
# of a team's games happened to sort first by rank, which for a game
|
|
||||||
# between two ranked sides is not necessarily the next one.
|
|
||||||
soonest_first = sorted(
|
|
||||||
games,
|
|
||||||
key=lambda g: (g.get("start_time_utc")
|
|
||||||
or datetime.max.replace(tzinfo=timezone.utc)).timestamp(),
|
|
||||||
reverse=newest_first,
|
|
||||||
)
|
|
||||||
seen, once_each = set(), [] # type: ignore[var-annotated]
|
|
||||||
for game in soonest_first:
|
|
||||||
sides = (game.get("home_abbr"), game.get("away_abbr"))
|
|
||||||
if any(side in seen for side in sides):
|
|
||||||
continue
|
|
||||||
seen.update(s for s in sides if s)
|
|
||||||
once_each.append(game)
|
|
||||||
return sorted(once_each, key=key)
|
|
||||||
|
|
||||||
def _other_games_window(self, others: List[Dict], limit: int) -> List[Dict]:
|
|
||||||
"""A rotating slice of the non-favourite games.
|
|
||||||
|
|
||||||
The window advances by its own width, so consecutive windows are
|
|
||||||
disjoint and the board walks the schedule rather than resampling the
|
|
||||||
same front of it. It wraps, so a short list still cycles.
|
|
||||||
|
|
||||||
Advancing is time-based, not per-update. update() runs every 30s; if
|
|
||||||
the window moved with it the games list would change identity on every
|
|
||||||
pass, reset the display index, and no card past the first would ever be
|
|
||||||
reached.
|
|
||||||
"""
|
|
||||||
if limit <= 0 or not others:
|
|
||||||
return []
|
|
||||||
if len(others) <= limit:
|
|
||||||
return others[:limit]
|
|
||||||
|
|
||||||
interval = self.other_rotation_interval_seconds
|
|
||||||
# Under the lock: update() advances this window through
|
|
||||||
# _favorites_first, and display() advances it through
|
|
||||||
# _rotate_other_games_on_display, so the read-modify-write below has two
|
|
||||||
# writers. Interleaved, both can see the interval elapsed and each add a
|
|
||||||
# width, skipping a window of games nobody ever sees. _games_lock is an
|
|
||||||
# RLock and the display path takes it again straight after, which is
|
|
||||||
# why this can be the same lock rather than another one to reason about.
|
|
||||||
with self._games_lock:
|
|
||||||
if interval > 0:
|
|
||||||
now = time.monotonic()
|
|
||||||
if not self._other_window_rotated_at:
|
|
||||||
self._other_window_rotated_at = now
|
|
||||||
elapsed = now - self._other_window_rotated_at
|
|
||||||
if elapsed >= interval:
|
|
||||||
# Advance by however many intervals actually passed. The
|
|
||||||
# board is not guaranteed to be running -- or this mode
|
|
||||||
# displayed -- for every one of them, and stepping once
|
|
||||||
# would let a plugin that sat idle crawl a step at a time.
|
|
||||||
steps = int(elapsed // interval)
|
|
||||||
self._other_window_start += steps * limit
|
|
||||||
self._other_window_rotated_at = now
|
|
||||||
|
|
||||||
start = self._other_window_start % len(others)
|
|
||||||
window = others[start:start + limit]
|
|
||||||
if len(window) < limit:
|
|
||||||
window += others[:limit - len(window)]
|
|
||||||
return window
|
|
||||||
|
|
||||||
def _rotate_other_games_on_display(self) -> bool:
|
|
||||||
"""Swap in a freshly cut slice when the rotation interval has passed.
|
|
||||||
|
|
||||||
Returns True when the list changed, so the caller forces a redraw.
|
|
||||||
|
|
||||||
The card currently on screen keeps its place if it survived the cut:
|
|
||||||
rotating the pool should change what comes NEXT, not interrupt whatever
|
|
||||||
someone is reading. Only when it is gone does the index reset, and then
|
|
||||||
the dwell resets with it so the replacement gets a full turn rather than
|
|
||||||
the tail of its predecessor's.
|
|
||||||
"""
|
|
||||||
rebuilt = self._advance_other_games_if_due()
|
|
||||||
if not rebuilt:
|
|
||||||
return False
|
|
||||||
with self._games_lock:
|
|
||||||
if [g.get("id") for g in rebuilt] == [g.get("id") for g in self.games_list]:
|
|
||||||
return False
|
|
||||||
current_id = (self.current_game or {}).get("id")
|
|
||||||
self.games_list = rebuilt
|
|
||||||
for index, game in enumerate(rebuilt):
|
|
||||||
if game.get("id") == current_id:
|
|
||||||
self.current_game_index = index
|
|
||||||
self.current_game = game
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
self.current_game_index = 0
|
|
||||||
self.current_game = rebuilt[0]
|
|
||||||
self.last_game_switch = time.time()
|
|
||||||
self.logger.info(
|
|
||||||
"Rotated the other-games slice to: %s",
|
|
||||||
", ".join("%s@%s" % (g.get("away_abbr"), g.get("home_abbr"))
|
|
||||||
for g in rebuilt),
|
|
||||||
)
|
|
||||||
self._attach_odds_to_rotated_games(rebuilt)
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _attach_odds_to_rotated_games(self, games: List[Dict]) -> None:
|
|
||||||
"""Fetch odds for freshly rotated-in games off the display path.
|
|
||||||
|
|
||||||
The rotation deliberately does no network work, but odds are only
|
|
||||||
attached in update(), and for an upcoming list that runs hourly --
|
|
||||||
far longer than any rotated-in card stays on screen. Every slice cut
|
|
||||||
between updates therefore rendered without a line even though ESPN
|
|
||||||
had one, while the favourites, which survive every cut, kept the
|
|
||||||
odds update() gave them.
|
|
||||||
|
|
||||||
One daemon thread per rotation, bounded by the slice size rather
|
|
||||||
than the pool's: only games actually going on screen are asked
|
|
||||||
about, and get_odds caches per game, so one re-entering the window
|
|
||||||
inside its TTL costs a cache lookup rather than a request. The
|
|
||||||
thread mutates each game dict in place; the renderer re-reads
|
|
||||||
game["odds"] every frame, so a line appears as soon as its fetch
|
|
||||||
lands, mid-dwell included. Same as football-scoreboard #343.
|
|
||||||
"""
|
|
||||||
# getattr: managers are built partially in places (the plugin tests
|
|
||||||
# among them) that never set show_odds or an odds manager.
|
|
||||||
if not getattr(self, "show_odds", False) or not getattr(self, "odds_manager", None):
|
|
||||||
return
|
|
||||||
pending = [g for g in games if not g.get("odds")]
|
|
||||||
if not pending:
|
|
||||||
return
|
|
||||||
interval = self.mode_config.get("odds_update_interval", 3600)
|
|
||||||
|
|
||||||
def fetch() -> None:
|
|
||||||
for game in pending:
|
|
||||||
try:
|
|
||||||
odds = self.odds_manager.get_odds(
|
|
||||||
sport=self.sport,
|
|
||||||
league=self.league,
|
|
||||||
event_id=game["id"],
|
|
||||||
update_interval_seconds=interval,
|
|
||||||
)
|
|
||||||
if odds:
|
|
||||||
game["odds"] = odds
|
|
||||||
except Exception as exc:
|
|
||||||
self.logger.debug(
|
|
||||||
"Odds fetch for rotated-in game %s failed: %s",
|
|
||||||
game.get("id"), exc)
|
|
||||||
|
|
||||||
threading.Thread(
|
|
||||||
target=fetch, daemon=True,
|
|
||||||
name="%s-rotated-odds" % self.sport_key).start()
|
|
||||||
|
|
||||||
def _advance_other_games_if_due(self) -> List[Dict]:
|
|
||||||
"""Re-cut the non-favourite slice on the display path, or [] if not due.
|
|
||||||
|
|
||||||
Costs one list slice and a sort of at most a few games -- no fetch, no
|
|
||||||
parsing, no network. Returns the new list rather than assigning it,
|
|
||||||
because the two callers keep different bookkeeping around games_list
|
|
||||||
and both hold their own lock while they swap it in.
|
|
||||||
"""
|
|
||||||
pools = getattr(self, "_selection_pools", None)
|
|
||||||
if not pools:
|
|
||||||
return []
|
|
||||||
interval = self.other_rotation_interval_seconds
|
|
||||||
limit = max(0, pools["other_limit"])
|
|
||||||
# Whichever pool _compose_selection will actually slice. It falls back
|
|
||||||
# to the unfiltered list only when NOTHING survived -- favourites
|
|
||||||
# included. With a favourite playing and the filters rejecting every
|
|
||||||
# other game, compose keeps the favourites-only list, so guessing the
|
|
||||||
# unfiltered pool here made the due-check fire on every display() call
|
|
||||||
# forever, recomposing an identical list each frame.
|
|
||||||
others = pools["others"]
|
|
||||||
favorites_fill = pools["favorites"] and pools["favorite_limit"] > 0
|
|
||||||
if not others and limit > 0 and not favorites_fill:
|
|
||||||
others = pools["unfiltered"]
|
|
||||||
if interval <= 0 or limit <= 0 or len(others) <= limit:
|
|
||||||
return [] # pinned, favourites-only, or nothing to rotate through
|
|
||||||
if not self._other_window_rotated_at:
|
|
||||||
return [] # no window has been cut yet; update() does the first
|
|
||||||
if time.monotonic() - self._other_window_rotated_at < interval:
|
|
||||||
return []
|
|
||||||
return self._compose_selection()
|
|
||||||
@@ -54,16 +54,12 @@ State: ``config``, ``fonts``, ``logger``, ``display_width``, ``display_height``,
|
|||||||
|
|
||||||
Methods that stay per-plugin, because they are not identical across the eight
|
Methods that stay per-plugin, because they are not identical across the eight
|
||||||
(or, for ``_get_timezone``, because they bind per-plugin modules):
|
(or, for ``_get_timezone``, because they bind per-plugin modules):
|
||||||
``_get_layout_offset``, ``_upcoming_date_and_time_text``,
|
``_get_layout_offset``, ``_by_importance``, ``_other_games_window``,
|
||||||
``_extract_game_details_common``, ``_get_timezone``. Four families were
|
``_upcoming_date_and_time_text``, ``_extract_game_details_common``,
|
||||||
here until the plugins reconciled them: ``_is_game_really_over``, which
|
``_load_division_team_ids``, ``_get_timezone``, ``_is_favorite_game``,
|
||||||
``_detect_stale_games`` below calls, is now ``src.common.sports_game_over``;
|
``_is_ranked_game``, ``_passes_other_filters``. (``_is_game_really_over``,
|
||||||
``_is_favorite_game`` is ``src.common.sports_favorites``;
|
which ``_detect_stale_games`` below calls, was here too until the plugins
|
||||||
``_by_importance`` and ``_other_games_window``, which ``_favorites_first`` and
|
reconciled it; it is now ``src.common.sports_game_over``.)
|
||||||
``_compose_selection`` below call, are ``src.common.sports_rotation``; and
|
|
||||||
``_load_division_team_ids``, ``_is_ranked_game`` and ``_passes_other_filters``
|
|
||||||
(``_game_divisions``, ``_check_ranking_coverage`` and ``_favorites_first``
|
|
||||||
below call them) are ``src.common.sports_rankings``.
|
|
||||||
|
|
||||||
Of the fourteen shared class constants, thirteen are identical everywhere and
|
Of the fourteen shared class constants, thirteen are identical everywhere and
|
||||||
live here. Only ``_SCORE_PROBE_TEXT`` varies -- afl and basketball reach three digits
|
live here. Only ``_SCORE_PROBE_TEXT`` varies -- afl and basketball reach three digits
|
||||||
|
|||||||
@@ -1,336 +0,0 @@
|
|||||||
"""The game dict every scoreboard screen draws (sports family 10).
|
|
||||||
|
|
||||||
The scoreboards' ``_extract_game_details_common``, reconciled in
|
|
||||||
ledmatrix-plugins (family 10) from nine bodies into one, and copied here
|
|
||||||
under the existing names with the helpers it calls. It lives on the plugins'
|
|
||||||
``SportsCore``, so there is one mixin, ``SportsViewModelMixin``:
|
|
||||||
|
|
||||||
- ``_extract_game_details_common(game_event)``: one ESPN scoreboard event to
|
|
||||||
``(details, home competitor, away competitor, status, situation)``, or five
|
|
||||||
Nones when the event has no competition, status, date or home/away pair.
|
|
||||||
Each sport's ``_extract_game_details`` adds its extras to ``details``.
|
|
||||||
- ``_status_is_final(status_type)``: whether ``status.type`` is a result.
|
|
||||||
- ``_score_text(score)``: a competitor's score as integer text.
|
|
||||||
- ``_team_logo_url(competitor)``, ``_extract_team_record(competitor)``.
|
|
||||||
- ``_logo_filename(abbr, team_id)``: the seam below.
|
|
||||||
|
|
||||||
THE KEYS
|
|
||||||
--------
|
|
||||||
Additive only: a key is never renamed or dropped, because every renderer
|
|
||||||
(``sports.py``'s scorebug, ``game_renderer.py``'s card, ``sports_card``,
|
|
||||||
``sports_game_renderer``, ``sports_shared``, ``sports_game_over``,
|
|
||||||
``sports_vegas``...) reads them. A sport's extras (down and distance, innings,
|
|
||||||
shots, tournament seeds...) belong in its own ``_extract_game_details``.
|
|
||||||
|
|
||||||
================== ================== ==========================================
|
|
||||||
key type value (default)
|
|
||||||
================== ================== ==========================================
|
|
||||||
id str | None the event id
|
|
||||||
game_time str local start, "7:00PM" ("" without a date)
|
|
||||||
game_date str local "MM/DD"; "M/D" with
|
|
||||||
``display.use_short_date_format`` ("")
|
|
||||||
start_time_utc datetime | None aware, UTC; a time without an offset
|
|
||||||
is read as UTC (None if unparseable)
|
|
||||||
status_text str ESPN ``status.type.shortDetail``
|
|
||||||
is_live bool state "in", not a called-off status
|
|
||||||
is_final bool ``_status_is_final(status.type)``
|
|
||||||
is_upcoming bool state "pre", or a scheduled status name
|
|
||||||
is_halftime bool state "halftime" or STATUS_HALFTIME
|
|
||||||
is_period_break bool STATUS_END_PERIOD
|
|
||||||
broadcast str ``str()`` of ESPN's field (""); unread
|
|
||||||
home_abbr/away_abbr str abbreviation, else the name's first 3
|
|
||||||
home_id/away_id str | None competitor id, else its team's id
|
|
||||||
home_score/ str ``_score_text``: integer text ("0")
|
|
||||||
away_score
|
|
||||||
home_logo_path/ Path ``logo_dir / _logo_filename(abbr, id)``
|
|
||||||
away_logo_path
|
|
||||||
home_logo_url/ str | None ``_team_logo_url``: ``team.logo``, else
|
|
||||||
away_logo_url ``team.logos[0].href`` (None)
|
|
||||||
home_record/ str ``_extract_team_record``; "0-0" and
|
|
||||||
away_record "0-0-0" read as "" ("")
|
|
||||||
is_within_window bool always True; unread
|
|
||||||
favorite_teams list the resolved favourites, for the card's
|
|
||||||
result colour ([])
|
|
||||||
period int 0 until the sport sets it
|
|
||||||
clock str "" until the sport sets it
|
|
||||||
================== ================== ==========================================
|
|
||||||
|
|
||||||
``period_text`` is not set here: each sport's extractor sets it, and the
|
|
||||||
renderers read its absence (baseball's) as "draw ESPN's status text", which
|
|
||||||
is how an extra-inning final reads "Final/10".
|
|
||||||
|
|
||||||
THE RULES (decided 2026-10-13)
|
|
||||||
------------------------------
|
|
||||||
A game is final when its state is "post", ESPN has not marked it
|
|
||||||
``completed: false`` (a payload without the flag is judged on state and name
|
|
||||||
alone), and its status is not one of ``_NOT_PLAYED_STATUS_NAMES``: postponed,
|
|
||||||
canceled, suspended, abandoned (``_CALLED_OFF_STATUS_NAMES``), delayed, or a
|
|
||||||
pending forfeit or no-contest. A forfeit is a result. A called-off game is
|
|
||||||
never live, even while ESPN still reports it "in". Scores are integer text in
|
|
||||||
every sport: 21, "21.0" and {"value": 21} are "21"; a missing, empty or
|
|
||||||
non-numeric score is "0". Halftime reads "HALF" with no clock in every
|
|
||||||
sport's ``period_text`` (each sport's own extractor, not this module).
|
|
||||||
|
|
||||||
THE SEAM
|
|
||||||
--------
|
|
||||||
``_logo_filename(abbr, team_id)``: the file under ``logo_dir`` a team's logo
|
|
||||||
is kept in. Default ``<ABBR>.png`` (``LogoDownloader.normalize_abbreviation``);
|
|
||||||
NRL overrides it with ``<ABBR>_<id>.png`` because its abbreviations are not
|
|
||||||
unique ("NEW" is two clubs).
|
|
||||||
|
|
||||||
A new module rather than more methods on ``sports_fetch``, for the reason
|
|
||||||
``sports_helpers`` gives: a missing module fails at load, where the version
|
|
||||||
checks see it; a missing method fails mid-update.
|
|
||||||
|
|
||||||
WHAT A HOST MUST PROVIDE
|
|
||||||
------------------------
|
|
||||||
Derived by walking every ``self.<attr>`` the mixin reads; the host-contract
|
|
||||||
test in ``test/test_sports_view_model.py`` fails if a read is added without
|
|
||||||
being listed here. Every scoreboard ``SportsCore`` supplies all of them.
|
|
||||||
|
|
||||||
- ``logger``, ``config`` (``display.use_short_date_format``), ``logo_dir``
|
|
||||||
(a ``Path``), ``favorite_teams`` (a list or None), ``_get_timezone``
|
|
||||||
(a callable returning the zone game times are shown in).
|
|
||||||
|
|
||||||
BASE ORDER
|
|
||||||
----------
|
|
||||||
No other mixin defines these names, so the position in the bases does not
|
|
||||||
change which body runs; a method on the plugin's own class still wins. The
|
|
||||||
mixin has no ``__init__``.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import re
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Callable, ClassVar, Dict, List, Optional, Tuple
|
|
||||||
|
|
||||||
import pytz
|
|
||||||
|
|
||||||
from src.logo_downloader import LogoDownloader
|
|
||||||
|
|
||||||
|
|
||||||
class SportsViewModelMixin:
|
|
||||||
"""``SportsCore``'s game dict. See module docstring."""
|
|
||||||
|
|
||||||
# The host contract, declared for type checking only.
|
|
||||||
logger: Any
|
|
||||||
config: Dict[str, Any]
|
|
||||||
logo_dir: Path
|
|
||||||
favorite_teams: Optional[List[str]]
|
|
||||||
_get_timezone: Callable[[], Any]
|
|
||||||
|
|
||||||
#: ESPN status names for a game called off: never live, never a result.
|
|
||||||
#: ESPN files a postponed or washed-out game under state "post" with both
|
|
||||||
#: scores "0" (so "post" alone drew it on Recent as "Final 0-0"), and a
|
|
||||||
#: suspended one can still read "in".
|
|
||||||
_CALLED_OFF_STATUS_NAMES: ClassVar[frozenset] = frozenset({
|
|
||||||
"STATUS_POSTPONED", "STATUS_CANCELED", "STATUS_CANCELLED",
|
|
||||||
"STATUS_SUSPENDED", "STATUS_ABANDONED",
|
|
||||||
})
|
|
||||||
#: Status names under state "post" that are not a result. A forfeit
|
|
||||||
#: (STATUS_FORFEIT) is one; a pending forfeit or no-contest is not.
|
|
||||||
_NOT_PLAYED_STATUS_NAMES: ClassVar[frozenset] = _CALLED_OFF_STATUS_NAMES | frozenset({
|
|
||||||
"STATUS_DELAYED", "STATUS_FORFEIT_PENDING", "STATUS_NO_CONTEST_PENDING",
|
|
||||||
})
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _status_is_final(cls, status_type: Any) -> bool:
|
|
||||||
"""Whether an ESPN ``status.type`` is a game played to a result.
|
|
||||||
|
|
||||||
State "post", a ``completed`` flag that is not False, and a name
|
|
||||||
outside ``_NOT_PLAYED_STATUS_NAMES``. A payload without the flag is
|
|
||||||
judged on state and name alone; ESPN sends False for a postponement.
|
|
||||||
"""
|
|
||||||
if not isinstance(status_type, dict) or status_type.get("state") != "post":
|
|
||||||
return False
|
|
||||||
if status_type.get("completed") is False:
|
|
||||||
return False
|
|
||||||
return str(status_type.get("name") or "").upper() not in cls._NOT_PLAYED_STATUS_NAMES
|
|
||||||
|
|
||||||
def _score_text(self, score: Any) -> str:
|
|
||||||
"""A competitor's score as integer text: "21" for 21, "21.0" or
|
|
||||||
{"value": 21.0}; "0" when it is missing or holds no number."""
|
|
||||||
if isinstance(score, dict):
|
|
||||||
value = score.get("value", 0)
|
|
||||||
score = value if value != 0 else score.get("displayValue", score.get("score", 0))
|
|
||||||
if isinstance(score, str):
|
|
||||||
text = score.strip()
|
|
||||||
try:
|
|
||||||
score = float(text)
|
|
||||||
except ValueError:
|
|
||||||
digits = re.findall(r"\d+", text)
|
|
||||||
if not digits:
|
|
||||||
if text:
|
|
||||||
self.logger.warning(f"Could not extract score from string: {text!r}")
|
|
||||||
return "0"
|
|
||||||
score = float(digits[0])
|
|
||||||
if score is None:
|
|
||||||
return "0"
|
|
||||||
try:
|
|
||||||
return str(int(float(score)))
|
|
||||||
except (TypeError, ValueError, OverflowError) as e:
|
|
||||||
self.logger.warning(f"Error extracting score: {e}, score value: {score!r}")
|
|
||||||
return "0"
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _team_logo_url(competitor: Dict) -> Optional[str]:
|
|
||||||
"""The team's ``logo``, else the first of its ``logos``, else None."""
|
|
||||||
team = competitor.get("team") or {}
|
|
||||||
logos = team.get("logos") or []
|
|
||||||
first = logos[0] if logos and isinstance(logos[0], dict) else {}
|
|
||||||
return team.get("logo") or first.get("href")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _extract_team_record(team_data: Dict) -> str:
|
|
||||||
"""Extract the overall record string from a competitor/team object.
|
|
||||||
|
|
||||||
The ESPN scoreboard API uses ``records`` (plural) with a ``summary``
|
|
||||||
field, while the team-schedule API uses ``record`` (singular) with a
|
|
||||||
``displayValue`` field. This helper handles both formats so that
|
|
||||||
records display correctly regardless of which API provided the data.
|
|
||||||
"""
|
|
||||||
# Scoreboard API format: records[0].summary (e.g. "10-2")
|
|
||||||
records = team_data.get("records")
|
|
||||||
if records and isinstance(records, list) and len(records) > 0:
|
|
||||||
return records[0].get("summary", "") # type: ignore[no-any-return]
|
|
||||||
|
|
||||||
# Team-schedule API format: record[0].displayValue (e.g. "7-0")
|
|
||||||
record = team_data.get("record")
|
|
||||||
if record and isinstance(record, list) and len(record) > 0:
|
|
||||||
return record[0].get("displayValue", record[0].get("summary", "")) # type: ignore[no-any-return]
|
|
||||||
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def _logo_filename(self, abbr: str, team_id: Any) -> str:
|
|
||||||
"""Seam: the file a team's logo is kept in under ``logo_dir``."""
|
|
||||||
return f"{LogoDownloader.normalize_abbreviation(abbr)}.png"
|
|
||||||
|
|
||||||
def _extract_game_details_common(
|
|
||||||
self, game_event: Dict
|
|
||||||
) -> Tuple[Optional[Dict], Optional[Dict], Optional[Dict], Optional[Dict], Optional[Dict]]:
|
|
||||||
"""The game dict every renderer reads, built from one ESPN event.
|
|
||||||
|
|
||||||
Returns (details, home competitor, away competitor, status,
|
|
||||||
situation), or five Nones when the event has no competition, status,
|
|
||||||
date or home/away pair. Keys are additive only: never rename or drop
|
|
||||||
one. Sport extras belong in the sport's ``_extract_game_details``.
|
|
||||||
"""
|
|
||||||
if not game_event:
|
|
||||||
return None, None, None, None, None
|
|
||||||
try:
|
|
||||||
competitions = game_event.get("competitions", [])
|
|
||||||
if not competitions:
|
|
||||||
self.logger.warning(f"No competitions data for game {game_event.get('id', 'unknown')}")
|
|
||||||
return None, None, None, None, None
|
|
||||||
competition = competitions[0]
|
|
||||||
status = competition.get("status")
|
|
||||||
if not status:
|
|
||||||
self.logger.warning(f"No status data for game {game_event.get('id', 'unknown')}")
|
|
||||||
return None, None, None, None, None
|
|
||||||
competitors = competition.get("competitors", [])
|
|
||||||
game_date_str = game_event["date"]
|
|
||||||
situation = competition.get("situation")
|
|
||||||
start_time_utc = None
|
|
||||||
try:
|
|
||||||
dt = datetime.fromisoformat(game_date_str.replace("Z", "+00:00"))
|
|
||||||
# A time without an offset is read as UTC, as ESPN sends it.
|
|
||||||
start_time_utc = (dt.replace(tzinfo=pytz.UTC) if dt.tzinfo is None
|
|
||||||
else dt.astimezone(pytz.UTC))
|
|
||||||
except ValueError:
|
|
||||||
self.logger.warning(f"Could not parse game date: {game_date_str}")
|
|
||||||
|
|
||||||
home_team = next(
|
|
||||||
(c for c in competitors if c.get("homeAway") == "home"), None
|
|
||||||
)
|
|
||||||
away_team = next(
|
|
||||||
(c for c in competitors if c.get("homeAway") == "away"), None
|
|
||||||
)
|
|
||||||
if not home_team or not away_team:
|
|
||||||
self.logger.warning(
|
|
||||||
f"Could not find home or away team in event: {game_event.get('id')}"
|
|
||||||
)
|
|
||||||
return None, None, None, None, None
|
|
||||||
|
|
||||||
try:
|
|
||||||
home_abbr = home_team["team"]["abbreviation"]
|
|
||||||
except KeyError:
|
|
||||||
home_abbr = home_team["team"]["name"][:3]
|
|
||||||
try:
|
|
||||||
away_abbr = away_team["team"]["abbreviation"]
|
|
||||||
except KeyError:
|
|
||||||
away_abbr = away_team["team"]["name"][:3]
|
|
||||||
home_id = home_team.get("id") or home_team["team"].get("id")
|
|
||||||
away_id = away_team.get("id") or away_team["team"].get("id")
|
|
||||||
|
|
||||||
game_time, game_date = "", ""
|
|
||||||
if start_time_utc:
|
|
||||||
local_time = start_time_utc.astimezone(self._get_timezone())
|
|
||||||
game_time = local_time.strftime("%I:%M%p").lstrip("0")
|
|
||||||
if self.config.get("display", {}).get("use_short_date_format", False):
|
|
||||||
# %-m/%-d are glibc extensions: strftime raises ValueError on
|
|
||||||
# Windows and musl. Build the same text portably instead.
|
|
||||||
game_date = f"{local_time.month}/{local_time.day}"
|
|
||||||
else:
|
|
||||||
game_date = local_time.strftime("%m/%d")
|
|
||||||
|
|
||||||
home_record = self._extract_team_record(home_team)
|
|
||||||
away_record = self._extract_team_record(away_team)
|
|
||||||
# Don't show "0-0" records - set to blank instead
|
|
||||||
if home_record in {"0-0", "0-0-0"}:
|
|
||||||
home_record = ""
|
|
||||||
if away_record in {"0-0", "0-0-0"}:
|
|
||||||
away_record = ""
|
|
||||||
|
|
||||||
status_type = status["type"]
|
|
||||||
status_name = str(status_type.get("name") or "").upper()
|
|
||||||
details = {
|
|
||||||
"id": game_event.get("id"),
|
|
||||||
"game_time": game_time,
|
|
||||||
"game_date": game_date,
|
|
||||||
"start_time_utc": start_time_utc,
|
|
||||||
"status_text": status_type["shortDetail"], # e.g. "Final", "7:30 PM", "Q1 12:34"
|
|
||||||
"is_live": (status_type["state"] == "in"
|
|
||||||
and status_name not in self._CALLED_OFF_STATUS_NAMES),
|
|
||||||
"is_final": self._status_is_final(status_type),
|
|
||||||
"is_upcoming": (
|
|
||||||
status_type["state"] == "pre"
|
|
||||||
or status_type["name"].lower()
|
|
||||||
in ["scheduled", "pre-game", "status_scheduled"]
|
|
||||||
),
|
|
||||||
"is_halftime": status_type["state"] == "halftime"
|
|
||||||
or status_type["name"] == "STATUS_HALFTIME",
|
|
||||||
"is_period_break": status_type["name"] == "STATUS_END_PERIOD",
|
|
||||||
# str(): ESPN's undocumented payloads have served this field as
|
|
||||||
# an object in some sports. Plain game metadata: nothing reads it.
|
|
||||||
"broadcast": str(competition.get("broadcast") or ""),
|
|
||||||
"home_abbr": home_abbr,
|
|
||||||
"home_id": home_id,
|
|
||||||
"home_score": self._score_text(home_team.get("score")),
|
|
||||||
"home_logo_path": self.logo_dir / Path(self._logo_filename(home_abbr, home_id)),
|
|
||||||
"home_logo_url": self._team_logo_url(home_team),
|
|
||||||
"home_record": home_record,
|
|
||||||
"away_record": away_record,
|
|
||||||
"away_abbr": away_abbr,
|
|
||||||
"away_id": away_id,
|
|
||||||
"away_score": self._score_text(away_team.get("score")),
|
|
||||||
"away_logo_path": self.logo_dir / Path(self._logo_filename(away_abbr, away_id)),
|
|
||||||
"away_logo_url": self._team_logo_url(away_team),
|
|
||||||
"is_within_window": True, # Whether game is within display window
|
|
||||||
# The resolved favorites for this league (dynamic groups such
|
|
||||||
# as AP_TOP_25 already expanded). Carried on the game so the
|
|
||||||
# scroll/Vegas renderer, which only ever sees the game dict and
|
|
||||||
# the raw config, can color a final score by the result.
|
|
||||||
"favorite_teams": list(self.favorite_teams or []),
|
|
||||||
# Defaults; a sport's _extract_game_details sets its own. No
|
|
||||||
# period_text: the renderers read its absence (baseball) as
|
|
||||||
# "draw ESPN's status text", e.g. "Final/10".
|
|
||||||
"period": 0,
|
|
||||||
"clock": "",
|
|
||||||
}
|
|
||||||
return details, home_team, away_team, status, situation
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(
|
|
||||||
f"Error extracting game details: {e} from event: {game_event.get('id')}",
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
return None, None, None, None, None
|
|
||||||
@@ -23,7 +23,6 @@ CORE_CONFIG_KEYS = frozenset({
|
|||||||
'auto_update',
|
'auto_update',
|
||||||
'schedule',
|
'schedule',
|
||||||
'dim_schedule',
|
'dim_schedule',
|
||||||
'plugin_schedules',
|
|
||||||
'timezone',
|
'timezone',
|
||||||
'target_fps',
|
'target_fps',
|
||||||
'location',
|
'location',
|
||||||
|
|||||||
+286
-145
@@ -20,7 +20,6 @@ Entry point: :func:`main` — instantiates :class:`DisplayController` and calls
|
|||||||
:meth:`~DisplayController.run`.
|
:meth:`~DisplayController.run`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
|
||||||
import time
|
import time
|
||||||
import os
|
import os
|
||||||
import inspect
|
import inspect
|
||||||
@@ -38,7 +37,6 @@ import pytz
|
|||||||
|
|
||||||
from src import display_watchdog
|
from src import display_watchdog
|
||||||
from src.malloc_tuning import MallocTrimmer
|
from src.malloc_tuning import MallocTrimmer
|
||||||
from src.plugin_schedule import hidden_plugins, in_window, schedule_state, wants_live_scan
|
|
||||||
from src.display_arbiter import (
|
from src.display_arbiter import (
|
||||||
Arbiter, ArbiterInputs, ArbiterState, FramePolicy,
|
Arbiter, ArbiterInputs, ArbiterState, FramePolicy,
|
||||||
ScreenPlan, Source, WifiNotice, live_pick, live_takeover, on_demand_bound, rotation_plan,
|
ScreenPlan, Source, WifiNotice, live_pick, live_takeover, on_demand_bound, rotation_plan,
|
||||||
@@ -50,7 +48,7 @@ from src.screen_runner import (
|
|||||||
from src.display_manager import DisplayManager
|
from src.display_manager import DisplayManager
|
||||||
from src.config_manager import ConfigManager
|
from src.config_manager import ConfigManager
|
||||||
from src.config_service import ConfigService
|
from src.config_service import ConfigService
|
||||||
from src.cache_manager import CacheManager
|
from src.cache_manager import CacheManager, MailboxWatch
|
||||||
from src.font_manager import FontManager
|
from src.font_manager import FontManager
|
||||||
from src.logging_config import get_logger
|
from src.logging_config import get_logger
|
||||||
from src.exceptions import PluginError
|
from src.exceptions import PluginError
|
||||||
@@ -71,6 +69,10 @@ from src.vegas_mode.render_pipeline import SYNC_SEND_INTERVAL
|
|||||||
# Get logger with consistent configuration
|
# Get logger with consistent configuration
|
||||||
logger = get_logger(__name__)
|
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
|
# How often the unchanged current mode is republished for the web UI, which
|
||||||
# treats display_current_state older than 120 s as unknown.
|
# treats display_current_state older than 120 s as unknown.
|
||||||
CURRENT_STATE_REFRESH_SECONDS = 30
|
CURRENT_STATE_REFRESH_SECONDS = 30
|
||||||
@@ -410,15 +412,19 @@ class DisplayController:
|
|||||||
# coordinator exists (Vegas was off at startup). The render thread
|
# coordinator exists (Vegas was off at startup). The render thread
|
||||||
# creates it in _is_vegas_mode_active(), never the watcher thread.
|
# creates it in _is_vegas_mode_active(), never the watcher thread.
|
||||||
self._pending_vegas_init = False
|
self._pending_vegas_init = False
|
||||||
# Monotonic stamp of the last _service_pending_changes pass. None
|
# Monotonic stamp of the last mailbox disk read; see
|
||||||
# means "never", so the first call always goes through.
|
# _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.
|
||||||
self._last_pending_service: Optional[float] = None
|
self._last_pending_service: Optional[float] = None
|
||||||
# Monotonic stamp of the last scheduled-update pass; see
|
# Monotonic stamp of the last scheduled-update pass; see
|
||||||
# _tick_plugin_updates_if_due. Same "None means never" convention.
|
# _tick_plugin_updates_if_due. Same "None means never" convention.
|
||||||
self._last_plugin_update_tick: Optional[float] = None
|
self._last_plugin_update_tick: Optional[float] = None
|
||||||
# The control socket (src/ipc), started by run(). None when it is not
|
# The control socket (src/ipc), started by run(). None when it is not
|
||||||
# served (Windows, LEDMATRIX_CONTROL_SOCKET=off, a bind failure);
|
# served (Windows, LEDMATRIX_CONTROL_SOCKET=off, a bind failure);
|
||||||
# then only plugins in this process can start on-demand sessions.
|
# the file mailbox works either way.
|
||||||
self._control_server = None
|
self._control_server = None
|
||||||
# A brightness set_brightness() refused, so the periodic service pass
|
# A brightness set_brightness() refused, so the periodic service pass
|
||||||
# doesn't retry (and log) the same failure several times a second.
|
# doesn't retry (and log) the same failure several times a second.
|
||||||
@@ -707,11 +713,6 @@ class DisplayController:
|
|||||||
self._schedule_checked_minute: Optional[tuple] = None
|
self._schedule_checked_minute: Optional[tuple] = None
|
||||||
self._dim_checked_minute: Optional[tuple] = None
|
self._dim_checked_minute: Optional[tuple] = None
|
||||||
self._cached_target_brightness: int = self._normal_brightness
|
self._cached_target_brightness: int = self._normal_brightness
|
||||||
# Per-plugin schedules (config['plugin_schedules'], src/plugin_schedule.py):
|
|
||||||
# (key, hidden set), see _hidden_plugins.
|
|
||||||
self._plugin_hidden_cache: Tuple[Optional[tuple], FrozenSet[str]] = (None, frozenset())
|
|
||||||
self._game_live = False
|
|
||||||
self._game_live_at = float('-inf')
|
|
||||||
|
|
||||||
# Register controller-level hot-reload callback so cached config values
|
# Register controller-level hot-reload callback so cached config values
|
||||||
# (_normal_brightness, _scroll_speed, _tz, minute-gates) stay in sync
|
# (_normal_brightness, _scroll_speed, _tz, minute-gates) stay in sync
|
||||||
@@ -758,8 +759,6 @@ class DisplayController:
|
|||||||
|
|
||||||
# Set up live priority checker
|
# Set up live priority checker
|
||||||
self.vegas_coordinator.set_live_priority_checker(self._check_live_priority)
|
self.vegas_coordinator.set_live_priority_checker(self._check_live_priority)
|
||||||
# Plugins scheduled off sit out the ticker too.
|
|
||||||
self.vegas_coordinator.stream_manager.hidden_plugins = self._hidden_plugins
|
|
||||||
|
|
||||||
# Set up interrupt checker for on-demand/wifi status and follower mode
|
# Set up interrupt checker for on-demand/wifi status and follower mode
|
||||||
def _vegas_interrupt():
|
def _vegas_interrupt():
|
||||||
@@ -921,19 +920,38 @@ class DisplayController:
|
|||||||
self._tz = pytz.UTC
|
self._tz = pytz.UTC
|
||||||
return self._tz
|
return self._tz
|
||||||
|
|
||||||
# The window rule, shared with the dim schedule and plugin schedules.
|
@staticmethod
|
||||||
_in_window = staticmethod(in_window)
|
def _in_window(start, end, now) -> bool:
|
||||||
|
"""Whether ``now`` is within [start, end), a window that may span midnight.
|
||||||
|
|
||||||
|
Half-open: on from the start minute, off at exactly the end minute.
|
||||||
|
A closed end made the end minute count as inside, and because ``now``
|
||||||
|
carries seconds, only a check at hh:mm:00.000 saw it that way -- so
|
||||||
|
whether the panel went off at the start or the end of that minute
|
||||||
|
depended on when the minute's one check ran. ``start == end`` is an
|
||||||
|
empty window, as it effectively was before.
|
||||||
|
"""
|
||||||
|
if start <= end:
|
||||||
|
return start <= now < end
|
||||||
|
return now >= start or now < end
|
||||||
|
|
||||||
def _check_schedule(self):
|
def _check_schedule(self):
|
||||||
"""Check if display should be active based on schedule."""
|
"""Check if display should be active based on schedule."""
|
||||||
schedule_config = self.config.get('schedule', {})
|
schedule_config = self.config.get('schedule', {})
|
||||||
|
|
||||||
# No schedule, or a disabled one: always active.
|
# No schedule configured: always active.
|
||||||
if not schedule_config or not schedule_config.get('enabled', True):
|
if not schedule_config:
|
||||||
self.is_display_active = True
|
self.is_display_active = True
|
||||||
self._was_display_active = True
|
self._was_display_active = True
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# A schedule without an 'enabled' key counts as enabled.
|
||||||
|
if 'enabled' in schedule_config and not schedule_config.get('enabled', True):
|
||||||
|
self.is_display_active = True
|
||||||
|
self._was_display_active = True
|
||||||
|
logger.debug("Schedule is disabled - display always active")
|
||||||
|
return
|
||||||
|
|
||||||
current_time = datetime.now(self._timezone())
|
current_time = datetime.now(self._timezone())
|
||||||
# Gate: schedule state can only change on a minute boundary, so skip
|
# Gate: schedule state can only change on a minute boundary, so skip
|
||||||
# all the strptime / comparison work if we already evaluated this minute.
|
# all the strptime / comparison work if we already evaluated this minute.
|
||||||
@@ -942,15 +960,81 @@ class DisplayController:
|
|||||||
return
|
return
|
||||||
self._schedule_checked_minute = current_minute_key
|
self._schedule_checked_minute = current_minute_key
|
||||||
|
|
||||||
active, why = schedule_state(schedule_config, current_time)
|
current_day = current_time.strftime('%A').lower() # e.g. 'monday'
|
||||||
if active != self._was_display_active:
|
current_time_only = current_time.time()
|
||||||
logger.info("Schedule activated: Display is now %s (%s)%s",
|
|
||||||
"ACTIVE" if active else "INACTIVE", why,
|
# Check if per-day schedule is configured
|
||||||
"" if active else ". Display will be blanked.")
|
days_config = schedule_config.get('days')
|
||||||
|
|
||||||
|
# Determine which schedule to use. Respect an explicit 'mode' field
|
||||||
|
# (like the dim schedule does) so a stray/legacy 'days' dict left over
|
||||||
|
# from config migration or a prior per-day setup can't silently
|
||||||
|
# override a user's Global schedule selection.
|
||||||
|
mode = schedule_config.get('mode')
|
||||||
|
mode_normalized = mode.replace('_', '-') if mode else None
|
||||||
|
|
||||||
|
use_per_day = False
|
||||||
|
if mode_normalized == 'global':
|
||||||
|
use_per_day = False
|
||||||
|
elif mode_normalized == 'per-day':
|
||||||
|
use_per_day = bool(days_config and current_day in days_config)
|
||||||
|
elif days_config:
|
||||||
|
# No explicit mode recorded (legacy config) - fall back to
|
||||||
|
# inferring from presence of a 'days' dict for the current day.
|
||||||
|
if current_day in days_config:
|
||||||
|
use_per_day = True
|
||||||
|
else:
|
||||||
|
logger.debug("Per-day schedule exists but %s not configured, using global schedule", current_day)
|
||||||
|
|
||||||
|
if use_per_day:
|
||||||
|
day_config = days_config[current_day]
|
||||||
|
|
||||||
|
if not day_config.get('enabled', True):
|
||||||
|
was_active = self._was_display_active
|
||||||
|
self.is_display_active = False
|
||||||
|
if was_active:
|
||||||
|
logger.info("Schedule activated: Display is now INACTIVE (%s is disabled in schedule). Display will be blanked.", current_day)
|
||||||
|
else:
|
||||||
|
logger.debug("Display inactive - %s is disabled in schedule", current_day)
|
||||||
|
self._was_display_active = self.is_display_active
|
||||||
|
return
|
||||||
|
|
||||||
|
start_time_str = day_config.get('start_time', '07:00')
|
||||||
|
end_time_str = day_config.get('end_time', '23:00')
|
||||||
|
schedule_type = f"per-day ({current_day})"
|
||||||
else:
|
else:
|
||||||
logger.debug("Display %s - %s", "active" if active else "inactive", why)
|
start_time_str = schedule_config.get('start_time', '07:00')
|
||||||
self.is_display_active = active
|
end_time_str = schedule_config.get('end_time', '23:00')
|
||||||
self._was_display_active = active
|
schedule_type = "global"
|
||||||
|
|
||||||
|
try:
|
||||||
|
start_time = datetime.strptime(start_time_str, '%H:%M').time()
|
||||||
|
end_time = datetime.strptime(end_time_str, '%H:%M').time()
|
||||||
|
self.is_display_active = self._in_window(start_time, end_time, current_time_only)
|
||||||
|
|
||||||
|
was_active = self._was_display_active
|
||||||
|
if not self.is_display_active:
|
||||||
|
if was_active:
|
||||||
|
logger.info("Schedule activated: Display is now INACTIVE (outside %s schedule window %s - %s). Display will be blanked.",
|
||||||
|
schedule_type, start_time_str, end_time_str)
|
||||||
|
else:
|
||||||
|
logger.debug("Display inactive - outside %s schedule window (%s - %s)",
|
||||||
|
schedule_type, start_time_str, end_time_str)
|
||||||
|
else:
|
||||||
|
if not was_active:
|
||||||
|
logger.info("Schedule activated: Display is now ACTIVE (within %s schedule window %s - %s)",
|
||||||
|
schedule_type, start_time_str, end_time_str)
|
||||||
|
else:
|
||||||
|
logger.debug("Display active - within %s schedule window (%s - %s)",
|
||||||
|
schedule_type, start_time_str, end_time_str)
|
||||||
|
|
||||||
|
self._was_display_active = self.is_display_active
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
logger.warning("Invalid schedule format for %s schedule: %s (start: %s, end: %s). Defaulting to active.",
|
||||||
|
schedule_type, e, start_time_str, end_time_str)
|
||||||
|
self.is_display_active = True
|
||||||
|
self._was_display_active = True
|
||||||
|
|
||||||
def _check_dim_schedule(self) -> int:
|
def _check_dim_schedule(self) -> int:
|
||||||
"""
|
"""
|
||||||
@@ -1429,18 +1513,8 @@ class DisplayController:
|
|||||||
if self._empty_pass_streak < rotation:
|
if self._empty_pass_streak < rotation:
|
||||||
return
|
return
|
||||||
if self._empty_pass_streak == rotation:
|
if self._empty_pass_streak == rotation:
|
||||||
# Blank once: with every plugin empty or scheduled off, the last
|
|
||||||
# screen drawn would otherwise stay up indefinitely. A blank the
|
|
||||||
# controller draws, so the last scroll's state ends first.
|
|
||||||
self._end_scroll_before_core_screen()
|
|
||||||
self.display_manager.clear()
|
|
||||||
self.display_manager.update_display()
|
|
||||||
logger.info("No mode has anything to show; checking one mode every %.0fs "
|
logger.info("No mode has anything to show; checking one mode every %.0fs "
|
||||||
"until one does", self.EMPTY_ROTATION_PAUSE)
|
"until one does", self.EMPTY_ROTATION_PAUSE)
|
||||||
# The preview throttle may have skipped the blank frame, and with no
|
|
||||||
# display() call to follow, nothing else writes it (see
|
|
||||||
# DisplayManager.write_owed_snapshot). Cheap when nothing is owed.
|
|
||||||
self.display_manager.write_owed_snapshot()
|
|
||||||
self._sleep_with_plugin_updates(self.EMPTY_ROTATION_PAUSE)
|
self._sleep_with_plugin_updates(self.EMPTY_ROTATION_PAUSE)
|
||||||
|
|
||||||
#: Plugins already warned about a display duration that is not a number,
|
#: Plugins already warned about a display duration that is not a number,
|
||||||
@@ -1711,9 +1785,6 @@ class DisplayController:
|
|||||||
'status': self.on_demand_status,
|
'status': self.on_demand_status,
|
||||||
'error': self.on_demand_last_error,
|
'error': self.on_demand_last_error,
|
||||||
'last_event': self.on_demand_last_event,
|
'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(),
|
'remaining': self._get_on_demand_remaining(),
|
||||||
'last_updated': time.time()
|
'last_updated': time.time()
|
||||||
}
|
}
|
||||||
@@ -1788,16 +1859,34 @@ class DisplayController:
|
|||||||
self.force_change = True
|
self.force_change = True
|
||||||
self._publish_on_demand_state()
|
self._publish_on_demand_state()
|
||||||
|
|
||||||
#: Shortest gap between _service_pending_changes passes. The schedule
|
#: Shortest gap between mailbox disk reads. This is called after every
|
||||||
#: checks are gated to once per clock minute and the rest is attribute
|
#: frame -- about 125 times a second on a scrolling mode -- and the read
|
||||||
#: compares. Callers run at frame rate,
|
#: 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,
|
||||||
#: so between passes the whole cost is one monotonic-clock compare.
|
#: so between passes the whole cost is one monotonic-clock compare.
|
||||||
PENDING_CHANGES_INTERVAL = 0.25
|
PENDING_CHANGES_INTERVAL = 0.25
|
||||||
|
|
||||||
#: Class-level defaults for controllers built without __init__ (tests).
|
#: Class-level defaults for controllers built without __init__ (tests).
|
||||||
_control_server: Optional[ControlServer] = None
|
_control_server: Optional[ControlServer] = None
|
||||||
#: The last on-demand request handled; published in _on_demand_state.
|
#: Created on the first poll; see _poll_on_demand_requests.
|
||||||
on_demand_request_id: Optional[str] = None
|
_on_demand_mailbox: Optional[MailboxWatch] = None
|
||||||
|
#: Writers whose mailbox requests have been logged (_note_mailbox_request).
|
||||||
|
_mailbox_writers_logged: FrozenSet[str] = frozenset()
|
||||||
#: Most plugin on-demand requests waiting for the render thread at once.
|
#: Most plugin on-demand requests waiting for the render thread at once.
|
||||||
#: A plugin that asks faster than the display drains (four times a
|
#: A plugin that asks faster than the display drains (four times a
|
||||||
#: second at worst) is refused, not queued without end.
|
#: second at worst) is refused, not queued without end.
|
||||||
@@ -1940,11 +2029,44 @@ class DisplayController:
|
|||||||
on_demand_plugin_id, len(enabled_plugins))
|
on_demand_plugin_id, len(enabled_plugins))
|
||||||
return 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:
|
def _start_control_server(self) -> None:
|
||||||
"""Serve the control socket (src/ipc/server.py). Never raises.
|
"""Serve the control socket (src/ipc/server.py). Never raises.
|
||||||
|
|
||||||
Its handlers only queue commands; _drain_control_commands applies
|
Its handlers only queue commands; _drain_control_commands applies
|
||||||
them on the render thread.
|
them on the render thread, where the mailbox is read.
|
||||||
"""
|
"""
|
||||||
if self._control_server is not None:
|
if self._control_server is not None:
|
||||||
return
|
return
|
||||||
@@ -1957,8 +2079,7 @@ class DisplayController:
|
|||||||
state_hub=hub,
|
state_hub=hub,
|
||||||
handlers={ControlCommand.ERRORS_CLEAR: apply_error_clear})
|
handlers={ControlCommand.ERRORS_CLEAR: apply_error_clear})
|
||||||
except Exception: # pylint: disable=broad-except
|
except Exception: # pylint: disable=broad-except
|
||||||
logger.exception("Control socket not started; the web interface cannot "
|
logger.exception("Control socket not started; using the file mailbox only")
|
||||||
"send this display commands")
|
|
||||||
if self._control_server is not None:
|
if self._control_server is not None:
|
||||||
self._start_state_stream(hub)
|
self._start_state_stream(hub)
|
||||||
|
|
||||||
@@ -1986,8 +2107,10 @@ class DisplayController:
|
|||||||
def _drain_control_commands(self) -> None:
|
def _drain_control_commands(self) -> None:
|
||||||
"""Apply the commands that arrived over the control socket.
|
"""Apply the commands that arrived over the control socket.
|
||||||
|
|
||||||
On-demand commands go through _handle_on_demand_request, as plugins'
|
On-demand commands go through _handle_on_demand_request, the
|
||||||
own requests do, so both ways in behave the same. A brightness is applied
|
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
|
||||||
here. A plugin reload waits for the top of the next loop pass, where
|
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
|
no plugin is on the stack (_apply_pending_plugin_reloads); until
|
||||||
then the current screen ends early (_plugin_reload_pending).
|
then the current screen ends early (_plugin_reload_pending).
|
||||||
@@ -2020,7 +2143,7 @@ class DisplayController:
|
|||||||
|
|
||||||
``PluginManager.request_on_demand`` / ``end_on_demand`` (which
|
``PluginManager.request_on_demand`` / ``end_on_demand`` (which
|
||||||
BasePlugin's methods of the same names call) build ``request``: the
|
BasePlugin's methods of the same names call) build ``request``: the
|
||||||
on-demand request shape, with ``source: 'plugin'`` and the asking plugin's
|
mailbox's shape, with ``source: 'plugin'`` and the asking plugin's
|
||||||
id. Nothing here touches the panel or the on-demand state; the render
|
id. Nothing here touches the panel or the on-demand state; the render
|
||||||
thread applies the request where it applies a socket command
|
thread applies the request where it applies a socket command
|
||||||
(_drain_control_commands), through _handle_on_demand_request, and is
|
(_drain_control_commands), through _handle_on_demand_request, and is
|
||||||
@@ -2315,35 +2438,105 @@ class DisplayController:
|
|||||||
}
|
}
|
||||||
command.succeed(dict(result))
|
command.succeed(dict(result))
|
||||||
|
|
||||||
def _poll_on_demand_requests(self) -> None:
|
def _mailbox_poll_interval(self) -> float:
|
||||||
"""Apply on-demand requests: the control socket's, then plugins' own.
|
"""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
|
||||||
|
|
||||||
Both are in memory (no disk read), so this has no floor and is
|
def _poll_on_demand_requests(self) -> None:
|
||||||
cheap to call every frame. The file mailbox
|
"""Apply on-demand requests: the control socket's, then the mailbox's.
|
||||||
(``display_on_demand_request``) is gone: nothing reads it, and a
|
|
||||||
write to it is dropped with a warning (CacheManager).
|
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().
|
||||||
"""
|
"""
|
||||||
|
# Socket commands are already in memory: no disk read, so no floor.
|
||||||
self._drain_control_commands()
|
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:
|
def _handle_on_demand_request(self, request: Dict[str, Any]) -> None:
|
||||||
"""Process one on-demand request, from the control socket or a plugin.
|
"""Process one on-demand request, from the mailbox or the control socket.
|
||||||
|
|
||||||
A socket command carries ``source: 'socket'``, and a plugin's own
|
A socket command carries ``source: 'socket'``, and a plugin's own
|
||||||
request (submit_plugin_on_demand) ``source: 'plugin'``.
|
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.
|
||||||
|
|
||||||
A plugin's stop ends only that plugin's own session: a 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
|
releasing the screen must not end one the user started for
|
||||||
another plugin. A stop from the socket (the web interface) ends any
|
another plugin. (A stop through the mailbox ends any session, as it
|
||||||
session.
|
always has.)
|
||||||
"""
|
"""
|
||||||
request_id = request.get('request_id')
|
request_id = request.get('request_id')
|
||||||
if not request_id:
|
if not request_id:
|
||||||
return
|
return
|
||||||
source = request.get('source')
|
source = request.get('source')
|
||||||
|
from_mailbox = source not in ('socket', 'plugin')
|
||||||
|
|
||||||
action = request.get('action')
|
action = request.get('action')
|
||||||
|
|
||||||
# For stop requests, always process them (no request-id guard)
|
# For stop requests, always process them (don't check processed_id)
|
||||||
# This allows stopping even if the same stop request was sent before
|
# This allows stopping even if the same stop request was sent before
|
||||||
if action == 'stop':
|
if action == 'stop':
|
||||||
if source == 'plugin' and not (
|
if source == 'plugin' and not (
|
||||||
@@ -2369,22 +2562,42 @@ class DisplayController:
|
|||||||
# without this the status route kept reporting it until
|
# without this the status route kept reporting it until
|
||||||
# the state aged out (120s) or another request came in.
|
# the state aged out (120s) or another request came in.
|
||||||
self._clear_on_demand(reason='requested-stop')
|
self._clear_on_demand(reason='requested-stop')
|
||||||
# Stop requests are deliberately exempt from the request_id
|
# Stop requests are deliberately exempt from the request_id/
|
||||||
# guard below, so that a second click stops a mode that a race
|
# processed_id guards above, so that a second click stops a mode
|
||||||
# left running.
|
# 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)
|
||||||
return
|
return
|
||||||
|
|
||||||
# A start already processed (a client that sent the same request
|
# For start requests, check if already processed. A duplicate in the
|
||||||
# id twice) is not applied a second time.
|
# 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.
|
||||||
if request_id == self.on_demand_request_id:
|
if request_id == self.on_demand_request_id:
|
||||||
logger.debug("On-demand start request %s already processed", 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)
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info("Received on-demand request %s: %s (plugin_id=%s, mode=%s, via %s)",
|
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'),
|
request_id, action, request.get('plugin_id'), request.get('mode'),
|
||||||
source or 'unknown')
|
'mailbox' if from_mailbox else source)
|
||||||
|
|
||||||
|
# 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
|
self.on_demand_request_id = request_id
|
||||||
|
if from_mailbox:
|
||||||
|
self._consume_on_demand_request(request_id)
|
||||||
|
|
||||||
if action == 'start':
|
if action == 'start':
|
||||||
logger.info("Processing on-demand start request for plugin: %s", request.get('plugin_id'))
|
logger.info("Processing on-demand start request for plugin: %s", request.get('plugin_id'))
|
||||||
@@ -2924,54 +3137,6 @@ class DisplayController:
|
|||||||
self.force_change = True
|
self.force_change = True
|
||||||
logger.info("Live priority ended - resuming rotation at %s", self.current_display_mode)
|
logger.info("Live priority ended - resuming rotation at %s", self.current_display_mode)
|
||||||
|
|
||||||
#: How long a "is any game live?" answer is reused for plugin schedules.
|
|
||||||
GAME_LIVE_TTL = 5.0
|
|
||||||
|
|
||||||
def _any_game_live(self) -> bool:
|
|
||||||
"""Whether any plugin reports live content, asked at most every GAME_LIVE_TTL s.
|
|
||||||
|
|
||||||
Unlike _collect_live_modes this asks every plugin, live priority or
|
|
||||||
not: a scoreboard without live priority still makes it gameday.
|
|
||||||
"""
|
|
||||||
now = time.monotonic()
|
|
||||||
if now - self._game_live_at >= self.GAME_LIVE_TTL:
|
|
||||||
self._game_live_at = now
|
|
||||||
live = False
|
|
||||||
for plugin in {id(p): p for p in list(self.plugin_modes.values())}.values():
|
|
||||||
try:
|
|
||||||
if hasattr(plugin, 'has_live_content') and plugin.has_live_content():
|
|
||||||
live = True
|
|
||||||
break
|
|
||||||
except Exception as e: # pylint: disable=broad-except
|
|
||||||
logger.warning("Error checking live content for %s: %s",
|
|
||||||
getattr(plugin, 'plugin_id', plugin), e)
|
|
||||||
self._game_live = live
|
|
||||||
return self._game_live
|
|
||||||
|
|
||||||
def _hidden_plugins(self) -> FrozenSet[str]:
|
|
||||||
"""Plugin ids their plugin_schedules entry keeps off the panel right now.
|
|
||||||
|
|
||||||
Recomputed when the schedules, the minute or the live-game answer
|
|
||||||
change. Also Vegas's filter (StreamManager.hidden_plugins), from its
|
|
||||||
own thread: the schedules are part of the key and the cache is one
|
|
||||||
tuple, so a call still holding the config from before a reload
|
|
||||||
cannot leave its answer standing for the new one.
|
|
||||||
"""
|
|
||||||
schedules = self.config.get('plugin_schedules')
|
|
||||||
if not isinstance(schedules, dict) or not schedules:
|
|
||||||
return frozenset()
|
|
||||||
now = datetime.now(self._timezone())
|
|
||||||
game_live = wants_live_scan(schedules) and self._any_game_live()
|
|
||||||
key = (schedules, now.hour, now.minute, game_live)
|
|
||||||
cached_key, hidden = self._plugin_hidden_cache
|
|
||||||
if key != cached_key:
|
|
||||||
previous = hidden
|
|
||||||
hidden = hidden_plugins(schedules, now, game_live)
|
|
||||||
if hidden != previous:
|
|
||||||
logger.info("Plugin schedules: hidden now %s", sorted(hidden) or "none")
|
|
||||||
self._plugin_hidden_cache = (key, hidden)
|
|
||||||
return hidden
|
|
||||||
|
|
||||||
def _collect_live_modes(self):
|
def _collect_live_modes(self):
|
||||||
"""Return every currently live-priority mode, in registration order.
|
"""Return every currently live-priority mode, in registration order.
|
||||||
|
|
||||||
@@ -2988,14 +3153,10 @@ class DisplayController:
|
|||||||
# Asked once per plugin per scan, not once per mode key: a scoreboard
|
# Asked once per plugin per scan, not once per mode key: a scoreboard
|
||||||
# registered under several modes computes the same answer each time.
|
# registered under several modes computes the same answer each time.
|
||||||
is_live: Dict[int, bool] = {}
|
is_live: Dict[int, bool] = {}
|
||||||
# A scoreboard scheduled off does not win the panel by going live.
|
|
||||||
hidden = self._hidden_plugins()
|
|
||||||
for mode_name, plugin_instance in self.plugin_modes.items():
|
for mode_name, plugin_instance in self.plugin_modes.items():
|
||||||
if not (hasattr(plugin_instance, 'has_live_priority')
|
if not (hasattr(plugin_instance, 'has_live_priority')
|
||||||
and hasattr(plugin_instance, 'has_live_content')):
|
and hasattr(plugin_instance, 'has_live_content')):
|
||||||
continue
|
continue
|
||||||
if getattr(plugin_instance, 'plugin_id', mode_name) in hidden:
|
|
||||||
continue
|
|
||||||
try:
|
try:
|
||||||
key = id(plugin_instance)
|
key = id(plugin_instance)
|
||||||
if key not in is_live:
|
if key not in is_live:
|
||||||
@@ -3454,20 +3615,6 @@ class DisplayController:
|
|||||||
logger.debug(f"Found plugin manager for mode {mode}: {type(plugin_instance).__name__}")
|
logger.debug(f"Found plugin manager for mode {mode}: {type(plugin_instance).__name__}")
|
||||||
return plugin_instance
|
return plugin_instance
|
||||||
|
|
||||||
def _plugin_to_draw(self, plan: ScreenPlan):
|
|
||||||
"""The plugin to draw ``plan``'s mode, or None to skip it this pass.
|
|
||||||
|
|
||||||
_plugin_for_mode, and None for a plugin its plugin_schedules entry
|
|
||||||
keeps off the panel right now: skipped like an empty screen. An
|
|
||||||
on-demand plan is the user asking, so it still shows.
|
|
||||||
"""
|
|
||||||
plugin = self._plugin_for_mode(plan.mode)
|
|
||||||
if (plugin is not None and plan.source is not Source.ON_DEMAND
|
|
||||||
and getattr(plugin, 'plugin_id', plan.mode) in self._hidden_plugins()):
|
|
||||||
logger.debug("%s is scheduled off, skipping", plan.mode)
|
|
||||||
return None
|
|
||||||
return plugin
|
|
||||||
|
|
||||||
def _start_screen_handover(self, plugin, active_mode: str) -> bool:
|
def _start_screen_handover(self, plugin, active_mode: str) -> bool:
|
||||||
"""Before a screen's first dispatch: if the screen is static, keep
|
"""Before a screen's first dispatch: if the screen is static, keep
|
||||||
the last scroll's leftovers off its first frame.
|
the last scroll's leftovers off its first frame.
|
||||||
@@ -3920,22 +4067,14 @@ class DisplayController:
|
|||||||
elif nxt is not state:
|
elif nxt is not state:
|
||||||
self._adopt_state(nxt)
|
self._adopt_state(nxt)
|
||||||
self.force_change = True
|
self.force_change = True
|
||||||
# DEBUG once a whole rotation has been empty (_note_empty_pass):
|
logger.info("Switching to mode: %s", self.current_display_mode)
|
||||||
# it then steps once a second, and every plugin scheduled off
|
|
||||||
# would otherwise journal a line a second.
|
|
||||||
logger.log(logging.DEBUG if self._empty_pass_streak >= max(1, len(self.available_modes))
|
|
||||||
else logging.INFO,
|
|
||||||
"Switching to mode: %s", self.current_display_mode)
|
|
||||||
|
|
||||||
def _still_live(self, mode: Optional[str]) -> bool:
|
def _still_live(self, mode: Optional[str]) -> bool:
|
||||||
"""The live hold: ``mode``'s plugin has live priority and live content,
|
"""The live hold: ``mode``'s plugin has live priority and live content,
|
||||||
so the rotation stays on it. Never for a plugin scheduled off: the
|
so the rotation stays on it."""
|
||||||
hold would keep the rotation on a mode it skips."""
|
|
||||||
if mode not in self.plugin_modes:
|
if mode not in self.plugin_modes:
|
||||||
return False
|
return False
|
||||||
plugin_instance = self.plugin_modes[mode]
|
plugin_instance = self.plugin_modes[mode]
|
||||||
if getattr(plugin_instance, 'plugin_id', mode) in self._hidden_plugins():
|
|
||||||
return False
|
|
||||||
if not (hasattr(plugin_instance, 'has_live_priority')
|
if not (hasattr(plugin_instance, 'has_live_priority')
|
||||||
and hasattr(plugin_instance, 'has_live_content')):
|
and hasattr(plugin_instance, 'has_live_content')):
|
||||||
return False
|
return False
|
||||||
@@ -4209,8 +4348,10 @@ class DisplayController:
|
|||||||
logger.debug("Loaded plugin modes: %s", list(self.plugin_modes.keys()))
|
logger.debug("Loaded plugin modes: %s", list(self.plugin_modes.keys()))
|
||||||
|
|
||||||
# Handle plugin-based display modes
|
# Handle plugin-based display modes
|
||||||
# None skips the mode; _plugin_to_draw has logged why.
|
manager_to_display = self._plugin_for_mode(active_mode)
|
||||||
manager_to_display = self._plugin_to_draw(plan)
|
if not manager_to_display:
|
||||||
|
logger.warning(f"No plugin manager found for mode {active_mode} - skipping display and rotating to next mode")
|
||||||
|
manager_to_display = None
|
||||||
|
|
||||||
# One screen: the first frame, the frame loop, the make-up
|
# One screen: the first frame, the frame loop, the make-up
|
||||||
# dwell (src/screen_runner.py).
|
# dwell (src/screen_runner.py).
|
||||||
|
|||||||
+175
-54
@@ -19,7 +19,7 @@ import uuid
|
|||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Dict, List, Optional, Any, Callable
|
from typing import Dict, List, Optional, Any, Callable, Tuple
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from src.exceptions import LEDMatrixError
|
from src.exceptions import LEDMatrixError
|
||||||
@@ -449,28 +449,33 @@ def record_error(
|
|||||||
# service publishes to the shared cache directory -- the same channel, and the
|
# service publishes to the shared cache directory -- the same channel, and the
|
||||||
# same file permissions, as display_current_state and plugin_metrics_snapshot: files
|
# 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
|
# are 0660 and carry the cache directory's group, so root writes and the web
|
||||||
# user reads.
|
# user reads, and the other way round for the clear request.
|
||||||
#
|
#
|
||||||
# ERROR_SNAPSHOT_KEY written by the display service only
|
# ERROR_SNAPSHOT_KEY written by the display service only
|
||||||
|
# ERROR_CLEAR_REQUEST_KEY written by the web interface only, as a fallback
|
||||||
#
|
#
|
||||||
# A clear goes over the control socket (``errors.clear``): the display applies
|
# A clear goes over the control socket (``errors.clear``): the display applies
|
||||||
# it (clear_before) and republishes the snapshot before it answers. When the
|
# it (clear_before) and republishes the snapshot before it answers. Only when
|
||||||
# socket cannot carry it, the clear fails and the route says so: the
|
# the socket cannot carry it (no socket, or a display older than the command)
|
||||||
# ``plugin_error_clear_request`` file mailbox it used to fall back to is gone.
|
# does the web interface record a request in the mailbox, which the display
|
||||||
# A stopped display's errors go anyway: its next run publishes an empty
|
# applies on its next tick; its tick reads that file only when it changed.
|
||||||
# snapshot over the old one. The web interface never writes the snapshot
|
# Until it has, the web interface hides whatever the snapshot shows from
|
||||||
# itself: two writers would race, and a snapshot owned by the web user is one
|
# before the cutoff, so a clear takes effect for readers immediately and a
|
||||||
# more file root's write has to replace.
|
# 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.
|
||||||
|
|
||||||
ERROR_SNAPSHOT_KEY = "plugin_error_snapshot"
|
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
|
#: 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
|
#: a tight loop changes the aggregator many times a second; the snapshot is
|
||||||
#: rewritten at most this often, and only when something changed.
|
#: rewritten at most this often, and only when something changed.
|
||||||
SNAPSHOT_MIN_INTERVAL = 10.0
|
SNAPSHOT_MIN_INTERVAL = 10.0
|
||||||
|
|
||||||
#: How often the display service checks for changes. A check is an
|
#: How often the display service checks for changes and clear requests. A
|
||||||
#: in-memory comparison.
|
#: check is an in-memory comparison plus reading one small file.
|
||||||
SNAPSHOT_TICK_INTERVAL = 5.0
|
SNAPSHOT_TICK_INTERVAL = 5.0
|
||||||
|
|
||||||
_SNAPSHOT_RECENT_ERRORS = 20
|
_SNAPSHOT_RECENT_ERRORS = 20
|
||||||
@@ -539,8 +544,8 @@ class ErrorSnapshotPublisher:
|
|||||||
Runs in the display service only. tick() is the whole job; start() just
|
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
|
calls it from a daemon thread every SNAPSHOT_TICK_INTERVAL seconds, which
|
||||||
also means errors recorded while a write was being throttled still reach
|
also means errors recorded while a write was being throttled still reach
|
||||||
the cache once the interval has passed. A clear (``errors.clear`` over
|
the cache once the interval has passed, and a clear request is applied
|
||||||
the control socket) is applied by :meth:`clear_now`.
|
even when no new error arrives to trigger a publish.
|
||||||
|
|
||||||
Nothing here raises: a failure to read or write the cache is logged at
|
Nothing here raises: a failure to read or write the cache is logged at
|
||||||
debug and retried on a later tick.
|
debug and retried on a later tick.
|
||||||
@@ -558,6 +563,11 @@ class ErrorSnapshotPublisher:
|
|||||||
self._published_version: Optional[int] = None
|
self._published_version: Optional[int] = None
|
||||||
self._last_attempt: Optional[float] = None
|
self._last_attempt: Optional[float] = None
|
||||||
self._applied_clear_id: Optional[str] = None
|
self._applied_clear_id: Optional[str] = None
|
||||||
|
# The widest cutoff applied in this process: a clear request at or
|
||||||
|
# before it has nothing left to clear (see _pending_cutoff).
|
||||||
|
self._applied_clear_cutoff: Optional[float] = None
|
||||||
|
from src.cache_manager import MailboxWatch # the display's cache, loaded already
|
||||||
|
self._mailbox = MailboxWatch(ERROR_CLEAR_REQUEST_KEY)
|
||||||
self._tick_lock = threading.Lock()
|
self._tick_lock = threading.Lock()
|
||||||
self._stop = threading.Event()
|
self._stop = threading.Event()
|
||||||
self._thread: Optional[threading.Thread] = None
|
self._thread: Optional[threading.Thread] = None
|
||||||
@@ -569,9 +579,39 @@ class ErrorSnapshotPublisher:
|
|||||||
cleared = self.aggregator.clear_before(datetime.fromtimestamp(cutoff))
|
cleared = self.aggregator.clear_before(datetime.fromtimestamp(cutoff))
|
||||||
_snapshot_logger.info("Cleared %d plugin error record(s) as requested (%s)",
|
_snapshot_logger.info("Cleared %d plugin error record(s) as requested (%s)",
|
||||||
cleared, request_id)
|
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
|
self._applied_clear_id = request_id
|
||||||
return cleared
|
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:
|
def clear_now(self, request_id: str, cutoff: float) -> int:
|
||||||
"""``errors.clear`` over the control socket: apply a clear at once and
|
"""``errors.clear`` over the control socket: apply a clear at once and
|
||||||
republish the snapshot, so the web interface's next read has it.
|
republish the snapshot, so the web interface's next read has it.
|
||||||
@@ -589,20 +629,23 @@ class ErrorSnapshotPublisher:
|
|||||||
self._last_attempt = now
|
self._last_attempt = now
|
||||||
snapshot = self.aggregator.build_snapshot()
|
snapshot = self.aggregator.build_snapshot()
|
||||||
snapshot["applied_clear_id"] = self._applied_clear_id
|
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.cache_manager.set(ERROR_SNAPSHOT_KEY, snapshot)
|
||||||
self._published_version = version
|
self._published_version = version
|
||||||
|
|
||||||
def tick(self) -> bool:
|
def tick(self) -> bool:
|
||||||
"""Publish if due. True if a snapshot was written."""
|
"""Apply a pending clear and publish if due. True if a snapshot was written."""
|
||||||
with self._tick_lock:
|
with self._tick_lock:
|
||||||
try:
|
try:
|
||||||
|
cleared = self._apply_clear_request()
|
||||||
version = self.aggregator.version
|
version = self.aggregator.version
|
||||||
now = self._clock()
|
now = self._clock()
|
||||||
if version == self._published_version:
|
if not cleared:
|
||||||
return False
|
if version == self._published_version:
|
||||||
if (self._last_attempt is not None
|
return False
|
||||||
and now - self._last_attempt < self.min_interval):
|
if (self._last_attempt is not None
|
||||||
return False
|
and now - self._last_attempt < self.min_interval):
|
||||||
|
return False
|
||||||
self._publish(version, now)
|
self._publish(version, now)
|
||||||
return True
|
return True
|
||||||
except Exception as err: # never let reporting break the display
|
except Exception as err: # never let reporting break the display
|
||||||
@@ -673,13 +716,16 @@ def apply_error_clear(request_id: str, args: Any) -> Dict[str, Any]:
|
|||||||
|
|
||||||
# --- Reading side (web interface) -------------------------------------------
|
# --- Reading side (web interface) -------------------------------------------
|
||||||
|
|
||||||
def read_error_report(cache_manager: Any) -> Optional[Dict[str, Any]]:
|
def read_error_report(cache_manager: Any) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
||||||
"""The display service's latest snapshot, or None before it has published.
|
"""The display service's latest snapshot and the latest clear request.
|
||||||
|
|
||||||
memory_ttl=0: the display writes the key, so only the file is current.
|
memory_ttl=0: both keys are written by the other process, so only the
|
||||||
|
file is current.
|
||||||
"""
|
"""
|
||||||
snapshot = cache_manager.get(ERROR_SNAPSHOT_KEY, max_age=None, memory_ttl=0)
|
snapshot = cache_manager.get(ERROR_SNAPSHOT_KEY, max_age=None, memory_ttl=0)
|
||||||
return snapshot if isinstance(snapshot, dict) else None
|
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)
|
||||||
|
|
||||||
|
|
||||||
def _epoch(iso: Any) -> Optional[float]:
|
def _epoch(iso: Any) -> Optional[float]:
|
||||||
@@ -692,6 +738,36 @@ def _epoch(iso: Any) -> Optional[float]:
|
|||||||
return None
|
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]:
|
def _empty_summary(snapshot: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"session_start": snapshot.get("session_start") if snapshot else None,
|
"session_start": snapshot.get("session_start") if snapshot else None,
|
||||||
@@ -704,11 +780,11 @@ def _empty_summary(snapshot: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def error_summary_from_report(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]:
|
||||||
"""The /errors/summary payload: get_error_summary()'s shape plus
|
"""The /errors/summary payload: get_error_summary()'s shape plus
|
||||||
``generated_at``, ``snapshot_available`` and ``clear_pending`` (always
|
``generated_at``, ``snapshot_available`` and ``clear_pending``."""
|
||||||
False now: a clear is applied before its route answers; kept for API
|
cutoff = _pending_cutoff(snapshot, clear_request)
|
||||||
compatibility)."""
|
|
||||||
summary = _empty_summary(snapshot)
|
summary = _empty_summary(snapshot)
|
||||||
if snapshot is not None:
|
if snapshot is not None:
|
||||||
for name, default in summary.items():
|
for name, default in summary.items():
|
||||||
@@ -717,17 +793,32 @@ def error_summary_from_report(snapshot: Optional[Dict[str, Any]]) -> Dict[str, A
|
|||||||
isinstance(default, float) and isinstance(value, int)) or (
|
isinstance(default, float) and isinstance(value, int)) or (
|
||||||
name == "session_start" and isinstance(value, str)):
|
name == "session_start" and isinstance(value, str)):
|
||||||
summary[name] = value
|
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["generated_at"] = snapshot.get("generated_at") if snapshot else None
|
||||||
summary["snapshot_available"] = snapshot is not None
|
summary["snapshot_available"] = snapshot is not None
|
||||||
summary["clear_pending"] = False
|
summary["clear_pending"] = cutoff is not None
|
||||||
return summary
|
return summary
|
||||||
|
|
||||||
|
|
||||||
def plugin_health_from_report(snapshot: Optional[Dict[str, Any]],
|
def plugin_health_from_report(snapshot: Optional[Dict[str, Any]],
|
||||||
|
clear_request: Optional[Dict[str, Any]],
|
||||||
plugin_id: str) -> Dict[str, Any]:
|
plugin_id: str) -> Dict[str, Any]:
|
||||||
"""The /errors/plugin/<id> payload: get_plugin_health()'s shape plus
|
"""The /errors/plugin/<id> payload: get_plugin_health()'s shape plus
|
||||||
``generated_at``, ``snapshot_available`` and ``clear_pending`` (always
|
``generated_at``, ``snapshot_available`` and ``clear_pending``."""
|
||||||
False, as in error_summary_from_report)."""
|
|
||||||
health: Dict[str, Any] = {
|
health: Dict[str, Any] = {
|
||||||
"plugin_id": plugin_id,
|
"plugin_id": plugin_id,
|
||||||
"status": "healthy",
|
"status": "healthy",
|
||||||
@@ -736,15 +827,19 @@ def plugin_health_from_report(snapshot: Optional[Dict[str, Any]],
|
|||||||
"recent_error_count": 0,
|
"recent_error_count": 0,
|
||||||
"last_error": None,
|
"last_error": None,
|
||||||
}
|
}
|
||||||
|
cutoff = _pending_cutoff(snapshot, clear_request)
|
||||||
table = snapshot.get("plugin_health") if snapshot else None
|
table = snapshot.get("plugin_health") if snapshot else None
|
||||||
entry = table.get(plugin_id) if isinstance(table, dict) else None
|
entry = table.get(plugin_id) if isinstance(table, dict) else None
|
||||||
if isinstance(entry, dict):
|
if isinstance(entry, dict):
|
||||||
for name in ("status", "total_errors", "error_types", "recent_error_count", "last_error"):
|
# last_error is the plugin's newest error: if even that predates a
|
||||||
if name in entry:
|
# pending clear, so does everything else the display reported for it.
|
||||||
health[name] = entry[name]
|
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]
|
||||||
health["generated_at"] = snapshot.get("generated_at") if snapshot else None
|
health["generated_at"] = snapshot.get("generated_at") if snapshot else None
|
||||||
health["snapshot_available"] = snapshot is not None
|
health["snapshot_available"] = snapshot is not None
|
||||||
health["clear_pending"] = False
|
health["clear_pending"] = cutoff is not None
|
||||||
return health
|
return health
|
||||||
|
|
||||||
|
|
||||||
@@ -766,35 +861,61 @@ def _count_cleared(summary: Dict[str, Any], cutoff: float) -> Optional[int]:
|
|||||||
|
|
||||||
|
|
||||||
#: ``send(request_id, cutoff)`` hands a clear to the display over the control
|
#: ``send(request_id, cutoff)`` hands a clear to the display over the control
|
||||||
#: socket and returns its ErrorsClearResult. It raises when the socket could
|
#: socket and returns its ErrorsClearResult, or None when the socket could
|
||||||
#: not carry it or the display failed it (``src.ipc.client.ControlError``).
|
#: not carry it and the mailbox should be written instead. Any exception it
|
||||||
ClearSender = Callable[[str, float], Dict[str, Any]]
|
#: raises reaches the caller: the display had the request and failed it.
|
||||||
|
ClearSender = Callable[[str, float], Optional[Dict[str, Any]]]
|
||||||
|
|
||||||
|
|
||||||
def request_error_clear(cache_manager: Any, cutoff: float,
|
def request_error_clear(cache_manager: Any, cutoff: float,
|
||||||
send: ClearSender) -> Dict[str, Any]:
|
send: Optional[ClearSender] = None) -> Dict[str, Any]:
|
||||||
"""Ask the display service to forget errors recorded at or before ``cutoff``.
|
"""Ask the display service to forget errors recorded at or before ``cutoff``.
|
||||||
|
|
||||||
Over the control socket: the display applies the clear and republishes
|
Over the control socket when ``send`` is given and carries it: the
|
||||||
its snapshot before it answers, so nothing is written here. Whatever
|
display applies the clear and republishes its snapshot before it
|
||||||
``send`` raises reaches the caller.
|
answers, so nothing is written here. Otherwise (no socket, or a display
|
||||||
|
older than ``errors.clear``) a request is written to the
|
||||||
|
``plugin_error_clear_request`` mailbox, which the display applies on
|
||||||
|
its next tick, and readers hide the cleared errors until then.
|
||||||
|
|
||||||
Returns ``request_id``, ``cutoff`` (ISO, local time), ``cleared_count``
|
Returns ``request_id``, ``cutoff`` (ISO, local time), ``cleared_count``,
|
||||||
(the display's own count, else an estimate from the snapshot; see
|
``clear_requested``, ``applied`` (the display has already cleared them)
|
||||||
_count_cleared), and ``clear_requested``, ``applied`` and ``transport``,
|
and ``transport`` (``socket`` or ``mailbox``). ``cleared_count`` is the
|
||||||
which are always True, True and ``"socket"`` now and are kept for API
|
display's own count over the socket, else an estimate from the snapshot
|
||||||
compatibility.
|
(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.
|
||||||
"""
|
"""
|
||||||
before = error_summary_from_report(read_error_report(cache_manager))
|
snapshot, clear_request = read_error_report(cache_manager)
|
||||||
|
pending = _pending_cutoff(snapshot, clear_request)
|
||||||
|
if pending is not None:
|
||||||
|
cutoff = max(cutoff, pending)
|
||||||
|
before = error_summary_from_report(snapshot, clear_request)
|
||||||
request_id = uuid.uuid4().hex
|
request_id = uuid.uuid4().hex
|
||||||
result = send(request_id, cutoff)
|
answer = {
|
||||||
count = result.get("cleared") if isinstance(result, dict) else None
|
|
||||||
return {
|
|
||||||
"clear_requested": True,
|
"clear_requested": True,
|
||||||
"request_id": request_id,
|
"request_id": request_id,
|
||||||
"cutoff": datetime.fromtimestamp(cutoff).isoformat(),
|
"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))
|
||||||
|
|||||||
+28
-23
@@ -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
|
error the display returned -- raises :class:`ControlError` with a short
|
||||||
``reason``. Nothing here blocks for longer than ``timeout`` in total.
|
``reason``. Nothing here blocks for longer than ``timeout`` in total.
|
||||||
|
|
||||||
There is no other way to reach the display: the file mailboxes the web
|
Whether the caller may then write the file mailbox instead is
|
||||||
interface used to fall back to are gone. :func:`display_not_listening` tells
|
:func:`should_fall_back`: only when the display never took the request (it
|
||||||
a caller when no display is listening yet (it is stopped, or still
|
could not be reached, or it is too old to know the command). A display that
|
||||||
starting), the one case where sending the same request again later, once a
|
took the request and then failed, refused or went quiet is answered as
|
||||||
display is up, can work.
|
that, not posted a second time through the mailbox.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -43,7 +43,8 @@ from src.ipc.contract import (
|
|||||||
|
|
||||||
#: Total budget for one request: connect, send and the reply. The display
|
#: Total budget for one request: connect, send and the reply. The display
|
||||||
#: answers from a thread that does no rendering, normally within a few
|
#: answers from a thread that does no rendering, normally within a few
|
||||||
#: milliseconds; this only bounds a wedged one.
|
#: 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.
|
||||||
DEFAULT_TIMEOUT_SECONDS = 1.0
|
DEFAULT_TIMEOUT_SECONDS = 1.0
|
||||||
|
|
||||||
|
|
||||||
@@ -73,25 +74,28 @@ class ControlError(Exception):
|
|||||||
|
|
||||||
#: Answers from a display that read the request but does not speak it: one
|
#: 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.
|
#: 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})
|
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``?
|
||||||
|
|
||||||
def display_not_listening(error: BaseException) -> bool:
|
Yes when the display never took the request: there is no socket (the
|
||||||
"""True when ``error`` says no display took the request because none is
|
display is stopped, predates the socket, or it is switched off), the
|
||||||
listening: it never reached one (``sent`` is False) and the reason is in
|
connection was refused or timed out, the display turned the connection
|
||||||
:data:`NOT_LISTENING_REASONS`. A display that is started, or finishes
|
away before reading it, or it is too old to know the command
|
||||||
starting, may take the same request later. False for everything else:
|
(:data:`UPGRADE_REASONS`). Also for an error that is not a
|
||||||
a display that had the request and failed it, one too old to know the
|
:class:`ControlError` (a bug in the client), as before.
|
||||||
command, a client that cannot use the socket at all (``disabled``,
|
|
||||||
``unsupported``), and an exception that is not a :class:`ControlError`.
|
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.
|
||||||
"""
|
"""
|
||||||
return (isinstance(error, ControlError) and not error.sent
|
if not isinstance(error, ControlError):
|
||||||
and error.reason in NOT_LISTENING_REASONS)
|
return True
|
||||||
|
return not error.sent or error.reason in UPGRADE_REASONS
|
||||||
|
|
||||||
|
|
||||||
def request(cmd: str, args: Optional[Mapping[str, Any]] = None, *,
|
def request(cmd: str, args: Optional[Mapping[str, Any]] = None, *,
|
||||||
@@ -214,8 +218,8 @@ def on_demand_start(request_id: str, plugin_id: Optional[str], mode: Optional[st
|
|||||||
paths: Optional[Sequence[str]] = None) -> Dict[str, Any]:
|
paths: Optional[Sequence[str]] = None) -> Dict[str, Any]:
|
||||||
"""Ask the display to show a plugin now. Returns the ack; raises :class:`ControlError`.
|
"""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 sent
|
``request_id`` doubles as the on-demand request id, so a request that a
|
||||||
twice with the same id is processed only once.
|
timed-out caller then also writes to the mailbox is processed only once.
|
||||||
"""
|
"""
|
||||||
args = {'plugin_id': plugin_id, 'mode': mode, 'duration': duration, 'pinned': pinned}
|
args = {'plugin_id': plugin_id, 'mode': mode, 'duration': duration, 'pinned': pinned}
|
||||||
return request(Command.ON_DEMAND_START, args, request_id=request_id,
|
return request(Command.ON_DEMAND_START, args, request_id=request_id,
|
||||||
@@ -277,7 +281,8 @@ def errors_clear(request_id: str, cutoff: float, *,
|
|||||||
|
|
||||||
Returns :class:`~src.ipc.contract.ErrorsClearResult` once it is done.
|
Returns :class:`~src.ipc.contract.ErrorsClearResult` once it is done.
|
||||||
Raises :class:`ControlError`: ``unknown_command`` from a display older
|
Raises :class:`ControlError`: ``unknown_command`` from a display older
|
||||||
than the command.
|
than the command, which still reads the ``plugin_error_clear_request``
|
||||||
|
mailbox.
|
||||||
"""
|
"""
|
||||||
return request(Command.ERRORS_CLEAR, {'cutoff': cutoff}, request_id=request_id,
|
return request(Command.ERRORS_CLEAR, {'cutoff': cutoff}, request_id=request_id,
|
||||||
timeout=timeout, paths=paths)
|
timeout=timeout, paths=paths)
|
||||||
|
|||||||
+11
-10
@@ -85,8 +85,8 @@ DEFAULT_SOCKET_PATH = DEFAULT_SOCKET_DIR + '/' + SOCKET_NAME
|
|||||||
|
|
||||||
#: Overrides the socket path for both processes (a dev checkout, a second
|
#: Overrides the socket path for both processes (a dev checkout, a second
|
||||||
#: instance, tests). One of :data:`DISABLED_VALUES` turns the socket off: the
|
#: instance, tests). One of :data:`DISABLED_VALUES` turns the socket off: the
|
||||||
#: display does not serve it, and the web interface cannot send it commands
|
#: display does not serve it and the web interface goes straight to the
|
||||||
#: (it still reads the state the display writes to the cache).
|
#: file mailbox.
|
||||||
SOCKET_PATH_ENV = 'LEDMATRIX_CONTROL_SOCKET'
|
SOCKET_PATH_ENV = 'LEDMATRIX_CONTROL_SOCKET'
|
||||||
DISABLED_VALUES = frozenset({'off', '0', 'false', 'no', 'none', 'disabled'})
|
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]:
|
def _optional_duration(value: Any) -> Optional[float]:
|
||||||
"""Seconds, or None for "until stopped". 0 means the same as None.
|
"""Seconds, or None for "until stopped". 0 means the same as None.
|
||||||
|
|
||||||
Numbers and numeric strings are accepted, the same as the REST route
|
Numbers and numeric strings are accepted, the same as the REST route and
|
||||||
takes them; anything else is refused rather than guessed.
|
the file mailbox take them; anything else is refused rather than guessed.
|
||||||
"""
|
"""
|
||||||
if value is None or value == '':
|
if value is None or value == '':
|
||||||
return None
|
return None
|
||||||
@@ -415,7 +415,7 @@ class HelloArgs:
|
|||||||
class OnDemandStartArgs:
|
class OnDemandStartArgs:
|
||||||
"""``on_demand.start``: show a plugin (or one of its modes) now.
|
"""``on_demand.start``: show a plugin (or one of its modes) now.
|
||||||
|
|
||||||
The same fields as the REST route's body. At least one of ``plugin_id``
|
The same fields the file mailbox carries. At least one of ``plugin_id``
|
||||||
and ``mode`` is required; the display resolves the other.
|
and ``mode`` is required; the display resolves the other.
|
||||||
"""
|
"""
|
||||||
plugin_id: Optional[str] = None
|
plugin_id: Optional[str] = None
|
||||||
@@ -600,12 +600,13 @@ def parse_args(cmd: str, args: Mapping[str, Any]) -> CommandArgs:
|
|||||||
|
|
||||||
def on_demand_request(request_id: str, args: Union[OnDemandStartArgs, OnDemandStopArgs],
|
def on_demand_request(request_id: str, args: Union[OnDemandStartArgs, OnDemandStopArgs],
|
||||||
timestamp: float) -> Dict[str, Any]:
|
timestamp: float) -> Dict[str, Any]:
|
||||||
"""The on-demand request dict for a queued on-demand command.
|
"""The file-mailbox payload for a queued on-demand command.
|
||||||
|
|
||||||
The display hands socket commands to the same code that handles
|
The display hands socket commands to the same code that handles the
|
||||||
plugins' own requests (``DisplayController._handle_on_demand_request``),
|
mailbox (``DisplayController._handle_on_demand_request``), so a command
|
||||||
so a command behaves identically whichever way it arrived. (This was
|
behaves identically whichever way it arrived, and a request that came
|
||||||
the file mailbox's payload, which the display no longer reads.)
|
both ways (a client that timed out and fell back) is processed once: the
|
||||||
|
request id is the same.
|
||||||
"""
|
"""
|
||||||
if isinstance(args, OnDemandStartArgs):
|
if isinstance(args, OnDemandStartArgs):
|
||||||
return {'request_id': request_id, 'action': 'start', 'plugin_id': args.plugin_id,
|
return {'request_id': request_id, 'action': 'start', 'plugin_id': args.plugin_id,
|
||||||
|
|||||||
+12
-13
@@ -3,9 +3,9 @@
|
|||||||
A small threaded server on a Unix stream socket (``/run/ledmatrix/control.sock``
|
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
|
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
|
rendering: a command that changes the panel is validated, put on a bounded
|
||||||
queue and acknowledged, and the render thread drains that queue
|
queue and acknowledged, and the render thread drains that queue at the point
|
||||||
(``DisplayController._poll_on_demand_requests``), handing each on-demand
|
where it reads the file mailbox (``DisplayController._poll_on_demand_requests``),
|
||||||
command to the code that handles plugins' own requests. Queries (``on_demand.status``) are
|
handing each command to the same code. Queries (``on_demand.status``) are
|
||||||
answered from a snapshot callable the display provides, and the few commands
|
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
|
that touch nothing the render thread owns (``errors.clear``) by a handler the
|
||||||
display registers, on the connection thread.
|
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
|
#: Commands waiting for the render thread. It drains them at least every
|
||||||
#: 0.25 s, so a full queue means the render thread is stuck, and the client
|
#: 0.25 s, so a full queue means the render thread is stuck, and the client
|
||||||
#: is told ``busy`` instead of piling up work, and the web interface reports
|
#: is told ``busy`` instead of piling up work. The mailbox would not be read
|
||||||
#: the failure.
|
#: either, so the web interface reports the failure rather than fall back.
|
||||||
QUEUE_SIZE = 16
|
QUEUE_SIZE = 16
|
||||||
|
|
||||||
#: Timeout for one recv()/send() on a connection.
|
#: Timeout for one recv()/send() on a connection.
|
||||||
@@ -185,7 +185,7 @@ class QueuedCommand:
|
|||||||
outcome: Optional[CommandOutcome] = field(default=None, compare=False, repr=False)
|
outcome: Optional[CommandOutcome] = field(default=None, compare=False, repr=False)
|
||||||
|
|
||||||
def as_on_demand_request(self) -> Dict[str, Any]:
|
def as_on_demand_request(self) -> Dict[str, Any]:
|
||||||
"""The on-demand request dict the display's on-demand handler takes."""
|
"""The mailbox-shaped payload the display's on-demand handler takes."""
|
||||||
if not isinstance(self.args, (OnDemandStartArgs, OnDemandStopArgs)):
|
if not isinstance(self.args, (OnDemandStartArgs, OnDemandStopArgs)):
|
||||||
raise TypeError(f'{self.cmd} is not an on-demand command')
|
raise TypeError(f'{self.cmd} is not an on-demand command')
|
||||||
return on_demand_request(self.request_id, self.args, self.received_at)
|
return on_demand_request(self.request_id, self.args, self.received_at)
|
||||||
@@ -619,8 +619,8 @@ class ControlServer:
|
|||||||
def start(self) -> bool:
|
def start(self) -> bool:
|
||||||
"""Bind and start serving. False (logged) when the socket cannot be served.
|
"""Bind and start serving. False (logged) when the socket cannot be served.
|
||||||
|
|
||||||
Never raises: without the socket the display runs, but the web
|
Never raises: without the socket the web interface uses the file
|
||||||
interface cannot send it commands.
|
mailbox, exactly as before.
|
||||||
"""
|
"""
|
||||||
if not socket_supported():
|
if not socket_supported():
|
||||||
logger.debug("Control socket not started: no Unix sockets on this platform")
|
logger.debug("Control socket not started: no Unix sockets on this platform")
|
||||||
@@ -632,7 +632,7 @@ class ControlServer:
|
|||||||
self._bind()
|
self._bind()
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
logger.warning("Control socket not started at %s (%s); the web interface "
|
logger.warning("Control socket not started at %s (%s); the web interface "
|
||||||
"cannot send this display commands", self.path, e)
|
"will use the file mailbox", self.path, e)
|
||||||
self._close_socket()
|
self._close_socket()
|
||||||
return False
|
return False
|
||||||
self._stopping.clear()
|
self._stopping.clear()
|
||||||
@@ -1134,13 +1134,12 @@ def start_control_server(status_provider: Optional[StatusProvider] = None,
|
|||||||
"""Start the display's control socket, or return None when it can't run.
|
"""Start the display's control socket, or return None when it can't run.
|
||||||
|
|
||||||
None covers Windows, ``LEDMATRIX_CONTROL_SOCKET=off`` and any failure to
|
None covers Windows, ``LEDMATRIX_CONTROL_SOCKET=off`` and any failure to
|
||||||
bind; in every case the web interface cannot send the display commands,
|
bind; in every case the web interface falls back to the file mailbox
|
||||||
and reads the cache keys the display still writes.
|
and to the cache keys the display still writes.
|
||||||
"""
|
"""
|
||||||
path = server_socket_path(environ)
|
path = server_socket_path(environ)
|
||||||
if path is None:
|
if path is None:
|
||||||
logger.debug("Control socket disabled or unsupported here; the web interface "
|
logger.debug("Control socket disabled or unsupported here; using the file mailbox only")
|
||||||
"cannot send this display commands")
|
|
||||||
return None
|
return None
|
||||||
server = ControlServer(path, status_provider, resolve_socket_group(cache_dir),
|
server = ControlServer(path, status_provider, resolve_socket_group(cache_dir),
|
||||||
state_hub=state_hub, handlers=handlers)
|
state_hub=state_hub, handlers=handlers)
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
"""Schedule windows: the display's on/off schedule and per-plugin schedules.
|
|
||||||
|
|
||||||
A schedule is the dict config.json keeps under ``schedule``::
|
|
||||||
|
|
||||||
{"enabled": true, "mode": "global" | "per-day",
|
|
||||||
"start_time": "07:00", "end_time": "23:00",
|
|
||||||
"days": {"monday": {"enabled": true, "start_time": ..., "end_time": ...}, ...}}
|
|
||||||
|
|
||||||
``config['plugin_schedules']`` maps a plugin id to one of these, the window in
|
|
||||||
which that plugin may take a turn on the panel, plus ``hide_when_live``: hide
|
|
||||||
the plugin while any plugin reports a live game ("no news or stocks on
|
|
||||||
gameday"). A plugin without an entry always shows. Hiding is a skip at
|
|
||||||
display time, never the plugin's ``enabled`` flag: flipping that unloads and
|
|
||||||
reloads the plugin and throws away its data.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from datetime import datetime, time
|
|
||||||
from typing import Any, FrozenSet, Mapping, Tuple
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def in_window(start: time, end: time, now: time) -> bool:
|
|
||||||
"""Whether ``now`` is within [start, end), a window that may span midnight.
|
|
||||||
|
|
||||||
Half-open: on from the start minute, off at exactly the end minute.
|
|
||||||
A closed end made the end minute count as inside, and because ``now``
|
|
||||||
carries seconds, only a check at hh:mm:00.000 saw it that way -- so
|
|
||||||
whether the panel went off at the start or the end of that minute
|
|
||||||
depended on when the minute's one check ran. ``start == end`` is an
|
|
||||||
empty window, as it effectively was before.
|
|
||||||
"""
|
|
||||||
if start <= end:
|
|
||||||
return start <= now < end
|
|
||||||
return now >= start or now < end
|
|
||||||
|
|
||||||
|
|
||||||
def schedule_state(schedule: Mapping[str, Any], now: datetime) -> Tuple[bool, str]:
|
|
||||||
"""Whether ``now`` is inside ``schedule``, and a phrase saying why.
|
|
||||||
|
|
||||||
No schedule, or ``enabled: false``, is always inside; a schedule without
|
|
||||||
an ``enabled`` key counts as enabled. ``mode`` "global" ignores ``days``;
|
|
||||||
"per-day" (or "per_day") uses today's entry and falls back to the global
|
|
||||||
times when today has none; with no ``mode`` (older configs) a ``days``
|
|
||||||
entry for today wins. A disabled day is outside all day. A time that does
|
|
||||||
not parse is logged and counts as inside, so a bad edit never blanks the
|
|
||||||
panel.
|
|
||||||
"""
|
|
||||||
if not schedule or not schedule.get('enabled', True):
|
|
||||||
return True, 'no schedule'
|
|
||||||
|
|
||||||
day = now.strftime('%A').lower()
|
|
||||||
# Wrong-shaped values (the raw config editor allows any) are treated as
|
|
||||||
# missing rather than raised: this runs in the display loop.
|
|
||||||
days = schedule.get('days')
|
|
||||||
if not isinstance(days, Mapping):
|
|
||||||
days = {}
|
|
||||||
mode = schedule.get('mode')
|
|
||||||
mode = mode.replace('_', '-') if isinstance(mode, str) else None
|
|
||||||
# An explicit Global mode beats a stray 'days' dict left from a past
|
|
||||||
# per-day setup.
|
|
||||||
per_day = mode != 'global' and isinstance(days.get(day), Mapping)
|
|
||||||
|
|
||||||
if per_day:
|
|
||||||
day_config = days[day]
|
|
||||||
if not day_config.get('enabled', True):
|
|
||||||
return False, f'{day} is disabled'
|
|
||||||
start_str = day_config.get('start_time', '07:00')
|
|
||||||
end_str = day_config.get('end_time', '23:00')
|
|
||||||
kind = f'per-day ({day})'
|
|
||||||
else:
|
|
||||||
start_str = schedule.get('start_time', '07:00')
|
|
||||||
end_str = schedule.get('end_time', '23:00')
|
|
||||||
kind = 'global'
|
|
||||||
|
|
||||||
try:
|
|
||||||
start = datetime.strptime(start_str, '%H:%M').time()
|
|
||||||
end = datetime.strptime(end_str, '%H:%M').time()
|
|
||||||
except (TypeError, ValueError) as e:
|
|
||||||
logger.warning("Invalid %s schedule: %s (start: %s, end: %s). Treating as inside.",
|
|
||||||
kind, e, start_str, end_str)
|
|
||||||
return True, f'invalid {kind} schedule'
|
|
||||||
return in_window(start, end, now.time()), f'{kind} window {start_str} - {end_str}'
|
|
||||||
|
|
||||||
|
|
||||||
def wants_live_scan(plugin_schedules: Mapping[str, Any]) -> bool:
|
|
||||||
"""Whether any plugin hides during live games, so live content is worth asking about."""
|
|
||||||
return any(isinstance(s, Mapping) and s.get('hide_when_live')
|
|
||||||
for s in plugin_schedules.values())
|
|
||||||
|
|
||||||
|
|
||||||
def hidden_plugins(plugin_schedules: Mapping[str, Any], now: datetime,
|
|
||||||
game_live: bool) -> FrozenSet[str]:
|
|
||||||
"""The plugin ids that may not show at ``now``.
|
|
||||||
|
|
||||||
A plugin is hidden outside its schedule window, or while ``game_live``
|
|
||||||
when its entry sets ``hide_when_live``.
|
|
||||||
"""
|
|
||||||
return frozenset(
|
|
||||||
plugin_id for plugin_id, schedule in plugin_schedules.items()
|
|
||||||
if isinstance(schedule, Mapping)
|
|
||||||
and ((game_live and schedule.get('hide_when_live'))
|
|
||||||
or not schedule_state(schedule, now)[0]))
|
|
||||||
@@ -1114,16 +1114,16 @@ class BasePlugin(ABC):
|
|||||||
Returns:
|
Returns:
|
||||||
The request id once the display has queued it, or None when
|
The request id once the display has queued it, or None when
|
||||||
there is no display in this process to ask (the web interface,
|
there is no display in this process to ask (the web interface,
|
||||||
scripts/check_plugin.py) or its queue is full. Nothing else can
|
scripts/check_plugin.py) or its queue is full. A plugin that
|
||||||
take the request then: the ``display_on_demand_request`` file
|
also runs on cores without this method writes the
|
||||||
mailbox older cores read is gone, and a write to it is dropped
|
``display_on_demand_request`` mailbox on None, as before; see
|
||||||
with a warning. See "On-demand display" in
|
"On-demand display" in docs/PLUGIN_API_REFERENCE.md.
|
||||||
docs/PLUGIN_API_REFERENCE.md.
|
|
||||||
|
|
||||||
Example::
|
Example::
|
||||||
|
|
||||||
if self.request_on_demand(mode='my_alert', duration=15) is None:
|
if not (hasattr(self, 'request_on_demand')
|
||||||
self.logger.info("No display to show the alert on")
|
and self.request_on_demand(mode='my_alert', duration=15)):
|
||||||
|
self._write_on_demand_mailbox(...) # older cores
|
||||||
"""
|
"""
|
||||||
request = getattr(getattr(self, 'plugin_manager', None), 'request_on_demand', None)
|
request = getattr(getattr(self, 'plugin_manager', None), 'request_on_demand', None)
|
||||||
if not callable(request):
|
if not callable(request):
|
||||||
|
|||||||
@@ -1869,7 +1869,7 @@ class PluginManager:
|
|||||||
"""Route plugins' on-demand requests to ``handler`` (None: nowhere).
|
"""Route plugins' on-demand requests to ``handler`` (None: nowhere).
|
||||||
|
|
||||||
The display controller sets its ``submit_plugin_on_demand`` here
|
The display controller sets its ``submit_plugin_on_demand`` here
|
||||||
before any plugin loads. The handler takes an on-demand request dict
|
before any plugin loads. The handler takes a mailbox-shaped request
|
||||||
from any thread, queues it for the render thread and returns True,
|
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
|
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
|
web interface's, a test's, scripts/check_plugin.py's) has no screen
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ from typing import Optional, Dict, Any, FrozenSet, List, Callable, TYPE_CHECKING
|
|||||||
from src import display_watchdog
|
from src import display_watchdog
|
||||||
from src.common import render_gate
|
from src.common import render_gate
|
||||||
from src.plugin_system.base_plugin import finite_seconds
|
from src.plugin_system.base_plugin import finite_seconds
|
||||||
from src.screen_runner import HIGH_FPS_INTERVAL, STATIC_INTERVAL
|
|
||||||
from src.vegas_mode.config import VegasModeConfig
|
from src.vegas_mode.config import VegasModeConfig
|
||||||
from src.vegas_mode.elements import LiveEpochs
|
from src.vegas_mode.elements import LiveEpochs
|
||||||
from src.vegas_mode.plugin_adapter import PluginAdapter
|
from src.vegas_mode.plugin_adapter import PluginAdapter
|
||||||
@@ -303,9 +302,7 @@ class VegasModeCoordinator:
|
|||||||
|
|
||||||
# Initialize stream manager
|
# Initialize stream manager
|
||||||
if not self.stream_manager.initialize():
|
if not self.stream_manager.initialize():
|
||||||
# Only fails with no plugin to show (every one excluded or
|
logger.error("Failed to initialize stream manager")
|
||||||
# scheduled off), which initialize() has logged once.
|
|
||||||
logger.debug("Vegas not started: no plugins to scroll")
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Compose initial content
|
# Compose initial content
|
||||||
@@ -973,38 +970,25 @@ class VegasModeCoordinator:
|
|||||||
# turn rather than stall the marquee.
|
# turn rather than stall the marquee.
|
||||||
get_lock = getattr(self.plugin_manager, 'get_plugin_lock', None)
|
get_lock = getattr(self.plugin_manager, 'get_plugin_lock', None)
|
||||||
plugin_lock = get_lock(plugin_id) if get_lock else None
|
plugin_lock = get_lock(plugin_id) if get_lock else None
|
||||||
if not self._draw_static(plugin, plugin_lock, self.STATIC_LOCK_TIMEOUT,
|
if plugin_lock is not None and not plugin_lock.acquire(
|
||||||
force_clear=True):
|
timeout=self.STATIC_LOCK_TIMEOUT):
|
||||||
logger.info("Static pause skipped for %s: its update() is still running",
|
logger.info("Static pause skipped for %s: its update() is still running",
|
||||||
plugin_id)
|
plugin_id)
|
||||||
return True
|
return True
|
||||||
|
try:
|
||||||
|
plugin.display(force_clear=True)
|
||||||
|
finally:
|
||||||
|
if plugin_lock is not None:
|
||||||
|
plugin_lock.release()
|
||||||
|
self.display_manager.update_display()
|
||||||
|
|
||||||
# Wait for the plugin's display duration. Monotonic, like the
|
# Wait for the plugin's display duration. Monotonic, like the
|
||||||
# iteration clock: an NTP step on an RTC-less Pi would otherwise
|
# iteration clock: an NTP step on an RTC-less Pi would otherwise
|
||||||
# end the pause at once or stretch it by the correction.
|
# end the pause at once or stretch it by the correction.
|
||||||
duration = self._static_pause_duration(plugin)
|
duration = self._static_pause_duration(plugin)
|
||||||
start = time.monotonic()
|
start = time.monotonic()
|
||||||
# Redraw for the whole pause at the rotation's own rate. One
|
|
||||||
# display() used to be all a pause got, so a plugin that rotates
|
|
||||||
# its screens or animates was frozen on its first frame.
|
|
||||||
interval = (HIGH_FPS_INTERVAL if self._static_needs_high_fps(plugin)
|
|
||||||
else STATIC_INTERVAL)
|
|
||||||
next_draw = start + interval
|
|
||||||
next_check = start
|
|
||||||
|
|
||||||
while (now := time.monotonic()) - start < duration:
|
|
||||||
if now >= next_draw:
|
|
||||||
next_draw = max(next_draw, now) + interval
|
|
||||||
# No wait for the lock: update() holding it costs this
|
|
||||||
# frame only, and the next one tries again.
|
|
||||||
self._draw_static(plugin, plugin_lock, 0)
|
|
||||||
if now < next_check:
|
|
||||||
time.sleep(max(0.0, min(next_draw, next_check) - time.monotonic()))
|
|
||||||
continue
|
|
||||||
# Every 0.1 s, however fast the redraws: the live-priority
|
|
||||||
# check asks the plugins.
|
|
||||||
next_check = now + 0.1
|
|
||||||
|
|
||||||
|
while time.monotonic() - start < duration:
|
||||||
# Check for interruptions
|
# Check for interruptions
|
||||||
if self._should_stop:
|
if self._should_stop:
|
||||||
logger.info("Static pause interrupted by stop request")
|
logger.info("Static pause interrupted by stop request")
|
||||||
@@ -1019,8 +1003,9 @@ class VegasModeCoordinator:
|
|||||||
logger.info("Static pause interrupted by the display controller")
|
logger.info("Static pause interrupted by the display controller")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# Sleep in small increments to remain responsive
|
||||||
|
time.sleep(0.1)
|
||||||
display_watchdog.beat()
|
display_watchdog.beat()
|
||||||
time.sleep(max(0.0, min(next_draw, next_check) - time.monotonic()))
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Static pause completed for %s after %.1fs",
|
"Static pause completed for %s after %.1fs",
|
||||||
@@ -1036,35 +1021,6 @@ class VegasModeCoordinator:
|
|||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _draw_static(self, plugin: 'BasePlugin', plugin_lock: Any,
|
|
||||||
timeout: float, **kwargs: Any) -> bool:
|
|
||||||
"""One display() of a static pause, under the plugin's lock like
|
|
||||||
every other display() call: without it this could draw while the
|
|
||||||
update worker is inside the plugin's update(). False when update()
|
|
||||||
held the lock past ``timeout`` and nothing was drawn."""
|
|
||||||
if plugin_lock is not None and not plugin_lock.acquire(timeout=timeout):
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
plugin.display(**kwargs)
|
|
||||||
finally:
|
|
||||||
if plugin_lock is not None:
|
|
||||||
plugin_lock.release()
|
|
||||||
self.display_manager.update_display()
|
|
||||||
return True
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _static_needs_high_fps(plugin: 'BasePlugin') -> bool:
|
|
||||||
"""Whether the rotation would run ``plugin`` in its high-FPS loop:
|
|
||||||
DisplayController._needs_high_fps's rule. Older static-image
|
|
||||||
versions, which do not declare needs_high_fps, get high FPS for
|
|
||||||
their GIFs there too."""
|
|
||||||
declared = getattr(plugin, 'needs_high_fps', None)
|
|
||||||
if declared is not None:
|
|
||||||
return bool(declared)
|
|
||||||
if getattr(plugin, 'plugin_id', None) == 'static-image':
|
|
||||||
return True
|
|
||||||
return bool(getattr(plugin, 'enable_scrolling', False))
|
|
||||||
|
|
||||||
def _static_pause_duration(self, plugin: 'BasePlugin') -> float:
|
def _static_pause_duration(self, plugin: 'BasePlugin') -> float:
|
||||||
"""Seconds a static pause shows ``plugin``: its display duration,
|
"""Seconds a static pause shows ``plugin``: its display duration,
|
||||||
read the way the rotation reads it.
|
read the way the rotation reads it.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ BasePlugin.get_vegas_participation):
|
|||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from typing import Callable, Optional, List, Dict, Deque, FrozenSet, Tuple, TYPE_CHECKING
|
from typing import Optional, List, Dict, Deque, Tuple, TYPE_CHECKING
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
@@ -75,16 +75,6 @@ class StreamManager:
|
|||||||
self.config = config
|
self.config = config
|
||||||
self.plugin_manager = plugin_manager
|
self.plugin_manager = plugin_manager
|
||||||
self.plugin_adapter = plugin_adapter
|
self.plugin_adapter = plugin_adapter
|
||||||
# Plugin ids to leave out right now (config['plugin_schedules']);
|
|
||||||
# the display controller sets it. Read at each list refresh.
|
|
||||||
# A change to it rebuilds the list at the next refresh() even inside
|
|
||||||
# the refresh interval; segments already composed still scroll out.
|
|
||||||
self.hidden_plugins: Optional[Callable[[], FrozenSet[str]]] = None
|
|
||||||
self._applied_hidden: FrozenSet[str] = frozenset()
|
|
||||||
# True once "no plugins" has been logged, until the list fills again:
|
|
||||||
# with every plugin scheduled off the display retries every second,
|
|
||||||
# and three log lines a second is a full journal on an SD card.
|
|
||||||
self._empty_logged = False
|
|
||||||
|
|
||||||
# Segments composed into the current cycle (swap mode only).
|
# Segments composed into the current cycle (swap mode only).
|
||||||
self._active_buffer: Deque[ContentSegment] = deque()
|
self._active_buffer: Deque[ContentSegment] = deque()
|
||||||
@@ -116,11 +106,8 @@ class StreamManager:
|
|||||||
self._refresh_plugin_list()
|
self._refresh_plugin_list()
|
||||||
|
|
||||||
if not self._ordered_plugins:
|
if not self._ordered_plugins:
|
||||||
if not self._empty_logged:
|
logger.warning("No plugins available for Vegas scroll")
|
||||||
logger.warning("No plugins available for Vegas scroll")
|
|
||||||
self._empty_logged = True
|
|
||||||
return False
|
return False
|
||||||
self._empty_logged = False
|
|
||||||
|
|
||||||
# Fill the buffer to a whole cycle's worth of plugins. This used to be
|
# Fill the buffer to a whole cycle's worth of plugins. This used to be
|
||||||
# buffer_ahead + 1, which conflated prefetch depth with cycle size and
|
# buffer_ahead + 1, which conflated prefetch depth with cycle size and
|
||||||
@@ -281,9 +268,7 @@ class StreamManager:
|
|||||||
Called periodically to pick up new plugins or config changes.
|
Called periodically to pick up new plugins or config changes.
|
||||||
"""
|
"""
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
hidden = self.hidden_plugins() if self.hidden_plugins else frozenset()
|
if current_time - self._last_refresh < self._refresh_interval:
|
||||||
if (current_time - self._last_refresh < self._refresh_interval
|
|
||||||
and hidden == self._applied_hidden):
|
|
||||||
return
|
return
|
||||||
|
|
||||||
self._last_refresh = current_time
|
self._last_refresh = current_time
|
||||||
@@ -310,15 +295,10 @@ class StreamManager:
|
|||||||
# own thread (DisplayController._start_plugin_reload).
|
# own thread (DisplayController._start_plugin_reload).
|
||||||
plugins = list(self.plugin_manager.plugins.items())
|
plugins = list(self.plugin_manager.plugins.items())
|
||||||
loaded = len(plugins)
|
loaded = len(plugins)
|
||||||
hidden = self.hidden_plugins() if self.hidden_plugins else frozenset()
|
|
||||||
self._applied_hidden = hidden
|
|
||||||
for plugin_id, plugin in plugins:
|
for plugin_id, plugin in plugins:
|
||||||
if not getattr(plugin, 'enabled', False):
|
if not getattr(plugin, 'enabled', False):
|
||||||
logger.debug("[%s] Vegas: skipped (not enabled)", plugin_id)
|
logger.debug("[%s] Vegas: skipped (not enabled)", plugin_id)
|
||||||
continue
|
continue
|
||||||
if plugin_id in hidden:
|
|
||||||
logger.debug("[%s] Vegas: skipped (scheduled off)", plugin_id)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 'pause' plugins stay in the rotation: they pause the scroll
|
# 'pause' plugins stay in the rotation: they pause the scroll
|
||||||
# for their turn rather than contributing to it.
|
# for their turn rather than contributing to it.
|
||||||
@@ -349,8 +329,7 @@ class StreamManager:
|
|||||||
|
|
||||||
slots = (f", {len(ordered_plugins)} slots"
|
slots = (f", {len(ordered_plugins)} slots"
|
||||||
if len(ordered_plugins) != len(set(ordered_plugins)) else "")
|
if len(ordered_plugins) != len(set(ordered_plugins)) else "")
|
||||||
logger.log(
|
logger.info(
|
||||||
logging.DEBUG if (self._empty_logged and not ordered_plugins) else logging.INFO,
|
|
||||||
"Vegas rotation: %d of %d loaded plugin(s)%s: %s",
|
"Vegas rotation: %d of %d loaded plugin(s)%s: %s",
|
||||||
len(set(ordered_plugins)), loaded, slots, ', '.join(ordered_plugins)
|
len(set(ordered_plugins)), loaded, slots, ', '.join(ordered_plugins)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -155,6 +155,13 @@ class FakeCache:
|
|||||||
self._writes += 1
|
self._writes += 1
|
||||||
self._written[key] = self._writes
|
self._written[key] = self._writes
|
||||||
|
|
||||||
|
def file_signature(self, key):
|
||||||
|
"""CacheManager.file_signature: None without a file, else a value
|
||||||
|
that changes with every write."""
|
||||||
|
if key not in self.data:
|
||||||
|
return None
|
||||||
|
return (self._written.get(key, 0), 0, 0)
|
||||||
|
|
||||||
def delete(self, key):
|
def delete(self, key):
|
||||||
self.data.pop(key, None)
|
self.data.pop(key, None)
|
||||||
|
|
||||||
@@ -722,23 +729,10 @@ class RunLoopHarness:
|
|||||||
self.controller.available_modes.append(mode)
|
self.controller.available_modes.append(mode)
|
||||||
|
|
||||||
def on_demand_request(self, t: float, request_id: str, action: str = "start", **fields):
|
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():
|
def post():
|
||||||
self.log("request", f"{action}:{request_id}")
|
self.log("request", f"{action}:{request_id}")
|
||||||
server.queue.append(command)
|
self.cache.set("display_on_demand_request",
|
||||||
|
{"request_id": request_id, "action": action, **fields})
|
||||||
self.clock.at(t, post)
|
self.clock.at(t, post)
|
||||||
|
|
||||||
def restore_on_demand(self, plugin_id: str, mode: Optional[str] = None,
|
def restore_on_demand(self, plugin_id: str, mode: Optional[str] = None,
|
||||||
|
|||||||
@@ -328,20 +328,6 @@ def _hermetic_control_socket(monkeypatch):
|
|||||||
monkeypatch.setenv(SOCKET_PATH_ENV, 'off')
|
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)
|
@pytest.fixture(autouse=True)
|
||||||
def _hermetic_unit_refresh(monkeypatch, tmp_path_factory):
|
def _hermetic_unit_refresh(monkeypatch, tmp_path_factory):
|
||||||
"""Keep updates' systemd unit refresh off the host.
|
"""Keep updates' systemd unit refresh off the host.
|
||||||
@@ -357,24 +343,6 @@ def _hermetic_unit_refresh(monkeypatch, tmp_path_factory):
|
|||||||
monkeypatch.setattr(unit_refresh, 'SYSTEMD_DIR', str(tmp_path_factory.getbasetemp() / 'no-systemd'))
|
monkeypatch.setattr(unit_refresh, 'SYSTEMD_DIR', str(tmp_path_factory.getbasetemp() / 'no-systemd'))
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _forget_settled_espn_chunks(monkeypatch):
|
|
||||||
"""src.common.espn_dates remembers past days process-wide; tests fake
|
|
||||||
different answers for the same dates, so none may inherit another's.
|
|
||||||
|
|
||||||
"Today" is also pinned to 2000-01-01, so no date a test uses counts as
|
|
||||||
settled unless the test says so (by pinning _utc_today itself). Without
|
|
||||||
that, a test asking for last month twice passes while that month is
|
|
||||||
recent and fails once it is three days old: the second ask is answered
|
|
||||||
from memory."""
|
|
||||||
from datetime import date
|
|
||||||
from src.common import espn_dates
|
|
||||||
monkeypatch.setattr(espn_dates, "_utc_today", lambda: date(2000, 1, 1))
|
|
||||||
espn_dates.clear_settled_chunk_cache()
|
|
||||||
yield
|
|
||||||
espn_dates.clear_settled_chunk_cache()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def reset_logging():
|
def reset_logging():
|
||||||
"""Reset logging configuration before each test."""
|
"""Reset logging configuration before each test."""
|
||||||
|
|||||||
Vendored
-17
@@ -159,23 +159,6 @@
|
|||||||
"POST"
|
"POST"
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
[
|
|
||||||
"/api/v3/config/plugin-schedules",
|
|
||||||
"api_v3.get_plugin_schedules",
|
|
||||||
[
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/v3/config/plugin-schedules",
|
|
||||||
"api_v3.save_plugin_schedule",
|
|
||||||
[
|
|
||||||
"OPTIONS",
|
|
||||||
"POST"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
[
|
||||||
"/api/v3/config/raw/main",
|
"/api/v3/config/raw/main",
|
||||||
"api_v3.save_raw_main_config",
|
"api_v3.save_raw_main_config",
|
||||||
|
|||||||
+3
-3
@@ -1,16 +1,16 @@
|
|||||||
{
|
{
|
||||||
"screens": [
|
"screens": [
|
||||||
[0.0, "clock", 20.0, "duration", 20, false],
|
[0.0, "clock", 20.0, "duration", 20, false],
|
||||||
[20.0, "weather", 5.0, "on-demand-start", 5, true],
|
[20.0, "weather", 5.0, "on-demand-start", 6, true],
|
||||||
[25.0, "sports_recent", 15.0, "duration", 15, true],
|
[25.0, "sports_recent", 15.0, "duration", 15, true],
|
||||||
[40.0, "sports_upcoming", 15.0, "duration", 15, true],
|
[40.0, "sports_upcoming", 15.0, "duration", 15, true],
|
||||||
[55.0, "sports_recent", 15.0, "duration", 15, true],
|
[55.0, "sports_recent", 15.0, "duration", 15, true],
|
||||||
[70.0, "sports_upcoming", 15.0, "duration", 15, true],
|
[70.0, "sports_upcoming", 15.0, "duration", 15, true],
|
||||||
[85.0, "sports_recent", 10.0, "on-demand-requested-stop", 10, true],
|
[85.0, "sports_recent", 10.0, "on-demand-requested-stop", 11, true],
|
||||||
[95.0, "weather", 20.0, "duration", 20, true],
|
[95.0, "weather", 20.0, "duration", 20, true],
|
||||||
[115.0, "sports_recent", 15.0, "duration", 15, true],
|
[115.0, "sports_recent", 15.0, "duration", 15, true],
|
||||||
[130.0, "sports_upcoming", 15.0, "duration", 15, true],
|
[130.0, "sports_upcoming", 15.0, "duration", 15, true],
|
||||||
[145.0, "clock", 5.0, "on-demand-start", 5, true],
|
[145.0, "clock", 5.0, "on-demand-start", 6, true],
|
||||||
[150.0, "weather", 20.0, "duration", 20, true],
|
[150.0, "weather", 20.0, "duration", 20, true],
|
||||||
[170.0, "weather", 10.0, "on-demand-expired", 10, true],
|
[170.0, "weather", 10.0, "on-demand-expired", 10, true],
|
||||||
[180.0, "clock", 20.0, "duration", 20, true],
|
[180.0, "clock", 20.0, "duration", 20, true],
|
||||||
|
|||||||
+4
-4
@@ -1,18 +1,18 @@
|
|||||||
{
|
{
|
||||||
"screens": [
|
"screens": [
|
||||||
[0.0, "clock", 5.0, "on-demand-start", 5, false],
|
[0.0, "clock", 5.0, "on-demand-start", 6, false],
|
||||||
[5.0, "sports_live", 15.0, "duration", 15, true],
|
[5.0, "sports_live", 15.0, "duration", 15, true],
|
||||||
[20.0, "sports_recent", 15.0, "duration", 15, true],
|
[20.0, "sports_recent", 15.0, "duration", 15, true],
|
||||||
[35.0, "sports_upcoming", 5.0, "on-demand-requested-stop", 5, true],
|
[35.0, "sports_upcoming", 5.0, "on-demand-requested-stop", 6, true],
|
||||||
[40.0, "clock", 20.0, "duration", 20, true],
|
[40.0, "clock", 20.0, "duration", 20, true],
|
||||||
[60.0, "sports_live", 15.0, "display-false", 11, true],
|
[60.0, "sports_live", 15.0, "display-false", 11, true],
|
||||||
[75.0, "sports_recent", 15.0, "duration", 15, true],
|
[75.0, "sports_recent", 15.0, "duration", 15, true],
|
||||||
[90.0, "sports_upcoming", 10.0, "on-demand-start", 10, true],
|
[90.0, "sports_upcoming", 10.0, "on-demand-start", 11, true],
|
||||||
[100.0, "sports_live", 0.0, "empty", 1, true],
|
[100.0, "sports_live", 0.0, "empty", 1, true],
|
||||||
[100.0, "sports_recent", 15.0, "duration", 15, true],
|
[100.0, "sports_recent", 15.0, "duration", 15, true],
|
||||||
[115.0, "sports_upcoming", 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_live", 0.0, "empty", 1, true],
|
||||||
[130.0, "sports_recent", 10.0, "on-demand-requested-stop", 10, true],
|
[130.0, "sports_recent", 10.0, "on-demand-requested-stop", 11, true],
|
||||||
[140.0, "sports_upcoming", 15.0, "duration", 15, true],
|
[140.0, "sports_upcoming", 15.0, "duration", 15, true],
|
||||||
[155.0, "clock", 5.0, "horizon", 5, true]
|
[155.0, "clock", 5.0, "horizon", 5, true]
|
||||||
],
|
],
|
||||||
|
|||||||
+2
-2
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"screens": [
|
"screens": [
|
||||||
[0.0, "clock", 12.0, "on-demand-start", 12, false],
|
[0.0, "clock", 12.0, "on-demand-start", 13, false],
|
||||||
[12.0, "sports_upcoming", 15.0, "duration", 15, true],
|
[12.0, "sports_upcoming", 15.0, "duration", 15, true],
|
||||||
[27.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],
|
[42.0, "sports_upcoming", 15.0, "duration", 15, true],
|
||||||
[57.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", 8, true],
|
[72.0, "sports_upcoming", 8.0, "on-demand-start", 9, true],
|
||||||
[80.0, "app_a", 0.0, "empty", 1, true],
|
[80.0, "app_a", 0.0, "empty", 1, true],
|
||||||
[80.0, "app_b", 10.0, "duration", 10, true],
|
[80.0, "app_b", 10.0, "duration", 10, true],
|
||||||
[90.0, "app_a", 0.0, "empty", 1, 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],
|
[70.255, "sports_live", 20.0, "duration", 20, true],
|
||||||
[90.255, "sports_live", 20.0, "display-false", 11, false],
|
[90.255, "sports_live", 20.0, "display-false", 11, false],
|
||||||
[110.255, "<vegas>", 30.008, "duration", 3751, null],
|
[110.255, "<vegas>", 30.008, "duration", 3751, null],
|
||||||
[140.263, "<vegas>", 9.744, "on-demand-start", 1218, null],
|
[140.263, "<vegas>", 10.0, "on-demand-start", 1250, null],
|
||||||
[150.007, "clock", 20.0, "duration", 20, true],
|
[150.263, "clock", 20.0, "duration", 20, true],
|
||||||
[170.007, "clock", 5.0, "on-demand-expired", 5, true],
|
[170.263, "clock", 5.0, "on-demand-expired", 5, true],
|
||||||
[175.007, "<vegas>", 25.999, "vegas-interrupt", 3250, null],
|
[175.263, "<vegas>", 24.959, "vegas-interrupt", 3120, null],
|
||||||
[201.006, "<wifi>", 2.0, "duration", 4, null],
|
[200.222, "<wifi>", 3.0, "duration", 6, null],
|
||||||
[203.006, "<vegas>", 30.008, "duration", 3751, null],
|
[203.222, "<vegas>", 30.008, "duration", 3751, null],
|
||||||
[233.014, "<vegas>", 26.986, "horizon", 3374, null]
|
[233.23, "<vegas>", 26.77, "horizon", 3347, null]
|
||||||
],
|
],
|
||||||
"events": [
|
"events": [
|
||||||
[70.255, "vegas-live"],
|
[70.255, "vegas-live"],
|
||||||
[70.255, "live", "sports_live"],
|
[70.255, "live", "sports_live"],
|
||||||
[150.0, "request", "start:v1"],
|
[150.0, "request", "start:v1"],
|
||||||
[150.007, "on-demand-start", "clock"],
|
[150.263, "on-demand-start", "clock"],
|
||||||
[150.007, "vegas-interrupt"],
|
[150.263, "vegas-interrupt"],
|
||||||
[175.007, "on-demand-expired"],
|
[175.263, "on-demand-expired"],
|
||||||
[200.0, "wifi-file", "Connected to HomeNet"],
|
[200.0, "wifi-file", "Connected to HomeNet"],
|
||||||
[201.006, "vegas-interrupt"]
|
[200.222, "vegas-interrupt"]
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,11 +39,8 @@ const ok = (l, c, x) => c ? (pass++, console.log(' ok ' + l))
|
|||||||
ok('the partial has no inline handlers', !/\son(click|input|change|submit)=/i.test(partial));
|
ok('the partial has no inline handlers', !/\son(click|input|change|submit)=/i.test(partial));
|
||||||
ok('the forms carry no hx-on handler', !/hx-on/i.test(partial));
|
ok('the forms carry no hx-on handler', !/hx-on/i.test(partial));
|
||||||
ok('the partial root is data-page="schedule"', /data-page="schedule"/.test(partial));
|
ok('the partial root is data-page="schedule"', /data-page="schedule"/.test(partial));
|
||||||
// The two schedule forms, plus one per plugin in the Plugin Schedules card.
|
ok('both forms are marked data-reports-result',
|
||||||
ok('every form is marked data-reports-result',
|
(partial.match(/<form[^>]*data-reports-result/g) || []).length === 2);
|
||||||
(partial.match(/<form[^>]*data-reports-result/g) || []).length
|
|
||||||
=== (partial.match(/<form[\s>]/g) || []).length
|
|
||||||
&& (partial.match(/<form[\s>]/g) || []).length >= 2);
|
|
||||||
|
|
||||||
const errs = [];
|
const errs = [];
|
||||||
const logged = [];
|
const logged = [];
|
||||||
|
|||||||
+1
-3
@@ -32,9 +32,7 @@ const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.js',
|
|||||||
'unit/test_page_registry.js', 'unit/test_core_modules.js',
|
'unit/test_page_registry.js', 'unit/test_core_modules.js',
|
||||||
'unit/test_overview_reconciliation_poll.js',
|
'unit/test_overview_reconciliation_poll.js',
|
||||||
'unit/test_display_partial_ids.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',
|
|
||||||
'unit/test_general_display_autostart.js'];
|
|
||||||
const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js',
|
const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js',
|
||||||
'dom/test_tools_sections.js', 'dom/test_cache_page.js',
|
'dom/test_tools_sections.js', 'dom/test_cache_page.js',
|
||||||
'dom/test_durations_page.js', 'dom/test_operation_history_page.js',
|
'dom/test_durations_page.js', 'dom/test_operation_history_page.js',
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
// The General tab's "Start Display on Boot" checkbox (setDisplayAutostart in
|
|
||||||
// static/v3/js/pages/general.js) posts enable_autostart / disable_autostart
|
|
||||||
// to /api/v3/system/action and puts the box back when that is refused --
|
|
||||||
// including the route's HTTP-200 {status: "error"} answer for a failed
|
|
||||||
// systemctl (e.g. the sudo rule is missing).
|
|
||||||
//
|
|
||||||
// Fake fetch, no jsdom and no server needed.
|
|
||||||
|
|
||||||
const path = require('path');
|
|
||||||
const { pathToFileURL } = require('url');
|
|
||||||
|
|
||||||
const JS = path.resolve(__dirname, '../../../web_interface/static/v3/js');
|
|
||||||
const load = f => import(pathToFileURL(path.join(JS, f)).href);
|
|
||||||
|
|
||||||
let pass = 0, fail = 0;
|
|
||||||
const ok = (label, cond, extra) => cond
|
|
||||||
? (pass++, console.log(' ok ' + label))
|
|
||||||
: (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' ' + JSON.stringify(extra) : '')));
|
|
||||||
|
|
||||||
async function run(checked, answer) {
|
|
||||||
const { createApi } = await load('core/api.js');
|
|
||||||
const general = await load('pages/general.js');
|
|
||||||
const sent = [];
|
|
||||||
const notes = [];
|
|
||||||
const fetch = (url, init) => {
|
|
||||||
sent.push([url, JSON.parse(init.body)]);
|
|
||||||
if (answer === 'network') return Promise.reject(new TypeError('Failed to fetch'));
|
|
||||||
return Promise.resolve({
|
|
||||||
ok: answer.ok, status: answer.ok ? 200 : 500,
|
|
||||||
headers: { get: () => null },
|
|
||||||
text: () => Promise.resolve(JSON.stringify(answer.body)),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const ctx = { root: {}, state: {}, signal: { aborted: false },
|
|
||||||
api: createApi({ fetch }), notify: (m, t) => notes.push([m, t]) };
|
|
||||||
const box = { checked, disabled: false };
|
|
||||||
await general.setDisplayAutostart(ctx, box);
|
|
||||||
return { box, sent, notes };
|
|
||||||
}
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
console.log('\n── General tab: Start Display on Boot ──');
|
|
||||||
|
|
||||||
{
|
|
||||||
const t = await run(false, { ok: true, body: { status: 'success', message: 'ok' } });
|
|
||||||
ok('unchecking posts disable_autostart',
|
|
||||||
t.sent.length === 1 && t.sent[0][0] === '/api/v3/system/action' && t.sent[0][1].action === 'disable_autostart', t.sent);
|
|
||||||
ok('...stays unchecked and re-enabled', t.box.checked === false && t.box.disabled === false);
|
|
||||||
ok('...and reports success', t.notes.some(([, type]) => type === 'success'), t.notes);
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
const t = await run(true, { ok: true, body: { status: 'success' } });
|
|
||||||
ok('checking posts enable_autostart', t.sent[0][1].action === 'enable_autostart', t.sent);
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
const t = await run(false, { ok: true, body: { status: 'error', message: 'sudo: a password is required' } });
|
|
||||||
ok('a failed systemctl (HTTP 200, status error) puts the box back', t.box.checked === true && !t.box.disabled);
|
|
||||||
ok('...and shows the route\'s message',
|
|
||||||
t.notes.some(([m, type]) => type === 'error' && /sudo/.test(m)), t.notes);
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
const t = await run(true, { ok: false, body: { status: 'error', message: 'boom' } });
|
|
||||||
ok('an HTTP error puts the box back', t.box.checked === false && !t.box.disabled);
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
const t = await run(true, 'network');
|
|
||||||
ok('a network failure puts the box back', t.box.checked === false && !t.box.disabled);
|
|
||||||
ok('...and reports it', t.notes.some(([, type]) => type === 'error'), t.notes);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`\n${pass} passed, ${fail} failed\n`);
|
|
||||||
process.exit(fail ? 1 : 0);
|
|
||||||
})().catch(e => { console.log('HARNESS ERROR: ' + e.stack); process.exit(1); });
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
// 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);
|
|
||||||
})();
|
|
||||||
@@ -54,10 +54,7 @@ class TestOnDemandStart:
|
|||||||
def service(self, api_v3_module):
|
def service(self, api_v3_module):
|
||||||
api_v3_module.api_v3.plugin_catalog = None
|
api_v3_module.api_v3.plugin_catalog = None
|
||||||
api_v3_module.api_v3.config_manager = None
|
api_v3_module.api_v3.config_manager = None
|
||||||
# The display answers the control socket (the only way in).
|
with patch("web_interface.blueprints.api_v3.display._get_display_service_status",
|
||||||
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}), \
|
return_value={"active": True}), \
|
||||||
patch("web_interface.blueprints.api_v3.display._stop_display_service") as stop, \
|
patch("web_interface.blueprints.api_v3.display._stop_display_service") as stop, \
|
||||||
patch("web_interface.blueprints.api_v3.display._ensure_display_service_running",
|
patch("web_interface.blueprints.api_v3.display._ensure_display_service_running",
|
||||||
|
|||||||
@@ -160,7 +160,8 @@ class TestOneBadConfigSectionDoesNotBlankTheList:
|
|||||||
|
|
||||||
class TestOnDemandUsesTheRegisteredSpelling:
|
class TestOnDemandUsesTheRegisteredSpelling:
|
||||||
def test_a_mode_differing_in_case_is_sent_as_registered(self, client):
|
def test_a_mode_differing_in_case_is_sent_as_registered(self, client):
|
||||||
with patch('web_interface.blueprints.api_v3.display._send_on_demand') as deliver:
|
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',
|
response = client.post('/api/v3/display/on-demand/start',
|
||||||
json={'plugin_id': 'football-scoreboard',
|
json={'plugin_id': 'football-scoreboard',
|
||||||
'mode': 'NFL_LIVE', 'start_service': False})
|
'mode': 'NFL_LIVE', 'start_service': False})
|
||||||
|
|||||||
@@ -77,22 +77,11 @@ def fresh_web_process(api_v3_module, plugins_dir):
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def display_service(api_v3_module):
|
def display_service(api_v3_module):
|
||||||
"""Keep on-demand start away from systemctl and the real cache; the
|
"""Keep on-demand start away from systemctl and the real cache."""
|
||||||
display's control socket is a mock that acks. What it was sent is
|
cache = api_v3_module.api_v3.cache_manager = MagicMock()
|
||||||
recorded as ``.set(cmd, request)`` calls on the yielded mock."""
|
with patch('web_interface.blueprints.api_v3.display._get_display_service_status') as status:
|
||||||
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}
|
status.return_value = {'active': True}
|
||||||
yield sent
|
yield cache
|
||||||
|
|
||||||
|
|
||||||
def _start(client, **body):
|
def _start(client, **body):
|
||||||
|
|||||||
@@ -5,20 +5,18 @@ 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
|
ran ``systemctl stop``, slept 1.5s and started it again. Every on-demand or
|
||||||
"Preview on display" click therefore cold-restarted the display process --
|
"Preview on display" click therefore cold-restarted the display process --
|
||||||
every plugin reloaded, the panel blank for seconds -- to deliver a request the
|
every plugin reloaded, the panel blank for seconds -- to deliver a request the
|
||||||
running process takes within a frame over its control socket anyway (see
|
running process polls for every ON_DEMAND_POLL_INTERVAL anyway (see
|
||||||
test_display_pending_changes.py for the display side: commands land
|
test_on_demand_mailbox.py and test_display_pending_changes.py for the display
|
||||||
mid-dwell, mid-screen and mid-Vegas-iteration).
|
side: the mailbox is read mid-dwell, mid-screen and mid-Vegas-iteration).
|
||||||
|
|
||||||
The restart did not buy anything either: a freshly started display restores
|
The restart did not buy anything either: a freshly started display restores
|
||||||
only the on-demand session it saved itself (``display_on_demand_config``).
|
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.
|
||||||
|
|
||||||
This file previously pinned that restart path (it guarded a broken
|
This file previously pinned that restart path (it guarded a broken
|
||||||
``import _pkg.time`` inside it). The path is gone; these tests pin its
|
``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
|
replacement: a running service is left alone, a stopped one is started (only
|
||||||
when start_service is set), and the request goes over the control socket
|
when start_service is set), and the request lands in the mailbox either way.
|
||||||
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
|
The service helpers are patched where they run. display.py binds
|
||||||
_get_display_service_status by value, while _ensure_display_service_running
|
_get_display_service_status by value, while _ensure_display_service_running
|
||||||
@@ -27,7 +25,6 @@ _run_systemctl_command is the one place a systemctl command is issued.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
import time
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
@@ -37,8 +34,6 @@ 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 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"
|
START_URL = "/api/v3/display/on-demand/start"
|
||||||
STOP_URL = "/api/v3/display/on-demand/stop"
|
STOP_URL = "/api/v3/display/on-demand/stop"
|
||||||
MAILBOX = "display_on_demand_request"
|
MAILBOX = "display_on_demand_request"
|
||||||
@@ -51,10 +46,7 @@ def service(api_v3_module):
|
|||||||
|
|
||||||
plugin_manager and config_manager are None so the route skips plugin
|
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
|
resolution (not what is under test here). The cache is the blueprint's
|
||||||
MagicMock cache_manager, so a write would be visible as a set() call.
|
MagicMock cache_manager, so mailbox writes are visible as set() calls.
|
||||||
|
|
||||||
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.plugin_catalog = None
|
||||||
api_v3_module.api_v3.config_manager = None
|
api_v3_module.api_v3.config_manager = None
|
||||||
@@ -70,32 +62,7 @@ def service(api_v3_module):
|
|||||||
state["active"] = False
|
state["active"] = False
|
||||||
return {"returncode": 0, "stdout": "", "stderr": ""}
|
return {"returncode": 0, "stdout": "", "stderr": ""}
|
||||||
|
|
||||||
sent = []
|
with patch("web_interface.blueprints.api_v3._get_display_service_status",
|
||||||
|
|
||||||
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), \
|
side_effect=status), \
|
||||||
patch("web_interface.blueprints.api_v3.display._get_display_service_status",
|
patch("web_interface.blueprints.api_v3.display._get_display_service_status",
|
||||||
side_effect=status), \
|
side_effect=status), \
|
||||||
@@ -107,7 +74,6 @@ def service(api_v3_module):
|
|||||||
"systemctl": run_systemctl,
|
"systemctl": run_systemctl,
|
||||||
"stop_service": stop_service,
|
"stop_service": stop_service,
|
||||||
"cache": api_v3_module.api_v3.cache_manager,
|
"cache": api_v3_module.api_v3.cache_manager,
|
||||||
"sent": sent,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -133,15 +99,19 @@ class TestStartWhileTheServiceIsRunning:
|
|||||||
assert _systemctl_verbs(service["systemctl"]) == [], (
|
assert _systemctl_verbs(service["systemctl"]) == [], (
|
||||||
"a running display service was sent a systemctl command")
|
"a running display service was sent a systemctl command")
|
||||||
|
|
||||||
def test_the_request_is_sent_to_the_running_display(self, api_v3_client, service):
|
def test_the_request_is_posted_for_the_running_display(self, api_v3_client, service):
|
||||||
response = api_v3_client.post(
|
response = api_v3_client.post(
|
||||||
START_URL, json={"plugin_id": "weather", "mode": "weather_current",
|
START_URL, json={"plugin_id": "weather", "mode": "weather_current",
|
||||||
"duration": 60, "pinned": True})
|
"duration": 60, "pinned": True})
|
||||||
data = response.get_json()["data"]
|
data = response.get_json()["data"]
|
||||||
assert service["sent"] == [("start", data["request_id"], "weather",
|
writes = _mailbox_writes(service["cache"])
|
||||||
"weather_current", 60, True)]
|
assert len(writes) == 1
|
||||||
assert data["transport"] == "socket"
|
assert writes[0]["action"] == "start"
|
||||||
assert _mailbox_writes(service["cache"]) == []
|
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
|
||||||
|
|
||||||
def test_the_response_reports_the_service_was_not_started(self, api_v3_client, service):
|
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"]
|
data = api_v3_client.post(START_URL, json={"plugin_id": "weather"}).get_json()["data"]
|
||||||
@@ -159,20 +129,11 @@ class TestStartWhileTheServiceIsStopped:
|
|||||||
def test_start_service_starts_it_once_and_never_stops_it(self, api_v3_client, service):
|
def test_start_service_starts_it_once_and_never_stops_it(self, api_v3_client, service):
|
||||||
service["state"]["active"] = False
|
service["state"]["active"] = False
|
||||||
response = api_v3_client.post(START_URL, json={"plugin_id": "weather"})
|
response = api_v3_client.post(START_URL, json={"plugin_id": "weather"})
|
||||||
# Answered at once: the web process sends the request in the
|
assert response.status_code == 200, response.get_json()
|
||||||
# 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"]
|
assert _systemctl_verbs(service["systemctl"]) == ["start"]
|
||||||
service["stop_service"].assert_not_called()
|
service["stop_service"].assert_not_called()
|
||||||
from web_interface import on_demand_dispatch
|
# Written before the start, so the new process finds it on its first poll.
|
||||||
dispatcher = on_demand_dispatch.current()
|
assert len(_mailbox_writes(service["cache"])) == 1
|
||||||
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):
|
def test_without_start_service_it_is_left_stopped(self, api_v3_client, service):
|
||||||
service["state"]["active"] = False
|
service["state"]["active"] = False
|
||||||
@@ -180,7 +141,6 @@ class TestStartWhileTheServiceIsStopped:
|
|||||||
START_URL, json={"plugin_id": "weather", "start_service": "false"})
|
START_URL, json={"plugin_id": "weather", "start_service": "false"})
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
assert _systemctl_verbs(service["systemctl"]) == []
|
assert _systemctl_verbs(service["systemctl"]) == []
|
||||||
assert service["sent"] == []
|
|
||||||
|
|
||||||
def test_a_start_that_fails_is_reported(self, api_v3_client, service):
|
def test_a_start_that_fails_is_reported(self, api_v3_client, service):
|
||||||
service["state"]["active"] = False
|
service["state"]["active"] = False
|
||||||
@@ -191,12 +151,31 @@ class TestStartWhileTheServiceIsStopped:
|
|||||||
assert response.get_json()["status"] == "error"
|
assert response.get_json()["status"] == "error"
|
||||||
|
|
||||||
|
|
||||||
|
class _Mailbox:
|
||||||
|
"""The CacheManager calls the routes make, over a dict."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.entries = {}
|
||||||
|
|
||||||
|
def set(self, key, value, ttl=None):
|
||||||
|
self.entries[key] = value
|
||||||
|
|
||||||
|
def get(self, key, max_age=300, memory_ttl=None):
|
||||||
|
return self.entries.get(key)
|
||||||
|
|
||||||
|
def delete(self, key):
|
||||||
|
self.entries.pop(key, None)
|
||||||
|
|
||||||
|
|
||||||
class TestARefusedStartLeavesNoRequestBehind:
|
class TestARefusedStartLeavesNoRequestBehind:
|
||||||
"""A start the route answers with an error must not run later.
|
"""A start the route answers with an error must not run later.
|
||||||
|
|
||||||
With the mailbox, the request was posted before the route refused it, and
|
The request was posted (to the mailbox, with the display stopped) before
|
||||||
a display started later ran it. Now nothing is written anywhere: the
|
the route refused it, and the display reads the mailbox for an hour
|
||||||
request only ever goes over the socket, to a display that answers.
|
without looking at a request's age. So "Display service is not running"
|
||||||
|
(start_service off) or "Failed to start display service" left the
|
||||||
|
request waiting, and the next time the display started -- minutes later,
|
||||||
|
by hand -- it ran that plugin, pinned if the request said so.
|
||||||
|
|
||||||
A socket acknowledgement is the other side of it: the display answered,
|
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
|
so it is running and has the request, whatever systemd says (a display
|
||||||
@@ -205,48 +184,76 @@ class TestARefusedStartLeavesNoRequestBehind:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def stopped(self, service):
|
def mailbox(self, api_v3_module, service):
|
||||||
|
box = _Mailbox()
|
||||||
|
api_v3_module.api_v3.cache_manager = box
|
||||||
service["state"]["active"] = False
|
service["state"]["active"] = False
|
||||||
return service
|
return box
|
||||||
|
|
||||||
@pytest.mark.parametrize("body", [
|
@pytest.mark.parametrize("body", [
|
||||||
{"plugin_id": "weather", "start_service": False},
|
{"plugin_id": "weather", "start_service": False},
|
||||||
{"plugin_id": "weather"}, # start_service defaults on
|
{"plugin_id": "weather"}, # start_service defaults on
|
||||||
])
|
])
|
||||||
def test_a_socket_ack_is_a_success_whatever_systemd_says(
|
def test_a_socket_ack_is_a_success_whatever_systemd_says(
|
||||||
self, api_v3_client, stopped, body):
|
self, api_v3_client, service, mailbox, body):
|
||||||
with patch(f"{DISPLAY}.control_client.on_demand_start",
|
with patch(f"{DISPLAY}.control_client.on_demand_start",
|
||||||
side_effect=lambda request_id, *a: {"accepted": True}):
|
side_effect=lambda request_id, *a: {"accepted": True}):
|
||||||
response = api_v3_client.post(START_URL, json=body)
|
response = api_v3_client.post(START_URL, json=body)
|
||||||
assert response.status_code == 200, response.get_json()
|
assert response.status_code == 200, response.get_json()
|
||||||
assert response.get_json()["data"]["transport"] == "socket"
|
assert response.get_json()["data"]["transport"] == "socket"
|
||||||
assert _systemctl_verbs(stopped["systemctl"]) == [], (
|
assert MAILBOX not in mailbox.entries
|
||||||
|
assert _systemctl_verbs(service["systemctl"]) == [], (
|
||||||
"a unit was started beside a display that answered the socket")
|
"a unit was started beside a display that answered the socket")
|
||||||
|
|
||||||
def test_without_start_service_nothing_is_left_behind(self, api_v3_client, stopped):
|
def test_without_start_service_the_request_is_taken_back(
|
||||||
|
self, api_v3_client, service, mailbox):
|
||||||
response = api_v3_client.post(START_URL, json={
|
response = api_v3_client.post(START_URL, json={
|
||||||
"plugin_id": "weather", "pinned": True, "start_service": False})
|
"plugin_id": "weather", "pinned": True, "start_service": False})
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
assert response.get_json()["status"] == "error"
|
assert response.get_json()["status"] == "error"
|
||||||
assert stopped["cache"].set.call_count == 0
|
assert MAILBOX not in mailbox.entries
|
||||||
assert stopped["sent"] == []
|
|
||||||
|
|
||||||
def test_a_start_that_fails_leaves_nothing_behind(self, api_v3_client, stopped):
|
def test_a_start_that_fails_takes_its_request_back(self, api_v3_client, service, mailbox):
|
||||||
stopped["systemctl"].side_effect = lambda args: {
|
service["systemctl"].side_effect = lambda args: {
|
||||||
"returncode": 1, "stdout": "", "stderr": "denied"}
|
"returncode": 1, "stdout": "", "stderr": "denied"}
|
||||||
response = api_v3_client.post(START_URL, json={"plugin_id": "weather"})
|
response = api_v3_client.post(START_URL, json={"plugin_id": "weather"})
|
||||||
assert response.status_code == 500
|
assert response.status_code == 500
|
||||||
assert stopped["cache"].set.call_count == 0
|
assert MAILBOX not in mailbox.entries
|
||||||
assert stopped["sent"] == []
|
|
||||||
|
def test_a_newer_request_is_left_alone_on_the_400(self, api_v3_client, service, mailbox):
|
||||||
|
newer = {"request_id": "someone-else", "action": "start", "plugin_id": "clock"}
|
||||||
|
|
||||||
|
def stopped_and_another_post_lands(*args):
|
||||||
|
mailbox.entries[MAILBOX] = newer
|
||||||
|
return {"active": False}
|
||||||
|
|
||||||
|
with patch(f"{DISPLAY}._get_display_service_status",
|
||||||
|
side_effect=stopped_and_another_post_lands):
|
||||||
|
response = api_v3_client.post(START_URL, json={
|
||||||
|
"plugin_id": "weather", "start_service": False})
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert mailbox.entries[MAILBOX] is newer
|
||||||
|
|
||||||
|
def test_a_newer_request_is_left_alone_on_the_500(self, api_v3_client, service, mailbox):
|
||||||
|
newer = {"request_id": "someone-else", "action": "start", "plugin_id": "clock"}
|
||||||
|
|
||||||
|
def start_fails_after_another_post(args):
|
||||||
|
mailbox.entries[MAILBOX] = newer
|
||||||
|
return {"returncode": 1, "stdout": "", "stderr": "denied"}
|
||||||
|
|
||||||
|
service["systemctl"].side_effect = start_fails_after_another_post
|
||||||
|
response = api_v3_client.post(START_URL, json={"plugin_id": "weather"})
|
||||||
|
assert response.status_code == 500
|
||||||
|
assert mailbox.entries[MAILBOX] is newer
|
||||||
|
|
||||||
|
|
||||||
class TestStop:
|
class TestStop:
|
||||||
def test_stop_sends_a_stop_request_and_leaves_the_service_running(
|
def test_stop_posts_a_stop_request_and_leaves_the_service_running(
|
||||||
self, api_v3_client, service):
|
self, api_v3_client, service):
|
||||||
response = api_v3_client.post(STOP_URL, json={})
|
response = api_v3_client.post(STOP_URL, json={})
|
||||||
assert response.status_code == 200, response.get_json()
|
assert response.status_code == 200, response.get_json()
|
||||||
assert [s[0] for s in service["sent"]] == ["stop"]
|
writes = _mailbox_writes(service["cache"])
|
||||||
assert _mailbox_writes(service["cache"]) == []
|
assert [w["action"] for w in writes] == ["stop"]
|
||||||
service["stop_service"].assert_not_called()
|
service["stop_service"].assert_not_called()
|
||||||
assert _systemctl_verbs(service["systemctl"]) == []
|
assert _systemctl_verbs(service["systemctl"]) == []
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
"""POST /display/on-demand/start and /stop: the control socket is the only way.
|
"""POST /display/on-demand/start and /stop: control socket first, mailbox fallback.
|
||||||
|
|
||||||
The routes hand the request to the display over the control socket
|
The routes hand the request to the display over the control socket
|
||||||
(src/ipc) and get an acknowledgement. The file mailbox they used to fall
|
(src/ipc) and get an acknowledgement. When the socket could not carry the
|
||||||
back to (``display_on_demand_request``) is gone (stage 5), so nothing is
|
request -- no socket (a stopped display, or one older than the socket), a
|
||||||
ever written to the cache. When no display is listening (a stopped one, or
|
refused or timed-out connect, a display too old to know the command, a bug
|
||||||
one still starting) the start route starts the service if asked to and
|
in the client -- they write the file mailbox exactly as they did before the
|
||||||
sends the request again once the socket is up; every other failure is
|
socket existed. When the display had the request and failed it (a full
|
||||||
answered as what it is.
|
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).
|
||||||
|
|
||||||
The socket client is patched at the route's module attribute; the last class
|
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).
|
runs a real server on a temp socket (Linux/macOS only).
|
||||||
@@ -14,8 +17,6 @@ runs a real server on a temp socket (Linux/macOS only).
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import time
|
|
||||||
import types
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
@@ -32,12 +33,11 @@ START_URL = "/api/v3/display/on-demand/start"
|
|||||||
STOP_URL = "/api/v3/display/on-demand/stop"
|
STOP_URL = "/api/v3/display/on-demand/stop"
|
||||||
MAILBOX = "display_on_demand_request"
|
MAILBOX = "display_on_demand_request"
|
||||||
CLIENT = "web_interface.blueprints.api_v3.display.control_client"
|
CLIENT = "web_interface.blueprints.api_v3.display.control_client"
|
||||||
DISPLAY = "web_interface.blueprints.api_v3.display"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def service(api_v3_module):
|
def service(api_v3_module):
|
||||||
"""A running display service; records systemctl calls and cache writes."""
|
"""A running display service; records systemctl calls and mailbox writes."""
|
||||||
api_v3_module.api_v3.plugin_catalog = None
|
api_v3_module.api_v3.plugin_catalog = None
|
||||||
api_v3_module.api_v3.config_manager = None
|
api_v3_module.api_v3.config_manager = None
|
||||||
state = {"active": True}
|
state = {"active": True}
|
||||||
@@ -101,398 +101,85 @@ class TestSocketPath:
|
|||||||
assert _mailbox_writes(service["cache"]) == []
|
assert _mailbox_writes(service["cache"]) == []
|
||||||
|
|
||||||
|
|
||||||
class FakeTime:
|
class TestMailboxFallback:
|
||||||
"""Stands in for the route's ``time`` module: sleep() moves the clock."""
|
@pytest.mark.parametrize("reason", [
|
||||||
|
"no_socket", "refused", "timeout", "closed", "bad_response", "invalid_request",
|
||||||
def __init__(self):
|
"busy", "forbidden", "unknown_command", "unsupported_version", "disabled",
|
||||||
self.now = 1000.0
|
"unsupported",
|
||||||
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,
|
def test_a_request_the_socket_never_carried_writes_the_mailbox(
|
||||||
reason, sent, status):
|
self, api_v3_client, service, reason):
|
||||||
service["state"]["active"] = False
|
# sent=False: the display never had it (no socket, a refused or
|
||||||
|
# timed-out connect, turned away at the door).
|
||||||
with patch(f"{CLIENT}.on_demand_start",
|
with patch(f"{CLIENT}.on_demand_start",
|
||||||
side_effect=control_client.ControlError(reason, "x", sent=sent)) as start:
|
side_effect=control_client.ControlError(reason, "x", sent=False)):
|
||||||
resp = api_v3_client.post(START_URL, json={"plugin_id": "weather"})
|
resp = api_v3_client.post(START_URL, json={
|
||||||
assert resp.status_code == status
|
"plugin_id": "weather", "mode": "weather_current",
|
||||||
body = resp.get_json()
|
"duration": 60, "pinned": True})
|
||||||
assert body["status"] == "error"
|
assert resp.status_code == 200
|
||||||
assert body["data"]["socket_error"] == reason
|
data = resp.get_json()["data"]
|
||||||
assert start.call_count == 1 and clock.sleeps == 0
|
assert data["transport"] == "mailbox"
|
||||||
assert service["calls"] == []
|
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)
|
||||||
|
|
||||||
def test_a_client_bug_is_an_error(self, api_v3_client, service):
|
def test_a_client_bug_still_falls_back(self, api_v3_client, service):
|
||||||
with patch(f"{CLIENT}.on_demand_start", side_effect=RuntimeError("boom")):
|
with patch(f"{CLIENT}.on_demand_start", side_effect=RuntimeError("boom")):
|
||||||
resp = api_v3_client.post(START_URL, json={"plugin_id": "weather"})
|
resp = api_v3_client.post(START_URL, json={"plugin_id": "weather"})
|
||||||
assert resp.status_code == 503
|
assert resp.status_code == 200
|
||||||
assert resp.get_json()["data"]["socket_error"] == "internal"
|
assert resp.get_json()["data"]["socket_error"] == "internal"
|
||||||
assert _mailbox_writes(service["cache"]) == []
|
assert len(_mailbox_writes(service["cache"])) == 1
|
||||||
|
|
||||||
def test_an_unknown_reason_is_reported_as_other(self, api_v3_client, service):
|
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.
|
# Only known codes are echoed back; anything else stays server-side.
|
||||||
with patch(f"{CLIENT}.on_demand_start",
|
with patch(f"{CLIENT}.on_demand_start",
|
||||||
side_effect=control_client.ControlError("/run/secret/path", "x")):
|
side_effect=control_client.ControlError("/run/secret/path", "x")):
|
||||||
resp = api_v3_client.post(START_URL, json={"plugin_id": "weather"})
|
data = api_v3_client.post(START_URL, json={"plugin_id": "weather"}).get_json()["data"]
|
||||||
assert resp.status_code == 503
|
assert data["transport"] == "mailbox"
|
||||||
assert resp.get_json()["data"]["socket_error"] == "other"
|
assert data["socket_error"] == "other"
|
||||||
assert "/run/secret" not in resp.get_data(as_text=True)
|
assert len(_mailbox_writes(service["cache"])) == 1
|
||||||
|
|
||||||
def test_every_display_error_code_is_reportable(self):
|
def test_every_display_error_code_is_reportable(self):
|
||||||
from web_interface.blueprints.api_v3 import display
|
from web_interface.blueprints.api_v3 import display
|
||||||
codes = {v for k, v in vars(c.ErrorCode).items() if not k.startswith("_")}
|
codes = {v for k, v in vars(c.ErrorCode).items() if not k.startswith("_")}
|
||||||
assert codes <= set(display._REPORTABLE_SOCKET_REASONS)
|
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:
|
class TestTheDisplayHadIt:
|
||||||
"""Once the display has the request, its answer stands.
|
"""Once the display has the request, its answer stands: no mailbox copy.
|
||||||
|
|
||||||
A busy queue, a refusal or silence after the request was sent mean the
|
A busy queue, a refusal or silence after the request was sent mean the
|
||||||
display may have applied it, so the route reports the failure and does
|
display may have applied it, or would refuse the mailbox copy too, so
|
||||||
not send it again.
|
the route reports the failure instead of posting it a second time.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@pytest.mark.parametrize("reason,status", [
|
@pytest.mark.parametrize("reason,status", [
|
||||||
@@ -530,6 +217,16 @@ class TestTheDisplayHadIt:
|
|||||||
stop.assert_called_once()
|
stop.assert_called_once()
|
||||||
assert _mailbox_writes(service["cache"]) == []
|
assert _mailbox_writes(service["cache"]) == []
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("reason", ["unknown_command", "unsupported_version"])
|
||||||
|
def test_an_older_display_that_does_not_speak_it_gets_the_mailbox(
|
||||||
|
self, api_v3_client, service, reason):
|
||||||
|
# The upgrade case: new web interface, display still on an old build.
|
||||||
|
with patch(f"{CLIENT}.on_demand_start",
|
||||||
|
side_effect=control_client.ControlError(reason, "x", sent=True)):
|
||||||
|
data = api_v3_client.post(START_URL, json={"plugin_id": "weather"}).get_json()["data"]
|
||||||
|
assert data["transport"] == "mailbox" and data["socket_error"] == reason
|
||||||
|
assert len(_mailbox_writes(service["cache"])) == 1
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(not c.socket_supported(), reason="AF_UNIX sockets are Linux/macOS only")
|
@pytest.mark.skipif(not c.socket_supported(), reason="AF_UNIX sockets are Linux/macOS only")
|
||||||
class TestRealSocket:
|
class TestRealSocket:
|
||||||
@@ -562,21 +259,11 @@ class TestRealSocket:
|
|||||||
assert data["transport"] == "socket"
|
assert data["transport"] == "socket"
|
||||||
assert [x.request_id for x in live.drain()] == [data["request_id"]]
|
assert [x.request_id for x in live.drain()] == [data["request_id"]]
|
||||||
|
|
||||||
def test_a_display_that_went_away_is_given_up_on(self, api_v3_client, service, live,
|
def test_a_display_that_went_away_falls_back(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()
|
live.close()
|
||||||
resp = api_v3_client.post(START_URL, json={"plugin_id": "weather"})
|
data = api_v3_client.post(START_URL, json={"plugin_id": "weather"}).get_json()["data"]
|
||||||
# The service still reads as running: answered at once, and the
|
assert data["transport"] == "mailbox" and data["socket_error"] == "no_socket"
|
||||||
# web process gives up once the wait is over.
|
assert len(_mailbox_writes(service["cache"])) == 1
|
||||||
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):
|
def test_a_full_queue_is_reported_not_mailed(self, api_v3_client, service, monkeypatch):
|
||||||
import shutil
|
import shutil
|
||||||
|
|||||||
@@ -9,11 +9,9 @@ Git runs for real against a throwaway origin/device clone pair; systemd, sudo
|
|||||||
and the health check service are faked.
|
and the health check service are faked.
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
@@ -581,27 +579,6 @@ class TestUpdateIsVerified:
|
|||||||
assert repo.head() == old
|
assert repo.head() == old
|
||||||
assert 'rolled back' in result['core_message']
|
assert 'rolled back' in result['core_message']
|
||||||
|
|
||||||
@pytest.mark.skipif(not Path('/proc/self/comm').exists(), reason='needs /proc')
|
|
||||||
def test_a_pull_killed_by_its_timeout_is_still_rolled_back(self, tmp_path):
|
|
||||||
"""subprocess.run SIGKILLs a timed-out git, which leaves .git/index.lock
|
|
||||||
behind; the rollback's reset would fail on it."""
|
|
||||||
repo = Repo(tmp_path)
|
|
||||||
old = repo.head()
|
|
||||||
repo.publish()
|
|
||||||
pull = real_pull(repo.device)
|
|
||||||
lock = repo.device / '.git' / 'index.lock'
|
|
||||||
|
|
||||||
def killed(**kwargs):
|
|
||||||
pull()
|
|
||||||
lock.write_text('')
|
|
||||||
stale = time.time() - 60
|
|
||||||
os.utime(lock, (stale, stale))
|
|
||||||
raise subprocess.TimeoutExpired(['git', 'pull'], 60)
|
|
||||||
result = Harness(tmp_path, repo, core_update=killed).updater.run()
|
|
||||||
assert repo.head() == old
|
|
||||||
assert 'rolled back' in result['core_message']
|
|
||||||
assert not lock.exists()
|
|
||||||
|
|
||||||
@pytest.mark.parametrize('how', ['partial_pull', 'check_never_started'])
|
@pytest.mark.parametrize('how', ['partial_pull', 'check_never_started'])
|
||||||
def test_a_failed_rollback_leaves_plugins_and_the_display_alone(self, tmp_path, how):
|
def test_a_failed_rollback_leaves_plugins_and_the_display_alone(self, tmp_path, how):
|
||||||
"""update_core returns rollback_failed itself on two paths. The device
|
"""update_core returns rollback_failed itself on two paths. The device
|
||||||
@@ -823,7 +800,6 @@ def test_core_update_names_the_requirement_files_that_failed():
|
|||||||
pkg.api_v3.plugin_store_manager = None
|
pkg.api_v3.plugin_store_manager = None
|
||||||
failed_install = subprocess.CompletedProcess([], 1, stdout='', stderr='boom')
|
failed_install = subprocess.CompletedProcess([], 1, stdout='', stderr='boom')
|
||||||
with patch.object(pkg.subprocess, 'run', run), \
|
with patch.object(pkg.subprocess, 'run', run), \
|
||||||
patch.object(system.git_index_lock, 'run_git', run), \
|
|
||||||
patch.object(system, '_pip_install_requirements', return_value=failed_install):
|
patch.object(system, '_pip_install_requirements', return_value=failed_install):
|
||||||
result = system.perform_core_update()
|
result = system.perform_core_update()
|
||||||
assert result['status'] == 'success'
|
assert result['status'] == 'success'
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ now pauses between passes, and it stops pausing as soon as something shows.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
os.environ.setdefault("EMULATOR", "true")
|
os.environ.setdefault("EMULATOR", "true")
|
||||||
|
|
||||||
@@ -22,7 +21,6 @@ def _bare_controller(modes, on_demand_modes=None):
|
|||||||
dc.on_demand_modes = list(on_demand_modes or [])
|
dc.on_demand_modes = list(on_demand_modes or [])
|
||||||
dc.sleeps = []
|
dc.sleeps = []
|
||||||
dc._sleep_with_plugin_updates = lambda duration, tick_interval=1.0: dc.sleeps.append(duration)
|
dc._sleep_with_plugin_updates = lambda duration, tick_interval=1.0: dc.sleeps.append(duration)
|
||||||
dc.display_manager = MagicMock()
|
|
||||||
return dc
|
return dc
|
||||||
|
|
||||||
|
|
||||||
@@ -39,19 +37,6 @@ class TestNoteEmptyPass:
|
|||||||
dc._note_empty_pass()
|
dc._note_empty_pass()
|
||||||
# Passes 3, 4 and 5 are at or past one full rotation.
|
# Passes 3, 4 and 5 are at or past one full rotation.
|
||||||
assert dc.sleeps == [DisplayController.EMPTY_ROTATION_PAUSE] * 3
|
assert dc.sleeps == [DisplayController.EMPTY_ROTATION_PAUSE] * 3
|
||||||
# Blanked once, so the last screen drawn does not stay up.
|
|
||||||
assert dc.display_manager.clear.call_count == 1
|
|
||||||
# A controller-drawn blank: the last scroll's state is ended first.
|
|
||||||
dc.display_manager.set_scrolling_state.assert_called_once_with(False)
|
|
||||||
assert dc.display_manager.update_display.call_count == 1
|
|
||||||
# ...and the preview gets the blank frame however the throttle fell.
|
|
||||||
assert dc.display_manager.write_owed_snapshot.call_count == 3
|
|
||||||
|
|
||||||
def test_a_partly_empty_rotation_never_blanks(self):
|
|
||||||
dc = _bare_controller(["a", "b", "c"])
|
|
||||||
dc._note_empty_pass()
|
|
||||||
dc._note_empty_pass()
|
|
||||||
dc.display_manager.clear.assert_not_called()
|
|
||||||
|
|
||||||
def test_content_resets_the_streak(self):
|
def test_content_resets_the_streak(self):
|
||||||
dc = _bare_controller(["a", "b"])
|
dc = _bare_controller(["a", "b"])
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ a Vegas iteration runs for vegas_scroll.max_cycle_duration (240s here). On a
|
|||||||
real Pi on 2026-09-23:
|
real Pi on 2026-09-23:
|
||||||
|
|
||||||
* an on-demand request posted at 10:54:27 was activated at 10:57:24, when the
|
* 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 request
|
Vegas iteration it arrived during finally ended -- nothing read the mailbox
|
||||||
in between, because _check_vegas_interrupt only looked at a flag that the
|
in between, because _check_vegas_interrupt only looked at a flag that the
|
||||||
main-loop read sets;
|
main-loop read sets;
|
||||||
* two brightness saves 12s apart inside one 30s screen never showed at all.
|
* two brightness saves 12s apart inside one 30s screen never showed at all.
|
||||||
@@ -15,11 +15,6 @@ _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
|
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
|
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.
|
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
|
import threading
|
||||||
@@ -33,6 +28,7 @@ from src.vegas_mode.config import VegasModeConfig
|
|||||||
from src.vegas_mode.coordinator import VegasModeCoordinator
|
from src.vegas_mode.coordinator import VegasModeCoordinator
|
||||||
|
|
||||||
VEGAS_ITERATION_SECONDS = 240
|
VEGAS_ITERATION_SECONDS = 240
|
||||||
|
REQUEST_KEY = 'display_on_demand_request'
|
||||||
|
|
||||||
|
|
||||||
class FakeClock:
|
class FakeClock:
|
||||||
@@ -79,15 +75,26 @@ def clock(monkeypatch):
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def controller(test_display_controller, clock):
|
def controller(test_display_controller, clock):
|
||||||
"""A controller at rest: no schedule, full brightness, nothing queued."""
|
"""A controller at rest: no schedule, full brightness, empty mailbox."""
|
||||||
c = test_display_controller
|
c = test_display_controller
|
||||||
c._refresh_config_cache({'display': {'hardware': {'brightness': 90}}})
|
c._refresh_config_cache({'display': {'hardware': {'brightness': 90}}})
|
||||||
c.current_brightness = 90
|
c.current_brightness = 90
|
||||||
c.is_display_active = True
|
c.is_display_active = True
|
||||||
c._check_wifi_status_message = MagicMock(return_value=None)
|
c._check_wifi_status_message = MagicMock(return_value=None)
|
||||||
|
|
||||||
c.cache_manager.get = MagicMock(return_value=None)
|
c.mailbox = {} # what the web process has written
|
||||||
c.cache_manager.delete = MagicMock()
|
|
||||||
|
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.set = MagicMock()
|
c.cache_manager.set = MagicMock()
|
||||||
c.display_manager.set_brightness = MagicMock(return_value=True)
|
c.display_manager.set_brightness = MagicMock(return_value=True)
|
||||||
c.display_manager.update_display = MagicMock()
|
c.display_manager.update_display = MagicMock()
|
||||||
@@ -103,12 +110,11 @@ def controller(test_display_controller, clock):
|
|||||||
return c
|
return c
|
||||||
|
|
||||||
|
|
||||||
def post_request(controller, request_id='r1', mode='clock', action='start'):
|
def post_request(controller, request_id='r1', mode='clock'):
|
||||||
"""A plugin asking for the screen (or giving it back) from its thread."""
|
controller.mailbox['request'] = {
|
||||||
assert controller.submit_plugin_on_demand({
|
'request_id': request_id, 'action': 'start',
|
||||||
'request_id': request_id, 'action': action,
|
'plugin_id': mode, 'mode': mode,
|
||||||
'plugin_id': mode, 'mode': mode, 'source': 'plugin',
|
}
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def save_brightness(controller, brightness):
|
def save_brightness(controller, brightness):
|
||||||
@@ -118,11 +124,9 @@ def save_brightness(controller, brightness):
|
|||||||
{'display': {'hardware': {'brightness': brightness}}})
|
{'display': {'hardware': {'brightness': brightness}}})
|
||||||
|
|
||||||
|
|
||||||
def count_passes(controller):
|
def mailbox_reads(controller):
|
||||||
"""Count the pending-changes passes that got past the throttle."""
|
return sum(1 for call in controller.cache_manager.get.call_args_list
|
||||||
controller._poll_on_demand_requests = MagicMock(
|
if call.args and call.args[0] == REQUEST_KEY)
|
||||||
wraps=controller._poll_on_demand_requests)
|
|
||||||
return controller._poll_on_demand_requests
|
|
||||||
|
|
||||||
|
|
||||||
def vegas_coordinator(controller):
|
def vegas_coordinator(controller):
|
||||||
@@ -202,16 +206,15 @@ class TestOnDemandDuringVegas:
|
|||||||
"interrupt checker never saw the request")
|
"interrupt checker never saw the request")
|
||||||
assert controller.on_demand_active
|
assert controller.on_demand_active
|
||||||
|
|
||||||
def test_a_quiet_iteration_runs_out(self, controller, clock):
|
def test_an_empty_mailbox_lets_the_iteration_run_out(self, controller, clock):
|
||||||
coord = vegas_coordinator(controller)
|
coord = vegas_coordinator(controller)
|
||||||
passes = count_passes(controller)
|
|
||||||
assert coord.run_iteration() is True
|
assert coord.run_iteration() is True
|
||||||
assert not controller.on_demand_active
|
assert not controller.on_demand_active
|
||||||
# And the pass is throttled: at most one per interval, not per check.
|
# And the read is throttled: at most one per interval, not per check.
|
||||||
max_passes = VEGAS_ITERATION_SECONDS / controller.PENDING_CHANGES_INTERVAL + 1
|
max_reads = VEGAS_ITERATION_SECONDS / controller.PENDING_CHANGES_INTERVAL + 1
|
||||||
assert 0 < passes.call_count <= max_passes
|
assert 0 < mailbox_reads(controller) <= max_reads
|
||||||
checks = coord.frames // coord._interrupt_check_interval
|
checks = coord.frames // coord._interrupt_check_interval
|
||||||
assert passes.call_count < checks / 2
|
assert mailbox_reads(controller) < checks / 2
|
||||||
|
|
||||||
|
|
||||||
class TestBrightnessIsAppliedMidScreen:
|
class TestBrightnessIsAppliedMidScreen:
|
||||||
@@ -291,25 +294,23 @@ class TestBrightnessIsAppliedMidScreen:
|
|||||||
|
|
||||||
|
|
||||||
class TestThrottle:
|
class TestThrottle:
|
||||||
def test_no_passes_or_brightness_calls_between_passes(self, controller, clock):
|
def test_no_reads_or_brightness_calls_between_passes(self, controller, clock):
|
||||||
controller._service_pending_changes()
|
controller._service_pending_changes()
|
||||||
passes = count_passes(controller)
|
reads = mailbox_reads(controller)
|
||||||
save_brightness(controller, 40)
|
save_brightness(controller, 40)
|
||||||
|
post_request(controller)
|
||||||
for _ in range(500): # a few seconds of frames, all inside one interval
|
for _ in range(500): # a few seconds of frames, all inside one interval
|
||||||
controller._service_pending_changes()
|
controller._service_pending_changes()
|
||||||
clock.t += controller.PENDING_CHANGES_INTERVAL / 1000
|
clock.t += controller.PENDING_CHANGES_INTERVAL / 1000
|
||||||
assert passes.call_count == 0
|
assert mailbox_reads(controller) == reads
|
||||||
controller.display_manager.set_brightness.assert_not_called()
|
controller.display_manager.set_brightness.assert_not_called()
|
||||||
|
assert not controller.on_demand_active
|
||||||
|
|
||||||
clock.t += controller.PENDING_CHANGES_INTERVAL
|
clock.t += controller.PENDING_CHANGES_INTERVAL
|
||||||
controller._service_pending_changes()
|
controller._service_pending_changes()
|
||||||
assert passes.call_count == 1
|
# The poll, plus the consume step's re-read of the request it acted on.
|
||||||
|
assert mailbox_reads(controller) > reads
|
||||||
controller.display_manager.set_brightness.assert_called_once_with(40)
|
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
|
assert controller.on_demand_active
|
||||||
|
|
||||||
def test_between_passes_it_does_no_work_at_all(self, controller, clock):
|
def test_between_passes_it_does_no_work_at_all(self, controller, clock):
|
||||||
@@ -396,7 +397,7 @@ class TestScheduleAndDwells:
|
|||||||
controller._service_pending_changes()
|
controller._service_pending_changes()
|
||||||
assert controller.on_demand_active
|
assert controller.on_demand_active
|
||||||
clock.t += controller.PENDING_CHANGES_INTERVAL
|
clock.t += controller.PENDING_CHANGES_INTERVAL
|
||||||
post_request(controller, request_id='r2', action='stop')
|
controller.mailbox['request'] = {'request_id': 'r2', 'action': 'stop'}
|
||||||
start = clock.t
|
start = clock.t
|
||||||
controller._sleep_with_plugin_updates(30)
|
controller._sleep_with_plugin_updates(30)
|
||||||
assert not controller.on_demand_active
|
assert not controller.on_demand_active
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
|
|||||||
from src.cache_manager import CacheManager # noqa: E402
|
from src.cache_manager import CacheManager # noqa: E402
|
||||||
from src import error_aggregator as errors # noqa: E402
|
from src import error_aggregator as errors # noqa: E402
|
||||||
from src.error_aggregator import ( # noqa: E402
|
from src.error_aggregator import ( # noqa: E402
|
||||||
ERROR_SNAPSHOT_KEY, ErrorAggregator,
|
ERROR_CLEAR_REQUEST_KEY, ERROR_SNAPSHOT_KEY, ErrorAggregator,
|
||||||
ErrorSnapshotPublisher,
|
ErrorSnapshotPublisher,
|
||||||
)
|
)
|
||||||
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
||||||
@@ -314,36 +314,151 @@ class TestRoutes:
|
|||||||
assert secret not in published, secret
|
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"
|
CLIENT = "web_interface.blueprints.api_v3.control_client"
|
||||||
RETIRED_CLEAR_KEY = "plugin_error_clear_request"
|
|
||||||
|
|
||||||
|
|
||||||
def _mailbox_file(shared_cache):
|
def _mailbox_file(shared_cache):
|
||||||
_, _, directory = shared_cache
|
_, _, directory = shared_cache
|
||||||
return directory / f"{RETIRED_CLEAR_KEY}.json"
|
return directory / f"{ERROR_CLEAR_REQUEST_KEY}.json"
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
class TestClearOverTheSocket:
|
||||||
def socket_up(display, monkeypatch):
|
"""``errors.clear``: the display applies the clear before it answers, and
|
||||||
"""The control socket, as the display serves it: errors_clear runs the
|
the mailbox is written only when the socket could not carry it."""
|
||||||
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):
|
@pytest.fixture
|
||||||
calls.append((request_id, cutoff))
|
def socket_up(self, display, monkeypatch):
|
||||||
return errors.apply_error_clear(request_id, ErrorsClearArgs(cutoff=cutoff))
|
"""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 = []
|
||||||
|
|
||||||
monkeypatch.setattr(f"{CLIENT}.errors_clear", errors_clear)
|
def errors_clear(request_id, cutoff, **kw):
|
||||||
assert control_client.errors_clear is errors_clear
|
calls.append((request_id, cutoff))
|
||||||
return calls
|
return errors.apply_error_clear(request_id, ErrorsClearArgs(cutoff=cutoff))
|
||||||
|
|
||||||
|
monkeypatch.setattr(f"{CLIENT}.errors_clear", errors_clear)
|
||||||
class TestClear:
|
assert control_client.errors_clear is errors_clear
|
||||||
"""``errors.clear``: the display applies the clear before it answers."""
|
return calls
|
||||||
|
|
||||||
def test_socket_clear_is_applied_before_the_answer(self, web, display, socket_up,
|
def test_socket_clear_is_applied_before_the_answer(self, web, display, socket_up,
|
||||||
shared_cache):
|
shared_cache):
|
||||||
@@ -356,7 +471,6 @@ class TestClear:
|
|||||||
body = response.get_json()
|
body = response.get_json()
|
||||||
data = body["data"]
|
data = body["data"]
|
||||||
assert data["transport"] == "socket" and data["applied"] is True
|
assert data["transport"] == "socket" and data["applied"] is True
|
||||||
assert data["clear_requested"] is True
|
|
||||||
assert data["cleared_count"] == 3
|
assert data["cleared_count"] == 3
|
||||||
assert body["message"] == "Cleared all errors"
|
assert body["message"] == "Cleared all errors"
|
||||||
[(request_id, _)] = socket_up
|
[(request_id, _)] = socket_up
|
||||||
@@ -365,134 +479,90 @@ class TestClear:
|
|||||||
assert aggregator.get_error_summary()["total_errors"] == 0
|
assert aggregator.get_error_summary()["total_errors"] == 0
|
||||||
summary = _summary(web)
|
summary = _summary(web)
|
||||||
assert summary["total_errors"] == 0 and summary["clear_pending"] is False
|
assert summary["total_errors"] == 0 and summary["clear_pending"] is False
|
||||||
|
# And no mailbox file.
|
||||||
assert not _mailbox_file(shared_cache).exists()
|
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):
|
def test_an_older_mailbox_request_does_not_read_as_pending(self, web, display, socket_up,
|
||||||
aggregator, publisher, _ = display
|
shared_cache, monkeypatch):
|
||||||
_fail(aggregator, plugin_id="before")
|
# A clear that went to the mailbox while the socket was down, then a
|
||||||
for record in aggregator._records:
|
# wider one over the socket: the old request has nothing left to hide.
|
||||||
record.timestamp -= timedelta(seconds=5)
|
from unittest.mock import patch
|
||||||
publisher.tick()
|
from src.ipc import client as control_client
|
||||||
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
|
aggregator, publisher, _ = display
|
||||||
_fail(aggregator)
|
_fail(aggregator)
|
||||||
publisher.tick()
|
publisher.tick()
|
||||||
response = web.post("/api/v3/errors/clear")
|
with patch(f"{CLIENT}.errors_clear",
|
||||||
assert response.status_code == 200
|
side_effect=control_client.ControlError("no_socket")):
|
||||||
assert response.get_json()["data"]["cleared_count"] == 0
|
data = web.post("/api/v3/errors/clear", json={"max_age_hours": 1}).get_json()["data"]
|
||||||
assert _summary(web)["total_errors"] == 1
|
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
|
||||||
|
|
||||||
def test_validation_is_unchanged_but_all_skips_it(self, web, socket_up):
|
@pytest.mark.parametrize("reason", ["no_socket", "refused", "disabled", "unsupported"])
|
||||||
assert web.post("/api/v3/errors/clear", json={"max_age_hours": 0}).status_code == 400
|
def test_no_socket_writes_the_mailbox(self, web, display, shared_cache, monkeypatch, reason):
|
||||||
assert web.post("/api/v3/errors/clear", json={"max_age_hours": 9000}).status_code == 400
|
from src.ipc import client as control_client
|
||||||
assert web.post("/api/v3/errors/clear",
|
monkeypatch.setattr(f"{CLIENT}.errors_clear", MagicMock(
|
||||||
json={"all": True, "max_age_hours": "junk"}).status_code == 200
|
side_effect=control_client.ControlError(reason, sent=False)))
|
||||||
|
|
||||||
|
|
||||||
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
|
aggregator, publisher, _ = display
|
||||||
_fail(aggregator)
|
_fail(aggregator)
|
||||||
publisher.tick()
|
publisher.tick()
|
||||||
return web.post("/api/v3/errors/clear", json={"all": True})
|
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
|
||||||
|
|
||||||
@pytest.mark.parametrize("reason", ["no_socket", "refused"])
|
def test_an_older_display_gets_the_mailbox(self, web, display, shared_cache, monkeypatch):
|
||||||
def test_a_stopped_display_is_an_error(self, web, display, shared_cache, monkeypatch,
|
# The upgrade case: new web interface, a display from before errors.clear.
|
||||||
reason):
|
|
||||||
from src.ipc import client as control_client
|
from src.ipc import client as control_client
|
||||||
response = self._post(web, display, monkeypatch,
|
monkeypatch.setattr(f"{CLIENT}.errors_clear", MagicMock(
|
||||||
control_client.ControlError(reason, sent=False))
|
side_effect=control_client.ControlError("unknown_command", sent=True)))
|
||||||
assert response.status_code == 503
|
aggregator, publisher, _ = display
|
||||||
body = response.get_json()
|
_fail(aggregator)
|
||||||
assert body["context"]["socket_error"] == reason
|
publisher.tick()
|
||||||
assert "not running" in body["message"]
|
data = web.post("/api/v3/errors/clear", json={"all": True}).get_json()["data"]
|
||||||
assert not _mailbox_file(shared_cache).exists()
|
assert data["transport"] == "mailbox"
|
||||||
# Nothing hides them: they are still the display's last report.
|
publisher.tick()
|
||||||
summary = _summary(web)
|
assert aggregator.get_error_summary()["total_errors"] == 0
|
||||||
assert summary["total_errors"] == 1 and summary["clear_pending"] is False
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("reason", ["disabled", "unsupported"])
|
|
||||||
def test_no_socket_here_is_an_error(self, web, display, shared_cache, monkeypatch, reason):
|
|
||||||
from src.ipc import client as control_client
|
|
||||||
response = self._post(web, display, monkeypatch,
|
|
||||||
control_client.ControlError(reason, sent=False))
|
|
||||||
assert response.status_code == 503
|
|
||||||
assert "not available" in response.get_json()["message"]
|
|
||||||
assert not _mailbox_file(shared_cache).exists()
|
|
||||||
|
|
||||||
def test_an_older_display_is_told_to_restart(self, web, display, shared_cache,
|
|
||||||
monkeypatch):
|
|
||||||
from src.ipc import client as control_client
|
|
||||||
response = self._post(web, display, monkeypatch,
|
|
||||||
control_client.ControlError("unknown_command", sent=True))
|
|
||||||
assert response.status_code == 503
|
|
||||||
assert "restart" in response.get_json()["message"]
|
|
||||||
assert not _mailbox_file(shared_cache).exists()
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("reason", ["internal", "timeout", "busy", "invalid_args"])
|
@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,
|
def test_a_display_that_had_it_and_failed_is_an_error(self, web, display, shared_cache,
|
||||||
monkeypatch, reason):
|
monkeypatch, reason):
|
||||||
from src.ipc import client as control_client
|
from src.ipc import client as control_client
|
||||||
response = self._post(web, display, monkeypatch,
|
monkeypatch.setattr(f"{CLIENT}.errors_clear", MagicMock(
|
||||||
control_client.ControlError(reason, sent=True))
|
side_effect=control_client.ControlError(reason, sent=True)))
|
||||||
assert response.status_code == 503
|
|
||||||
body = response.get_json()
|
|
||||||
assert body["context"]["socket_error"] == reason
|
|
||||||
assert body["message"] == "The display service did not apply the clear"
|
|
||||||
assert not _mailbox_file(shared_cache).exists()
|
|
||||||
|
|
||||||
def test_the_default_test_setup_has_no_socket(self, web, display, shared_cache):
|
|
||||||
# conftest turns the socket off: the real client answers "disabled".
|
|
||||||
aggregator, publisher, _ = display
|
|
||||||
_fail(aggregator)
|
|
||||||
publisher.tick()
|
|
||||||
response = web.post("/api/v3/errors/clear", json={"all": True})
|
response = web.post("/api/v3/errors/clear", json={"all": True})
|
||||||
assert response.status_code == 503
|
assert response.status_code == 503
|
||||||
assert response.get_json()["context"]["socket_error"] in ("disabled", "unsupported")
|
assert response.get_json()["context"]["socket_error"] == reason
|
||||||
|
assert not _mailbox_file(shared_cache).exists()
|
||||||
|
|
||||||
|
|
||||||
class TestPublisher:
|
class TestPublisherMailboxPoll:
|
||||||
def test_a_tick_reads_no_clear_request(self, display, shared_cache):
|
def test_the_mailbox_is_read_only_when_its_file_changed(self, display, shared_cache):
|
||||||
_, publisher, clock = display
|
_, publisher, _ = display
|
||||||
_, web_cache, directory = shared_cache
|
_, web_cache, _ = shared_cache
|
||||||
# An old web interface's leftover request file is ignored.
|
publisher.tick()
|
||||||
(directory / f"{RETIRED_CLEAR_KEY}.json").write_text(
|
|
||||||
'{"timestamp": 1, "data": {"request_id": "old", "cutoff": 9e9}}')
|
|
||||||
publisher.cache_manager = MagicMock(wraps=publisher.cache_manager)
|
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):
|
for _ in range(5):
|
||||||
clock.now += 60
|
|
||||||
publisher.tick()
|
publisher.tick()
|
||||||
publisher.cache_manager.get.assert_not_called()
|
assert reads() == [] # no file: a stat per tick, no read
|
||||||
snapshot = web_cache.get(ERROR_SNAPSHOT_KEY, max_age=None, memory_ttl=0)
|
errors.request_error_clear(web_cache, 1.0)
|
||||||
assert snapshot["applied_clear_id"] is None
|
publisher.tick()
|
||||||
|
assert len(reads()) == 1
|
||||||
|
for _ in range(5):
|
||||||
|
publisher.tick()
|
||||||
|
assert len(reads()) == 1 # unchanged file: not read again
|
||||||
|
errors.request_error_clear(web_cache, 2.0)
|
||||||
|
publisher.tick()
|
||||||
|
assert len(reads()) == 2
|
||||||
|
|
||||||
def test_clear_now_publishes_what_it_applied(self, display, shared_cache):
|
def test_clear_now_publishes_what_it_applied(self, display, shared_cache):
|
||||||
aggregator, publisher, _ = display
|
aggregator, publisher, _ = display
|
||||||
@@ -502,6 +572,7 @@ class TestPublisher:
|
|||||||
snapshot = web_cache.get(ERROR_SNAPSHOT_KEY, max_age=None, memory_ttl=0)
|
snapshot = web_cache.get(ERROR_SNAPSHOT_KEY, max_age=None, memory_ttl=0)
|
||||||
assert snapshot["applied_clear_id"] == "sock-1"
|
assert snapshot["applied_clear_id"] == "sock-1"
|
||||||
assert snapshot["total_errors"] == 0
|
assert snapshot["total_errors"] == 0
|
||||||
|
assert snapshot["applied_clear_cutoff"] is not None
|
||||||
|
|
||||||
def test_the_handler_needs_a_running_publisher(self, monkeypatch):
|
def test_the_handler_needs_a_running_publisher(self, monkeypatch):
|
||||||
from src.ipc.contract import ErrorsClearArgs
|
from src.ipc.contract import ErrorsClearArgs
|
||||||
@@ -512,10 +583,12 @@ class TestPublisher:
|
|||||||
|
|
||||||
@pytest.mark.skipif(not hasattr(os, "fchmod") or os.name == "nt",
|
@pytest.mark.skipif(not hasattr(os, "fchmod") or os.name == "nt",
|
||||||
reason="POSIX file modes")
|
reason="POSIX file modes")
|
||||||
def test_the_snapshot_is_group_readable(web, display, shared_cache):
|
def test_both_files_are_group_readable(web, display, shared_cache):
|
||||||
aggregator, publisher, _ = display
|
aggregator, publisher, _ = display
|
||||||
_, _, directory = shared_cache
|
_, _, directory = shared_cache
|
||||||
_fail(aggregator)
|
_fail(aggregator)
|
||||||
publisher.tick()
|
publisher.tick()
|
||||||
mode = stat.S_IMODE(os.stat(directory / f"{ERROR_SNAPSHOT_KEY}.json").st_mode)
|
web.post("/api/v3/errors/clear", json={"all": True})
|
||||||
assert mode == 0o660, oct(mode)
|
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))
|
||||||
|
|||||||
@@ -39,14 +39,6 @@ def forget_rejected_ranges(monkeypatch):
|
|||||||
monkeypatch.setattr(espn_dates, "_ranges_rejected_until", 0.0)
|
monkeypatch.setattr(espn_dates, "_ranges_rejected_until", 0.0)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def nothing_is_settled_yet(monkeypatch):
|
|
||||||
"""Pin "today" before every date these tests use, so the settled-chunk
|
|
||||||
memory stays out of tests that are not about it whatever the real date.
|
|
||||||
TestSettledChunkCache moves it forward."""
|
|
||||||
monkeypatch.setattr(espn_dates, "_utc_today", lambda: date(2000, 1, 1))
|
|
||||||
|
|
||||||
|
|
||||||
class FakeResponse:
|
class FakeResponse:
|
||||||
def __init__(self, status_code=200, payload=None):
|
def __init__(self, status_code=200, payload=None):
|
||||||
self.status_code = status_code
|
self.status_code = status_code
|
||||||
@@ -499,100 +491,6 @@ class TestConcurrency:
|
|||||||
assert live["peak"] > 1, "chunks should actually overlap"
|
assert live["peak"] > 1, "chunks should actually overlap"
|
||||||
|
|
||||||
|
|
||||||
class TestSettledChunkCache:
|
|
||||||
"""Days that ended three or more days ago are fetched once a day, not hourly.
|
|
||||||
|
|
||||||
The scoreboards re-fetch a 22-day window every hour; on hdpi (2026-10-02)
|
|
||||||
the 12 settled days were 68% of that window's bytes.
|
|
||||||
"""
|
|
||||||
|
|
||||||
TODAY = date(2026, 10, 2)
|
|
||||||
# The scoreboards' default window on that day: 14 back, 7 ahead.
|
|
||||||
WINDOW = "20260918-20261009"
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def frozen_today(self, monkeypatch):
|
|
||||||
monkeypatch.setattr(espn_dates, "_utc_today", lambda: self.TODAY)
|
|
||||||
# These count day requests; whole edge months are covered below.
|
|
||||||
monkeypatch.setattr(espn_dates, "ESPN_MONTH_COVER_MIN_DAYS", 0)
|
|
||||||
|
|
||||||
def _events(self):
|
|
||||||
days = [(9, d) for d in range(18, 31)] + [(10, d) for d in range(1, 10)]
|
|
||||||
return {"2026%02d%02d" % (m, d): [{"id": f"{m}-{d}"}] for m, d in days}
|
|
||||||
|
|
||||||
def test_the_second_refresh_only_asks_for_unsettled_days(self):
|
|
||||||
session = FakeSession(self._events())
|
|
||||||
first = fetch_espn_scoreboard(session, URL, params={"dates": self.WINDOW})
|
|
||||||
session.calls.clear()
|
|
||||||
|
|
||||||
second = fetch_espn_scoreboard(session, URL, params={"dates": self.WINDOW})
|
|
||||||
|
|
||||||
asked = sorted(call["dates"] for call in session.calls)
|
|
||||||
# Sep 29 is the last settled day (today minus three).
|
|
||||||
assert asked == ["20260930"] + ["202610%02d" % d for d in range(1, 10)]
|
|
||||||
assert second["events"] == first["events"] # same events, same order
|
|
||||||
assert len(second["events"]) == 22
|
|
||||||
|
|
||||||
def test_a_hit_is_a_fresh_copy(self):
|
|
||||||
session = FakeSession(self._events())
|
|
||||||
fetch_espn_date_chunks(session, URL, params={"dates": self.WINDOW})
|
|
||||||
hit = fetch_espn_date_chunks(session, URL, params={"dates": self.WINDOW})
|
|
||||||
hit["events"][0]["id"] = "mutated"
|
|
||||||
again = fetch_espn_date_chunks(session, URL, params={"dates": self.WINDOW})
|
|
||||||
assert again["events"][0]["id"] == "9-18"
|
|
||||||
|
|
||||||
def test_other_params_are_part_of_the_key(self):
|
|
||||||
session = FakeSession(self._events())
|
|
||||||
fetch_espn_date_chunks(session, URL, params={"dates": self.WINDOW, "groups": 80})
|
|
||||||
session.calls.clear()
|
|
||||||
fetch_espn_date_chunks(session, URL, params={"dates": self.WINDOW})
|
|
||||||
assert len(session.calls) == 22 # a different question, nothing reused
|
|
||||||
|
|
||||||
def test_entries_expire_after_a_day(self, monkeypatch):
|
|
||||||
clock = [1000.0]
|
|
||||||
monkeypatch.setattr(espn_dates.time, "monotonic", lambda: clock[0])
|
|
||||||
session = FakeSession(self._events())
|
|
||||||
fetch_espn_date_chunks(session, URL, params={"dates": self.WINDOW})
|
|
||||||
clock[0] += espn_dates.SETTLED_CHUNK_TTL_SECONDS + 1
|
|
||||||
session.calls.clear()
|
|
||||||
fetch_espn_date_chunks(session, URL, params={"dates": self.WINDOW})
|
|
||||||
assert len(session.calls) == 22
|
|
||||||
|
|
||||||
def test_failed_chunks_are_not_remembered(self):
|
|
||||||
session = FakeSession(self._events(), fail_chunks={"20260920"})
|
|
||||||
fetch_espn_date_chunks(session, URL, params={"dates": self.WINDOW})
|
|
||||||
session.fail_chunks.clear()
|
|
||||||
session.calls.clear()
|
|
||||||
data = fetch_espn_date_chunks(session, URL, params={"dates": self.WINDOW})
|
|
||||||
assert "20260920" in [call["dates"] for call in session.calls]
|
|
||||||
assert len(data["events"]) == 22
|
|
||||||
|
|
||||||
def test_a_capped_month_is_not_remembered_but_its_days_are(self):
|
|
||||||
full = [{"id": f"x{i}"} for i in range(ESPN_MAX_LIMIT)]
|
|
||||||
session = FakeSession({"202608": full, "20260801": [{"id": "d1"}]})
|
|
||||||
fetch_espn_date_chunks(session, URL, params={"dates": "20260801-20260831"})
|
|
||||||
session.calls.clear()
|
|
||||||
data = fetch_espn_date_chunks(session, URL, params={"dates": "20260801-20260831"})
|
|
||||||
assert [call["dates"] for call in session.calls] == ["202608"]
|
|
||||||
assert [event["id"] for event in data["events"]] == ["d1"]
|
|
||||||
|
|
||||||
def test_memory_is_bounded(self, monkeypatch):
|
|
||||||
monkeypatch.setattr(espn_dates, "SETTLED_CACHE_MAX_ENTRIES", 5)
|
|
||||||
session = FakeSession(self._events())
|
|
||||||
fetch_espn_date_chunks(session, URL, params={"dates": self.WINDOW})
|
|
||||||
assert len(espn_dates._settled_chunks) == 5
|
|
||||||
assert espn_dates._settled_bytes == sum(
|
|
||||||
len(blob) for _, blob in espn_dates._settled_chunks.values())
|
|
||||||
|
|
||||||
def test_single_day_requests_are_untouched(self):
|
|
||||||
# The live path asks for today (or one day) as a plain request; that
|
|
||||||
# never goes through chunks or the memory.
|
|
||||||
session = FakeSession(self._events())
|
|
||||||
for _ in range(2):
|
|
||||||
fetch_espn_scoreboard(session, URL, params={"dates": "20260918"})
|
|
||||||
assert len(session.calls) == 2
|
|
||||||
|
|
||||||
|
|
||||||
class TestEdgeMonths:
|
class TestEdgeMonths:
|
||||||
"""A window's partial edge months are asked whole and trimmed.
|
"""A window's partial edge months are asked whole and trimmed.
|
||||||
|
|
||||||
@@ -675,25 +573,6 @@ class TestEdgeMonths:
|
|||||||
# An event with no readable date is kept, never dropped on a guess.
|
# An event with no readable date is kept, never dropped on a guess.
|
||||||
assert [e["id"] for e in data["events"]] == ["first", "late", "oct1", "last", "undated"]
|
assert [e["id"] for e in data["events"]] == ["first", "late", "oct1", "last", "undated"]
|
||||||
|
|
||||||
def test_a_remembered_month_is_trimmed_per_window(self, monkeypatch):
|
|
||||||
"""The settled-chunk memory holds the month whole; each window trims it."""
|
|
||||||
monkeypatch.setattr(espn_dates, "_utc_today", lambda: date(2026, 12, 20))
|
|
||||||
espn_dates.clear_settled_chunk_cache()
|
|
||||||
november = [
|
|
||||||
{"id": "early", "date": "2026-11-02T18:00Z"},
|
|
||||||
{"id": "late", "date": "2026-11-25T18:00Z"},
|
|
||||||
]
|
|
||||||
session = FakeSession({"202611": november})
|
|
||||||
|
|
||||||
late = fetch_espn_date_chunks(session, URL, params={"dates": "20261120-20261215"})
|
|
||||||
session.calls.clear()
|
|
||||||
early = fetch_espn_date_chunks(session, URL, params={"dates": "20261101-20261110"})
|
|
||||||
|
|
||||||
assert [e["id"] for e in late["events"]] == ["late"]
|
|
||||||
assert [e["id"] for e in early["events"]] == ["early"]
|
|
||||||
assert not any(call["dates"] == "202611" for call in session.calls)
|
|
||||||
espn_dates.clear_settled_chunk_cache()
|
|
||||||
|
|
||||||
def test_eastern_standard_time_is_honoured_after_the_clocks_change(self):
|
def test_eastern_standard_time_is_honoured_after_the_clocks_change(self):
|
||||||
# 2026-11-01 ends daylight saving: Eastern is UTC-5 from then on.
|
# 2026-11-01 ends daylight saving: Eastern is UTC-5 from then on.
|
||||||
november = [
|
november = [
|
||||||
|
|||||||
@@ -392,15 +392,6 @@ class TestGetEspnScoreboard:
|
|||||||
"20261004", cache_manager=cache)
|
"20261004", cache_manager=cache)
|
||||||
assert cache.records == {}
|
assert cache.records == {}
|
||||||
|
|
||||||
@pytest.mark.parametrize("payload", [{"events": None}, {}, {"events": "x"}, []])
|
|
||||||
def test_an_answer_without_an_events_list_is_returned_not_cached(self, service, payload):
|
|
||||||
espn = Espn()
|
|
||||||
espn.get = lambda url, **kwargs: _response(payload, url, None)
|
|
||||||
cache = RecordCache()
|
|
||||||
assert get_espn_scoreboard(espn, "football", "nfl", "20261004",
|
|
||||||
cache_manager=cache) == payload
|
|
||||||
assert cache.records == {}
|
|
||||||
|
|
||||||
def test_no_cache_manager_just_fetches(self, service):
|
def test_no_cache_manager_just_fetches(self, service):
|
||||||
espn = Espn()
|
espn = Espn()
|
||||||
get_espn_scoreboard(espn, "football", "nfl", "20261004")
|
get_espn_scoreboard(espn, "football", "nfl", "20261004")
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
"""The General tab's "Start Display on Boot" toggle reads systemd's state.
|
|
||||||
|
|
||||||
ledmatrix.service enabled -> checked, disabled -> unchecked, unreadable (no
|
|
||||||
systemctl, unit not installed) -> no toggle. The toggle itself posts the
|
|
||||||
existing enable_autostart / disable_autostart system actions, whose sudo
|
|
||||||
grants test_systemctl_sudoers_alignment.py already checks.
|
|
||||||
"""
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).parent.parent
|
|
||||||
sys.path.insert(0, str(PROJECT_ROOT))
|
|
||||||
|
|
||||||
from test.test_web_settings_ui import client # noqa: F401,E402
|
|
||||||
from web_interface.blueprints import pages_v3 # noqa: E402
|
|
||||||
|
|
||||||
|
|
||||||
REAL_RUN = subprocess.run
|
|
||||||
UNIT_QUERY = ['systemctl', 'is-enabled', 'ledmatrix.service']
|
|
||||||
|
|
||||||
|
|
||||||
def _fake_systemctl(monkeypatch, answer):
|
|
||||||
"""Answer the unit query with `answer` (stdout, or an exception to
|
|
||||||
raise); every other command (the update channel's git) runs for real."""
|
|
||||||
calls = []
|
|
||||||
|
|
||||||
def run(cmd, *args, **kwargs):
|
|
||||||
if cmd != UNIT_QUERY:
|
|
||||||
return REAL_RUN(cmd, *args, **kwargs)
|
|
||||||
calls.append(cmd)
|
|
||||||
if isinstance(answer, Exception):
|
|
||||||
raise answer
|
|
||||||
return subprocess.CompletedProcess(cmd, 0, stdout=answer, stderr='')
|
|
||||||
monkeypatch.setattr(pages_v3.subprocess, 'run', run)
|
|
||||||
return calls
|
|
||||||
|
|
||||||
|
|
||||||
def _toggle(body):
|
|
||||||
start = body.find('data-action="display-autostart"')
|
|
||||||
return None if start < 0 else body[start:body.find('>', start)]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize('stdout, checked', [('enabled\n', True), ('disabled\n', False)])
|
|
||||||
def test_toggle_reflects_the_unit(client, monkeypatch, stdout, checked):
|
|
||||||
calls = _fake_systemctl(monkeypatch, stdout)
|
|
||||||
tag = _toggle(client.get('/v3/partials/general').get_data(as_text=True))
|
|
||||||
assert calls == [UNIT_QUERY]
|
|
||||||
assert tag is not None
|
|
||||||
assert ('checked' in tag) is checked
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize('outcome', [FileNotFoundError(), 'not-found\n'])
|
|
||||||
def test_toggle_hidden_when_state_unreadable(client, monkeypatch, outcome):
|
|
||||||
_fake_systemctl(monkeypatch, outcome)
|
|
||||||
body = client.get('/v3/partials/general').get_data(as_text=True)
|
|
||||||
assert _toggle(body) is None
|
|
||||||
assert 'name="plugins_directory"' in body # rest of the tab still renders
|
|
||||||
@@ -1,325 +0,0 @@
|
|||||||
"""A stale .git/index.lock must not block Update Code forever.
|
|
||||||
|
|
||||||
subprocess.run's timeout SIGKILLs git, and a killed git cannot remove its
|
|
||||||
index.lock. Every later update then failed with "Unable to create
|
|
||||||
'.../.git/index.lock': File exists" until someone removed the file over SSH.
|
|
||||||
The update now removes the lock when no git process can still own it.
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
from pathlib import Path
|
|
||||||
import contextlib
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from flask import Flask
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
from web_interface import git_index_lock as gil # noqa: E402
|
|
||||||
from web_interface.blueprints import api_v3 as mod # noqa: E402
|
|
||||||
from web_interface.blueprints.api_v3 import api_v3 # noqa: E402
|
|
||||||
|
|
||||||
needs_git = pytest.mark.skipif(shutil.which('git') is None, reason='git not installed')
|
|
||||||
|
|
||||||
OLD = 1_000_000.0
|
|
||||||
|
|
||||||
|
|
||||||
@contextlib.contextmanager
|
|
||||||
def _patched(fake):
|
|
||||||
"""Fake git for both the plain subprocess calls and the timeout-safe runner."""
|
|
||||||
with patch.object(mod.subprocess, 'run', fake), patch.object(mod.git_index_lock, 'run_git', fake):
|
|
||||||
yield
|
|
||||||
|
|
||||||
|
|
||||||
def _checkout(tmp_path, lock_age=None):
|
|
||||||
repo = tmp_path / 'LEDMatrix'
|
|
||||||
(repo / '.git').mkdir(parents=True)
|
|
||||||
if lock_age is not None:
|
|
||||||
lock = repo / '.git' / 'index.lock'
|
|
||||||
lock.write_text('')
|
|
||||||
os.utime(lock, (OLD, OLD))
|
|
||||||
return repo
|
|
||||||
|
|
||||||
|
|
||||||
def _proc(tmp_path, *procs):
|
|
||||||
"""A fake /proc: ``procs`` are (comm, cwd[, argv]); cwd None means unreadable."""
|
|
||||||
root = tmp_path / 'proc'
|
|
||||||
root.mkdir()
|
|
||||||
(root / 'self').mkdir() # non-numeric entries are skipped
|
|
||||||
for pid, (comm, cwd, *argv) in enumerate(procs, start=100):
|
|
||||||
entry = root / str(pid)
|
|
||||||
entry.mkdir()
|
|
||||||
(entry / 'comm').write_text(comm + '\n')
|
|
||||||
(entry / 'cmdline').write_bytes(b'\0'.join(a.encode() for a in [comm, *argv]) + b'\0')
|
|
||||||
if cwd is not None:
|
|
||||||
(entry / 'cwd').symlink_to(cwd)
|
|
||||||
else:
|
|
||||||
(entry / 'cwd').mkdir() # readlink on a directory fails: unreadable
|
|
||||||
return root
|
|
||||||
|
|
||||||
|
|
||||||
class TestClearStaleIndexLock:
|
|
||||||
def test_no_lock_is_left_alone(self, tmp_path):
|
|
||||||
repo = _checkout(tmp_path)
|
|
||||||
assert gil.clear_stale_index_lock(repo, proc_root=_proc(tmp_path)) == gil.ABSENT
|
|
||||||
|
|
||||||
def test_a_stale_lock_is_removed(self, tmp_path):
|
|
||||||
repo = _checkout(tmp_path, lock_age=60)
|
|
||||||
proc = _proc(tmp_path, ('python3', str(repo)), ('git', str(tmp_path / 'elsewhere')))
|
|
||||||
assert gil.clear_stale_index_lock(repo, proc_root=proc, now=OLD + 60) == gil.REMOVED
|
|
||||||
assert not (repo / '.git' / 'index.lock').exists()
|
|
||||||
|
|
||||||
@pytest.mark.parametrize('comm', ['git', 'git-remote-http'])
|
|
||||||
def test_a_lock_held_by_a_running_git_is_kept(self, tmp_path, comm):
|
|
||||||
repo = _checkout(tmp_path, lock_age=60)
|
|
||||||
proc = _proc(tmp_path, (comm, str(repo / 'src')))
|
|
||||||
assert gil.clear_stale_index_lock(repo, proc_root=proc, now=OLD + 60) == gil.IN_USE
|
|
||||||
assert (repo / '.git' / 'index.lock').exists()
|
|
||||||
|
|
||||||
def test_a_git_pointed_at_the_checkout_from_elsewhere_is_running(self, tmp_path):
|
|
||||||
repo = _checkout(tmp_path, lock_age=60)
|
|
||||||
proc = _proc(tmp_path, ('git', str(tmp_path), f'--git-dir={repo.resolve()}/.git',
|
|
||||||
f'--work-tree={repo.resolve()}', 'checkout', 'v3.8.7'))
|
|
||||||
assert gil.clear_stale_index_lock(repo, proc_root=proc, now=OLD + 60) == gil.IN_USE
|
|
||||||
assert (repo / '.git' / 'index.lock').exists()
|
|
||||||
|
|
||||||
@pytest.mark.parametrize('argv', [['-C', 'LEDMatrix', 'status'],
|
|
||||||
['--git-dir=LEDMatrix/.git', '--work-tree=LEDMatrix', 'add', '.']])
|
|
||||||
def test_a_git_pointed_at_the_checkout_by_a_relative_path_is_running(self, tmp_path, argv):
|
|
||||||
repo = _checkout(tmp_path, lock_age=60)
|
|
||||||
proc = _proc(tmp_path, ('git', str(repo.resolve().parent), *argv))
|
|
||||||
assert gil.clear_stale_index_lock(repo, proc_root=proc, now=OLD + 60) == gil.IN_USE
|
|
||||||
|
|
||||||
def test_a_git_working_on_another_repository_is_not(self, tmp_path):
|
|
||||||
repo = _checkout(tmp_path, lock_age=60)
|
|
||||||
proc = _proc(tmp_path, ('git', str(tmp_path), '-C', 'other', 'pull'),
|
|
||||||
('git', str(tmp_path), '--git-dir=LEDMatrix-backup/.git', 'fetch'))
|
|
||||||
assert gil.clear_stale_index_lock(repo, proc_root=proc, now=OLD + 60) == gil.REMOVED
|
|
||||||
|
|
||||||
def test_a_git_whose_cwd_cannot_be_read_counts_as_running(self, tmp_path):
|
|
||||||
repo = _checkout(tmp_path, lock_age=60)
|
|
||||||
proc = _proc(tmp_path, ('git', None))
|
|
||||||
assert gil.clear_stale_index_lock(repo, proc_root=proc, now=OLD + 60) == gil.IN_USE
|
|
||||||
assert (repo / '.git' / 'index.lock').exists()
|
|
||||||
|
|
||||||
def test_a_brand_new_lock_is_kept(self, tmp_path):
|
|
||||||
"""A git may have created it between the process scan and the removal."""
|
|
||||||
repo = _checkout(tmp_path, lock_age=0)
|
|
||||||
assert gil.clear_stale_index_lock(repo, proc_root=_proc(tmp_path), now=OLD + 1) == gil.IN_USE
|
|
||||||
assert (repo / '.git' / 'index.lock').exists()
|
|
||||||
|
|
||||||
def test_without_proc_nothing_is_removed(self, tmp_path):
|
|
||||||
repo = _checkout(tmp_path, lock_age=60)
|
|
||||||
missing = tmp_path / 'no-proc'
|
|
||||||
assert gil.clear_stale_index_lock(repo, proc_root=missing, now=OLD + 60) == gil.UNKNOWN
|
|
||||||
assert (repo / '.git' / 'index.lock').exists()
|
|
||||||
|
|
||||||
def test_a_checkout_without_a_git_directory_is_left_alone(self, tmp_path):
|
|
||||||
assert gil.clear_stale_index_lock(tmp_path, proc_root=_proc(tmp_path)) == gil.ABSENT
|
|
||||||
|
|
||||||
|
|
||||||
@needs_git
|
|
||||||
@pytest.mark.skipif(not Path('/proc/self/comm').exists(), reason='needs /proc')
|
|
||||||
def test_git_works_again_after_a_real_stale_lock_is_cleared(tmp_path):
|
|
||||||
repo = tmp_path / 'repo'
|
|
||||||
env = {**os.environ, 'GIT_AUTHOR_NAME': 't', 'GIT_AUTHOR_EMAIL': 't@t',
|
|
||||||
'GIT_COMMITTER_NAME': 't', 'GIT_COMMITTER_EMAIL': 't@t'}
|
|
||||||
|
|
||||||
def git(*args):
|
|
||||||
return subprocess.run(['git', *args], cwd=repo, env=env, capture_output=True, text=True)
|
|
||||||
repo.mkdir()
|
|
||||||
git('init', '-q')
|
|
||||||
(repo / 'a.txt').write_text('a')
|
|
||||||
lock = repo / '.git' / 'index.lock'
|
|
||||||
lock.write_text('')
|
|
||||||
old = time.time() - 60
|
|
||||||
os.utime(lock, (old, old))
|
|
||||||
assert 'index.lock' in git('add', 'a.txt').stderr
|
|
||||||
|
|
||||||
assert gil.clear_stale_index_lock(repo) == gil.REMOVED
|
|
||||||
assert git('add', 'a.txt').returncode == 0
|
|
||||||
|
|
||||||
|
|
||||||
# -- Update Code ----------------------------------------------------------------
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def client():
|
|
||||||
app = Flask(__name__)
|
|
||||||
app.config['TESTING'] = True
|
|
||||||
app.register_blueprint(api_v3, url_prefix='/api/v3')
|
|
||||||
api_v3.plugin_store_manager = None
|
|
||||||
api_v3.config_manager = None
|
|
||||||
return app.test_client()
|
|
||||||
|
|
||||||
|
|
||||||
def _fake_git(calls, pull_rc=0, pull_err=''):
|
|
||||||
def run(args, **kwargs):
|
|
||||||
calls.append(list(args))
|
|
||||||
if args[:2] == ['git', 'rev-parse'] and args[-1] == 'HEAD':
|
|
||||||
return subprocess.CompletedProcess(args, 0, stdout='aaa111\n', stderr='')
|
|
||||||
if 'symbolic-full-name' in args or '@{u}' in args:
|
|
||||||
return subprocess.CompletedProcess(args, 0, stdout='origin/main\n', stderr='')
|
|
||||||
if args[:2] == ['git', 'pull']:
|
|
||||||
return subprocess.CompletedProcess(args, pull_rc, stdout='', stderr=pull_err)
|
|
||||||
return subprocess.CompletedProcess(args, 0, stdout='', stderr='')
|
|
||||||
return run
|
|
||||||
|
|
||||||
|
|
||||||
def _update(client):
|
|
||||||
return client.post('/api/v3/system/action', json={'action': 'git_pull'}).get_json()
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_code_clears_the_lock_before_running_git(client):
|
|
||||||
seen = []
|
|
||||||
|
|
||||||
def clear(project_dir):
|
|
||||||
seen.append(project_dir)
|
|
||||||
return gil.REMOVED
|
|
||||||
calls = []
|
|
||||||
with patch.object(gil, 'clear_stale_index_lock', clear), \
|
|
||||||
_patched(_fake_git(calls)):
|
|
||||||
data = _update(client)
|
|
||||||
assert seen == [str(mod.PROJECT_ROOT)]
|
|
||||||
assert data['status'] == 'success'
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_code_waits_for_a_git_that_is_still_running(client):
|
|
||||||
calls = []
|
|
||||||
with patch.object(gil, 'clear_stale_index_lock', lambda d: gil.IN_USE), \
|
|
||||||
_patched(_fake_git(calls)):
|
|
||||||
data = _update(client)
|
|
||||||
assert data['status'] == 'error'
|
|
||||||
assert 'Another git command is running' in data['message']
|
|
||||||
assert calls == [], "nothing may touch the checkout while another git owns it"
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_lock_it_could_not_clear_gets_the_manual_fix(client):
|
|
||||||
err = "fatal: Unable to create '/home/ledpi/LEDMatrix/.git/index.lock': File exists.\n"
|
|
||||||
calls = []
|
|
||||||
with patch.object(gil, 'clear_stale_index_lock', lambda d: gil.UNKNOWN), \
|
|
||||||
_patched(_fake_git(calls, pull_rc=128, pull_err=err)):
|
|
||||||
data = _update(client)
|
|
||||||
assert data['status'] == 'error'
|
|
||||||
assert data['message'].startswith('Update failed: fatal: Unable to create')
|
|
||||||
assert 'rm -f ' in data['message'] and 'index.lock' in data['message']
|
|
||||||
|
|
||||||
|
|
||||||
# -- run_git: a timed-out git must not leave a lock or an orphaned helper ------
|
|
||||||
|
|
||||||
def _lock_holder_script(tmp_path, lock):
|
|
||||||
"""A stand-in for a slow git: takes the lock, spawns a helper, then hangs."""
|
|
||||||
script = tmp_path / 'slow.sh'
|
|
||||||
script.write_text(f'#!/bin/sh\n: > "{lock}"\nsleep 60 &\necho $! > "{tmp_path}/helper.pid"\nwait\n')
|
|
||||||
script.chmod(0o755)
|
|
||||||
return script
|
|
||||||
|
|
||||||
|
|
||||||
def test_run_git_returns_a_completed_process(tmp_path):
|
|
||||||
result = gil.run_git([sys.executable, '-c', 'print("hi")'], cwd=str(tmp_path), timeout=30)
|
|
||||||
assert (result.returncode, result.stdout.strip()) == (0, 'hi')
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(not os.path.isdir('/proc'), reason='needs /proc')
|
|
||||||
def test_run_git_timeout_stops_the_group_and_clears_the_lock(tmp_path, monkeypatch):
|
|
||||||
(tmp_path / '.git').mkdir()
|
|
||||||
lock = tmp_path / '.git' / 'index.lock'
|
|
||||||
script = _lock_holder_script(tmp_path, lock)
|
|
||||||
monkeypatch.setattr(gil, 'MIN_STALE_AGE_SECONDS', 0)
|
|
||||||
monkeypatch.setattr(gil, 'TERM_GRACE_SECONDS', 1)
|
|
||||||
# The scan only sees processes named git, so the stand-in must not match;
|
|
||||||
# the lock is then provably unowned once the group is gone.
|
|
||||||
with pytest.raises(subprocess.TimeoutExpired):
|
|
||||||
gil.run_git([str(script)], cwd=str(tmp_path), timeout=1)
|
|
||||||
assert not lock.exists()
|
|
||||||
helper = int((tmp_path / 'helper.pid').read_text())
|
|
||||||
time.sleep(0.2)
|
|
||||||
with pytest.raises(ProcessLookupError):
|
|
||||||
os.kill(helper, 0)
|
|
||||||
|
|
||||||
|
|
||||||
def test_read_only_env_disables_optional_locks():
|
|
||||||
assert gil.read_only_env()['GIT_OPTIONAL_LOCKS'] == '0'
|
|
||||||
|
|
||||||
|
|
||||||
def test_run_git_waits_out_the_freshness_window_before_giving_up_on_the_lock(tmp_path, monkeypatch):
|
|
||||||
# The lock is created a moment before the timeout, so on age alone it is
|
|
||||||
# still "in use" once git is stopped; it must not be left to block the retry.
|
|
||||||
(tmp_path / '.git').mkdir()
|
|
||||||
lock = tmp_path / '.git' / 'index.lock'
|
|
||||||
script = tmp_path / 'slow.sh'
|
|
||||||
script.write_text(f'#!/bin/sh\n: > "{lock}"\nexec sleep 60\n')
|
|
||||||
script.chmod(0o755)
|
|
||||||
monkeypatch.setattr(gil, 'MIN_STALE_AGE_SECONDS', 2)
|
|
||||||
monkeypatch.setattr(gil, 'TERM_GRACE_SECONDS', 1)
|
|
||||||
with pytest.raises(subprocess.TimeoutExpired):
|
|
||||||
gil.run_git([str(script)], cwd=str(tmp_path), timeout=1)
|
|
||||||
assert not lock.exists()
|
|
||||||
|
|
||||||
|
|
||||||
# -- a rebase in the checkout ----------------------------------------------------
|
|
||||||
|
|
||||||
def _rebase_checkout(tmp_path, monkeypatch):
|
|
||||||
from web_interface.blueprints.api_v3 import system
|
|
||||||
(tmp_path / '.git' / 'rebase-merge').mkdir(parents=True)
|
|
||||||
monkeypatch.setattr(system, 'PROJECT_ROOT', tmp_path)
|
|
||||||
return tmp_path / '.git' / 'rebase-merge'
|
|
||||||
|
|
||||||
|
|
||||||
def _rebase_git(calls, on_branch, quit_rc=0, remove_dir=None):
|
|
||||||
base = _fake_git(calls)
|
|
||||||
|
|
||||||
def run(args, **kwargs):
|
|
||||||
if args[:2] == ['git', 'symbolic-ref']:
|
|
||||||
calls.append(list(args))
|
|
||||||
return subprocess.CompletedProcess(args, 0 if on_branch else 1, stdout='', stderr='')
|
|
||||||
if args[:3] == ['git', 'rebase', '--quit']:
|
|
||||||
calls.append(list(args))
|
|
||||||
if quit_rc == 0 and remove_dir is not None:
|
|
||||||
remove_dir.rmdir()
|
|
||||||
return subprocess.CompletedProcess(args, quit_rc, stdout='', stderr='cannot quit' if quit_rc else '')
|
|
||||||
return base(args, **kwargs)
|
|
||||||
return run
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_rebase_in_progress_is_left_alone(client, tmp_path, monkeypatch):
|
|
||||||
_rebase_checkout(tmp_path, monkeypatch)
|
|
||||||
calls = []
|
|
||||||
with _patched(_rebase_git(calls, on_branch=False)):
|
|
||||||
data = _update(client)
|
|
||||||
assert data['status'] == 'error' and 'rebase is in progress' in data['message']
|
|
||||||
assert not any(c[:2] in (['git', 'pull'], ['git', 'checkout']) or c[:3] == ['git', 'rebase', '--quit']
|
|
||||||
for c in calls), "a paused rebase holds the user's conflict work"
|
|
||||||
|
|
||||||
|
|
||||||
def test_an_abandoned_rebase_is_quit_before_the_pull(client, tmp_path, monkeypatch):
|
|
||||||
rebase = _rebase_checkout(tmp_path, monkeypatch)
|
|
||||||
calls = []
|
|
||||||
with _patched(_rebase_git(calls, on_branch=True, remove_dir=rebase)):
|
|
||||||
_update(client)
|
|
||||||
names = [c[1] for c in calls]
|
|
||||||
assert ['git', 'rebase', '--quit'] in calls
|
|
||||||
assert names.index('rebase') < names.index('pull')
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_rebase_that_cannot_be_cleared_stops_the_update(client, tmp_path, monkeypatch):
|
|
||||||
_rebase_checkout(tmp_path, monkeypatch)
|
|
||||||
calls = []
|
|
||||||
with _patched(_rebase_git(calls, on_branch=True, quit_rc=1)):
|
|
||||||
data = _update(client)
|
|
||||||
assert data['status'] == 'error' and 'could not be cleared' in data['message']
|
|
||||||
assert not any(c[:2] == ['git', 'pull'] for c in calls)
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_automatic_status_check_is_read_only_whatever_the_runner(tmp_path):
|
|
||||||
from web_interface import auto_update
|
|
||||||
seen = {}
|
|
||||||
|
|
||||||
def run(args, **kwargs):
|
|
||||||
seen.update(kwargs)
|
|
||||||
return subprocess.CompletedProcess(args, 0, stdout='', stderr='')
|
|
||||||
auto_update.local_changes(tmp_path, run=run)
|
|
||||||
assert seen['env']['GIT_OPTIONAL_LOCKS'] == '0'
|
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
Pure data, so every test here runs on every platform. What they pin:
|
Pure data, so every test here runs on every platform. What they pin:
|
||||||
|
|
||||||
* a request and a response survive encode -> decode -> parse unchanged, and
|
* a request and a response survive encode -> decode -> parse unchanged, and
|
||||||
the on-demand arguments carry exactly what the REST route sends;
|
the on-demand arguments carry exactly what the file mailbox carries;
|
||||||
* the envelope and the arguments refuse what the display could not act on
|
* 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;
|
(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
|
* framing never holds more than one message's worth of bytes, however the
|
||||||
@@ -186,8 +186,8 @@ class TestOnDemandArgs:
|
|||||||
|
|
||||||
|
|
||||||
class TestMailboxShape:
|
class TestMailboxShape:
|
||||||
"""Socket commands are handed to the display's on-demand handler, so they
|
"""Socket commands are handed to the mailbox's own handler, so they must
|
||||||
must look exactly like the request dict it takes."""
|
look exactly like what the web route writes to the mailbox."""
|
||||||
|
|
||||||
def test_start(self):
|
def test_start(self):
|
||||||
args = OnDemandStartArgs(plugin_id='clock', mode='clock_main', duration=60.0,
|
args = OnDemandStartArgs(plugin_id='clock', mode='clock_main', duration=60.0,
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
"""DisplayController's side of the control socket.
|
"""DisplayController's side of the control socket.
|
||||||
|
|
||||||
The server's handlers only queue; the render thread drains the queue
|
The server's handlers only queue; the render thread drains the queue where
|
||||||
(_poll_on_demand_requests) and hands each on-demand command to
|
it reads the file mailbox (_poll_on_demand_requests) and hands each command
|
||||||
_handle_on_demand_request, which plugins' own requests use too. These tests
|
to the mailbox's own handler (_handle_on_demand_request). These tests pin
|
||||||
pin that hook:
|
that hook:
|
||||||
|
|
||||||
* a socket command is applied with its request id, at once, and touches no
|
* a socket command is applied by the same code as a mailbox request, with
|
||||||
cache key (the file mailbox and the persisted processed id are gone);
|
its request id, and without waiting for the mailbox's 0.25 s read floor;
|
||||||
* a start sent twice with one request id is activated once;
|
* a request that arrives both ways (a client that timed out after the
|
||||||
|
command was queued, then wrote the mailbox) is activated once;
|
||||||
* a command that fails is contained, and the ones after it still run;
|
* a command that fails is contained, and the ones after it still run;
|
||||||
* cleanup closes the socket; a disabled socket changes nothing.
|
* cleanup closes the socket; a disabled socket changes nothing.
|
||||||
"""
|
"""
|
||||||
@@ -57,15 +58,28 @@ def controller(test_display_controller):
|
|||||||
c_ = test_display_controller
|
c_ = test_display_controller
|
||||||
c_.on_demand_active = False
|
c_.on_demand_active = False
|
||||||
c_.on_demand_request_id = None
|
c_.on_demand_request_id = None
|
||||||
c_.cache_manager.get = MagicMock(return_value=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.set = MagicMock()
|
c_.cache_manager.set = MagicMock()
|
||||||
c_.cache_manager.delete = MagicMock()
|
c_.cache_manager.delete = MagicMock(side_effect=fake_delete)
|
||||||
c_._activate_on_demand = MagicMock()
|
c_._activate_on_demand = MagicMock()
|
||||||
|
c_.mailbox = mailbox
|
||||||
return c_
|
return c_
|
||||||
|
|
||||||
|
|
||||||
class TestDrain:
|
class TestDrain:
|
||||||
def test_a_socket_start_is_activated_with_its_request_id(self, controller):
|
def test_a_socket_start_goes_through_the_mailbox_handler(self, controller):
|
||||||
controller._control_server = FakeServer(_start('sock-1', duration=30.0, pinned=True))
|
controller._control_server = FakeServer(_start('sock-1', duration=30.0, pinned=True))
|
||||||
controller._poll_on_demand_requests()
|
controller._poll_on_demand_requests()
|
||||||
controller._activate_on_demand.assert_called_once()
|
controller._activate_on_demand.assert_called_once()
|
||||||
@@ -75,28 +89,47 @@ class TestDrain:
|
|||||||
assert request['plugin_id'] == 'clock'
|
assert request['plugin_id'] == 'clock'
|
||||||
assert request['duration'] == 30.0 and request['pinned'] is True
|
assert request['duration'] == 30.0 and request['pinned'] is True
|
||||||
assert controller.on_demand_request_id == 'sock-1'
|
assert controller.on_demand_request_id == 'sock-1'
|
||||||
# No mailbox, and no persisted processed id.
|
# The same restart-replay guard as a mailbox request.
|
||||||
keys = {call.args[0] for m in (controller.cache_manager.get,
|
controller.cache_manager.set.assert_any_call(
|
||||||
controller.cache_manager.set,
|
'display_on_demand_processed_id', 'sock-1', ttl=3600)
|
||||||
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_land_at_once(self, controller):
|
def test_socket_commands_skip_the_mailbox_floor(self, controller):
|
||||||
server = FakeServer()
|
server = FakeServer()
|
||||||
controller._control_server = server
|
controller._control_server = server
|
||||||
controller._poll_on_demand_requests()
|
controller._poll_on_demand_requests() # reads the mailbox, sets the floor
|
||||||
|
reads = controller.cache_manager.get.call_count
|
||||||
server.commands.append(_start('quick'))
|
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._poll_on_demand_requests()
|
||||||
controller._activate_on_demand.assert_called_once()
|
controller._activate_on_demand.assert_called_once()
|
||||||
|
|
||||||
def test_a_start_sent_twice_is_activated_once(self, controller):
|
def test_the_mailbox_still_works_alongside(self, controller):
|
||||||
server = FakeServer(_start('dup'))
|
controller._control_server = FakeServer()
|
||||||
controller._control_server = server
|
controller.mailbox['value'] = {'request_id': 'mb', 'action': 'start', 'plugin_id': 'p'}
|
||||||
controller._poll_on_demand_requests()
|
controller._poll_on_demand_requests()
|
||||||
server.commands.append(_start('dup'))
|
assert controller._activate_on_demand.call_args.args[0]['request_id'] == 'mb'
|
||||||
controller._poll_on_demand_requests()
|
|
||||||
controller._activate_on_demand.assert_called_once()
|
|
||||||
|
|
||||||
def test_a_socket_stop_ends_on_demand(self, controller):
|
def test_a_socket_stop_ends_on_demand(self, controller):
|
||||||
controller.on_demand_active = True
|
controller.on_demand_active = True
|
||||||
@@ -126,11 +159,10 @@ class TestDrain:
|
|||||||
controller._poll_on_demand_requests()
|
controller._poll_on_demand_requests()
|
||||||
assert calls == ['bad', 'good']
|
assert calls == ['bad', 'good']
|
||||||
|
|
||||||
def test_no_server_means_nothing_to_apply(self, controller):
|
def test_no_server_means_mailbox_only(self, controller):
|
||||||
controller._control_server = None
|
controller._control_server = None
|
||||||
controller._poll_on_demand_requests()
|
controller._poll_on_demand_requests()
|
||||||
controller._activate_on_demand.assert_not_called()
|
controller._activate_on_demand.assert_not_called()
|
||||||
controller.cache_manager.get.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
class TestPendingChangesFloor:
|
class TestPendingChangesFloor:
|
||||||
@@ -162,14 +194,6 @@ class TestLifecycle:
|
|||||||
assert status['on_demand']['plugin_id'] == 'clock'
|
assert status['on_demand']['plugin_id'] == 'clock'
|
||||||
c.encode_message(status) # it has to fit on the wire
|
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):
|
def test_cleanup_closes_the_socket(self, controller):
|
||||||
server = FakeServer()
|
server = FakeServer()
|
||||||
controller._control_server = server
|
controller._control_server = server
|
||||||
|
|||||||
+209
-144
@@ -1,13 +1,14 @@
|
|||||||
"""Stages 4 and 5 of the control socket: the socket is the only way in.
|
"""Stage 4 of the control socket: the file mailboxes are only a fallback.
|
||||||
|
|
||||||
* The client knows whether the display had the request (``ControlError.sent``)
|
* The client knows whether the display had the request (``ControlError.sent``)
|
||||||
and ``display_not_listening`` says when no display was there to take it
|
and ``should_fall_back`` allows a mailbox write only when it did not, or
|
||||||
(stopped or still starting), the one case a later retry can fix.
|
when the display is too old to know the command (the upgrade case).
|
||||||
* ``errors.clear`` is answered on the connection thread by a handler the
|
* ``errors.clear`` is answered on the connection thread by a handler the
|
||||||
display registers; a display without one answers like an older display.
|
display registers; a display without one answers like an older display.
|
||||||
* Stage 5: the display no longer reads the ``display_on_demand_request``
|
* The display looks at the on-demand mailbox once a second while the socket
|
||||||
mailbox at all, and the cache refuses writes to the retired mailbox keys,
|
is up (0.25 s without it), reads it only when its file changed, never
|
||||||
warning once per writer.
|
touches it for a socket command, and logs who still writes it.
|
||||||
|
* ``CacheManager.file_signature`` / ``MailboxWatch`` make a look one stat().
|
||||||
|
|
||||||
The web routes are covered in test_api_v3_on_demand_socket.py and
|
The web routes are covered in test_api_v3_on_demand_socket.py and
|
||||||
test_error_snapshot_cross_process.py.
|
test_error_snapshot_cross_process.py.
|
||||||
@@ -22,8 +23,7 @@ from unittest.mock import MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src import cache_manager as cache_module
|
from src.cache_manager import CacheManager, MailboxWatch
|
||||||
from src.cache_manager import CacheManager
|
|
||||||
from src.ipc import client
|
from src.ipc import client
|
||||||
from src.ipc import contract as c
|
from src.ipc import contract as c
|
||||||
from src.ipc.contract import Command, ErrorsClearArgs, OnDemandStartArgs, ProtocolError
|
from src.ipc.contract import Command, ErrorsClearArgs, OnDemandStartArgs, ProtocolError
|
||||||
@@ -81,41 +81,38 @@ class TestSent:
|
|||||||
def test_an_answer_is_returned(self):
|
def test_an_answer_is_returned(self):
|
||||||
assert _call(FakeSock([_reply('rid-1', ok=True, result={'pong': True})])) == {'pong': True}
|
assert _call(FakeSock([_reply('rid-1', ok=True, result={'pong': True})])) == {'pong': True}
|
||||||
|
|
||||||
@pytest.mark.parametrize('reason,listening', [('no_socket', False), ('refused', False),
|
@pytest.mark.parametrize('reason', ['no_socket', 'refused', 'timeout', 'busy'])
|
||||||
('timeout', True), ('busy', True)])
|
def test_a_failed_connect_was_not_sent(self, reason):
|
||||||
def test_a_failed_connect_was_not_sent(self, reason, listening):
|
|
||||||
e = _error(connect_error=client.ControlError(reason))
|
e = _error(connect_error=client.ControlError(reason))
|
||||||
assert e.reason == reason and e.sent is False
|
assert e.reason == reason and e.sent is False
|
||||||
# Only "nothing there" is worth waiting for: a timeout or a full
|
assert client.should_fall_back(e)
|
||||||
# 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):
|
def test_a_send_that_timed_out_was_not_sent(self):
|
||||||
e = _error(sock=FakeSock(send_error=socket.timeout()))
|
e = _error(sock=FakeSock(send_error=socket.timeout()))
|
||||||
assert e.reason == 'timeout' and e.sent is False
|
assert e.reason == 'timeout' and e.sent is False
|
||||||
assert not client.display_not_listening(e)
|
assert client.should_fall_back(e)
|
||||||
|
|
||||||
def test_silence_after_the_request_was_sent(self):
|
def test_silence_after_the_request_was_sent(self):
|
||||||
e = _error(sock=FakeSock(recv_error=socket.timeout()))
|
e = _error(sock=FakeSock(recv_error=socket.timeout()))
|
||||||
assert e.reason == 'timeout' and e.sent is True
|
assert e.reason == 'timeout' and e.sent is True
|
||||||
assert not client.display_not_listening(e)
|
assert not client.should_fall_back(e)
|
||||||
|
|
||||||
def test_a_hang_up_after_the_request_was_sent(self):
|
def test_a_hang_up_after_the_request_was_sent(self):
|
||||||
e = _error(sock=FakeSock([]))
|
e = _error(sock=FakeSock([]))
|
||||||
assert e.reason == 'closed' and e.sent is True
|
assert e.reason == 'closed' and e.sent is True
|
||||||
assert not client.display_not_listening(e)
|
assert not client.should_fall_back(e)
|
||||||
|
|
||||||
def test_a_garbled_reply(self):
|
def test_a_garbled_reply(self):
|
||||||
e = _error(sock=FakeSock([b'not json\n']))
|
e = _error(sock=FakeSock([b'not json\n']))
|
||||||
assert e.reason == 'bad_response' and e.sent is True
|
assert e.reason == 'bad_response' and e.sent is True
|
||||||
assert not client.display_not_listening(e)
|
assert not client.should_fall_back(e)
|
||||||
|
|
||||||
@pytest.mark.parametrize('code', ['busy', 'invalid_args', 'internal', 'pending', 'failed'])
|
@pytest.mark.parametrize('code', ['busy', 'invalid_args', 'internal', 'pending', 'failed'])
|
||||||
def test_a_display_error_with_an_id_was_sent(self, code):
|
def test_a_display_error_with_an_id_was_sent(self, code):
|
||||||
sock = FakeSock([_reply('rid-1', ok=False, error={'code': code, 'message': 'x'})])
|
sock = FakeSock([_reply('rid-1', ok=False, error={'code': code, 'message': 'x'})])
|
||||||
e = _error(sock=sock)
|
e = _error(sock=sock)
|
||||||
assert e.reason == code and e.sent is True
|
assert e.reason == code and e.sent is True
|
||||||
assert not client.display_not_listening(e)
|
assert not client.should_fall_back(e)
|
||||||
|
|
||||||
@pytest.mark.parametrize('code', ['forbidden', 'busy'])
|
@pytest.mark.parametrize('code', ['forbidden', 'busy'])
|
||||||
def test_a_refusal_at_the_door_was_not_sent(self, code):
|
def test_a_refusal_at_the_door_was_not_sent(self, code):
|
||||||
@@ -125,33 +122,23 @@ class TestSent:
|
|||||||
'error': {'code': code, 'message': 'x'}}) + '\n').encode()])
|
'error': {'code': code, 'message': 'x'}}) + '\n').encode()])
|
||||||
e = _error(sock=sock)
|
e = _error(sock=sock)
|
||||||
assert e.reason == code and e.sent is False
|
assert e.reason == code and e.sent is False
|
||||||
assert not client.display_not_listening(e)
|
assert client.should_fall_back(e)
|
||||||
|
|
||||||
@pytest.mark.parametrize('code', ['unknown_command', 'unsupported_version'])
|
@pytest.mark.parametrize('code', ['unknown_command', 'unsupported_version'])
|
||||||
def test_an_older_display_is_listening(self, code):
|
def test_an_older_display_is_fallen_back_from(self, code):
|
||||||
sock = FakeSock([_reply('rid-1', ok=False, error={'code': code, 'message': 'x'})])
|
sock = FakeSock([_reply('rid-1', ok=False, error={'code': code, 'message': 'x'})])
|
||||||
e = _error(sock=sock)
|
e = _error(sock=sock)
|
||||||
assert e.sent is True
|
assert e.sent is True
|
||||||
assert not client.display_not_listening(e)
|
assert client.should_fall_back(e)
|
||||||
|
|
||||||
def test_a_request_refused_locally_never_left(self):
|
def test_a_request_refused_locally_never_left(self):
|
||||||
with pytest.raises(client.ControlError) as e:
|
with pytest.raises(client.ControlError) as e:
|
||||||
client.request(Command.ERRORS_CLEAR, {'cutoff': 'soon'}, paths=['/x.sock'])
|
client.request(Command.ERRORS_CLEAR, {'cutoff': 'soon'}, paths=['/x.sock'])
|
||||||
assert e.value.reason == 'invalid_request' and e.value.sent is False
|
assert e.value.reason == 'invalid_request' and e.value.sent is False
|
||||||
assert not client.display_not_listening(e.value)
|
assert client.should_fall_back(e.value)
|
||||||
|
|
||||||
@pytest.mark.parametrize('reason', ['disabled', 'unsupported'])
|
def test_a_client_bug_falls_back(self):
|
||||||
def test_a_client_without_the_socket_is_not_waiting_for_a_display(self, reason):
|
assert client.should_fall_back(RuntimeError('boom'))
|
||||||
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 ------------------------------------------------------
|
# -- errors.clear on the server ------------------------------------------------------
|
||||||
@@ -217,28 +204,37 @@ class TestErrorsClearOnTheServer:
|
|||||||
assert Command.ERRORS_CLEAR in result['commands']
|
assert Command.ERRORS_CLEAR in result['commands']
|
||||||
|
|
||||||
|
|
||||||
# -- stage 5: the display reads no mailbox --------------------------------------------
|
# -- the display's mailbox poll ------------------------------------------------------
|
||||||
|
|
||||||
class CountingCache:
|
class SignedCache:
|
||||||
"""The slice of CacheManager the on-demand path uses, counting every call."""
|
"""The slice of CacheManager the poll uses, counting what it costs."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.data = {}
|
self.data = {}
|
||||||
self.calls = []
|
self.writes = 0
|
||||||
|
self.version = {}
|
||||||
|
self.reads = []
|
||||||
|
self.stats = 0
|
||||||
|
self.deletes = []
|
||||||
|
self.sets = []
|
||||||
|
|
||||||
def __getattr__(self, name):
|
def file_signature(self, key):
|
||||||
# Any other method (file_signature, delete, ...) is recorded too.
|
self.stats += 1
|
||||||
def call(*a, **kw):
|
return (self.version[key], 0, 0) if key in self.data else None
|
||||||
self.calls.append((name, a[0] if a else None))
|
|
||||||
return call
|
|
||||||
|
|
||||||
def get(self, key, *a, **kw):
|
def get(self, key, *a, **kw):
|
||||||
self.calls.append(('get', key))
|
self.reads.append(key)
|
||||||
return self.data.get(key)
|
return self.data.get(key)
|
||||||
|
|
||||||
def set(self, key, value, *a, **kw):
|
def set(self, key, value, *a, **kw):
|
||||||
self.calls.append(('set', key))
|
self.sets.append(key)
|
||||||
self.data[key] = value
|
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:
|
class FakeServer:
|
||||||
@@ -254,140 +250,209 @@ class FakeServer:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
class Clock:
|
||||||
|
def __init__(self):
|
||||||
|
self.t = 1000.0
|
||||||
|
|
||||||
|
def __call__(self):
|
||||||
|
return self.t
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def controller(test_display_controller):
|
def controller(test_display_controller, monkeypatch):
|
||||||
dc = test_display_controller
|
dc = test_display_controller
|
||||||
dc.cache_manager = CountingCache()
|
dc.cache_manager = SignedCache()
|
||||||
dc._activate_on_demand = MagicMock()
|
dc._activate_on_demand = MagicMock()
|
||||||
dc.on_demand_active = False
|
dc.on_demand_active = False
|
||||||
dc.on_demand_request_id = None
|
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
|
return dc
|
||||||
|
|
||||||
|
|
||||||
class TestNoMailbox:
|
def _post(dc, rid, action='start', **fields):
|
||||||
@pytest.mark.parametrize('with_socket', [False, True])
|
dc.cache_manager.set(MAILBOX, dict({'request_id': rid, 'action': action}, **fields))
|
||||||
def test_polling_touches_no_cache_key(self, controller, with_socket):
|
|
||||||
controller._control_server = FakeServer() if with_socket else None
|
|
||||||
controller.cache_manager.data[MAILBOX] = {'request_id': 'left', 'action': 'start',
|
|
||||||
'plugin_id': 'clock'}
|
|
||||||
for _ in range(200):
|
|
||||||
controller._poll_on_demand_requests()
|
|
||||||
assert controller.cache_manager.calls == []
|
|
||||||
controller._activate_on_demand.assert_not_called()
|
|
||||||
|
|
||||||
def test_socket_commands_land_at_once(self, controller):
|
|
||||||
|
def _poll_for(dc, seconds, step=1 / 16): # exact in binary: no drift past a floor
|
||||||
|
end = dc.clock.t + seconds
|
||||||
|
while dc.clock.t < end:
|
||||||
|
dc._poll_on_demand_requests()
|
||||||
|
dc.clock.t += step
|
||||||
|
|
||||||
|
|
||||||
|
class TestMailboxCadence:
|
||||||
|
def test_without_a_socket_it_is_looked_at_every_quarter_second(self, controller):
|
||||||
|
controller._control_server = None
|
||||||
|
_poll_for(controller, 10.0)
|
||||||
|
assert 38 <= controller.cache_manager.stats <= 42
|
||||||
|
|
||||||
|
def test_with_a_socket_it_is_looked_at_once_a_second(self, controller):
|
||||||
|
controller._control_server = FakeServer()
|
||||||
|
_poll_for(controller, 10.0)
|
||||||
|
assert 9 <= controller.cache_manager.stats <= 11
|
||||||
|
|
||||||
|
def test_a_look_that_finds_nothing_reads_nothing(self, controller):
|
||||||
|
controller._control_server = FakeServer()
|
||||||
|
_poll_for(controller, 10.0)
|
||||||
|
assert controller.cache_manager.reads == []
|
||||||
|
|
||||||
|
def test_an_unchanged_mailbox_is_not_read_again(self, controller):
|
||||||
|
# An already-processed start the delete could not remove, say.
|
||||||
|
controller._control_server = FakeServer()
|
||||||
|
controller.cache_manager.delete = MagicMock() # the file stays
|
||||||
|
_post(controller, 'once', plugin_id='clock')
|
||||||
|
_poll_for(controller, 10.0)
|
||||||
|
assert controller.cache_manager.reads.count(MAILBOX) <= 2 # the read + the re-check
|
||||||
|
controller._activate_on_demand.assert_called_once()
|
||||||
|
|
||||||
|
def test_a_mailbox_request_lands_within_a_second_with_the_socket_up(self, controller):
|
||||||
|
# The upgrade case the other way round: a new display, and a web
|
||||||
|
# interface (or a plugin) that still writes the mailbox.
|
||||||
|
controller._control_server = FakeServer()
|
||||||
|
controller._poll_on_demand_requests()
|
||||||
|
controller.clock.t += 0.1
|
||||||
|
_post(controller, 'old-web', plugin_id='clock')
|
||||||
|
posted = controller.clock.t
|
||||||
|
while not controller._activate_on_demand.called:
|
||||||
|
controller._poll_on_demand_requests()
|
||||||
|
controller.clock.t += 0.05
|
||||||
|
assert controller.clock.t - posted < 1.5
|
||||||
|
assert controller.clock.t - posted <= controller.MAILBOX_POLL_INTERVAL_WITH_SOCKET + 0.06
|
||||||
|
assert MAILBOX in controller.cache_manager.deletes # consumed
|
||||||
|
|
||||||
|
def test_socket_commands_still_land_at_once(self, controller):
|
||||||
server = controller._control_server = FakeServer()
|
server = controller._control_server = FakeServer()
|
||||||
|
controller._poll_on_demand_requests()
|
||||||
server.commands.append(QueuedCommand('sock', Command.ON_DEMAND_START,
|
server.commands.append(QueuedCommand('sock', Command.ON_DEMAND_START,
|
||||||
OnDemandStartArgs(plugin_id='clock'), time.time()))
|
OnDemandStartArgs(plugin_id='clock'), time.time()))
|
||||||
|
controller._poll_on_demand_requests() # inside the mailbox interval
|
||||||
|
controller._activate_on_demand.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
class TestSocketCommandsLeaveTheMailboxAlone:
|
||||||
|
def test_a_socket_start_reads_and_deletes_no_mailbox(self, controller):
|
||||||
|
server = controller._control_server = FakeServer()
|
||||||
|
controller._poll_on_demand_requests()
|
||||||
|
before = list(controller.cache_manager.reads)
|
||||||
|
server.commands.append(QueuedCommand('s1', Command.ON_DEMAND_START,
|
||||||
|
OnDemandStartArgs(plugin_id='clock'), time.time()))
|
||||||
controller._poll_on_demand_requests()
|
controller._poll_on_demand_requests()
|
||||||
controller._activate_on_demand.assert_called_once()
|
controller._activate_on_demand.assert_called_once()
|
||||||
|
assert MAILBOX not in controller.cache_manager.reads[len(before):]
|
||||||
|
assert controller.cache_manager.deletes == []
|
||||||
|
|
||||||
def test_a_start_is_applied_once_and_writes_no_processed_id(self, controller):
|
def test_a_socket_stop_reads_and_deletes_no_mailbox(self, controller):
|
||||||
server = controller._control_server = FakeServer()
|
|
||||||
for _ in range(2):
|
|
||||||
server.commands.append(QueuedCommand('same', Command.ON_DEMAND_START,
|
|
||||||
OnDemandStartArgs(plugin_id='clock'), time.time()))
|
|
||||||
controller._poll_on_demand_requests()
|
|
||||||
controller._activate_on_demand.assert_called_once()
|
|
||||||
assert controller.cache_manager.calls == []
|
|
||||||
|
|
||||||
def test_a_socket_stop_touches_no_cache_key(self, controller):
|
|
||||||
from src.ipc.contract import OnDemandStopArgs
|
from src.ipc.contract import OnDemandStopArgs
|
||||||
controller.on_demand_active = True
|
controller.on_demand_active = True
|
||||||
controller._clear_on_demand = MagicMock()
|
controller._clear_on_demand = MagicMock()
|
||||||
server = controller._control_server = FakeServer()
|
server = controller._control_server = FakeServer()
|
||||||
server.commands.append(QueuedCommand('s2', Command.ON_DEMAND_STOP,
|
server.commands.append(QueuedCommand('s2', Command.ON_DEMAND_STOP,
|
||||||
OnDemandStopArgs(), time.time()))
|
OnDemandStopArgs(), time.time()))
|
||||||
|
controller.clock.t += 5
|
||||||
controller._drain_control_commands()
|
controller._drain_control_commands()
|
||||||
controller._clear_on_demand.assert_called_once()
|
controller._clear_on_demand.assert_called_once()
|
||||||
assert controller.cache_manager.calls == []
|
assert MAILBOX not in controller.cache_manager.reads
|
||||||
|
assert controller.cache_manager.deletes == []
|
||||||
|
|
||||||
def test_the_mailbox_helpers_are_gone(self):
|
def test_a_mailbox_copy_of_a_socket_command_is_dropped(self, controller):
|
||||||
from src import display_controller as dcm
|
# An older web interface timed out after the display queued the
|
||||||
from src import error_aggregator as ea
|
# command, then wrote the mailbox too.
|
||||||
for name in ('ON_DEMAND_MAILBOX_KEY', 'MailboxWatch'):
|
server = controller._control_server = FakeServer()
|
||||||
assert not hasattr(dcm, name)
|
server.commands.append(QueuedCommand('both', Command.ON_DEMAND_START,
|
||||||
assert not hasattr(ea, 'ERROR_CLEAR_REQUEST_KEY')
|
OnDemandStartArgs(plugin_id='clock'), time.time()))
|
||||||
assert not hasattr(cache_module, 'MailboxWatch')
|
controller._poll_on_demand_requests()
|
||||||
assert not hasattr(CacheManager, 'file_signature')
|
_post(controller, 'both', plugin_id='clock')
|
||||||
assert not hasattr(client, 'should_fall_back')
|
_poll_for(controller, 2.0)
|
||||||
for name in ('_consume_on_demand_request', '_note_mailbox_request',
|
controller._activate_on_demand.assert_called_once()
|
||||||
'_mailbox_poll_interval', 'MAILBOX_POLL_INTERVAL_WITH_SOCKET'):
|
assert MAILBOX not in controller.cache_manager.data
|
||||||
assert not hasattr(dcm.DisplayController, name)
|
|
||||||
|
|
||||||
|
|
||||||
# -- stage 5: writes to the retired keys are refused, with one warning per writer -----
|
class TestDeprecationLog:
|
||||||
|
def test_each_mailbox_writer_is_logged_once(self, controller, caplog):
|
||||||
|
controller._control_server = FakeServer()
|
||||||
|
caplog.set_level(logging.INFO, logger='src.display_controller')
|
||||||
|
for i, plugin in enumerate(['on-air', 'on-air', 'pomodoro-timer']):
|
||||||
|
_post(controller, f'r{i}', plugin_id=plugin)
|
||||||
|
_poll_for(controller, 1.2)
|
||||||
|
lines = [r.getMessage() for r in caplog.records if 'file mailbox' in r.getMessage()]
|
||||||
|
assert len(lines) == 2
|
||||||
|
assert 'on-air' in lines[0] and 'pomodoro-timer' in lines[1]
|
||||||
|
|
||||||
|
def test_nothing_is_logged_without_a_socket(self, controller, caplog):
|
||||||
|
controller._control_server = None
|
||||||
|
caplog.set_level(logging.INFO, logger='src.display_controller')
|
||||||
|
_post(controller, 'r', plugin_id='on-air')
|
||||||
|
_poll_for(controller, 1.0)
|
||||||
|
controller._activate_on_demand.assert_called_once()
|
||||||
|
assert not [r for r in caplog.records if 'file mailbox' in r.getMessage()]
|
||||||
|
|
||||||
|
|
||||||
|
# -- file_signature and MailboxWatch -------------------------------------------------
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def real_cache(tmp_path, monkeypatch):
|
def real_cache(tmp_path, monkeypatch):
|
||||||
monkeypatch.setattr(CacheManager, '_get_writable_cache_dir', lambda self: str(tmp_path))
|
monkeypatch.setattr(CacheManager, '_get_writable_cache_dir', lambda self: str(tmp_path))
|
||||||
monkeypatch.setattr(cache_module, '_retired_writers_warned', set())
|
|
||||||
cache = CacheManager()
|
cache = CacheManager()
|
||||||
yield cache
|
yield cache
|
||||||
cache.stop_cleanup_thread()
|
cache.stop_cleanup_thread()
|
||||||
|
|
||||||
|
|
||||||
class OldPlugin:
|
class TestFileSignature:
|
||||||
"""What an old plugin looks like on the stack: BasePlugin gives every
|
def test_absent_key(self, real_cache):
|
||||||
plugin ``plugin_id`` and ``cache_manager``."""
|
assert real_cache.file_signature('nothing') is None
|
||||||
|
|
||||||
def __init__(self, plugin_id, cache):
|
def test_every_write_is_a_new_signature(self, real_cache):
|
||||||
self.plugin_id = plugin_id
|
seen = set()
|
||||||
self.cache_manager = cache
|
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 trigger(self, target=None):
|
def test_gone_after_a_delete(self, real_cache):
|
||||||
self.cache_manager.set(MAILBOX, {'request_id': 'r', 'action': 'start',
|
real_cache.set(MAILBOX, {'a': 1})
|
||||||
'plugin_id': target or self.plugin_id})
|
real_cache.delete(MAILBOX)
|
||||||
|
assert real_cache.file_signature(MAILBOX) is None
|
||||||
|
|
||||||
|
|
||||||
def _warnings(caplog):
|
class TestMailboxWatch:
|
||||||
return [r.getMessage() for r in caplog.records
|
def test_reads_once_per_write(self, real_cache):
|
||||||
if r.levelno == logging.WARNING and 'retired' in r.getMessage()]
|
watch = MailboxWatch(MAILBOX)
|
||||||
|
assert watch.changed(real_cache) is False # no file
|
||||||
|
real_cache.set(MAILBOX, {'request_id': 'a'})
|
||||||
|
assert watch.changed(real_cache) is True
|
||||||
|
assert watch.changed(real_cache) is False
|
||||||
|
real_cache.set(MAILBOX, {'request_id': 'b'})
|
||||||
|
assert watch.changed(real_cache) is True
|
||||||
|
|
||||||
|
def test_forget_reads_again(self, real_cache):
|
||||||
|
watch = MailboxWatch(MAILBOX)
|
||||||
|
real_cache.set(MAILBOX, {'request_id': 'a'})
|
||||||
|
assert watch.changed(real_cache) is True
|
||||||
|
watch.forget()
|
||||||
|
assert watch.changed(real_cache) is True
|
||||||
|
|
||||||
class TestRetiredKeys:
|
def test_a_rewrite_after_a_delete_is_seen(self, real_cache):
|
||||||
@pytest.mark.parametrize('key', sorted(cache_module.RETIRED_MAILBOX_KEYS))
|
watch = MailboxWatch(MAILBOX)
|
||||||
def test_a_write_stores_nothing(self, real_cache, key):
|
real_cache.set(MAILBOX, {'request_id': 'a'})
|
||||||
real_cache.set(key, {'request_id': 'r'})
|
assert watch.changed(real_cache)
|
||||||
real_cache.save_cache(key, {'request_id': 'r2'})
|
real_cache.delete(MAILBOX)
|
||||||
assert real_cache.get(key, max_age=None, memory_ttl=0) is None
|
assert watch.changed(real_cache) is False
|
||||||
assert not [f for f in os.listdir(real_cache.cache_dir) if key in f]
|
real_cache.set(MAILBOX, {'request_id': 'a'})
|
||||||
|
assert watch.changed(real_cache) is True
|
||||||
|
|
||||||
def test_other_keys_are_unaffected(self, real_cache):
|
def test_a_cache_that_cannot_tell_is_read_every_time(self):
|
||||||
real_cache.set('display_on_demand_state', {'active': True})
|
watch = MailboxWatch(MAILBOX)
|
||||||
assert real_cache.get('display_on_demand_state', max_age=None,
|
assert watch.changed(MagicMock()) is True
|
||||||
memory_ttl=0) == {'active': True}
|
assert watch.changed(MagicMock()) is True
|
||||||
|
assert watch.changed(object()) is 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 ---------------------------------------------------
|
# -- end to end over a real socket ---------------------------------------------------
|
||||||
@@ -414,24 +479,24 @@ class TestOverTheSocket:
|
|||||||
finally:
|
finally:
|
||||||
server.close()
|
server.close()
|
||||||
|
|
||||||
def test_an_older_display_is_listening(self, sock_path):
|
def test_an_older_display_is_an_upgrade_fallback(self, sock_path):
|
||||||
server = ControlServer(sock_path) # no errors.clear handler
|
server = ControlServer(sock_path) # no errors.clear handler
|
||||||
assert server.start()
|
assert server.start()
|
||||||
try:
|
try:
|
||||||
with pytest.raises(client.ControlError) as e:
|
with pytest.raises(client.ControlError) as e:
|
||||||
client.errors_clear('clr-2', 1.0, paths=[sock_path])
|
client.errors_clear('clr-2', 1.0, paths=[sock_path])
|
||||||
assert e.value.reason == 'unknown_command' and e.value.sent is True
|
assert e.value.reason == 'unknown_command' and e.value.sent is True
|
||||||
assert not client.display_not_listening(e.value)
|
assert client.should_fall_back(e.value)
|
||||||
finally:
|
finally:
|
||||||
server.close()
|
server.close()
|
||||||
|
|
||||||
def test_no_display_is_not_listening(self, sock_path):
|
def test_no_display_is_a_fallback(self, sock_path):
|
||||||
with pytest.raises(client.ControlError) as e:
|
with pytest.raises(client.ControlError) as e:
|
||||||
client.errors_clear('clr-3', 1.0, paths=[sock_path])
|
client.errors_clear('clr-3', 1.0, paths=[sock_path])
|
||||||
assert e.value.reason == 'no_socket' and e.value.sent is False
|
assert e.value.reason == 'no_socket' and e.value.sent is False
|
||||||
assert client.display_not_listening(e.value)
|
assert client.should_fall_back(e.value)
|
||||||
|
|
||||||
def test_a_full_queue_is_listening(self, sock_path):
|
def test_a_full_queue_is_not_a_fallback(self, sock_path):
|
||||||
server = ControlServer(sock_path, queue_size=1)
|
server = ControlServer(sock_path, queue_size=1)
|
||||||
assert server.start()
|
assert server.start()
|
||||||
try:
|
try:
|
||||||
@@ -439,6 +504,6 @@ class TestOverTheSocket:
|
|||||||
with pytest.raises(client.ControlError) as e:
|
with pytest.raises(client.ControlError) as e:
|
||||||
client.on_demand_start('q2', 'clock', None, paths=[sock_path])
|
client.on_demand_start('q2', 'clock', None, paths=[sock_path])
|
||||||
assert e.value.reason == 'busy' and e.value.sent is True
|
assert e.value.reason == 'busy' and e.value.sent is True
|
||||||
assert not client.display_not_listening(e.value)
|
assert not client.should_fall_back(e.value)
|
||||||
finally:
|
finally:
|
||||||
server.close()
|
server.close()
|
||||||
|
|||||||
@@ -327,27 +327,3 @@ class TestCleartextIsCalledOut:
|
|||||||
def test_tls_on_does_not_warn(self, bridge_module):
|
def test_tls_on_does_not_warn(self, bridge_module):
|
||||||
assert bridge_module.warn_if_cleartext(
|
assert bridge_module.warn_if_cleartext(
|
||||||
{"mqtt_tls": True, "mqtt_password": "hunter2"}) is False
|
{"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")
|
|
||||||
|
|||||||
@@ -346,8 +346,13 @@ class TestResumingAfterTheSession:
|
|||||||
|
|
||||||
class TestStopClearsAnError:
|
class TestStopClearsAnError:
|
||||||
def _post_stop(self, c):
|
def _post_stop(self, c):
|
||||||
c._handle_on_demand_request({'request_id': 'S1', 'action': 'stop',
|
stop = {'request_id': 'S1', 'action': 'stop'}
|
||||||
'source': 'socket'})
|
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()
|
||||||
|
|
||||||
def test_a_stop_after_a_failed_request_clears_the_error(self, controller):
|
def test_a_stop_after_a_failed_request_clears_the_error(self, controller):
|
||||||
_start(controller, plugin_id='uninstalled')
|
_start(controller, plugin_id='uninstalled')
|
||||||
|
|||||||
@@ -1,272 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"""The on-demand request mailbox: how often it is read, and how it is consumed.
|
||||||
|
|
||||||
|
The mailbox is a cache key the web process writes and the display process
|
||||||
|
reads. Two properties matter and neither is obvious from the call site:
|
||||||
|
|
||||||
|
* it is polled after every rendered frame, so an uncached read here is a
|
||||||
|
disk read at frame rate;
|
||||||
|
* consuming it must not throw away a request that arrived while the previous
|
||||||
|
one was being processed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
class TestPollingIsBounded:
|
||||||
|
"""_poll_on_demand_requests runs ~125x/second on a scrolling mode.
|
||||||
|
|
||||||
|
The read is deliberately uncached (memory_ttl=0) because a cached one
|
||||||
|
pinned the first request for an hour. That makes the call a real disk read,
|
||||||
|
so it needs a floor -- without one it was ~125 reads per second to find
|
||||||
|
nothing at all.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_first_call_always_reads(self, test_display_controller):
|
||||||
|
c = test_display_controller
|
||||||
|
c.cache_manager.get = MagicMock(return_value=None)
|
||||||
|
c._poll_on_demand_requests()
|
||||||
|
assert c.cache_manager.get.call_count == 1
|
||||||
|
|
||||||
|
def test_immediate_second_call_does_not_read(self, test_display_controller):
|
||||||
|
c = test_display_controller
|
||||||
|
c.cache_manager.get = MagicMock(return_value=None)
|
||||||
|
c._poll_on_demand_requests()
|
||||||
|
for _ in range(50):
|
||||||
|
c._poll_on_demand_requests()
|
||||||
|
assert c.cache_manager.get.call_count == 1, "polling was not bounded"
|
||||||
|
|
||||||
|
def test_reads_again_once_the_interval_has_passed(self, test_display_controller, monkeypatch):
|
||||||
|
c = test_display_controller
|
||||||
|
c.cache_manager.get = MagicMock(return_value=None)
|
||||||
|
clock = {"t": 1000.0}
|
||||||
|
monkeypatch.setattr("src.display_controller.time.monotonic", lambda: clock["t"])
|
||||||
|
|
||||||
|
c._poll_on_demand_requests()
|
||||||
|
clock["t"] += c.ON_DEMAND_POLL_INTERVAL / 2
|
||||||
|
c._poll_on_demand_requests()
|
||||||
|
assert c.cache_manager.get.call_count == 1, "read before the interval elapsed"
|
||||||
|
|
||||||
|
clock["t"] += c.ON_DEMAND_POLL_INTERVAL
|
||||||
|
c._poll_on_demand_requests()
|
||||||
|
assert c.cache_manager.get.call_count == 2
|
||||||
|
|
||||||
|
def test_the_interval_is_short_enough_to_feel_instant(self, test_display_controller):
|
||||||
|
# A person clicking in the web UI must not notice the floor.
|
||||||
|
assert test_display_controller.ON_DEMAND_POLL_INTERVAL <= 0.5
|
||||||
|
|
||||||
|
|
||||||
|
class TestMailboxIsConsumedByIdentity:
|
||||||
|
"""Deleting whatever is in the mailbox loses a request that raced in."""
|
||||||
|
|
||||||
|
def _arrange(self, controller, first, later):
|
||||||
|
"""Mailbox returns `first`, then `later` on the pre-delete re-read."""
|
||||||
|
controller.on_demand_active = False
|
||||||
|
controller.on_demand_request_id = None
|
||||||
|
controller._last_on_demand_poll = None
|
||||||
|
reads = iter([first, later])
|
||||||
|
|
||||||
|
def fake_get(key, *a, **kw):
|
||||||
|
if key == 'display_on_demand_request':
|
||||||
|
return next(reads, later)
|
||||||
|
return None # processed-id lookup
|
||||||
|
|
||||||
|
controller.cache_manager.get = MagicMock(side_effect=fake_get)
|
||||||
|
controller.cache_manager.set = MagicMock()
|
||||||
|
controller.cache_manager.delete = MagicMock()
|
||||||
|
controller._activate_on_demand = MagicMock()
|
||||||
|
|
||||||
|
REQ_A = {'request_id': 'A', 'action': 'start', 'plugin_id': 'p', 'mode': 'm'}
|
||||||
|
REQ_B = {'request_id': 'B', 'action': 'start', 'plugin_id': 'p', 'mode': 'm'}
|
||||||
|
|
||||||
|
def test_own_request_is_deleted(self, test_display_controller):
|
||||||
|
c = test_display_controller
|
||||||
|
self._arrange(c, self.REQ_A, self.REQ_A)
|
||||||
|
c._poll_on_demand_requests()
|
||||||
|
c.cache_manager.delete.assert_called_once_with('display_on_demand_request')
|
||||||
|
|
||||||
|
def test_a_newer_request_is_left_for_the_next_poll(self, test_display_controller):
|
||||||
|
c = test_display_controller
|
||||||
|
self._arrange(c, self.REQ_A, self.REQ_B)
|
||||||
|
c._poll_on_demand_requests()
|
||||||
|
assert c.cache_manager.delete.call_count == 0, \
|
||||||
|
"request B was deleted without ever being processed"
|
||||||
|
|
||||||
|
def test_an_already_empty_mailbox_is_still_cleared(self, test_display_controller):
|
||||||
|
c = test_display_controller
|
||||||
|
self._arrange(c, self.REQ_A, None)
|
||||||
|
c._poll_on_demand_requests()
|
||||||
|
c.cache_manager.delete.assert_called_once_with('display_on_demand_request')
|
||||||
|
|
||||||
|
def test_the_request_is_still_processed(self, test_display_controller):
|
||||||
|
c = test_display_controller
|
||||||
|
self._arrange(c, self.REQ_A, self.REQ_B)
|
||||||
|
c._poll_on_demand_requests()
|
||||||
|
c._activate_on_demand.assert_called_once()
|
||||||
@@ -8,9 +8,8 @@ Three separate gaps, all reachable from the web UI's force-display dialog:
|
|||||||
* restarting while on-demand was active loaded *only* the on-demand plugin,
|
* restarting while on-demand was active loaded *only* the on-demand plugin,
|
||||||
so normal rotation had nothing to return to for the life of the process;
|
so normal rotation had nothing to return to for the life of the process;
|
||||||
* a stop request was exempt from the duplicate guards on purpose and was
|
* a stop request was exempt from the duplicate guards on purpose and was
|
||||||
never removed from the file mailbox, so it was re-processed on every poll
|
never removed from the mailbox, so it was re-processed on every poll
|
||||||
forever. The mailbox is gone (stage 5); a stop still skips the guards,
|
forever.
|
||||||
so a second click stops a session a race left running.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
@@ -186,25 +185,51 @@ class TestRestartDoesNotStarveTheOtherPlugins:
|
|||||||
assert controller.on_demand_active is False
|
assert controller.on_demand_active is False
|
||||||
|
|
||||||
|
|
||||||
class TestStopRequestsSkipTheDuplicateGuard:
|
class TestStopRequestsAreConsumed:
|
||||||
"""A stop is exempt from the request-id guard: every one is acted on."""
|
"""A stop request is exempt from the duplicate guards, so the mailbox
|
||||||
|
delete is the only thing that ends it."""
|
||||||
|
|
||||||
|
STOP = {'request_id': 'S1', 'action': 'stop'}
|
||||||
|
|
||||||
def _arrange(self, controller, active):
|
def _arrange(self, controller, active):
|
||||||
controller.on_demand_active = active
|
controller.on_demand_active = active
|
||||||
controller.on_demand_status = 'active' if active else 'idle'
|
controller.on_demand_status = 'active' if active else 'idle'
|
||||||
|
controller._last_on_demand_poll = None
|
||||||
|
controller.cache_manager.get = MagicMock(
|
||||||
|
side_effect=lambda key, *a, **kw:
|
||||||
|
self.STOP if key == 'display_on_demand_request' else None)
|
||||||
|
controller.cache_manager.set = MagicMock()
|
||||||
|
controller.cache_manager.delete = MagicMock()
|
||||||
controller._clear_on_demand = MagicMock()
|
controller._clear_on_demand = MagicMock()
|
||||||
|
|
||||||
def test_the_stop_is_acted_on(self, test_display_controller):
|
def test_a_handled_stop_is_removed_from_the_mailbox(self, test_display_controller):
|
||||||
c = test_display_controller
|
c = test_display_controller
|
||||||
self._arrange(c, active=True)
|
self._arrange(c, active=True)
|
||||||
c._handle_on_demand_request({'request_id': 'S1', 'action': 'stop',
|
c._poll_on_demand_requests()
|
||||||
'source': 'socket'})
|
c.cache_manager.delete.assert_called_once_with('display_on_demand_request')
|
||||||
|
|
||||||
|
def test_a_stop_arriving_while_idle_is_also_removed(self, test_display_controller):
|
||||||
|
"""Otherwise a stop sent to an idle display re-fires forever."""
|
||||||
|
c = test_display_controller
|
||||||
|
self._arrange(c, active=False)
|
||||||
|
c._poll_on_demand_requests()
|
||||||
|
c.cache_manager.delete.assert_called_once_with('display_on_demand_request')
|
||||||
|
|
||||||
|
def test_the_stop_is_still_acted_on(self, test_display_controller):
|
||||||
|
c = test_display_controller
|
||||||
|
self._arrange(c, active=True)
|
||||||
|
c._poll_on_demand_requests()
|
||||||
c._clear_on_demand.assert_called_once_with(reason='requested-stop')
|
c._clear_on_demand.assert_called_once_with(reason='requested-stop')
|
||||||
|
|
||||||
def test_the_same_stop_twice_is_acted_on_twice(self, test_display_controller):
|
def test_a_start_racing_in_behind_a_stop_is_not_discarded(self, test_display_controller):
|
||||||
|
"""The compare-before-delete applies to stops too."""
|
||||||
c = test_display_controller
|
c = test_display_controller
|
||||||
self._arrange(c, active=True)
|
self._arrange(c, active=True)
|
||||||
for _ in range(2):
|
newer = {'request_id': 'S2', 'action': 'start', 'plugin_id': 'p', 'mode': 'm'}
|
||||||
c._handle_on_demand_request({'request_id': 'S1', 'action': 'stop',
|
reads = iter([self.STOP, newer])
|
||||||
'source': 'socket'})
|
c.cache_manager.get = MagicMock(
|
||||||
assert c._clear_on_demand.call_count == 2
|
side_effect=lambda key, *a, **kw:
|
||||||
|
next(reads, newer) if key == 'display_on_demand_request' else None)
|
||||||
|
|
||||||
|
c._poll_on_demand_requests()
|
||||||
|
assert c.cache_manager.delete.call_count == 0
|
||||||
|
|||||||
@@ -2,17 +2,19 @@
|
|||||||
and end_on_demand().
|
and end_on_demand().
|
||||||
|
|
||||||
A plugin running in the display process used to write the
|
A plugin running in the display process used to write the
|
||||||
``display_on_demand_request`` mailbox, which the display no longer reads
|
``display_on_demand_request`` mailbox, which the display reads once a second
|
||||||
(stage 5). These tests pin the way in that replaced it:
|
while the control socket is up. These tests pin the way in that replaces it:
|
||||||
|
|
||||||
* BasePlugin -> PluginManager -> DisplayController.submit_plugin_on_demand,
|
* BasePlugin -> PluginManager -> DisplayController.submit_plugin_on_demand,
|
||||||
which only queues, from any thread;
|
which only queues, from any thread;
|
||||||
* the render thread applies the queue where it applies socket commands,
|
* the render thread applies the queue where it applies socket commands,
|
||||||
through _handle_on_demand_request, at once, and woken by the control
|
through the mailbox's own handler, without the mailbox's read floor, and
|
||||||
socket when it is up;
|
woken by the control socket when it is up;
|
||||||
* a plugin's stop ends only its own session;
|
* a plugin's stop ends only its own session;
|
||||||
* no display to ask (the web interface's plugin manager, an old core's
|
* no display to ask (the web interface's plugin manager, an old core's
|
||||||
plugin manager) answers None.
|
plugin manager) answers None, which is a plugin's cue to fall back to the
|
||||||
|
mailbox;
|
||||||
|
* the mailbox still works for plugins that write it.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -71,10 +73,19 @@ def controller(test_display_controller):
|
|||||||
c_ = test_display_controller
|
c_ = test_display_controller
|
||||||
c_.on_demand_active = False
|
c_.on_demand_active = False
|
||||||
c_.on_demand_request_id = None
|
c_.on_demand_request_id = None
|
||||||
c_.cache_manager.get = MagicMock(return_value=None)
|
c_._last_on_demand_poll = None
|
||||||
|
mailbox = {'value': None}
|
||||||
|
|
||||||
|
def fake_get(key, *a, **kw):
|
||||||
|
if key == 'display_on_demand_request':
|
||||||
|
return mailbox['value']
|
||||||
|
return None
|
||||||
|
|
||||||
|
c_.cache_manager.get = MagicMock(side_effect=fake_get)
|
||||||
c_.cache_manager.set = MagicMock()
|
c_.cache_manager.set = MagicMock()
|
||||||
c_.cache_manager.delete = MagicMock()
|
c_.cache_manager.delete = MagicMock()
|
||||||
c_._activate_on_demand = MagicMock()
|
c_._activate_on_demand = MagicMock()
|
||||||
|
c_.mailbox = mailbox
|
||||||
return c_
|
return c_
|
||||||
|
|
||||||
|
|
||||||
@@ -90,7 +101,7 @@ class TestWiring:
|
|||||||
controller.plugin_manager.set_on_demand_handler.assert_called_once_with(
|
controller.plugin_manager.set_on_demand_handler.assert_called_once_with(
|
||||||
controller.submit_plugin_on_demand)
|
controller.submit_plugin_on_demand)
|
||||||
|
|
||||||
def test_a_start_reaches_the_on_demand_handler(self, wired):
|
def test_a_start_reaches_the_mailbox_handler(self, wired):
|
||||||
controller, manager = wired
|
controller, manager = wired
|
||||||
rid = _plugin('pomodoro-timer', manager).request_on_demand(
|
rid = _plugin('pomodoro-timer', manager).request_on_demand(
|
||||||
mode='pomodoro', duration=30, pinned=True)
|
mode='pomodoro', duration=30, pinned=True)
|
||||||
@@ -128,6 +139,15 @@ class TestWiring:
|
|||||||
controller._poll_on_demand_requests()
|
controller._poll_on_demand_requests()
|
||||||
assert seen == ['a', 'b', 'c']
|
assert seen == ['a', 'b', 'c']
|
||||||
|
|
||||||
|
def test_the_mailbox_still_works_for_older_plugins(self, wired):
|
||||||
|
controller, manager = wired
|
||||||
|
controller.mailbox['value'] = {'request_id': 'mb', 'action': 'start',
|
||||||
|
'plugin_id': 'birdnet-go'}
|
||||||
|
_plugin('on-air', manager).request_on_demand()
|
||||||
|
controller._poll_on_demand_requests()
|
||||||
|
ids = [call.args[0]['request_id'] for call in controller._activate_on_demand.call_args_list]
|
||||||
|
assert 'mb' in ids and len(ids) == 2
|
||||||
|
|
||||||
def test_a_failing_request_is_contained(self, wired):
|
def test_a_failing_request_is_contained(self, wired):
|
||||||
controller, manager = wired
|
controller, manager = wired
|
||||||
calls = []
|
calls = []
|
||||||
@@ -154,10 +174,10 @@ class TestPromptness:
|
|||||||
controller._service_pending_changes() # well inside the 0.25 s floor
|
controller._service_pending_changes() # well inside the 0.25 s floor
|
||||||
controller._activate_on_demand.assert_called_once()
|
controller._activate_on_demand.assert_called_once()
|
||||||
|
|
||||||
def test_a_plugin_request_lands_on_the_next_poll(self, wired):
|
def test_a_plugin_request_skips_the_mailbox_floor(self, wired):
|
||||||
controller, manager = wired
|
controller, manager = wired
|
||||||
controller._control_server = _WakeServer()
|
controller._control_server = _WakeServer()
|
||||||
controller._poll_on_demand_requests()
|
controller._poll_on_demand_requests() # sets the 1 s mailbox floor
|
||||||
_plugin('p', manager).request_on_demand()
|
_plugin('p', manager).request_on_demand()
|
||||||
controller._poll_on_demand_requests()
|
controller._poll_on_demand_requests()
|
||||||
controller._activate_on_demand.assert_called_once()
|
controller._activate_on_demand.assert_called_once()
|
||||||
@@ -266,13 +286,14 @@ class TestStop:
|
|||||||
controller._poll_on_demand_requests()
|
controller._poll_on_demand_requests()
|
||||||
controller._clear_on_demand.assert_not_called()
|
controller._clear_on_demand.assert_not_called()
|
||||||
|
|
||||||
def test_a_socket_stop_still_ends_any_session(self, wired):
|
def test_a_mailbox_stop_still_ends_any_session(self, wired):
|
||||||
controller, _ = wired
|
controller, _ = wired
|
||||||
controller.on_demand_active = True
|
controller.on_demand_active = True
|
||||||
controller.on_demand_plugin_id = 'clock'
|
controller.on_demand_plugin_id = 'clock'
|
||||||
controller._clear_on_demand = MagicMock()
|
controller._clear_on_demand = MagicMock()
|
||||||
controller._handle_on_demand_request({'request_id': 's', 'action': 'stop',
|
controller.mailbox['value'] = {'request_id': 's', 'action': 'stop',
|
||||||
'source': 'socket'})
|
'plugin_id': 'on-air'}
|
||||||
|
controller._poll_on_demand_requests()
|
||||||
controller._clear_on_demand.assert_called_once_with(reason='requested-stop')
|
controller._clear_on_demand.assert_called_once_with(reason='requested-stop')
|
||||||
|
|
||||||
def test_start_then_stop_from_one_thread_ends_the_session(self, wired):
|
def test_start_then_stop_from_one_thread_ends_the_session(self, wired):
|
||||||
@@ -293,7 +314,7 @@ class TestStop:
|
|||||||
|
|
||||||
|
|
||||||
class TestNoDisplay:
|
class TestNoDisplay:
|
||||||
"""None: no display in this process took the request."""
|
"""None is a plugin's cue to write the mailbox instead."""
|
||||||
|
|
||||||
def test_a_manager_with_no_handler_answers_none(self):
|
def test_a_manager_with_no_handler_answers_none(self):
|
||||||
plugin = _plugin('p', _manager())
|
plugin = _plugin('p', _manager())
|
||||||
@@ -332,30 +353,21 @@ class TestNoDisplay:
|
|||||||
assert bare._plugin_on_demand_pending() is False
|
assert bare._plugin_on_demand_pending() is False
|
||||||
bare._drain_plugin_on_demand() # nothing to do, no error
|
bare._drain_plugin_on_demand() # nothing to do, no error
|
||||||
|
|
||||||
def test_a_mailbox_write_after_none_is_dropped_with_a_warning(self, tmp_path,
|
def test_the_feature_detection_pattern(self):
|
||||||
monkeypatch, caplog):
|
"""The hasattr pattern from docs/PLUGIN_API_REFERENCE.md."""
|
||||||
"""What a plugin written for older cores does on None now: its
|
writes = []
|
||||||
fallback write to the retired key stores nothing, and the log names
|
|
||||||
it once."""
|
class OldCorePlugin: # an older core's BasePlugin has no such method
|
||||||
from src import cache_manager as cache_module
|
pass
|
||||||
from src.cache_manager import CacheManager
|
|
||||||
monkeypatch.setattr(CacheManager, '_get_writable_cache_dir',
|
for plugin, expect_mailbox in ((OldCorePlugin(), True),
|
||||||
lambda self: str(tmp_path))
|
(_plugin('p', _manager()), True),
|
||||||
monkeypatch.setattr(cache_module, '_retired_writers_warned', set())
|
(_plugin('p', _manager(lambda r: True)), False)):
|
||||||
cache = CacheManager()
|
writes.clear()
|
||||||
try:
|
if not (hasattr(plugin, 'request_on_demand')
|
||||||
plugin = _plugin('birdnet-go', _manager())
|
and plugin.request_on_demand(mode='m')):
|
||||||
plugin.cache_manager = cache
|
writes.append('mailbox')
|
||||||
caplog.set_level(logging.WARNING)
|
assert (writes == ['mailbox']) is expect_mailbox
|
||||||
for _ in range(2):
|
|
||||||
if plugin.request_on_demand(mode='m') is None:
|
|
||||||
plugin.cache_manager.set('display_on_demand_request', {
|
|
||||||
'request_id': 'r', 'action': 'start', 'plugin_id': 'birdnet-go'})
|
|
||||||
assert cache.get('display_on_demand_request', max_age=None, memory_ttl=0) is None
|
|
||||||
lines = [r.getMessage() for r in caplog.records if 'retired' in r.getMessage()]
|
|
||||||
assert len(lines) == 1 and "plugin 'birdnet-go'" in lines[0]
|
|
||||||
finally:
|
|
||||||
cache.stop_cleanup_thread()
|
|
||||||
|
|
||||||
|
|
||||||
class TestArguments:
|
class TestArguments:
|
||||||
@@ -389,7 +401,7 @@ class TestArguments:
|
|||||||
|
|
||||||
class TestMockManagers:
|
class TestMockManagers:
|
||||||
def test_a_magicmock_manager_reads_as_not_taken(self):
|
def test_a_magicmock_manager_reads_as_not_taken(self):
|
||||||
"""A plugin's test with a MagicMock manager reads as "not taken"."""
|
"""A plugin's test with a MagicMock manager keeps its mailbox path."""
|
||||||
plugin = _plugin('p', MagicMock())
|
plugin = _plugin('p', MagicMock())
|
||||||
assert plugin.request_on_demand(mode='m') is None
|
assert plugin.request_on_demand(mode='m') is None
|
||||||
assert plugin.end_on_demand() is None
|
assert plugin.end_on_demand() is None
|
||||||
|
|||||||
@@ -1,205 +0,0 @@
|
|||||||
"""Per-plugin schedules (config['plugin_schedules'], src/plugin_schedule.py).
|
|
||||||
|
|
||||||
The window rule itself is the display schedule's, covered case by case in
|
|
||||||
test_display_controller_schedule.py through _check_schedule; here: the
|
|
||||||
plugin-level answer, and the three places it is applied -- the rotation,
|
|
||||||
the live scan, and the Vegas ticker -- plus the save endpoint.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import copy
|
|
||||||
import os
|
|
||||||
from datetime import datetime
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
os.environ.setdefault("EMULATOR", "true")
|
|
||||||
|
|
||||||
from src.display_arbiter import ScreenPlan, Source # noqa: E402
|
|
||||||
from src.display_controller import DisplayController # noqa: E402
|
|
||||||
from src.plugin_schedule import hidden_plugins, schedule_state, wants_live_scan # noqa: E402
|
|
||||||
from src.vegas_mode.config import VegasModeConfig # noqa: E402
|
|
||||||
from src.vegas_mode.stream_manager import StreamManager # noqa: E402
|
|
||||||
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: E402,F401
|
|
||||||
|
|
||||||
DAYS = ('monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday')
|
|
||||||
SUNDAY_1PM = datetime(2026, 10, 18, 13, 0) # a Sunday
|
|
||||||
MONDAY_1PM = datetime(2026, 10, 19, 13, 0)
|
|
||||||
|
|
||||||
|
|
||||||
def sundays_off(**extra):
|
|
||||||
"""The gameday schedule: every day all day, except Sunday."""
|
|
||||||
days = {d: {'enabled': d != 'sunday', 'start_time': '00:00', 'end_time': '23:59'}
|
|
||||||
for d in DAYS}
|
|
||||||
return {'enabled': True, 'mode': 'per-day', 'days': days, **extra}
|
|
||||||
|
|
||||||
|
|
||||||
ALWAYS_OFF = {'enabled': True, 'mode': 'per-day',
|
|
||||||
'days': {d: {'enabled': False} for d in DAYS}}
|
|
||||||
|
|
||||||
|
|
||||||
class TestHiddenPlugins:
|
|
||||||
def test_outside_window_is_hidden(self):
|
|
||||||
schedules = {'news': sundays_off(), 'stocks': sundays_off()}
|
|
||||||
assert hidden_plugins(schedules, SUNDAY_1PM, False) == {'news', 'stocks'}
|
|
||||||
assert hidden_plugins(schedules, MONDAY_1PM, False) == frozenset()
|
|
||||||
|
|
||||||
def test_hide_when_live_only_while_live(self):
|
|
||||||
schedules = {'news': {'enabled': False, 'hide_when_live': True}}
|
|
||||||
assert hidden_plugins(schedules, MONDAY_1PM, True) == {'news'}
|
|
||||||
assert hidden_plugins(schedules, MONDAY_1PM, False) == frozenset()
|
|
||||||
|
|
||||||
def test_disabled_schedule_and_junk_entries_hide_nothing(self):
|
|
||||||
schedules = {'news': {**ALWAYS_OFF, 'enabled': False}, 'bad': 'x', 'none': None}
|
|
||||||
assert hidden_plugins(schedules, SUNDAY_1PM, True) == frozenset()
|
|
||||||
|
|
||||||
def test_bad_time_shows_rather_than_hides(self):
|
|
||||||
assert schedule_state({'mode': 'global', 'start_time': '25:00'}, MONDAY_1PM)[0]
|
|
||||||
|
|
||||||
@pytest.mark.parametrize('schedule', [
|
|
||||||
{'mode': 1},
|
|
||||||
{'mode': 'per-day', 'days': 5},
|
|
||||||
{'mode': 'per-day', 'days': {'sunday': False}},
|
|
||||||
{'mode': 'per-day', 'days': ['sunday']},
|
|
||||||
])
|
|
||||||
def test_wrong_shapes_show_rather_than_raise(self, schedule):
|
|
||||||
# Falls back to the global 07:00-23:00 window, which 13:00 is inside.
|
|
||||||
assert schedule_state(schedule, SUNDAY_1PM)[0]
|
|
||||||
|
|
||||||
def test_global_mode_ignores_days(self):
|
|
||||||
schedule = {**sundays_off(), 'mode': 'global', 'start_time': '12:00', 'end_time': '14:00'}
|
|
||||||
assert schedule_state(schedule, SUNDAY_1PM)[0]
|
|
||||||
|
|
||||||
def test_wants_live_scan(self):
|
|
||||||
assert not wants_live_scan({'news': sundays_off()})
|
|
||||||
assert wants_live_scan({'news': sundays_off(), 'stocks': {'hide_when_live': True}})
|
|
||||||
|
|
||||||
|
|
||||||
def plugin(plugin_id, live=False, priority=False):
|
|
||||||
return SimpleNamespace(plugin_id=plugin_id, display=lambda **_: True,
|
|
||||||
has_live_content=lambda: live,
|
|
||||||
has_live_priority=lambda: priority,
|
|
||||||
get_live_modes=lambda: [f'{plugin_id}_live'])
|
|
||||||
|
|
||||||
|
|
||||||
def controller(schedules, plugin_modes):
|
|
||||||
dc = object.__new__(DisplayController)
|
|
||||||
dc.config = {'plugin_schedules': schedules}
|
|
||||||
dc._tz = None
|
|
||||||
dc.plugin_modes = plugin_modes
|
|
||||||
dc._plugin_hidden_cache = (None, frozenset())
|
|
||||||
dc._game_live = False
|
|
||||||
dc._game_live_at = float('-inf')
|
|
||||||
dc._health_tracker = lambda: None
|
|
||||||
return dc
|
|
||||||
|
|
||||||
|
|
||||||
class TestController:
|
|
||||||
def test_rotation_skips_hidden_but_on_demand_shows_it(self):
|
|
||||||
news = plugin('news')
|
|
||||||
dc = controller({'news': ALWAYS_OFF}, {'news': news})
|
|
||||||
assert dc._plugin_to_draw(ScreenPlan(Source.ROTATION, mode='news')) is None
|
|
||||||
assert dc._plugin_to_draw(ScreenPlan(Source.ON_DEMAND, mode='news')) is news
|
|
||||||
|
|
||||||
def test_no_schedules_changes_nothing(self):
|
|
||||||
news = plugin('news')
|
|
||||||
dc = controller({}, {'news': news})
|
|
||||||
assert dc._plugin_to_draw(ScreenPlan(Source.ROTATION, mode='news')) is news
|
|
||||||
|
|
||||||
def test_any_live_game_hides_news_even_without_live_priority(self):
|
|
||||||
modes = {'news': plugin('news'), 'nfl_live': plugin('nfl', live=True)}
|
|
||||||
dc = controller({'news': {'enabled': False, 'hide_when_live': True}}, modes)
|
|
||||||
assert dc._hidden_plugins() == {'news'}
|
|
||||||
|
|
||||||
def test_scoreboard_scheduled_off_does_not_win_live(self):
|
|
||||||
modes = {'nfl_live': plugin('nfl', live=True, priority=True)}
|
|
||||||
dc = controller({'nfl': ALWAYS_OFF}, modes)
|
|
||||||
assert dc._collect_live_modes() == []
|
|
||||||
dc.config = {}
|
|
||||||
assert dc._collect_live_modes() == ['nfl_live']
|
|
||||||
|
|
||||||
def test_a_scheduled_off_live_mode_does_not_hold_the_rotation(self):
|
|
||||||
modes = {'nfl_live': plugin('nfl', live=True, priority=True)}
|
|
||||||
dc = controller({'nfl': ALWAYS_OFF}, modes)
|
|
||||||
assert not dc._still_live('nfl_live')
|
|
||||||
dc.config = {}
|
|
||||||
assert dc._still_live('nfl_live')
|
|
||||||
|
|
||||||
@pytest.mark.parametrize('schedules', [['news'], 'news', 5])
|
|
||||||
def test_wrong_shaped_plugin_schedules_hide_nothing(self, schedules):
|
|
||||||
dc = controller(schedules, {'news': plugin('news')})
|
|
||||||
assert dc._hidden_plugins() == frozenset()
|
|
||||||
|
|
||||||
def test_a_reload_is_never_answered_from_the_old_schedules(self):
|
|
||||||
dc = controller({'news': ALWAYS_OFF}, {'news': plugin('news')})
|
|
||||||
assert dc._hidden_plugins() == {'news'}
|
|
||||||
# A new config in the same minute, with no cache reset.
|
|
||||||
dc.config = {'plugin_schedules': {'news': {'enabled': False}}}
|
|
||||||
assert dc._hidden_plugins() == frozenset()
|
|
||||||
|
|
||||||
|
|
||||||
class TestVegas:
|
|
||||||
def test_hidden_plugins_sit_out_the_ticker(self):
|
|
||||||
plugins = {pid: SimpleNamespace(enabled=True, config={}) for pid in ('news', 'nfl')}
|
|
||||||
stream = StreamManager(VegasModeConfig(), SimpleNamespace(plugins=plugins), MagicMock())
|
|
||||||
stream._refresh_plugin_list()
|
|
||||||
assert set(stream._ordered_plugins) == {'news', 'nfl'}
|
|
||||||
stream.hidden_plugins = lambda: frozenset({'news'})
|
|
||||||
stream._refresh_plugin_list()
|
|
||||||
assert set(stream._ordered_plugins) == {'nfl'}
|
|
||||||
|
|
||||||
def test_a_changed_hidden_set_skips_the_refresh_interval(self):
|
|
||||||
plugins = {pid: SimpleNamespace(enabled=True, config={}) for pid in ('news', 'nfl')}
|
|
||||||
stream = StreamManager(VegasModeConfig(), SimpleNamespace(plugins=plugins), MagicMock())
|
|
||||||
stream.refresh() # the interval's refresh
|
|
||||||
assert set(stream._ordered_plugins) == {'news', 'nfl'}
|
|
||||||
stream.refresh() # inside the interval, nothing changed: no rebuild
|
|
||||||
stream.hidden_plugins = lambda: frozenset({'news'})
|
|
||||||
stream.refresh() # still inside the interval, but the set changed
|
|
||||||
assert set(stream._ordered_plugins) == {'nfl'}
|
|
||||||
|
|
||||||
|
|
||||||
class TestSaveEndpoint:
|
|
||||||
@pytest.fixture
|
|
||||||
def store(self, api_v3_module, monkeypatch):
|
|
||||||
state = {'config': {}}
|
|
||||||
api_v3_module.api_v3.config_manager.load_config.side_effect = \
|
|
||||||
lambda *a, **k: copy.deepcopy(state['config'])
|
|
||||||
api_v3_module.api_v3.plugin_catalog.plugin_manifests = {'news': {}}
|
|
||||||
|
|
||||||
def fake_save(_manager, config, **_kwargs):
|
|
||||||
state['config'] = copy.deepcopy(config)
|
|
||||||
return True, ''
|
|
||||||
|
|
||||||
monkeypatch.setattr(api_v3_module, '_save_config_atomic', fake_save)
|
|
||||||
return state
|
|
||||||
|
|
||||||
def test_round_trip(self, api_v3_client, store):
|
|
||||||
body = {'plugin_id': 'news', 'hide_when_live': 'on', **sundays_off()}
|
|
||||||
resp = api_v3_client.post('/api/v3/config/plugin-schedules', json=body)
|
|
||||||
assert resp.status_code == 200, resp.get_json()
|
|
||||||
saved = store['config']['plugin_schedules']['news']
|
|
||||||
assert saved['hide_when_live'] is True
|
|
||||||
assert saved['days']['sunday']['enabled'] is False
|
|
||||||
got = api_v3_client.get('/api/v3/config/plugin-schedules').get_json()['data']
|
|
||||||
assert got == {'news': saved}
|
|
||||||
|
|
||||||
def test_schedule_that_does_nothing_is_removed(self, api_v3_client, store):
|
|
||||||
store['config'] = {'plugin_schedules': {'news': sundays_off()}}
|
|
||||||
resp = api_v3_client.post('/api/v3/config/plugin-schedules',
|
|
||||||
json={'plugin_id': 'news', 'enabled': False})
|
|
||||||
assert resp.status_code == 200, resp.get_json()
|
|
||||||
assert store['config']['plugin_schedules'] == {}
|
|
||||||
|
|
||||||
@pytest.mark.parametrize('body,status', [
|
|
||||||
({'plugin_id': 'display', 'enabled': True}, 400), # a core section
|
|
||||||
({'plugin_id': 'nope', 'enabled': True}, 404), # not installed
|
|
||||||
({'plugin_id': 'news', 'enabled': True, 'mode': 'global',
|
|
||||||
'start_time': '25:00', 'end_time': '23:00'}, 400), # bad time
|
|
||||||
({'enabled': True}, 400), # no id
|
|
||||||
])
|
|
||||||
def test_refusals(self, api_v3_client, store, body, status):
|
|
||||||
resp = api_v3_client.post('/api/v3/config/plugin-schedules', json=body)
|
|
||||||
assert resp.status_code == status, resp.get_json()
|
|
||||||
assert 'plugin_schedules' not in store['config']
|
|
||||||
@@ -12,8 +12,8 @@ frame, so a command is applied:
|
|||||||
* at the next frame in Vegas (8 ms here, at 125 fps).
|
* at the next frame in Vegas (8 ms here, at 125 fps).
|
||||||
|
|
||||||
Each test runs the real run() loop on the fake clock of
|
Each test runs the real run() loop on the fake clock of
|
||||||
test/_run_loop_harness.py. The latencies are the fake clock's, so they are
|
test/_run_loop_harness.py and compares the socket with the mailbox for the
|
||||||
exact. (The file mailbox these were once compared with is gone: stage 5.)
|
same request. The latencies are the fake clock's, so they are exact.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -32,10 +32,13 @@ def _event_time(trace, kind):
|
|||||||
return next(e[0] for e in trace["events"] if e[1] == kind)
|
return next(e[0] for e in trace["events"] if e[1] == kind)
|
||||||
|
|
||||||
|
|
||||||
def _on_demand_latency(tmp_path, build, horizon=40):
|
def _on_demand_latency(tmp_path, build, via, horizon=40):
|
||||||
h = RunLoopHarness(tmp_path, horizon=horizon)
|
h = RunLoopHarness(tmp_path, horizon=horizon)
|
||||||
build(h)
|
build(h)
|
||||||
h.control_socket().post(POSTED, Command.ON_DEMAND_START, {"plugin_id": "weather"})
|
if via == "socket":
|
||||||
|
h.control_socket().post(POSTED, Command.ON_DEMAND_START, {"plugin_id": "weather"})
|
||||||
|
else:
|
||||||
|
h.on_demand_request(POSTED, "mb1", plugin_id="weather")
|
||||||
trace = h.run()
|
trace = h.run()
|
||||||
return round(_event_time(trace, "on-demand-start") - POSTED, 3), trace
|
return round(_event_time(trace, "on-demand-start") - POSTED, 3), trace
|
||||||
|
|
||||||
@@ -57,12 +60,18 @@ def _vegas(h):
|
|||||||
h.enable_vegas(cycle=30)
|
h.enable_vegas(cycle=30)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("build", [_static, _dwell, _vegas],
|
@pytest.mark.parametrize("build, mailbox_latency", [
|
||||||
ids=["static-screen", "dwell", "vegas"])
|
(_static, 0.7), # the next 1 s frame, at t=11
|
||||||
def test_a_socket_command_lands_at_once(tmp_path, build):
|
(_dwell, 0.2), # the next 0.25 s tick, at t=10.5
|
||||||
# Before stage 2 these waited for the next 1 s frame, the next 0.25 s
|
(_vegas, 0.02), # the next 10-frame check: 80 ms at 125 fps, ~0.4 s on a Pi 4
|
||||||
# dwell tick, or the next 10-frame Vegas check.
|
], ids=["static-screen", "dwell", "vegas"])
|
||||||
socket_latency, trace = _on_demand_latency(tmp_path, build)
|
def test_a_socket_command_lands_at_once(tmp_path, build, mailbox_latency):
|
||||||
|
(tmp_path / "s").mkdir()
|
||||||
|
(tmp_path / "m").mkdir()
|
||||||
|
socket_latency, trace = _on_demand_latency(tmp_path / "s", build, "socket")
|
||||||
|
via_mailbox, _ = _on_demand_latency(tmp_path / "m", build, "mailbox")
|
||||||
|
|
||||||
|
assert via_mailbox == pytest.approx(mailbox_latency, abs=0.002)
|
||||||
if build is _vegas:
|
if build is _vegas:
|
||||||
# One 8 ms frame: Vegas checks the queue every frame now.
|
# One 8 ms frame: Vegas checks the queue every frame now.
|
||||||
assert socket_latency <= 0.008
|
assert socket_latency <= 0.008
|
||||||
|
|||||||
@@ -1,399 +0,0 @@
|
|||||||
"""src.common.sports_live_fetch: the live poll's request and the odds queue.
|
|
||||||
|
|
||||||
The cases follow ledmatrix-plugins' ``scripts/test_live_fetch_odds.py`` (the
|
|
||||||
tables the family 9 reconcile was checked against); the expected values are
|
|
||||||
its columns, which agree across all nine scoreboards. The host is the mixin
|
|
||||||
over core's ``SportsFetchMixin`` (``_needs_previous_day``), with a session
|
|
||||||
answering like ESPN did on 2026-10-12 (a single date is 200, a range 400) and
|
|
||||||
a cache that ages entries on a test clock. Nothing reaches the network. The
|
|
||||||
odds endpoint is held on an event, so "update() never waits" is shown by the
|
|
||||||
call returning while the request is still open.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import ast
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import requests
|
|
||||||
|
|
||||||
from src.common import espn_dates, sports_live_fetch
|
|
||||||
from src.common import fetch_service as fs
|
|
||||||
from src.common.fetch_service import FetchService
|
|
||||||
from src.common.sports_fetch import SportsFetchMixin
|
|
||||||
from src.common.sports_live_fetch import SportsLiveFetchMixin
|
|
||||||
|
|
||||||
LOG = logging.getLogger("test_sports_live_fetch")
|
|
||||||
SITE = "https://site.api.espn.com/apis/site/v2/sports/"
|
|
||||||
|
|
||||||
|
|
||||||
def _response(url, answer):
|
|
||||||
resp = requests.Response()
|
|
||||||
resp.url = url
|
|
||||||
resp.status_code = answer if isinstance(answer, int) else 200
|
|
||||||
resp._content = answer if isinstance(answer, bytes) else json.dumps(
|
|
||||||
{} if isinstance(answer, int) else answer).encode()
|
|
||||||
return resp
|
|
||||||
|
|
||||||
|
|
||||||
class Espn(requests.Session):
|
|
||||||
"""Two games a day for a single date; a range is 400, as ESPN answers."""
|
|
||||||
|
|
||||||
def __init__(self, answer=None):
|
|
||||||
super().__init__()
|
|
||||||
self.answer, self.asked = answer, []
|
|
||||||
|
|
||||||
def get(self, url, params=None, **_kwargs):
|
|
||||||
dates = (params or {}).get("dates")
|
|
||||||
self.asked.append((url[len(SITE):], dates))
|
|
||||||
if dates and "-" in dates:
|
|
||||||
return _response(url, 400)
|
|
||||||
if isinstance(self.answer, type):
|
|
||||||
raise self.answer("refused by the test")
|
|
||||||
if self.answer is not None:
|
|
||||||
return _response(url, self.answer)
|
|
||||||
return _response(url, {"events": [{"id": dates[4:] + s} for s in "ab"]})
|
|
||||||
|
|
||||||
|
|
||||||
class Cache:
|
|
||||||
"""``get(key, max_age)`` against entries stamped on ``clock``."""
|
|
||||||
|
|
||||||
def __init__(self, clock):
|
|
||||||
self.clock, self.store, self.reads = clock, {}, []
|
|
||||||
|
|
||||||
def get(self, key, max_age=None):
|
|
||||||
self.reads.append((key, max_age))
|
|
||||||
if key not in self.store:
|
|
||||||
return None
|
|
||||||
data, stamp = self.store[key]
|
|
||||||
return data if max_age is None or self.clock() - stamp <= max_age else None
|
|
||||||
|
|
||||||
def set(self, key, data, ttl=None):
|
|
||||||
self.store[key] = (data, self.clock())
|
|
||||||
|
|
||||||
|
|
||||||
class Clock:
|
|
||||||
def __init__(self):
|
|
||||||
self.now = 100_000.0
|
|
||||||
|
|
||||||
def __call__(self):
|
|
||||||
return self.now
|
|
||||||
|
|
||||||
|
|
||||||
class Frozen(datetime):
|
|
||||||
at = datetime(2026, 10, 12, 10, 0)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def now(cls, tz=None):
|
|
||||||
return tz.localize(cls.at) if tz is not None else cls.at
|
|
||||||
|
|
||||||
|
|
||||||
class Host(SportsLiveFetchMixin, SportsFetchMixin):
|
|
||||||
"""A scoreboard SportsCore as far as the family reaches."""
|
|
||||||
|
|
||||||
sport, league, sport_key = "basketball", "nba", "nba"
|
|
||||||
|
|
||||||
def __init__(self, session=None, cache=None, odds=None, show_odds=True):
|
|
||||||
self.session, self.headers, self.logger = session or Espn(), {}, LOG
|
|
||||||
self.cache_manager = cache
|
|
||||||
self.odds_manager, self.show_odds = odds, show_odds
|
|
||||||
self.mode_config = {}
|
|
||||||
self._games_lock = threading.RLock()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def offline(monkeypatch):
|
|
||||||
clock = Clock()
|
|
||||||
monkeypatch.setattr(fs, "_service", FetchService({"rate_limits": {}}))
|
|
||||||
monkeypatch.setattr(espn_dates, "_ranges_rejected_until", 0.0)
|
|
||||||
monkeypatch.setattr(sports_live_fetch, "datetime", Frozen)
|
|
||||||
monkeypatch.setattr(time, "time", clock)
|
|
||||||
Frozen.at = datetime(2026, 10, 12, 10, 0)
|
|
||||||
return clock
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# The live poll's request
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("hour, asked, key, ids", [
|
|
||||||
(10, [("20261012",)], "espn_scoreboard_basketball_nba_20261012", ["1012a", "1012b"]),
|
|
||||||
(2, [("20261011-20261012",), ("20261011",), ("20261012",)],
|
|
||||||
"espn_scoreboard_basketball_nba_20261011-20261012",
|
|
||||||
["1011a", "1011b", "1012a", "1012b"]),
|
|
||||||
])
|
|
||||||
def test_what_the_poll_asks_and_keeps(offline, hour, asked, key, ids):
|
|
||||||
Frozen.at = datetime(2026, 10, 12, hour, 0)
|
|
||||||
espn, cache = Espn(), Cache(offline)
|
|
||||||
data = Host(espn, cache)._fetch_todays_games()
|
|
||||||
assert [(d,) for _, d in espn.asked] == asked
|
|
||||||
assert {path for path, _ in espn.asked} == {"basketball/nba/scoreboard"}
|
|
||||||
assert cache.reads == [(key, 1)]
|
|
||||||
assert list(cache.store) == [key]
|
|
||||||
assert [e["id"] for e in data["events"]] == ids
|
|
||||||
|
|
||||||
|
|
||||||
def test_college_basketball_is_asked_by_date(offline):
|
|
||||||
espn, host = Espn(), Host()
|
|
||||||
host.session, host.league = espn, "mens-college-basketball"
|
|
||||||
host._fetch_todays_games()
|
|
||||||
assert espn.asked == [("basketball/mens-college-basketball/scoreboard", "20261012")]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("seed_age, later, requests_made", [
|
|
||||||
(None, 20, [1, 1]), # every poll asks ESPN; nothing is held 30 s
|
|
||||||
(None, 0.5, [1, 0]), # a copy from this second is the same answer
|
|
||||||
(0, None, [0]), # another reader's copy from this second
|
|
||||||
(2, None, [1]), # ... and one two seconds old is asked again
|
|
||||||
])
|
|
||||||
def test_every_poll_is_fresh(offline, seed_age, later, requests_made):
|
|
||||||
espn, cache = Espn(), Cache(offline)
|
|
||||||
if seed_age is not None:
|
|
||||||
cache.store["espn_scoreboard_basketball_nba_20261012"] = (
|
|
||||||
{"events": [{"id": "seed"}]}, offline.now - seed_age)
|
|
||||||
host, made = Host(espn, cache), []
|
|
||||||
for step in ([0] if later is None else [0, later]):
|
|
||||||
offline.now += step
|
|
||||||
before = len(espn.asked)
|
|
||||||
host._fetch_todays_games()
|
|
||||||
made.append(len(espn.asked) - before)
|
|
||||||
assert made == requests_made
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_old_per_plugin_key_is_not_read(offline):
|
|
||||||
cache = Cache(offline)
|
|
||||||
cache.store["nba_scoreboard_current"] = ({"events": [{"id": "old"}]}, offline.now)
|
|
||||||
data = Host(Espn(), cache)._fetch_todays_games()
|
|
||||||
assert [e["id"] for e in data["events"]] == ["1012a", "1012b"]
|
|
||||||
assert "nba_scoreboard_current" not in {k for k, _ in cache.reads}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("answer, result, cached", [
|
|
||||||
({}, {"events": []}, False),
|
|
||||||
({"events": None}, {"events": []}, False),
|
|
||||||
({"events": []}, {"events": []}, True),
|
|
||||||
(404, None, False),
|
|
||||||
(500, None, False),
|
|
||||||
(requests.ConnectionError, None, False),
|
|
||||||
(requests.Timeout, None, False),
|
|
||||||
(b"<html>", None, False),
|
|
||||||
])
|
|
||||||
def test_what_a_failed_or_malformed_answer_becomes(offline, answer, result, cached):
|
|
||||||
cache = Cache(offline)
|
|
||||||
assert Host(Espn(answer), cache)._fetch_todays_games() == result
|
|
||||||
assert bool(cache.store) is cached
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Odds
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class Odds:
|
|
||||||
"""An odds endpoint held open until ``release()``, counting what is in
|
|
||||||
flight and how many odds workers are alive while it answers."""
|
|
||||||
|
|
||||||
def __init__(self, answer="odds", held=True):
|
|
||||||
self.answer, self.calls, self.gate = answer, [], threading.Event()
|
|
||||||
if not held:
|
|
||||||
self.gate.set()
|
|
||||||
self.lock, self.active, self.most_active, self.most_workers = threading.Lock(), 0, 0, 0
|
|
||||||
|
|
||||||
def get_odds(self, sport, league, event_id, update_interval_seconds=None):
|
|
||||||
workers = sum(t.name == "nba-odds" and t.is_alive() for t in threading.enumerate())
|
|
||||||
with self.lock:
|
|
||||||
self.calls.append((event_id, update_interval_seconds))
|
|
||||||
self.active += 1
|
|
||||||
self.most_active = max(self.most_active, self.active)
|
|
||||||
self.most_workers = max(self.most_workers, workers)
|
|
||||||
self.gate.wait(10)
|
|
||||||
with self.lock:
|
|
||||||
self.active -= 1
|
|
||||||
if isinstance(self.answer, type):
|
|
||||||
raise self.answer("odds endpoint failed")
|
|
||||||
return {"details": f"{event_id} -3.5"} if self.answer else None
|
|
||||||
|
|
||||||
def release(self):
|
|
||||||
self.gate.set()
|
|
||||||
|
|
||||||
|
|
||||||
def settle():
|
|
||||||
for thread in threading.enumerate():
|
|
||||||
if thread.name == "nba-odds":
|
|
||||||
thread.join(10)
|
|
||||||
|
|
||||||
|
|
||||||
def game(gid="g1", **flags):
|
|
||||||
return {"id": gid, **flags}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("flags, interval", [({"is_live": True}, 60), ({"is_upcoming": True}, 3600),
|
|
||||||
({"is_final": True}, 3600)])
|
|
||||||
def test_the_call_never_waits_and_the_line_lands_later(flags, interval):
|
|
||||||
odds = Odds()
|
|
||||||
host, g = Host(odds=odds), game(**flags)
|
|
||||||
host._fetch_odds(g) # returns while the request is held open
|
|
||||||
assert "odds" not in g
|
|
||||||
odds.release()
|
|
||||||
settle()
|
|
||||||
assert g["odds"] == {"details": "g1 -3.5"}
|
|
||||||
assert odds.calls == [("g1", interval)]
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_intervals_come_from_the_mode_config():
|
|
||||||
odds = Odds(held=False)
|
|
||||||
host = Host(odds=odds)
|
|
||||||
host.mode_config = {"live_odds_update_interval": 30, "odds_update_interval": 900}
|
|
||||||
host._fetch_odds(game("l", is_live=True))
|
|
||||||
settle()
|
|
||||||
host._fetch_odds(game("u"))
|
|
||||||
settle()
|
|
||||||
assert odds.calls == [("l", 30), ("u", 900)]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("answer", [None, RuntimeError])
|
|
||||||
def test_no_line_or_a_failed_request_leaves_the_game_bare(caplog, answer):
|
|
||||||
odds = Odds(answer, held=False)
|
|
||||||
host, g = Host(odds=odds), game()
|
|
||||||
with caplog.at_level(logging.DEBUG, logger=LOG.name):
|
|
||||||
host._fetch_odds(g)
|
|
||||||
settle()
|
|
||||||
assert "odds" not in g and host._odds_worker_busy is False
|
|
||||||
assert [r for r in caplog.records if r.levelno >= logging.WARNING] == []
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("host", [
|
|
||||||
Host(odds=Odds(held=False), show_odds=False),
|
|
||||||
Host(odds=None),
|
|
||||||
], ids=["show_odds off", "no odds manager"])
|
|
||||||
def test_nothing_is_asked(caplog, host):
|
|
||||||
with caplog.at_level(logging.DEBUG, logger=LOG.name):
|
|
||||||
host._fetch_odds(game())
|
|
||||||
assert not [t for t in threading.enumerate() if t.name == "nba-odds"]
|
|
||||||
assert [r.levelname for r in caplog.records] in ([], ["DEBUG"])
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_game_without_an_id_is_not_asked():
|
|
||||||
odds = Odds(held=False)
|
|
||||||
Host(odds=odds)._fetch_odds({"is_live": True})
|
|
||||||
settle()
|
|
||||||
assert odds.calls == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_one_batch_per_manager_and_every_dict_gets_the_answer():
|
|
||||||
odds = Odds()
|
|
||||||
host = Host(odds=odds)
|
|
||||||
first, again, other = game("g1"), game("g1"), game("g2")
|
|
||||||
with host._games_lock: # queued before the worker takes one
|
|
||||||
for g in (first, again, other): # update() rebuilds the dicts
|
|
||||||
host._fetch_odds(g)
|
|
||||||
odds.release()
|
|
||||||
settle()
|
|
||||||
assert odds.calls == [("g1", 3600), ("g2", 3600)]
|
|
||||||
assert odds.most_workers == 1
|
|
||||||
assert first["odds"] == again["odds"] == {"details": "g1 -3.5"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_thread_that_will_not_start_is_tried_again(monkeypatch):
|
|
||||||
class NoThread:
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def start(self):
|
|
||||||
raise RuntimeError("can't start new thread")
|
|
||||||
|
|
||||||
odds = Odds(held=False)
|
|
||||||
host = Host(odds=odds)
|
|
||||||
monkeypatch.setattr(sports_live_fetch.threading, "Thread", NoThread)
|
|
||||||
host._fetch_odds(game("g1"))
|
|
||||||
assert host._odds_worker_busy is False
|
|
||||||
monkeypatch.undo()
|
|
||||||
host._fetch_odds(game("g2"))
|
|
||||||
settle()
|
|
||||||
assert [c[0] for c in odds.calls] == ["g1", "g2"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_and_display_on_two_threads():
|
|
||||||
"""Two updaters queueing fresh dicts while a reader walks the games under
|
|
||||||
the lock, as display() does: no exception, one worker, one request at a
|
|
||||||
time, and every dict ends with its line."""
|
|
||||||
odds = Odds(held=False)
|
|
||||||
odds.get_odds = _slowed(odds.get_odds)
|
|
||||||
host, shown, raised = Host(odds=odds), [], []
|
|
||||||
|
|
||||||
def updater():
|
|
||||||
try:
|
|
||||||
for n in range(30):
|
|
||||||
g = game(f"g{n % 4}")
|
|
||||||
with host._games_lock:
|
|
||||||
shown.append(g)
|
|
||||||
host._fetch_odds(g)
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
raised.append(exc)
|
|
||||||
|
|
||||||
def display():
|
|
||||||
try:
|
|
||||||
for _ in range(300):
|
|
||||||
with host._games_lock:
|
|
||||||
[g.get("odds") for g in shown]
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
raised.append(exc)
|
|
||||||
|
|
||||||
threads = [threading.Thread(target=updater) for _ in range(2)]
|
|
||||||
threads.append(threading.Thread(target=display))
|
|
||||||
for t in threads:
|
|
||||||
t.start()
|
|
||||||
for t in threads:
|
|
||||||
t.join(30)
|
|
||||||
settle()
|
|
||||||
assert raised == []
|
|
||||||
assert odds.most_active == 1 and odds.most_workers == 1
|
|
||||||
assert all(g.get("odds") for g in shown)
|
|
||||||
|
|
||||||
|
|
||||||
def _slowed(get_odds):
|
|
||||||
def slow(**kwargs):
|
|
||||||
time.sleep(0.005)
|
|
||||||
return get_odds(**kwargs)
|
|
||||||
return slow
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Carrier and host contract
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
MEMBERS = ["_drain_odds_queue", "_fetch_odds", "_fetch_todays_games"]
|
|
||||||
|
|
||||||
|
|
||||||
def _self_reads():
|
|
||||||
tree = ast.parse(Path(sports_live_fetch.__file__).read_text(encoding="utf-8"))
|
|
||||||
reads = {node.attr for node in ast.walk(tree)
|
|
||||||
if isinstance(node, ast.Attribute) and isinstance(node.ctx, ast.Load)
|
|
||||||
and isinstance(node.value, ast.Name) and node.value.id == "self"}
|
|
||||||
gets = {node.args[1].value for node in ast.walk(tree)
|
|
||||||
if isinstance(node, ast.Call) and getattr(node.func, "id", None) == "getattr"
|
|
||||||
and len(node.args) >= 2 and isinstance(node.args[1], ast.Constant)}
|
|
||||||
return reads | gets
|
|
||||||
|
|
||||||
|
|
||||||
class TestHostContract:
|
|
||||||
def test_the_mixin_carries_only_the_family(self):
|
|
||||||
assert sorted(n for n in vars(SportsLiveFetchMixin) if not n.startswith("__")) == MEMBERS
|
|
||||||
|
|
||||||
def test_every_host_read_is_documented(self):
|
|
||||||
undocumented = sorted(n for n in _self_reads() - set(MEMBERS)
|
|
||||||
if f"``{n}``" not in sports_live_fetch.__doc__)
|
|
||||||
assert undocumented == [], f"read but not in the host contract: {undocumented}"
|
|
||||||
|
|
||||||
def test_no_other_shared_mixin_defines_these(self):
|
|
||||||
from src.common import (sports_display_rules, sports_favorites, sports_helpers,
|
|
||||||
sports_rankings, sports_rotation, sports_shared)
|
|
||||||
others = [sports_shared.SportsCoreSharedMixin, sports_shared.SportsRecentSharedMixin,
|
|
||||||
sports_shared.SportsLiveSharedMixin, sports_helpers.SportsHelpersMixin,
|
|
||||||
sports_favorites.SportsFavoritesMixin, sports_rotation.SportsRotationMixin,
|
|
||||||
sports_rankings.SportsRankingsMixin, SportsFetchMixin,
|
|
||||||
sports_display_rules.SportsGameRulesMixin]
|
|
||||||
assert [m for m in MEMBERS if any(m in vars(o) for o in others)] == []
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
"""sports_live_fetch still matches every plugin copy.
|
|
||||||
|
|
||||||
``src.common.sports_live_fetch`` was copied from the scoreboards once family 9
|
|
||||||
had made each method one body in all nine ``SportsCore`` classes:
|
|
||||||
``_fetch_todays_games``, ``_fetch_odds`` and ``_drain_odds_queue``. The plugins delete their copies once they floor on the release that
|
|
||||||
ships this module. Until each has, a copy that changes on its own is a fix one
|
|
||||||
side has and the other lacks.
|
|
||||||
|
|
||||||
Point LEDMATRIX_PLUGINS at a ledmatrix-plugins checkout and each method is
|
|
||||||
compared with every plugin copy using ``scripts/sports_drift_report.py``'s own
|
|
||||||
normalisation (the AST with docstrings and annotations dropped), plus the
|
|
||||||
decorators. A copy that is gone counts as adopted when the plugin's ``sports.py`` names the module.
|
|
||||||
Without the variable this skips: core CI has no plugins checkout.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import ast
|
|
||||||
import importlib.util
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.common import sports_live_fetch
|
|
||||||
|
|
||||||
REPO = Path(__file__).resolve().parents[1]
|
|
||||||
SPORTS = ("afl", "baseball", "basketball", "football", "hockey", "lacrosse",
|
|
||||||
"nrl", "soccer", "ufc")
|
|
||||||
CARRIER, MIXIN = "SportsCore", "SportsLiveFetchMixin"
|
|
||||||
METHODS = ("_fetch_todays_games", "_fetch_odds", "_drain_odds_queue")
|
|
||||||
|
|
||||||
|
|
||||||
def _drift_report():
|
|
||||||
"""scripts/sports_drift_report.py, loaded by path (scripts/ is no package)."""
|
|
||||||
spec = importlib.util.spec_from_file_location(
|
|
||||||
"sports_drift_report", REPO / "scripts" / "sports_drift_report.py")
|
|
||||||
module = importlib.util.module_from_spec(spec)
|
|
||||||
spec.loader.exec_module(module)
|
|
||||||
return module
|
|
||||||
|
|
||||||
|
|
||||||
DRIFT = _drift_report()
|
|
||||||
|
|
||||||
|
|
||||||
def _plugins_root():
|
|
||||||
root = DRIFT.resolve_plugins_dir(os.environ.get("LEDMATRIX_PLUGINS"))
|
|
||||||
if root is None:
|
|
||||||
pytest.skip("set LEDMATRIX_PLUGINS to a ledmatrix-plugins checkout to "
|
|
||||||
"compare this module against the plugin copies")
|
|
||||||
return root
|
|
||||||
|
|
||||||
|
|
||||||
def _class(tree, name):
|
|
||||||
return next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == name)
|
|
||||||
|
|
||||||
|
|
||||||
def _method(cls, name):
|
|
||||||
return next((n for n in cls.body
|
|
||||||
if isinstance(n, ast.FunctionDef) and n.name == name), None)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _fingerprint(node):
|
|
||||||
return (DRIFT._digest(node, DRIFT._Canonical()),
|
|
||||||
tuple(ast.unparse(d) for d in node.decorator_list))
|
|
||||||
|
|
||||||
|
|
||||||
def _plugin_tree(root, sport):
|
|
||||||
source = (root / f"{sport}-scoreboard" / "sports.py").read_text(encoding="utf-8")
|
|
||||||
return source, ast.parse(source)
|
|
||||||
|
|
||||||
|
|
||||||
OURS = _class(ast.parse(Path(sports_live_fetch.__file__).read_text(encoding="utf-8")), MIXIN)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("sport, name", [(s, n) for s in SPORTS for n in METHODS])
|
|
||||||
def test_every_remaining_plugin_copy_matches(sport, name):
|
|
||||||
source, tree = _plugin_tree(_plugins_root(), sport)
|
|
||||||
copy = _method(_class(tree, CARRIER), name)
|
|
||||||
if copy is None:
|
|
||||||
assert sports_live_fetch.__name__ in source, (
|
|
||||||
f"{sport}: no {name} on {CARRIER} and no {sports_live_fetch.__name__} import")
|
|
||||||
else:
|
|
||||||
assert _fingerprint(copy) == _fingerprint(_method(OURS, name)), (
|
|
||||||
f"{CARRIER}.{name} in {sport} differs from sports_live_fetch. "
|
|
||||||
f"Port the change to both, or stop treating it as shared.")
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("sport", SPORTS)
|
|
||||||
def test_no_other_plugin_class_carries_a_copy(sport):
|
|
||||||
"""A copy on another class would shadow the shared one for that class."""
|
|
||||||
_, tree = _plugin_tree(_plugins_root(), sport)
|
|
||||||
strays = [f"{node.name}.{name}" for node in tree.body if isinstance(node, ast.ClassDef)
|
|
||||||
for name in METHODS if node.name != CARRIER and _method(node, name) is not None]
|
|
||||||
assert strays == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_drift_report_calls_them_identical():
|
|
||||||
families = DRIFT.build(_plugins_root(), ("sports.py",))
|
|
||||||
rows = {(r["file"], r["family"]): r
|
|
||||||
for r in (DRIFT.summarise(k, v) for k, v in families.items())}
|
|
||||||
for name in METHODS:
|
|
||||||
row = rows.get(("sports.py", name))
|
|
||||||
assert row is None or row["worst_class_variants"] == 1, name
|
|
||||||
@@ -1,395 +0,0 @@
|
|||||||
"""src.common.sports_rankings: the poll, the tables, the season, the filter.
|
|
||||||
|
|
||||||
The cases follow ledmatrix-plugins' ``scripts/test_rankings.py`` (the tables
|
|
||||||
the family 8 reconcile was checked against); the expected values are its
|
|
||||||
columns, which agree across all nine scoreboards for everything this module
|
|
||||||
does. The host is the mixin over core's ``SportsCoreSharedMixin``
|
|
||||||
(``_league_has_rankings``, ``_game_divisions``) and ``SportsRotationMixin``
|
|
||||||
(``_rankings_loaded``), with a data source and an HTTP session that answer
|
|
||||||
from fixtures shaped like ESPN's payloads. Nothing reaches the network.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import ast
|
|
||||||
import logging
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.common import sports_rankings
|
|
||||||
from src.common.sports_favorites import SportsFavoritesMixin
|
|
||||||
from src.common.sports_helpers import SportsHelpersMixin
|
|
||||||
from src.common.sports_rankings import SportsRankingsMixin, choose_top_division_poll
|
|
||||||
from src.common.sports_rotation import SportsRotationMixin
|
|
||||||
from src.common.sports_shared import SportsCoreSharedMixin
|
|
||||||
|
|
||||||
LOG = logging.getLogger("test_sports_rankings")
|
|
||||||
CORE_API = "https://sports.core.api.espn.com/v2/sports/"
|
|
||||||
|
|
||||||
UGA, ND, OSU, TEX, VAN, SDSU = ("61", "UGA"), ("87", "ND"), ("194", "OSU"), \
|
|
||||||
("251", "TEX"), ("238", "VAN"), ("21", "SDSU")
|
|
||||||
SDST, NDSU, NEWARK, FRST, DEN = ("2571", "SDST"), ("2449", "NDSU"), \
|
|
||||||
("9194", "OSU"), ("9001", "FRST"), ("2172", "DEN")
|
|
||||||
FBS_IDS, FCS_IDS = (UGA, ND, OSU, TEX, VAN, SDSU), (SDST, NDSU)
|
|
||||||
|
|
||||||
|
|
||||||
def poll(name, kind, *teams, **extra):
|
|
||||||
return {"name": name, "shortName": name, "type": kind, **extra, "ranks": [
|
|
||||||
{"current": n, "previous": n, "team": {"id": tid, "abbreviation": abbr}}
|
|
||||||
for n, (tid, abbr) in enumerate(teams, 1)]}
|
|
||||||
|
|
||||||
|
|
||||||
AP = poll("AP Top 25", "ap", UGA, ND, OSU, SDSU)
|
|
||||||
COACHES = poll("AFCA Coaches Poll", "usa", ND, UGA, TEX)
|
|
||||||
CFP = poll("College Football Playoff Rankings", "cfp", OSU, UGA)
|
|
||||||
FCS = poll("FCS Coaches Poll", "fcs", NDSU, SDST)
|
|
||||||
D2 = poll("AFCA Division II Coaches Poll", "afca", FRST)
|
|
||||||
SEEDS = poll("NCAA Men's Hockey Tournament Seedings", "tournament", DEN)
|
|
||||||
USCHO = poll("USCHO Men's Poll", "USCHOMENSPOLL", DEN, ND)
|
|
||||||
AP_TABLE = {"ND": 2, "OSU": 3, "SDSU": 4, "UGA": 1}
|
|
||||||
AP_IDS = {194: 3, 21: 4, 61: 1, 87: 2}
|
|
||||||
|
|
||||||
|
|
||||||
def at(text):
|
|
||||||
return datetime.fromisoformat(text).replace(tzinfo=timezone.utc).timestamp()
|
|
||||||
|
|
||||||
|
|
||||||
class Clock:
|
|
||||||
def __init__(self):
|
|
||||||
self.now = 100_000.0
|
|
||||||
|
|
||||||
def __call__(self):
|
|
||||||
return self.now
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def clock(monkeypatch):
|
|
||||||
c = Clock()
|
|
||||||
monkeypatch.setattr(sports_rankings.time, "time", c)
|
|
||||||
monkeypatch.setattr(sports_rankings.time, "monotonic", c)
|
|
||||||
return c
|
|
||||||
|
|
||||||
|
|
||||||
class Rosters:
|
|
||||||
"""The core API's division rosters, published up to ``last`` season."""
|
|
||||||
|
|
||||||
def __init__(self, last=2026):
|
|
||||||
self.last, self.urls = last, []
|
|
||||||
|
|
||||||
def get(self, url, params=None, timeout=None):
|
|
||||||
self.urls.append(url[len(CORE_API):])
|
|
||||||
season, group = url.split("/seasons/")[1].split("/")[0], url.split("/groups/")[1][:2]
|
|
||||||
ids = {"80": FBS_IDS, "81": FCS_IDS}[group] if int(season) <= self.last else ()
|
|
||||||
resp = MagicMock()
|
|
||||||
resp.json.return_value = {"items": [{"$ref": f"{CORE_API}x/teams/{t}?lang=en"}
|
|
||||||
for t, _ in ids]}
|
|
||||||
return resp
|
|
||||||
|
|
||||||
|
|
||||||
class Host(SportsRankingsMixin, SportsRotationMixin, SportsCoreSharedMixin,
|
|
||||||
SportsFavoritesMixin, SportsHelpersMixin):
|
|
||||||
"""A scoreboard SportsCore as far as the rankings reach."""
|
|
||||||
|
|
||||||
sport = "football"
|
|
||||||
|
|
||||||
def __init__(self, league="college-football", payload=None, quality="any",
|
|
||||||
divisions=(), rosters=None):
|
|
||||||
self.logger = LOG
|
|
||||||
self.league = league
|
|
||||||
self.data_source = MagicMock()
|
|
||||||
self.data_source.fetch_standings.return_value = (
|
|
||||||
{"rankings": [AP, COACHES]} if payload is None else payload)
|
|
||||||
self.session = rosters or Rosters()
|
|
||||||
self.cache_manager = MagicMock()
|
|
||||||
self.cache_manager.get.return_value = None
|
|
||||||
self._team_rankings_cache, self._ranked_team_ids = {}, {}
|
|
||||||
self._rankings_cache_timestamp, self._rankings_cache_duration = 0, 3600
|
|
||||||
self._division_team_ids, self._division_loaded_at = None, 0.0
|
|
||||||
self.other_games_min_quality = quality
|
|
||||||
self.other_games_divisions = list(divisions)
|
|
||||||
|
|
||||||
|
|
||||||
def tables(host):
|
|
||||||
return host._team_rankings_cache, host._ranked_team_ids
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Which poll
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
POLL_SETS = [
|
|
||||||
([AP, COACHES], "AP Top 25"),
|
|
||||||
([COACHES, AP], "AFCA Coaches Poll"),
|
|
||||||
([CFP, AP, COACHES], "College Football Playoff Rankings"),
|
|
||||||
([FCS, D2, AP, COACHES], "AP Top 25"),
|
|
||||||
([FCS, D2], None),
|
|
||||||
([poll("Coaches Poll", "fcs", NDSU), AP], "AP Top 25"), # by type alone
|
|
||||||
([SEEDS, USCHO], "USCHO Men's Poll"),
|
|
||||||
([], None),
|
|
||||||
(None, None),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("blocks, expected", POLL_SETS)
|
|
||||||
def test_the_poll_chosen(blocks, expected):
|
|
||||||
assert Host()._choose_poll(blocks).get("name") == expected
|
|
||||||
assert choose_top_division_poll(blocks).get("name") == expected
|
|
||||||
|
|
||||||
|
|
||||||
def test_an_untyped_unnamed_block_counts():
|
|
||||||
"""An exclude list: a block ESPN invents is a poll unless it says otherwise."""
|
|
||||||
assert choose_top_division_poll([{"ranks": []}]) == {"ranks": []}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# The tables
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("league, asked", [
|
|
||||||
("college-football", True), ("mens-college-basketball", True),
|
|
||||||
("usa.ncaa.m.1", True), ("nfl", False), ("nba", False), ("eng.1", False)])
|
|
||||||
def test_only_a_league_with_a_poll_is_asked(clock, league, asked):
|
|
||||||
host = Host(league)
|
|
||||||
host._fetch_team_rankings()
|
|
||||||
assert host.data_source.fetch_standings.called is asked
|
|
||||||
assert tables(host) == ((AP_TABLE, AP_IDS) if asked else ({}, {}))
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("payload, abbr, ids", [
|
|
||||||
({}, {}, {}),
|
|
||||||
({"rankings": []}, {}, {}),
|
|
||||||
({"rankings": None}, {}, {}),
|
|
||||||
({"rankings": [{"name": "AP Top 25", "type": "ap"}]}, {}, {}),
|
|
||||||
({"rankings": [{"name": "AP Top 25", "type": "ap", "ranks": [
|
|
||||||
{"current": 0, "team": {"id": "61", "abbreviation": "UGA"}},
|
|
||||||
{"current": -1, "team": {"id": "87", "abbreviation": "ND"}},
|
|
||||||
{"team": {"id": "194", "abbreviation": "OSU"}},
|
|
||||||
{"current": 4, "team": {"id": "251", "abbreviation": "TEX"}}]}]},
|
|
||||||
{"TEX": 4}, {251: 4}),
|
|
||||||
({"rankings": [{"name": "AP Top 25", "type": "ap", "ranks": [
|
|
||||||
{"current": 1},
|
|
||||||
{"current": 2, "team": {"id": "87"}},
|
|
||||||
{"current": 3, "team": {"id": "abc", "abbreviation": "OSU"}},
|
|
||||||
{"current": 4, "team": {"abbreviation": "TEX"}}]}]},
|
|
||||||
{"OSU": 3, "TEX": 4}, {87: 2}),
|
|
||||||
({"rankings": [{"name": "AP Top 25", "type": "ap", "ranks": [ # #789: one bad entry
|
|
||||||
{"current": None, "team": {"id": "61", "abbreviation": "UGA"}},
|
|
||||||
{"current": "n/a", "team": {"id": "87", "abbreviation": "ND"}},
|
|
||||||
{"current": 3, "team": {"id": "194", "abbreviation": "OSU"}}]}]},
|
|
||||||
{"OSU": 3}, {194: 3}),
|
|
||||||
])
|
|
||||||
def test_payload_shapes(clock, payload, abbr, ids):
|
|
||||||
host = Host(payload=payload)
|
|
||||||
host._fetch_team_rankings()
|
|
||||||
assert tables(host) == (abbr, ids)
|
|
||||||
assert host._rankings_cache_timestamp == clock.now # an empty answer is cached too
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_failed_fetch_is_not_cached(clock):
|
|
||||||
host = Host(payload={"rankings": {"name": "AP Top 25"}}) # not a list
|
|
||||||
assert host._fetch_team_rankings() == {}
|
|
||||||
assert host._rankings_cache_timestamp == 0
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("payload", [{"rankings": [AP, COACHES]}, {"rankings": []}])
|
|
||||||
def test_rankings_are_fetched_hourly(clock, payload):
|
|
||||||
host = Host(payload=payload)
|
|
||||||
calls = []
|
|
||||||
for step in (0, 60, 3599, 3601):
|
|
||||||
clock.now = 100_000.0 + step
|
|
||||||
before = host.data_source.fetch_standings.call_count
|
|
||||||
host._fetch_team_rankings()
|
|
||||||
calls.append(host.data_source.fetch_standings.call_count - before)
|
|
||||||
assert calls == [1, 0, 0, 1]
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# A poll from a season that has ended
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("end, now, kept", [
|
|
||||||
("2026-04-08T06:59Z", "2026-10-12T12:00", False), # college basketball in October
|
|
||||||
("2026-04-08T06:59Z", "2026-04-08T06:58", True),
|
|
||||||
("2026-04-08T06:59Z", "2026-04-08T06:59", True), # at endDate: not yet over
|
|
||||||
("2026-04-08T06:59Z", "2026-04-08T07:00", False),
|
|
||||||
("2027-01-28T07:59Z", "2027-01-10T12:00", True), # bowl week
|
|
||||||
("2027-04-08T06:59Z", "2026-11-03T12:00", True), # the new season's first poll
|
|
||||||
("2026-04-08T06:59:00Z", "2026-10-12T12:00", False),
|
|
||||||
("April", "2026-10-12T12:00", True), # unreadable: current
|
|
||||||
(None, "2026-10-12T12:00", True), # no season block
|
|
||||||
])
|
|
||||||
def test_a_finished_seasons_poll_loads_nothing(clock, end, now, kept):
|
|
||||||
block = dict(AP, season={"year": 2026, "endDate": end}) if end else AP
|
|
||||||
host = Host(payload={"rankings": [block, COACHES]})
|
|
||||||
clock.now = at(now)
|
|
||||||
host._fetch_team_rankings()
|
|
||||||
assert tables(host) == ((AP_TABLE, AP_IDS) if kept else ({}, {}))
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Division rosters
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class Frozen(datetime):
|
|
||||||
at = datetime(2026, 10, 12, 12)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def now(cls, tz=None):
|
|
||||||
return cls.at
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("today, season, sizes", [
|
|
||||||
("2026-01-15", 2025, (6, 2)), ("2026-06-30", 2025, (6, 2)),
|
|
||||||
("2026-07-01", 2026, (6, 2)), ("2026-10-12", 2026, (6, 2)),
|
|
||||||
("2026-12-31", 2026, (6, 2)), ("2027-01-15", 2026, (6, 2))])
|
|
||||||
def test_the_rosters_are_for_the_season_year(clock, monkeypatch, today, season, sizes):
|
|
||||||
monkeypatch.setattr(sports_rankings, "datetime", Frozen)
|
|
||||||
Frozen.at = datetime.fromisoformat(today)
|
|
||||||
host = Host()
|
|
||||||
rosters = host._load_division_team_ids()
|
|
||||||
assert host.session.urls == [
|
|
||||||
f"football/leagues/college-football/seasons/{season}/types/2/groups/{g}/teams"
|
|
||||||
for g in (80, 81)]
|
|
||||||
assert (len(rosters["fbs"]), len(rosters["fcs"])) == sizes
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_rosters_outside_college_football(clock):
|
|
||||||
host = Host("mens-college-basketball")
|
|
||||||
assert host._load_division_team_ids() == {}
|
|
||||||
assert host.session.urls == []
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("last, cached, calls, sizes", [
|
|
||||||
(2026, None, [2, 0, 0, 2], (6, 2)), # a day
|
|
||||||
(0, None, [2, 0, 2, 2], (0, 0)), # empty: retried after 10 minutes
|
|
||||||
(2026, "cache", [0, 0, 0, 0], (2, 1)), # the cache manager's copy
|
|
||||||
])
|
|
||||||
def test_roster_caching(clock, last, cached, calls, sizes):
|
|
||||||
host = Host(rosters=Rosters(last))
|
|
||||||
if cached:
|
|
||||||
host.cache_manager.get.side_effect = lambda key, *a, **k: (
|
|
||||||
["61", "87"] if key.endswith("_80") else ["2571"])
|
|
||||||
made = []
|
|
||||||
for step in (0, 60, 601, 86_401):
|
|
||||||
clock.now = 100_000.0 + step
|
|
||||||
before = len(host.session.urls)
|
|
||||||
host._load_division_team_ids()
|
|
||||||
made.append(len(host.session.urls) - before)
|
|
||||||
rosters = host._division_team_ids
|
|
||||||
assert made == calls
|
|
||||||
assert (len(rosters["fbs"]), len(rosters["fcs"])) == sizes
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Ranked games and the filter
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
GAMES = {
|
|
||||||
"UGA v TEX": (UGA, TEX),
|
|
||||||
"TEX v VAN": (TEX, VAN),
|
|
||||||
"OSU v VAN": (OSU, VAN),
|
|
||||||
"Newark 'OSU' v VAN": (NEWARK, VAN),
|
|
||||||
"SDSU v SDST": (SDSU, SDST),
|
|
||||||
"SDST v NDSU": (SDST, NDSU),
|
|
||||||
"'OSU' v VAN, no ids": ((None, "OSU"), (None, "VAN")),
|
|
||||||
"61 v 238, no abbrs": (("61", None), ("238", None)),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def game(label):
|
|
||||||
g = {"id": "g"}
|
|
||||||
for side, (tid, abbr) in zip(("home", "away"), GAMES[label]):
|
|
||||||
if tid is not None:
|
|
||||||
g[f"{side}_id"] = tid
|
|
||||||
if abbr is not None:
|
|
||||||
g[f"{side}_abbr"] = abbr
|
|
||||||
return g
|
|
||||||
|
|
||||||
|
|
||||||
def loaded(**kwargs):
|
|
||||||
host = Host(**kwargs)
|
|
||||||
host._fetch_team_rankings()
|
|
||||||
return host
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("label, best", [
|
|
||||||
("UGA v TEX", 1), ("TEX v VAN", 99), ("OSU v VAN", 3),
|
|
||||||
("Newark 'OSU' v VAN", 99), # same abbreviation, another school: by id
|
|
||||||
("SDSU v SDST", 4), ("SDST v NDSU", 99),
|
|
||||||
("'OSU' v VAN, no ids", 3), # no ids: by abbreviation
|
|
||||||
("61 v 238, no abbrs", 1)])
|
|
||||||
def test_ranked_and_best_rank(clock, label, best):
|
|
||||||
host = loaded()
|
|
||||||
assert host._best_rank(game(label)) == best
|
|
||||||
assert host._is_ranked_game(game(label)) is (best != 99)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("home, away, best", [
|
|
||||||
(NEWARK, (None, "VAN"), 99), # Newark's id is unranked; "OSU" must not rank it
|
|
||||||
(("61", None), (None, "VAN"), 1), # UGA's id still counts with no id on the other side
|
|
||||||
])
|
|
||||||
def test_each_side_is_ranked_by_its_own_id(clock, home, away, best):
|
|
||||||
"""#789: one side's missing id no longer sends both sides to abbreviations."""
|
|
||||||
g = {f"{side}_{key}": value for side, team in (("home", home), ("away", away))
|
|
||||||
for key, value in zip(("id", "abbr"), team) if value is not None}
|
|
||||||
assert loaded()._best_rank(g) == best
|
|
||||||
|
|
||||||
|
|
||||||
#: filter -> (quality, divisions, poll loaded?, kept: one Y/. per GAMES entry)
|
|
||||||
FILTERS = {
|
|
||||||
"any": ("any", (), True, "YYYYYYYY"),
|
|
||||||
"ranked": ("ranked", (), True, "Y.Y.Y.YY"),
|
|
||||||
"ranked, no poll loaded": ("ranked", (), False, "YYYYYYYY"),
|
|
||||||
"any, fbs": ("any", ("fbs",), True, "YYYYY.YY"),
|
|
||||||
"ranked, fbs": ("ranked", ("fbs",), True, "Y.Y.Y.YY"),
|
|
||||||
"any, fcs": ("any", ("fcs",), True, "....YYY."),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("name", FILTERS)
|
|
||||||
def test_passes_other_filters(clock, monkeypatch, name):
|
|
||||||
quality, divisions, with_poll, kept = FILTERS[name]
|
|
||||||
monkeypatch.setattr(sports_rankings, "datetime", Frozen)
|
|
||||||
Frozen.at = datetime(2026, 10, 12, 12)
|
|
||||||
host = loaded(payload={"rankings": [AP, COACHES] if with_poll else []},
|
|
||||||
quality=quality, divisions=divisions)
|
|
||||||
assert "".join("Y" if host._passes_other_filters(game(g)) else "." for g in GAMES) == kept
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Carrier and host contract
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
MEMBERS = ["_DIVISION_CACHE_TTL", "_DIVISION_GROUPS_BY_LEAGUE", "_DIVISION_RETRY_SECONDS",
|
|
||||||
"_NON_TOP_POLL_NAMES", "_NON_TOP_POLL_TYPES", "_best_rank", "_choose_poll",
|
|
||||||
"_fetch_team_rankings", "_is_ranked_game", "_load_division_team_ids",
|
|
||||||
"_passes_other_filters", "_poll_season_ended", "_ranked_positions"]
|
|
||||||
|
|
||||||
|
|
||||||
def _self_reads():
|
|
||||||
tree = ast.parse(Path(sports_rankings.__file__).read_text(encoding="utf-8"))
|
|
||||||
return {node.attr for node in ast.walk(tree)
|
|
||||||
if isinstance(node, ast.Attribute) and isinstance(node.ctx, ast.Load)
|
|
||||||
and isinstance(node.value, ast.Name) and node.value.id == "self"}
|
|
||||||
|
|
||||||
|
|
||||||
class TestHostContract:
|
|
||||||
def test_the_mixin_carries_only_the_family(self):
|
|
||||||
"""Every scoreboard's SportsCore has all of them, so adopting it adds none."""
|
|
||||||
assert sorted(n for n in vars(SportsRankingsMixin)
|
|
||||||
if not n.startswith("__")) == MEMBERS
|
|
||||||
|
|
||||||
def test_every_host_read_is_documented(self):
|
|
||||||
own = set(MEMBERS)
|
|
||||||
undocumented = sorted(n for n in _self_reads() - own
|
|
||||||
if f"``{n}``" not in sports_rankings.__doc__)
|
|
||||||
assert undocumented == [], f"read but not in the host contract: {undocumented}"
|
|
||||||
|
|
||||||
def test_no_other_shared_mixin_defines_these(self):
|
|
||||||
from src.common import sports_display_rules, sports_shared
|
|
||||||
others = [sports_shared.SportsCoreSharedMixin, sports_shared.SportsRecentSharedMixin,
|
|
||||||
sports_shared.SportsLiveSharedMixin, SportsHelpersMixin,
|
|
||||||
SportsFavoritesMixin, SportsRotationMixin,
|
|
||||||
sports_display_rules.SportsGameRulesMixin]
|
|
||||||
assert [m for m in MEMBERS if any(m in vars(o) for o in others)] == []
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
"""sports_rankings still matches every plugin copy.
|
|
||||||
|
|
||||||
``src.common.sports_rankings`` was copied from the scoreboards once family 8
|
|
||||||
had made each method one body in all nine ``SportsCore`` classes:
|
|
||||||
``_choose_poll``, ``_fetch_team_rankings``, ``_poll_season_ended``,
|
|
||||||
``_load_division_team_ids``, ``_ranked_positions``, ``_is_ranked_game``,
|
|
||||||
``_best_rank`` and ``_passes_other_filters``, with the class constants they
|
|
||||||
read. The plugins delete their copies once they floor on the release that
|
|
||||||
ships this module. Until each has, a copy that changes on its own is a fix one
|
|
||||||
side has and the other lacks.
|
|
||||||
|
|
||||||
Point LEDMATRIX_PLUGINS at a ledmatrix-plugins checkout and each method is
|
|
||||||
compared with every plugin copy using ``scripts/sports_drift_report.py``'s own
|
|
||||||
normalisation (the AST with docstrings and annotations dropped), plus the
|
|
||||||
decorators, and each constant a plugin still defines by its expression. A copy that is
|
|
||||||
gone counts as adopted when the plugin's ``sports.py`` names the module.
|
|
||||||
Without the variable this skips: core CI has no plugins checkout.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import ast
|
|
||||||
import importlib.util
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.common import sports_rankings
|
|
||||||
|
|
||||||
REPO = Path(__file__).resolve().parents[1]
|
|
||||||
SPORTS = ("afl", "baseball", "basketball", "football", "hockey", "lacrosse",
|
|
||||||
"nrl", "soccer", "ufc")
|
|
||||||
CARRIER, MIXIN = "SportsCore", "SportsRankingsMixin"
|
|
||||||
METHODS = ("_choose_poll", "_fetch_team_rankings", "_poll_season_ended",
|
|
||||||
"_load_division_team_ids", "_ranked_positions", "_is_ranked_game",
|
|
||||||
"_best_rank", "_passes_other_filters")
|
|
||||||
CONSTANTS = ("_NON_TOP_POLL_TYPES", "_NON_TOP_POLL_NAMES", "_DIVISION_GROUPS_BY_LEAGUE",
|
|
||||||
"_DIVISION_CACHE_TTL", "_DIVISION_RETRY_SECONDS")
|
|
||||||
|
|
||||||
|
|
||||||
def _drift_report():
|
|
||||||
"""scripts/sports_drift_report.py, loaded by path (scripts/ is no package)."""
|
|
||||||
spec = importlib.util.spec_from_file_location(
|
|
||||||
"sports_drift_report", REPO / "scripts" / "sports_drift_report.py")
|
|
||||||
module = importlib.util.module_from_spec(spec)
|
|
||||||
spec.loader.exec_module(module)
|
|
||||||
return module
|
|
||||||
|
|
||||||
|
|
||||||
DRIFT = _drift_report()
|
|
||||||
|
|
||||||
|
|
||||||
def _plugins_root():
|
|
||||||
root = DRIFT.resolve_plugins_dir(os.environ.get("LEDMATRIX_PLUGINS"))
|
|
||||||
if root is None:
|
|
||||||
pytest.skip("set LEDMATRIX_PLUGINS to a ledmatrix-plugins checkout to "
|
|
||||||
"compare this module against the plugin copies")
|
|
||||||
return root
|
|
||||||
|
|
||||||
|
|
||||||
def _class(tree, name):
|
|
||||||
return next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == name)
|
|
||||||
|
|
||||||
|
|
||||||
def _method(cls, name):
|
|
||||||
return next((n for n in cls.body
|
|
||||||
if isinstance(n, ast.FunctionDef) and n.name == name), None)
|
|
||||||
|
|
||||||
|
|
||||||
def _constant(cls, name):
|
|
||||||
"""The value a class body assigns to ``name`` (plain or annotated), or None."""
|
|
||||||
for node in cls.body:
|
|
||||||
if isinstance(node, ast.Assign) and any(
|
|
||||||
isinstance(t, ast.Name) and t.id == name for t in node.targets):
|
|
||||||
return node.value
|
|
||||||
if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name) \
|
|
||||||
and node.target.id == name:
|
|
||||||
return node.value
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _fingerprint(node):
|
|
||||||
return (DRIFT._digest(node, DRIFT._Canonical()),
|
|
||||||
tuple(ast.unparse(d) for d in node.decorator_list))
|
|
||||||
|
|
||||||
|
|
||||||
def _plugin_tree(root, sport):
|
|
||||||
source = (root / f"{sport}-scoreboard" / "sports.py").read_text(encoding="utf-8")
|
|
||||||
return source, ast.parse(source)
|
|
||||||
|
|
||||||
|
|
||||||
OURS = _class(ast.parse(Path(sports_rankings.__file__).read_text(encoding="utf-8")), MIXIN)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("sport, name", [(s, n) for s in SPORTS for n in METHODS])
|
|
||||||
def test_every_remaining_plugin_copy_matches(sport, name):
|
|
||||||
source, tree = _plugin_tree(_plugins_root(), sport)
|
|
||||||
copy = _method(_class(tree, CARRIER), name)
|
|
||||||
if copy is None:
|
|
||||||
assert sports_rankings.__name__ in source, (
|
|
||||||
f"{sport}: no {name} on {CARRIER} and no {sports_rankings.__name__} import")
|
|
||||||
else:
|
|
||||||
assert _fingerprint(copy) == _fingerprint(_method(OURS, name)), (
|
|
||||||
f"{CARRIER}.{name} in {sport} differs from sports_rankings. "
|
|
||||||
f"Port the change to both, or stop treating it as shared.")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("sport", SPORTS)
|
|
||||||
def test_the_plugin_constants_match(sport):
|
|
||||||
_, tree = _plugin_tree(_plugins_root(), sport)
|
|
||||||
core = _class(tree, CARRIER)
|
|
||||||
for name in CONSTANTS:
|
|
||||||
value = _constant(core, name)
|
|
||||||
if value is not None:
|
|
||||||
assert ast.dump(value) == ast.dump(_constant(OURS, name)), f"{sport}: {name}"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("sport", SPORTS)
|
|
||||||
def test_no_other_plugin_class_carries_a_copy(sport):
|
|
||||||
"""A copy on another class would shadow the shared one for that class."""
|
|
||||||
_, tree = _plugin_tree(_plugins_root(), sport)
|
|
||||||
strays = [f"{node.name}.{name}" for node in tree.body if isinstance(node, ast.ClassDef)
|
|
||||||
for name in METHODS if node.name != CARRIER and _method(node, name) is not None]
|
|
||||||
assert strays == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_drift_report_calls_them_identical():
|
|
||||||
families = DRIFT.build(_plugins_root(), ("sports.py",))
|
|
||||||
rows = {(r["file"], r["family"]): r
|
|
||||||
for r in (DRIFT.summarise(k, v) for k, v in families.items())}
|
|
||||||
for name in METHODS:
|
|
||||||
row = rows.get(("sports.py", name))
|
|
||||||
assert row is None or row["worst_class_variants"] == 1, name
|
|
||||||
@@ -1,439 +0,0 @@
|
|||||||
"""src.common.sports_rotation: behaviour, the lock, the seam, host contract.
|
|
||||||
|
|
||||||
The cases follow ledmatrix-plugins' ``scripts/test_other_games_rotation.py``
|
|
||||||
(the tables the family 7 reconcile was checked against): the expected values
|
|
||||||
are its columns, for the abbreviation-ranked sports and, where the
|
|
||||||
``_rankings_loaded`` seam is overridden, for football. The host is the mixin
|
|
||||||
over core's ``SportsCoreSharedMixin`` (``_favorites_first`` and
|
|
||||||
``_compose_selection`` call into it) with the family-8 ranking reads copied in,
|
|
||||||
and ``_favorites_first`` stands in for the plugins' ``update()``.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import ast
|
|
||||||
import logging
|
|
||||||
import threading
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.common import sports_rotation
|
|
||||||
from src.common.sports_favorites import SportsFavoritesMixin
|
|
||||||
from src.common.sports_helpers import SportsHelpersMixin
|
|
||||||
from src.common.sports_rotation import SportsRotationMixin
|
|
||||||
from src.common.sports_shared import SportsCoreSharedMixin
|
|
||||||
|
|
||||||
LOG = logging.getLogger("test_sports_rotation")
|
|
||||||
NOW = datetime(2026, 10, 9, 12, tzinfo=timezone.utc)
|
|
||||||
T0 = 100_000.0 # not 0: the window reads a zero stamp as "never cut"
|
|
||||||
|
|
||||||
TEAM = {str(n): chr(64 + n) * 3 for n in range(1, 17)} # 1 AAA ... 16 PPP
|
|
||||||
ID_OF = {abbr: int(tid) for tid, abbr in TEAM.items()}
|
|
||||||
|
|
||||||
|
|
||||||
def game(gid, home, away, hours, recent=False):
|
|
||||||
g = {"id": gid, "home_id": home, "away_id": away,
|
|
||||||
"home_abbr": TEAM[home], "away_abbr": TEAM[away]}
|
|
||||||
if hours is not None:
|
|
||||||
g["start_time_utc"] = NOW + timedelta(hours=-hours if recent else hours)
|
|
||||||
return g
|
|
||||||
|
|
||||||
|
|
||||||
class Host(SportsRotationMixin, SportsCoreSharedMixin, SportsFavoritesMixin,
|
|
||||||
SportsHelpersMixin):
|
|
||||||
"""A scoreboard SportsCore as far as the rotation reaches."""
|
|
||||||
|
|
||||||
league = sport = "test"
|
|
||||||
sport_key = "test"
|
|
||||||
|
|
||||||
def __init__(self, favorites=(), interval=60, quality="any", rankings=None):
|
|
||||||
self.logger = LOG
|
|
||||||
self.favorite_teams = list(favorites)
|
|
||||||
self.other_rotation_interval_seconds = interval
|
|
||||||
self.other_games_min_quality = quality
|
|
||||||
self.other_games_divisions = []
|
|
||||||
self._team_rankings_cache = dict(rankings or {})
|
|
||||||
self._ranking_coverage_logged_at = 0.0
|
|
||||||
self._other_window_start = 0
|
|
||||||
self._other_window_rotated_at = 0.0
|
|
||||||
self._games_lock = threading.RLock()
|
|
||||||
self.games_list, self.current_game, self.current_game_index = [], None, 0
|
|
||||||
self.last_game_switch = 0.0
|
|
||||||
self.mode_config = {}
|
|
||||||
|
|
||||||
# Family 8, still per plugin: the abbreviation sports' bodies.
|
|
||||||
def _is_ranked_game(self, game):
|
|
||||||
rankings = self._team_rankings_cache
|
|
||||||
return bool(rankings.get(game.get("home_abbr"), 0)
|
|
||||||
or rankings.get(game.get("away_abbr"), 0))
|
|
||||||
|
|
||||||
def _best_rank(self, game):
|
|
||||||
rankings = self._team_rankings_cache
|
|
||||||
ranked = [r for r in (rankings.get(game.get("home_abbr"), 0),
|
|
||||||
rankings.get(game.get("away_abbr"), 0)) if r]
|
|
||||||
return min(ranked) if ranked else 99
|
|
||||||
|
|
||||||
def _passes_other_filters(self, game):
|
|
||||||
return not (self.other_games_min_quality == "ranked"
|
|
||||||
and self._team_rankings_cache and not self._is_ranked_game(game))
|
|
||||||
|
|
||||||
|
|
||||||
class IdRankedHost(Host):
|
|
||||||
"""football's override of the seam, and its by-id first rank read."""
|
|
||||||
|
|
||||||
def __init__(self, ranked_ids=None, **kw):
|
|
||||||
super().__init__(**kw)
|
|
||||||
self._ranked_team_ids = dict(ranked_ids or {})
|
|
||||||
|
|
||||||
def _rankings_loaded(self):
|
|
||||||
return bool(self._ranked_team_ids or self._team_rankings_cache)
|
|
||||||
|
|
||||||
def _best_rank(self, game):
|
|
||||||
try:
|
|
||||||
ids = [int(game["home_id"]), int(game["away_id"])]
|
|
||||||
except (KeyError, TypeError, ValueError):
|
|
||||||
ids = []
|
|
||||||
if self._ranked_team_ids and ids:
|
|
||||||
found = [r for r in (self._ranked_team_ids.get(i, 0) for i in ids) if r]
|
|
||||||
return min(found) if found else 99
|
|
||||||
return super()._best_rank(game)
|
|
||||||
|
|
||||||
|
|
||||||
class Clock:
|
|
||||||
def __init__(self):
|
|
||||||
self.now = T0
|
|
||||||
|
|
||||||
def __call__(self):
|
|
||||||
return self.now
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def clock(monkeypatch):
|
|
||||||
c = Clock()
|
|
||||||
monkeypatch.setattr(sports_rotation.time, "monotonic", c)
|
|
||||||
monkeypatch.setattr(sports_rotation.time, "time", c)
|
|
||||||
return c
|
|
||||||
|
|
||||||
|
|
||||||
def ids(games):
|
|
||||||
return ",".join(g["id"] for g in games) or "none"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# _by_importance and the _rankings_loaded seam
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def importance_slate():
|
|
||||||
no_abbr = game("n9", "15", "16", 9)
|
|
||||||
del no_abbr["home_abbr"], no_abbr["away_abbr"]
|
|
||||||
return [game("i3", "6", "7", 3), game("i1", "1", "3", 1), game("i2", "4", "5", 2),
|
|
||||||
game("i4", "8", "9", 4), game("i5", "2", "10", 5), game("i6", "11", "12", 6),
|
|
||||||
game("i7", "4", "11", 7), game("i8", "13", "14", None), no_abbr,
|
|
||||||
game("i10", "2", "15", 10)]
|
|
||||||
|
|
||||||
|
|
||||||
RANKED = {"HHH": 1, "FFF": 2, "DDD": 3, "BBB": 4}
|
|
||||||
TIED = {"HHH": 1, "FFF": 2, "DDD": 2, "BBB": 4}
|
|
||||||
AS_LISTED = "i3,i1,i2,i4,i5,i6,i7,i8,n9,i10"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("rankings, newest, expected", [
|
|
||||||
({}, False, AS_LISTED),
|
|
||||||
({}, True, AS_LISTED),
|
|
||||||
(RANKED, False, "i4,i3,i2,i5,i1,i6,n9,i8"),
|
|
||||||
(RANKED, True, "i4,i3,i7,i10,n9,i1,i8"),
|
|
||||||
(TIED, False, "i4,i2,i3,i5,i1,i6,n9,i8"), # a tie keeps kickoff order
|
|
||||||
(TIED, True, "i4,i7,i3,i10,n9,i1,i8"),
|
|
||||||
])
|
|
||||||
def test_by_importance(rankings, newest, expected):
|
|
||||||
assert ids(Host(rankings=rankings)._by_importance(importance_slate(), newest)) == expected
|
|
||||||
|
|
||||||
|
|
||||||
BY_ID = {ID_OF[a]: r for a, r in RANKED.items()}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("abbr, by_id, newest, default, overridden", [
|
|
||||||
({}, BY_ID, False, AS_LISTED, "i4,i3,i2,i5,i1,i6,n9,i8"),
|
|
||||||
({}, BY_ID, True, AS_LISTED, "i4,i3,i7,i10,n9,i1,i8"),
|
|
||||||
({"HHH": 1, "FFF": 2}, {ID_OF["CCC"]: 1}, False,
|
|
||||||
"i4,i3,i1,i2,i5,i6,n9,i8", "i1,i2,i3,i4,i5,i6,n9,i8"),
|
|
||||||
({"HHH": 1, "FFF": 2}, {ID_OF["CCC"]: 1}, True,
|
|
||||||
"i4,i3,i10,n9,i7,i1,i8", "i1,i10,n9,i7,i4,i3,i8"),
|
|
||||||
])
|
|
||||||
def test_the_rankings_loaded_seam(abbr, by_id, newest, default, overridden):
|
|
||||||
"""The default asks the abbreviation table; football counts its id table too."""
|
|
||||||
plain = Host(rankings=abbr)
|
|
||||||
plain._ranked_team_ids = by_id # read by nothing in the default
|
|
||||||
assert ids(plain._by_importance(importance_slate(), newest)) == default
|
|
||||||
football = IdRankedHost(ranked_ids=by_id, rankings=abbr)
|
|
||||||
assert ids(football._by_importance(importance_slate(), newest)) == overridden
|
|
||||||
|
|
||||||
|
|
||||||
def test_rankings_loaded_default():
|
|
||||||
assert Host()._rankings_loaded() is False
|
|
||||||
assert Host(rankings={"AAA": 1})._rankings_loaded() is True
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# _other_games_window
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("size, limit, interval, expected", [
|
|
||||||
(0, 3, 60, "none / none / none / none / none / none"),
|
|
||||||
(7, 0, 60, "none / none / none / none / none / none"),
|
|
||||||
(1, 3, 60, "o1 / o1 / o1 / o1 / o1 / o1"),
|
|
||||||
(2, 2, 60, "o1,o2 / o1,o2 / o1,o2 / o1,o2 / o1,o2 / o1,o2"),
|
|
||||||
(7, 3, 0, "o1,o2,o3 / o1,o2,o3 / o1,o2,o3 / o1,o2,o3 / o1,o2,o3 / o1,o2,o3"),
|
|
||||||
(7, 2, 60, "o1,o2 / o1,o2 / o3,o4 / o3,o4 / o5,o6 / o4,o5"),
|
|
||||||
(7, 3, 60, "o1,o2,o3 / o1,o2,o3 / o4,o5,o6 / o4,o5,o6 / o7,o1,o2 / o2,o3,o4"),
|
|
||||||
])
|
|
||||||
def test_the_window_advances_catches_up_and_wraps(clock, size, limit, interval, expected):
|
|
||||||
"""At 0, 30, 60, 119, 120 and 300 s: 300 is three intervals after 120."""
|
|
||||||
host = Host(interval=interval)
|
|
||||||
pool = [{"id": f"o{n}"} for n in range(1, size + 1)]
|
|
||||||
seen = []
|
|
||||||
for t in (0, 30, 60, 119, 120, 300):
|
|
||||||
clock.now = T0 + t
|
|
||||||
seen.append(ids(host._other_games_window(pool, limit)))
|
|
||||||
assert " / ".join(seen) == expected
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# The display path: _advance_other_games_if_due, _rotate_other_games_on_display
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
UPDATE_SLATE = (("u1", "1", "2", 1), ("u2", "3", "4", 2), ("u3", "5", "6", 3),
|
|
||||||
("u4", "7", "8", 4), ("u5", "2", "3", 5), ("u6", "4", "5", 6),
|
|
||||||
("u7", "6", "7", 7), ("u8", "1", "8", 8))
|
|
||||||
|
|
||||||
|
|
||||||
def updated(clock, favorites, favorite_limit=2, other_limit=2, recent=False, **kw):
|
|
||||||
"""What the plugins' update() leaves: _favorites_first's pick, its last card on screen."""
|
|
||||||
host = Host(favorites=favorites, **kw)
|
|
||||||
clock.now = T0
|
|
||||||
games = [game(*row, recent=recent) for row in UPDATE_SLATE]
|
|
||||||
host.games_list = host._favorites_first(games, favorite_limit, other_limit,
|
|
||||||
newest_first=recent)
|
|
||||||
if host.games_list:
|
|
||||||
host.current_game_index = len(host.games_list) - 1
|
|
||||||
host.current_game = host.games_list[-1]
|
|
||||||
return host
|
|
||||||
|
|
||||||
|
|
||||||
def ticks(clock, host):
|
|
||||||
"""games_list@on screen after update() and each tick (^: redraw), and #composes."""
|
|
||||||
calls = []
|
|
||||||
compose = host._compose_selection
|
|
||||||
|
|
||||||
def counted():
|
|
||||||
calls.append(1)
|
|
||||||
return compose()
|
|
||||||
host._compose_selection = counted
|
|
||||||
|
|
||||||
def shown():
|
|
||||||
return f"{ids(host.games_list)}@{(host.current_game or {}).get('id')}"
|
|
||||||
out = [shown()]
|
|
||||||
for t in (30, 60, 90, 125, 305):
|
|
||||||
clock.now = T0 + t
|
|
||||||
redraw = host._rotate_other_games_on_display()
|
|
||||||
out.append(shown() + ("^" if redraw else ""))
|
|
||||||
return f"{' > '.join(out)} #{len(calls)}"
|
|
||||||
|
|
||||||
|
|
||||||
ROTATION = {
|
|
||||||
"AAA, 2 others, 60s": (
|
|
||||||
{"favorites": ["AAA"]},
|
|
||||||
"u1,u2,u3,u8@u8 > u1,u2,u3,u8@u8 > u1,u4,u5,u8@u8^ > u1,u4,u5,u8@u8 > "
|
|
||||||
"u1,u6,u7,u8@u8^ > u1,u6,u7,u8@u8 #3"),
|
|
||||||
"pinned (0s)": (
|
|
||||||
{"favorites": ["AAA"], "interval": 0},
|
|
||||||
"u1,u2,u3,u8@u8 > u1,u2,u3,u8@u8 > u1,u2,u3,u8@u8 > u1,u2,u3,u8@u8 > "
|
|
||||||
"u1,u2,u3,u8@u8 > u1,u2,u3,u8@u8 #0"),
|
|
||||||
"0 others": (
|
|
||||||
{"favorites": ["AAA"], "other_limit": 0},
|
|
||||||
"u1,u8@u8 > u1,u8@u8 > u1,u8@u8 > u1,u8@u8 > u1,u8@u8 > u1,u8@u8 #0"),
|
|
||||||
"no favourite slots, 3 others": (
|
|
||||||
{"favorites": [], "favorite_limit": 0, "other_limit": 3},
|
|
||||||
"u1,u2,u3@u3 > u1,u2,u3@u3 > u4,u5,u6@u4^ > u4,u5,u6@u4 > u1,u7,u8@u1^ > "
|
|
||||||
"u1,u2,u8@u1^ #3"),
|
|
||||||
"ranked, poll on the slate": (
|
|
||||||
{"favorites": ["AAA"], "quality": "ranked", "rankings": RANKED},
|
|
||||||
"u1,u3,u4,u8@u8 > u1,u3,u4,u8@u8 > u1,u2,u4,u8@u8^ > u1,u2,u4,u8@u8 > "
|
|
||||||
"u1,u2,u3,u8@u8^ > u1,u2,u3,u8@u8 #3"),
|
|
||||||
# The favourite still fills the board: no recompose on any frame.
|
|
||||||
"ranked, poll matches nothing, favourite playing": (
|
|
||||||
{"favorites": ["AAA"], "quality": "ranked", "rankings": {"ZZZ": 1}},
|
|
||||||
"u1,u8@u8 > u1,u8@u8 > u1,u8@u8 > u1,u8@u8 > u1,u8@u8 > u1,u8@u8 #0"),
|
|
||||||
# Nothing survived, so compose cuts the unfiltered fallback: it rotates too.
|
|
||||||
"ranked, poll matches nothing, favourite not playing": (
|
|
||||||
{"favorites": ["ZZZ"], "quality": "ranked", "rankings": {"ZZZ": 1}},
|
|
||||||
"u1,u2@u2 > u1,u2@u2 > u3,u4@u3^ > u3,u4@u3 > u1,u2@u1^ > u3,u4@u3^ #3"),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("recent", [False, True], ids=["upcoming", "recent"])
|
|
||||||
@pytest.mark.parametrize("scenario", list(ROTATION))
|
|
||||||
def test_the_display_path_rotation(clock, scenario, recent):
|
|
||||||
settings, expected = ROTATION[scenario]
|
|
||||||
assert ticks(clock, updated(clock, recent=recent, **settings)) == expected
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_pools_no_rotation(clock):
|
|
||||||
"""A manager whose update() never built the pools (ufc's MMA ones) never rotates."""
|
|
||||||
host = Host()
|
|
||||||
clock.now = T0 + 3600
|
|
||||||
assert host._advance_other_games_if_due() == []
|
|
||||||
assert host._rotate_other_games_on_display() is False
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# update() and display() both advancing: the lock
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _racing(host):
|
|
||||||
"""Run display()'s rotation on a second thread inside update()'s advance.
|
|
||||||
|
|
||||||
The first time this thread reads ``_other_window_start`` -- the advance's
|
|
||||||
read-modify-write, after the interval was found elapsed -- the display path
|
|
||||||
starts; it either finishes first (no lock) or waits on ``_games_lock``.
|
|
||||||
"""
|
|
||||||
owner, threads = threading.get_ident(), []
|
|
||||||
|
|
||||||
class Interleaved(type(host)):
|
|
||||||
@property
|
|
||||||
def _other_window_start(self):
|
|
||||||
if threading.get_ident() == owner and not threads:
|
|
||||||
display = threading.Thread(target=self._rotate_other_games_on_display,
|
|
||||||
daemon=True)
|
|
||||||
threads.append(display)
|
|
||||||
display.start()
|
|
||||||
display.join(2.0)
|
|
||||||
return self.__dict__["_other_window_start"]
|
|
||||||
|
|
||||||
@_other_window_start.setter
|
|
||||||
def _other_window_start(self, value):
|
|
||||||
self.__dict__["_other_window_start"] = value
|
|
||||||
|
|
||||||
host.__class__ = Interleaved
|
|
||||||
return threads
|
|
||||||
|
|
||||||
|
|
||||||
class _NoLock:
|
|
||||||
def __enter__(self):
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __exit__(self, *exc):
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _update_again(host):
|
|
||||||
host.games_list = host._favorites_first(
|
|
||||||
[game(*row) for row in UPDATE_SLATE], 2, 2)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("lock, windows, shown", [
|
|
||||||
(True, 1, "u1,u4,u5,u8"),
|
|
||||||
(False, 2, "u1,u6,u7,u8"), # what the eight plugins did before
|
|
||||||
])
|
|
||||||
def test_display_inside_updates_advance(clock, lock, windows, shown):
|
|
||||||
host = updated(clock, ["AAA"])
|
|
||||||
if not lock:
|
|
||||||
host._games_lock = _NoLock()
|
|
||||||
clock.now = T0 + 61
|
|
||||||
threads = _racing(host)
|
|
||||||
_update_again(host)
|
|
||||||
for t in threads:
|
|
||||||
t.join(10)
|
|
||||||
assert threads, "update() never advanced the window"
|
|
||||||
assert (host.__dict__["_other_window_start"] // 2, ids(host.games_list)) == (windows, shown)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("display_first", [False, True])
|
|
||||||
def test_update_and_display_in_sequence(clock, display_first):
|
|
||||||
host = updated(clock, ["AAA"])
|
|
||||||
clock.now = T0 + 61
|
|
||||||
if display_first:
|
|
||||||
host._rotate_other_games_on_display()
|
|
||||||
_update_again(host)
|
|
||||||
if not display_first:
|
|
||||||
host._rotate_other_games_on_display()
|
|
||||||
assert (host._other_window_start // 2, ids(host.games_list)) == (1, "u1,u4,u5,u8")
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Odds for rotated-in games
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _rotate_with_odds(clock, show_odds=True, manager=True, preset=None):
|
|
||||||
host = updated(clock, ["AAA"])
|
|
||||||
host.show_odds = show_odds
|
|
||||||
host.odds_manager = MagicMock() if manager else None
|
|
||||||
if manager:
|
|
||||||
host.odds_manager.get_odds.side_effect = lambda **k: {"details": k["event_id"]}
|
|
||||||
for g in host._selection_pools["favorites"] + host._selection_pools["others"]:
|
|
||||||
if g["id"] in (preset or ()):
|
|
||||||
g["odds"] = {"details": "already"}
|
|
||||||
clock.now = T0 + 61
|
|
||||||
assert host._rotate_other_games_on_display()
|
|
||||||
for t in threading.enumerate():
|
|
||||||
if t.name == "test-rotated-odds":
|
|
||||||
t.join(10)
|
|
||||||
return host
|
|
||||||
|
|
||||||
|
|
||||||
def test_rotated_in_games_get_odds(clock):
|
|
||||||
host = _rotate_with_odds(clock, preset={"u1"})
|
|
||||||
assert ids(host.games_list) == "u1,u4,u5,u8"
|
|
||||||
assert {g["id"]: g["odds"]["details"] for g in host.games_list} == {
|
|
||||||
"u1": "already", "u4": "u4", "u5": "u5", "u8": "u8"}
|
|
||||||
asked = [c.kwargs["event_id"] for c in host.odds_manager.get_odds.call_args_list]
|
|
||||||
assert asked == ["u4", "u5", "u8"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("show_odds, manager", [(False, True), (True, False)])
|
|
||||||
def test_no_odds_without_show_odds_or_a_manager(clock, show_odds, manager):
|
|
||||||
host = _rotate_with_odds(clock, show_odds=show_odds, manager=manager)
|
|
||||||
assert not any(g.get("odds") for g in host.games_list)
|
|
||||||
if manager:
|
|
||||||
host.odds_manager.get_odds.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Carrier and host contract
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
METHODS = ["_advance_other_games_if_due", "_attach_odds_to_rotated_games",
|
|
||||||
"_by_importance", "_other_games_window", "_rankings_loaded",
|
|
||||||
"_rotate_other_games_on_display"]
|
|
||||||
|
|
||||||
|
|
||||||
def _self_reads():
|
|
||||||
tree = ast.parse(Path(sports_rotation.__file__).read_text(encoding="utf-8"))
|
|
||||||
return {node.attr for node in ast.walk(tree)
|
|
||||||
if isinstance(node, ast.Attribute) and isinstance(node.ctx, ast.Load)
|
|
||||||
and isinstance(node.value, ast.Name) and node.value.id == "self"}
|
|
||||||
|
|
||||||
|
|
||||||
class TestHostContract:
|
|
||||||
def test_the_mixin_carries_only_the_family(self):
|
|
||||||
"""Every scoreboard's SportsCore has all six, so adopting it adds none."""
|
|
||||||
assert sorted(n for n in vars(SportsRotationMixin)
|
|
||||||
if not n.startswith("__")) == METHODS
|
|
||||||
|
|
||||||
def test_every_host_read_is_documented(self):
|
|
||||||
undocumented = sorted(n for n in _self_reads()
|
|
||||||
if f"``{n}``" not in sports_rotation.__doc__)
|
|
||||||
assert undocumented == [], f"read but not in the host contract: {undocumented}"
|
|
||||||
|
|
||||||
def test_no_other_shared_mixin_defines_these(self):
|
|
||||||
from src.common import sports_display_rules, sports_shared
|
|
||||||
others = [sports_shared.SportsCoreSharedMixin, sports_shared.SportsRecentSharedMixin,
|
|
||||||
sports_shared.SportsLiveSharedMixin, SportsHelpersMixin,
|
|
||||||
SportsFavoritesMixin, sports_display_rules.SportsGameRulesMixin]
|
|
||||||
assert [m for m in METHODS if any(m in vars(o) for o in others)] == []
|
|
||||||
|
|
||||||
def test_the_host_class_wins(self):
|
|
||||||
"""football keeps its _rankings_loaded; the plugin's own method runs."""
|
|
||||||
assert IdRankedHost(ranked_ids={1: 1})._rankings_loaded() is True
|
|
||||||
assert Host()._rankings_loaded() is False
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
"""sports_rotation still matches every plugin copy, and only football overrides the seam.
|
|
||||||
|
|
||||||
``src.common.sports_rotation`` was copied from the scoreboards once family 7
|
|
||||||
had made each method one body in all nine ``SportsCore`` classes:
|
|
||||||
``_by_importance``, ``_other_games_window``, ``_advance_other_games_if_due``,
|
|
||||||
``_rotate_other_games_on_display``, ``_attach_odds_to_rotated_games`` and the
|
|
||||||
default ``_rankings_loaded``. The plugins delete their copies once they floor
|
|
||||||
on the release that ships this module. Until each has, a copy that changes on
|
|
||||||
its own is a fix one side has and the other lacks.
|
|
||||||
|
|
||||||
Point LEDMATRIX_PLUGINS at a ledmatrix-plugins checkout and each method is
|
|
||||||
compared with every plugin copy using ``scripts/sports_drift_report.py``'s own
|
|
||||||
normalisation (the AST with docstrings and annotations dropped), plus the
|
|
||||||
decorators. A copy that is gone counts as adopted when the plugin's
|
|
||||||
``sports.py`` names the module. football's ``_rankings_loaded`` is the
|
|
||||||
decided override (it counts its rankings keyed by team id), checked as the only
|
|
||||||
one; it stays in the plugin after adoption. Without the variable this skips:
|
|
||||||
core CI has no plugins checkout.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import ast
|
|
||||||
import importlib.util
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.common import sports_rotation
|
|
||||||
|
|
||||||
REPO = Path(__file__).resolve().parents[1]
|
|
||||||
SPORTS = ("afl", "baseball", "basketball", "football", "hockey", "lacrosse",
|
|
||||||
"nrl", "soccer", "ufc")
|
|
||||||
CARRIER, MIXIN = "SportsCore", "SportsRotationMixin"
|
|
||||||
METHODS = ("_by_importance", "_other_games_window", "_advance_other_games_if_due",
|
|
||||||
"_rotate_other_games_on_display", "_attach_odds_to_rotated_games",
|
|
||||||
"_rankings_loaded")
|
|
||||||
SEAM = "_rankings_loaded"
|
|
||||||
|
|
||||||
#: The owner's decision (docs/SPORTS_UNIFICATION.md, family 7): the sports
|
|
||||||
#: whose own _rankings_loaded replaces the default.
|
|
||||||
OVERRIDES_RANKINGS_LOADED = {"football"}
|
|
||||||
|
|
||||||
|
|
||||||
def _drift_report():
|
|
||||||
"""scripts/sports_drift_report.py, loaded by path (scripts/ is no package)."""
|
|
||||||
spec = importlib.util.spec_from_file_location(
|
|
||||||
"sports_drift_report", REPO / "scripts" / "sports_drift_report.py")
|
|
||||||
module = importlib.util.module_from_spec(spec)
|
|
||||||
spec.loader.exec_module(module)
|
|
||||||
return module
|
|
||||||
|
|
||||||
|
|
||||||
DRIFT = _drift_report()
|
|
||||||
|
|
||||||
|
|
||||||
def _plugins_root():
|
|
||||||
root = DRIFT.resolve_plugins_dir(os.environ.get("LEDMATRIX_PLUGINS"))
|
|
||||||
if root is None:
|
|
||||||
pytest.skip("set LEDMATRIX_PLUGINS to a ledmatrix-plugins checkout to "
|
|
||||||
"compare this module against the plugin copies")
|
|
||||||
return root
|
|
||||||
|
|
||||||
|
|
||||||
def _class(tree, name):
|
|
||||||
return next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == name)
|
|
||||||
|
|
||||||
|
|
||||||
def _method(cls, name):
|
|
||||||
return next((n for n in cls.body
|
|
||||||
if isinstance(n, ast.FunctionDef) and n.name == name), None)
|
|
||||||
|
|
||||||
|
|
||||||
def _fingerprint(node):
|
|
||||||
return (DRIFT._digest(node, DRIFT._Canonical()),
|
|
||||||
tuple(ast.unparse(d) for d in node.decorator_list))
|
|
||||||
|
|
||||||
|
|
||||||
def _ours(name):
|
|
||||||
tree = ast.parse(Path(sports_rotation.__file__).read_text(encoding="utf-8"))
|
|
||||||
return _method(_class(tree, MIXIN), name)
|
|
||||||
|
|
||||||
|
|
||||||
def _plugin_tree(root, sport):
|
|
||||||
source = (root / f"{sport}-scoreboard" / "sports.py").read_text(encoding="utf-8")
|
|
||||||
return source, ast.parse(source)
|
|
||||||
|
|
||||||
|
|
||||||
CASES = [(sport, name) for sport in SPORTS for name in METHODS
|
|
||||||
if not (name == SEAM and sport in OVERRIDES_RANKINGS_LOADED)]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("sport, name", CASES)
|
|
||||||
def test_every_remaining_plugin_copy_matches(sport, name):
|
|
||||||
source, tree = _plugin_tree(_plugins_root(), sport)
|
|
||||||
copy = _method(_class(tree, CARRIER), name)
|
|
||||||
if copy is None:
|
|
||||||
assert sports_rotation.__name__ in source, (
|
|
||||||
f"{sport}: no {name} on {CARRIER} and no {sports_rotation.__name__} import")
|
|
||||||
else:
|
|
||||||
assert _fingerprint(copy) == _fingerprint(_ours(name)), (
|
|
||||||
f"{CARRIER}.{name} in {sport} differs from sports_rotation. "
|
|
||||||
f"Port the change to both, or stop treating it as shared.")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("sport", SPORTS)
|
|
||||||
def test_no_other_plugin_class_carries_a_copy(sport):
|
|
||||||
"""A copy on another class would shadow the shared one for that class."""
|
|
||||||
_, tree = _plugin_tree(_plugins_root(), sport)
|
|
||||||
strays = [f"{node.name}.{name}" for node in tree.body if isinstance(node, ast.ClassDef)
|
|
||||||
for name in METHODS if node.name != CARRIER and _method(node, name) is not None]
|
|
||||||
assert strays == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_only_the_decided_sports_override_the_seam():
|
|
||||||
root = _plugins_root()
|
|
||||||
ours = _fingerprint(_ours(SEAM))
|
|
||||||
overriding = set()
|
|
||||||
for sport in SPORTS:
|
|
||||||
copy = _method(_class(_plugin_tree(root, sport)[1], CARRIER), SEAM)
|
|
||||||
if copy is not None and _fingerprint(copy) != ours:
|
|
||||||
overriding.add(sport)
|
|
||||||
assert overriding == OVERRIDES_RANKINGS_LOADED
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_drift_report_still_calls_them_identical():
|
|
||||||
root = _plugins_root()
|
|
||||||
families = DRIFT.build(root, ("sports.py",))
|
|
||||||
rows = {(r["file"], r["family"]): r
|
|
||||||
for r in (DRIFT.summarise(k, v) for k, v in families.items())}
|
|
||||||
for name in METHODS:
|
|
||||||
if name == SEAM:
|
|
||||||
continue # the default and football's override: two by design
|
|
||||||
row = rows.get(("sports.py", name))
|
|
||||||
assert row is None or row["worst_class_variants"] == 1, name
|
|
||||||
@@ -1,346 +0,0 @@
|
|||||||
"""src.common.sports_view_model: the game dict built from one ESPN event.
|
|
||||||
|
|
||||||
The cases follow ledmatrix-plugins' ``scripts/test_view_model.py`` (the table
|
|
||||||
the family 10 reconcile was checked against); the expected values are its
|
|
||||||
``EXPECTED_COMMON`` cells, which agree across all nine scoreboards once the
|
|
||||||
reconcile landed (nrl differs only through the ``_logo_filename`` seam and
|
|
||||||
its id favourites, which are host data). Each row is one change to one base
|
|
||||||
event, a scheduled AAA (home, id 1) v BBB (away, id 2) at 7:00 PM EDT, and
|
|
||||||
lists only the keys whose value differs from the base game. The host shows
|
|
||||||
times in America/New_York. Nothing reaches the network.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import ast
|
|
||||||
import copy
|
|
||||||
import logging
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import pytz
|
|
||||||
|
|
||||||
from src.common import sports_view_model
|
|
||||||
from src.common.sports_view_model import SportsViewModelMixin
|
|
||||||
|
|
||||||
LOGO_DIR = Path("logos")
|
|
||||||
|
|
||||||
|
|
||||||
class Host(SportsViewModelMixin):
|
|
||||||
def __init__(self, config=None):
|
|
||||||
self.logger = logging.getLogger("test_sports_view_model")
|
|
||||||
self.config = config or {}
|
|
||||||
self.logo_dir = LOGO_DIR
|
|
||||||
self.favorite_teams = ["AAA"]
|
|
||||||
|
|
||||||
def _get_timezone(self):
|
|
||||||
return pytz.timezone("America/New_York")
|
|
||||||
|
|
||||||
|
|
||||||
def competitor(home_away, tid, abbr, name, record):
|
|
||||||
return {"id": tid, "homeAway": home_away, "score": "0",
|
|
||||||
"team": {"id": tid, "abbreviation": abbr, "name": name,
|
|
||||||
"logo": f"https://a.espncdn.com/i/teamlogos/{abbr.lower()}.png"},
|
|
||||||
"records": [{"type": "total", "summary": record}]}
|
|
||||||
|
|
||||||
|
|
||||||
def base_event():
|
|
||||||
return {"id": "401", "date": "2026-10-12T23:00Z", "competitions": [{
|
|
||||||
"id": "4011", "broadcast": "ESPN",
|
|
||||||
"status": {"displayClock": "0:00", "period": 0, "type": {
|
|
||||||
"name": "STATUS_SCHEDULED", "state": "pre", "completed": False,
|
|
||||||
"shortDetail": "10/12 - 7:00 PM EDT"}},
|
|
||||||
"competitors": [competitor("home", "1", "AAA", "Alpha", "3-1"),
|
|
||||||
competitor("away", "2", "BBB", "Bravo", "2-2")]}]}
|
|
||||||
|
|
||||||
|
|
||||||
BASE = {
|
|
||||||
"id": "401", "game_time": "7:00PM", "game_date": "10/12",
|
|
||||||
"start_time_utc": datetime(2026, 10, 12, 23, 0, tzinfo=pytz.UTC),
|
|
||||||
"status_text": "10/12 - 7:00 PM EDT", "is_live": False, "is_final": False,
|
|
||||||
"is_upcoming": True, "is_halftime": False, "is_period_break": False,
|
|
||||||
"broadcast": "ESPN", "home_abbr": "AAA", "home_id": "1", "home_score": "0",
|
|
||||||
"home_logo_path": LOGO_DIR / "AAA.png",
|
|
||||||
"home_logo_url": "https://a.espncdn.com/i/teamlogos/aaa.png",
|
|
||||||
"home_record": "3-1", "away_record": "2-2", "away_abbr": "BBB", "away_id": "2",
|
|
||||||
"away_score": "0", "away_logo_path": LOGO_DIR / "BBB.png",
|
|
||||||
"away_logo_url": "https://a.espncdn.com/i/teamlogos/bbb.png",
|
|
||||||
"is_within_window": True, "favorite_teams": ["AAA"],
|
|
||||||
"period": 0, "clock": "",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def comp(e):
|
|
||||||
return e["competitions"][0]
|
|
||||||
|
|
||||||
|
|
||||||
def side(e, home_away):
|
|
||||||
return next(c for c in comp(e)["competitors"] if c["homeAway"] == home_away)
|
|
||||||
|
|
||||||
|
|
||||||
def status(state, name, short, completed=False, drop_completed=False):
|
|
||||||
def edit(e):
|
|
||||||
st = {"name": name, "state": state, "completed": completed, "shortDetail": short}
|
|
||||||
if drop_completed:
|
|
||||||
del st["completed"]
|
|
||||||
comp(e)["status"] = {"displayClock": "5:32", "period": 2, "type": st}
|
|
||||||
return edit
|
|
||||||
|
|
||||||
|
|
||||||
def scores(home, away):
|
|
||||||
def edit(e):
|
|
||||||
side(e, "home")["score"], side(e, "away")["score"] = home, away
|
|
||||||
return edit
|
|
||||||
|
|
||||||
|
|
||||||
def drop_scores(e):
|
|
||||||
for c in comp(e)["competitors"]:
|
|
||||||
del c["score"]
|
|
||||||
|
|
||||||
|
|
||||||
def both(fn):
|
|
||||||
def edit(e):
|
|
||||||
for c in comp(e)["competitors"]:
|
|
||||||
fn(c)
|
|
||||||
return edit
|
|
||||||
|
|
||||||
|
|
||||||
def dated(date):
|
|
||||||
def edit(e):
|
|
||||||
e["date"] = date
|
|
||||||
return edit
|
|
||||||
|
|
||||||
|
|
||||||
def chain(*edits):
|
|
||||||
def edit(e):
|
|
||||||
for fn in edits:
|
|
||||||
fn(e)
|
|
||||||
return edit
|
|
||||||
|
|
||||||
|
|
||||||
def utc(*args):
|
|
||||||
return datetime(*args, tzinfo=pytz.UTC)
|
|
||||||
|
|
||||||
|
|
||||||
FINAL = status("post", "STATUS_FINAL", "Final", completed=True)
|
|
||||||
POST = {"is_upcoming": False, "is_final": True, "status_text": "Final"}
|
|
||||||
|
|
||||||
#: label -> (edit, extra host config, keys that differ from BASE; None: no game)
|
|
||||||
ROWS = {
|
|
||||||
"pre": (None, None, {}),
|
|
||||||
"short date format": (dated("2026-03-05T23:00Z"), {"display": {"use_short_date_format": True}},
|
|
||||||
{"game_date": "3/5", "game_time": "6:00PM",
|
|
||||||
"start_time_utc": utc(2026, 3, 5, 23, 0)}),
|
|
||||||
"in progress": (status("in", "STATUS_IN_PROGRESS", "5:32 - 2nd"), None,
|
|
||||||
{"is_live": True, "is_upcoming": False, "status_text": "5:32 - 2nd"}),
|
|
||||||
"halftime": (status("in", "STATUS_HALFTIME", "Halftime"), None,
|
|
||||||
{"is_live": True, "is_halftime": True, "is_upcoming": False,
|
|
||||||
"status_text": "Halftime"}),
|
|
||||||
"end of period": (status("in", "STATUS_END_PERIOD", "End of 1st"), None,
|
|
||||||
{"is_live": True, "is_period_break": True, "is_upcoming": False,
|
|
||||||
"status_text": "End of 1st"}),
|
|
||||||
"suspended while in": (status("in", "STATUS_SUSPENDED", "Suspended"), None,
|
|
||||||
{"is_upcoming": False, "status_text": "Suspended"}),
|
|
||||||
"final": (FINAL, None, POST),
|
|
||||||
"final, no completed flag": (status("post", "STATUS_FINAL", "Final", drop_completed=True),
|
|
||||||
None, POST),
|
|
||||||
"final, completed false": (status("post", "STATUS_FINAL", "Final"), None,
|
|
||||||
{"is_upcoming": False, "status_text": "Final"}),
|
|
||||||
"postponed": (status("post", "STATUS_POSTPONED", "Postponed"), None,
|
|
||||||
{"is_upcoming": False, "status_text": "Postponed"}),
|
|
||||||
"canceled": (status("post", "STATUS_CANCELED", "Canceled"), None,
|
|
||||||
{"is_upcoming": False, "status_text": "Canceled"}),
|
|
||||||
"delayed, completed": (status("post", "STATUS_DELAYED", "Delayed", completed=True), None,
|
|
||||||
{"is_upcoming": False, "status_text": "Delayed"}),
|
|
||||||
"forfeit, completed": (status("post", "STATUS_FORFEIT", "Forfeit", completed=True), None,
|
|
||||||
{**POST, "status_text": "Forfeit"}),
|
|
||||||
"forfeit pending": (status("post", "STATUS_FORFEIT_PENDING", "Forfeit", completed=True),
|
|
||||||
None, {"is_upcoming": False, "status_text": "Forfeit"}),
|
|
||||||
"scores int": (chain(FINAL, scores(21, 3)), None, {**POST, "home_score": "21", "away_score": "3"}),
|
|
||||||
"scores '21.0'": (chain(FINAL, scores("21.0", " 3 ")), None,
|
|
||||||
{**POST, "home_score": "21", "away_score": "3"}),
|
|
||||||
"scores dict": (chain(FINAL, scores({"value": 21.0, "displayValue": "21"},
|
|
||||||
{"value": 0, "displayValue": "4"})), None,
|
|
||||||
{**POST, "home_score": "21", "away_score": "4"}),
|
|
||||||
"scores None and ''": (chain(FINAL, scores(None, "")), None, POST),
|
|
||||||
"scores missing": (chain(FINAL, drop_scores), None, POST),
|
|
||||||
"scores not numbers": (chain(FINAL, scores("SO", "12 (3)")), None, {**POST, "away_score": "12"}),
|
|
||||||
"records 0-0 and 0-0-0": (lambda e: (side(e, "home")["records"][0].update(summary="0-0"),
|
|
||||||
side(e, "away")["records"][0].update(summary="0-0-0")),
|
|
||||||
None, {"home_record": "", "away_record": ""}),
|
|
||||||
"records missing": (both(lambda c: c.pop("records")), None,
|
|
||||||
{"home_record": "", "away_record": ""}),
|
|
||||||
"record, team-schedule shape": (both(lambda c: (c.pop("records"),
|
|
||||||
c.update(record=[{"displayValue": "7-0"}]))),
|
|
||||||
None, {"home_record": "7-0", "away_record": "7-0"}),
|
|
||||||
"no abbreviation": (both(lambda c: c["team"].pop("abbreviation")), None,
|
|
||||||
{"home_abbr": "Alp", "away_abbr": "Bra",
|
|
||||||
"home_logo_path": LOGO_DIR / "ALP.png",
|
|
||||||
"away_logo_path": LOGO_DIR / "BRA.png"}),
|
|
||||||
"logos array only": (both(lambda c: c["team"].update(
|
|
||||||
logo=None, logos=[{"href": "https://a.espncdn.com/x.png"}])), None,
|
|
||||||
{"home_logo_url": "https://a.espncdn.com/x.png", "away_logo_url": "https://a.espncdn.com/x.png"}),
|
|
||||||
"logo and logos": (both(lambda c: c["team"].update(logos=[{"href": "https://a.espncdn.com/x.png"}])),
|
|
||||||
None, {}),
|
|
||||||
"no logos": (both(lambda c: c["team"].pop("logo")), None,
|
|
||||||
{"home_logo_url": None, "away_logo_url": None}),
|
|
||||||
"competitor without id": (lambda e: side(e, "home").pop("id"), None, {}),
|
|
||||||
"no ids at all": (lambda e: (side(e, "home").pop("id"), side(e, "home")["team"].pop("id")),
|
|
||||||
None, {"home_id": None}),
|
|
||||||
"broadcast object": (lambda e: comp(e).update(broadcast={"names": ["ESPN"]}), None,
|
|
||||||
{"broadcast": "{'names': ['ESPN']}"}),
|
|
||||||
"broadcast missing": (lambda e: comp(e).pop("broadcast"), None, {"broadcast": ""}),
|
|
||||||
"date 'TBD'": (dated("TBD"), None, {"game_date": "", "game_time": "", "start_time_utc": None}),
|
|
||||||
"date +10:00": (dated("2026-10-13T19:40+10:00"), None,
|
|
||||||
{"game_date": "10/13", "game_time": "5:40AM",
|
|
||||||
"start_time_utc": utc(2026, 10, 13, 9, 40)}),
|
|
||||||
"date naive, read as UTC": (dated("2026-10-12T23:00:00"), None, {}),
|
|
||||||
"late UTC, previous local day": (dated("2026-10-13T03:30Z"), None,
|
|
||||||
{"game_time": "11:30PM", "start_time_utc": utc(2026, 10, 13, 3, 30)}),
|
|
||||||
"DST ends, 05:30Z (EDT)": (dated("2026-11-01T05:30Z"), None,
|
|
||||||
{"game_date": "11/01", "game_time": "1:30AM",
|
|
||||||
"start_time_utc": utc(2026, 11, 1, 5, 30)}),
|
|
||||||
"DST ends, 06:30Z (EST)": (dated("2026-11-01T06:30Z"), None,
|
|
||||||
{"game_date": "11/01", "game_time": "1:30AM",
|
|
||||||
"start_time_utc": utc(2026, 11, 1, 6, 30)}),
|
|
||||||
"DST starts, 07:30Z": (dated("2026-03-08T07:30Z"), None,
|
|
||||||
{"game_date": "03/08", "game_time": "3:30AM",
|
|
||||||
"start_time_utc": utc(2026, 3, 8, 7, 30)}),
|
|
||||||
"naive, repeated DST hour": (dated("2026-11-01T01:30:00"), None,
|
|
||||||
{"game_date": "10/31", "game_time": "9:30PM",
|
|
||||||
"start_time_utc": utc(2026, 11, 1, 1, 30)}),
|
|
||||||
"one competitor": (lambda e: comp(e)["competitors"].pop(), None, None),
|
|
||||||
"no competitors": (lambda e: comp(e).pop("competitors"), None, None),
|
|
||||||
"no competitions": (lambda e: e.update(competitions=[]), None, None),
|
|
||||||
"no status": (lambda e: comp(e).pop("status"), None, None),
|
|
||||||
"no date": (lambda e: e.pop("date"), None, None),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("label", ROWS)
|
|
||||||
def test_the_game_dict(label):
|
|
||||||
edit, config, changed = ROWS[label]
|
|
||||||
event = base_event()
|
|
||||||
if edit:
|
|
||||||
edit(event)
|
|
||||||
details, home, away, st, situation = Host(config)._extract_game_details_common(event)
|
|
||||||
if changed is None:
|
|
||||||
assert (details, home, away, st, situation) == (None,) * 5
|
|
||||||
return
|
|
||||||
assert details == {**BASE, **changed}
|
|
||||||
assert home is side(event, "home") and away is side(event, "away")
|
|
||||||
assert st is comp(event)["status"] and situation is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_an_empty_event_is_no_game():
|
|
||||||
assert Host()._extract_game_details_common({}) == (None,) * 5
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_situation_is_handed_back():
|
|
||||||
event = base_event()
|
|
||||||
comp(event)["situation"] = {"possession": "1"}
|
|
||||||
assert Host()._extract_game_details_common(event)[4] == {"possession": "1"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_logo_filename_seam():
|
|
||||||
class ById(Host):
|
|
||||||
def _logo_filename(self, abbr, team_id):
|
|
||||||
return f"{abbr}_{team_id}.png"
|
|
||||||
|
|
||||||
details = ById()._extract_game_details_common(base_event())[0]
|
|
||||||
assert (details["home_logo_path"], details["away_logo_path"]) == (
|
|
||||||
LOGO_DIR / "AAA_1.png", LOGO_DIR / "BBB_2.png")
|
|
||||||
|
|
||||||
|
|
||||||
def test_favourites_are_a_copy():
|
|
||||||
host = Host()
|
|
||||||
host.favorite_teams = None
|
|
||||||
assert host._extract_game_details_common(base_event())[0]["favorite_teams"] == []
|
|
||||||
host.favorite_teams = ["AAA"]
|
|
||||||
details = host._extract_game_details_common(base_event())[0]
|
|
||||||
assert details["favorite_teams"] == ["AAA"] and details["favorite_teams"] is not host.favorite_teams
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("status_type, final", [
|
|
||||||
({"state": "post", "name": "STATUS_FINAL", "completed": True}, True),
|
|
||||||
({"state": "post", "name": "STATUS_FINAL"}, True),
|
|
||||||
({"state": "post", "name": "status_final", "completed": True}, True),
|
|
||||||
({"state": "post", "name": "STATUS_FINAL", "completed": False}, False),
|
|
||||||
({"state": "post", "name": "STATUS_FORFEIT", "completed": True}, True),
|
|
||||||
({"state": "in", "name": "STATUS_IN_PROGRESS"}, False),
|
|
||||||
({"state": "post"}, True),
|
|
||||||
(None, False),
|
|
||||||
("post", False),
|
|
||||||
] + [({"state": "post", "name": n, "completed": True}, False)
|
|
||||||
for n in sorted(SportsViewModelMixin._NOT_PLAYED_STATUS_NAMES)])
|
|
||||||
def test_status_is_final(status_type, final):
|
|
||||||
assert SportsViewModelMixin._status_is_final(status_type) is final
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_status_lists():
|
|
||||||
assert SportsViewModelMixin._CALLED_OFF_STATUS_NAMES == {
|
|
||||||
"STATUS_POSTPONED", "STATUS_CANCELED", "STATUS_CANCELLED",
|
|
||||||
"STATUS_SUSPENDED", "STATUS_ABANDONED"}
|
|
||||||
assert SportsViewModelMixin._NOT_PLAYED_STATUS_NAMES == (
|
|
||||||
SportsViewModelMixin._CALLED_OFF_STATUS_NAMES
|
|
||||||
| {"STATUS_DELAYED", "STATUS_FORFEIT_PENDING", "STATUS_NO_CONTEST_PENDING"})
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("score, text", [
|
|
||||||
(21, "21"), (21.9, "21"), ("21", "21"), ("21.0", "21"), (" 7 ", "7"), ("", "0"),
|
|
||||||
(None, "0"), ("SO", "0"), ("12 (3)", "12"), ({"value": 21.0}, "21"),
|
|
||||||
({"value": 0, "displayValue": "4"}, "4"), ({"value": None}, "0"), ([], "0"),
|
|
||||||
(float("nan"), "0"), (float("inf"), "0"),
|
|
||||||
])
|
|
||||||
def test_score_text(score, text):
|
|
||||||
assert Host()._score_text(score) == text
|
|
||||||
|
|
||||||
|
|
||||||
def test_records_do_not_mutate_the_event():
|
|
||||||
event = base_event()
|
|
||||||
before = copy.deepcopy(event)
|
|
||||||
Host()._extract_game_details_common(event)
|
|
||||||
assert event == before
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Carrier and host contract
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
MEMBERS = ["_CALLED_OFF_STATUS_NAMES", "_NOT_PLAYED_STATUS_NAMES",
|
|
||||||
"_extract_game_details_common", "_extract_team_record", "_logo_filename",
|
|
||||||
"_score_text", "_status_is_final", "_team_logo_url"]
|
|
||||||
|
|
||||||
|
|
||||||
def _self_reads():
|
|
||||||
tree = ast.parse(Path(sports_view_model.__file__).read_text(encoding="utf-8"))
|
|
||||||
return {node.attr for node in ast.walk(tree)
|
|
||||||
if isinstance(node, ast.Attribute) and isinstance(node.ctx, ast.Load)
|
|
||||||
and isinstance(node.value, ast.Name) and node.value.id in ("self", "cls")}
|
|
||||||
|
|
||||||
|
|
||||||
class TestHostContract:
|
|
||||||
def test_the_mixin_carries_only_the_family(self):
|
|
||||||
assert sorted(n for n in vars(SportsViewModelMixin) if not n.startswith("__")) == MEMBERS
|
|
||||||
|
|
||||||
def test_every_host_read_is_documented(self):
|
|
||||||
undocumented = sorted(n for n in _self_reads() - set(MEMBERS)
|
|
||||||
if f"``{n}``" not in sports_view_model.__doc__)
|
|
||||||
assert undocumented == [], f"read but not in the host contract: {undocumented}"
|
|
||||||
|
|
||||||
def test_every_key_is_in_the_key_table(self):
|
|
||||||
doc = sports_view_model.__doc__
|
|
||||||
table = doc[doc.index("THE KEYS"):doc.index("THE RULES")]
|
|
||||||
missing = [k for k in BASE if k not in table]
|
|
||||||
assert missing == []
|
|
||||||
|
|
||||||
def test_no_other_shared_mixin_defines_these(self):
|
|
||||||
from src.common import (sports_display_rules, sports_favorites, sports_fetch,
|
|
||||||
sports_helpers, sports_live_fetch, sports_rankings,
|
|
||||||
sports_rotation, sports_shared)
|
|
||||||
others = [sports_shared.SportsCoreSharedMixin, sports_shared.SportsRecentSharedMixin,
|
|
||||||
sports_shared.SportsLiveSharedMixin, sports_helpers.SportsHelpersMixin,
|
|
||||||
sports_favorites.SportsFavoritesMixin, sports_rotation.SportsRotationMixin,
|
|
||||||
sports_rankings.SportsRankingsMixin, sports_fetch.SportsFetchMixin,
|
|
||||||
sports_live_fetch.SportsLiveFetchMixin,
|
|
||||||
sports_display_rules.SportsGameRulesMixin]
|
|
||||||
assert [m for m in MEMBERS if any(m in vars(o) for o in others)] == []
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
"""sports_view_model still matches every plugin copy.
|
|
||||||
|
|
||||||
``src.common.sports_view_model`` was copied from the scoreboards once family
|
|
||||||
10 had made ``_extract_game_details_common`` one body in all nine
|
|
||||||
``SportsCore`` classes, with the helpers it calls (``_status_is_final``,
|
|
||||||
``_score_text``, ``_team_logo_url``, ``_extract_team_record``, the
|
|
||||||
``_logo_filename`` seam) and the two status-name sets. The plugins delete
|
|
||||||
their copies once they floor on the release that ships this module. nrl
|
|
||||||
overrides the seam on ``BaseNrlManager``, not on ``SportsCore``. Until each has, a copy that changes on its own is a fix one
|
|
||||||
side has and the other lacks.
|
|
||||||
|
|
||||||
Point LEDMATRIX_PLUGINS at a ledmatrix-plugins checkout and each method is
|
|
||||||
compared with every plugin copy using ``scripts/sports_drift_report.py``'s own
|
|
||||||
normalisation (the AST with docstrings and annotations dropped), plus the
|
|
||||||
decorators. A copy that is gone counts as adopted when the plugin's ``sports.py`` names the module.
|
|
||||||
Without the variable this skips: core CI has no plugins checkout.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import ast
|
|
||||||
import importlib.util
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.common import sports_view_model
|
|
||||||
|
|
||||||
REPO = Path(__file__).resolve().parents[1]
|
|
||||||
SPORTS = ("afl", "baseball", "basketball", "football", "hockey", "lacrosse",
|
|
||||||
"nrl", "soccer", "ufc")
|
|
||||||
CARRIER, MIXIN = "SportsCore", "SportsViewModelMixin"
|
|
||||||
METHODS = ("_extract_game_details_common", "_status_is_final", "_score_text",
|
|
||||||
"_team_logo_url", "_extract_team_record", "_logo_filename")
|
|
||||||
CONSTANTS = ("_CALLED_OFF_STATUS_NAMES", "_NOT_PLAYED_STATUS_NAMES")
|
|
||||||
|
|
||||||
|
|
||||||
def _drift_report():
|
|
||||||
"""scripts/sports_drift_report.py, loaded by path (scripts/ is no package)."""
|
|
||||||
spec = importlib.util.spec_from_file_location(
|
|
||||||
"sports_drift_report", REPO / "scripts" / "sports_drift_report.py")
|
|
||||||
module = importlib.util.module_from_spec(spec)
|
|
||||||
spec.loader.exec_module(module)
|
|
||||||
return module
|
|
||||||
|
|
||||||
|
|
||||||
DRIFT = _drift_report()
|
|
||||||
|
|
||||||
|
|
||||||
def _plugins_root():
|
|
||||||
root = DRIFT.resolve_plugins_dir(os.environ.get("LEDMATRIX_PLUGINS"))
|
|
||||||
if root is None:
|
|
||||||
pytest.skip("set LEDMATRIX_PLUGINS to a ledmatrix-plugins checkout to "
|
|
||||||
"compare this module against the plugin copies")
|
|
||||||
return root
|
|
||||||
|
|
||||||
|
|
||||||
def _class(tree, name):
|
|
||||||
return next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == name)
|
|
||||||
|
|
||||||
|
|
||||||
def _method(cls, name):
|
|
||||||
return next((n for n in cls.body
|
|
||||||
if isinstance(n, ast.FunctionDef) and n.name == name), None)
|
|
||||||
|
|
||||||
|
|
||||||
def _constant(cls, name):
|
|
||||||
"""The value assigned to ``name`` in the class body, as an AST dump."""
|
|
||||||
return next((ast.dump(n.value) for n in cls.body
|
|
||||||
if isinstance(n, ast.AnnAssign) and getattr(n.target, "id", None) == name),
|
|
||||||
None)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _adopted(source, tree):
|
|
||||||
"""The plugin imports core's module and its carrier inherits the mixin."""
|
|
||||||
bases = _class(tree, CARRIER).bases
|
|
||||||
return (sports_view_model.__name__ in source
|
|
||||||
and any(getattr(b, "id", getattr(b, "attr", None)) == MIXIN for b in bases))
|
|
||||||
|
|
||||||
|
|
||||||
def _fingerprint(node):
|
|
||||||
return (DRIFT._digest(node, DRIFT._Canonical()),
|
|
||||||
tuple(ast.unparse(d) for d in node.decorator_list))
|
|
||||||
|
|
||||||
|
|
||||||
def _plugin_tree(root, sport):
|
|
||||||
source = (root / f"{sport}-scoreboard" / "sports.py").read_text(encoding="utf-8")
|
|
||||||
return source, ast.parse(source)
|
|
||||||
|
|
||||||
|
|
||||||
OURS = _class(ast.parse(Path(sports_view_model.__file__).read_text(encoding="utf-8")), MIXIN)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("sport, name", [(s, n) for s in SPORTS for n in METHODS])
|
|
||||||
def test_every_remaining_plugin_copy_matches(sport, name):
|
|
||||||
source, tree = _plugin_tree(_plugins_root(), sport)
|
|
||||||
copy = _method(_class(tree, CARRIER), name)
|
|
||||||
if copy is None:
|
|
||||||
assert _adopted(source, tree), (
|
|
||||||
f"{sport}: no {name} on {CARRIER}, and {CARRIER} does not inherit {MIXIN}")
|
|
||||||
else:
|
|
||||||
assert _fingerprint(copy) == _fingerprint(_method(OURS, name)), (
|
|
||||||
f"{CARRIER}.{name} in {sport} differs from sports_view_model. "
|
|
||||||
f"Port the change to both, or stop treating it as shared.")
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("sport, name", [(s, n) for s in SPORTS for n in CONSTANTS])
|
|
||||||
def test_every_remaining_status_set_matches(sport, name):
|
|
||||||
source, tree = _plugin_tree(_plugins_root(), sport)
|
|
||||||
copy = _constant(_class(tree, CARRIER), name)
|
|
||||||
if copy is None:
|
|
||||||
assert _adopted(source, tree), (
|
|
||||||
f"{sport}: no {name} on {CARRIER}, and {CARRIER} does not inherit {MIXIN}")
|
|
||||||
else:
|
|
||||||
assert copy == _constant(OURS, name), f"{CARRIER}.{name} in {sport} differs"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("sport", SPORTS)
|
|
||||||
def test_no_other_plugin_class_carries_a_copy(sport):
|
|
||||||
"""A copy on another class would shadow the shared one for that class."""
|
|
||||||
_, tree = _plugin_tree(_plugins_root(), sport)
|
|
||||||
strays = [f"{node.name}.{name}" for node in tree.body if isinstance(node, ast.ClassDef)
|
|
||||||
for name in METHODS if node.name != CARRIER and _method(node, name) is not None]
|
|
||||||
assert strays == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_drift_report_calls_them_identical():
|
|
||||||
families = DRIFT.build(_plugins_root(), ("sports.py",))
|
|
||||||
rows = {(r["file"], r["family"]): r
|
|
||||||
for r in (DRIFT.summarise(k, v) for k, v in families.items())}
|
|
||||||
for name in METHODS:
|
|
||||||
row = rows.get(("sports.py", name))
|
|
||||||
assert row is None or row["worst_class_variants"] == 1, name
|
|
||||||
@@ -604,15 +604,6 @@ class TestFreshnessOverTicks:
|
|||||||
|
|
||||||
# --- end to end over a real socket -------------------------------------------------------
|
# --- end to end over a real socket -------------------------------------------------------
|
||||||
|
|
||||||
def _until(predicate, timeout=5.0):
|
|
||||||
end = time.monotonic() + timeout
|
|
||||||
while time.monotonic() < end:
|
|
||||||
if predicate():
|
|
||||||
return True
|
|
||||||
time.sleep(0.005)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
@needs_unix_sockets
|
@needs_unix_sockets
|
||||||
class TestEndToEnd:
|
class TestEndToEnd:
|
||||||
def test_routes_read_the_socket_then_fall_back_when_it_goes(self, web, tmp_path,
|
def test_routes_read_the_socket_then_fall_back_when_it_goes(self, web, tmp_path,
|
||||||
@@ -633,17 +624,14 @@ class TestEndToEnd:
|
|||||||
try:
|
try:
|
||||||
data = _data(client, '/api/v3/display/current-status')
|
data = _data(client, '/api/v3/display/current-status')
|
||||||
assert (data['mode'], data['source']) == ('weather', 'socket')
|
assert (data['mode'], data['source']) == ('weather', 'socket')
|
||||||
# The subscription picks up a change the display publishes. That
|
# The subscription picks up a change the display publishes.
|
||||||
# first answer can be a one-shot state.get with the subscription's
|
|
||||||
# own snapshot still on its way: a change published in between
|
|
||||||
# would read new (one-shot), then old (that snapshot), then new
|
|
||||||
# again (its event). So wait for the subscription first.
|
|
||||||
feed = display_state._subscription()
|
|
||||||
assert _until(lambda: feed.latest() is not None)
|
|
||||||
hub.publish('display', dict(hub.snapshot()['state']['display'], mode='stocks'),
|
hub.publish('display', dict(hub.snapshot()['state']['display'], mode='stocks'),
|
||||||
volatile=('last_updated',))
|
volatile=('last_updated',))
|
||||||
assert _until(lambda: (display_state.current_status(feed.latest()) or {})
|
deadline = time.monotonic() + 5
|
||||||
.get('mode') == 'stocks')
|
while time.monotonic() < deadline:
|
||||||
|
if _data(client, '/api/v3/display/current-status')['mode'] == 'stocks':
|
||||||
|
break
|
||||||
|
time.sleep(0.05)
|
||||||
assert _data(client, '/api/v3/display/current-status')['mode'] == 'stocks'
|
assert _data(client, '/api/v3/display/current-status')['mode'] == 'stocks'
|
||||||
assert hub.subscribers == 1 and hub.readers_active()
|
assert hub.subscribers == 1 and hub.readers_active()
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ restart is owed, and the UI raises the existing restart-pending banner.
|
|||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import contextlib
|
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -21,13 +20,6 @@ from web_interface.blueprints import api_v3 as mod # noqa: E402
|
|||||||
from web_interface.blueprints.api_v3 import api_v3 # noqa: E402
|
from web_interface.blueprints.api_v3 import api_v3 # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
@contextlib.contextmanager
|
|
||||||
def _patched(fake):
|
|
||||||
"""Fake git for both the plain subprocess calls and the timeout-safe runner."""
|
|
||||||
with patch.object(mod.subprocess, 'run', fake), patch.object(mod.git_index_lock, 'run_git', fake):
|
|
||||||
yield
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def client():
|
def client():
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
@@ -70,7 +62,7 @@ def _pull(client):
|
|||||||
|
|
||||||
class TestRestartIsRequestedWhenCodeChanged:
|
class TestRestartIsRequestedWhenCodeChanged:
|
||||||
def test_a_pull_that_moved_head_asks_for_a_restart(self, client):
|
def test_a_pull_that_moved_head_asks_for_a_restart(self, client):
|
||||||
with _patched(_git(['aaa111', 'bbb222'])):
|
with patch.object(mod.subprocess, 'run', _git(['aaa111', 'bbb222'])):
|
||||||
data = _pull(client)
|
data = _pull(client)
|
||||||
assert data['status'] == 'success'
|
assert data['status'] == 'success'
|
||||||
assert data['restart_required'] is True, (
|
assert data['restart_required'] is True, (
|
||||||
@@ -78,14 +70,15 @@ class TestRestartIsRequestedWhenCodeChanged:
|
|||||||
"nothing told the user to restart")
|
"nothing told the user to restart")
|
||||||
|
|
||||||
def test_already_up_to_date_does_not(self, client):
|
def test_already_up_to_date_does_not(self, client):
|
||||||
with _patched(_git(['aaa111', 'aaa111'], pull_out='Already up to date.\n')):
|
with patch.object(mod.subprocess, 'run',
|
||||||
|
_git(['aaa111', 'aaa111'], pull_out='Already up to date.\n')):
|
||||||
data = _pull(client)
|
data = _pull(client)
|
||||||
assert data['status'] == 'success'
|
assert data['status'] == 'success'
|
||||||
assert data['restart_required'] is False, (
|
assert data['restart_required'] is False, (
|
||||||
"prompting after a no-op update trains users to ignore the prompt")
|
"prompting after a no-op update trains users to ignore the prompt")
|
||||||
|
|
||||||
def test_a_failed_pull_does_not(self, client):
|
def test_a_failed_pull_does_not(self, client):
|
||||||
with _patched(_git(['aaa111'], pull_rc=1)):
|
with patch.object(mod.subprocess, 'run', _git(['aaa111'], pull_rc=1)):
|
||||||
data = _pull(client)
|
data = _pull(client)
|
||||||
assert data['status'] == 'error'
|
assert data['status'] == 'error'
|
||||||
assert data['restart_required'] is False
|
assert data['restart_required'] is False
|
||||||
@@ -107,18 +100,19 @@ class TestUnitsAreRefreshedWithTheCode:
|
|||||||
return calls
|
return calls
|
||||||
|
|
||||||
def test_an_update_that_moved_head_refreshes_the_units(self, client, refresh):
|
def test_an_update_that_moved_head_refreshes_the_units(self, client, refresh):
|
||||||
with _patched(_git(['aaa111', 'bbb222'])):
|
with patch.object(mod.subprocess, 'run', _git(['aaa111', 'bbb222'])):
|
||||||
data = _pull(client)
|
data = _pull(client)
|
||||||
assert refresh == [True]
|
assert refresh == [True]
|
||||||
assert data['unit_refresh']['status'] == 'refreshed'
|
assert data['unit_refresh']['status'] == 'refreshed'
|
||||||
assert 'Service settings updated' in data['message']
|
assert 'Service settings updated' in data['message']
|
||||||
|
|
||||||
def test_nothing_new_touches_no_units(self, client, refresh):
|
def test_nothing_new_touches_no_units(self, client, refresh):
|
||||||
with _patched(_git(['aaa111', 'aaa111'], pull_out='Already up to date.\n')):
|
with patch.object(mod.subprocess, 'run',
|
||||||
|
_git(['aaa111', 'aaa111'], pull_out='Already up to date.\n')):
|
||||||
data = _pull(client)
|
data = _pull(client)
|
||||||
assert refresh == [] and data['unit_refresh'] is None
|
assert refresh == [] and data['unit_refresh'] is None
|
||||||
|
|
||||||
def test_a_failed_pull_touches_no_units(self, client, refresh):
|
def test_a_failed_pull_touches_no_units(self, client, refresh):
|
||||||
with _patched(_git(['aaa111'], pull_rc=1)):
|
with patch.object(mod.subprocess, 'run', _git(['aaa111'], pull_rc=1)):
|
||||||
data = _pull(client)
|
data = _pull(client)
|
||||||
assert refresh == [] and data['unit_refresh'] is None
|
assert refresh == [] and data['unit_refresh'] is None
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ from collections import deque
|
|||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
import pytest
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from src.plugin_system.base_plugin import VegasDisplayMode
|
from src.plugin_system.base_plugin import VegasDisplayMode
|
||||||
@@ -234,7 +233,7 @@ class TestCoordinatorStaticPause:
|
|||||||
plugin.display.side_effect = lambda **k: seen.append(lock.locked())
|
plugin.display.side_effect = lambda **k: seen.append(lock.locked())
|
||||||
coord = self._coord(plugin, lock)
|
coord = self._coord(plugin, lock)
|
||||||
assert coord._handle_static_pause(plugin) is True
|
assert coord._handle_static_pause(plugin) is True
|
||||||
assert seen and all(seen)
|
assert seen == [True]
|
||||||
assert not lock.locked()
|
assert not lock.locked()
|
||||||
|
|
||||||
def test_pause_is_skipped_while_update_holds_the_lock(self):
|
def test_pause_is_skipped_while_update_holds_the_lock(self):
|
||||||
@@ -248,108 +247,3 @@ class TestCoordinatorStaticPause:
|
|||||||
finally:
|
finally:
|
||||||
lock.release()
|
lock.release()
|
||||||
plugin.display.assert_not_called()
|
plugin.display.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
class FakeClock:
|
|
||||||
"""time.monotonic/time.sleep for the pause loop: sleeping moves the clock."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.now = 0.0
|
|
||||||
|
|
||||||
def monotonic(self):
|
|
||||||
return self.now
|
|
||||||
|
|
||||||
def sleep(self, seconds):
|
|
||||||
assert seconds >= 0
|
|
||||||
self.now += seconds
|
|
||||||
if self.now > 3600:
|
|
||||||
raise RuntimeError("the static pause never ended")
|
|
||||||
|
|
||||||
|
|
||||||
class TestStaticPauseRedraws:
|
|
||||||
"""A pause redraws its plugin for as long as it lasts.
|
|
||||||
|
|
||||||
It used to call display() once and sleep out the duration, so a plugin
|
|
||||||
that rotates its screens or animates (Fantasy Blitz's playlist, say) was
|
|
||||||
frozen on its first frame for the whole pause.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def _run(self, monkeypatch, duration=10.0, high_fps=False, interrupt_at=None,
|
|
||||||
lock=None):
|
|
||||||
from src.vegas_mode import coordinator
|
|
||||||
clock = FakeClock()
|
|
||||||
monkeypatch.setattr(coordinator, 'time', clock)
|
|
||||||
plugin = MagicMock()
|
|
||||||
plugin.plugin_id = 'clock'
|
|
||||||
plugin.needs_high_fps = high_fps
|
|
||||||
plugin.get_display_duration.return_value = duration
|
|
||||||
calls = []
|
|
||||||
plugin.display.side_effect = lambda **k: calls.append((clock.now, k))
|
|
||||||
coord = TestCoordinatorStaticPause()._coord(plugin, lock)
|
|
||||||
if interrupt_at is not None:
|
|
||||||
coord._interrupt_check = lambda: clock.now >= interrupt_at
|
|
||||||
return coord._handle_static_pause(plugin), calls, clock
|
|
||||||
|
|
||||||
def test_redraws_once_a_second_for_the_whole_pause(self, monkeypatch):
|
|
||||||
done, calls, clock = self._run(monkeypatch, duration=10.0)
|
|
||||||
assert done is True
|
|
||||||
assert len(calls) == 10
|
|
||||||
assert [round(t, 6) for t, _ in calls] == [float(i) for i in range(10)]
|
|
||||||
assert 10.0 <= clock.now < 10.2
|
|
||||||
|
|
||||||
def test_only_the_first_draw_clears(self, monkeypatch):
|
|
||||||
_, calls, _ = self._run(monkeypatch, duration=3.0)
|
|
||||||
assert calls[0][1] == {'force_clear': True}
|
|
||||||
assert all(kwargs == {} for _, kwargs in calls[1:])
|
|
||||||
|
|
||||||
def test_a_high_fps_plugin_redraws_at_the_high_fps_rate(self, monkeypatch):
|
|
||||||
from src.screen_runner import HIGH_FPS_INTERVAL
|
|
||||||
_, calls, _ = self._run(monkeypatch, duration=1.0, high_fps=True)
|
|
||||||
assert len(calls) == pytest.approx(1.0 / HIGH_FPS_INTERVAL, abs=2)
|
|
||||||
|
|
||||||
def test_the_high_fps_rule_matches_the_rotation(self):
|
|
||||||
from src.vegas_mode.coordinator import VegasModeCoordinator
|
|
||||||
rule = VegasModeCoordinator._static_needs_high_fps
|
|
||||||
legacy_image = SimpleNamespace(plugin_id='static-image')
|
|
||||||
assert rule(legacy_image) is True
|
|
||||||
assert rule(SimpleNamespace(plugin_id='static-image', needs_high_fps=False)) is False
|
|
||||||
assert rule(SimpleNamespace(plugin_id='clock', enable_scrolling=True)) is True
|
|
||||||
assert rule(SimpleNamespace(plugin_id='clock')) is False
|
|
||||||
|
|
||||||
def test_an_interrupt_still_ends_the_pause(self, monkeypatch):
|
|
||||||
done, calls, clock = self._run(monkeypatch, duration=30.0, interrupt_at=2.5)
|
|
||||||
assert done is False
|
|
||||||
assert clock.now < 2.7
|
|
||||||
assert len(calls) == 3
|
|
||||||
|
|
||||||
def test_a_held_lock_skips_one_redraw_not_the_pause(self, monkeypatch):
|
|
||||||
lock = threading.Lock()
|
|
||||||
real_acquire = lock.acquire
|
|
||||||
held = {'count': 0}
|
|
||||||
|
|
||||||
class FlakyLock:
|
|
||||||
def acquire(self, timeout=-1):
|
|
||||||
held['count'] += 1
|
|
||||||
# update() holds the lock at the third draw only.
|
|
||||||
return False if held['count'] == 3 else real_acquire(timeout=timeout)
|
|
||||||
|
|
||||||
def release(self):
|
|
||||||
lock.release()
|
|
||||||
|
|
||||||
done, calls, _ = self._run(monkeypatch, duration=5.0, lock=FlakyLock())
|
|
||||||
assert done is True
|
|
||||||
assert [round(t) for t, _ in calls] == [0, 1, 3, 4]
|
|
||||||
|
|
||||||
def test_a_raise_from_a_redraw_ends_the_pause(self, monkeypatch):
|
|
||||||
from src.vegas_mode import coordinator
|
|
||||||
clock = FakeClock()
|
|
||||||
monkeypatch.setattr(coordinator, 'time', clock)
|
|
||||||
plugin = MagicMock()
|
|
||||||
plugin.plugin_id = 'clock'
|
|
||||||
plugin.needs_high_fps = False
|
|
||||||
plugin.get_display_duration.return_value = 30.0
|
|
||||||
plugin.display.side_effect = [None, RuntimeError('boom')]
|
|
||||||
coord = TestCoordinatorStaticPause()._coord(plugin)
|
|
||||||
assert coord._handle_static_pause(plugin) is False
|
|
||||||
assert clock.now < 1.2
|
|
||||||
assert coord._static_pause_active is False
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import MagicMock, call
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
os.environ.setdefault("EMULATOR", "true")
|
os.environ.setdefault("EMULATOR", "true")
|
||||||
|
|
||||||
@@ -62,7 +62,6 @@ def _plugin(duration, plugin_id='clock-simple'):
|
|||||||
plugin = MagicMock()
|
plugin = MagicMock()
|
||||||
plugin.plugin_id = plugin_id
|
plugin.plugin_id = plugin_id
|
||||||
plugin.get_display_duration.return_value = duration
|
plugin.get_display_duration.return_value = duration
|
||||||
plugin.needs_high_fps = False
|
|
||||||
return plugin
|
return plugin
|
||||||
|
|
||||||
|
|
||||||
@@ -105,9 +104,7 @@ class TestPauseLength:
|
|||||||
completed, lasted = _pause(_coord(plugin), plugin, clock)
|
completed, lasted = _pause(_coord(plugin), plugin, clock)
|
||||||
assert completed is True
|
assert completed is True
|
||||||
assert lasted == pytest.approx(30.0, abs=0.15)
|
assert lasted == pytest.approx(30.0, abs=0.15)
|
||||||
# Redrawn once a second; only the first draw clears.
|
plugin.display.assert_called_once_with(force_clear=True)
|
||||||
assert plugin.display.call_args_list[0] == call(force_clear=True)
|
|
||||||
assert plugin.display.call_count == 30
|
|
||||||
|
|
||||||
@pytest.mark.parametrize('value', NOT_ABOVE_ZERO, ids=repr)
|
@pytest.mark.parametrize('value', NOT_ABOVE_ZERO, ids=repr)
|
||||||
def test_a_number_not_above_zero_pauses_for_15s(self, clock, value):
|
def test_a_number_not_above_zero_pauses_for_15s(self, clock, value):
|
||||||
|
|||||||
@@ -59,25 +59,6 @@ def test_a_refresh_logs_a_single_info_summary(caplog):
|
|||||||
assert any('off' in m and 'not enabled' in m for m in debug)
|
assert any('off' in m and 'not enabled' in m for m in debug)
|
||||||
|
|
||||||
|
|
||||||
def test_an_empty_list_is_logged_once_until_it_fills(caplog):
|
|
||||||
# Every plugin scheduled off: the display retries every second.
|
|
||||||
stream = _stream({'clock': _Plugin()})
|
|
||||||
stream.hidden_plugins = lambda: frozenset({'clock'})
|
|
||||||
with caplog.at_level(logging.DEBUG, logger=LOGGER):
|
|
||||||
for _ in range(3):
|
|
||||||
assert not stream.initialize()
|
|
||||||
loud = [r for r in caplog.records if r.name == LOGGER and r.levelno >= logging.INFO]
|
|
||||||
assert len(loud) == 2, [r.getMessage() for r in loud] # one summary, one warning
|
|
||||||
caplog.clear()
|
|
||||||
stream.hidden_plugins = lambda: frozenset()
|
|
||||||
with caplog.at_level(logging.DEBUG, logger=LOGGER):
|
|
||||||
assert stream.initialize()
|
|
||||||
stream.hidden_plugins = lambda: frozenset({'clock'})
|
|
||||||
assert not stream.initialize()
|
|
||||||
warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
|
|
||||||
assert len(warnings) == 1 # warned again after the list had filled
|
|
||||||
|
|
||||||
|
|
||||||
def test_fetching_content_logs_nothing_at_info(caplog):
|
def test_fetching_content_logs_nothing_at_info(caplog):
|
||||||
stream = _stream({'clock': _Plugin()})
|
stream = _stream({'clock': _Plugin()})
|
||||||
with caplog.at_level(logging.DEBUG, logger=LOGGER):
|
with caplog.at_level(logging.DEBUG, logger=LOGGER):
|
||||||
|
|||||||
+8
-11
@@ -528,17 +528,14 @@ class TestDisplayAPI:
|
|||||||
from web_interface.blueprints.api_v3 import api_v3
|
from web_interface.blueprints.api_v3 import api_v3
|
||||||
|
|
||||||
mock_cache_manager = api_v3.cache_manager = MagicMock()
|
mock_cache_manager = api_v3.cache_manager = MagicMock()
|
||||||
|
|
||||||
with patch('web_interface.blueprints.api_v3.display.control_client.on_demand_stop',
|
response = client.post('/api/v3/display/on-demand/stop')
|
||||||
side_effect=lambda request_id, **kw: {'accepted': True}) as stop:
|
|
||||||
response = client.post('/api/v3/display/on-demand/stop')
|
# May return 200 if successful or 500 on error
|
||||||
|
assert response.status_code in [200, 500]
|
||||||
assert response.status_code == 200
|
# Verify stop request was set in cache if successful
|
||||||
assert response.get_json()['data']['transport'] == 'socket'
|
if response.status_code == 200:
|
||||||
stop.assert_called_once()
|
assert mock_cache_manager.set.called
|
||||||
# The request goes over the control socket; nothing is written to
|
|
||||||
# the cache (the file mailbox is gone).
|
|
||||||
mock_cache_manager.set.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
class TestPluginsAPI:
|
class TestPluginsAPI:
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ REMOVED_CATCH_ALLS = [
|
|||||||
("GET", "/api/v3/config/main", None),
|
("GET", "/api/v3/config/main", None),
|
||||||
("GET", "/api/v3/config/secrets", None),
|
("GET", "/api/v3/config/secrets", None),
|
||||||
("GET", "/api/v3/display/modes", None),
|
("GET", "/api/v3/display/modes", None),
|
||||||
|
("POST", "/api/v3/display/on-demand/stop", {}),
|
||||||
("GET", "/api/v3/cache/list", None),
|
("GET", "/api/v3/cache/list", None),
|
||||||
("GET", "/api/v3/plugins/installed", None),
|
("GET", "/api/v3/plugins/installed", None),
|
||||||
("GET", "/api/v3/plugins/health", None),
|
("GET", "/api/v3/plugins/health", None),
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ import time
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from web_interface import git_index_lock, update_channel
|
from web_interface import update_channel
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -302,12 +302,10 @@ def local_changes(project_dir, run=None, timeout=30):
|
|||||||
Returns the changed paths, or None when git could not tell. Raises what
|
Returns the changed paths, or None when git could not tell. Raises what
|
||||||
``run`` raises (e.g. ``subprocess.TimeoutExpired``).
|
``run`` raises (e.g. ``subprocess.TimeoutExpired``).
|
||||||
"""
|
"""
|
||||||
# A status that times out must not leave index.lock: don't refresh the index.
|
run = run or subprocess.run
|
||||||
run = run or git_index_lock.run_git
|
|
||||||
result = run(['git', '-c', 'core.fileMode=false', 'status', '--porcelain',
|
result = run(['git', '-c', 'core.fileMode=false', 'status', '--porcelain',
|
||||||
'--untracked-files=no', '-z'],
|
'--untracked-files=no', '-z'],
|
||||||
cwd=str(project_dir), capture_output=True, text=True, timeout=timeout,
|
cwd=str(project_dir), capture_output=True, text=True, timeout=timeout)
|
||||||
env=git_index_lock.read_only_env())
|
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
return None
|
return None
|
||||||
changed = []
|
changed = []
|
||||||
@@ -617,9 +615,6 @@ class AutoUpdater:
|
|||||||
if core.get('status') != 'success':
|
if core.get('status') != 'success':
|
||||||
message = core.get('message') or 'The LEDMatrix update failed.'
|
message = core.get('message') or 'The LEDMatrix update failed.'
|
||||||
if new_head and new_head != old_head:
|
if new_head and new_head != old_head:
|
||||||
# A pull killed by its timeout leaves .git/index.lock behind,
|
|
||||||
# and the rollback's reset would fail on it.
|
|
||||||
git_index_lock.clear_stale_index_lock(self.project_root)
|
|
||||||
ok, detail = rollback()
|
ok, detail = rollback()
|
||||||
message += (' The partial update was rolled back.' if ok
|
message += (' The partial update was rolled back.' if ok
|
||||||
else f' Rolling back the partial update also failed: {detail}.')
|
else f' Rolling back the partial update also failed: {detail}.')
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ from typing import Dict, Any, Optional, Tuple, Type
|
|||||||
from urllib.parse import urlparse, urlunparse
|
from urllib.parse import urlparse, urlunparse
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
# Import new infrastructure
|
# Import new infrastructure
|
||||||
from web_interface import git_index_lock
|
|
||||||
from src.web_interface.api_helpers import (success_response, error_response,
|
from src.web_interface.api_helpers import (success_response, error_response,
|
||||||
exception_error_response, validate_request_json)
|
exception_error_response, validate_request_json)
|
||||||
from src.web_interface.errors import ErrorCode
|
from src.web_interface.errors import ErrorCode
|
||||||
@@ -559,8 +558,7 @@ def get_git_version(project_dir=None):
|
|||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=5,
|
timeout=5,
|
||||||
cwd=str(project_dir),
|
cwd=str(project_dir)
|
||||||
env=git_index_lock.read_only_env()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
|
|||||||
@@ -224,105 +224,6 @@ def get_refresh_rate():
|
|||||||
}})
|
}})
|
||||||
|
|
||||||
|
|
||||||
def _build_schedule(data):
|
|
||||||
"""``(schedule, None)`` from a schedule picker's POST body, or ``(None, message)``.
|
|
||||||
|
|
||||||
The display schedule and each plugin schedule (``plugin_schedules``)
|
|
||||||
share it. Times are validated; a per-day schedule that is on needs a day
|
|
||||||
that is on.
|
|
||||||
"""
|
|
||||||
# Build schedule configuration
|
|
||||||
# Handle enabled checkbox - can be True, False, or 'on'
|
|
||||||
enabled_value = data.get('enabled', False)
|
|
||||||
if isinstance(enabled_value, str):
|
|
||||||
enabled_value = enabled_value.lower() in ('true', 'on', '1')
|
|
||||||
schedule_config = {
|
|
||||||
'enabled': enabled_value
|
|
||||||
}
|
|
||||||
|
|
||||||
mode = data.get('mode', 'global')
|
|
||||||
schedule_config['mode'] = mode
|
|
||||||
|
|
||||||
if mode == 'global':
|
|
||||||
# Simple global schedule
|
|
||||||
start_time = data.get('start_time', '07:00')
|
|
||||||
end_time = data.get('end_time', '23:00')
|
|
||||||
|
|
||||||
# Validate time formats
|
|
||||||
is_valid, error_msg = _validate_time_format(start_time)
|
|
||||||
if not is_valid:
|
|
||||||
return None, error_msg
|
|
||||||
|
|
||||||
is_valid, error_msg = _validate_time_format(end_time)
|
|
||||||
if not is_valid:
|
|
||||||
return None, error_msg
|
|
||||||
|
|
||||||
schedule_config['start_time'] = start_time
|
|
||||||
schedule_config['end_time'] = end_time
|
|
||||||
# Remove days config when switching to global mode
|
|
||||||
schedule_config.pop('days', None)
|
|
||||||
else:
|
|
||||||
# Per-day schedule
|
|
||||||
schedule_config['days'] = {}
|
|
||||||
# Remove global times when switching to per-day mode
|
|
||||||
schedule_config.pop('start_time', None)
|
|
||||||
schedule_config.pop('end_time', None)
|
|
||||||
days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']
|
|
||||||
enabled_days_count = 0
|
|
||||||
|
|
||||||
for day in days:
|
|
||||||
day_config = {}
|
|
||||||
enabled_key = f'{day}_enabled'
|
|
||||||
start_key = f'{day}_start'
|
|
||||||
end_key = f'{day}_end'
|
|
||||||
|
|
||||||
# Check if day is enabled
|
|
||||||
has_enabled, enabled_val = _day_setting(data, day, enabled_key, 'enabled')
|
|
||||||
if has_enabled:
|
|
||||||
# Handle checkbox values that may come as 'on', True, or False
|
|
||||||
if isinstance(enabled_val, str):
|
|
||||||
day_config['enabled'] = enabled_val.lower() in ('true', 'on', '1')
|
|
||||||
else:
|
|
||||||
day_config['enabled'] = bool(enabled_val)
|
|
||||||
else:
|
|
||||||
# Default to enabled if not specified
|
|
||||||
day_config['enabled'] = True
|
|
||||||
|
|
||||||
# Only add times if day is enabled
|
|
||||||
if day_config.get('enabled', True):
|
|
||||||
enabled_days_count += 1
|
|
||||||
start_time = None
|
|
||||||
end_time = None
|
|
||||||
|
|
||||||
start_time = _day_setting(data, day, start_key, 'start_time')[1] or '07:00'
|
|
||||||
end_time = _day_setting(data, day, end_key, 'end_time')[1] or '23:00'
|
|
||||||
|
|
||||||
# Validate time formats
|
|
||||||
is_valid, error_msg = _validate_time_format(start_time)
|
|
||||||
if not is_valid:
|
|
||||||
return None, f"Invalid start time for {day}: {error_msg}"
|
|
||||||
|
|
||||||
is_valid, error_msg = _validate_time_format(end_time)
|
|
||||||
if not is_valid:
|
|
||||||
return None, f"Invalid end time for {day}: {error_msg}"
|
|
||||||
|
|
||||||
day_config['start_time'] = start_time
|
|
||||||
day_config['end_time'] = end_time
|
|
||||||
else:
|
|
||||||
day_config.update(_disabled_day_times(data, day, start_key, end_key))
|
|
||||||
|
|
||||||
schedule_config['days'][day] = day_config
|
|
||||||
|
|
||||||
# An enabled per-day schedule needs a day to be on. A disabled
|
|
||||||
# one does not: every day off with the schedule off is what
|
|
||||||
# config.template.json ships, so refusing it meant a fresh
|
|
||||||
# install could not post back the schedule GET returned.
|
|
||||||
if enabled_days_count == 0 and enabled_value:
|
|
||||||
return None, "At least one day must be enabled in per-day schedule mode"
|
|
||||||
|
|
||||||
return schedule_config, None
|
|
||||||
|
|
||||||
|
|
||||||
@api_v3.route('/config/schedule', methods=['GET'])
|
@api_v3.route('/config/schedule', methods=['GET'])
|
||||||
def get_schedule_config():
|
def get_schedule_config():
|
||||||
"""Get current schedule configuration"""
|
"""Get current schedule configuration"""
|
||||||
@@ -360,9 +261,114 @@ def save_schedule_config():
|
|||||||
# Load current config
|
# Load current config
|
||||||
current_config = api_v3.config_manager.load_config()
|
current_config = api_v3.config_manager.load_config()
|
||||||
|
|
||||||
schedule_config, error_msg = _build_schedule(data)
|
# Build schedule configuration
|
||||||
if error_msg:
|
# Handle enabled checkbox - can be True, False, or 'on'
|
||||||
return error_response(ErrorCode.VALIDATION_ERROR, error_msg, status_code=400)
|
enabled_value = data.get('enabled', False)
|
||||||
|
if isinstance(enabled_value, str):
|
||||||
|
enabled_value = enabled_value.lower() in ('true', 'on', '1')
|
||||||
|
schedule_config = {
|
||||||
|
'enabled': enabled_value
|
||||||
|
}
|
||||||
|
|
||||||
|
mode = data.get('mode', 'global')
|
||||||
|
schedule_config['mode'] = mode
|
||||||
|
|
||||||
|
if mode == 'global':
|
||||||
|
# Simple global schedule
|
||||||
|
start_time = data.get('start_time', '07:00')
|
||||||
|
end_time = data.get('end_time', '23:00')
|
||||||
|
|
||||||
|
# Validate time formats
|
||||||
|
is_valid, error_msg = _validate_time_format(start_time)
|
||||||
|
if not is_valid:
|
||||||
|
return error_response(
|
||||||
|
ErrorCode.VALIDATION_ERROR,
|
||||||
|
error_msg,
|
||||||
|
status_code=400
|
||||||
|
)
|
||||||
|
|
||||||
|
is_valid, error_msg = _validate_time_format(end_time)
|
||||||
|
if not is_valid:
|
||||||
|
return error_response(
|
||||||
|
ErrorCode.VALIDATION_ERROR,
|
||||||
|
error_msg,
|
||||||
|
status_code=400
|
||||||
|
)
|
||||||
|
|
||||||
|
schedule_config['start_time'] = start_time
|
||||||
|
schedule_config['end_time'] = end_time
|
||||||
|
# Remove days config when switching to global mode
|
||||||
|
schedule_config.pop('days', None)
|
||||||
|
else:
|
||||||
|
# Per-day schedule
|
||||||
|
schedule_config['days'] = {}
|
||||||
|
# Remove global times when switching to per-day mode
|
||||||
|
schedule_config.pop('start_time', None)
|
||||||
|
schedule_config.pop('end_time', None)
|
||||||
|
days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']
|
||||||
|
enabled_days_count = 0
|
||||||
|
|
||||||
|
for day in days:
|
||||||
|
day_config = {}
|
||||||
|
enabled_key = f'{day}_enabled'
|
||||||
|
start_key = f'{day}_start'
|
||||||
|
end_key = f'{day}_end'
|
||||||
|
|
||||||
|
# Check if day is enabled
|
||||||
|
has_enabled, enabled_val = _day_setting(data, day, enabled_key, 'enabled')
|
||||||
|
if has_enabled:
|
||||||
|
# Handle checkbox values that may come as 'on', True, or False
|
||||||
|
if isinstance(enabled_val, str):
|
||||||
|
day_config['enabled'] = enabled_val.lower() in ('true', 'on', '1')
|
||||||
|
else:
|
||||||
|
day_config['enabled'] = bool(enabled_val)
|
||||||
|
else:
|
||||||
|
# Default to enabled if not specified
|
||||||
|
day_config['enabled'] = True
|
||||||
|
|
||||||
|
# Only add times if day is enabled
|
||||||
|
if day_config.get('enabled', True):
|
||||||
|
enabled_days_count += 1
|
||||||
|
start_time = None
|
||||||
|
end_time = None
|
||||||
|
|
||||||
|
start_time = _day_setting(data, day, start_key, 'start_time')[1] or '07:00'
|
||||||
|
end_time = _day_setting(data, day, end_key, 'end_time')[1] or '23:00'
|
||||||
|
|
||||||
|
# Validate time formats
|
||||||
|
is_valid, error_msg = _validate_time_format(start_time)
|
||||||
|
if not is_valid:
|
||||||
|
return error_response(
|
||||||
|
ErrorCode.VALIDATION_ERROR,
|
||||||
|
f"Invalid start time for {day}: {error_msg}",
|
||||||
|
status_code=400
|
||||||
|
)
|
||||||
|
|
||||||
|
is_valid, error_msg = _validate_time_format(end_time)
|
||||||
|
if not is_valid:
|
||||||
|
return error_response(
|
||||||
|
ErrorCode.VALIDATION_ERROR,
|
||||||
|
f"Invalid end time for {day}: {error_msg}",
|
||||||
|
status_code=400
|
||||||
|
)
|
||||||
|
|
||||||
|
day_config['start_time'] = start_time
|
||||||
|
day_config['end_time'] = end_time
|
||||||
|
else:
|
||||||
|
day_config.update(_disabled_day_times(data, day, start_key, end_key))
|
||||||
|
|
||||||
|
schedule_config['days'][day] = day_config
|
||||||
|
|
||||||
|
# An enabled per-day schedule needs a day to be on. A disabled
|
||||||
|
# one does not: every day off with the schedule off is what
|
||||||
|
# config.template.json ships, so refusing it meant a fresh
|
||||||
|
# install could not post back the schedule GET returned.
|
||||||
|
if enabled_days_count == 0 and enabled_value:
|
||||||
|
return error_response(
|
||||||
|
ErrorCode.VALIDATION_ERROR,
|
||||||
|
"At least one day must be enabled in per-day schedule mode",
|
||||||
|
status_code=400
|
||||||
|
)
|
||||||
|
|
||||||
# Update and save config using atomic save
|
# Update and save config using atomic save
|
||||||
current_config['schedule'] = schedule_config
|
current_config['schedule'] = schedule_config
|
||||||
@@ -385,67 +391,6 @@ def save_schedule_config():
|
|||||||
|
|
||||||
status_code=500, details=describe_exception(e)
|
status_code=500, details=describe_exception(e)
|
||||||
)
|
)
|
||||||
@api_v3.route('/config/plugin-schedules', methods=['GET'])
|
|
||||||
def get_plugin_schedules():
|
|
||||||
"""Every plugin's schedule (config['plugin_schedules']), by plugin id."""
|
|
||||||
if not api_v3.config_manager:
|
|
||||||
return error_response(ErrorCode.CONFIG_LOAD_FAILED,
|
|
||||||
'Config manager not initialized', status_code=500)
|
|
||||||
config = api_v3.config_manager.load_config()
|
|
||||||
return success_response(data=config.get('plugin_schedules') or {})
|
|
||||||
|
|
||||||
|
|
||||||
@api_v3.route('/config/plugin-schedules', methods=['POST'])
|
|
||||||
def save_plugin_schedule():
|
|
||||||
"""Save one plugin's schedule (src/plugin_schedule.py).
|
|
||||||
|
|
||||||
The body is a schedule picker's fields plus ``plugin_id`` and
|
|
||||||
``hide_when_live``. A schedule that is off and does not hide during live
|
|
||||||
games changes nothing, so it is removed rather than stored.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
if not api_v3.config_manager:
|
|
||||||
return error_response(ErrorCode.CONFIG_LOAD_FAILED,
|
|
||||||
'Config manager not initialized', status_code=500)
|
|
||||||
data = request.get_json(silent=True)
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
return error_response(ErrorCode.VALIDATION_ERROR, 'No data provided', status_code=400)
|
|
||||||
plugin_id = data.get('plugin_id')
|
|
||||||
if not isinstance(plugin_id, str) or not plugin_id:
|
|
||||||
return error_response(ErrorCode.VALIDATION_ERROR, 'plugin_id is required', status_code=400)
|
|
||||||
not_plugin = _pkg._non_plugin_id_error(plugin_id)
|
|
||||||
if not_plugin is not None:
|
|
||||||
return not_plugin
|
|
||||||
if plugin_id not in _pkg._discovered_plugin_manifests(plugin_id):
|
|
||||||
return error_response(ErrorCode.PLUGIN_NOT_FOUND,
|
|
||||||
f"Plugin '{plugin_id}' is not installed", status_code=404)
|
|
||||||
|
|
||||||
schedule, error_msg = _build_schedule(data)
|
|
||||||
if error_msg:
|
|
||||||
return error_response(ErrorCode.VALIDATION_ERROR, error_msg, status_code=400)
|
|
||||||
schedule['hide_when_live'] = _coerce_to_bool(data.get('hide_when_live'))
|
|
||||||
|
|
||||||
current_config = api_v3.config_manager.load_config()
|
|
||||||
schedules = dict(current_config.get('plugin_schedules') or {})
|
|
||||||
if schedule['enabled'] or schedule['hide_when_live']:
|
|
||||||
schedules[plugin_id] = schedule
|
|
||||||
else:
|
|
||||||
schedules.pop(plugin_id, None)
|
|
||||||
current_config['plugin_schedules'] = schedules
|
|
||||||
success, error_msg = _pkg._save_config_atomic(api_v3.config_manager, current_config,
|
|
||||||
create_backup=True)
|
|
||||||
if not success:
|
|
||||||
return error_response(ErrorCode.CONFIG_SAVE_FAILED,
|
|
||||||
f"Failed to save plugin schedule: {error_msg}", status_code=500)
|
|
||||||
invalidate_cache()
|
|
||||||
return success_response(message=f"Schedule for {plugin_id} saved")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Error saving plugin schedule", exc_info=True)
|
|
||||||
return error_response(ErrorCode.CONFIG_SAVE_FAILED,
|
|
||||||
"An error occurred; see logs for details",
|
|
||||||
status_code=500, details=describe_exception(e))
|
|
||||||
|
|
||||||
|
|
||||||
@api_v3.route('/config/dim-schedule', methods=['GET'])
|
@api_v3.route('/config/dim-schedule', methods=['GET'])
|
||||||
def get_dim_schedule_config():
|
def get_dim_schedule_config():
|
||||||
"""Get current dim schedule configuration"""
|
"""Get current dim schedule configuration"""
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from web_interface.blueprints.api_v3 import (
|
|||||||
_get_display_service_status, _socket_reason_code, _stop_display_service, api_v3,
|
_get_display_service_status, _socket_reason_code, _stop_display_service, api_v3,
|
||||||
jsonify, logger, request, uuid,
|
jsonify, logger, request, uuid,
|
||||||
)
|
)
|
||||||
from web_interface import display_preview, display_state, on_demand_dispatch
|
from web_interface import display_preview, display_state
|
||||||
import web_interface.blueprints.api_v3 as _pkg
|
import web_interface.blueprints.api_v3 as _pkg
|
||||||
from src.ipc import client as control_client
|
from src.ipc import client as control_client
|
||||||
# Read through the module rather than bound by value: tests patch these
|
# Read through the module rather than bound by value: tests patch these
|
||||||
@@ -33,97 +33,87 @@ def _cache_manager():
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
#: How long a start is sent again to a service that systemd reports running
|
class _NotDelivered(Exception):
|
||||||
#: but that has no socket yet (a display still loading its plugins, or one
|
"""The display took an on-demand request over the socket and did not
|
||||||
#: someone else just restarted). A cold start gets the dispatcher's own
|
accept it (``busy``, ``invalid_args``, ...) or did not answer in time."""
|
||||||
#: START_WAIT_SECONDS. Either way the route answers at once (202) and the
|
|
||||||
#: web process's dispatcher does the waiting.
|
def __init__(self, reason):
|
||||||
ON_DEMAND_SOCKET_WAIT_RUNNING_SECONDS = 10.0
|
super().__init__(reason)
|
||||||
|
self.reason = reason
|
||||||
|
|
||||||
|
|
||||||
def _dispatcher():
|
def _deliver_on_demand(payload):
|
||||||
"""The web process's on-demand dispatcher (web_interface/on_demand_dispatch.py)."""
|
"""Hand an on-demand request to the display: control socket, else mailbox.
|
||||||
return on_demand_dispatch.get_dispatcher(_send_on_demand)
|
|
||||||
|
|
||||||
|
The socket (src/ipc) answers with an acknowledgement as soon as the
|
||||||
|
display has the command queued for its render thread. The file mailbox
|
||||||
|
is written only when the socket could not carry the request at all
|
||||||
|
(``control_client.should_fall_back``): no socket (the display is stopped
|
||||||
|
or predates it), a refused connection, or a display too old to know the
|
||||||
|
command. The display reads it within its mailbox poll interval.
|
||||||
|
|
||||||
def _pending_start_state():
|
A display that had the request and refused it, or did not answer in
|
||||||
"""A start the dispatcher is still delivering, one it delivered but the
|
time, raises :class:`_NotDelivered`: a mailbox copy would be refused
|
||||||
display has not acted on yet, or one it gave up on: what the status
|
the same way, or hide a stuck display behind a "success".
|
||||||
routes report instead of the display's own state (see _shadows). None
|
|
||||||
when there is none.
|
|
||||||
|
|
||||||
A delivered start reads as ``status: "starting"`` with ``delivered:
|
Returns ``(transport, socket_error)``: ``'socket'`` and None, or
|
||||||
true`` for at most DELIVERED_SHOWN_SECONDS: the display acknowledges it
|
``'mailbox'`` and the socket failure's reason code.
|
||||||
when its socket opens and acts on it seconds later, and until then
|
|
||||||
publishes its own idle state, which would flash in the UI.
|
|
||||||
"""
|
"""
|
||||||
dispatcher = on_demand_dispatch.current()
|
try:
|
||||||
status = dispatcher.status() if dispatcher is not None else None
|
if payload['action'] == 'start':
|
||||||
if status is None:
|
control_client.on_demand_start(
|
||||||
return None
|
payload['request_id'], payload.get('plugin_id'), payload.get('mode'),
|
||||||
if status.get('status') == 'delivered':
|
payload.get('duration'), bool(payload.get('pinned', False)))
|
||||||
delivered_at = status.get('last_updated') or 0
|
else:
|
||||||
if _pkg.time.time() - delivered_at > on_demand_dispatch.DELIVERED_SHOWN_SECONDS:
|
control_client.on_demand_stop(payload['request_id'])
|
||||||
return None
|
return 'socket', None
|
||||||
return dict(status, status='starting', delivered=True, delivered_at=delivered_at)
|
except control_client.ControlError as e:
|
||||||
if status.get('status') not in ('starting', 'error'):
|
reason = _socket_reason_code(e.reason)
|
||||||
return None
|
if not control_client.should_fall_back(e):
|
||||||
return status
|
logger.warning("The display did not accept on-demand %s %s (%s)",
|
||||||
|
payload['action'], payload['request_id'], e)
|
||||||
|
raise _NotDelivered(reason) from None
|
||||||
|
if reason in _QUIET_SOCKET_REASONS:
|
||||||
|
logger.debug("On-demand %s via the mailbox: %s", payload['action'], e)
|
||||||
|
else:
|
||||||
|
logger.warning("Control socket did not take on-demand %s (%s); "
|
||||||
|
"using the mailbox", payload['action'], e)
|
||||||
|
except Exception: # never let the socket path break the route
|
||||||
|
logger.exception("Control socket client failed; using the mailbox")
|
||||||
|
reason = 'internal'
|
||||||
|
_cache_manager().set('display_on_demand_request', payload)
|
||||||
|
return 'mailbox', reason
|
||||||
|
|
||||||
|
|
||||||
def _display_on_demand_state(snapshot):
|
def _not_delivered_response(request_id, action, reason):
|
||||||
"""The display's own on-demand state: (state, source). From the socket's
|
"""The answer when the display had the request and did not accept it."""
|
||||||
snapshot, else the cache key it also writes; state None when neither."""
|
|
||||||
state = display_state.on_demand_state(snapshot)
|
|
||||||
if state is not None:
|
|
||||||
return state, 'socket'
|
|
||||||
# memory_ttl=0: the display service writes this key, so only the file
|
|
||||||
# is current. This process's memory tier would keep serving the first
|
|
||||||
# copy it read for the full max_age -- "active" for two minutes after
|
|
||||||
# the display had already stopped.
|
|
||||||
return _cache_manager().get('display_on_demand_state', max_age=120, memory_ttl=0), 'cache'
|
|
||||||
|
|
||||||
|
|
||||||
def _send_on_demand(payload):
|
|
||||||
"""Hand an on-demand request to the display over the control socket.
|
|
||||||
|
|
||||||
Returns the display's acknowledgement: it has the command queued for
|
|
||||||
its render thread. Raises ``control_client.ControlError`` when it did
|
|
||||||
not take it; there is no other way to reach the display (the file
|
|
||||||
mailbox ``display_on_demand_request`` is gone).
|
|
||||||
"""
|
|
||||||
if payload['action'] == 'start':
|
|
||||||
return control_client.on_demand_start(
|
|
||||||
payload['request_id'], payload.get('plugin_id'), payload.get('mode'),
|
|
||||||
payload.get('duration'), bool(payload.get('pinned', False)))
|
|
||||||
return control_client.on_demand_stop(payload['request_id'])
|
|
||||||
|
|
||||||
|
|
||||||
def _socket_error_response(request_id, action, reason, message=None, **extra):
|
|
||||||
"""The answer when the display did not take an on-demand request: ``400``
|
|
||||||
for arguments it refused, else ``503``."""
|
|
||||||
status = 400 if reason == 'invalid_args' else 503
|
status = 400 if reason == 'invalid_args' else 503
|
||||||
data = {'request_id': request_id, 'transport': 'socket', 'socket_error': reason}
|
|
||||||
data.update(extra)
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': message or (f'The display service did not accept the on-demand {action} '
|
'message': (f'The display service did not accept the on-demand {action} '
|
||||||
f'request ({reason})'),
|
f'request ({reason})'),
|
||||||
'data': data,
|
'data': {'request_id': request_id, 'transport': 'socket', 'socket_error': reason},
|
||||||
}), status
|
}), status
|
||||||
|
|
||||||
|
|
||||||
def _socket_failure_reason(error):
|
def _withdraw_on_demand(request_id):
|
||||||
"""The reportable reason code for an exception from _send_on_demand, logged."""
|
"""Take a start request the route has refused back out of the mailbox.
|
||||||
if isinstance(error, control_client.ControlError):
|
|
||||||
reason = _socket_reason_code(error.reason)
|
The display reads the mailbox for an hour without looking at a
|
||||||
if reason in _QUIET_SOCKET_REASONS:
|
request's age, so one left there after an error answer ran whenever the
|
||||||
logger.debug("On-demand request not taken over the control socket: %s", error)
|
display next started. Only this request is removed: the mailbox is
|
||||||
else:
|
re-read and cleared only while it still holds this request_id, as the
|
||||||
logger.warning("On-demand request not taken over the control socket: %s", error)
|
display's _consume_on_demand_request does, so a newer request posted in
|
||||||
return reason
|
the meantime stays for the display to take.
|
||||||
logger.error("Control socket client failed", exc_info=error)
|
"""
|
||||||
return 'internal'
|
cache = _cache_manager()
|
||||||
|
try:
|
||||||
|
current = cache.get('display_on_demand_request', max_age=3600, memory_ttl=0)
|
||||||
|
if isinstance(current, dict) and current.get('request_id') == request_id:
|
||||||
|
cache.delete('display_on_demand_request')
|
||||||
|
except Exception: # the route is answering an error already
|
||||||
|
logger.warning("Could not withdraw on-demand request %s from the mailbox",
|
||||||
|
request_id, exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
@api_v3.route('/display/current', methods=['GET'])
|
@api_v3.route('/display/current', methods=['GET'])
|
||||||
@@ -228,13 +218,16 @@ def get_on_demand_status():
|
|||||||
available (``source: "socket"``), else the cache key it also writes
|
available (``source: "socket"``), else the cache key it also writes
|
||||||
(``source: "cache"``).
|
(``source: "cache"``).
|
||||||
"""
|
"""
|
||||||
state, source = _display_on_demand_state(display_state.read_state())
|
state = display_state.on_demand_state(display_state.read_state())
|
||||||
pending = _pending_start_state()
|
source = 'socket'
|
||||||
if pending is not None and _shadows(pending, state):
|
if state is None:
|
||||||
# A start the web process is still delivering, has delivered but
|
source = 'cache'
|
||||||
# the display has not answered yet, or gave up on (start-timeout):
|
cache = _cache_manager()
|
||||||
# newer than anything the display has said.
|
# memory_ttl=0: the display service writes this key, so only the file
|
||||||
state, source = pending, 'web'
|
# is current. This process's memory tier would keep serving the first
|
||||||
|
# copy it read for the full max_age -- "active" for two minutes after
|
||||||
|
# the display had already stopped.
|
||||||
|
state = cache.get('display_on_demand_state', max_age=120, memory_ttl=0)
|
||||||
if state is None:
|
if state is None:
|
||||||
state = {
|
state = {
|
||||||
'active': False,
|
'active': False,
|
||||||
@@ -250,31 +243,6 @@ def get_on_demand_status():
|
|||||||
'source': source,
|
'source': source,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
def _shadows(pending, state):
|
|
||||||
"""Whether the web process's start (or its failure) is newer than the
|
|
||||||
display's on-demand ``state``.
|
|
||||||
|
|
||||||
* Still being sent: always.
|
|
||||||
* Delivered: until the display publishes the state that answers it.
|
|
||||||
A display that names its request (``request_id``) answers when the
|
|
||||||
id matches; its startup state, published as the socket opens and so
|
|
||||||
possibly after the acknowledgement, names no request or an older one
|
|
||||||
and does not count. An older display without the field answers with
|
|
||||||
any state published after the delivery.
|
|
||||||
* Failed: until the display publishes something later.
|
|
||||||
"""
|
|
||||||
if not isinstance(state, dict):
|
|
||||||
return True
|
|
||||||
if pending.get('status') == 'starting' and not pending.get('delivered'):
|
|
||||||
return True
|
|
||||||
if pending.get('delivered') and 'request_id' in state:
|
|
||||||
return state.get('request_id') != pending.get('request_id')
|
|
||||||
shown = state.get('last_updated')
|
|
||||||
if not isinstance(shown, (int, float)) or isinstance(shown, bool):
|
|
||||||
return True
|
|
||||||
return shown < (pending.get('last_updated') or 0)
|
|
||||||
|
|
||||||
|
|
||||||
@api_v3.route('/display/on-demand/start', methods=['POST'])
|
@api_v3.route('/display/on-demand/start', methods=['POST'])
|
||||||
def start_on_demand_display():
|
def start_on_demand_display():
|
||||||
"""Request the display controller to run a specific plugin on-demand."""
|
"""Request the display controller to run a specific plugin on-demand."""
|
||||||
@@ -332,10 +300,11 @@ def start_on_demand_display():
|
|||||||
resolved_plugin,
|
resolved_plugin,
|
||||||
)
|
)
|
||||||
|
|
||||||
# The request goes over the control socket, the only way to reach the
|
# Deliver the request over the control socket, or post it to the
|
||||||
# display. A display that is not listening (stopped, or still starting)
|
# mailbox the display process polls (DisplayController.
|
||||||
# is started when start_service asks for it, and the request is sent
|
# _poll_on_demand_requests). Done before any service start: a stopped
|
||||||
# again once its socket is up.
|
# display has no socket, so the request lands in the mailbox, where a
|
||||||
|
# freshly started display finds it on its first poll.
|
||||||
request_id = data.get('request_id') or str(uuid.uuid4())
|
request_id = data.get('request_id') or str(uuid.uuid4())
|
||||||
request_payload = {
|
request_payload = {
|
||||||
'request_id': request_id,
|
'request_id': request_id,
|
||||||
@@ -346,85 +315,69 @@ def start_on_demand_display():
|
|||||||
'pinned': pinned,
|
'pinned': pinned,
|
||||||
'timestamp': _pkg.time.time()
|
'timestamp': _pkg.time.time()
|
||||||
}
|
}
|
||||||
# This start supersedes one the dispatcher is still delivering,
|
|
||||||
# whatever becomes of it: an older request must not land after it.
|
|
||||||
dispatcher = on_demand_dispatch.current()
|
|
||||||
if dispatcher is not None:
|
|
||||||
dispatcher.cancel('superseded')
|
|
||||||
try:
|
try:
|
||||||
_send_on_demand(request_payload)
|
transport, socket_error = _deliver_on_demand(request_payload)
|
||||||
except Exception as e: # pylint: disable=broad-except
|
except _NotDelivered as e:
|
||||||
error = e
|
return _not_delivered_response(request_id, 'start', e.reason)
|
||||||
else:
|
|
||||||
# A socket acknowledgement is the display itself answering: it is
|
# A socket acknowledgement is the display itself answering: it is
|
||||||
# running and has the request queued, whatever systemd says (a
|
# running and has the request queued, whatever systemd says (a display
|
||||||
# display run by hand or in the emulator has no active unit). So
|
# run by hand or in the emulator has no active unit). So nothing is
|
||||||
# nothing is checked or started for it. The service is still
|
# checked or started for it -- that answered "not running" for a request
|
||||||
# reported the way _ensure_display_service_running reports a
|
# that had already taken effect. The service is still reported the way
|
||||||
# running one.
|
# _ensure_display_service_running reports a running one.
|
||||||
|
if transport == 'socket':
|
||||||
service_result = (dict(_get_display_service_status(), started=False)
|
service_result = (dict(_get_display_service_status(), started=False)
|
||||||
if start_service else None)
|
if start_service else None)
|
||||||
return _on_demand_started(request_id, resolved_plugin, resolved_mode,
|
return _on_demand_started(request_id, resolved_plugin, resolved_mode,
|
||||||
duration, pinned, service_result)
|
duration, pinned, service_result, transport,
|
||||||
|
socket_error)
|
||||||
reason = _socket_failure_reason(error)
|
|
||||||
if not control_client.display_not_listening(error):
|
|
||||||
# The display had it and refused it, is too old for the command, or
|
|
||||||
# this web process cannot use the socket at all (switched off, no
|
|
||||||
# Unix sockets): starting a service would not change that.
|
|
||||||
return _socket_error_response(request_id, 'start', reason)
|
|
||||||
|
|
||||||
service_status = _get_display_service_status()
|
service_status = _get_display_service_status()
|
||||||
|
|
||||||
if not service_status.get('active') and not start_service:
|
if not service_status.get('active') and not start_service:
|
||||||
|
# The request is in the mailbox, and the display reads it whenever
|
||||||
|
# it next starts: taken back out, or a request answered with this
|
||||||
|
# error ran later anyway.
|
||||||
|
_withdraw_on_demand(request_id)
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': 'Display service is not running. Please start the display service or enable "Start Service" option.',
|
'message': 'Display service is not running. Please start the display service or enable "Start Service" option.',
|
||||||
'service_status': service_status,
|
'service_status': service_status
|
||||||
'data': {'request_id': request_id, 'transport': 'socket', 'socket_error': reason},
|
|
||||||
}), 400
|
}), 400
|
||||||
|
|
||||||
# start_service means "start it if it is not running", as the UI's
|
# start_service means "start it if it is not running", as the UI's
|
||||||
# checkbox says; _ensure_display_service_running leaves a running
|
# checkbox says; _ensure_display_service_running leaves a running service
|
||||||
# service alone (restarting it cost seconds of blank panel for nothing).
|
# alone. This used to stop a running service, sleep 1.5s and start it
|
||||||
# Either way the display has no socket yet. The route does not wait for
|
# again, so every on-demand or "Preview on display" click -- and every
|
||||||
# it -- a cold start can outlast a client's timeout (the MQTT bridge's is
|
# MQTT on-demand command, which posts here with the default -- cold-
|
||||||
# 15 s) -- but answers 202 and leaves the sending to the dispatcher,
|
# restarted the display process: every plugin reloaded and the panel was
|
||||||
# whose outcome the status routes report.
|
# blank for seconds. The restart bought nothing. The running process
|
||||||
wait = ON_DEMAND_SOCKET_WAIT_RUNNING_SECONDS
|
# looks at this mailbox at least once a second, from its dwell sleep,
|
||||||
|
# its render loops and Vegas's interrupt check as well as the main loop
|
||||||
|
# (DisplayController._mailbox_poll_interval), and a restarted one got
|
||||||
|
# the request the same way: the
|
||||||
|
# startup path only restores a session the display itself saved
|
||||||
|
# (display_on_demand_config), so it loaded nothing it would not have had.
|
||||||
service_result = None
|
service_result = None
|
||||||
if not service_status.get('active'):
|
if start_service:
|
||||||
service_result = _ensure_display_service_running()
|
service_result = _ensure_display_service_running()
|
||||||
|
# Check if service actually started
|
||||||
if service_result and not service_result.get('active'):
|
if service_result and not service_result.get('active'):
|
||||||
|
_withdraw_on_demand(request_id)
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': 'Failed to start display service. Please check service logs or start it manually.',
|
'message': 'Failed to start display service. Please check service logs or start it manually.',
|
||||||
'service_result': service_result
|
'service_result': service_result
|
||||||
}), 500
|
}), 500
|
||||||
wait = on_demand_dispatch.START_WAIT_SECONDS
|
|
||||||
elif start_service:
|
|
||||||
service_result = dict(service_status, started=False)
|
|
||||||
|
|
||||||
_dispatcher().submit(request_payload, wait_seconds=wait)
|
return _on_demand_started(request_id, resolved_plugin, resolved_mode,
|
||||||
return jsonify({
|
duration, pinned, service_result, transport,
|
||||||
'status': 'starting',
|
socket_error)
|
||||||
'message': ('The display service is starting; the request is sent to it as soon '
|
|
||||||
'as it is listening. Check the on-demand status for the outcome.'),
|
|
||||||
'data': {
|
|
||||||
'request_id': request_id,
|
|
||||||
'plugin_id': resolved_plugin,
|
|
||||||
'mode': resolved_mode,
|
|
||||||
'duration': duration,
|
|
||||||
'pinned': pinned,
|
|
||||||
'service': service_result,
|
|
||||||
'transport': 'socket',
|
|
||||||
'socket_error': reason,
|
|
||||||
'pending': True,
|
|
||||||
'wait_seconds': wait,
|
|
||||||
},
|
|
||||||
}), 202
|
|
||||||
|
|
||||||
|
|
||||||
def _on_demand_started(request_id, plugin_id, mode, duration, pinned, service_result):
|
def _on_demand_started(request_id, plugin_id, mode, duration, pinned,
|
||||||
|
service_result, transport, socket_error):
|
||||||
"""The success answer of /display/on-demand/start."""
|
"""The success answer of /display/on-demand/start."""
|
||||||
response_data = {
|
response_data = {
|
||||||
'request_id': request_id,
|
'request_id': request_id,
|
||||||
@@ -433,8 +386,10 @@ def _on_demand_started(request_id, plugin_id, mode, duration, pinned, service_re
|
|||||||
'duration': duration,
|
'duration': duration,
|
||||||
'pinned': pinned,
|
'pinned': pinned,
|
||||||
'service': service_result,
|
'service': service_result,
|
||||||
'transport': 'socket',
|
'transport': transport,
|
||||||
}
|
}
|
||||||
|
if socket_error:
|
||||||
|
response_data['socket_error'] = socket_error
|
||||||
return jsonify({'status': 'success', 'data': response_data})
|
return jsonify({'status': 'success', 'data': response_data})
|
||||||
@api_v3.route('/display/on-demand/stop', methods=['POST'])
|
@api_v3.route('/display/on-demand/stop', methods=['POST'])
|
||||||
def stop_on_demand_display():
|
def stop_on_demand_display():
|
||||||
@@ -443,38 +398,23 @@ def stop_on_demand_display():
|
|||||||
# _coerce_to_bool: bool("false") is True, which stopped the service.
|
# _coerce_to_bool: bool("false") is True, which stopped the service.
|
||||||
stop_service = _coerce_to_bool(data.get('stop_service', False))
|
stop_service = _coerce_to_bool(data.get('stop_service', False))
|
||||||
|
|
||||||
# The running display takes the stop over the control socket and
|
# The running display takes the stop over the control socket, or reads
|
||||||
# resumes normal rotation in place (_clear_on_demand); nothing is
|
# it from the mailbox within ON_DEMAND_POLL_INTERVAL, and resumes normal
|
||||||
# restarted.
|
# rotation in place (_clear_on_demand); nothing is restarted.
|
||||||
request_id = data.get('request_id') or str(uuid.uuid4())
|
request_id = data.get('request_id') or str(uuid.uuid4())
|
||||||
request_payload = {
|
request_payload = {
|
||||||
'request_id': request_id,
|
'request_id': request_id,
|
||||||
'action': 'stop',
|
'action': 'stop',
|
||||||
'timestamp': _pkg.time.time()
|
'timestamp': _pkg.time.time()
|
||||||
}
|
}
|
||||||
socket_error = None
|
|
||||||
# A start the web process is still delivering is dropped first: the
|
|
||||||
# stop is newer, whatever happens to it below.
|
|
||||||
dispatcher = on_demand_dispatch.current()
|
|
||||||
cancelled = dispatcher.cancel('requested-stop') if dispatcher is not None else None
|
|
||||||
try:
|
try:
|
||||||
_send_on_demand(request_payload)
|
transport, socket_error = _deliver_on_demand(request_payload)
|
||||||
except Exception as e: # pylint: disable=broad-except
|
except _NotDelivered as e:
|
||||||
socket_error = _socket_failure_reason(e)
|
if not stop_service:
|
||||||
if not stop_service and not (cancelled and control_client.display_not_listening(e)):
|
return _not_delivered_response(request_id, 'stop', e.reason)
|
||||||
if control_client.display_not_listening(e):
|
|
||||||
service_status = _get_display_service_status()
|
|
||||||
message = ('Display service is not running, so the stop could not be '
|
|
||||||
'delivered. If it resumes an on-demand session when it starts, '
|
|
||||||
'stop it then.' if not service_status.get('active') else
|
|
||||||
f'The display service is running but its control socket did '
|
|
||||||
f'not answer ({socket_error}). It may still be starting; '
|
|
||||||
f'try again shortly.')
|
|
||||||
return _socket_error_response(request_id, 'stop', socket_error, message,
|
|
||||||
service=service_status)
|
|
||||||
return _socket_error_response(request_id, 'stop', socket_error)
|
|
||||||
# Stopping the service ends on-demand too, whatever the display did
|
# Stopping the service ends on-demand too, whatever the display did
|
||||||
# with the request.
|
# with the request.
|
||||||
|
transport, socket_error = 'socket', e.reason
|
||||||
|
|
||||||
service_result = None
|
service_result = None
|
||||||
if stop_service:
|
if stop_service:
|
||||||
@@ -483,16 +423,11 @@ def stop_on_demand_display():
|
|||||||
response_data = {
|
response_data = {
|
||||||
'request_id': request_id,
|
'request_id': request_id,
|
||||||
'service': service_result,
|
'service': service_result,
|
||||||
'transport': 'socket',
|
'transport': transport,
|
||||||
}
|
}
|
||||||
if cancelled:
|
|
||||||
# The start it ended never reached the display.
|
|
||||||
response_data['cancelled_request_id'] = cancelled
|
|
||||||
if socket_error:
|
if socket_error:
|
||||||
response_data['socket_error'] = socket_error
|
response_data['socket_error'] = socket_error
|
||||||
return jsonify({'status': 'success', 'data': response_data})
|
return jsonify({'status': 'success', 'data': response_data})
|
||||||
|
|
||||||
|
|
||||||
@api_v3.route('/display/current-status', methods=['GET'])
|
@api_v3.route('/display/current-status', methods=['GET'])
|
||||||
def get_current_display_status():
|
def get_current_display_status():
|
||||||
"""Return the display mode/plugin currently intended to be shown.
|
"""Return the display mode/plugin currently intended to be shown.
|
||||||
@@ -522,11 +457,4 @@ def get_current_display_status():
|
|||||||
'plugin_id': None,
|
'plugin_id': None,
|
||||||
'last_updated': None,
|
'last_updated': None,
|
||||||
}
|
}
|
||||||
data = dict(state, source=source)
|
return jsonify({'status': 'success', 'data': dict(state, source=source)})
|
||||||
pending = _pending_start_state()
|
|
||||||
if pending is not None and _shadows(pending, _display_on_demand_state(snapshot)[0]):
|
|
||||||
# An on-demand start the web process is still delivering, has
|
|
||||||
# delivered but the display has not acted on yet, or gave up on:
|
|
||||||
# what the panel is about to show, or why it will not.
|
|
||||||
data['on_demand_pending'] = pending
|
|
||||||
return jsonify({'status': 'success', 'data': data})
|
|
||||||
|
|||||||
@@ -370,13 +370,25 @@ def _redact_error_record(record):
|
|||||||
|
|
||||||
|
|
||||||
def _read_errors():
|
def _read_errors():
|
||||||
return _errors.read_error_report(_errors_cache())
|
snapshot, clear_request = _errors.read_error_report(_errors_cache())
|
||||||
|
return snapshot, clear_request
|
||||||
|
|
||||||
|
|
||||||
def _send_error_clear(request_id, cutoff):
|
def _send_error_clear(request_id, cutoff):
|
||||||
"""``errors.clear`` over the control socket: the display's answer.
|
"""``errors.clear`` over the control socket: the display's answer, or None
|
||||||
Raises ControlError when it did not take or apply it."""
|
when the socket could not carry it (no socket, or a display older than
|
||||||
return _pkg.control_client.errors_clear(request_id, cutoff)
|
the command) and the clear goes to the mailbox instead. A display that
|
||||||
|
took the request and failed raises ControlError (no second copy)."""
|
||||||
|
client = _pkg.control_client
|
||||||
|
try:
|
||||||
|
return client.errors_clear(request_id, cutoff)
|
||||||
|
except client.ControlError as e:
|
||||||
|
if not client.should_fall_back(e):
|
||||||
|
raise
|
||||||
|
_pkg._log_socket_failure('errors.clear', e, _pkg._socket_reason_code(e.reason))
|
||||||
|
except Exception: # never let the socket path break the route
|
||||||
|
logger.exception("Control socket client failed clearing errors; using the mailbox")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@api_v3.route('/errors/summary', methods=['GET'])
|
@api_v3.route('/errors/summary', methods=['GET'])
|
||||||
@@ -390,7 +402,7 @@ def get_error_summary():
|
|||||||
until it has reported; ``generated_at`` says when it did.
|
until it has reported; ``generated_at`` says when it did.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
summary = _errors.error_summary_from_report(_read_errors())
|
summary = _errors.error_summary_from_report(*_read_errors())
|
||||||
summary['recent_errors'] = [_redact_error_record(r) for r in summary['recent_errors']]
|
summary['recent_errors'] = [_redact_error_record(r) for r in summary['recent_errors']]
|
||||||
for pattern in summary['active_patterns'].values():
|
for pattern in summary['active_patterns'].values():
|
||||||
if isinstance(pattern, dict) and isinstance(pattern.get('sample_messages'), list):
|
if isinstance(pattern, dict) and isinstance(pattern.get('sample_messages'), list):
|
||||||
@@ -418,7 +430,7 @@ def get_plugin_errors(plugin_id):
|
|||||||
recorded errors is "healthy".
|
recorded errors is "healthy".
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
health = _errors.plugin_health_from_report(_read_errors(), plugin_id)
|
health = _errors.plugin_health_from_report(*_read_errors(), plugin_id)
|
||||||
health['last_error'] = _redact_error_record(health['last_error'])
|
health['last_error'] = _redact_error_record(health['last_error'])
|
||||||
return success_response(data=health, message="Plugin health retrieved")
|
return success_response(data=health, message="Plugin health retrieved")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -437,10 +449,9 @@ def clear_old_errors():
|
|||||||
max_age_hours: Maximum age in hours (default: 24, max: 8760 = 1 year)
|
max_age_hours: Maximum age in hours (default: 24, max: 8760 = 1 year)
|
||||||
all: true clears every error recorded so far (max_age_hours ignored)
|
all: true clears every error recorded so far (max_age_hours ignored)
|
||||||
|
|
||||||
The errors live in the display service, so the clear is sent to it over
|
The errors live in the display service, so this records a clear request
|
||||||
the control socket, and it has applied it when this answers. Without a
|
that it applies within a few seconds. Reads hide the cleared errors from
|
||||||
running display it fails with 503: the errors shown are then the last
|
the moment the request is recorded.
|
||||||
run's, and the display's next run starts with none.
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
@@ -481,28 +492,28 @@ def clear_old_errors():
|
|||||||
send=_send_error_clear)
|
send=_send_error_clear)
|
||||||
except _pkg.control_client.ControlError as e:
|
except _pkg.control_client.ControlError as e:
|
||||||
reason = _pkg._socket_reason_code(e.reason)
|
reason = _pkg._socket_reason_code(e.reason)
|
||||||
_pkg._log_socket_failure('errors.clear', e, reason)
|
logger.warning("The display did not apply the error clear: %s", e)
|
||||||
if _pkg.control_client.display_not_listening(e):
|
|
||||||
message = ("The display service is not running (or is still starting), "
|
|
||||||
"so the errors could not be cleared. The errors shown are "
|
|
||||||
"from its last run; it starts again with none.")
|
|
||||||
elif reason in _pkg.control_client.UPGRADE_REASONS:
|
|
||||||
message = ("The running display service is too old to clear errors; "
|
|
||||||
"restart it to pick up the installed version")
|
|
||||||
elif reason in _pkg._QUIET_SOCKET_REASONS:
|
|
||||||
message = ("The control socket is not available here, so the errors "
|
|
||||||
"could not be cleared")
|
|
||||||
else:
|
|
||||||
message = "The display service did not apply the clear"
|
|
||||||
return error_response(
|
return error_response(
|
||||||
error_code=ErrorCode.SYSTEM_ERROR,
|
error_code=ErrorCode.SYSTEM_ERROR,
|
||||||
message=message,
|
message="The display service did not apply the clear",
|
||||||
context={'socket_error': reason},
|
context={'socket_error': reason},
|
||||||
status_code=503
|
status_code=503
|
||||||
)
|
)
|
||||||
|
except OSError as e:
|
||||||
|
logger.error("Could not record an error clear request: %s", e)
|
||||||
|
return error_response(
|
||||||
|
error_code=ErrorCode.SYSTEM_ERROR,
|
||||||
|
message="Could not record the clear request in the shared cache",
|
||||||
|
status_code=500
|
||||||
|
)
|
||||||
|
|
||||||
scope = "all errors" if clear_all else f"errors older than {max_age_hours} hours"
|
scope = "all errors" if clear_all else f"errors older than {max_age_hours} hours"
|
||||||
return success_response(data=result, message=f"Cleared {scope}")
|
if result.get('applied'):
|
||||||
|
message = f"Cleared {scope}"
|
||||||
|
else:
|
||||||
|
message = (f"Clear of {scope} requested; the display service applies it "
|
||||||
|
f"within about {int(_errors.SNAPSHOT_TICK_INTERVAL)} seconds")
|
||||||
|
return success_response(data=result, message=message)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error clearing old errors: {e}", exc_info=True)
|
logger.error(f"Error clearing old errors: {e}", exc_info=True)
|
||||||
return error_response(
|
return error_response(
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ from web_interface.blueprints.api_v3 import (
|
|||||||
)
|
)
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
from web_interface import git_index_lock
|
|
||||||
from web_interface.cache import get_cached, set_cached
|
from web_interface.cache import get_cached, set_cached
|
||||||
from web_interface.system_metrics import collect_system_metrics, format_uptime
|
from web_interface.system_metrics import collect_system_metrics, format_uptime
|
||||||
import web_interface.blueprints.api_v3 as _pkg
|
import web_interface.blueprints.api_v3 as _pkg
|
||||||
@@ -164,7 +163,6 @@ def check_for_update():
|
|||||||
['git', 'fetch', '--quiet', '--tags', '--force', update_channel.REMOTE,
|
['git', 'fetch', '--quiet', '--tags', '--force', update_channel.REMOTE,
|
||||||
update_channel.BETA_BRANCH],
|
update_channel.BETA_BRANCH],
|
||||||
capture_output=True, timeout=10, cwd=cwd,
|
capture_output=True, timeout=10, cwd=cwd,
|
||||||
env=git_index_lock.read_only_env(),
|
|
||||||
)
|
)
|
||||||
if fetch_result.returncode != 0:
|
if fetch_result.returncode != 0:
|
||||||
stderr = fetch_result.stderr.decode(errors='replace').strip()
|
stderr = fetch_result.stderr.decode(errors='replace').strip()
|
||||||
@@ -336,37 +334,6 @@ def _perform_core_update_locked(stash_local_changes=True):
|
|||||||
project_dir = str(PROJECT_ROOT)
|
project_dir = str(PROJECT_ROOT)
|
||||||
from web_interface import update_channel
|
from web_interface import update_channel
|
||||||
|
|
||||||
# A git killed mid-command (a timed-out pull, a power cut) leaves
|
|
||||||
# .git/index.lock behind, and every later update then fails with
|
|
||||||
# "Unable to create .../index.lock: File exists". Remove it when no git
|
|
||||||
# process can still own it; refuse while one is running.
|
|
||||||
if git_index_lock.clear_stale_index_lock(project_dir) == git_index_lock.IN_USE:
|
|
||||||
return {'status': 'error', 'restart_required': False, 'dependency_failures': [],
|
|
||||||
'message': 'Another git command is running in the LEDMatrix folder; '
|
|
||||||
'try again in a minute.'}
|
|
||||||
|
|
||||||
# A pull killed mid-rebase leaves .git/rebase-merge behind, and every
|
|
||||||
# later `pull --rebase` then fails. Same rule as the automatic update's
|
|
||||||
# preflight: a rebase really in progress leaves HEAD detached and holds
|
|
||||||
# the user's conflict work, so leave it; with HEAD back on a branch it was
|
|
||||||
# abandoned, and `--quit` drops its state without touching the tree.
|
|
||||||
git_dir = Path(project_dir) / '.git'
|
|
||||||
rebase_dirs = [git_dir / name for name in ('rebase-merge', 'rebase-apply') if (git_dir / name).exists()]
|
|
||||||
if rebase_dirs:
|
|
||||||
def _git(*args):
|
|
||||||
return git_index_lock.run_git(['git', *args], capture_output=True, text=True,
|
|
||||||
timeout=60, cwd=project_dir)
|
|
||||||
if _git('symbolic-ref', '-q', 'HEAD').returncode != 0:
|
|
||||||
return {'status': 'error', 'restart_required': False, 'dependency_failures': [],
|
|
||||||
'message': 'A git rebase is in progress in the LEDMatrix folder. '
|
|
||||||
'Finish or abort it (git rebase --continue / --abort), then update again.'}
|
|
||||||
cleared = _git('rebase', '--quit')
|
|
||||||
if cleared.returncode != 0 or any(path.exists() for path in rebase_dirs):
|
|
||||||
return {'status': 'error', 'restart_required': False, 'dependency_failures': [],
|
|
||||||
'message': 'An abandoned git rebase in the LEDMatrix folder could not be cleared: '
|
|
||||||
f'{(cleared.stderr or "").strip() or "unknown error"}.'}
|
|
||||||
logger.warning("Cleared an abandoned git rebase from %s", ', '.join(str(p) for p in rebase_dirs))
|
|
||||||
|
|
||||||
# Which code to move to: the newest release (stable) or main (beta).
|
# Which code to move to: the newest release (stable) or main (beta).
|
||||||
# See web_interface/update_channel.py; it never picks an older commit.
|
# See web_interface/update_channel.py; it never picks an older commit.
|
||||||
channel, fetch_error = channel_status(project_dir)
|
channel, fetch_error = channel_status(project_dir)
|
||||||
@@ -458,33 +425,25 @@ def _perform_core_update_locked(stash_local_changes=True):
|
|||||||
# Stable checks out the release tag, carrying edits across the way the
|
# Stable checks out the release tag, carrying edits across the way the
|
||||||
# pull's --autostash does.
|
# pull's --autostash does.
|
||||||
channel_note = ''
|
channel_note = ''
|
||||||
try:
|
if action == update_channel.ACTION_CHECKOUT_TAG:
|
||||||
if action == update_channel.ACTION_CHECKOUT_TAG:
|
result, autostash_note = update_channel.checkout_release(project_dir, channel.newest_release)
|
||||||
result, autostash_note = update_channel.checkout_release(project_dir, channel.newest_release)
|
channel_note = f"Now on release {channel.newest_release} (stable channel). {autostash_note}".strip()
|
||||||
channel_note = f"Now on release {channel.newest_release} (stable channel). {autostash_note}".strip()
|
elif action == update_channel.ACTION_SWITCH_TO_BETA:
|
||||||
elif action == update_channel.ACTION_SWITCH_TO_BETA:
|
result, autostash_note = update_channel.checkout_beta_branch(project_dir)
|
||||||
result, autostash_note = update_channel.checkout_beta_branch(project_dir)
|
if result.returncode == 0:
|
||||||
if result.returncode == 0:
|
result = subprocess.run(['git', 'pull', '--rebase', '--autostash'],
|
||||||
result = git_index_lock.run_git(['git', 'pull', '--rebase', '--autostash'],
|
capture_output=True, text=True, timeout=60, cwd=project_dir)
|
||||||
capture_output=True, text=True,
|
channel_note = f"Now following {update_channel.BETA_BRANCH} (beta channel). {autostash_note}".strip()
|
||||||
timeout=git_index_lock.MUTATING_TIMEOUT_SECONDS, cwd=project_dir)
|
else:
|
||||||
channel_note = f"Now following {update_channel.BETA_BRANCH} (beta channel). {autostash_note}".strip()
|
result = subprocess.run(
|
||||||
else:
|
pull_args,
|
||||||
result = git_index_lock.run_git(
|
capture_output=True,
|
||||||
pull_args,
|
text=True,
|
||||||
capture_output=True,
|
timeout=60,
|
||||||
text=True,
|
cwd=project_dir
|
||||||
timeout=git_index_lock.MUTATING_TIMEOUT_SECONDS,
|
)
|
||||||
cwd=project_dir
|
if channel.waiting and channel.newest_release:
|
||||||
)
|
channel_note = channel.message
|
||||||
if channel.waiting and channel.newest_release:
|
|
||||||
channel_note = channel.message
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
# run_git has stopped git and removed the lock it left.
|
|
||||||
logger.warning("git timed out during Update Code")
|
|
||||||
return {'status': 'error', 'restart_required': False, 'dependency_failures': [],
|
|
||||||
'message': 'Update failed: git took too long (a slow network or SD card). '
|
|
||||||
'The partial update was cleaned up; try again.'}
|
|
||||||
|
|
||||||
# Give the branch tracking information so the next pull is a plain
|
# Give the branch tracking information so the next pull is a plain
|
||||||
# `git pull` — otherwise every update repeats the fallback.
|
# `git pull` — otherwise every update repeats the fallback.
|
||||||
@@ -595,8 +554,6 @@ def _perform_core_update_locked(stash_local_changes=True):
|
|||||||
detail = next((ln.strip() for ln in (result.stderr or '').splitlines()
|
detail = next((ln.strip() for ln in (result.stderr or '').splitlines()
|
||||||
if ln.strip()), '')
|
if ln.strip()), '')
|
||||||
pull_message = f"Update failed: {detail}" if detail else "Update failed; check logs for details"
|
pull_message = f"Update failed: {detail}" if detail else "Update failed; check logs for details"
|
||||||
if 'index.lock' in (result.stderr or ''):
|
|
||||||
pull_message = f"{pull_message} {git_index_lock.manual_fix_hint(project_dir)}"
|
|
||||||
|
|
||||||
# Nothing here restarts anything: the pull replaces files on
|
# Nothing here restarts anything: the pull replaces files on
|
||||||
# disk while the display and web services keep running the code
|
# disk while the display and web services keep running the code
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from html.parser import HTMLParser
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Strict allowlists for URL-derived values used in path and script operations.
|
# Strict allowlists for URL-derived values used in path and script operations.
|
||||||
@@ -488,25 +487,9 @@ def _load_general_partial():
|
|||||||
main_config=main_config,
|
main_config=main_config,
|
||||||
auto_update_status=auto_update_status,
|
auto_update_status=auto_update_status,
|
||||||
update_channel_status=update_channel_status,
|
update_channel_status=update_channel_status,
|
||||||
display_autostart=_display_autostart_state(),
|
|
||||||
web_login=_web_login_state())
|
web_login=_web_login_state())
|
||||||
|
|
||||||
|
|
||||||
def _display_autostart_state():
|
|
||||||
"""Whether ledmatrix.service starts at boot; None hides the toggle.
|
|
||||||
|
|
||||||
systemd's enablement is the setting itself (the installer enables the
|
|
||||||
unit), so nothing is stored in config.json. None when it cannot be read:
|
|
||||||
no systemctl (a dev machine) or the unit is not installed.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
result = subprocess.run(['systemctl', 'is-enabled', 'ledmatrix.service'],
|
|
||||||
capture_output=True, text=True, timeout=5)
|
|
||||||
except (OSError, subprocess.SubprocessError):
|
|
||||||
return None
|
|
||||||
return {'enabled': True, 'disabled': False}.get(result.stdout.strip())
|
|
||||||
|
|
||||||
|
|
||||||
def _web_login_state():
|
def _web_login_state():
|
||||||
"""What the General tab's Security section shows; None hides it.
|
"""What the General tab's Security section shows; None hides it.
|
||||||
|
|
||||||
@@ -615,28 +598,7 @@ def _load_schedule_partial():
|
|||||||
return render_template('v3/partials/schedule.html',
|
return render_template('v3/partials/schedule.html',
|
||||||
schedule_config=schedule_config,
|
schedule_config=schedule_config,
|
||||||
dim_schedule_config=dim_schedule_config,
|
dim_schedule_config=dim_schedule_config,
|
||||||
normal_brightness=normal_brightness,
|
normal_brightness=normal_brightness)
|
||||||
plugin_schedule_rows=_plugin_schedule_rows(main_config))
|
|
||||||
|
|
||||||
|
|
||||||
def _plugin_schedule_rows(main_config):
|
|
||||||
"""One row per enabled plugin, or plugin with a saved schedule, for the
|
|
||||||
Plugin Schedules card: ``{id, name, schedule}``, sorted by name."""
|
|
||||||
schedules = main_config.get('plugin_schedules') or {}
|
|
||||||
if not pages_v3.plugin_catalog:
|
|
||||||
return []
|
|
||||||
try:
|
|
||||||
pages_v3.plugin_catalog.discover_plugins()
|
|
||||||
infos = pages_v3.plugin_catalog.get_all_plugin_info()
|
|
||||||
except Exception:
|
|
||||||
logger.warning("schedule: could not enumerate plugins", exc_info=True)
|
|
||||||
return []
|
|
||||||
rows = [{'id': info['id'], 'name': info.get('name') or info['id'],
|
|
||||||
'schedule': schedules.get(info['id']) or {}}
|
|
||||||
for info in infos
|
|
||||||
if info.get('id') and ((main_config.get(info['id']) or {}).get('enabled', False)
|
|
||||||
or info['id'] in schedules)]
|
|
||||||
return sorted(rows, key=lambda r: r['name'].lower())
|
|
||||||
|
|
||||||
|
|
||||||
def _load_plugins_partial():
|
def _load_plugins_partial():
|
||||||
|
|||||||
@@ -1,220 +0,0 @@
|
|||||||
"""Clear a stale ``.git/index.lock`` before Update Code touches the checkout.
|
|
||||||
|
|
||||||
git creates ``.git/index.lock`` while it rewrites the index and removes it when
|
|
||||||
it finishes, or when it is stopped with SIGTERM/SIGINT. A git that dies any
|
|
||||||
other way leaves the file behind: ``subprocess.run(..., timeout=...)`` SIGKILLs
|
|
||||||
its child when the timeout expires (a slow pull or checkout on a Pi), and a
|
|
||||||
power cut or an OOM kill does the same. From then on every git command that
|
|
||||||
writes the index fails with::
|
|
||||||
|
|
||||||
fatal: Unable to create '.../.git/index.lock': File exists.
|
|
||||||
|
|
||||||
and Update Code can never succeed again until someone removes it over SSH.
|
|
||||||
|
|
||||||
``clear_stale_index_lock`` removes the file only when it is provably stale: no
|
|
||||||
git process is running in this checkout, and the file is old enough that no
|
|
||||||
git can have just created it. When it cannot tell (no ``/proc``), it leaves
|
|
||||||
the file alone. Standard library only.
|
|
||||||
"""
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import signal
|
|
||||||
import subprocess # nosec B404 - list-form argv only, no shell # nosemgrep
|
|
||||||
import time
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
#: A lock younger than this is left alone even when no git process is seen,
|
|
||||||
#: so a git that started between the process scan and the removal keeps it.
|
|
||||||
MIN_STALE_AGE_SECONDS = 5
|
|
||||||
|
|
||||||
#: ``clear_stale_index_lock`` outcomes.
|
|
||||||
ABSENT = 'absent' # no lock file: nothing to do
|
|
||||||
REMOVED = 'removed' # it was stale and has been removed
|
|
||||||
IN_USE = 'in_use' # a git process is running here (or the lock is brand new)
|
|
||||||
UNKNOWN = 'unknown' # could not tell whether git is running; left alone
|
|
||||||
|
|
||||||
#: How long a timed-out git gets to exit after SIGTERM (which lets it remove
|
|
||||||
#: its own lock) before the whole process group is SIGKILLed.
|
|
||||||
TERM_GRACE_SECONDS = 5
|
|
||||||
|
|
||||||
#: Timeout for commands that rewrite the checkout (pull, checkout, reset,
|
|
||||||
#: stash). A Pi on an SD card can take minutes; killing git is what leaves the
|
|
||||||
#: lock behind, so this is generous.
|
|
||||||
MUTATING_TIMEOUT_SECONDS = 600
|
|
||||||
|
|
||||||
_PROC = Path('/proc')
|
|
||||||
|
|
||||||
|
|
||||||
def index_lock_path(project_dir):
|
|
||||||
"""``<project_dir>/.git/index.lock``, or None when ``.git`` is not a directory."""
|
|
||||||
git_dir = Path(project_dir) / '.git'
|
|
||||||
return git_dir / 'index.lock' if git_dir.is_dir() else None
|
|
||||||
|
|
||||||
|
|
||||||
def _is_within(path, root):
|
|
||||||
try:
|
|
||||||
Path(path).relative_to(root)
|
|
||||||
return True
|
|
||||||
except ValueError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def git_running_in(project_dir, proc_root=_PROC):
|
|
||||||
"""True when a git process may be working in ``project_dir``.
|
|
||||||
|
|
||||||
Scans ``/proc`` for processes named ``git`` or ``git-*`` (``git-remote-https``
|
|
||||||
and friends) whose working directory is inside the checkout, or whose
|
|
||||||
arguments name a path in it (``git -C ...``, ``--git-dir=...``). A git process
|
|
||||||
whose working directory cannot be read (another user's) counts as running
|
|
||||||
here: it might be. Returns None when ``/proc`` is unavailable.
|
|
||||||
"""
|
|
||||||
proc_root = Path(proc_root)
|
|
||||||
if not proc_root.is_dir():
|
|
||||||
return None
|
|
||||||
root = Path(project_dir).resolve()
|
|
||||||
try:
|
|
||||||
entries = list(proc_root.iterdir())
|
|
||||||
except OSError:
|
|
||||||
return None
|
|
||||||
for entry in entries:
|
|
||||||
if not entry.name.isdigit():
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
comm = (entry / 'comm').read_text().strip()
|
|
||||||
except OSError:
|
|
||||||
continue # exited meanwhile
|
|
||||||
if comm != 'git' and not comm.startswith('git-'):
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
cwd = os.readlink(entry / 'cwd')
|
|
||||||
except FileNotFoundError:
|
|
||||||
continue # exited meanwhile
|
|
||||||
except OSError:
|
|
||||||
return True
|
|
||||||
if _is_within(cwd, root) or _names_checkout(entry, cwd, root):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _names_checkout(entry, cwd, root):
|
|
||||||
"""True when the process's arguments name a path in the checkout
|
|
||||||
(``-C``, ``--git-dir``, ``--work-tree``, absolute or relative to its
|
|
||||||
working directory): git can work on it from anywhere. Unreadable
|
|
||||||
arguments count as naming it."""
|
|
||||||
try:
|
|
||||||
args = os.fsdecode((entry / 'cmdline').read_bytes()).split('\0')
|
|
||||||
except FileNotFoundError:
|
|
||||||
return False # exited meanwhile
|
|
||||||
except OSError:
|
|
||||||
return True
|
|
||||||
for arg in args[1:]:
|
|
||||||
value = arg.split('=', 1)[1] if arg.startswith('--') and '=' in arg else arg
|
|
||||||
if value and _is_within(os.path.normpath(os.path.join(cwd, value)), root):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def clear_stale_index_lock(project_dir, proc_root=_PROC, now=None):
|
|
||||||
"""Remove ``.git/index.lock`` when no git process can still own it.
|
|
||||||
|
|
||||||
Returns one of ABSENT, REMOVED, IN_USE or UNKNOWN.
|
|
||||||
"""
|
|
||||||
lock = index_lock_path(project_dir)
|
|
||||||
if lock is None:
|
|
||||||
return ABSENT
|
|
||||||
try:
|
|
||||||
mtime = lock.stat().st_mtime
|
|
||||||
except FileNotFoundError:
|
|
||||||
return ABSENT
|
|
||||||
except OSError:
|
|
||||||
return UNKNOWN
|
|
||||||
running = git_running_in(project_dir, proc_root)
|
|
||||||
if running is None:
|
|
||||||
return UNKNOWN
|
|
||||||
age = (time.time() if now is None else now) - mtime
|
|
||||||
if running or age < MIN_STALE_AGE_SECONDS:
|
|
||||||
return IN_USE
|
|
||||||
try:
|
|
||||||
lock.unlink()
|
|
||||||
except FileNotFoundError:
|
|
||||||
return ABSENT # its owner finished after all
|
|
||||||
except OSError as exc:
|
|
||||||
logger.warning("Could not remove stale git lock %s: %s", lock, exc)
|
|
||||||
return UNKNOWN
|
|
||||||
logger.warning("Removed stale git lock %s (%.0fs old, no git process running); "
|
|
||||||
"a previous git command was killed before it finished", lock, age)
|
|
||||||
return REMOVED
|
|
||||||
|
|
||||||
|
|
||||||
def _stop_group(proc):
|
|
||||||
"""SIGTERM the process group, then SIGKILL whatever is left of it."""
|
|
||||||
try:
|
|
||||||
os.killpg(proc.pid, signal.SIGTERM)
|
|
||||||
except (ProcessLookupError, PermissionError):
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
proc.wait(timeout=TERM_GRACE_SECONDS)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
pass
|
|
||||||
# Also reaps git's children (git-remote-https, rebase helpers) that
|
|
||||||
# outlive a killed parent.
|
|
||||||
try:
|
|
||||||
os.killpg(proc.pid, signal.SIGKILL)
|
|
||||||
except (ProcessLookupError, PermissionError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _clear_after_stop(project_dir):
|
|
||||||
"""Remove the lock a stopped git left, waiting out the freshness window.
|
|
||||||
|
|
||||||
A lock created within ``MIN_STALE_AGE_SECONDS`` of the stop reads as IN_USE
|
|
||||||
on age alone; with no git process left it only needs to age, so wait for
|
|
||||||
that rather than leave it to block the next attempt.
|
|
||||||
"""
|
|
||||||
deadline = time.monotonic() + MIN_STALE_AGE_SECONDS + 1
|
|
||||||
while clear_stale_index_lock(project_dir) == IN_USE and git_running_in(project_dir) is False:
|
|
||||||
if time.monotonic() >= deadline:
|
|
||||||
break
|
|
||||||
time.sleep(0.5)
|
|
||||||
|
|
||||||
|
|
||||||
def run_git(cmd, *, cwd=None, timeout=None, capture_output=True, text=True, **kwargs):
|
|
||||||
"""``subprocess.run`` for git, without leaving ``.git/index.lock`` behind.
|
|
||||||
|
|
||||||
``subprocess.run`` SIGKILLs only the direct child when ``timeout`` expires,
|
|
||||||
which skips git's cleanup and orphans its helpers. This starts git in its
|
|
||||||
own session, stops the whole group with SIGTERM before SIGKILL, removes a
|
|
||||||
lock nothing owns any more, then raises ``TimeoutExpired`` as
|
|
||||||
``subprocess.run`` would.
|
|
||||||
"""
|
|
||||||
if capture_output:
|
|
||||||
kwargs['stdout'] = kwargs['stderr'] = subprocess.PIPE
|
|
||||||
with subprocess.Popen(cmd, cwd=cwd, text=text, start_new_session=True, **kwargs) as proc: # nosec B603 - list-form argv # nosemgrep
|
|
||||||
try:
|
|
||||||
stdout, stderr = proc.communicate(timeout=timeout)
|
|
||||||
except subprocess.TimeoutExpired as exc:
|
|
||||||
_stop_group(proc)
|
|
||||||
exc.stdout, exc.stderr = proc.communicate()
|
|
||||||
if cwd is not None:
|
|
||||||
_clear_after_stop(cwd)
|
|
||||||
raise
|
|
||||||
except BaseException:
|
|
||||||
_stop_group(proc)
|
|
||||||
raise
|
|
||||||
return subprocess.CompletedProcess(proc.args, proc.returncode, stdout, stderr) # result object only, nothing is run # nosemgrep
|
|
||||||
|
|
||||||
|
|
||||||
def read_only_env():
|
|
||||||
"""Environment for git calls that only read: ``GIT_OPTIONAL_LOCKS=0``
|
|
||||||
stops ``status`` and ``describe --dirty`` refreshing (and so locking) the
|
|
||||||
index, so a poll that times out cannot leave a lock."""
|
|
||||||
return {**os.environ, 'GIT_OPTIONAL_LOCKS': '0'}
|
|
||||||
|
|
||||||
|
|
||||||
def manual_fix_hint(project_dir):
|
|
||||||
"""The sentence shown when a lock blocks the update and was not removed."""
|
|
||||||
lock = Path(project_dir) / '.git' / 'index.lock'
|
|
||||||
return (f"If it keeps coming back, stop the LEDMatrix services, kill any running git "
|
|
||||||
f"(ps aux | grep git), then remove it with rm -f {lock} and try again.")
|
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
"""Deliver an on-demand start to a display that is not listening yet.
|
|
||||||
|
|
||||||
``POST /api/v3/display/on-demand/start`` can find no display on the control
|
|
||||||
socket: the service is stopped (the route starts it) or still loading its
|
|
||||||
plugins. The socket comes up only when the display's run loop starts, which
|
|
||||||
can take longer than a client waits -- the MQTT bridge gives up after 15 s.
|
|
||||||
So the route answers at once (``202``, ``status: "starting"``) and hands
|
|
||||||
the request to the one :class:`OnDemandDispatcher` of the web process, whose
|
|
||||||
worker thread sends it again until the display acknowledges it or
|
|
||||||
:data:`START_WAIT_SECONDS` pass.
|
|
||||||
|
|
||||||
* One request at a time: a newer start replaces the pending one, and a
|
|
||||||
stop cancels it (:meth:`OnDemandDispatcher.cancel`).
|
|
||||||
* Its outcome is :meth:`OnDemandDispatcher.status`, which
|
|
||||||
``GET /display/on-demand/status`` and ``/display/current-status`` report:
|
|
||||||
``starting`` while it waits, ``delivered`` once acknowledged (the
|
|
||||||
display's own state takes over from there), or ``error`` with
|
|
||||||
``start-timeout`` or the socket's reason.
|
|
||||||
|
|
||||||
Nothing is written to disk: the file mailbox that once carried such a
|
|
||||||
request is gone (docs/IPC_CONTROL_SOCKET.md, stage 5).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from typing import Any, Callable, Dict, Optional
|
|
||||||
|
|
||||||
from src.ipc import client as control_client
|
|
||||||
from src.logging_config import get_logger
|
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
|
||||||
|
|
||||||
#: How long the worker keeps sending a start before it gives up
|
|
||||||
#: (``start-timeout``). The socket comes up when the display's run loop
|
|
||||||
#: starts, after every plugin has loaded.
|
|
||||||
START_WAIT_SECONDS = 45.0
|
|
||||||
|
|
||||||
#: Gap between two sends while nothing is listening.
|
|
||||||
RETRY_INTERVAL = 0.5
|
|
||||||
|
|
||||||
#: How long a finished outcome (delivered, error, cancelled) is still
|
|
||||||
#: reported, so a client polling every few seconds sees it.
|
|
||||||
OUTCOME_SECONDS = 120.0
|
|
||||||
|
|
||||||
#: How long a delivered start is still reported as ``starting`` while the
|
|
||||||
#: display has not published the state that answers it. The display
|
|
||||||
#: acknowledges a start when its socket opens, but its run loop acts on it
|
|
||||||
#: only after its first screen is built (about 5 s on a Pi 4 in Vegas), and
|
|
||||||
#: until then it reports its own idle state. See
|
|
||||||
#: ``web_interface/blueprints/api_v3/display.py:_pending_start_state``.
|
|
||||||
DELIVERED_SHOWN_SECONDS = 30.0
|
|
||||||
|
|
||||||
#: ``send(payload)`` hands the request to the display (the route's
|
|
||||||
#: ``_send_on_demand``) and raises ``ControlError`` when it does not take it.
|
|
||||||
Sender = Callable[[Dict[str, Any]], Any]
|
|
||||||
|
|
||||||
|
|
||||||
class OnDemandDispatcher:
|
|
||||||
"""One pending on-demand start, and the worker thread that delivers it."""
|
|
||||||
|
|
||||||
def __init__(self, send: Sender, *,
|
|
||||||
wait_seconds: Optional[float] = None,
|
|
||||||
retry_interval: Optional[float] = None,
|
|
||||||
clock: Callable[[], float] = time.monotonic,
|
|
||||||
wall_clock: Callable[[], float] = time.time):
|
|
||||||
self._send = send
|
|
||||||
self.wait_seconds = START_WAIT_SECONDS if wait_seconds is None else wait_seconds
|
|
||||||
self.retry_interval = RETRY_INTERVAL if retry_interval is None else retry_interval
|
|
||||||
self._clock = clock
|
|
||||||
self._wall = wall_clock
|
|
||||||
self._lock = threading.Lock()
|
|
||||||
# Held by the worker while it reads the pending start and sends it,
|
|
||||||
# so cancel() can wait out a send already in flight: a cancelled (or
|
|
||||||
# superseded) start must not reach the display after the request
|
|
||||||
# that replaced it. Never taken while holding _lock.
|
|
||||||
self._send_lock = threading.Lock()
|
|
||||||
self._wake = threading.Event()
|
|
||||||
# Bumped by every submit and cancel: a send that started under an
|
|
||||||
# older generation does not report its result as the current one.
|
|
||||||
self._generation = 0
|
|
||||||
self._pending: Optional[Dict[str, Any]] = None
|
|
||||||
self._deadline = 0.0
|
|
||||||
self._status: Optional[Dict[str, Any]] = None
|
|
||||||
self._finished_at: Optional[float] = None
|
|
||||||
self._thread: Optional[threading.Thread] = None
|
|
||||||
|
|
||||||
# -- the routes' side ------------------------------------------------------
|
|
||||||
|
|
||||||
def submit(self, payload: Dict[str, Any], wait_seconds: Optional[float] = None) -> None:
|
|
||||||
"""Deliver ``payload`` (an on-demand start) in the background,
|
|
||||||
replacing any start still pending."""
|
|
||||||
with self._lock:
|
|
||||||
self._generation += 1
|
|
||||||
superseded = self._pending
|
|
||||||
self._pending = dict(payload)
|
|
||||||
self._deadline = self._clock() + (self.wait_seconds if wait_seconds is None
|
|
||||||
else wait_seconds)
|
|
||||||
self._status = self._describe('starting', payload)
|
|
||||||
self._finished_at = None
|
|
||||||
if self._thread is None or not self._thread.is_alive():
|
|
||||||
self._thread = threading.Thread(target=self._run, name='on-demand-dispatch',
|
|
||||||
daemon=True)
|
|
||||||
self._thread.start()
|
|
||||||
if superseded is not None:
|
|
||||||
logger.info("On-demand start %s superseded by %s before the display took it",
|
|
||||||
superseded.get('request_id'), payload.get('request_id'))
|
|
||||||
self._wake.set()
|
|
||||||
|
|
||||||
def cancel(self, reason: str = 'cancelled') -> Optional[str]:
|
|
||||||
"""Drop the pending start (a stop arrived). Returns its request id,
|
|
||||||
or None when nothing was pending."""
|
|
||||||
with self._lock:
|
|
||||||
pending = self._pending
|
|
||||||
if pending is None:
|
|
||||||
return None
|
|
||||||
self._generation += 1
|
|
||||||
self._pending = None
|
|
||||||
self._finish(self._describe('idle', pending, last_event=reason))
|
|
||||||
self._wake.set()
|
|
||||||
# A send of it may be in flight (at most the client's timeout, 1 s):
|
|
||||||
# let it finish, so whatever the caller sends next lands after it.
|
|
||||||
with self._send_lock:
|
|
||||||
pass
|
|
||||||
logger.info("On-demand start %s cancelled before the display took it (%s)",
|
|
||||||
pending.get('request_id'), reason)
|
|
||||||
return pending.get('request_id')
|
|
||||||
|
|
||||||
def status(self) -> Optional[Dict[str, Any]]:
|
|
||||||
"""The pending start's state, or its outcome for OUTCOME_SECONDS
|
|
||||||
after it finished; None otherwise. In the shape of the display's
|
|
||||||
on-demand state (``active``, ``status``, ``error``, ...), plus
|
|
||||||
``source: "web"``."""
|
|
||||||
with self._lock:
|
|
||||||
if self._status is None:
|
|
||||||
return None
|
|
||||||
if (self._finished_at is not None
|
|
||||||
and self._clock() - self._finished_at > OUTCOME_SECONDS):
|
|
||||||
return None
|
|
||||||
return dict(self._status)
|
|
||||||
|
|
||||||
def pending(self) -> bool:
|
|
||||||
with self._lock:
|
|
||||||
return self._pending is not None
|
|
||||||
|
|
||||||
# -- the worker ------------------------------------------------------------
|
|
||||||
|
|
||||||
def _describe(self, status: str, payload: Dict[str, Any], error: Optional[str] = None,
|
|
||||||
last_event: Optional[str] = None) -> Dict[str, Any]:
|
|
||||||
return {
|
|
||||||
'active': False,
|
|
||||||
'status': status,
|
|
||||||
'error': error,
|
|
||||||
'last_event': last_event,
|
|
||||||
'request_id': payload.get('request_id'),
|
|
||||||
'plugin_id': payload.get('plugin_id'),
|
|
||||||
'mode': payload.get('mode'),
|
|
||||||
'duration': payload.get('duration'),
|
|
||||||
'pinned': bool(payload.get('pinned', False)),
|
|
||||||
'last_updated': self._wall(),
|
|
||||||
'source': 'web',
|
|
||||||
}
|
|
||||||
|
|
||||||
def _finish(self, status: Dict[str, Any]) -> None:
|
|
||||||
"""Record an outcome. Caller holds _lock."""
|
|
||||||
self._status = status
|
|
||||||
self._finished_at = self._clock()
|
|
||||||
|
|
||||||
def _run(self) -> None:
|
|
||||||
while True:
|
|
||||||
with self._send_lock:
|
|
||||||
with self._lock:
|
|
||||||
payload, generation = self._pending, self._generation
|
|
||||||
deadline = self._deadline
|
|
||||||
if payload is None:
|
|
||||||
self._thread = None
|
|
||||||
return
|
|
||||||
outcome, error = self._attempt(payload)
|
|
||||||
with self._lock:
|
|
||||||
if generation != self._generation:
|
|
||||||
continue # superseded or cancelled meanwhile
|
|
||||||
if outcome == 'retry' and self._clock() + self.retry_interval > deadline:
|
|
||||||
outcome, error = 'error', 'start-timeout'
|
|
||||||
if outcome == 'delivered':
|
|
||||||
self._pending = None
|
|
||||||
self._finish(self._describe('delivered', payload,
|
|
||||||
last_event='delivered'))
|
|
||||||
elif outcome == 'error':
|
|
||||||
self._pending = None
|
|
||||||
self._finish(self._describe('error', payload, error=error))
|
|
||||||
else:
|
|
||||||
self._wake.clear()
|
|
||||||
if outcome == 'delivered':
|
|
||||||
logger.info("On-demand start %s delivered once the display was listening",
|
|
||||||
payload.get('request_id'))
|
|
||||||
elif outcome == 'error':
|
|
||||||
logger.warning("On-demand start %s not delivered: %s",
|
|
||||||
payload.get('request_id'), error)
|
|
||||||
else:
|
|
||||||
# A submit or cancel wakes the wait at once.
|
|
||||||
self._wake.wait(self.retry_interval)
|
|
||||||
|
|
||||||
def _attempt(self, payload: Dict[str, Any]):
|
|
||||||
try:
|
|
||||||
self._send(payload)
|
|
||||||
except control_client.ControlError as e:
|
|
||||||
if control_client.display_not_listening(e):
|
|
||||||
return 'retry', None
|
|
||||||
return 'error', str(e.reason)
|
|
||||||
except Exception: # pylint: disable=broad-except
|
|
||||||
logger.exception("On-demand start %s: the control socket client failed",
|
|
||||||
payload.get('request_id'))
|
|
||||||
return 'error', 'internal'
|
|
||||||
return 'delivered', None
|
|
||||||
|
|
||||||
|
|
||||||
_dispatcher: Optional[OnDemandDispatcher] = None
|
|
||||||
_dispatcher_lock = threading.Lock()
|
|
||||||
|
|
||||||
|
|
||||||
def get_dispatcher(send: Sender) -> OnDemandDispatcher:
|
|
||||||
"""The web process's dispatcher, created on first use."""
|
|
||||||
global _dispatcher
|
|
||||||
with _dispatcher_lock:
|
|
||||||
if _dispatcher is None:
|
|
||||||
_dispatcher = OnDemandDispatcher(send)
|
|
||||||
return _dispatcher
|
|
||||||
|
|
||||||
|
|
||||||
def current() -> Optional[OnDemandDispatcher]:
|
|
||||||
"""The dispatcher if one was created, without creating one."""
|
|
||||||
return _dispatcher
|
|
||||||
|
|
||||||
|
|
||||||
def reset_for_tests() -> None:
|
|
||||||
global _dispatcher
|
|
||||||
with _dispatcher_lock:
|
|
||||||
_dispatcher = None
|
|
||||||
@@ -355,12 +355,9 @@ window.previewPluginNow = function(pluginId) {
|
|||||||
})
|
})
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
// 'starting' (202): the display service is starting and the request
|
|
||||||
// follows once it listens.
|
|
||||||
const starting = data.status === 'starting';
|
|
||||||
showNotification(data.message || ('Previewing ' + pluginId + ' for 60 seconds'),
|
showNotification(data.message || ('Previewing ' + pluginId + ' for 60 seconds'),
|
||||||
starting ? 'info' : (data.status || 'success'));
|
data.status || 'success');
|
||||||
if (data.status === 'success' || starting) window.toggleFloatingPreview(true);
|
if (data.status === 'success') window.toggleFloatingPreview(true);
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
showNotification('Preview failed: ' + err.message, 'error');
|
showNotification('Preview failed: ' + err.message, 'error');
|
||||||
|
|||||||
@@ -191,28 +191,6 @@ export function revokeToken(ctx, id, name) {
|
|||||||
}).catch(failed(ctx));
|
}).catch(failed(ctx));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The "Start Display on Boot" checkbox: enable or disable ledmatrix.service
|
|
||||||
* at once. A refusal (e.g. the sudo rule is missing) puts the box back.
|
|
||||||
*/
|
|
||||||
export function setDisplayAutostart(ctx, box) {
|
|
||||||
const on = box.checked;
|
|
||||||
box.disabled = true;
|
|
||||||
return send(ctx, 'POST', '/api/v3/system/action', { action: on ? 'enable_autostart' : 'disable_autostart' }).then(function(res) {
|
|
||||||
if (!res) return false;
|
|
||||||
const ok = res.ok && res.d.status === 'success';
|
|
||||||
if (!ok) box.checked = !on;
|
|
||||||
ctx.notify(ok ? (on ? 'The display will start on boot' : 'The display will not start on boot')
|
|
||||||
: (res.d.message || 'Could not change display autostart'), ok ? 'success' : 'error');
|
|
||||||
return ok;
|
|
||||||
}).catch(function(err) {
|
|
||||||
box.checked = !on;
|
|
||||||
failed(ctx)(err);
|
|
||||||
}).finally(function() {
|
|
||||||
box.disabled = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const FORM_ACTIONS = { 'set-password': setPassword, 'disable-login': disableLogin, 'create-token': createToken };
|
const FORM_ACTIONS = { 'set-password': setPassword, 'disable-login': disableLogin, 'create-token': createToken };
|
||||||
|
|
||||||
function drawTimezone(root, ctx) {
|
function drawTimezone(root, ctx) {
|
||||||
@@ -260,10 +238,6 @@ export function init(root, ctx) {
|
|||||||
if (row) revokeToken(ctx, row.dataset.tokenId, button.dataset.tokenName);
|
if (row) revokeToken(ctx, row.dataset.tokenId, button.dataset.tokenName);
|
||||||
}
|
}
|
||||||
}, on);
|
}, on);
|
||||||
root.addEventListener('change', function(event) {
|
|
||||||
const box = event.target;
|
|
||||||
if (box && box.dataset && box.dataset.action === 'display-autostart') setDisplayAutostart(ctx, box);
|
|
||||||
}, on);
|
|
||||||
drawTimezone(root, ctx);
|
drawTimezone(root, ctx);
|
||||||
active = ctx;
|
active = ctx;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,26 +39,12 @@ const PICKERS = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Each plugin's picker (the Plugin Schedules card): the window in which the
|
|
||||||
// plugin may show. Unset, it offers the whole day.
|
|
||||||
const PLUGIN_PICKER = { label: 'PluginSchedule', start: '00:00', end: '23:59', fillEmptyDays: true };
|
|
||||||
|
|
||||||
const FORMS = {
|
const FORMS = {
|
||||||
schedule_form: { saved: 'Schedule settings saved', failed: 'Error saving schedule' },
|
schedule_form: { saved: 'Schedule settings saved', failed: 'Error saving schedule' },
|
||||||
dim_schedule_form: { saved: 'Dim schedule settings saved', failed: 'Error saving dim schedule' },
|
dim_schedule_form: { saved: 'Dim schedule settings saved', failed: 'Error saving dim schedule' },
|
||||||
};
|
};
|
||||||
const PLUGIN_FORM = { saved: 'Plugin schedule saved', failed: 'Error saving plugin schedule' };
|
|
||||||
|
|
||||||
/** The notification labels for a form on this page, or null. */
|
|
||||||
function formLabels(form) {
|
|
||||||
if (FORMS[form.id]) return FORMS[form.id];
|
|
||||||
return form.classList.contains('plugin-schedule-form') ? PLUGIN_FORM : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const WIDGET_OPTIONS = { showModeToggle: true, showEnableToggle: true, compactMode: false };
|
const WIDGET_OPTIONS = { showModeToggle: true, showEnableToggle: true, compactMode: false };
|
||||||
const PLUGIN_WIDGET_OPTIONS = Object.assign({}, WIDGET_OPTIONS, {
|
|
||||||
enableDescription: 'When enabled, this plugin only shows during the hours below.',
|
|
||||||
});
|
|
||||||
|
|
||||||
// The mounted page, for the deprecated aliases (boot.js).
|
// The mounted page, for the deprecated aliases (boot.js).
|
||||||
let active = null;
|
let active = null;
|
||||||
@@ -140,11 +126,6 @@ function drawPickers(root, ctx) {
|
|||||||
widget.render(container, { 'x-options': WIDGET_OPTIONS },
|
widget.render(container, { 'x-options': WIDGET_OPTIONS },
|
||||||
widgetValue(readConfig(root, spec.data), spec), { fieldId: spec.fieldId });
|
widgetValue(readConfig(root, spec.data), spec), { fieldId: spec.fieldId });
|
||||||
});
|
});
|
||||||
root.querySelectorAll('[data-plugin-schedule-config]').forEach(function(container) {
|
|
||||||
widget.render(container, { 'x-options': PLUGIN_WIDGET_OPTIONS },
|
|
||||||
widgetValue(readConfig(container, 'data-plugin-schedule-config'), PLUGIN_PICKER),
|
|
||||||
{ fieldId: container.id });
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
attempt();
|
attempt();
|
||||||
}
|
}
|
||||||
@@ -154,7 +135,7 @@ export function init(root, ctx) {
|
|||||||
|
|
||||||
root.addEventListener('htmx:afterRequest', function(event) {
|
root.addEventListener('htmx:afterRequest', function(event) {
|
||||||
const form = event.target && event.target.closest ? event.target.closest('form') : null;
|
const form = event.target && event.target.closest ? event.target.closest('form') : null;
|
||||||
const labels = form && root.contains(form) ? formLabels(form) : null;
|
const labels = form && root.contains(form) ? FORMS[form.id] : null;
|
||||||
if (labels && event.detail && event.detail.xhr) report(ctx.notify, event.detail.xhr, labels);
|
if (labels && event.detail && event.detail.xhr) report(ctx.notify, event.detail.xhr, labels);
|
||||||
}, on);
|
}, on);
|
||||||
|
|
||||||
|
|||||||
@@ -188,7 +188,7 @@
|
|||||||
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||||||
<span class="ml-2 text-sm font-medium text-gray-900">Enable Schedule</span>
|
<span class="ml-2 text-sm font-medium text-gray-900">Enable Schedule</span>
|
||||||
</label>
|
</label>
|
||||||
<p class="mt-1 text-sm text-gray-600">${window.LEDEscape.html(xOptions.enableDescription || 'When enabled, the display will only operate during specified hours.')}</p>
|
<p class="mt-1 text-sm text-gray-600">When enabled, the display will only operate during specified hours.</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1739,13 +1739,6 @@ function submitOnDemandRequest(event) {
|
|||||||
showNotification(`Requested on-demand mode for ${pluginName}`, 'success');
|
showNotification(`Requested on-demand mode for ${pluginName}`, 'success');
|
||||||
closeOnDemandModal();
|
closeOnDemandModal();
|
||||||
setTimeout(() => loadOnDemandStatus(true), 700);
|
setTimeout(() => loadOnDemandStatus(true), 700);
|
||||||
} else if (result.status === 'starting') {
|
|
||||||
// 202: the display service is starting; the web process sends
|
|
||||||
// the request once it listens. The status card shows the outcome.
|
|
||||||
const pluginName = resolvePluginDisplayName(currentOnDemandPluginId);
|
|
||||||
showNotification(`Starting the display for ${pluginName}…`, 'info');
|
|
||||||
closeOnDemandModal();
|
|
||||||
setTimeout(() => loadOnDemandStatus(true), 700);
|
|
||||||
} else {
|
} else {
|
||||||
console.error('[submitOnDemandRequest] Request failed:', result);
|
console.error('[submitOnDemandRequest] Request failed:', result);
|
||||||
showNotification(result.message || 'Failed to start on-demand mode', 'error');
|
showNotification(result.message || 'Failed to start on-demand mode', 'error');
|
||||||
|
|||||||
@@ -12,22 +12,6 @@
|
|||||||
|
|
||||||
{{ ui.settings_filter() }}
|
{{ ui.settings_filter() }}
|
||||||
|
|
||||||
{# Display autostart is systemd's enable/disable of ledmatrix.service, not a
|
|
||||||
config.json key: outside the form so Save never posts it. general.js
|
|
||||||
applies it on change. Hidden when the unit's state cannot be read. #}
|
|
||||||
{% if display_autostart is not none %}
|
|
||||||
<div class="form-group mb-6" id="setting-general-display_autostart" data-setting-key="display_autostart">
|
|
||||||
<label class="flex items-center">
|
|
||||||
<input type="checkbox"
|
|
||||||
data-action="display-autostart"
|
|
||||||
{% if display_autostart %}checked{% endif %}
|
|
||||||
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
|
||||||
<span class="ml-2 text-sm font-medium text-gray-900">Start Display on Boot</span>
|
|
||||||
{{ ui.help_tip('Start the LED display automatically when the device powers on.\nApplies right away; no Save needed.\nWhen off, start it with Start Display on the Overview tab. The web interface still starts on boot.\nDefault: on.', 'Start Display on Boot') }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<form hx-post="/api/v3/config/main"
|
<form hx-post="/api/v3/config/main"
|
||||||
hx-ext="json-enc"
|
hx-ext="json-enc"
|
||||||
hx-headers='{"Content-Type": "application/json"}'
|
hx-headers='{"Content-Type": "application/json"}'
|
||||||
|
|||||||
@@ -85,51 +85,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Plugin Schedules Section (config plugin_schedules, src/plugin_schedule.py) -->
|
|
||||||
<div class="bg-white rounded-lg shadow p-6 mt-6">
|
|
||||||
<div class="border-b border-gray-200 pb-4 mb-6">
|
|
||||||
<h2 class="text-lg font-semibold text-gray-900">Plugin Schedules</h2>
|
|
||||||
<p class="mt-1 text-sm text-gray-600">Choose when each plugin may show. Outside its schedule a plugin is skipped in the rotation and in Vegas mode; it stays loaded, and on-demand still shows it. "Hide while a game is live" skips it whenever any plugin has a live game, so on gameday you only see scores.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if plugin_schedule_rows %}
|
|
||||||
<div class="space-y-3">
|
|
||||||
{% for row in plugin_schedule_rows %}
|
|
||||||
<details class="border border-gray-200 rounded-md">
|
|
||||||
<summary class="cursor-pointer px-4 py-3 text-sm font-medium text-gray-900">
|
|
||||||
{{ row.name }}
|
|
||||||
{% if row.schedule.enabled or row.schedule.hide_when_live %}
|
|
||||||
<span class="ml-2 text-xs text-blue-700">scheduled</span>
|
|
||||||
{% endif %}
|
|
||||||
</summary>
|
|
||||||
<form class="plugin-schedule-form px-4 pb-4 space-y-4"
|
|
||||||
hx-post="/api/v3/config/plugin-schedules"
|
|
||||||
hx-ext="json-enc"
|
|
||||||
hx-headers='{"Content-Type": "application/json"}'
|
|
||||||
hx-swap="none"
|
|
||||||
data-reports-result>
|
|
||||||
<input type="hidden" name="plugin_id" value="{{ row.id }}">
|
|
||||||
<label class="flex items-center text-sm text-gray-700">
|
|
||||||
<input type="checkbox" name="hide_when_live" class="mr-2"
|
|
||||||
{% if row.schedule.hide_when_live %}checked{% endif %}>
|
|
||||||
Hide while a game is live
|
|
||||||
</label>
|
|
||||||
<div id="plugin_schedule_picker_{{ loop.index }}"
|
|
||||||
data-plugin-schedule-config='{{ row.schedule | tojson }}'></div>
|
|
||||||
<div class="flex justify-end">
|
|
||||||
<button type="submit"
|
|
||||||
class="btn bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md">
|
|
||||||
<i class="fas fa-save mr-2"></i>
|
|
||||||
Save {{ row.name }} Schedule
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</details>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<p class="text-sm text-gray-600">No enabled plugins.</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,8 +24,6 @@ import re
|
|||||||
import subprocess # nosec B404 - list-form argv only, no shell # nosemgrep
|
import subprocess # nosec B404 - list-form argv only, no shell # nosemgrep
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from web_interface import git_index_lock
|
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
CHANNELS = ('stable', 'beta')
|
CHANNELS = ('stable', 'beta')
|
||||||
DEFAULT_CHANNEL = 'stable'
|
DEFAULT_CHANNEL = 'stable'
|
||||||
@@ -107,7 +105,7 @@ class ChannelStatus(dict):
|
|||||||
|
|
||||||
|
|
||||||
def _git(project_dir, run, *args, timeout=30):
|
def _git(project_dir, run, *args, timeout=30):
|
||||||
run = run or git_index_lock.run_git
|
run = run or subprocess.run
|
||||||
return run(['git', *args], cwd=str(project_dir), capture_output=True, text=True, timeout=timeout)
|
return run(['git', *args], cwd=str(project_dir), capture_output=True, text=True, timeout=timeout)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user