mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-09 00:26:37 +00:00
Compare commits
8
Commits
v3.8.0
...
41db488c73
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41db488c73 | ||
|
|
77e0ea91ae | ||
|
|
6c6394a1a7 | ||
|
|
4be53d048b | ||
|
|
9edeb6da14 | ||
|
|
a21650e746 | ||
|
|
eb8128a981 | ||
|
|
16b566e14f |
+117
@@ -17,6 +17,111 @@ release that ships it.
|
||||
accepts both, but the store flags the old spelling as deprecated
|
||||
(`store_manager.py`) and only the new one is in `schema/manifest_schema.json`.
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Shared fetch service (stage 1)
|
||||
|
||||
Core's own HTTP fetch paths now go through one service, so the plugins that
|
||||
use them get pooling, merging, host budgets and per-plugin request counts
|
||||
without a code change. Return values, exceptions, cache keys, TTLs and retry
|
||||
policies are unchanged.
|
||||
|
||||
- **What goes through it.** `APIHelper.get`/`post`, `fetch_espn_scoreboard`
|
||||
and its date chunks (`src/common/espn_dates.py` -- every scoreboard's live,
|
||||
recent and upcoming fetch, and `SportsFetchMixin._fetch_season_directly`),
|
||||
`BackgroundDataService` and `BaseOddsManager.get_odds`. Plugins' own
|
||||
`requests` calls are not covered yet.
|
||||
- **Shared connection pools.** Core sessions with the same retry policy mount
|
||||
one shared adapter, so the odds managers (one per scoreboard league
|
||||
manager), the background service and the APIHelpers reuse one connection
|
||||
pool per host. Headers, cookies and auth stay per session.
|
||||
- **Merged requests.** Identical GETs in flight at once (same URL and query,
|
||||
effective headers, timeout and retry policy) go out once; the others get a
|
||||
copy of that response or the same exception. `BackgroundDataService`'s own
|
||||
request opts out (`share_in_flight=False`): it cancels and replaces fetches,
|
||||
and already merges by cache key.
|
||||
- **Host budgets.** Per-host token buckets, `fetch_service.rate_limits` in
|
||||
`config.json` (new optional section in the template). ESPN hosts default to
|
||||
20 requests/s with a burst of 200, far above normal traffic; no request waits
|
||||
longer than `max_wait_seconds` (2 s). Other hosts are unthrottled.
|
||||
- **Conditional GET.** A response with `ETag` or `Last-Modified` is kept in a
|
||||
small bounded store (64 entries, 4 MB, 1 MB each) and revalidated; a `304`
|
||||
is returned to the caller as the original `200`. ESPN sends neither
|
||||
validator today, so on ESPN this is dormant.
|
||||
- **Counters.** Requests, merged, bytes, 304s, errors, HTTP errors, adapter
|
||||
retries, throttled requests and seconds waited, per plugin and per host.
|
||||
Which plugin made a request comes from a context variable the plugin
|
||||
executor and plugin loader set (carried across the background service's and
|
||||
`espn_dates`' worker threads), or else from the plugin directory on the
|
||||
stack, so a plugin's own threads count too. The display publishes them to
|
||||
the shared cache at most once a minute on change; read them at
|
||||
`GET /api/v3/plugins/fetch-stats`.
|
||||
- `fetch_service` is a core config section (`src/core_config_keys.py`).
|
||||
|
||||
### New modules
|
||||
|
||||
- `src/common/fetch_service.py` -- the fetch service above. Core-internal in
|
||||
this release: plugins reach it through `APIHelper` and `espn_dates`, and
|
||||
should not import it directly until a plugin-facing API ships (stage 3), so
|
||||
it sets no `ledmatrix_min_version` floor.
|
||||
|
||||
### Tooling
|
||||
|
||||
- Golden trace tests for the display loop. `test/test_run_loop_golden.py`
|
||||
runs the real `DisplayController.run()` against fake plugins on a fake
|
||||
clock (`test/_run_loop_harness.py`), with no hardware and no real sleeps,
|
||||
and compares which mode was shown, for how long and why it ended with
|
||||
`test/fixtures/run_loop_golden/`. It has 15 scenarios: rotation,
|
||||
empty and failing modes, dynamic duration, live priority, on-demand
|
||||
(including pinned and resumed after a restart), the schedule and dim
|
||||
schedule, WiFi notices, sync follower and Vegas. The whole file runs in
|
||||
about a second. This is stage 1 of restructuring `run()`, described in
|
||||
`docs/RUN_LOOP_REDESIGN.md`. The other part of stage 1 is internal and
|
||||
changes no behaviour: twelve blocks of `run()` move into named helpers
|
||||
(`_dispatch_first_frame`, `_resolve_durations`, `_resolve_active_mode`,
|
||||
`_needs_high_fps`, `_advance_after_screen` and others), and the traces are
|
||||
identical before and after the move.
|
||||
|
||||
### Fixes
|
||||
|
||||
- A plugin whose `display()` raises now opens its circuit breaker. The first
|
||||
frame of each screen goes through the plugin executor, which caught the
|
||||
exception and returned False. The display read that as "no content" and
|
||||
recorded a success, which reset the plugin's failure streak, so the breaker
|
||||
never tripped. The plugin stayed in rotation and logged a traceback on
|
||||
every screen. The raise now counts as a failure, so after three in a row
|
||||
the plugin leaves rotation until the cooldown ends, the same as a raising
|
||||
`update()`. The display still moves straight on to the next mode. A hung
|
||||
`display()` is still recorded once, as a hang.
|
||||
- A WiFi notice (such as "Connected to HomeNet" or "AP mode on") now shows
|
||||
within about a second of being posted. It was only checked between
|
||||
screens, so a 5 s notice posted during a 20 s screen expired before that
|
||||
screen ended and never appeared. The screen it interrupts comes back in
|
||||
full once the notice ends. When Vegas stops scrolling for a notice, the
|
||||
notice is what shows next, and Vegas resumes after it; before, a rotation
|
||||
screen showed instead and the notice expired behind it. An active
|
||||
on-demand session still holds the panel until it ends.
|
||||
- A game that goes live now takes over the panel within about a second.
|
||||
Live priority was only checked between screens, so a game that went live
|
||||
during a 30 s screen waited for that screen to end. The frame loops and the
|
||||
dwell sleep now check too, at most once a second, and not while an
|
||||
on-demand session is running or a live game is already showing. When Vegas
|
||||
stops for a live game, the game is the next screen. Before, one rotation
|
||||
screen showed first and the game came after it. Each check also asks each
|
||||
plugin `has_live_content()` once, where a plugin registered under several
|
||||
modes used to be asked once per mode.
|
||||
- The display schedule turns the panel off at exactly the end time. A window
|
||||
now runs from its start time up to, but not including, its end time: with
|
||||
07:00-23:00 the panel is on at 07:00 and off at 23:00. Before, the end
|
||||
minute counted as on, and because the schedule is checked once a minute,
|
||||
the panel went off at 23:00 or at 23:01 depending on when in the minute
|
||||
that check ran. Windows that cross midnight and per-day schedules follow
|
||||
the same rule, and so does the dim schedule.
|
||||
- An on-demand session that ends during scheduled-off hours, by expiring or
|
||||
being stopped, blanks the panel within about a second. It used to stay on
|
||||
until the next minute, because the once-a-minute schedule check had
|
||||
already run that minute and the session had overridden its answer.
|
||||
|
||||
## 3.8.0
|
||||
|
||||
Live Vegas elements: plugin content that keeps changing while it scrolls
|
||||
@@ -112,6 +217,18 @@ guard the import, since the loader's version check is advisory).
|
||||
processes, or turns the socket off with `off`. A non-root dev run uses a
|
||||
private per-user path under the temp directory.
|
||||
|
||||
### Scroll speed
|
||||
|
||||
- The Vegas Scroll Speed slider now says what the panel will do with the speed
|
||||
it is on, and offers the nearest smooth ones to click. Only speeds that advance
|
||||
a whole number of pixels per refresh look smooth, and which those are depends
|
||||
on the panel (`GET /api/v3/config/scroll-speed-advice`, built on
|
||||
`scroll_config.speed_advice()`; it uses the refresh the display measured, not
|
||||
the `limit_refresh_rate_hz` cap). The slider steps by 1 px/s instead of 5.
|
||||
- The default 50 px/s no longer snaps to a stepped 48 px/s (2 px every 5
|
||||
refreshes, 24 fps) on a 120 Hz panel: `solve_crisp()` now prefers 60 or 40 px/s,
|
||||
which move one pixel at a time. 100 Hz panels are unaffected.
|
||||
|
||||
### Update channels
|
||||
|
||||
- Devices no longer pick up every merge to `main`. A new setting,
|
||||
|
||||
@@ -174,6 +174,16 @@
|
||||
"plugin_system": {
|
||||
"plugins_directory": "plugin-repos"
|
||||
},
|
||||
"fetch_service": {
|
||||
"enabled": true,
|
||||
"max_wait_seconds": 2,
|
||||
"rate_limits": {
|
||||
"*.espn.com": {
|
||||
"per_second": 20,
|
||||
"burst": 200
|
||||
}
|
||||
}
|
||||
},
|
||||
"web-ui-info": {
|
||||
"enabled": true,
|
||||
"display_duration": 10
|
||||
|
||||
@@ -48,6 +48,7 @@ each other. They share three things:
|
||||
| Plugin errors | cache `plugin_error_snapshot` | display: `ErrorSnapshotPublisher` ([`src/error_aggregator.py`](../src/error_aggregator.py)) | web: `read_error_report()` for `/api/v3/errors/*` |
|
||||
| Error clear | cache `plugin_error_clear_request` | web | display |
|
||||
| Font usage | cache `font_usage_snapshot` | display: `FontUsagePublisher` ([`src/font_usage.py`](../src/font_usage.py)) | web: Fonts tab |
|
||||
| Fetch statistics (requests per plugin and host) | cache `fetch_stats_snapshot` | display: `FetchStatsPublisher` ([`src/common/fetch_service.py`](../src/common/fetch_service.py)), at most once a minute on change | web: `read_fetch_stats()` for `/api/v3/plugins/fetch-stats` |
|
||||
| Plugin health | cache `plugin_health:<id>` | display (web writes on reset) | web: `/api/v3/plugins/health` |
|
||||
| Plugin runtime (loaded, state, last error, version) | cache `plugin_runtime_snapshot` | display: `PluginRuntimePublisher` ([`src/plugin_system/plugin_runtime.py`](../src/plugin_system/plugin_runtime.py)) | web: `read_plugin_runtime()` for `/api/v3/plugins/installed`, `/plugins/state`, reconciliation |
|
||||
| Preview frame | `/tmp/led_matrix_preview.png` | display: `DisplayManager`, gated by [`snapshot_policy`](../src/common/snapshot_policy.py) | web: display SSE stream, `/api/v3/health` (file age) |
|
||||
@@ -187,7 +188,8 @@ the scheduler), and sets up Vegas mode.
|
||||
enable/disable, poll on-demand requests, run scheduled plugin updates, check
|
||||
the on/off schedule and brightness, then show one screen. Priority is
|
||||
on-demand, then WiFi status messages, then live priority, then Vegas mode,
|
||||
then normal rotation.
|
||||
then normal rotation. [RUN_LOOP_REDESIGN.md](RUN_LOOP_REDESIGN.md) is the
|
||||
plan for restructuring this loop and lists its golden trace tests.
|
||||
|
||||
- **Rotation.** `available_modes` is the ordered list of display modes;
|
||||
`current_mode_index` advances after each screen.
|
||||
@@ -209,7 +211,10 @@ then normal rotation.
|
||||
to it, rotating between several live games.
|
||||
- **Schedule and dim schedule.** `_check_schedule()` reads `schedule`;
|
||||
`_check_dim_schedule()` reads `dim_schedule` and
|
||||
`display.hardware.brightness`. Both are re-evaluated once a minute.
|
||||
`display.hardware.brightness`. Both are re-evaluated once a minute, and
|
||||
both windows are half-open: on (or dimmed) from the start time, off at
|
||||
the end time. When an on-demand session ends, the on/off schedule is
|
||||
re-checked at once rather than at the next minute.
|
||||
- **Long screens.** While a screen is showing (a dwell, a scroll, a Vegas
|
||||
iteration), `_service_pending_changes()` repeats the on-demand, schedule
|
||||
and brightness checks every 0.25 s, so a change does not wait for the
|
||||
|
||||
@@ -31,6 +31,13 @@ tooling against it.
|
||||
| `start_time` / `end_time` | `"HH:MM"`, `07:00`–`23:00` | Global-mode on/off times |
|
||||
| `days.<weekday>.{enabled,start_time,end_time}` | per-day objects | Per-day-mode overrides |
|
||||
|
||||
The display is on from `start_time` up to, but not including, `end_time`:
|
||||
with `07:00`–`23:00` it turns on at 07:00 and off at 23:00. An end earlier
|
||||
than the start crosses midnight (`22:00`–`07:00` is on overnight). In
|
||||
per-day mode, the entry for the current day decides. An on-demand session
|
||||
keeps the display on during off hours; once it ends or is stopped, the
|
||||
display blanks within about a second.
|
||||
|
||||
Read by `DisplayController._check_schedule()` (`src/display_controller.py`).
|
||||
Managed in the web UI under Schedule.
|
||||
|
||||
@@ -44,7 +51,9 @@ Same shape as `schedule` (the template sets its `mode` to `"global"`), plus:
|
||||
|
||||
Read by `DisplayController._check_dim_schedule()` (`src/display_controller.py`;
|
||||
saved via `POST /api/v3/config/dim-schedule`). The display returns to
|
||||
`display.hardware.brightness` outside the window.
|
||||
`display.hardware.brightness` outside the window. The window has the same
|
||||
boundaries as `schedule`: dimmed from `start_time` up to, but not including,
|
||||
`end_time`.
|
||||
|
||||
## `display.hardware` — matrix panel hardware
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ Complete API reference for plugin developers. This document describes all method
|
||||
- [Display Manager](#display-manager)
|
||||
- [Cache Manager](#cache-manager)
|
||||
- [Plugin Manager](#plugin-manager)
|
||||
- [Fetching data](#fetching-data)
|
||||
- [Deprecated APIs](#deprecated-apis)
|
||||
|
||||
---
|
||||
@@ -1030,6 +1031,63 @@ if weather is not None and weather.enabled:
|
||||
|
||||
---
|
||||
|
||||
## Fetching data
|
||||
|
||||
Use the core helpers for HTTP rather than a `requests.Session` of your own:
|
||||
`APIHelper` (`from src.common import APIHelper`) for JSON APIs, and
|
||||
`fetch_espn_scoreboard()` (`src.common.espn_dates`) or
|
||||
`BackgroundDataService` for ESPN scoreboards. Since the release after 3.7.0
|
||||
these go through the core **fetch service** (`src/common/fetch_service.py`),
|
||||
so a plugin that uses them gets the following with no code change. Return
|
||||
values, exceptions and retries are what they were.
|
||||
|
||||
- **Shared connections.** Core sessions with the same retry policy share one
|
||||
connection pool per host, instead of one pool per helper.
|
||||
- **Merged requests.** Identical GETs in flight at the same time (same URL
|
||||
and query, headers, timeout and retry policy) go to the network once, and
|
||||
every caller gets its own copy of the response, or the same exception.
|
||||
- **Host budgets.** A host can have a token-bucket budget. A request past it
|
||||
waits for a token, but never longer than `max_wait_seconds` (2 s by
|
||||
default). Only ESPN hosts have one by default (20 requests a second, burst
|
||||
200), which normal use never reaches.
|
||||
- **Conditional GET.** When a server sends `ETag` or `Last-Modified`, the
|
||||
next identical request revalidates, and a `304 Not Modified` comes back to
|
||||
your code as the original `200` with its body. ESPN currently sends
|
||||
neither, so this does nothing there.
|
||||
- **Counters.** Requests, merged requests, bytes, 304s, errors and time spent
|
||||
waiting are counted per plugin and per host, and published for the web UI
|
||||
at `GET /api/v3/plugins/fetch-stats` (see
|
||||
[REST_API_REFERENCE.md](REST_API_REFERENCE.md#get-fetch-statistics)). A
|
||||
request is counted against your plugin when it runs inside your
|
||||
`update()`/`display()`, your constructor or `on_enable()`, or anywhere in
|
||||
code under your plugin's directory, including threads you start.
|
||||
|
||||
What is not covered yet: requests a plugin makes with its own `requests.get()`
|
||||
or `Session.get()` calls. They work as before but are invisible to the
|
||||
budgets and counters.
|
||||
|
||||
The settings live in `config.json` under `fetch_service`, read when the
|
||||
display starts and on a config reload:
|
||||
|
||||
```json
|
||||
"fetch_service": {
|
||||
"enabled": true,
|
||||
"max_wait_seconds": 2,
|
||||
"rate_limits": {
|
||||
"*.espn.com": {"per_second": 20, "burst": 200},
|
||||
"api.example.com": {"per_second": 1, "burst": 5}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`rate_limits` keys are a host or a `*.domain` pattern (which also matches
|
||||
the bare domain); `"per_second": 0` removes a budget. `"enabled": false`
|
||||
turns the whole service into a plain `session.get()`. Two further switches,
|
||||
`"single_flight": false` and `"conditional_get": false`, turn off merging and
|
||||
revalidation.
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Caching
|
||||
|
||||
@@ -980,6 +980,63 @@ Metrics for one plugin; `data` has the same fields as one entry above.
|
||||
|
||||
Reset metrics for a plugin.
|
||||
|
||||
### Get Fetch Statistics
|
||||
|
||||
**GET** `/api/v3/plugins/fetch-stats`
|
||||
|
||||
Network requests made through the core fetch service
|
||||
(`src/common/fetch_service.py`), per plugin and per host, cumulative since
|
||||
the display started. Read-only. The display publishes the counters at most
|
||||
once a minute when they change (every 10 minutes otherwise), so they can be
|
||||
up to a minute old. Requests a plugin makes with its own `requests` calls,
|
||||
outside `APIHelper`, `espn_dates`, `BackgroundDataService` and
|
||||
`BaseOddsManager`, are not counted yet.
|
||||
|
||||
`data.status` is `live`, `stale` (no publish for longer than
|
||||
`stale_after`), `stopped` (the display exited; the last counters are kept)
|
||||
or `unknown` (nothing published; `data.data` is `null`).
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"data": {
|
||||
"status": "live",
|
||||
"age_seconds": 12.4,
|
||||
"data": {
|
||||
"schema": 1,
|
||||
"running": true,
|
||||
"published_at": 1790000000.0,
|
||||
"stale_after": 720.0,
|
||||
"since": 1789990000.0,
|
||||
"totals": {"requests": 412, "merged": 3, "not_modified": 0,
|
||||
"errors": 1, "http_errors": 2, "retries": 0,
|
||||
"throttled": 0, "overruns": 0, "bytes": 18234011,
|
||||
"wait_seconds": 0.0},
|
||||
"plugins": {
|
||||
"football-scoreboard": {"requests": 240, "merged": 2, "bytes": 9120330,
|
||||
"hosts": {"site.api.espn.com": 180,
|
||||
"sports.core.api.espn.com": 62},
|
||||
"...": "the other counters, as in totals"}
|
||||
},
|
||||
"hosts": {
|
||||
"site.api.espn.com": {"requests": 301, "...": "as in totals"}
|
||||
},
|
||||
"validators": {"entries": 0, "bytes": 0},
|
||||
"config": {"enabled": true, "single_flight": true,
|
||||
"conditional_get": true, "max_wait_seconds": 2.0,
|
||||
"rate_limits": {"*.espn.com": {"per_second": 20.0, "burst": 200.0}}}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`requests` counts round trips sent (retries inside the HTTP adapter are in
|
||||
`retries`), `merged` requests answered by an identical one already in
|
||||
flight, `not_modified` 304s served from the stored body, `errors` transport
|
||||
failures and `http_errors` responses with status 400 or above. `bytes` is the
|
||||
decoded body size. `core` is everything no plugin made.
|
||||
|
||||
### Get/Set Plugin Limits
|
||||
|
||||
**GET** `/api/v3/plugins/limits/<plugin_id>`
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
# Restructuring `DisplayController.run()`
|
||||
|
||||
`run()` in [`src/display_controller.py`](../src/display_controller.py) decides
|
||||
what the panel shows and runs it. This document is the plan for turning it
|
||||
from one long loop into three parts with clear jobs: an **Arbiter** that
|
||||
decides, a **ScreenRunner** that runs one screen, and **Sources** that each
|
||||
know about one kind of content. It covers the target design, the stages that
|
||||
get there, and how each stage is checked.
|
||||
|
||||
The goal is to change how the control flow is organised, not to move code
|
||||
into more files. Each stage ships as its own PR, and none of them changes
|
||||
what the panel shows unless that PR says so and updates the golden traces
|
||||
on purpose.
|
||||
|
||||
## Why
|
||||
|
||||
- **The priority order is written in branch order, twice.** It is
|
||||
Follower, on-demand, WiFi notice, live priority, Vegas, rotation. In
|
||||
`run()` that order exists only as the order of `if` blocks. Vegas
|
||||
repeats part of it in its interrupt callback (`_check_vegas_interrupt`).
|
||||
- **Preemption is found by re-checking.** A screen ends early when
|
||||
something else changed `current_display_mode` or `is_display_active`
|
||||
underneath it. `run()` notices with five separate
|
||||
`current_display_mode != active_mode` checks: after an empty pass, in each
|
||||
of the two frame loops, after the frame loops, and before rotating.
|
||||
- **Most recent fixes were ordering bugs** between these branches (#618,
|
||||
#644, #649, #652): a lost mode switch, rotating past an on-demand request,
|
||||
spinning when every mode is empty.
|
||||
- **It could not be tested** without threads, real sleeps and stopping the
|
||||
loop by raising from a patched method.
|
||||
|
||||
## What `run()` does today
|
||||
|
||||
Each pass, in order:
|
||||
|
||||
1. `loop_pass()` (watchdog). Apply a pending plugin enable/disable.
|
||||
2. With no modes: dwell 1 s, next pass.
|
||||
3. Poll on-demand requests and expiry, release plugins loaded only for
|
||||
on-demand, tick plugin updates, drop an expired WiFi notice, evaluate
|
||||
the schedule (an on-demand session overrides scheduled-off), apply the
|
||||
brightness target.
|
||||
4. **Scheduled off:** blank, dwell up to 60 s. `_blank_while_scheduled_off`
|
||||
5. **Follower:** render one frame from the leader. `_run_follower_frame`
|
||||
6. **WiFi notice** (unless on-demand): draw it, dwell 0.5 s. `_show_wifi_notice`.
|
||||
It is also polled mid-screen (`_wifi_notice_pending`): the frame loops,
|
||||
the dwell sleep and an interrupted Vegas iteration end within about a
|
||||
second when one arrives, and a screen cut short resumes after it.
|
||||
7. **Live priority** (unless on-demand, or Vegas keeps live content in the
|
||||
ticker): switch to the next live mode, or resume the rotation. A game
|
||||
that goes live during a screen is caught sooner, by
|
||||
`_check_live_takeover` in the frame loops and the dwell sleep (at most
|
||||
once a second, and not while a live mode is showing).
|
||||
8. **Vegas** (unless on-demand, or live content preempts it): run one
|
||||
iteration of up to `max_cycle_duration`. A completed iteration ends the
|
||||
pass, and so does one that yielded for a WiFi notice or the schedule.
|
||||
Any other interrupted one falls through to step 9 in the same pass.
|
||||
9. **One screen:** pick the mode (`_resolve_active_mode`), the plugin
|
||||
(`_plugin_for_mode`), draw the first frame through the executor
|
||||
(`_dispatch_first_frame`). On no content, rotate at once
|
||||
(`_note_empty_pass`, `_skip_failed_plugin_modes`). Otherwise work out the
|
||||
bounds (`_track_dynamic_cycle`, `_resolve_durations`,
|
||||
`_clamp_to_on_demand`) and the frame rate (`_needs_high_fps`), run the
|
||||
125 Hz or 1 Hz frame loop, make up the minimum duration, then pick the
|
||||
next mode (`_advance_after_screen`).
|
||||
|
||||
The helpers named above were extracted in stage 1 without changing
|
||||
behaviour. The frame loops, the Vegas branch and every early exit are still
|
||||
inline in `run()`.
|
||||
|
||||
## Target design
|
||||
|
||||
```python
|
||||
def run(self):
|
||||
while True:
|
||||
inputs = self._drain_inputs() # requests, schedule, config, sync
|
||||
plan = self.arbiter.decide(self.state, inputs, clock.now())
|
||||
outcome = self.runner.run(plan) # ExitReason + elapsed
|
||||
self.state = self.state.after(plan, outcome) # rotation, on-demand index, live resume
|
||||
```
|
||||
|
||||
### Sources
|
||||
|
||||
Each kind of content is a Source. A Source looks at the state and the
|
||||
inputs and either offers a screen or passes. The Arbiter asks them in this
|
||||
order:
|
||||
|
||||
| Order | Source | Offers a screen when | Today |
|
||||
|---|---|---|---|
|
||||
| gate | ScheduledOff | the schedule is off and no on-demand session overrides it | step 4 |
|
||||
| 1 | Follower | a sync leader is driving this panel | step 5 |
|
||||
| 2 | OnDemand | a session is active (its mode list, index, expiry and pin) | `_resolve_active_mode` |
|
||||
| 3 | Wifi | a status message is pending and on-demand is not active | step 6 |
|
||||
| 4 | Live | a live-priority plugin has live content (round-robin across several) | step 7 |
|
||||
| 5 | Vegas | Vegas is enabled and nothing above wants the panel | step 8 |
|
||||
| 6 | Rotation | always: `available_modes[current_mode_index]` | step 9 |
|
||||
|
||||
ScheduledOff is a gate in front of the Sources because that is how it works
|
||||
today: a scheduled-off panel stays blank even for a follower, and only an
|
||||
on-demand session overrides it.
|
||||
|
||||
### Arbiter
|
||||
|
||||
```python
|
||||
Arbiter.decide(state, inputs, now) -> ScreenPlan
|
||||
```
|
||||
|
||||
`decide` is a pure function: it does no I/O, takes no locks and does not
|
||||
sleep. It can be tested with plain tables of (state, inputs, now) mapped to
|
||||
an expected plan. It returns a `ScreenPlan`:
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `source` | which Source won |
|
||||
| `mode`, `plugin` | what to draw (None for a blank or follower plan) |
|
||||
| `min_duration`, `max_duration` | from `_resolve_durations` and `_clamp_to_on_demand` |
|
||||
| `dynamic` | run until the plugin's cycle completes, between min and max |
|
||||
| `frame_policy` | today `_needs_high_fps` (125 Hz or 1 Hz); see stage 5 |
|
||||
| `preemptible_by` | the Sources allowed to interrupt this plan mid-screen |
|
||||
|
||||
### ScreenRunner
|
||||
|
||||
```python
|
||||
ScreenRunner(clock: FrameClock).run(plan) -> Outcome(exit_reason, elapsed)
|
||||
```
|
||||
|
||||
The ScreenRunner draws the first frame (`_dispatch_first_frame`), runs the
|
||||
frame loop that the plan's frame policy selects, services pending changes
|
||||
between frames, and returns one `ExitReason`:
|
||||
|
||||
| ExitReason | Today's equivalent (golden-trace exit) |
|
||||
|---|---|
|
||||
| `DURATION` | target duration reached (`duration`) |
|
||||
| `CYCLE_COMPLETE` | dynamic plugin finished after its minimum (`cycle-complete`) |
|
||||
| `EMPTY` | first frame returned False (`empty`; `raised` when display() raised inside the executor) |
|
||||
| `ERROR` | the dispatch itself raised (`error`) |
|
||||
| `DISPLAY_FALSE` | a later frame returned False (`display-false`) |
|
||||
| `PREEMPTED` | another Source took the panel (`on-demand-*`, `schedule-off`, `vegas-interrupt`, ...) |
|
||||
|
||||
`PREEMPTED` replaces the five `current_display_mode != active_mode` checks.
|
||||
The runner asks the Arbiter, at the throttled service points it already has,
|
||||
whether a Source in `plan.preemptible_by` now wants the panel.
|
||||
|
||||
`FrameClock` provides `now()` and `sleep()`. In production it is
|
||||
`time.monotonic`/`time.sleep`. In the golden traces it is the fake clock
|
||||
that the harness patches in today.
|
||||
|
||||
## Stages
|
||||
|
||||
| Stage | Change | Behaviour change | Verified by |
|
||||
|---|---|---|---|
|
||||
| 1 | Golden traces; extract helpers from `run()` | none | traces generated on main pass unchanged; mutation check |
|
||||
| 2 | Arbiter with Follower and Wifi Sources | none | traces unchanged; Arbiter unit tables; ledpi smoke |
|
||||
| 3 | ScreenRunner, FrameClock, ExitReason, `PREEMPTED`; OnDemand, Live, Rotation Sources | none | traces unchanged; ledpi frame soak A/B |
|
||||
| 4 | Vegas as a Source driven by `run_frame()` | none intended | traces against the real coordinator; ledpi Vegas soak A/B |
|
||||
| 5 | Plugins declare `frame_policy` | DEBUG instead of INFO for the FPS line | traces; soak on a static-heavy rotation |
|
||||
|
||||
### Stage 1 (this PR)
|
||||
|
||||
- `test/_run_loop_harness.py` builds a real `DisplayController` through
|
||||
`__init__` on in-memory fakes (plugins, cache, config service, plugin
|
||||
manager, sync manager, display manager). It swaps the module's `time` and
|
||||
`datetime` for one fake clock and runs the real `run()` until a horizon.
|
||||
The first frame of each screen still goes through the real
|
||||
`PluginExecutor` and the per-plugin locks.
|
||||
- `test/test_run_loop_golden.py` has 15 scenarios, each compared with
|
||||
`test/fixtures/run_loop_golden/<scenario>.json`:
|
||||
- plain rotation (display_durations override, a high-FPS scroller, a
|
||||
plugin whose `display()` takes no `display_mode`)
|
||||
- empty modes and a mode with no plugin; an all-empty rotation (the 1 s
|
||||
pause)
|
||||
- plugin errors and the circuit breaker
|
||||
- dynamic duration (cycle complete, plugin cap, global cap)
|
||||
- live priority taking over and handing back; live round-robin
|
||||
- on-demand start/stop/expiry; pinned on-demand; a session resumed after
|
||||
a restart
|
||||
- schedule off and dim, with an on-demand override during downtime
|
||||
- WiFi notice; sync follower
|
||||
- Vegas, with and without `live_in_ticker`
|
||||
- Each trace row is `[start, mode, duration, exit_reason, frames,
|
||||
force_clear]`. The exit reason is the event that decided what came next.
|
||||
- All 16 tests run in under a second. The goldens were generated from
|
||||
main's `run()` before any code moved.
|
||||
- Vegas uses `FakeVegas`, which implements only the contract the controller
|
||||
depends on: `run_iteration()` returns True after its duration and False
|
||||
when the interrupt or live check asks it to yield, checking at the real
|
||||
coordinator's cadence. Running the real coordinator on the fake clock
|
||||
belongs to stage 4.
|
||||
- Twelve helpers were extracted from `run()` (listed under "What `run()`
|
||||
does today"). Breaking any one of them fails at least one golden trace.
|
||||
|
||||
### Stage 2: Arbiter, starting with Follower and Wifi
|
||||
|
||||
1. Add `ScreenPlan` and an `Arbiter` with the ScheduledOff gate, Follower
|
||||
and Wifi. Every other case returns a `LEGACY` plan, which means "carry on
|
||||
with the existing code" (steps 7-9).
|
||||
2. `run()` calls `decide()` after the bookkeeping in step 3 and dispatches
|
||||
on `plan.source`: blank, `_run_follower_frame()`, the WiFi notice, or the
|
||||
existing path. Inputs that Sources read (follower active, the pending
|
||||
WiFi message, schedule state) are collected first, so `decide()` stays
|
||||
pure.
|
||||
3. Unit-test `decide()` with tables. The golden traces must not change.
|
||||
The Wifi Source must keep the mid-screen preemption described in step 6
|
||||
of "What `run()` does today".
|
||||
|
||||
Follower and Wifi go first because each is one self-contained branch that
|
||||
ends the pass. They prove the plumbing without touching the frame loops.
|
||||
|
||||
### Stage 3: ScreenRunner and `PREEMPTED`
|
||||
|
||||
Move the two frame loops, the make-up dwell and the dynamic-duration exit
|
||||
into `ScreenRunner.run(plan)` with an injected `FrameClock`. Replace the
|
||||
five re-checks with `PREEMPTED`. Add the OnDemand, Live and Rotation Sources
|
||||
so `LEGACY` is left meaning only Vegas.
|
||||
|
||||
This stage touches frame pacing (the 8 ms deadline sleep, the 1 ms yield),
|
||||
so it needs a frame soak on ledpi, A/B against main. Coordinate with
|
||||
whoever owns scroll performance (`docs/SCROLL_PERFORMANCE.md`).
|
||||
|
||||
### Stage 4: Vegas as a Source
|
||||
|
||||
The controller calls `coordinator.run_frame()` once per frame from the
|
||||
ScreenRunner instead of handing over to `run_iteration()` for up to
|
||||
`max_cycle_duration`. The interrupt callback and the second copy of the
|
||||
priority order go away, because preemption becomes `PREEMPTED`. The
|
||||
`vegas-plugin-tick` thread that is spawned every 4 s becomes the
|
||||
controller's normal update tick. Extend the harness to drive the real
|
||||
coordinator on the fake clock, which means patching its `time` and running
|
||||
its prefetch inline. Verify with a Vegas soak on ledpi, A/B.
|
||||
|
||||
### Stage 5: `frame_policy`
|
||||
|
||||
Plugins declare `frame_policy` (STATIC, PERIODIC(hz), ANIMATED(fps),
|
||||
SCROLL). `_needs_high_fps` becomes the mapping for legacy plugins
|
||||
(`needs_high_fps`, the `static-image` special case, `enable_scrolling`),
|
||||
and its per-screen INFO line drops to DEBUG.
|
||||
|
||||
## How each stage is verified
|
||||
|
||||
- **Golden traces.** Run `python -m pytest test/test_run_loop_golden.py`;
|
||||
it takes about a second. A refactoring stage must leave every trace
|
||||
unchanged. A deliberate behaviour change regenerates them with
|
||||
`LEDMATRIX_REGEN_GOLDEN=1` in its own commit, and the commit message
|
||||
explains each changed row. A new scenario's golden is generated against
|
||||
main's `run()` first, then checked against the branch.
|
||||
- **Mutation check.** Break each moved or new piece once, for example take
|
||||
`max` of the caps instead of `min`, or skip the live hold. At least one
|
||||
trace must fail each time. Stage 1 did this for all twelve helpers.
|
||||
- **Full suite.** Diff the FAILED/ERROR ids against a baseline run of main
|
||||
in a separate worktree. The Windows host has a stable set of
|
||||
pre-existing failures, so never compare against zero.
|
||||
- **ledpi soak** (stages 2-5). With the service running the branch:
|
||||
`python3 scripts/frame_soak.py --preview` for 10 minutes on a scrolling
|
||||
rotation, and on Vegas for stages 3-4. Alternate which build goes first.
|
||||
Compare late-frame rate and freezes with main. Also check by hand that
|
||||
on-demand start, stop and expiry, a live game taking over and handing
|
||||
back, and the schedule turning the panel off and on all behave as before.
|
||||
|
||||
## Behaviour the traces pin down that may be wrong
|
||||
|
||||
Stage 1 recorded six behaviours as they were, each to be fixed in its own
|
||||
PR that updates the affected trace and explains why. All six are fixed:
|
||||
|
||||
- A WiFi notice was only checked between screens, and Vegas yielded to one
|
||||
and then showed a rotation screen instead. Notices now preempt within
|
||||
about a second, and Vegas yields straight to them (#712; `wifi_notice`,
|
||||
`vegas`).
|
||||
- A live game only took over between screens, and Vegas yielded to one and
|
||||
then showed a rotation screen first. Games now take over within about a
|
||||
second, and Vegas yields straight to them (#713; `live_priority`,
|
||||
`vegas`).
|
||||
- An on-demand session that ended during scheduled-off kept the panel on
|
||||
until the next minute, and a schedule window's end minute counted as on
|
||||
only sometimes. Windows are now half-open `[start, end)`, and the panel
|
||||
blanks as soon as on-demand ends in off hours (#714; `schedule`).
|
||||
|
||||
A new one found later goes the same way: record it here with the trace that
|
||||
shows it, then fix it in its own PR, not inside a restructure stage.
|
||||
@@ -73,6 +73,10 @@ Sample ladder for a 100 Hz panel:
|
||||
100.0 px/s (1px every 1 refresh = 100.0 fps, smooth)
|
||||
```
|
||||
|
||||
The Vegas **Scroll Speed** slider in the web UI shows the same thing live: a
|
||||
line under it says what your speed will run as on this panel, and links to the
|
||||
nearest smooth speeds.
|
||||
|
||||
### How a slow speed stays crisp
|
||||
|
||||
`SwapOnVSync(canvas, framerate_fraction)` holds each frame for N panel
|
||||
@@ -517,19 +521,25 @@ On the 2×128×64 chain above, which refreshes at about 130 Hz flat out
|
||||
|
||||
### What the display does about it
|
||||
|
||||
At one pixel per refresh, the fastest crisp speed, the step is exactly one
|
||||
refresh's worth of motion, so it can be cancelled: show one half of the panel
|
||||
The step is the motion of one refresh, so it can be cancelled: show one half of the panel
|
||||
a refresh behind the other -- the half whose row at the seam lights at the
|
||||
start of each refresh. The two rows either side of the seam then show the same
|
||||
moment again. What is left is a
|
||||
lean of one pixel per half from top to bottom, continuous across the panel,
|
||||
which reads as nothing where the step read as a tear. `DisplayManager` does
|
||||
this while something scrolls at one frame per refresh
|
||||
this while something scrolls
|
||||
(`display.scan_order_compensation`, `"auto"` by default, `"off"` to disable;
|
||||
the geometry is in `src/scan_order.py`). The lagging rows come from the
|
||||
previous frame the display presented, so it works for Vegas and every plugin
|
||||
ticker without knowing how they scroll.
|
||||
|
||||
A frame held for several refreshes (any crisp speed below the panel's full
|
||||
refresh rate, e.g. 60 px/s at 120 Hz) is presented as two swaps instead of one:
|
||||
the lagging half shows the previous frame for the first refresh and the new one
|
||||
for the rest, so it steps one refresh after the rest rather than one frame.
|
||||
That costs a second blit inside the refresh after the first swap, so it is
|
||||
skipped when a blit takes more than half a refresh.
|
||||
|
||||
Checked on hdpi (4×128×64 on one chain, rotated 180, 2026-09-24) before it was
|
||||
written: `scan_mode: 1` (interlaced) made the step vanish but turned moving
|
||||
edges grainy, and halving the speed halved it, so it is the scan and not a torn
|
||||
@@ -537,9 +547,6 @@ frame. With the compensation the step is gone at 90 px/s.
|
||||
|
||||
It is left off where the row order is unknown or the maths does not hold:
|
||||
|
||||
- **Slower speeds**, where each frame is held for two or more refreshes. The
|
||||
offset there is half a pixel or less, and cancelling it would need a lag of
|
||||
a fraction of a frame.
|
||||
- **Other layouts:** pixel mappers other than a 0 or 180 degree rotation
|
||||
(U-mapper, 90/270), non-zero `multiplexing`, interlaced `scan_mode`, and a
|
||||
canvas remapped to another height (double-sided mode).
|
||||
|
||||
@@ -22,6 +22,7 @@ src/common/api_helper.py
|
||||
src/common/bdf_font.py
|
||||
src/common/espn_dates.py
|
||||
src/common/favorite_team_check.py
|
||||
src/common/fetch_service.py
|
||||
src/common/font_layout.py
|
||||
src/common/frame_timing.py
|
||||
src/common/json_body.py
|
||||
|
||||
@@ -27,6 +27,13 @@ from concurrent.futures import ThreadPoolExecutor
|
||||
import pytz
|
||||
from src.cache_manager import CacheManager
|
||||
from src.common.json_body import response_json
|
||||
from src.common.fetch_service import (
|
||||
current_plugin_id,
|
||||
fetch_get,
|
||||
get_fetch_service,
|
||||
plugin_scope,
|
||||
share_connection_pool,
|
||||
)
|
||||
from src.common.espn_dates import (
|
||||
RANGE_RETRY_SECONDS,
|
||||
_note_range_rejected,
|
||||
@@ -78,6 +85,9 @@ class FetchRequest:
|
||||
commit_claimed: bool = False
|
||||
result: Optional[Any] = None
|
||||
error: Optional[str] = None
|
||||
# The plugin that submitted the request, so the fetch service counts the
|
||||
# worker's requests against it (fetch_service, caller identity).
|
||||
owner: Optional[str] = None
|
||||
|
||||
@dataclass
|
||||
class FetchResult:
|
||||
@@ -119,6 +129,12 @@ class _ConnectionRetryingSession:
|
||||
def __init__(self, session):
|
||||
self._session = session
|
||||
|
||||
@property
|
||||
def fetch_identity_session(self):
|
||||
"""The wrapped Session, whose headers and adapter the fetch service
|
||||
reads to key this request (src/common/fetch_service.py)."""
|
||||
return self._session
|
||||
|
||||
def get(self, *args, **kwargs):
|
||||
for attempt in range(self.ATTEMPTS):
|
||||
try:
|
||||
@@ -196,9 +212,12 @@ class BackgroundDataService:
|
||||
# connection errors three times, a dead network cost up to 16
|
||||
# connection attempts per request and held one of the few worker
|
||||
# threads for all of them.
|
||||
#
|
||||
# The adapter is the fetch service's shared no-retry one: the same
|
||||
# max_retries=0, with the connection pool shared with the other core
|
||||
# sessions that do not retry (the odds managers).
|
||||
self.session = requests.Session()
|
||||
self.session.mount('http://', requests.adapters.HTTPAdapter(max_retries=0))
|
||||
self.session.mount('https://', requests.adapters.HTTPAdapter(max_retries=0))
|
||||
share_connection_pool(self.session, max_retries=0)
|
||||
|
||||
# Default headers: core's shared set (real User-Agent, no hand-set
|
||||
# Accept-Encoding) -- see src/common/api_helper.py.
|
||||
@@ -299,6 +318,10 @@ class BackgroundDataService:
|
||||
if url.split('?', 1)[0].rstrip('/').endswith('/scoreboard'):
|
||||
params = clamp_espn_limit(params)
|
||||
|
||||
# Who asked, resolved on the submitting thread: the worker thread
|
||||
# runs no plugin code, so it could not tell (fetch_service).
|
||||
owner = current_plugin_id()
|
||||
|
||||
# Create fetch request
|
||||
request = FetchRequest(
|
||||
id=request_id,
|
||||
@@ -311,7 +334,8 @@ class BackgroundDataService:
|
||||
timeout=timeout or self.request_timeout,
|
||||
max_retries=max_retries,
|
||||
priority=priority,
|
||||
callback=callback
|
||||
callback=callback,
|
||||
owner=owner,
|
||||
)
|
||||
|
||||
with self._lock:
|
||||
@@ -330,6 +354,7 @@ class BackgroundDataService:
|
||||
self.stats['deduplicated_requests'] = (
|
||||
self.stats.get('deduplicated_requests', 0) + 1
|
||||
)
|
||||
get_fetch_service().note_merged(url, owner)
|
||||
logger.info(
|
||||
"Joined in-flight fetch %s for %s (cache_key=%s) instead of "
|
||||
"starting a duplicate", existing_id, sport, cache_key
|
||||
@@ -357,6 +382,11 @@ class BackgroundDataService:
|
||||
Returns:
|
||||
Fetch result with data or error information
|
||||
"""
|
||||
with plugin_scope(request.owner):
|
||||
return self._fetch_data_worker_scoped(request)
|
||||
|
||||
def _fetch_data_worker_scoped(self, request: FetchRequest) -> FetchResult:
|
||||
"""_fetch_data_worker's body, run with the submitter as the caller."""
|
||||
start_time = time.time()
|
||||
result = FetchResult(request_id=request.id, success=False, retry_count=request.retry_count)
|
||||
|
||||
@@ -621,8 +651,14 @@ class BackgroundDataService:
|
||||
|
||||
for attempt in range(request.max_retries + 1):
|
||||
try:
|
||||
response = self.session.get(
|
||||
# Not shared with an identical request in flight: this
|
||||
# service cancels and replaces fetches, and a replacement
|
||||
# must not join the one it replaced. Its own cache_key
|
||||
# dedup already merges what should be merged.
|
||||
response = fetch_get(
|
||||
self.session,
|
||||
request.url,
|
||||
share_in_flight=False,
|
||||
params=request.params,
|
||||
headers=request.headers,
|
||||
timeout=request.timeout
|
||||
|
||||
@@ -19,6 +19,7 @@ import json
|
||||
from typing import Dict, Any, Optional, List, cast
|
||||
|
||||
from src.common.api_helper import DEFAULT_HTTP_HEADERS
|
||||
from src.common.fetch_service import fetch_get, share_connection_pool
|
||||
|
||||
|
||||
|
||||
@@ -59,7 +60,13 @@ class BaseOddsManager:
|
||||
# Deliberately no retry adapter, unlike api_helper: retries multiply
|
||||
# request_timeout, which is set to 5s precisely to stay inside that
|
||||
# budget. One try, then the cooldown below.
|
||||
#
|
||||
# Every scoreboard league manager builds one of these, so the session
|
||||
# mounts the fetch service's shared no-retry adapter: the same single
|
||||
# try, over one connection pool per host for all of them instead of
|
||||
# one pool per instance.
|
||||
self.session = requests.Session()
|
||||
share_connection_pool(self.session, max_retries=0)
|
||||
self.session.headers.update(DEFAULT_HTTP_HEADERS)
|
||||
|
||||
# Configuration with defaults
|
||||
@@ -168,7 +175,7 @@ class BaseOddsManager:
|
||||
url = f"{self.base_url}/{sport}/leagues/{espn_league}/events/{event_id}/competitions/{event_id}/odds"
|
||||
self.logger.debug(f"Requesting odds from URL: {url}")
|
||||
|
||||
response = self.session.get(url, timeout=self.request_timeout)
|
||||
response = fetch_get(self.session, url, timeout=self.request_timeout)
|
||||
response.raise_for_status()
|
||||
raw_data = response.json()
|
||||
|
||||
|
||||
+21
-1
@@ -27,6 +27,7 @@ Rules for the package:
|
||||
| [`bdf_font`](#bdf_font) | Load and draw BDF bitmap fonts | Yes, if drawing BDF text directly | 3.5.0 |
|
||||
| [`espn_dates`](#espn_dates) | Fetch ESPN scoreboards across a date range | Yes (scoreboards) | 3.5.0 |
|
||||
| [`favorite_team_check`](#favorite_team_check) | Log why a favourite team code shows nothing | Yes (scoreboards) | 3.6.0 |
|
||||
| [`fetch_service`](#fetch_service) | Pooled, merged, budgeted and counted HTTP for core fetch paths | No, core-internal (reached through `api_helper` and `espn_dates`) | n/a |
|
||||
| [`font_layout`](#font_layout) | Reproducible TrueType loading, crisp sizes | Yes | 3.4.0 |
|
||||
| [`frame_timing`](#frame_timing) | Timing of every presented frame, stall watchdog | No, core-internal | n/a |
|
||||
| [`json_body`](#json_body) | Parse a response body as JSON, with orjson if installed | Optional (large payloads) | 3.5.0 |
|
||||
@@ -108,7 +109,9 @@ and truncates results when `limit` is above 500. `fetch_espn_scoreboard()`
|
||||
splits a range into month and day requests ESPN accepts and merges the
|
||||
results; `espn_date_chunks()`, `fetch_espn_date_chunks()`,
|
||||
`clamp_espn_limit()` and `merge_scoreboard_payloads()` are the pieces.
|
||||
Scoreboard plugins also bundle a copy for older cores.
|
||||
Every request goes through [`fetch_service`](#fetch_service), the chunks
|
||||
counted against the plugin that asked. Scoreboard plugins also bundle a copy
|
||||
for older cores.
|
||||
|
||||
### favorite_team_check
|
||||
|
||||
@@ -121,6 +124,23 @@ says the league has nothing on yet; `reset()` re-arms it after a config edit.
|
||||
Diagnostics only: every failure is swallowed. Scoreboard plugins also bundle
|
||||
a copy for older cores.
|
||||
|
||||
### fetch_service
|
||||
|
||||
[`fetch_service.py`](fetch_service.py). Core-internal for now. Every core
|
||||
fetch path -- `APIHelper.get`/`post`, `espn_dates` (so every scoreboard's
|
||||
ESPN scoreboard fetch and `SportsFetchMixin`), `BackgroundDataService` and
|
||||
`BaseOddsManager` -- calls `fetch_get(session, url, ...)` instead of
|
||||
`session.get(url, ...)`. Same arguments, return value and exceptions; on top
|
||||
it shares one connection pool per host per retry policy
|
||||
(`share_connection_pool`), merges identical GETs in flight, applies per-host
|
||||
token buckets (`fetch_service.rate_limits` in config.json; ESPN gets 20/s,
|
||||
burst 200), revalidates with server-sent `ETag`/`Last-Modified` and counts
|
||||
requests per plugin and per host. The display publishes the counters
|
||||
(`FetchStatsPublisher`) for `GET /api/v3/plugins/fetch-stats`. Which plugin
|
||||
made a request comes from `plugin_scope()`, set by the plugin executor, or
|
||||
else from the plugin directory on the stack. See
|
||||
[docs/PLUGIN_API_REFERENCE.md](../../docs/PLUGIN_API_REFERENCE.md#fetching-data).
|
||||
|
||||
### font_layout
|
||||
|
||||
[`font_layout.py`](font_layout.py). `load_truetype(path, size)` is
|
||||
|
||||
@@ -11,10 +11,10 @@ import time
|
||||
from datetime import datetime
|
||||
from types import MappingProxyType
|
||||
from src.common.espn_dates import ESPN_MAX_LIMIT
|
||||
from src.common.fetch_service import fetch_get, fetch_post, share_connection_pool
|
||||
from typing import TYPE_CHECKING, Any, Dict, Mapping, Optional, cast
|
||||
|
||||
import requests
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -45,7 +45,11 @@ class APIHelper:
|
||||
|
||||
- Requests go through one ``requests.Session`` that retries GET, HEAD
|
||||
and OPTIONS on 429 and 5xx with exponential backoff, and sends
|
||||
:data:`DEFAULT_HTTP_HEADERS`.
|
||||
:data:`DEFAULT_HTTP_HEADERS`. Its connection pool is shared with every
|
||||
other helper using the same retry policy, and requests go through the
|
||||
core fetch service (``src/common/fetch_service.py``): identical GETs in
|
||||
flight are merged, hosts with a budget are paced, and requests are
|
||||
counted per plugin. Return values and errors are unchanged.
|
||||
- Consecutive requests from one helper are spaced at least
|
||||
``set_rate_limit()`` seconds apart (1 second by default). A cache hit
|
||||
does not count.
|
||||
@@ -81,9 +85,10 @@ class APIHelper:
|
||||
status_forcelist=[429, 500, 502, 503, 504],
|
||||
allowed_methods=["GET", "HEAD", "OPTIONS"]
|
||||
)
|
||||
adapter = HTTPAdapter(max_retries=retry_strategy)
|
||||
self.session.mount("https://", adapter)
|
||||
self.session.mount("http://", adapter)
|
||||
# The shared adapter for this retry policy: the same retries as a
|
||||
# private HTTPAdapter(max_retries=retry_strategy), with the connection
|
||||
# pool shared by every helper (fetch_service).
|
||||
share_connection_pool(self.session, retry_strategy)
|
||||
|
||||
self.session.headers.update({**DEFAULT_HTTP_HEADERS, 'Connection': 'keep-alive'})
|
||||
|
||||
@@ -128,7 +133,8 @@ class APIHelper:
|
||||
request_headers.update(headers)
|
||||
|
||||
# Make request
|
||||
response = self.session.get(
|
||||
response = fetch_get(
|
||||
self.session,
|
||||
url,
|
||||
params=params,
|
||||
headers=request_headers,
|
||||
@@ -255,7 +261,8 @@ class APIHelper:
|
||||
if headers:
|
||||
request_headers.update(headers)
|
||||
|
||||
response = self.session.post(
|
||||
response = fetch_post(
|
||||
self.session,
|
||||
url,
|
||||
data=data,
|
||||
json=json_data,
|
||||
|
||||
@@ -32,6 +32,7 @@ scoreboards ask every 30 seconds. After that the range is tried again, so the
|
||||
workaround retires itself if ESPN reverts.
|
||||
"""
|
||||
|
||||
import contextvars
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
@@ -47,6 +48,21 @@ except ImportError:
|
||||
def response_json(response: Any) -> Any:
|
||||
return response.json()
|
||||
|
||||
try:
|
||||
# The core fetch service: counts, per-host budget, merging of identical
|
||||
# requests. Same call, same result and errors as ``session.get``.
|
||||
from src.common.fetch_service import fetch_get, pinned_caller
|
||||
except ImportError:
|
||||
# Bundled copies on cores without it call the session directly.
|
||||
import contextlib
|
||||
|
||||
def fetch_get(session: Any, url: str, *, share_in_flight: bool = True,
|
||||
**kwargs: Any) -> Any:
|
||||
return session.get(url, **kwargs)
|
||||
|
||||
def pinned_caller() -> Any:
|
||||
return contextlib.nullcontext()
|
||||
|
||||
# Above this, ESPN returns a truncated list instead of an error. See module
|
||||
# docstring: 500 is the largest value measured to return complete data.
|
||||
ESPN_MAX_LIMIT = 500
|
||||
@@ -195,7 +211,8 @@ def _fetch_one_chunk(
|
||||
logged and swallowed here rather than raised to the gather below.
|
||||
"""
|
||||
try:
|
||||
response = session.get(
|
||||
response = fetch_get(
|
||||
session,
|
||||
url,
|
||||
params=dict(params, dates=chunk, limit=ESPN_MAX_LIMIT),
|
||||
headers=headers,
|
||||
@@ -220,6 +237,10 @@ def _fetch_chunks(
|
||||
callers keep ``chunks`` order from the returned list -- but it does mean
|
||||
the session is shared across threads, which is why this only ever issues
|
||||
GETs and never touches session state.
|
||||
|
||||
Each chunk runs in a copy of the caller's context, with the caller pinned
|
||||
into it, so the fetch service counts the chunks against the plugin that
|
||||
asked for the range rather than against the core.
|
||||
"""
|
||||
if not chunks:
|
||||
return []
|
||||
@@ -229,10 +250,15 @@ def _fetch_chunks(
|
||||
if len(chunks) == 1:
|
||||
return [fetch(chunks[0])]
|
||||
workers = min(ESPN_CHUNK_WORKERS, len(chunks))
|
||||
with pinned_caller():
|
||||
# One copy per chunk: a Context cannot be entered by two threads.
|
||||
contexts = [contextvars.copy_context() for _ in chunks]
|
||||
with ThreadPoolExecutor(
|
||||
max_workers=workers, thread_name_prefix="espn-chunk",
|
||||
) as pool:
|
||||
return list(pool.map(fetch, chunks))
|
||||
futures = [pool.submit(context.run, fetch, chunk)
|
||||
for context, chunk in zip(contexts, chunks)]
|
||||
return [future.result() for future in futures]
|
||||
|
||||
|
||||
def fetch_espn_date_chunks(
|
||||
@@ -363,7 +389,7 @@ def fetch_espn_scoreboard(
|
||||
# real error to log, without spending the chunks a second time.
|
||||
chunks_tried = True
|
||||
|
||||
response = session.get(url, params=params, headers=headers, timeout=timeout)
|
||||
response = fetch_get(session, url, params=params, headers=headers, timeout=timeout)
|
||||
if is_range and response.status_code == 400 and not chunks_tried:
|
||||
_note_range_rejected()
|
||||
if logger:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -157,7 +157,13 @@ def crisp_ladder(
|
||||
#: when 30 was asked for -- being 11% slow is worth far less than looking bad.
|
||||
_STEP_PENALTY = 0.05
|
||||
_SLOW_FPS_PENALTY = 0.25 # below 20fps
|
||||
_LOWISH_FPS_PENALTY = 0.10 # below 25fps
|
||||
_LOWISH_FPS_PENALTY = 0.16 # below 30fps, i.e. "slightly stepped"
|
||||
# Up to 30fps, matching CrispSpeed.steppiness: a measured 125.7Hz panel makes
|
||||
# 50.3px/s (2px every 5 refreshes) 25.1fps, which a 25fps cutoff let through.
|
||||
# 0.16, not less: asked for 50px/s on a 120Hz panel, 48px/s (2px every 5
|
||||
# refreshes, 24fps) costs 0.04 + 0.05 + this, and has to lose to both 60px/s
|
||||
# and 40px/s (1px, smooth, 20% off = 0.20). At 0.10 it won and shipped a
|
||||
# visibly stepped scroll to anyone asking for the default.
|
||||
|
||||
|
||||
def _quality_cost(candidate: "CrispSpeed", target: float) -> float:
|
||||
@@ -173,7 +179,7 @@ def _quality_cost(candidate: "CrispSpeed", target: float) -> float:
|
||||
fps = candidate.frames_per_second
|
||||
if fps < 20:
|
||||
cost += _SLOW_FPS_PENALTY
|
||||
elif fps < 25:
|
||||
elif fps < 30:
|
||||
cost += _LOWISH_FPS_PENALTY
|
||||
return cost
|
||||
|
||||
@@ -474,3 +480,61 @@ def refresh_hz_from_config(global_config: Optional[Dict[str, Any]]) -> float:
|
||||
if not isinstance(hardware, dict):
|
||||
return DEFAULT_REFRESH_HZ
|
||||
return _coerce(hardware.get("limit_refresh_rate_hz")) or DEFAULT_REFRESH_HZ
|
||||
|
||||
|
||||
#: Smooth options offered next to a speed that is not one itself.
|
||||
_ADVICE_ALTERNATIVES = 2
|
||||
|
||||
|
||||
def speed_advice(
|
||||
requested_pixels_per_second: float,
|
||||
refresh_hz: float,
|
||||
min_pixels_per_second: float = MIN_PIXELS_PER_SECOND,
|
||||
max_pixels_per_second: float = MAX_PIXELS_PER_SECOND,
|
||||
) -> Dict[str, Any]:
|
||||
"""What the panel will do with a requested speed, for showing in a UI.
|
||||
|
||||
``applied`` is what :func:`solve_crisp` picks, i.e. what really runs.
|
||||
``smooth`` is true when that is single-pixel-ish, 30fps-or-better motion.
|
||||
``alternatives`` are the smooth ladder entries nearest the request inside
|
||||
the given range, for a click-to-apply suggestion; empty when the request
|
||||
already is one.
|
||||
"""
|
||||
hz = _coerce(refresh_hz) or DEFAULT_REFRESH_HZ
|
||||
requested = max(MIN_PIXELS_PER_SECOND,
|
||||
min(MAX_PIXELS_PER_SECOND, _coerce(requested_pixels_per_second) or 0.0))
|
||||
applied = solve_crisp(requested, hz)
|
||||
|
||||
def as_dict(c: CrispSpeed) -> Dict[str, Any]:
|
||||
return {
|
||||
"pixels_per_second": round(c.pixels_per_second, 1),
|
||||
"pixels_per_frame": c.pixels_per_frame,
|
||||
"frame_hold": c.frame_hold,
|
||||
"frames_per_second": round(c.frames_per_second, 1),
|
||||
"steppiness": c.steppiness,
|
||||
}
|
||||
|
||||
smooth_ladder = [
|
||||
c for c in crisp_ladder(hz)
|
||||
if c.steppiness == "smooth"
|
||||
and min_pixels_per_second <= c.pixels_per_second <= max_pixels_per_second
|
||||
]
|
||||
# 2%: a UI hands over whole numbers, and 63 asked of a 62.9 px/s panel is
|
||||
# as good as exact.
|
||||
exact = abs(applied.pixels_per_second - requested) <= max(0.05, 0.02 * requested)
|
||||
smooth = applied.steppiness == "smooth"
|
||||
alternatives: List[CrispSpeed] = []
|
||||
if not (exact and smooth):
|
||||
alternatives = sorted(
|
||||
smooth_ladder,
|
||||
key=lambda c: abs(c.pixels_per_second - requested),
|
||||
)[:_ADVICE_ALTERNATIVES]
|
||||
alternatives.sort(key=lambda c: c.pixels_per_second)
|
||||
return {
|
||||
"requested": round(requested, 1),
|
||||
"refresh_hz": round(hz, 1),
|
||||
"applied": as_dict(applied),
|
||||
"exact": exact,
|
||||
"smooth": smooth,
|
||||
"alternatives": [as_dict(c) for c in alternatives],
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ CORE_CONFIG_KEYS = frozenset({
|
||||
'display',
|
||||
'sync',
|
||||
'plugin_system',
|
||||
'fetch_service',
|
||||
# Older or optional core sections still found in existing config files.
|
||||
'logging',
|
||||
'network',
|
||||
|
||||
+740
-457
File diff suppressed because it is too large
Load Diff
+50
-25
@@ -51,7 +51,7 @@ from src.pi5_matrix_support import is_raspberry_pi_5
|
||||
import threading
|
||||
import time
|
||||
from collections import OrderedDict, deque
|
||||
from typing import Dict, Any, Optional, Tuple, TYPE_CHECKING
|
||||
from typing import Dict, Any, List, Optional, Tuple, TYPE_CHECKING
|
||||
import zlib
|
||||
import freetype
|
||||
|
||||
@@ -223,6 +223,11 @@ def _per_thread_canvas_attr(name: str) -> property:
|
||||
|
||||
|
||||
|
||||
#: A held frame is only split when its blit takes less than this share of a
|
||||
#: refresh: the second blit has to land before the next vsync.
|
||||
_SPLIT_BLIT_FRACTION = 0.5
|
||||
|
||||
|
||||
class DisplayManager:
|
||||
"""
|
||||
Singleton hardware abstraction layer for the RGB LED matrix.
|
||||
@@ -971,28 +976,36 @@ class DisplayManager:
|
||||
# mode the logical screen is first tiled across the full chain.
|
||||
blit_started = time.perf_counter()
|
||||
if self._double_sided is not None:
|
||||
self.offscreen_canvas.SetImage(self._composite_double_sided())
|
||||
segments = [(self._composite_double_sided(), self._frame_hold)]
|
||||
else:
|
||||
self.offscreen_canvas.SetImage(self._scan_compensated(self.image))
|
||||
blit_done = time.perf_counter()
|
||||
|
||||
# Swap buffers immediately. framerate_fraction holds the frame
|
||||
# for N refreshes; SwapOnVSync blocks for all of them, which is
|
||||
# what paces the render loop to the chosen frame rate.
|
||||
segments = self._scan_segments(self.image)
|
||||
gate = self.render_gate
|
||||
blit_time = swap_time = 0.0
|
||||
# Usually one segment: the frame, held for _frame_hold
|
||||
# refreshes. SwapOnVSync blocks for all of them, which is what
|
||||
# paces the render loop to the chosen frame rate. Scan-order
|
||||
# compensation on a held frame splits it, so the lagging rows
|
||||
# change one refresh after the rest.
|
||||
if gate is not None:
|
||||
gate.before_swap(self._frame_hold)
|
||||
self.matrix.SwapOnVSync(self.offscreen_canvas, self._frame_hold)
|
||||
for index, (shown, hold) in enumerate(segments):
|
||||
if index:
|
||||
blit_started = time.perf_counter()
|
||||
self.offscreen_canvas.SetImage(shown)
|
||||
blit_done = time.perf_counter()
|
||||
blit_time += blit_done - blit_started
|
||||
self.matrix.SwapOnVSync(self.offscreen_canvas, hold)
|
||||
swap_time += time.perf_counter() - blit_done
|
||||
# Swap our canvas references
|
||||
self.offscreen_canvas, self.current_canvas = self.current_canvas, self.offscreen_canvas
|
||||
if gate is not None:
|
||||
gate.after_swap(self._frame_hold)
|
||||
presented_at = time.perf_counter()
|
||||
self._last_blit_seconds = blit_time / len(segments)
|
||||
self.frame_timing.record(
|
||||
blit_done - blit_started, presented_at - blit_done,
|
||||
blit_time, swap_time,
|
||||
self._frame_hold, self.is_currently_scrolling(), presented_at)
|
||||
|
||||
# Swap our canvas references
|
||||
self.offscreen_canvas, self.current_canvas = self.current_canvas, self.offscreen_canvas
|
||||
|
||||
self._last_pushed_digest = digest
|
||||
|
||||
# Write a snapshot for the web preview (throttled)
|
||||
@@ -1038,23 +1051,35 @@ class DisplayManager:
|
||||
", ".join(f"rows {top}-{bottom - 1} show {lag} refresh(es) behind"
|
||||
for top, bottom, lag in bands))
|
||||
|
||||
def _scan_compensated(self, image: Image.Image) -> Image.Image:
|
||||
"""The frame to present, with lagging rows taken from earlier frames.
|
||||
def _scan_segments(self, image: Image.Image) -> List[Tuple[Image.Image, int]]:
|
||||
"""What to present for this frame: ``[(image, refreshes), ...]``.
|
||||
|
||||
Only mid-scroll at one frame per refresh: that is when consecutive
|
||||
frames are consecutive refreshes. At a longer hold, or on a static
|
||||
screen, the history is dropped and the frame goes out as it is.
|
||||
Mid-scroll with compensation on, lagging rows are taken from earlier
|
||||
refreshes (see src/scan_order.py). At one refresh per frame that is one
|
||||
image. A frame held longer is split at the refresh where the lagging
|
||||
rows catch up, so those rows step a refresh after the rest. The split
|
||||
needs a second blit inside the refresh that follows the first swap, so
|
||||
it is skipped when a blit is too slow to fit. A static screen goes out
|
||||
as it is, and drops the history.
|
||||
"""
|
||||
hold = self._frame_hold
|
||||
bands = getattr(self, '_scan_lag_bands', None)
|
||||
if not bands:
|
||||
return image
|
||||
if self._frame_hold != 1 or not self.is_currently_scrolling():
|
||||
self._scan_history.clear()
|
||||
return image
|
||||
presented = scan_order.compose(image, self._scan_history, bands)
|
||||
if not bands or not self.is_currently_scrolling():
|
||||
if bands:
|
||||
self._scan_history.clear()
|
||||
return [(image, hold)]
|
||||
if hold > 1:
|
||||
blit = getattr(self, '_last_blit_seconds', 0.0)
|
||||
if blit > _SPLIT_BLIT_FRACTION / max(1.0, self.refresh_hz):
|
||||
self._scan_history.clear()
|
||||
return [(image, hold)]
|
||||
segments = [
|
||||
(scan_order.compose(image, self._scan_history, bands, backs), count)
|
||||
for backs, count in scan_order.refresh_plan(bands, hold)
|
||||
]
|
||||
# A copy: plugins draw into the same image object frame after frame.
|
||||
self._scan_history.appendleft(image.copy())
|
||||
return presented
|
||||
return segments
|
||||
|
||||
def clear(self):
|
||||
"""Clear the display completely."""
|
||||
|
||||
@@ -10,6 +10,7 @@ from typing import Any, Dict, Optional, Callable
|
||||
from threading import Thread
|
||||
import logging
|
||||
|
||||
from src.common.fetch_service import plugin_scope
|
||||
from src.exceptions import PluginError
|
||||
from src.logging_config import get_logger
|
||||
from src.error_aggregator import record_error
|
||||
@@ -83,7 +84,10 @@ class PluginExecutor:
|
||||
|
||||
def target():
|
||||
try:
|
||||
result_container['value'] = operation()
|
||||
# Fetches made by the operation (and by threads the core
|
||||
# starts from it) are counted against this plugin.
|
||||
with plugin_scope(plugin_id):
|
||||
result_container['value'] = operation()
|
||||
result_container['completed'] = True
|
||||
except Exception as e:
|
||||
result_container['exception'] = e
|
||||
@@ -173,7 +177,8 @@ class PluginExecutor:
|
||||
force_clear: bool = False,
|
||||
display_mode: Optional[str] = None,
|
||||
timeout: Optional[float] = None,
|
||||
accepts_display_mode: Optional[bool] = None
|
||||
accepts_display_mode: Optional[bool] = None,
|
||||
raise_errors: bool = False
|
||||
) -> bool:
|
||||
"""
|
||||
Execute plugin display() method with error handling.
|
||||
@@ -187,9 +192,18 @@ class PluginExecutor:
|
||||
accepts_display_mode: Whether plugin.display() takes a
|
||||
display_mode keyword. Pass it when the caller already knows;
|
||||
None falls back to inspecting the callable.
|
||||
raise_errors: Re-raise the PluginError wrapping an exception
|
||||
display() raised, instead of returning False. False alone
|
||||
cannot tell "no content" from "raised", and a caller that
|
||||
feeds the circuit breaker needs that difference. The error
|
||||
is still logged and recorded first. A timeout still returns
|
||||
False either way.
|
||||
|
||||
Returns:
|
||||
True if display succeeded, False otherwise
|
||||
|
||||
Raises:
|
||||
PluginError: Only with ``raise_errors``, when display() raised.
|
||||
"""
|
||||
try:
|
||||
start_time = time.monotonic()
|
||||
@@ -245,6 +259,8 @@ class PluginExecutor:
|
||||
return False
|
||||
except PluginError:
|
||||
# Already logged and recorded in execute_with_timeout
|
||||
if raise_errors:
|
||||
raise
|
||||
return False
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
|
||||
@@ -32,6 +32,7 @@ from src.plugin_system.schema_manager import (
|
||||
from src.plugin_system.plugin_dirs import (
|
||||
ManifestStatus, PluginDirectoryIndex, resolve_plugin_dir,
|
||||
)
|
||||
from src.common.fetch_service import plugin_scope, register_plugin_directory
|
||||
from src.common.permission_utils import (
|
||||
ensure_directory_permissions,
|
||||
get_plugin_dir_mode
|
||||
@@ -423,6 +424,11 @@ class PluginManager:
|
||||
# Update mapping if found via search
|
||||
if plugin_id not in self.plugin_directories:
|
||||
self.plugin_directories[plugin_id] = plugin_dir
|
||||
|
||||
# Code under this directory is this plugin's: the fetch service
|
||||
# counts a request against it even from a thread the plugin
|
||||
# started itself (src/common/fetch_service.py, caller identity).
|
||||
register_plugin_directory(plugin_id, plugin_dir)
|
||||
|
||||
# Get plugin config
|
||||
if self.config_manager:
|
||||
@@ -462,18 +468,20 @@ class PluginManager:
|
||||
config = dict(config)
|
||||
config['enabled'] = True
|
||||
|
||||
# Use PluginLoader to load plugin
|
||||
plugin_instance, _module = self.plugin_loader.load_plugin(
|
||||
plugin_id=plugin_id,
|
||||
manifest=manifest,
|
||||
plugin_dir=plugin_dir,
|
||||
config=config,
|
||||
display_manager=self.display_manager,
|
||||
cache_manager=self.cache_manager,
|
||||
plugin_manager=self,
|
||||
install_deps=True,
|
||||
plugins_dir=self.plugins_dir,
|
||||
)
|
||||
# Use PluginLoader to load plugin. Fetches the constructor makes
|
||||
# count against the plugin.
|
||||
with plugin_scope(plugin_id):
|
||||
plugin_instance, _module = self.plugin_loader.load_plugin(
|
||||
plugin_id=plugin_id,
|
||||
manifest=manifest,
|
||||
plugin_dir=plugin_dir,
|
||||
config=config,
|
||||
display_manager=self.display_manager,
|
||||
cache_manager=self.cache_manager,
|
||||
plugin_manager=self,
|
||||
install_deps=True,
|
||||
plugins_dir=self.plugins_dir,
|
||||
)
|
||||
|
||||
# Register plugin-shipped fonts with the FontManager (if any).
|
||||
# Plugin manifests can declare a "fonts" block that ships custom
|
||||
@@ -527,7 +535,8 @@ class PluginManager:
|
||||
# Call on_enable if plugin is enabled
|
||||
if hasattr(plugin_instance, 'on_enable'):
|
||||
try:
|
||||
plugin_instance.on_enable()
|
||||
with plugin_scope(plugin_id):
|
||||
plugin_instance.on_enable()
|
||||
except Exception:
|
||||
# Undo the registration above before the outer
|
||||
# handler marks it ERROR: left in self.plugins, the
|
||||
|
||||
+41
-9
@@ -23,6 +23,10 @@ above it near the end, the section below must show one more refresh of lag to
|
||||
stay continuous with it (and one less where the order jumps the other way).
|
||||
Stacked parallel chains are lit simultaneously, so each further half adds one.
|
||||
|
||||
A frame held for several refreshes (a slower, crisp scroll) is presented as a
|
||||
sequence of swaps instead of one long hold, so the lagging half can step one
|
||||
refresh after the rest: see :func:`refresh_plan`.
|
||||
|
||||
Only layouts whose physical row order is known are compensated: plain chains,
|
||||
parallel chains, and a 0 or 180 degree rotation. Other pixel mappers
|
||||
(U-mapper, 90/270 rotation, ...), special multiplexing and interlaced scan are
|
||||
@@ -109,19 +113,47 @@ def scan_lag_bands(hardware: Mapping[str, Any], height: int,
|
||||
return [band for band in bands if band[2] > 0] or None
|
||||
|
||||
|
||||
def compose(image: Image.Image, history: Sequence[Image.Image],
|
||||
bands: Sequence[Band]) -> Image.Image:
|
||||
"""``image`` with each band taken from the frame ``lag`` refreshes back.
|
||||
def refresh_plan(bands: Sequence[Band], hold: int) -> List[Tuple[Tuple[int, ...], int]]:
|
||||
"""How to present one frame that is held for ``hold`` refreshes.
|
||||
|
||||
``history[0]`` is the previous frame. A band whose frame is not available
|
||||
yet (the first frames of a scroll) is left current. Returns ``image`` itself
|
||||
when nothing changes, so the caller pays for a copy only when it must.
|
||||
A band lagging ``lag`` refreshes shows, on refresh ``r`` of the frame, what
|
||||
the panel showed ``lag`` refreshes earlier: the current frame once
|
||||
``r >= lag``, else a frame ``ceil((lag - r) / hold)`` back. At one refresh
|
||||
per frame that is just ``lag`` frames back. Held longer, the lagging band
|
||||
steps one refresh after the rest instead of one frame, which is the only
|
||||
way to cancel the offset: it is a fraction of a frame there.
|
||||
|
||||
Returns ``[(frames_back_per_band, refreshes), ...]`` in order, merging
|
||||
neighbouring refreshes that show the same thing so each costs one swap.
|
||||
"""
|
||||
plan: List[Tuple[Tuple[int, ...], int]] = []
|
||||
for r in range(max(1, hold)):
|
||||
backs = tuple(max(0, -((r - lag) // max(1, hold))) for _, _, lag in bands)
|
||||
if plan and plan[-1][0] == backs:
|
||||
plan[-1] = (backs, plan[-1][1] + 1)
|
||||
else:
|
||||
plan.append((backs, 1))
|
||||
return plan
|
||||
|
||||
|
||||
def compose(image: Image.Image, history: Sequence[Image.Image],
|
||||
bands: Sequence[Band],
|
||||
backs: Optional[Sequence[int]] = None) -> Image.Image:
|
||||
"""``image`` with each band taken from an earlier frame.
|
||||
|
||||
``history[0]`` is the previous frame. ``backs`` is how many frames back each
|
||||
band is taken from (0 = the current one); by default that is the band's lag,
|
||||
which is right when every frame is held for one refresh. A band whose frame
|
||||
is not available yet (the first frames of a scroll) is left current.
|
||||
Returns ``image`` itself when nothing changes, so the caller pays for a copy
|
||||
only when it must.
|
||||
"""
|
||||
out = image
|
||||
for top, bottom, lag in bands:
|
||||
if lag > len(history):
|
||||
for i, (top, bottom, lag) in enumerate(bands):
|
||||
back = lag if backs is None else backs[i]
|
||||
if back <= 0 or back > len(history):
|
||||
continue
|
||||
source = history[lag - 1]
|
||||
source = history[back - 1]
|
||||
if source.size != image.size:
|
||||
continue
|
||||
if out is image:
|
||||
|
||||
@@ -0,0 +1,821 @@
|
||||
"""Drive the real DisplayController.run() on a fake clock and record a trace.
|
||||
|
||||
The golden trace tests (test_run_loop_golden.py) use this to pin down what
|
||||
run() does today -- which mode is on the panel, for how long, and why it
|
||||
left -- so that the loop can be restructured (docs/RUN_LOOP_REDESIGN.md)
|
||||
without changing any of it.
|
||||
|
||||
What is real and what is fake
|
||||
-----------------------------
|
||||
Real: DisplayController itself (constructed through __init__, then run()),
|
||||
PluginExecutor (each screen's first frame still goes through its thread),
|
||||
the per-plugin display locks, and every controller method run() calls.
|
||||
|
||||
Fake, so the run is deterministic and takes milliseconds:
|
||||
|
||||
* the clock -- ``src.display_controller.time`` and ``datetime`` are replaced
|
||||
by one FakeClock; sleeping only advances it. Scripted events (an on-demand
|
||||
request, a WiFi notice, live content starting) fire as it passes them.
|
||||
* plugins -- FakePlugin, whose content, liveness and dynamic-duration answers
|
||||
are functions of the fake clock.
|
||||
* the plugin manager, cache, config service, display manager and sync
|
||||
manager -- in-memory stand-ins with no threads.
|
||||
* the Vegas coordinator -- FakeVegas implements only the contract the
|
||||
controller relies on (run_iteration() returning True when it ran its
|
||||
duration and False when interrupted, the interrupt and live checks it
|
||||
calls back into). The real coordinator spawns threads and renders a strip;
|
||||
driving it on the fake clock is part of stage 4 (Vegas as a Source).
|
||||
|
||||
The run ends when the fake clock passes the scenario's horizon: the clock
|
||||
raises StopRun, a BaseException, which run()'s ``except Exception`` lets
|
||||
through after its ``finally`` has run cleanup().
|
||||
|
||||
How the trace is read
|
||||
---------------------
|
||||
Everything observable is appended to one ordered event log. reduce_trace()
|
||||
folds it into screens: a screen starts at the first display() call of a
|
||||
loop pass (a "pass" is one call of the watchdog's loop_pass(), at the top of
|
||||
run()'s loop), or at the first follower / Vegas / WiFi / blank frame. Its
|
||||
exit reason is the first reason-bearing event logged before the next screen
|
||||
starts, else ``duration``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from src.common.sync_manager import SyncRole
|
||||
from src.plugin_system.plugin_executor import PluginExecutor
|
||||
|
||||
GOLDEN_DIR = Path(__file__).parent / "fixtures" / "run_loop_golden"
|
||||
|
||||
#: Monday 2026-01-05 22:59:30 UTC. The schedule scenario's windows are set
|
||||
#: around 23:00; every other scenario has no schedule, so the date is moot.
|
||||
T0 = datetime(2026, 1, 5, 22, 59, 30, tzinfo=timezone.utc).timestamp()
|
||||
|
||||
#: Loop passes allowed without the clock moving before the run is called a
|
||||
#: spin. run() must sleep somewhere on every few passes.
|
||||
SPIN_LIMIT = 500
|
||||
|
||||
|
||||
class StopRun(BaseException):
|
||||
"""Ends a harness run. A BaseException so run()'s handlers pass it on."""
|
||||
|
||||
|
||||
class SpinError(BaseException):
|
||||
"""run() went round SPIN_LIMIT times without the clock moving.
|
||||
|
||||
A BaseException for the same reason as StopRun: run() would log and
|
||||
swallow anything less, and the test would see a short trace."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Clock
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class FakeClock:
|
||||
"""time.time/monotonic/perf_counter all read ``now``; sleep() advances it.
|
||||
|
||||
Alarms are (time, callback) pairs fired, in time order, by the sleep that
|
||||
carries the clock past them. Reaching the horizon raises StopRun.
|
||||
"""
|
||||
|
||||
def __init__(self, start: float, horizon: float):
|
||||
self.start = start
|
||||
self.now = start
|
||||
self.horizon = start + horizon
|
||||
self._alarms: List[Tuple[float, int, Callable[[], None]]] = []
|
||||
self._seq = 0
|
||||
self.passes_since_advance = 0
|
||||
|
||||
def rel(self) -> float:
|
||||
return self.now - self.start
|
||||
|
||||
def at(self, t: float, callback: Callable[[], None]) -> None:
|
||||
self._alarms.append((self.start + t, self._seq, callback))
|
||||
self._seq += 1
|
||||
self._alarms.sort()
|
||||
|
||||
def time(self) -> float:
|
||||
return self.now
|
||||
|
||||
def sleep(self, seconds: float) -> None:
|
||||
target = self.now + max(0.0, seconds)
|
||||
while self._alarms and self._alarms[0][0] <= target:
|
||||
when, _, callback = self._alarms.pop(0)
|
||||
self.now = max(self.now, when)
|
||||
callback()
|
||||
self.now = target
|
||||
if seconds > 0:
|
||||
self.passes_since_advance = 0
|
||||
if self.now >= self.horizon:
|
||||
raise StopRun()
|
||||
|
||||
def time_module(self) -> SimpleNamespace:
|
||||
return SimpleNamespace(time=self.time, monotonic=self.time,
|
||||
perf_counter=self.time, sleep=self.sleep)
|
||||
|
||||
def datetime_class(self):
|
||||
clock = self
|
||||
|
||||
class FakeDateTime(datetime):
|
||||
@classmethod
|
||||
def now(cls, tz=None): # type: ignore[override]
|
||||
return datetime.fromtimestamp(clock.now, tz or timezone.utc)
|
||||
|
||||
return FakeDateTime
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fakes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class FakeCache:
|
||||
"""The in-memory slice of CacheManager that run() and its helpers use."""
|
||||
|
||||
def __init__(self):
|
||||
self.data: Dict[str, Any] = {}
|
||||
self.cache_dir = "/nonexistent/run-loop-harness"
|
||||
|
||||
def get(self, key, max_age=None, memory_ttl=None):
|
||||
return self.data.get(key)
|
||||
|
||||
def set(self, key, data, ttl=None):
|
||||
self.data[key] = data
|
||||
|
||||
def delete(self, key):
|
||||
self.data.pop(key, None)
|
||||
|
||||
def clear_cache(self, key=None):
|
||||
if key is None:
|
||||
self.data.clear()
|
||||
else:
|
||||
self.data.pop(key, None)
|
||||
|
||||
def __getattr__(self, name):
|
||||
# Anything else (stats, cleanup hooks) is a no-op.
|
||||
return lambda *a, **k: None
|
||||
|
||||
|
||||
class FakeConfigService:
|
||||
def __init__(self, config):
|
||||
self.config = config
|
||||
|
||||
def get_config(self):
|
||||
return self.config
|
||||
|
||||
def subscribe(self, *a, **k):
|
||||
pass
|
||||
|
||||
def unsubscribe(self, *a, **k):
|
||||
pass
|
||||
|
||||
def shutdown(self):
|
||||
pass
|
||||
|
||||
|
||||
class FakeSync:
|
||||
"""A standalone sync manager whose follower state follows the script."""
|
||||
|
||||
role = SyncRole.STANDALONE
|
||||
|
||||
def __init__(self, harness: "RunLoopHarness"):
|
||||
self._h = harness
|
||||
self.follower_windows: List[Tuple[float, float]] = []
|
||||
|
||||
def is_follower_active(self) -> bool:
|
||||
t = self._h.clock.rel()
|
||||
return any(a <= t < b for a, b in self.follower_windows)
|
||||
|
||||
def get_latest_scroll_x(self):
|
||||
return None
|
||||
|
||||
def get_latest_frame(self):
|
||||
return "leader-frame"
|
||||
|
||||
def stop(self):
|
||||
pass
|
||||
|
||||
def __getattr__(self, name):
|
||||
return lambda *a, **k: None
|
||||
|
||||
|
||||
class FakeHealthTracker:
|
||||
"""Circuit breaker stand-in: opens after two consecutive failures and
|
||||
stays open (no wall-clock cooldown, which would not be deterministic)."""
|
||||
|
||||
def __init__(self, harness: "RunLoopHarness"):
|
||||
self._h = harness
|
||||
self.failures: Dict[str, int] = {}
|
||||
|
||||
def should_skip_plugin(self, plugin_id):
|
||||
skip = self.failures.get(plugin_id, 0) >= 2
|
||||
if skip:
|
||||
self._h.log("breaker-open", plugin_id, quiet=True)
|
||||
return skip
|
||||
|
||||
def record_success(self, plugin_id):
|
||||
self.failures[plugin_id] = 0
|
||||
|
||||
def record_failure(self, plugin_id, exc=None):
|
||||
self.failures[plugin_id] = self.failures.get(plugin_id, 0) + 1
|
||||
self._h.log("health-failure", plugin_id)
|
||||
|
||||
|
||||
class FakePluginManager:
|
||||
def __init__(self):
|
||||
self.plugins: Dict[str, Any] = {}
|
||||
self.plugin_manifests: Dict[str, Any] = {}
|
||||
self.plugin_last_update: Dict[str, float] = {}
|
||||
self.health_tracker = None
|
||||
self.resource_monitor = None
|
||||
self.state_manager = None
|
||||
self.plugin_executor = PluginExecutor()
|
||||
self.no_lock: set = set()
|
||||
self._locks: Dict[str, threading.Lock] = {}
|
||||
self.hangs: List[str] = []
|
||||
|
||||
def discover_plugins(self):
|
||||
return []
|
||||
|
||||
def discovered_plugin_ids(self):
|
||||
return set(self.plugins)
|
||||
|
||||
def load_plugin(self, plugin_id, force_enabled=False):
|
||||
return False
|
||||
|
||||
def get_plugin(self, plugin_id):
|
||||
return self.plugins.get(plugin_id)
|
||||
|
||||
def unload_plugin(self, plugin_id):
|
||||
self.plugins.pop(plugin_id, None)
|
||||
return True
|
||||
|
||||
def get_plugin_lock(self, plugin_id):
|
||||
if plugin_id in self.no_lock:
|
||||
return None # as when loading failed part-way
|
||||
return self._locks.setdefault(plugin_id, threading.Lock())
|
||||
|
||||
def record_display_hang(self, plugin_id, seconds):
|
||||
self.hangs.append(plugin_id)
|
||||
|
||||
def note_display_duration(self, plugin_id, seconds):
|
||||
pass
|
||||
|
||||
def run_scheduled_updates(self):
|
||||
pass
|
||||
|
||||
def run_scheduled_updates_with_changes(self):
|
||||
return []
|
||||
|
||||
def stop_update_worker(self):
|
||||
pass
|
||||
|
||||
|
||||
class FakePlugin:
|
||||
"""A plugin whose answers are functions of the harness clock.
|
||||
|
||||
Args:
|
||||
plugin_id: The plugin id.
|
||||
modes: Its display modes, registered in this order.
|
||||
duration: get_display_duration().
|
||||
content: ``content(t, mode) -> bool``: what display() returns.
|
||||
Defaults to always True.
|
||||
live: ``(start, end)`` seconds during which has_live_content() is
|
||||
True; get_live_modes() then names its modes ending in ``_live``.
|
||||
live_priority: has_live_priority().
|
||||
dynamic: Enables dynamic duration. Keys: ``cap`` (the plugin's cap),
|
||||
``cycle`` (get_cycle_duration()), ``complete_after`` (seconds
|
||||
after reset_cycle_state() that is_cycle_complete() turns True;
|
||||
None means never).
|
||||
needs_high_fps / enable_scrolling: Set as attributes only when given,
|
||||
since run() tests for their presence.
|
||||
raises: display() raises RuntimeError.
|
||||
first_frame_only: display() returns True on a screen's first frame
|
||||
and False on every later one.
|
||||
"""
|
||||
|
||||
def __init__(self, plugin_id: str, modes: List[str], duration: float = 30,
|
||||
content: Optional[Callable[[float, str], bool]] = None,
|
||||
live: Optional[Tuple[float, float]] = None,
|
||||
live_priority: bool = False,
|
||||
dynamic: Optional[Dict[str, Any]] = None,
|
||||
needs_high_fps: Optional[bool] = None,
|
||||
enable_scrolling: Optional[bool] = None,
|
||||
raises: bool = False,
|
||||
first_frame_only: bool = False):
|
||||
self.plugin_id = plugin_id
|
||||
self.modes = list(modes)
|
||||
self.duration = duration
|
||||
self.content = content
|
||||
self.live = live
|
||||
self.live_priority = live_priority
|
||||
self.dynamic = dynamic
|
||||
self.raises = raises
|
||||
self.first_frame_only = first_frame_only
|
||||
if needs_high_fps is not None:
|
||||
self.needs_high_fps = needs_high_fps
|
||||
if enable_scrolling is not None:
|
||||
self.enable_scrolling = enable_scrolling
|
||||
self._h: Optional["RunLoopHarness"] = None
|
||||
self._reset_at: Optional[float] = None
|
||||
|
||||
# -- display -----------------------------------------------------------
|
||||
def display(self, display_mode=None, force_clear=False):
|
||||
assert self._h is not None
|
||||
return self._h.on_display(self, display_mode or self.modes[0], force_clear)
|
||||
|
||||
def get_display_duration(self):
|
||||
return self.duration
|
||||
|
||||
# -- live --------------------------------------------------------------
|
||||
def _is_live(self) -> bool:
|
||||
if not self.live or self._h is None:
|
||||
return False
|
||||
t = self._h.clock.rel()
|
||||
return self.live[0] <= t < self.live[1]
|
||||
|
||||
def has_live_priority(self):
|
||||
return self.live_priority
|
||||
|
||||
def has_live_content(self):
|
||||
return self._is_live()
|
||||
|
||||
def get_live_modes(self):
|
||||
return [m for m in self.modes if m.endswith("_live")]
|
||||
|
||||
# -- dynamic duration ----------------------------------------------------
|
||||
def supports_dynamic_duration(self):
|
||||
return bool(self.dynamic)
|
||||
|
||||
def get_dynamic_duration_cap(self):
|
||||
return (self.dynamic or {}).get("cap")
|
||||
|
||||
def get_cycle_duration(self, display_mode=None):
|
||||
return (self.dynamic or {}).get("cycle")
|
||||
|
||||
def reset_cycle_state(self):
|
||||
assert self._h is not None
|
||||
self._reset_at = self._h.clock.rel()
|
||||
self._h.log("cycle-reset", self.plugin_id)
|
||||
|
||||
def is_cycle_complete(self):
|
||||
if not self.dynamic:
|
||||
return True
|
||||
after = self.dynamic.get("complete_after")
|
||||
if after is None or self._reset_at is None or self._h is None:
|
||||
return False
|
||||
done = self._h.clock.rel() - self._reset_at >= after
|
||||
if done:
|
||||
self._h.log("cycle-complete", self.plugin_id, quiet=True)
|
||||
return done
|
||||
|
||||
|
||||
class LegacyFakePlugin(FakePlugin):
|
||||
"""display() without a display_mode parameter, as older plugins have."""
|
||||
|
||||
def display(self, force_clear=False): # type: ignore[override]
|
||||
assert self._h is not None
|
||||
return self._h.on_display(self, self.modes[0], force_clear)
|
||||
|
||||
|
||||
class FakeVegas:
|
||||
"""The coordinator contract DisplayController relies on, nothing more.
|
||||
|
||||
run_iteration() renders frames at 125 Hz on the fake clock for
|
||||
``cycle`` seconds and returns True, or returns False as soon as the
|
||||
interrupt checker (every 10 frames) or the live-priority checker (every
|
||||
0.25 s) asks it to yield -- the same cadence the real coordinator uses.
|
||||
A live-priority pause is lifted by the next call, as in the real one.
|
||||
"""
|
||||
|
||||
FRAME = 1.0 / 125
|
||||
INTERRUPT_EVERY = 10
|
||||
LIVE_EVERY = 0.25
|
||||
|
||||
def __init__(self, harness: "RunLoopHarness", cycle: float = 30.0,
|
||||
live_in_ticker: bool = False):
|
||||
self._h = harness
|
||||
self.cycle = cycle
|
||||
self.is_enabled = True
|
||||
self.vegas_config = SimpleNamespace(live_in_ticker=live_in_ticker)
|
||||
self.render_pipeline = None
|
||||
self._interrupt: Optional[Callable[[], bool]] = None
|
||||
self._live: Optional[Callable[[], Any]] = None
|
||||
self._paused_for_live = False
|
||||
|
||||
def set_live_priority_checker(self, fn):
|
||||
self._live = fn
|
||||
|
||||
def set_interrupt_checker(self, fn, check_interval=10):
|
||||
self._interrupt = fn
|
||||
|
||||
def apply_pending_config_if_idle(self):
|
||||
pass
|
||||
|
||||
def cleanup(self):
|
||||
pass
|
||||
|
||||
def run_iteration(self) -> bool:
|
||||
h = self._h
|
||||
clock = h.clock
|
||||
if self._paused_for_live:
|
||||
self._paused_for_live = False
|
||||
h.log("vegas-start", None, quiet=True)
|
||||
start = clock.now
|
||||
last_live = None
|
||||
frames = 0
|
||||
while True:
|
||||
now = clock.now
|
||||
if (self._live and not self.vegas_config.live_in_ticker
|
||||
and (last_live is None or now - last_live >= self.LIVE_EVERY)):
|
||||
last_live = now
|
||||
if self._live():
|
||||
self._paused_for_live = True
|
||||
h.log("vegas-live")
|
||||
return False
|
||||
h.log("vegas-frame", None, quiet=True)
|
||||
clock.sleep(self.FRAME)
|
||||
frames += 1
|
||||
if self._interrupt and frames % self.INTERRUPT_EVERY == 0 and self._interrupt():
|
||||
h.log("vegas-interrupt")
|
||||
return False
|
||||
if clock.now - start >= self.cycle:
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Harness
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: Events that can end a screen, as they appear in the trace.
|
||||
REASON_EVENTS = {
|
||||
"schedule-off", "schedule-on", "live", "live-ended", "on-demand-start",
|
||||
"on-demand-requested-stop", "on-demand-expired",
|
||||
"on-demand-no-modes-available", "vegas-live", "vegas-interrupt",
|
||||
"cycle-complete", "display-false",
|
||||
}
|
||||
|
||||
_SEGMENT_FOR = {
|
||||
"follower-frame": "<follower>",
|
||||
"vegas-frame": "<vegas>",
|
||||
"wifi": "<wifi>",
|
||||
"blank": "<off>",
|
||||
}
|
||||
|
||||
|
||||
class RunLoopHarness:
|
||||
"""Build a DisplayController on fakes, run it, and return its trace."""
|
||||
|
||||
def __init__(self, tmp_path: Path, horizon: float):
|
||||
self.clock = FakeClock(T0, horizon)
|
||||
self.events: List[Tuple[float, str, Any, Dict[str, Any]]] = []
|
||||
self.tmp_path = tmp_path
|
||||
# The controller keeps this very dict as self.config, so a scenario
|
||||
# can edit what run() reads live (durations, schedules). Values
|
||||
# __init__ copies out (global_dynamic_config) are set on the
|
||||
# controller instead.
|
||||
self.config: Dict[str, Any] = {
|
||||
"timezone": "UTC",
|
||||
"display": {"hardware": {"brightness": 90}},
|
||||
}
|
||||
self.cache = FakeCache()
|
||||
self.pm = FakePluginManager()
|
||||
self.sync = FakeSync(self)
|
||||
self.dm = self._display_manager()
|
||||
self._displayed_this_pass = False
|
||||
self.controller = self._build()
|
||||
|
||||
# -- event log -----------------------------------------------------------
|
||||
def log(self, kind: str, subject: Any = None, quiet: bool = False, **data):
|
||||
data["quiet"] = quiet
|
||||
self.events.append((round(self.clock.rel(), 3), kind, subject, data))
|
||||
|
||||
def on_display(self, plugin: FakePlugin, mode: str, force_clear: bool):
|
||||
first = not self._displayed_this_pass
|
||||
self._displayed_this_pass = True
|
||||
if plugin.raises:
|
||||
self.log("first" if first else "frame", mode, quiet=True,
|
||||
clear=bool(force_clear), result="raised")
|
||||
raise RuntimeError(f"{plugin.plugin_id} display() failed")
|
||||
if plugin.first_frame_only:
|
||||
result = first
|
||||
elif plugin.content is None:
|
||||
result = True
|
||||
else:
|
||||
result = bool(plugin.content(self.clock.rel(), mode))
|
||||
self.log("first" if first else "frame", mode, quiet=True,
|
||||
clear=bool(force_clear), result=result)
|
||||
return result
|
||||
|
||||
# -- construction ----------------------------------------------------------
|
||||
def _display_manager(self):
|
||||
dm = MagicMock(name="DisplayManager")
|
||||
dm.width = 128
|
||||
dm.height = 32
|
||||
dm._sync_render_allowed = False
|
||||
dm.set_brightness = MagicMock(side_effect=self._on_set_brightness)
|
||||
dm.update_display = MagicMock(side_effect=self._on_update_display)
|
||||
dm.get_font_height = MagicMock(return_value=8)
|
||||
return dm
|
||||
|
||||
def _on_set_brightness(self, value):
|
||||
self.log("brightness", value)
|
||||
return True
|
||||
|
||||
def _on_update_display(self):
|
||||
if getattr(self.dm, "_sync_render_allowed", False):
|
||||
self.log("follower-frame", None, quiet=True)
|
||||
elif not self.controller.is_display_active:
|
||||
self.log("blank", None, quiet=True)
|
||||
|
||||
def _build(self):
|
||||
from src import display_controller as dc_mod
|
||||
|
||||
clock = self.clock
|
||||
env = {"LEDMATRIX_HOT_RELOAD": "false", "EMULATOR": "true"}
|
||||
with patch.dict(os.environ, env), \
|
||||
patch.object(dc_mod, "time", clock.time_module()), \
|
||||
patch.object(dc_mod, "datetime", clock.datetime_class()), \
|
||||
patch.object(dc_mod, "ConfigManager", MagicMock()), \
|
||||
patch.object(dc_mod, "ConfigService", lambda **kw: FakeConfigService(self.config)), \
|
||||
patch.object(dc_mod, "CacheManager", lambda: self.cache), \
|
||||
patch.object(dc_mod, "DisplayManager", lambda config: self.dm), \
|
||||
patch.object(dc_mod, "FontManager", MagicMock()), \
|
||||
patch.object(dc_mod, "DisplaySyncManager", lambda **kw: self.sync), \
|
||||
patch("src.plugin_system.PluginManager", lambda **kw: self.pm), \
|
||||
patch("src.error_aggregator.start_error_snapshot_publisher", lambda cm: None), \
|
||||
patch("src.font_usage.start_font_usage_publisher", lambda *a, **k: None), \
|
||||
patch("src.plugin_system.plugin_runtime.start_plugin_runtime_publisher",
|
||||
lambda *a, **k: None), \
|
||||
patch("src.auto_update_setup.ensure_update_helper", lambda config: None):
|
||||
controller = dc_mod.DisplayController()
|
||||
|
||||
# __init__ wires real health/resource monitors; swap in the fake
|
||||
# breaker so failures and skips are deterministic.
|
||||
self.pm.health_tracker = FakeHealthTracker(self)
|
||||
self.pm.resource_monitor = None
|
||||
controller.wifi_status_file = self.tmp_path / "wifi_status.json"
|
||||
self._instrument(controller)
|
||||
return controller
|
||||
|
||||
def _instrument(self, dc) -> None:
|
||||
"""Log the controller's decisions without changing any of them.
|
||||
|
||||
Each wrapper calls straight through to the real method; only methods
|
||||
that exist both before and after the stage-1 extraction are wrapped,
|
||||
so the same harness records the same trace from either.
|
||||
"""
|
||||
h = self
|
||||
|
||||
def wrap(name, before, after):
|
||||
real = getattr(dc, name)
|
||||
|
||||
def wrapper(*args, **kwargs):
|
||||
token = before(*args, **kwargs)
|
||||
result = real(*args, **kwargs)
|
||||
after(token, *args, **kwargs)
|
||||
return result
|
||||
setattr(dc, name, wrapper)
|
||||
|
||||
wrap("_evaluate_schedule",
|
||||
lambda: dc.is_display_active,
|
||||
lambda was: (h.log("schedule-off") if was and not dc.is_display_active
|
||||
else h.log("schedule-on") if not was and dc.is_display_active
|
||||
else None))
|
||||
wrap("_activate_on_demand",
|
||||
lambda request: None,
|
||||
lambda _, request: h.log("on-demand-start", request.get("plugin_id"))
|
||||
if dc.on_demand_active else h.log("on-demand-error", dc.on_demand_last_error))
|
||||
wrap("_clear_on_demand",
|
||||
lambda reason=None: dc.on_demand_active,
|
||||
lambda was, reason=None: h.log(f"on-demand-{reason}") if was else None)
|
||||
wrap("_apply_live_priority",
|
||||
lambda mode: dc.current_display_mode,
|
||||
lambda prev, mode: (None if dc.current_display_mode == prev
|
||||
else h.log("live" if mode else "live-ended",
|
||||
dc.current_display_mode)))
|
||||
real_note = dc._note_empty_pass
|
||||
|
||||
def note_empty_pass():
|
||||
h.log("empty", dc.current_display_mode, quiet=True)
|
||||
return real_note()
|
||||
dc._note_empty_pass = note_empty_pass
|
||||
|
||||
real_wifi = dc._display_wifi_status_message
|
||||
|
||||
def display_wifi(status):
|
||||
shown = real_wifi(status)
|
||||
if shown:
|
||||
h.log("wifi", status.get("message"), quiet=True)
|
||||
return shown
|
||||
dc._display_wifi_status_message = display_wifi
|
||||
|
||||
# -- scenario setup ------------------------------------------------------
|
||||
def add_plugin(self, plugin: FakePlugin, lock: bool = True) -> FakePlugin:
|
||||
"""Register a plugin the way _register_loaded_plugin leaves things."""
|
||||
dc = self.controller
|
||||
plugin._h = self
|
||||
self.pm.plugins[plugin.plugin_id] = plugin
|
||||
if not lock:
|
||||
self.pm.no_lock.add(plugin.plugin_id)
|
||||
dc.plugin_display_modes[plugin.plugin_id] = list(plugin.modes)
|
||||
for mode in plugin.modes:
|
||||
if mode not in dc.available_modes:
|
||||
dc.available_modes.append(mode)
|
||||
dc.plugin_modes[mode] = plugin
|
||||
dc.mode_to_plugin_id[mode] = plugin.plugin_id
|
||||
return plugin
|
||||
|
||||
def add_mode_without_plugin(self, mode: str) -> None:
|
||||
self.controller.available_modes.append(mode)
|
||||
|
||||
def on_demand_request(self, t: float, request_id: str, action: str = "start", **fields):
|
||||
def post():
|
||||
self.log("request", f"{action}:{request_id}")
|
||||
self.cache.set("display_on_demand_request",
|
||||
{"request_id": request_id, "action": action, **fields})
|
||||
self.clock.at(t, post)
|
||||
|
||||
def restore_on_demand(self, plugin_id: str, mode: Optional[str] = None,
|
||||
duration: Optional[float] = None, pinned: bool = False):
|
||||
"""Start with an on-demand session resumed from the cache, as after
|
||||
a restart: the state _select_startup_plugins restores, then
|
||||
_populate_on_demand_modes_from_plugin, as __init__ calls it."""
|
||||
dc = self.controller
|
||||
dc.on_demand_active = True
|
||||
dc.on_demand_plugin_id = plugin_id
|
||||
dc.on_demand_mode = mode
|
||||
dc.on_demand_duration = duration
|
||||
dc.on_demand_pinned = pinned
|
||||
dc.on_demand_requested_at = self.clock.now
|
||||
dc.on_demand_expires_at = self.clock.now + duration if duration else None
|
||||
dc.on_demand_status = 'active'
|
||||
dc.on_demand_schedule_override = True
|
||||
dc._populate_on_demand_modes_from_plugin()
|
||||
|
||||
def wifi_message(self, t: float, message: str, duration: float = 5):
|
||||
def write():
|
||||
self.log("wifi-file", message)
|
||||
self.controller.wifi_status_file.write_text(json.dumps(
|
||||
{"message": message, "timestamp": self.clock.now, "duration": duration}),
|
||||
encoding="utf-8")
|
||||
self.clock.at(t, write)
|
||||
|
||||
def enable_vegas(self, cycle: float = 30.0, live_in_ticker: bool = False) -> FakeVegas:
|
||||
"""Install FakeVegas, wired up as _initialize_vegas_mode wires the real one."""
|
||||
dc = self.controller
|
||||
vegas = FakeVegas(self, cycle=cycle, live_in_ticker=live_in_ticker)
|
||||
vegas.set_live_priority_checker(dc._check_live_priority)
|
||||
vegas.set_interrupt_checker(
|
||||
lambda: dc._check_vegas_interrupt() or dc.sync_manager.is_follower_active(),
|
||||
check_interval=10)
|
||||
dc.vegas_coordinator = vegas
|
||||
return vegas
|
||||
|
||||
# -- running -------------------------------------------------------------
|
||||
def run(self) -> Dict[str, Any]:
|
||||
from src import display_controller as dc_mod
|
||||
from src import display_watchdog
|
||||
|
||||
clock = self.clock
|
||||
watchdog = display_watchdog.watchdog
|
||||
real_loop_pass = watchdog.loop_pass
|
||||
|
||||
def loop_pass():
|
||||
self._displayed_this_pass = False
|
||||
self.log("pass", None, quiet=True)
|
||||
clock.passes_since_advance += 1
|
||||
if clock.passes_since_advance > SPIN_LIMIT:
|
||||
raise SpinError(f"run() spun {SPIN_LIMIT} passes at t={clock.rel():.3f}")
|
||||
return real_loop_pass()
|
||||
|
||||
with patch.object(dc_mod, "time", clock.time_module()), \
|
||||
patch.object(dc_mod, "datetime", clock.datetime_class()), \
|
||||
patch.object(watchdog, "loop_pass", loop_pass):
|
||||
try:
|
||||
self.controller.run()
|
||||
except StopRun:
|
||||
pass
|
||||
else:
|
||||
# run() only returns after catching something itself.
|
||||
raise AssertionError(
|
||||
f"run() returned at t={clock.rel():.3f} before the horizon")
|
||||
return reduce_trace(self.events, round(clock.horizon - clock.start, 3))
|
||||
|
||||
|
||||
def reduce_trace(events, horizon: float) -> Dict[str, Any]:
|
||||
"""Fold the event log into screens and the notable events."""
|
||||
screens: List[Dict[str, Any]] = []
|
||||
notable: List[List[Any]] = []
|
||||
cur: Optional[Dict[str, Any]] = None
|
||||
# What happened in the current loop pass, for attributing an empty pass.
|
||||
shown_this_pass = False
|
||||
failed_this_pass = False
|
||||
breaker_this_pass = False
|
||||
|
||||
def start(t, mode, clear=None):
|
||||
nonlocal cur
|
||||
cur = {"t": t, "mode": mode, "frames": 0, "clear": clear, "exit": None}
|
||||
screens.append(cur)
|
||||
|
||||
for t, kind, subject, data in events:
|
||||
if not data.get("quiet"):
|
||||
notable.append([t, kind] + ([subject] if subject is not None else []))
|
||||
if kind == "pass":
|
||||
shown_this_pass = failed_this_pass = breaker_this_pass = False
|
||||
elif kind in ("first", "frame"):
|
||||
if kind == "first" or cur is None:
|
||||
start(t, subject, data["clear"])
|
||||
shown_this_pass = True
|
||||
cur["result"] = data["result"]
|
||||
cur["frames"] += 1
|
||||
if kind == "frame" and data["result"] is False and cur["exit"] is None:
|
||||
cur["exit"] = "display-false"
|
||||
elif kind in _SEGMENT_FOR:
|
||||
segment = _SEGMENT_FOR[kind]
|
||||
if cur is None or cur["mode"] != segment or cur["exit"] is not None:
|
||||
start(t, segment)
|
||||
cur["frames"] += 1
|
||||
elif kind == "vegas-start":
|
||||
start(t, "<vegas>")
|
||||
elif kind == "health-failure":
|
||||
failed_this_pass = True
|
||||
elif kind == "breaker-open":
|
||||
breaker_this_pass = True
|
||||
elif kind == "empty":
|
||||
if shown_this_pass and cur is not None and cur["exit"] is None:
|
||||
# display() ran and had nothing (False) or raised.
|
||||
cur["exit"] = "raised" if cur.get("result") == "raised" else "empty"
|
||||
else:
|
||||
# Never reached display(): no plugin, the breaker is open, or
|
||||
# the dispatch itself raised.
|
||||
start(t, subject)
|
||||
cur["exit"] = ("error" if failed_this_pass
|
||||
else "breaker" if breaker_this_pass else "no-plugin")
|
||||
elif kind in REASON_EVENTS and cur is not None and cur["exit"] is None:
|
||||
cur["exit"] = kind
|
||||
|
||||
rows = []
|
||||
for i, screen in enumerate(screens):
|
||||
end = screens[i + 1]["t"] if i + 1 < len(screens) else horizon
|
||||
nxt = screens[i + 1] if i + 1 < len(screens) else None
|
||||
# A WiFi notice logs no event at the moment it takes the panel (the
|
||||
# file is written earlier), so a screen followed by one is labelled
|
||||
# "wifi". Its duration column shows whether it was cut short.
|
||||
exit_reason = screen["exit"] or (
|
||||
"horizon" if nxt is None
|
||||
else "wifi" if nxt["mode"] == "<wifi>" and screen["mode"] != "<wifi>"
|
||||
else "duration")
|
||||
rows.append([screen["t"], screen["mode"], round(end - screen["t"], 3),
|
||||
exit_reason, screen["frames"], screen["clear"]])
|
||||
return {"screens": rows, "events": notable}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Golden files
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def dump_golden(trace: Dict[str, Any]) -> str:
|
||||
"""One screen or event per line, so a diff points at the row that moved."""
|
||||
def block(name, rows, last=False):
|
||||
end = "" if last else ","
|
||||
if not rows:
|
||||
return [f' "{name}": []{end}']
|
||||
return [f' "{name}": [',
|
||||
",\n".join(" " + json.dumps(row) for row in rows),
|
||||
f" ]{end}"]
|
||||
|
||||
lines = (["{"] + block("screens", trace["screens"])
|
||||
+ block("events", trace["events"], last=True) + ["}"])
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def check_golden(name: str, trace: Dict[str, Any]) -> None:
|
||||
"""Compare against test/fixtures/run_loop_golden/<name>.json.
|
||||
|
||||
LEDMATRIX_REGEN_GOLDEN=1 rewrites the file instead. Only do that for a
|
||||
deliberate behaviour change, and say why in the commit.
|
||||
"""
|
||||
path = GOLDEN_DIR / f"{name}.json"
|
||||
text = dump_golden(trace)
|
||||
if os.environ.get("LEDMATRIX_REGEN_GOLDEN") == "1":
|
||||
GOLDEN_DIR.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text, encoding="utf-8", newline="\n")
|
||||
return
|
||||
assert path.exists(), f"no golden trace {path}; run with LEDMATRIX_REGEN_GOLDEN=1"
|
||||
expected = json.loads(path.read_text(encoding="utf-8"))
|
||||
actual = json.loads(text)
|
||||
if actual != expected:
|
||||
import difflib
|
||||
diff = "\n".join(difflib.unified_diff(
|
||||
dump_golden(expected).splitlines(), text.splitlines(),
|
||||
"golden", "actual", lineterm="", n=2))
|
||||
raise AssertionError(f"run() trace for {name!r} changed:\n{diff}")
|
||||
Vendored
+18
@@ -192,6 +192,15 @@
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/config/scroll-speed-advice",
|
||||
"api_v3.get_scroll_speed_advice",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/config/secrets",
|
||||
"api_v3.get_secrets_config",
|
||||
@@ -458,6 +467,15 @@
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/plugins/fetch-stats",
|
||||
"api_v3.get_fetch_stats",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/plugins/health",
|
||||
"api_v3.get_plugin_health",
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"screens": [
|
||||
[0.0, "a", 0.0, "empty", 1, false],
|
||||
[0.0, "b", 0.0, "empty", 1, true],
|
||||
[0.0, "c", 1.0, "empty", 1, true],
|
||||
[1.0, "a", 1.0, "empty", 1, true],
|
||||
[2.0, "b", 1.0, "empty", 1, true],
|
||||
[3.0, "c", 1.0, "empty", 1, true],
|
||||
[4.0, "a", 1.0, "empty", 1, true],
|
||||
[5.0, "b", 1.0, "empty", 1, true],
|
||||
[6.0, "c", 6.0, "horizon", 6, true]
|
||||
],
|
||||
"events": []
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"screens": [
|
||||
[0.0, "scroller", 20.008, "cycle-complete", 2502, false],
|
||||
[20.008, "news", 40.0, "duration", 40, true],
|
||||
[60.008, "board", 11.0, "cycle-complete", 12, true],
|
||||
[71.008, "clock", 10.0, "duration", 10, true],
|
||||
[81.008, "scroller", 20.007, "cycle-complete", 2502, true],
|
||||
[101.015, "news", 40.0, "duration", 40, true],
|
||||
[141.015, "board", 11.0, "cycle-complete", 12, true],
|
||||
[152.015, "clock", 10.0, "duration", 10, true],
|
||||
[162.015, "scroller", 20.008, "cycle-complete", 2502, true],
|
||||
[182.023, "news", 37.977, "horizon", 38, true]
|
||||
],
|
||||
"events": [
|
||||
[0.0, "cycle-reset", "scroller"],
|
||||
[20.008, "cycle-reset", "news"],
|
||||
[60.008, "cycle-reset", "board"],
|
||||
[81.008, "cycle-reset", "scroller"],
|
||||
[101.015, "cycle-reset", "news"],
|
||||
[141.015, "cycle-reset", "board"],
|
||||
[162.015, "cycle-reset", "scroller"],
|
||||
[182.023, "cycle-reset", "news"]
|
||||
]
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"screens": [
|
||||
[0.0, "clock", 10.0, "duration", 10, false],
|
||||
[10.0, "empty", 0.0, "empty", 1, true],
|
||||
[10.0, "ghost", 0.0, "no-plugin", 0, null],
|
||||
[10.0, "flaky", 12.0, "display-false", 2, true],
|
||||
[22.0, "clock", 10.0, "duration", 10, true],
|
||||
[32.0, "empty", 0.0, "empty", 1, true],
|
||||
[32.0, "ghost", 0.0, "no-plugin", 0, null],
|
||||
[32.0, "flaky", 12.0, "display-false", 2, true],
|
||||
[44.0, "clock", 10.0, "duration", 10, true],
|
||||
[54.0, "empty", 0.0, "empty", 1, true],
|
||||
[54.0, "ghost", 0.0, "no-plugin", 0, null],
|
||||
[54.0, "flaky", 12.0, "display-false", 2, true],
|
||||
[66.0, "clock", 10.0, "duration", 10, true],
|
||||
[76.0, "empty", 0.0, "empty", 1, true],
|
||||
[76.0, "ghost", 0.0, "no-plugin", 0, null],
|
||||
[76.0, "flaky", 12.0, "display-false", 2, true],
|
||||
[88.0, "clock", 2.0, "horizon", 2, true]
|
||||
],
|
||||
"events": []
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"screens": [
|
||||
[0.0, "clock", 20.0, "duration", 20, false],
|
||||
[20.0, "weather", 20.0, "duration", 20, true],
|
||||
[40.0, "<follower>", 10.017, "duration", 601, null],
|
||||
[50.017, "clock", 20.0, "duration", 20, true],
|
||||
[70.017, "weather", 9.983, "horizon", 10, true]
|
||||
],
|
||||
"events": []
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"screens": [
|
||||
[0.0, "clock", 20.0, "duration", 20, false],
|
||||
[20.0, "weather", 20.0, "duration", 20, true],
|
||||
[40.0, "sports_recent", 10.0, "live", 11, true],
|
||||
[50.0, "sports_live", 20.0, "duration", 20, true],
|
||||
[70.0, "sports_live", 20.0, "duration", 20, false],
|
||||
[90.0, "sports_live", 20.0, "live-ended", 20, false],
|
||||
[110.0, "sports_recent", 20.0, "duration", 20, true],
|
||||
[130.0, "sports_live", 0.0, "empty", 1, true],
|
||||
[130.0, "clock", 20.0, "duration", 20, true],
|
||||
[150.0, "weather", 20.0, "duration", 20, true],
|
||||
[170.0, "sports_recent", 20.0, "duration", 20, true],
|
||||
[190.0, "sports_live", 0.0, "empty", 1, true],
|
||||
[190.0, "clock", 10.0, "horizon", 10, true]
|
||||
],
|
||||
"events": [
|
||||
[50.0, "live", "sports_live"],
|
||||
[110.0, "live-ended", "sports_recent"]
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"screens": [
|
||||
[0.0, "nfl_live", 15.0, "duration", 15, true],
|
||||
[15.0, "nfl_live", 15.0, "live", 15, false],
|
||||
[30.0, "nhl_live", 15.0, "live", 15, true],
|
||||
[45.0, "nfl_live", 15.0, "live", 15, true],
|
||||
[60.0, "nhl_live", 15.0, "duration", 15, true],
|
||||
[75.0, "nhl_live", 15.0, "duration", 15, false],
|
||||
[90.0, "nhl_live", 15.0, "duration", 15, false],
|
||||
[105.0, "clock", 15.0, "duration", 15, true],
|
||||
[120.0, "nfl_live", 15.0, "duration", 15, true],
|
||||
[135.0, "nhl_live", 15.0, "horizon", 15, true]
|
||||
],
|
||||
"events": [
|
||||
[0.0, "live", "nfl_live"],
|
||||
[30.0, "live", "nhl_live"],
|
||||
[45.0, "live", "nfl_live"],
|
||||
[60.0, "live", "nhl_live"]
|
||||
]
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"screens": [
|
||||
[0.0, "clock", 20.0, "duration", 20, false],
|
||||
[20.0, "weather", 5.0, "on-demand-start", 6, true],
|
||||
[25.0, "sports_recent", 15.0, "duration", 15, true],
|
||||
[40.0, "sports_upcoming", 15.0, "duration", 15, true],
|
||||
[55.0, "sports_recent", 15.0, "duration", 15, true],
|
||||
[70.0, "sports_upcoming", 15.0, "duration", 15, true],
|
||||
[85.0, "sports_recent", 10.0, "on-demand-requested-stop", 11, true],
|
||||
[95.0, "weather", 20.0, "duration", 20, true],
|
||||
[115.0, "sports_recent", 15.0, "duration", 15, true],
|
||||
[130.0, "sports_upcoming", 15.0, "duration", 15, true],
|
||||
[145.0, "clock", 5.0, "on-demand-start", 6, true],
|
||||
[150.0, "weather", 20.0, "duration", 20, true],
|
||||
[170.0, "weather", 10.0, "on-demand-expired", 10, true],
|
||||
[180.0, "clock", 20.0, "duration", 20, true],
|
||||
[200.0, "weather", 20.0, "duration", 20, true],
|
||||
[220.0, "sports_recent", 15.0, "duration", 15, true],
|
||||
[235.0, "sports_upcoming", 5.0, "horizon", 5, true]
|
||||
],
|
||||
"events": [
|
||||
[25.0, "request", "start:r1"],
|
||||
[25.0, "on-demand-start", "sports"],
|
||||
[95.0, "request", "stop:r2"],
|
||||
[95.0, "on-demand-requested-stop"],
|
||||
[150.0, "request", "start:r3"],
|
||||
[150.0, "on-demand-start", "weather"],
|
||||
[180.0, "on-demand-expired"]
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"screens": [
|
||||
[0.0, "clock", 12.0, "on-demand-start", 13, false],
|
||||
[12.0, "sports_upcoming", 15.0, "duration", 15, true],
|
||||
[27.0, "sports_upcoming", 15.0, "duration", 15, true],
|
||||
[42.0, "sports_upcoming", 15.0, "duration", 15, true],
|
||||
[57.0, "sports_upcoming", 15.0, "duration", 15, true],
|
||||
[72.0, "sports_upcoming", 8.0, "on-demand-start", 9, true],
|
||||
[80.0, "app_a", 0.0, "empty", 1, true],
|
||||
[80.0, "app_b", 10.0, "duration", 10, true],
|
||||
[90.0, "app_a", 0.0, "empty", 1, true],
|
||||
[90.0, "app_b", 10.0, "duration", 10, true],
|
||||
[100.0, "app_a", 0.0, "empty", 1, true],
|
||||
[100.0, "app_b", 10.0, "duration", 10, true],
|
||||
[110.0, "app_a", 0.0, "empty", 1, true],
|
||||
[110.0, "app_b", 10.0, "on-demand-requested-stop", 10, true],
|
||||
[120.0, "clock", 20.0, "duration", 20, true],
|
||||
[140.0, "sports_recent", 15.0, "duration", 15, true],
|
||||
[155.0, "sports_upcoming", 5.0, "horizon", 5, true]
|
||||
],
|
||||
"events": [
|
||||
[12.0, "request", "start:p1"],
|
||||
[12.0, "on-demand-start", "sports"],
|
||||
[80.0, "request", "start:p2"],
|
||||
[80.0, "on-demand-start", "starlark"],
|
||||
[120.0, "request", "stop:p3"],
|
||||
[120.0, "on-demand-requested-stop"]
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"screens": [
|
||||
[0.0, "sports_upcoming", 15.0, "duration", 15, true],
|
||||
[15.0, "sports_recent", 15.0, "duration", 15, true],
|
||||
[30.0, "sports_upcoming", 10.0, "on-demand-expired", 10, true],
|
||||
[40.0, "clock", 20.0, "duration", 20, true],
|
||||
[60.0, "weather", 20.0, "duration", 20, true],
|
||||
[80.0, "sports_recent", 15.0, "duration", 15, true],
|
||||
[95.0, "sports_upcoming", 5.0, "horizon", 5, true]
|
||||
],
|
||||
"events": [
|
||||
[40.0, "on-demand-expired"]
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"screens": [
|
||||
[0.0, "clock", 15.0, "duration", 15, false],
|
||||
[15.0, "weather_now", 20.0, "duration", 20, true],
|
||||
[35.0, "weather_forecast", 20.0, "duration", 20, true],
|
||||
[55.0, "ticker", 10.008, "duration", 1252, true],
|
||||
[65.008, "legacy", 5.0, "duration", 5, true],
|
||||
[70.008, "clock", 15.0, "duration", 15, true],
|
||||
[85.008, "weather_now", 20.0, "duration", 20, true],
|
||||
[105.008, "weather_forecast", 20.0, "duration", 20, true],
|
||||
[125.008, "ticker", 10.008, "duration", 1252, true],
|
||||
[135.016, "legacy", 5.0, "duration", 5, true],
|
||||
[140.016, "clock", 15.0, "duration", 15, true],
|
||||
[155.016, "weather_now", 4.984, "horizon", 5, true]
|
||||
],
|
||||
"events": []
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"screens": [
|
||||
[0.0, "clock", 10.0, "duration", 10, false],
|
||||
[10.0, "broken_a", 0.0, "error", 0, null],
|
||||
[10.0, "weather", 10.0, "duration", 10, true],
|
||||
[20.0, "crashy", 0.0, "raised", 1, true],
|
||||
[20.0, "clock", 10.0, "duration", 10, true],
|
||||
[30.0, "broken_a", 0.0, "error", 0, null],
|
||||
[30.0, "weather", 10.0, "duration", 10, true],
|
||||
[40.0, "crashy", 0.0, "raised", 1, true],
|
||||
[40.0, "clock", 10.0, "duration", 10, true],
|
||||
[50.0, "broken_a", 0.0, "breaker", 0, null],
|
||||
[50.0, "broken_b", 0.0, "breaker", 0, null],
|
||||
[50.0, "weather", 10.0, "duration", 10, true],
|
||||
[60.0, "crashy", 0.0, "breaker", 0, null],
|
||||
[60.0, "clock", 10.0, "duration", 10, true],
|
||||
[70.0, "broken_a", 0.0, "breaker", 0, null],
|
||||
[70.0, "broken_b", 0.0, "breaker", 0, null],
|
||||
[70.0, "weather", 10.0, "duration", 10, true],
|
||||
[80.0, "crashy", 0.0, "breaker", 0, null],
|
||||
[80.0, "clock", 10.0, "horizon", 10, true]
|
||||
],
|
||||
"events": [
|
||||
[10.0, "health-failure", "broken"],
|
||||
[20.0, "health-failure", "crashy"],
|
||||
[30.0, "health-failure", "broken"],
|
||||
[40.0, "health-failure", "crashy"]
|
||||
]
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"screens": [
|
||||
[0.0, "clock", 20.0, "duration", 20, false],
|
||||
[20.0, "weather", 20.0, "duration", 20, true],
|
||||
[40.0, "clock", 20.0, "duration", 20, true],
|
||||
[60.0, "weather", 20.0, "duration", 20, true],
|
||||
[80.0, "clock", 10.0, "schedule-off", 11, true],
|
||||
[90.0, "<off>", 80.0, "on-demand-start", 3, null],
|
||||
[170.0, "weather", 20.0, "on-demand-expired", 20, true],
|
||||
[190.0, "<off>", 140.0, "schedule-on", 3, null],
|
||||
[330.0, "clock", 20.0, "duration", 20, true],
|
||||
[350.0, "weather", 20.0, "duration", 20, true],
|
||||
[370.0, "clock", 20.0, "duration", 20, true],
|
||||
[390.0, "weather", 10.0, "horizon", 10, true]
|
||||
],
|
||||
"events": [
|
||||
[30.0, "brightness", 30],
|
||||
[90.0, "schedule-off"],
|
||||
[170.0, "request", "start:s1"],
|
||||
[170.0, "on-demand-start", "weather"],
|
||||
[170.0, "schedule-on"],
|
||||
[170.0, "brightness", 90],
|
||||
[190.0, "on-demand-expired"],
|
||||
[190.0, "schedule-off"],
|
||||
[330.0, "schedule-on"]
|
||||
]
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"screens": [
|
||||
[0.0, "<vegas>", 30.008, "duration", 3751, null],
|
||||
[30.008, "<vegas>", 30.007, "duration", 3751, null],
|
||||
[60.015, "<vegas>", 10.24, "vegas-live", 1280, null],
|
||||
[70.255, "sports_live", 20.0, "duration", 20, true],
|
||||
[90.255, "sports_live", 20.0, "display-false", 11, false],
|
||||
[110.255, "<vegas>", 30.008, "duration", 3751, null],
|
||||
[140.263, "<vegas>", 10.0, "on-demand-start", 1250, null],
|
||||
[150.263, "clock", 20.0, "duration", 20, true],
|
||||
[170.263, "clock", 5.0, "on-demand-expired", 5, true],
|
||||
[175.263, "<vegas>", 24.959, "vegas-interrupt", 3120, null],
|
||||
[200.222, "<wifi>", 3.0, "duration", 6, null],
|
||||
[203.222, "<vegas>", 30.008, "duration", 3751, null],
|
||||
[233.23, "<vegas>", 26.77, "horizon", 3347, null]
|
||||
],
|
||||
"events": [
|
||||
[70.255, "vegas-live"],
|
||||
[70.255, "live", "sports_live"],
|
||||
[150.0, "request", "start:v1"],
|
||||
[150.263, "on-demand-start", "clock"],
|
||||
[150.263, "vegas-interrupt"],
|
||||
[175.263, "on-demand-expired"],
|
||||
[200.0, "wifi-file", "Connected to HomeNet"],
|
||||
[200.222, "vegas-interrupt"]
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"screens": [
|
||||
[0.0, "<vegas>", 30.008, "duration", 3751, null],
|
||||
[30.008, "<vegas>", 30.007, "duration", 3751, null],
|
||||
[60.015, "<vegas>", 30.008, "duration", 3751, null],
|
||||
[90.023, "<vegas>", 9.977, "horizon", 1248, null]
|
||||
],
|
||||
"events": []
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"screens": [
|
||||
[0.0, "clock", 20.0, "duration", 20, false],
|
||||
[20.0, "weather", 5.0, "wifi", 6, true],
|
||||
[25.0, "<wifi>", 5.0, "duration", 10, null],
|
||||
[30.0, "weather", 20.0, "duration", 20, true],
|
||||
[50.0, "clock", 20.0, "on-demand-start", 20, true],
|
||||
[70.0, "clock", 10.0, "on-demand-expired", 10, true],
|
||||
[80.0, "<wifi>", 15.0, "duration", 30, null],
|
||||
[95.0, "clock", 20.0, "duration", 20, true],
|
||||
[115.0, "weather", 20.0, "duration", 20, true],
|
||||
[135.0, "clock", 15.0, "horizon", 15, true]
|
||||
],
|
||||
"events": [
|
||||
[25.0, "wifi-file", "Connected to HomeNet"],
|
||||
[60.0, "request", "start:w1"],
|
||||
[60.0, "on-demand-start", "clock"],
|
||||
[65.0, "wifi-file", "AP mode on"],
|
||||
[80.0, "on-demand-expired"]
|
||||
]
|
||||
}
|
||||
@@ -6,7 +6,8 @@ test_display_controller_optimizations.py::TestScheduleMinuteGate already
|
||||
covers the once-per-minute gating; this file covers what it doesn't:
|
||||
midnight-crossing windows, mode selection (global / per-day / legacy
|
||||
inference), per-day disabled days, invalid time strings, unknown
|
||||
timezones, boundary equality, and the transition-tracking flags.
|
||||
timezones, the half-open [start, end) boundaries, on-demand ending during
|
||||
scheduled-off, and the transition-tracking flags.
|
||||
|
||||
Both methods read only self.config and a handful of instance attributes,
|
||||
so a bare stub via object.__new__ (the test_display_controller_vegas_tick
|
||||
@@ -41,12 +42,13 @@ def make_controller(config=None, *, normal_brightness=90):
|
||||
|
||||
|
||||
def at(time_str, day="monday"):
|
||||
"""Context manager patching the controller module's clock."""
|
||||
"""Patch the controller module's clock to ``HH:MM`` or ``HH:MM:SS``."""
|
||||
patcher = patch("src.display_controller.datetime")
|
||||
mock_dt = patcher.start()
|
||||
mock_dt.strptime = datetime.strptime
|
||||
fmt = "%H:%M:%S" if time_str.count(":") == 2 else "%H:%M"
|
||||
mock_dt.now.return_value.time.return_value = (
|
||||
datetime.strptime(time_str, "%H:%M").time())
|
||||
datetime.strptime(time_str, fmt).time())
|
||||
mock_dt.now.return_value.strftime.return_value.lower.return_value = day
|
||||
mock_dt.now.return_value.hour = int(time_str.split(":")[0])
|
||||
mock_dt.now.return_value.minute = int(time_str.split(":")[1])
|
||||
@@ -98,10 +100,13 @@ class TestScheduleWindows:
|
||||
assert check_at(dc, "20:00") is False
|
||||
assert check_at(dc, "08:59") is False
|
||||
|
||||
def test_boundaries_are_inclusive(self):
|
||||
def test_window_is_half_open(self):
|
||||
# [start, end): on from the start minute, off at the end minute.
|
||||
dc = make_controller(self._config("09:00", "17:00"))
|
||||
assert check_at(dc, "09:00") is True # now == start
|
||||
assert check_at(dc, "17:00") is True # now == end
|
||||
assert check_at(dc, "08:59:59") is False
|
||||
assert check_at(dc, "09:00") is True # now == start
|
||||
assert check_at(dc, "16:59:59") is True
|
||||
assert check_at(dc, "17:00") is False # now == end
|
||||
|
||||
def test_midnight_crossing_window(self):
|
||||
# 21:00 -> 07:00: active late evening AND early morning, inactive
|
||||
@@ -110,8 +115,11 @@ class TestScheduleWindows:
|
||||
assert check_at(dc, "23:00") is True
|
||||
assert check_at(dc, "03:00") is True
|
||||
assert check_at(dc, "12:00") is False
|
||||
assert check_at(dc, "21:00") is True # boundary
|
||||
assert check_at(dc, "07:00") is True # boundary
|
||||
assert check_at(dc, "20:59:59") is False
|
||||
assert check_at(dc, "21:00") is True # start
|
||||
assert check_at(dc, "00:00") is True # midnight itself
|
||||
assert check_at(dc, "06:59:59") is True
|
||||
assert check_at(dc, "07:00") is False # end
|
||||
|
||||
def test_no_schedule_config_is_always_active(self):
|
||||
dc = make_controller({"timezone": "UTC"})
|
||||
@@ -296,3 +304,185 @@ class TestDimSchedule:
|
||||
assert dc._was_dimmed is True
|
||||
dim_at(dc, "12:00")
|
||||
assert dc._was_dimmed is False
|
||||
|
||||
|
||||
def check_in_same_minute(dc, time_str, day="monday"):
|
||||
"""Run _check_schedule WITHOUT resetting the minute gate, as the loop does."""
|
||||
p = at(time_str, day)
|
||||
try:
|
||||
dc._check_schedule()
|
||||
finally:
|
||||
p.stop()
|
||||
return dc.is_display_active
|
||||
|
||||
|
||||
class TestEndMinuteBoundary:
|
||||
"""The panel goes off at the end minute whichever second the check runs.
|
||||
|
||||
The loop evaluates the schedule once per clock minute, on the first check
|
||||
in it. With a closed [start, end] window only a check at hh:mm:00.000 saw
|
||||
the end minute as inside, so the panel went off at the start or the end
|
||||
of that minute depending on timing.
|
||||
"""
|
||||
|
||||
WINDOWS = {
|
||||
"same_day": ({"start_time": "09:00", "end_time": "17:00"},
|
||||
"monday", "16:59", "17:00"),
|
||||
"midnight_crossing": ({"start_time": "22:00", "end_time": "07:00"},
|
||||
"monday", "06:59", "07:00"),
|
||||
"per_day_midnight_crossing": (
|
||||
{"mode": "per-day", "start_time": "09:00", "end_time": "17:00",
|
||||
"days": {"wednesday": {"enabled": True, "start_time": "22:00",
|
||||
"end_time": "07:00"}}},
|
||||
"wednesday", "06:59", "07:00"),
|
||||
}
|
||||
|
||||
def _controller(self, window):
|
||||
return make_controller({"schedule": {"enabled": True, **window},
|
||||
"timezone": "UTC"})
|
||||
|
||||
@pytest.mark.parametrize("name", sorted(WINDOWS))
|
||||
@pytest.mark.parametrize("second", ["00", "59"])
|
||||
def test_off_for_the_whole_end_minute(self, name, second):
|
||||
window, day, last_on, end = self.WINDOWS[name]
|
||||
dc = self._controller(window)
|
||||
assert check_at(dc, f"{last_on}:59", day) is True
|
||||
# First check of the end minute, at :00 or at :59.
|
||||
assert check_in_same_minute(dc, f"{end}:{second}", day) is False
|
||||
|
||||
@pytest.mark.parametrize("name", sorted(WINDOWS))
|
||||
def test_gated_minute_keeps_the_off_answer(self, name):
|
||||
window, day, last_on, end = self.WINDOWS[name]
|
||||
dc = self._controller(window)
|
||||
assert check_at(dc, f"{last_on}:30", day) is True
|
||||
assert check_in_same_minute(dc, f"{end}:00", day) is False
|
||||
assert check_in_same_minute(dc, f"{end}:59", day) is False
|
||||
|
||||
@pytest.mark.parametrize("second", ["00", "59"])
|
||||
def test_on_for_the_whole_start_minute(self, second):
|
||||
dc = self._controller({"start_time": "22:00", "end_time": "07:00"})
|
||||
assert check_at(dc, "21:59:59") is False
|
||||
assert check_in_same_minute(dc, f"22:00:{second}") is True
|
||||
|
||||
|
||||
class TestOnDemandEndsDuringScheduledOff:
|
||||
"""An on-demand session ending in off hours blanks the panel at once,
|
||||
not when the once-a-minute schedule check next runs."""
|
||||
|
||||
def _controller(self):
|
||||
dc = make_controller({"schedule": {"enabled": True,
|
||||
"start_time": "07:00",
|
||||
"end_time": "23:00"},
|
||||
"timezone": "UTC"})
|
||||
dc.on_demand_active = False
|
||||
dc.on_demand_schedule_override = False
|
||||
return dc
|
||||
|
||||
def _evaluate(self, dc, time_str):
|
||||
p = at(time_str)
|
||||
try:
|
||||
dc._evaluate_schedule()
|
||||
finally:
|
||||
p.stop()
|
||||
return dc.is_display_active
|
||||
|
||||
def test_session_end_in_off_hours_blanks_within_the_minute(self):
|
||||
dc = self._controller()
|
||||
assert self._evaluate(dc, "23:30:05") is False
|
||||
dc.on_demand_active = True
|
||||
assert self._evaluate(dc, "23:30:10") is True # override
|
||||
assert dc.on_demand_schedule_override is True
|
||||
dc._reset_on_demand_fields() # expired or stopped
|
||||
assert self._evaluate(dc, "23:30:40") is False # same minute
|
||||
assert dc.on_demand_schedule_override is False
|
||||
|
||||
def test_session_end_in_on_hours_stays_on(self):
|
||||
dc = self._controller()
|
||||
assert self._evaluate(dc, "12:00:05") is True
|
||||
dc.on_demand_active = True
|
||||
assert self._evaluate(dc, "12:00:10") is True
|
||||
dc._reset_on_demand_fields()
|
||||
assert self._evaluate(dc, "12:00:40") is True
|
||||
|
||||
|
||||
class TestOnDemandEndsDuringScheduledOffRunLoop:
|
||||
"""The same through the real run() loop (test/_run_loop_harness.py).
|
||||
|
||||
The harness clock starts at 22:59:30; the schedule below is off from
|
||||
23:01 (t=90) until 23:05 (t=330). The sessions end mid-minute, so the
|
||||
old behaviour (on until the next minute) would show as a gap."""
|
||||
|
||||
def _harness(self, tmp_path):
|
||||
from test._run_loop_harness import FakePlugin, RunLoopHarness
|
||||
h = RunLoopHarness(tmp_path, horizon=260)
|
||||
h.config["schedule"] = {"enabled": True, "start_time": "23:05",
|
||||
"end_time": "23:01"}
|
||||
h.add_plugin(FakePlugin("clock", ["clock"], duration=20))
|
||||
h.add_plugin(FakePlugin("weather", ["weather"], duration=20))
|
||||
return h
|
||||
|
||||
@staticmethod
|
||||
def _first_off_after(trace, t):
|
||||
return [row for row in trace["screens"]
|
||||
if row[1] == "<off>" and row[0] >= t][0]
|
||||
|
||||
def test_expiry_blanks_at_once(self, tmp_path):
|
||||
h = self._harness(tmp_path)
|
||||
# 15 s from t=170 ends at t=185, 23:02:35.
|
||||
h.on_demand_request(170, "x1", plugin_id="weather", duration=15)
|
||||
trace = h.run()
|
||||
session = [r for r in trace["screens"] if r[0] == 170.0][0]
|
||||
assert session[1:4] == ["weather", 15.0, "on-demand-expired"]
|
||||
assert self._first_off_after(trace, 170)[0] == 185.0
|
||||
|
||||
def test_stop_blanks_at_once(self, tmp_path):
|
||||
h = self._harness(tmp_path)
|
||||
h.on_demand_request(170, "x1", plugin_id="weather")
|
||||
h.on_demand_request(181, "x2", action="stop") # 23:02:31
|
||||
trace = h.run()
|
||||
assert 181.0 <= self._first_off_after(trace, 170)[0] <= 182.0
|
||||
|
||||
|
||||
class TestDimBoundaries:
|
||||
"""The dim schedule shares _in_window, so it is half-open too."""
|
||||
|
||||
def _config(self, start, end, **extra):
|
||||
return {"dim_schedule": {"enabled": True, "start_time": start,
|
||||
"end_time": end, "dim_brightness": 25,
|
||||
**extra},
|
||||
"timezone": "UTC"}
|
||||
|
||||
def test_same_day_dim_window_is_half_open(self):
|
||||
dc = make_controller(self._config("13:00", "14:00"))
|
||||
assert dim_at(dc, "12:59:59") == 90
|
||||
assert dim_at(dc, "13:00") == 25
|
||||
assert dim_at(dc, "13:59:59") == 25
|
||||
assert dim_at(dc, "14:00:00") == 90
|
||||
assert dim_at(dc, "14:00:59") == 90
|
||||
|
||||
def test_midnight_crossing_dim_window(self):
|
||||
dc = make_controller(self._config("20:00", "07:00"))
|
||||
assert dim_at(dc, "19:59:59") == 90
|
||||
assert dim_at(dc, "20:00") == 25
|
||||
assert dim_at(dc, "00:00") == 25
|
||||
assert dim_at(dc, "06:59:59") == 25
|
||||
assert dim_at(dc, "07:00:00") == 90
|
||||
assert dim_at(dc, "07:00:59") == 90
|
||||
|
||||
def test_per_day_dim_end_minute(self):
|
||||
dc = make_controller(self._config("20:00", "07:00", mode="per-day", days={
|
||||
"friday": {"enabled": True, "start_time": "23:00",
|
||||
"end_time": "06:00"},
|
||||
}))
|
||||
assert dim_at(dc, "05:59:59", day="friday") == 25
|
||||
assert dim_at(dc, "06:00:00", day="friday") == 90
|
||||
assert dim_at(dc, "06:00:59", day="friday") == 90
|
||||
|
||||
def test_dim_end_minute_checked_late_in_the_minute(self):
|
||||
dc = make_controller(self._config("20:00", "07:00"))
|
||||
assert dim_at(dc, "06:59:30") == 25
|
||||
p = at("07:00:59") # first check of the end minute; gate not reset
|
||||
try:
|
||||
assert dc._check_dim_schedule() == 90
|
||||
finally:
|
||||
p.stop()
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
"""A plugin whose display() raises must count as a circuit-breaker failure.
|
||||
|
||||
The first frame of every screen goes through PluginExecutor.execute_display,
|
||||
which catches whatever display() raises and reports False. run() read that
|
||||
False as "no content" and called record_success() on it, so a plugin that
|
||||
raised on every screen reset its own failure streak each time and the breaker
|
||||
never opened. It stayed in rotation, logging a traceback per screen, forever.
|
||||
|
||||
These tests drive the real run() on a fake clock with the real executor and
|
||||
the real health tracker.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import threading
|
||||
import types
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.exceptions import PluginError
|
||||
from src.plugin_system.plugin_executor import PluginExecutor
|
||||
from src.plugin_system.plugin_health import CircuitState, PluginHealthTracker
|
||||
|
||||
THRESHOLD = 3
|
||||
COOLDOWN = 300.0
|
||||
SCREEN_SECONDS = 10
|
||||
|
||||
|
||||
class FakeClock:
|
||||
"""Moves only when the code under test sleeps; runs events as it passes them."""
|
||||
|
||||
def __init__(self, start=10_000.0):
|
||||
self.t = start
|
||||
self._events = []
|
||||
|
||||
def now(self):
|
||||
return self.t
|
||||
|
||||
def sleep(self, seconds):
|
||||
self.t += max(seconds, 0.0005)
|
||||
while self._events and self._events[0][0] <= self.t:
|
||||
_, fn = self._events.pop(0)
|
||||
fn()
|
||||
|
||||
def after(self, seconds, fn):
|
||||
self._events.append((self.t + seconds, fn))
|
||||
self._events.sort(key=lambda e: e[0])
|
||||
|
||||
def module(self):
|
||||
return types.SimpleNamespace(time=self.now, monotonic=self.now,
|
||||
perf_counter=self.now, sleep=self.sleep)
|
||||
|
||||
|
||||
class _Stop(KeyboardInterrupt):
|
||||
"""Ends run(): it catches KeyboardInterrupt and cleans up."""
|
||||
|
||||
|
||||
class _Cache:
|
||||
def __init__(self):
|
||||
self.store = {}
|
||||
|
||||
def set(self, key, data, ttl=None, **kwargs):
|
||||
self.store[key] = copy.deepcopy(data)
|
||||
|
||||
def get(self, key, max_age=None, memory_ttl=None, **kwargs):
|
||||
return copy.deepcopy(self.store.get(key))
|
||||
|
||||
|
||||
class _Plugin:
|
||||
"""A static plugin. ``outcomes`` scripts each screen's first frame in
|
||||
turn: True/False is returned, an exception instance is raised; once the
|
||||
script runs out it returns True. Later frames of a screen return True.
|
||||
|
||||
A screen's first frame is the one PluginExecutor dispatches, on its own
|
||||
thread; the render loop's later frames run on the calling thread.
|
||||
"""
|
||||
|
||||
needs_high_fps = False
|
||||
|
||||
def __init__(self, plugin_id, clock, outcomes=()):
|
||||
self.plugin_id = plugin_id
|
||||
self._clock = clock
|
||||
self._outcomes = list(outcomes)
|
||||
self._caller = threading.current_thread()
|
||||
self.first_frames = [] # (time, outcome) of each executor dispatch
|
||||
self.calls = 0
|
||||
|
||||
def display(self, force_clear=False):
|
||||
if threading.current_thread() is self._caller:
|
||||
return True # a later frame of a screen that started fine
|
||||
self.calls += 1
|
||||
outcome = self._outcomes.pop(0) if self._outcomes else True
|
||||
self.first_frames.append((self._clock.t, outcome))
|
||||
if isinstance(outcome, BaseException):
|
||||
raise outcome
|
||||
return outcome
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clock(monkeypatch):
|
||||
c = FakeClock()
|
||||
fake_time = c.module()
|
||||
monkeypatch.setattr('src.display_controller.time', fake_time)
|
||||
# The breaker's cooldown is wall-clock; put it on the same clock.
|
||||
monkeypatch.setattr('src.plugin_system.plugin_health.time', fake_time)
|
||||
return c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tracker():
|
||||
return PluginHealthTracker(_Cache(), failure_threshold=THRESHOLD,
|
||||
cooldown_period=COOLDOWN)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def controller(test_display_controller, clock, tracker):
|
||||
c = test_display_controller
|
||||
c._refresh_config_cache({'display': {'hardware': {'brightness': 90}}})
|
||||
c.current_brightness = 90
|
||||
c.is_display_active = True
|
||||
c._check_wifi_status_message = MagicMock(return_value=None)
|
||||
c._cleanup_expired_wifi_status = MagicMock()
|
||||
c.cache_manager.get = MagicMock(return_value=None)
|
||||
c.cache_manager.set = MagicMock()
|
||||
c.cache_manager.delete = MagicMock()
|
||||
c.display_manager.set_brightness = MagicMock(return_value=True)
|
||||
c.display_manager.update_display = MagicMock()
|
||||
|
||||
pm = c.plugin_manager
|
||||
# The real executor: its exception handling is what is under test.
|
||||
pm.plugin_executor = PluginExecutor(default_timeout=5.0)
|
||||
pm.health_tracker = tracker
|
||||
locks = {}
|
||||
pm.get_plugin_lock = lambda pid: locks.setdefault(pid, threading.Lock())
|
||||
pm.record_display_hang = MagicMock()
|
||||
pm.note_display_duration = MagicMock()
|
||||
return c
|
||||
|
||||
|
||||
def _install(c, *plugins):
|
||||
c.plugin_modes.clear()
|
||||
c.mode_to_plugin_id.clear()
|
||||
c.plugin_display_modes.clear()
|
||||
for plugin in plugins:
|
||||
c.plugin_modes[plugin.plugin_id] = plugin
|
||||
c.mode_to_plugin_id[plugin.plugin_id] = plugin.plugin_id
|
||||
c.plugin_display_modes[plugin.plugin_id] = [plugin.plugin_id]
|
||||
c.available_modes = [p.plugin_id for p in plugins]
|
||||
c.current_mode_index = 0
|
||||
c.current_display_mode = c.available_modes[0]
|
||||
c.config.setdefault('display', {})['display_durations'] = {
|
||||
p.plugin_id: SCREEN_SECONDS for p in plugins}
|
||||
|
||||
|
||||
def _run_for(c, clock, seconds):
|
||||
def stop():
|
||||
raise _Stop()
|
||||
clock.after(seconds, stop)
|
||||
c.run()
|
||||
|
||||
|
||||
def _boom():
|
||||
return RuntimeError("display() failed")
|
||||
|
||||
|
||||
class TestRaisingDisplayOpensTheBreaker:
|
||||
def test_opens_at_the_threshold_and_leaves_rotation(self, controller, clock, tracker):
|
||||
c = controller
|
||||
crashy = _Plugin('crashy', clock, [_boom() for _ in range(100)])
|
||||
good = _Plugin('good', clock)
|
||||
_install(c, good, crashy)
|
||||
|
||||
_run_for(c, clock, 200)
|
||||
|
||||
state = tracker.get_health_state('crashy')
|
||||
assert state['circuit_state'] == CircuitState.OPEN.value
|
||||
assert state['consecutive_failures'] == THRESHOLD
|
||||
assert state['last_error'].endswith("display() failed")
|
||||
# Exactly THRESHOLD raises reached display(); the open breaker kept
|
||||
# it out of every later pass, well inside the cooldown.
|
||||
assert crashy.calls == THRESHOLD
|
||||
# The display kept moving: the healthy plugin went on being shown.
|
||||
assert len(good.first_frames) > THRESHOLD + 2
|
||||
assert tracker.get_health_state('good')['consecutive_failures'] == 0
|
||||
|
||||
def test_back_in_rotation_after_the_cooldown(self, controller, clock, tracker):
|
||||
c = controller
|
||||
crashy = _Plugin('crashy', clock, [_boom() for _ in range(THRESHOLD)])
|
||||
good = _Plugin('good', clock)
|
||||
_install(c, good, crashy)
|
||||
|
||||
_run_for(c, clock, COOLDOWN + 100)
|
||||
|
||||
# Half-open after the cooldown, one attempt succeeded, circuit closed.
|
||||
assert crashy.calls > THRESHOLD
|
||||
state = tracker.get_health_state('crashy')
|
||||
assert state['circuit_state'] == CircuitState.CLOSED.value
|
||||
assert state['consecutive_failures'] == 0
|
||||
opened_at = crashy.first_frames[THRESHOLD - 1][0]
|
||||
retried_at = crashy.first_frames[THRESHOLD][0]
|
||||
assert retried_at - opened_at >= COOLDOWN
|
||||
|
||||
def test_one_success_resets_the_streak(self, controller, clock, tracker):
|
||||
c = controller
|
||||
script = [_boom(), _boom(), True, _boom(), _boom(), True]
|
||||
flaky = _Plugin('flaky', clock, script)
|
||||
good = _Plugin('good', clock)
|
||||
_install(c, good, flaky)
|
||||
|
||||
_run_for(c, clock, 6 * 2 * SCREEN_SECONDS + 5)
|
||||
|
||||
assert flaky.calls >= len(script)
|
||||
assert [o if o is True else 'raised' for _, o in flaky.first_frames[:6]] == [
|
||||
'raised', 'raised', True, 'raised', 'raised', True]
|
||||
state = tracker.get_health_state('flaky')
|
||||
assert state['circuit_state'] == CircuitState.CLOSED.value
|
||||
assert state['consecutive_failures'] == 0
|
||||
assert state['total_failures'] == 4
|
||||
|
||||
def test_no_content_is_still_not_a_failure(self, controller, clock, tracker):
|
||||
c = controller
|
||||
empty = _Plugin('empty', clock, [False] * 100)
|
||||
good = _Plugin('good', clock)
|
||||
_install(c, good, empty)
|
||||
|
||||
_run_for(c, clock, 200)
|
||||
|
||||
state = tracker.get_health_state('empty')
|
||||
assert state['circuit_state'] == CircuitState.CLOSED.value
|
||||
assert state.get('total_failures', 0) == 0
|
||||
assert empty.calls > THRESHOLD
|
||||
|
||||
|
||||
class TestHangIsNotCountedTwice:
|
||||
def test_a_timed_out_display_records_only_the_hang(self, controller, clock, tracker):
|
||||
c = controller
|
||||
c.plugin_manager.plugin_executor = PluginExecutor(default_timeout=0.05)
|
||||
release = threading.Event()
|
||||
|
||||
class _Hung(_Plugin):
|
||||
def display(self, force_clear=False):
|
||||
self.calls += 1
|
||||
release.wait(2.0) # real time: outlives the executor's timeout
|
||||
return True
|
||||
|
||||
hung = _Hung('hung', clock)
|
||||
good = _Plugin('good', clock)
|
||||
_install(c, hung, good)
|
||||
failures = []
|
||||
real_record_failure = tracker.record_failure
|
||||
tracker.record_failure = lambda pid, err=None: (
|
||||
failures.append(pid), real_record_failure(pid, err))
|
||||
try:
|
||||
_run_for(c, clock, SCREEN_SECONDS - 1)
|
||||
finally:
|
||||
release.set()
|
||||
|
||||
c.plugin_manager.record_display_hang.assert_called_once()
|
||||
assert c.plugin_manager.record_display_hang.call_args.args[0] == 'hung'
|
||||
# The hang path records the failure (PluginManager._record_hang); the
|
||||
# dispatch adds neither a failure nor a success on top.
|
||||
assert failures == []
|
||||
assert tracker.get_health_state('hung').get('total_successes', 0) == 0
|
||||
|
||||
|
||||
class TestExecutorRaiseErrors:
|
||||
def _plugin(self, display):
|
||||
return types.SimpleNamespace(display=display)
|
||||
|
||||
def test_default_still_returns_false(self):
|
||||
def display(force_clear=False):
|
||||
raise ValueError("bad")
|
||||
assert PluginExecutor().execute_display(
|
||||
self._plugin(display), 'p', accepts_display_mode=False) is False
|
||||
|
||||
def test_raise_errors_surfaces_the_plugin_error(self):
|
||||
def display(force_clear=False):
|
||||
raise ValueError("bad")
|
||||
with pytest.raises(PluginError) as info:
|
||||
PluginExecutor().execute_display(
|
||||
self._plugin(display), 'p', accepts_display_mode=False,
|
||||
raise_errors=True)
|
||||
assert isinstance(info.value.__cause__, ValueError)
|
||||
|
||||
def test_raise_errors_leaves_a_timeout_as_false(self):
|
||||
done = threading.Event()
|
||||
|
||||
def display(force_clear=False):
|
||||
done.wait(1.0)
|
||||
return True
|
||||
try:
|
||||
assert PluginExecutor(default_timeout=0.05).execute_display(
|
||||
self._plugin(display), 'p', accepts_display_mode=False,
|
||||
raise_errors=True) is False
|
||||
finally:
|
||||
done.set()
|
||||
|
||||
def test_raise_errors_passes_results_through(self):
|
||||
executor = PluginExecutor()
|
||||
for value, expected in ((True, True), (False, False), (None, True)):
|
||||
assert executor.execute_display(
|
||||
self._plugin(lambda force_clear=False, v=value: v), 'p',
|
||||
accepts_display_mode=False, raise_errors=True) is expected
|
||||
@@ -477,7 +477,7 @@ class TestRunLoopBlanksWhenVegasHandsBack:
|
||||
c._cleanup_expired_wifi_status = MagicMock()
|
||||
c._refresh_config_cache({
|
||||
'display': {'hardware': {'brightness': 90}},
|
||||
'schedule': {'enabled': True, 'start_time': '07:00', 'end_time': '22:59'},
|
||||
'schedule': {'enabled': True, 'start_time': '07:00', 'end_time': '23:00'},
|
||||
})
|
||||
c.vegas_coordinator = vegas_coordinator(c)
|
||||
c.vegas_coordinator._pending_config_update = False
|
||||
@@ -518,7 +518,7 @@ class TestRunLoopBlanksWhenVegasHandsBack:
|
||||
c._refresh_config_cache({
|
||||
'display': {'hardware': {'brightness': 90},
|
||||
'display_durations': {'ticker': 120}},
|
||||
'schedule': {'enabled': True, 'start_time': '07:00', 'end_time': '22:59'},
|
||||
'schedule': {'enabled': True, 'start_time': '07:00', 'end_time': '23:00'},
|
||||
})
|
||||
c.plugin_manager.plugin_executor.execute_display.side_effect = (
|
||||
lambda target, plugin_id, force_clear=False, display_mode=None, **kw:
|
||||
|
||||
@@ -0,0 +1,878 @@
|
||||
"""The core fetch service: merging, host budgets, conditional GET, counters.
|
||||
|
||||
No network: every request goes to a fake transport -- a real
|
||||
``requests.Session`` subclass whose ``get`` answers from a handler -- so the
|
||||
service sees real ``requests.Response`` objects, real header merging and real
|
||||
adapters, and nothing leaves the machine. Clocks and sleeps are injected.
|
||||
|
||||
What callers already rely on (return values, exceptions, retries) is pinned
|
||||
by the existing suites, which run unchanged through the service:
|
||||
test_api_helper.py, test_background_data_service*.py,
|
||||
test_background_fetch_dedupe.py, test_base_odds_manager.py,
|
||||
test_odds_request_budget.py, test_espn_dates.py and test_sports_fetch.py.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from requests.structures import CaseInsensitiveDict
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
from src.common import fetch_service as fs
|
||||
from src.common.fetch_service import (
|
||||
FetchService,
|
||||
FetchStatsPublisher,
|
||||
TokenBucket,
|
||||
current_plugin_id,
|
||||
plugin_scope,
|
||||
read_fetch_stats,
|
||||
register_plugin_directory,
|
||||
unregister_plugin_directory,
|
||||
)
|
||||
|
||||
|
||||
# --- fakes -----------------------------------------------------------------------
|
||||
|
||||
class FakeClock:
|
||||
def __init__(self, start=1000.0):
|
||||
self.t = start
|
||||
self.sleeps = []
|
||||
|
||||
def now(self):
|
||||
return self.t
|
||||
|
||||
def sleep(self, seconds):
|
||||
self.sleeps.append(seconds)
|
||||
self.t += seconds
|
||||
|
||||
def advance(self, seconds):
|
||||
self.t += seconds
|
||||
|
||||
|
||||
def make_response(status=200, body=b'{"ok": 1}', headers=None, url="https://api.test/x"):
|
||||
response = requests.Response()
|
||||
response.status_code = status
|
||||
response._content = body
|
||||
response.headers = CaseInsensitiveDict(headers or {})
|
||||
response.url = url
|
||||
response.encoding = "utf-8"
|
||||
response.reason = "OK" if status < 400 else "Error"
|
||||
return response
|
||||
|
||||
|
||||
class FakeSession(requests.Session):
|
||||
"""A Session whose get() answers from ``handler(url, kwargs)``."""
|
||||
|
||||
def __init__(self, handler=None, gate=None):
|
||||
super().__init__()
|
||||
self.handler = handler or (lambda url, kwargs: make_response(url=url))
|
||||
self.gate = gate
|
||||
self.calls = []
|
||||
self.started = threading.Event()
|
||||
self._calls_lock = threading.Lock()
|
||||
|
||||
def get(self, url, **kwargs):
|
||||
with self._calls_lock:
|
||||
self.calls.append((url, kwargs))
|
||||
self.started.set()
|
||||
if self.gate is not None:
|
||||
assert self.gate.wait(5), "test gate never opened"
|
||||
return self.handler(url, kwargs)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clock():
|
||||
return FakeClock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service(clock):
|
||||
return FetchService({"rate_limits": {}}, clock=clock.now, sleep=clock.sleep)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def global_service(monkeypatch, clock):
|
||||
"""A fresh process-wide service, for code that calls fetch_get()."""
|
||||
svc = FetchService({"rate_limits": {}}, clock=clock.now, sleep=clock.sleep)
|
||||
monkeypatch.setattr(fs, "_service", svc)
|
||||
return svc
|
||||
|
||||
|
||||
def _counters(svc, plugin=None, host=None):
|
||||
snap = svc.snapshot()
|
||||
if plugin is not None:
|
||||
return snap["plugins"].get(plugin, {})
|
||||
if host is not None:
|
||||
return snap["hosts"].get(host, {})
|
||||
return snap["totals"]
|
||||
|
||||
|
||||
# --- the call itself is unchanged -----------------------------------------------------
|
||||
|
||||
class TestPassThrough:
|
||||
|
||||
def test_session_get_sees_exactly_the_callers_arguments(self, service):
|
||||
session = FakeSession()
|
||||
response = service.get(session, "https://api.test/x", params={"a": 1},
|
||||
headers={"X-Y": "z"}, timeout=7)
|
||||
assert session.calls == [("https://api.test/x",
|
||||
{"params": {"a": 1}, "headers": {"X-Y": "z"}, "timeout": 7})]
|
||||
assert response.json() == {"ok": 1}
|
||||
|
||||
def test_no_kwargs_the_caller_did_not_pass(self, service):
|
||||
session = FakeSession()
|
||||
service.get(session, "https://api.test/x", timeout=5)
|
||||
assert session.calls[0][1] == {"timeout": 5}
|
||||
|
||||
def test_the_transport_exception_reaches_the_caller_unchanged(self, service):
|
||||
boom = requests.ConnectionError("down")
|
||||
|
||||
def handler(url, kwargs):
|
||||
raise boom
|
||||
|
||||
with pytest.raises(requests.ConnectionError) as caught:
|
||||
service.get(FakeSession(handler), "https://api.test/x")
|
||||
assert caught.value is boom
|
||||
|
||||
def test_an_http_error_response_is_returned_not_raised(self, service):
|
||||
session = FakeSession(lambda url, kw: make_response(503, b"busy"))
|
||||
response = service.get(session, "https://api.test/x")
|
||||
assert response.status_code == 503
|
||||
with pytest.raises(requests.HTTPError):
|
||||
response.raise_for_status()
|
||||
|
||||
def test_disabled_is_a_plain_session_get(self, clock):
|
||||
svc = FetchService({"enabled": False, "rate_limits": {"api.test": {"per_second": 1, "burst": 1}}},
|
||||
clock=clock.now, sleep=clock.sleep)
|
||||
session = FakeSession()
|
||||
for _ in range(3):
|
||||
svc.get(session, "https://api.test/x")
|
||||
assert len(session.calls) == 3
|
||||
assert clock.sleeps == []
|
||||
assert _counters(svc)["requests"] == 0
|
||||
|
||||
def test_a_test_double_session_still_works(self, service):
|
||||
from unittest.mock import MagicMock
|
||||
session = MagicMock()
|
||||
session.get.return_value.json.return_value = {"a": 1}
|
||||
assert service.get(session, "https://api.test/x", timeout=3).json() == {"a": 1}
|
||||
session.get.assert_called_once_with("https://api.test/x", timeout=3)
|
||||
|
||||
def test_session_none_uses_the_pooled_session_for_the_host(self, service, monkeypatch):
|
||||
seen = []
|
||||
monkeypatch.setattr(requests.Session, "get",
|
||||
lambda self, url, **kw: seen.append(self) or make_response())
|
||||
service.get(None, "https://a.test/1")
|
||||
service.get(None, "https://a.test/2")
|
||||
service.get(None, "https://b.test/1")
|
||||
assert seen[0] is seen[1] is service.session_for("https://a.test/")
|
||||
assert seen[2] is not seen[0]
|
||||
|
||||
|
||||
# --- single-flight ------------------------------------------------------------------------
|
||||
|
||||
def _wait_for_waiters(svc, count, timeout=5):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
with svc._lock:
|
||||
flights = list(svc._inflight.values())
|
||||
if flights and flights[0].waiters >= count:
|
||||
return
|
||||
time.sleep(0.005)
|
||||
raise AssertionError(f"only {flights[0].waiters if flights else 0} of {count} callers joined")
|
||||
|
||||
|
||||
class TestSingleFlight:
|
||||
|
||||
def test_concurrent_identical_gets_go_out_once(self, service):
|
||||
gate = threading.Event()
|
||||
session = FakeSession(gate=gate)
|
||||
results = []
|
||||
|
||||
def call():
|
||||
results.append(service.get(session, "https://api.test/x",
|
||||
params={"d": "1"}, timeout=5))
|
||||
|
||||
threads = [threading.Thread(target=call) for _ in range(5)]
|
||||
threads[0].start()
|
||||
assert session.started.wait(5)
|
||||
for t in threads[1:]:
|
||||
t.start()
|
||||
_wait_for_waiters(service, 4)
|
||||
gate.set()
|
||||
for t in threads:
|
||||
t.join(5)
|
||||
|
||||
assert len(session.calls) == 1
|
||||
assert len(results) == 5
|
||||
assert all(r.json() == {"ok": 1} for r in results)
|
||||
# Each caller gets its own Response object to mutate.
|
||||
assert len({id(r) for r in results}) == 5
|
||||
totals = _counters(service)
|
||||
assert totals["requests"] == 1
|
||||
assert totals["merged"] == 4
|
||||
|
||||
def test_merged_callers_get_the_leaders_exception(self, service):
|
||||
gate = threading.Event()
|
||||
|
||||
def handler(url, kwargs):
|
||||
raise requests.Timeout("slow")
|
||||
|
||||
session = FakeSession(handler, gate=gate)
|
||||
errors = []
|
||||
|
||||
def call():
|
||||
try:
|
||||
service.get(session, "https://api.test/x", timeout=5)
|
||||
except requests.Timeout as err:
|
||||
errors.append(err)
|
||||
|
||||
threads = [threading.Thread(target=call) for _ in range(3)]
|
||||
threads[0].start()
|
||||
assert session.started.wait(5)
|
||||
for t in threads[1:]:
|
||||
t.start()
|
||||
_wait_for_waiters(service, 2)
|
||||
gate.set()
|
||||
for t in threads:
|
||||
t.join(5)
|
||||
|
||||
assert len(session.calls) == 1
|
||||
assert len(errors) == 3
|
||||
totals = _counters(service)
|
||||
assert totals["errors"] == 1 and totals["merged"] == 2
|
||||
|
||||
@pytest.mark.parametrize("second", [
|
||||
{"params": {"d": "2"}}, # another query
|
||||
{"params": {"d": "1"}, "timeout": 9}, # another timeout
|
||||
{"params": {"d": "1"}, "headers": {"Accept": "text/plain"}}, # another representation
|
||||
])
|
||||
def test_requests_that_could_answer_differently_are_not_merged(self, service, second):
|
||||
gate = threading.Event()
|
||||
session = FakeSession(gate=gate)
|
||||
first = threading.Thread(target=lambda: service.get(
|
||||
session, "https://api.test/x", params={"d": "1"}, timeout=5))
|
||||
first.start()
|
||||
assert session.started.wait(5)
|
||||
other = threading.Thread(target=lambda: service.get(
|
||||
session, "https://api.test/x", **{"timeout": 5, **second}))
|
||||
other.start()
|
||||
deadline = time.monotonic() + 5
|
||||
while len(session.calls) < 2 and time.monotonic() < deadline:
|
||||
time.sleep(0.005)
|
||||
gate.set()
|
||||
first.join(5)
|
||||
other.join(5)
|
||||
assert len(session.calls) == 2
|
||||
assert _counters(service)["merged"] == 0
|
||||
|
||||
def test_different_retry_policies_are_not_merged(self, service):
|
||||
gate = threading.Event()
|
||||
retrying = FakeSession(gate=gate)
|
||||
retrying.mount("https://", requests.adapters.HTTPAdapter(max_retries=Retry(total=3)))
|
||||
plain = FakeSession(gate=gate)
|
||||
a = threading.Thread(target=lambda: service.get(retrying, "https://api.test/x"))
|
||||
a.start()
|
||||
assert retrying.started.wait(5)
|
||||
b = threading.Thread(target=lambda: service.get(plain, "https://api.test/x"))
|
||||
b.start()
|
||||
assert plain.started.wait(5)
|
||||
gate.set()
|
||||
a.join(5)
|
||||
b.join(5)
|
||||
assert len(retrying.calls) == len(plain.calls) == 1
|
||||
|
||||
def test_sessions_with_the_same_policy_and_headers_share_a_flight(self, service):
|
||||
gate = threading.Event()
|
||||
one, two = FakeSession(gate=gate), FakeSession(gate=gate)
|
||||
a = threading.Thread(target=lambda: service.get(one, "https://api.test/x", timeout=5))
|
||||
a.start()
|
||||
assert one.started.wait(5)
|
||||
b = threading.Thread(target=lambda: service.get(two, "https://api.test/x", timeout=5))
|
||||
b.start()
|
||||
_wait_for_waiters(service, 1)
|
||||
gate.set()
|
||||
a.join(5)
|
||||
b.join(5)
|
||||
assert len(one.calls) == 1 and two.calls == []
|
||||
|
||||
def test_a_session_with_cookies_only_merges_with_itself(self, service):
|
||||
gate = threading.Event()
|
||||
cookied, plain = FakeSession(gate=gate), FakeSession(gate=gate)
|
||||
cookied.cookies.set("sid", "secret")
|
||||
a = threading.Thread(target=lambda: service.get(cookied, "https://api.test/x"))
|
||||
a.start()
|
||||
assert cookied.started.wait(5)
|
||||
b = threading.Thread(target=lambda: service.get(plain, "https://api.test/x"))
|
||||
b.start()
|
||||
assert plain.started.wait(5)
|
||||
gate.set()
|
||||
a.join(5)
|
||||
b.join(5)
|
||||
assert len(cookied.calls) == len(plain.calls) == 1
|
||||
|
||||
def test_sequential_identical_gets_each_go_out(self, service):
|
||||
session = FakeSession()
|
||||
service.get(session, "https://api.test/x")
|
||||
service.get(session, "https://api.test/x")
|
||||
assert len(session.calls) == 2
|
||||
|
||||
def test_streamed_requests_are_never_merged(self, service):
|
||||
gate = threading.Event()
|
||||
session = FakeSession(gate=gate)
|
||||
a = threading.Thread(target=lambda: service.get(session, "https://api.test/x", stream=True))
|
||||
a.start()
|
||||
assert session.started.wait(5)
|
||||
b = threading.Thread(target=lambda: service.get(session, "https://api.test/x", stream=True))
|
||||
b.start()
|
||||
deadline = time.monotonic() + 5
|
||||
while len(session.calls) < 2 and time.monotonic() < deadline:
|
||||
time.sleep(0.005)
|
||||
gate.set()
|
||||
a.join(5)
|
||||
b.join(5)
|
||||
assert len(session.calls) == 2
|
||||
|
||||
|
||||
# --- token buckets ---------------------------------------------------------------------------
|
||||
|
||||
class TestTokenBucket:
|
||||
|
||||
def test_burst_then_one_token_per_interval(self, clock):
|
||||
bucket = TokenBucket(per_second=2, burst=3, clock=clock.now)
|
||||
assert [bucket.reserve(10)[0] for _ in range(3)] == [0.0, 0.0, 0.0]
|
||||
assert bucket.reserve(10) == (0.5, False)
|
||||
assert bucket.reserve(10) == (1.0, False)
|
||||
|
||||
def test_tokens_refill_with_time_up_to_the_burst(self, clock):
|
||||
bucket = TokenBucket(per_second=2, burst=3, clock=clock.now)
|
||||
for _ in range(3):
|
||||
bucket.reserve(10)
|
||||
clock.advance(100)
|
||||
assert [bucket.reserve(10)[0] for _ in range(3)] == [0.0, 0.0, 0.0]
|
||||
assert bucket.reserve(10)[0] == 0.5
|
||||
|
||||
def test_a_wait_is_capped_at_max_wait(self, clock):
|
||||
bucket = TokenBucket(per_second=1, burst=1, clock=clock.now)
|
||||
bucket.reserve(0.5)
|
||||
assert bucket.reserve(0.5) == (0.5, True)
|
||||
# The debt never runs further than max_wait either.
|
||||
assert bucket.reserve(0.5) == (0.5, True)
|
||||
clock.advance(10)
|
||||
assert bucket.reserve(0.5) == (0.0, False)
|
||||
|
||||
|
||||
class TestHostBudgets:
|
||||
|
||||
def test_requests_past_the_budget_wait(self, clock):
|
||||
svc = FetchService({"rate_limits": {"api.test": {"per_second": 1, "burst": 2}},
|
||||
"max_wait_seconds": 10}, clock=clock.now, sleep=clock.sleep)
|
||||
session = FakeSession()
|
||||
for _ in range(4):
|
||||
svc.get(session, "https://api.test/x")
|
||||
assert clock.sleeps == [1.0, 1.0]
|
||||
host = _counters(svc, host="api.test")
|
||||
assert host["throttled"] == 2 and host["wait_seconds"] == 2.0
|
||||
assert host["requests"] == 4
|
||||
|
||||
def test_other_hosts_are_not_throttled(self, clock):
|
||||
svc = FetchService({"rate_limits": {"api.test": {"per_second": 1, "burst": 1}}},
|
||||
clock=clock.now, sleep=clock.sleep)
|
||||
session = FakeSession()
|
||||
for _ in range(5):
|
||||
svc.get(session, "https://elsewhere.test/x")
|
||||
assert clock.sleeps == []
|
||||
|
||||
def test_each_host_has_its_own_bucket(self, clock):
|
||||
svc = FetchService({"rate_limits": {"*.espn.com": {"per_second": 1, "burst": 1}},
|
||||
"max_wait_seconds": 10}, clock=clock.now, sleep=clock.sleep)
|
||||
session = FakeSession()
|
||||
svc.get(session, "https://site.api.espn.com/a")
|
||||
svc.get(session, "https://sports.core.api.espn.com/a")
|
||||
assert clock.sleeps == []
|
||||
svc.get(session, "https://site.api.espn.com/a")
|
||||
assert clock.sleeps == [1.0]
|
||||
|
||||
def test_wildcard_matches_the_bare_domain_and_subdomains_only(self, clock):
|
||||
svc = FetchService({"rate_limits": {"*.espn.com": {"per_second": 5, "burst": 9}}},
|
||||
clock=clock.now, sleep=clock.sleep)
|
||||
assert svc._limit_for("espn.com") == (5.0, 9.0)
|
||||
assert svc._limit_for("site.api.espn.com") == (5.0, 9.0)
|
||||
assert svc._limit_for("notespn.com") is None
|
||||
|
||||
def test_the_default_budget_covers_espn_and_a_cold_season_burst(self, clock):
|
||||
svc = FetchService(clock=clock.now, sleep=clock.sleep)
|
||||
session = FakeSession()
|
||||
for _ in range(200):
|
||||
svc.get(session, "https://site.api.espn.com/x")
|
||||
assert clock.sleeps == []
|
||||
svc.get(session, "https://site.api.espn.com/x")
|
||||
assert clock.sleeps == [pytest.approx(0.05)]
|
||||
svc.get(session, "https://api.example.org/x")
|
||||
assert len(clock.sleeps) == 1
|
||||
|
||||
def test_zero_per_second_removes_a_budget(self, clock):
|
||||
svc = FetchService({"rate_limits": {"*.espn.com": {"per_second": 0, "burst": 1}}},
|
||||
clock=clock.now, sleep=clock.sleep)
|
||||
session = FakeSession()
|
||||
for _ in range(5):
|
||||
svc.get(session, "https://site.api.espn.com/x")
|
||||
assert clock.sleeps == []
|
||||
|
||||
def test_a_merged_caller_spends_no_token(self, clock):
|
||||
svc = FetchService({"rate_limits": {"api.test": {"per_second": 1, "burst": 1}},
|
||||
"max_wait_seconds": 10}, clock=clock.now, sleep=clock.sleep)
|
||||
gate = threading.Event()
|
||||
session = FakeSession(gate=gate)
|
||||
a = threading.Thread(target=lambda: svc.get(session, "https://api.test/x"))
|
||||
a.start()
|
||||
assert session.started.wait(5)
|
||||
b = threading.Thread(target=lambda: svc.get(session, "https://api.test/x"))
|
||||
b.start()
|
||||
_wait_for_waiters(svc, 1)
|
||||
gate.set()
|
||||
a.join(5)
|
||||
b.join(5)
|
||||
assert clock.sleeps == []
|
||||
|
||||
|
||||
# --- conditional GET ----------------------------------------------------------------------------
|
||||
|
||||
class Versioned:
|
||||
"""A server with one resource and an ETag, honouring If-None-Match."""
|
||||
|
||||
def __init__(self, validator="etag"):
|
||||
self.version = 1
|
||||
self.validator = validator
|
||||
self.seen = []
|
||||
|
||||
def body(self):
|
||||
return json.dumps({"version": self.version}).encode()
|
||||
|
||||
def tag(self):
|
||||
if self.validator == "etag":
|
||||
return {"ETag": f'"v{self.version}"'}
|
||||
return {"Last-Modified": f"Thu, 01 Oct 2026 00:00:0{self.version} GMT"}
|
||||
|
||||
def __call__(self, url, kwargs):
|
||||
headers = CaseInsensitiveDict(kwargs.get("headers") or {})
|
||||
self.seen.append(dict(headers))
|
||||
current = self.tag()
|
||||
if (headers.get("If-None-Match") == current.get("ETag") and "ETag" in current) or \
|
||||
(headers.get("If-Modified-Since") == current.get("Last-Modified")
|
||||
and "Last-Modified" in current):
|
||||
return make_response(304, b"", headers={**current, "Date": "now"}, url=url)
|
||||
return make_response(200, self.body(),
|
||||
headers={**current, "Content-Type": "application/json"}, url=url)
|
||||
|
||||
|
||||
class TestConditionalGet:
|
||||
|
||||
@pytest.mark.parametrize("validator,header", [("etag", "If-None-Match"),
|
||||
("last-modified", "If-Modified-Since")])
|
||||
def test_a_304_returns_the_stored_body_as_a_200(self, service, validator, header):
|
||||
server = Versioned(validator)
|
||||
session = FakeSession(server)
|
||||
first = service.get(session, "https://api.test/x", timeout=5)
|
||||
second = service.get(session, "https://api.test/x", timeout=5)
|
||||
|
||||
assert header not in server.seen[0]
|
||||
assert header in server.seen[1]
|
||||
assert second.status_code == 200
|
||||
assert second.json() == first.json() == {"version": 1}
|
||||
assert second.headers["Content-Type"] == "application/json"
|
||||
second.raise_for_status()
|
||||
totals = _counters(service)
|
||||
assert totals["requests"] == 2
|
||||
assert totals["not_modified"] == 1
|
||||
assert totals["bytes"] == len(server.body()) # the 304 carried none
|
||||
|
||||
def test_a_changed_resource_is_fetched_and_stored_again(self, service):
|
||||
server = Versioned()
|
||||
session = FakeSession(server)
|
||||
service.get(session, "https://api.test/x")
|
||||
server.version = 2
|
||||
changed = service.get(session, "https://api.test/x")
|
||||
assert changed.json() == {"version": 2}
|
||||
again = service.get(session, "https://api.test/x")
|
||||
assert again.json() == {"version": 2}
|
||||
assert server.seen[2]["If-None-Match"] == '"v2"'
|
||||
|
||||
def test_no_validators_no_conditional_request(self, service):
|
||||
session = FakeSession() # answers 200 with no ETag/Last-Modified
|
||||
service.get(session, "https://api.test/x", headers={"A": "1"})
|
||||
service.get(session, "https://api.test/x", headers={"A": "1"})
|
||||
assert session.calls[1][1] == {"headers": {"A": "1"}}
|
||||
assert service.snapshot()["validators"]["entries"] == 0
|
||||
|
||||
def test_a_200_without_validators_drops_the_stored_one(self, service):
|
||||
server = Versioned()
|
||||
session = FakeSession(server)
|
||||
service.get(session, "https://api.test/x")
|
||||
session.handler = lambda url, kw: make_response(200, b'{"new": 1}', url=url)
|
||||
service.get(session, "https://api.test/x")
|
||||
assert service.snapshot()["validators"]["entries"] == 0
|
||||
|
||||
def test_a_callers_own_conditional_request_is_left_alone(self, service):
|
||||
server = Versioned()
|
||||
session = FakeSession(server)
|
||||
service.get(session, "https://api.test/x")
|
||||
raw = service.get(session, "https://api.test/x", headers={"If-None-Match": '"v1"'})
|
||||
assert raw.status_code == 304
|
||||
|
||||
def test_validators_are_per_representation(self, service):
|
||||
server = Versioned()
|
||||
session = FakeSession(server)
|
||||
service.get(session, "https://api.test/x", params={"d": "1"})
|
||||
service.get(session, "https://api.test/x", params={"d": "2"})
|
||||
assert "If-None-Match" not in server.seen[1]
|
||||
|
||||
def test_a_body_too_big_for_the_store_is_not_kept(self, clock):
|
||||
svc = FetchService({"rate_limits": {}, "validator_store": {"max_entry_bytes": 4}},
|
||||
clock=clock.now, sleep=clock.sleep)
|
||||
server = Versioned()
|
||||
session = FakeSession(server)
|
||||
svc.get(session, "https://api.test/x")
|
||||
svc.get(session, "https://api.test/x")
|
||||
assert "If-None-Match" not in server.seen[1]
|
||||
|
||||
def test_the_store_evicts_least_recently_used_past_its_budget(self, clock):
|
||||
svc = FetchService({"rate_limits": {}, "validator_store": {"max_entries": 2}},
|
||||
clock=clock.now, sleep=clock.sleep)
|
||||
session = FakeSession(Versioned())
|
||||
for path in ("a", "b", "c"):
|
||||
svc.get(session, f"https://api.test/{path}")
|
||||
assert svc.snapshot()["validators"]["entries"] == 2
|
||||
|
||||
def test_off_switch(self, clock):
|
||||
svc = FetchService({"rate_limits": {}, "conditional_get": False},
|
||||
clock=clock.now, sleep=clock.sleep)
|
||||
server = Versioned()
|
||||
session = FakeSession(server)
|
||||
svc.get(session, "https://api.test/x")
|
||||
svc.get(session, "https://api.test/x")
|
||||
assert "If-None-Match" not in server.seen[1]
|
||||
|
||||
|
||||
# --- counters and caller identity ----------------------------------------------------------------
|
||||
|
||||
class TestCounters:
|
||||
|
||||
def test_per_plugin_and_per_host(self, service):
|
||||
session = FakeSession()
|
||||
with plugin_scope("weather"):
|
||||
service.get(session, "https://api.weather.test/now")
|
||||
service.get(session, "https://api.weather.test/later")
|
||||
service.get(session, "https://site.api.espn.com/x")
|
||||
snap = service.snapshot()
|
||||
assert snap["plugins"]["weather"]["requests"] == 2
|
||||
assert snap["plugins"]["weather"]["hosts"] == {"api.weather.test": 2}
|
||||
assert snap["plugins"]["core"]["requests"] == 1
|
||||
assert snap["hosts"]["api.weather.test"]["requests"] == 2
|
||||
assert snap["hosts"]["site.api.espn.com"]["requests"] == 1
|
||||
assert snap["totals"]["bytes"] == 3 * len(b'{"ok": 1}')
|
||||
|
||||
def test_errors_and_http_errors(self, service):
|
||||
def handler(url, kwargs):
|
||||
if url.endswith("/down"):
|
||||
raise requests.ConnectionError("down")
|
||||
return make_response(404, b"nope", url=url)
|
||||
|
||||
session = FakeSession(handler)
|
||||
with pytest.raises(requests.ConnectionError):
|
||||
service.get(session, "https://api.test/down")
|
||||
service.get(session, "https://api.test/missing")
|
||||
totals = _counters(service)
|
||||
assert totals["requests"] == 2
|
||||
assert totals["errors"] == 1
|
||||
assert totals["http_errors"] == 1
|
||||
|
||||
def test_every_change_bumps_the_change_count(self, service):
|
||||
before = service.change_count
|
||||
service.get(FakeSession(), "https://api.test/x")
|
||||
assert service.change_count > before
|
||||
|
||||
def test_post_is_counted_and_never_merged(self, service):
|
||||
class PostSession(FakeSession):
|
||||
def post(self, url, **kwargs):
|
||||
self.calls.append((url, kwargs))
|
||||
return make_response(201, b"{}", url=url)
|
||||
|
||||
session = PostSession()
|
||||
with plugin_scope("poster"):
|
||||
response = service.post(session, "https://api.test/x", json={"a": 1})
|
||||
assert response.status_code == 201
|
||||
assert session.calls == [("https://api.test/x", {"json": {"a": 1}})]
|
||||
assert _counters(service, plugin="poster")["requests"] == 1
|
||||
|
||||
|
||||
class TestCallerIdentity:
|
||||
|
||||
def test_scope_wins_and_nests(self):
|
||||
assert current_plugin_id() is None
|
||||
with plugin_scope("outer"):
|
||||
assert current_plugin_id() == "outer"
|
||||
with plugin_scope("inner"):
|
||||
assert current_plugin_id() == "inner"
|
||||
with plugin_scope(None):
|
||||
assert current_plugin_id() == "outer"
|
||||
assert current_plugin_id() is None
|
||||
|
||||
def test_a_plugins_own_thread_is_named_by_its_source_directory(self, tmp_path, service):
|
||||
plugin_dir = tmp_path / "my-plugin"
|
||||
plugin_dir.mkdir()
|
||||
(plugin_dir / "fetcher.py").write_text(
|
||||
"import threading\n"
|
||||
"def fetch_in_thread(service, session, url):\n"
|
||||
" t = threading.Thread(target=lambda: service.get(session, url))\n"
|
||||
" t.start()\n"
|
||||
" t.join(5)\n",
|
||||
encoding="utf-8")
|
||||
spec = importlib.util.spec_from_file_location("_fs_test_fetcher", plugin_dir / "fetcher.py")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
register_plugin_directory("my-plugin", plugin_dir)
|
||||
try:
|
||||
module.fetch_in_thread(service, FakeSession(), "https://api.test/x")
|
||||
finally:
|
||||
unregister_plugin_directory("my-plugin")
|
||||
assert _counters(service, plugin="my-plugin")["requests"] == 1
|
||||
assert "core" not in service.snapshot()["plugins"]
|
||||
|
||||
def test_the_executor_scopes_a_plugin_operation(self):
|
||||
from src.plugin_system.plugin_executor import PluginExecutor
|
||||
seen = PluginExecutor().execute_with_timeout(current_plugin_id, plugin_id="clock")
|
||||
assert seen == "clock"
|
||||
|
||||
def test_background_fetches_count_against_the_submitter(self, global_service):
|
||||
from unittest.mock import MagicMock
|
||||
from src.background_data_service import BackgroundDataService
|
||||
|
||||
cache = MagicMock()
|
||||
cache.get.return_value = None
|
||||
bds = BackgroundDataService(cache, max_workers=1, request_timeout=5)
|
||||
bds.session = FakeSession(lambda url, kw: make_response(body=b'{"events": []}', url=url))
|
||||
try:
|
||||
with plugin_scope("football-scoreboard"):
|
||||
request_id = bds.submit_fetch_request(
|
||||
"nfl", 2026, "https://site.api.espn.com/apis/site/v2/sports/football/nfl/scoreboard",
|
||||
cache_key="fs_test_nfl", params={"dates": "2026"})
|
||||
deadline = time.monotonic() + 5
|
||||
while not bds.is_request_complete(request_id) and time.monotonic() < deadline:
|
||||
time.sleep(0.01)
|
||||
assert bds.get_result(request_id).success
|
||||
finally:
|
||||
bds.shutdown(wait=True)
|
||||
assert _counters(global_service, plugin="football-scoreboard")["requests"] == 1
|
||||
|
||||
def test_espn_chunks_on_worker_threads_count_against_the_caller(self, global_service):
|
||||
from src.common.espn_dates import espn_date_chunks, fetch_espn_date_chunks, parse_espn_date_range
|
||||
|
||||
session = FakeSession(lambda url, kw: make_response(body=b'{"events": []}', url=url))
|
||||
dates = "20260801-20261015"
|
||||
with plugin_scope("baseball-scoreboard"):
|
||||
fetch_espn_date_chunks(session, "https://site.api.espn.com/s/scoreboard",
|
||||
params={"dates": dates})
|
||||
chunks = len(espn_date_chunks(*parse_espn_date_range(dates)))
|
||||
assert chunks > 1
|
||||
assert len(session.calls) == chunks
|
||||
assert _counters(global_service, plugin="baseball-scoreboard")["requests"] == chunks
|
||||
assert "core" not in global_service.snapshot()["plugins"]
|
||||
|
||||
def test_api_helper_goes_through_the_service(self, global_service):
|
||||
from src.common.api_helper import APIHelper
|
||||
|
||||
helper = APIHelper()
|
||||
helper.set_rate_limit(0)
|
||||
helper.session = FakeSession(lambda url, kw: make_response(body=b'{"a": 1}', url=url))
|
||||
with plugin_scope("nfl-draft"):
|
||||
assert helper.get("https://api.test/x") == {"a": 1}
|
||||
assert _counters(global_service, plugin="nfl-draft")["requests"] == 1
|
||||
|
||||
def test_odds_go_through_the_service(self, global_service):
|
||||
from unittest.mock import MagicMock
|
||||
from src.base_odds_manager import BaseOddsManager
|
||||
|
||||
cache = MagicMock()
|
||||
cache.get_with_auto_strategy.return_value = None
|
||||
manager = BaseOddsManager(cache)
|
||||
manager.session = FakeSession(
|
||||
lambda url, kw: make_response(body=b'{"count": 0, "items": []}', url=url))
|
||||
with plugin_scope("odds-ticker"):
|
||||
assert manager.get_odds("football", "nfl", "401") is None
|
||||
assert manager.session.calls[0][1] == {"timeout": manager.request_timeout}
|
||||
assert _counters(global_service, plugin="odds-ticker")["requests"] == 1
|
||||
|
||||
|
||||
# --- pooling -------------------------------------------------------------------------------------------
|
||||
|
||||
class TestConnectionPool:
|
||||
|
||||
def test_core_sessions_with_one_policy_share_one_adapter(self, global_service):
|
||||
from unittest.mock import MagicMock
|
||||
from src.background_data_service import BackgroundDataService
|
||||
from src.base_odds_manager import BaseOddsManager
|
||||
|
||||
odds_a = BaseOddsManager(MagicMock()).session.get_adapter("https://x.test")
|
||||
odds_b = BaseOddsManager(MagicMock()).session.get_adapter("https://x.test")
|
||||
bds = BackgroundDataService(MagicMock(), max_workers=1)
|
||||
try:
|
||||
assert odds_a is odds_b is bds.session.get_adapter("https://x.test")
|
||||
assert odds_a.max_retries.total == 0
|
||||
finally:
|
||||
bds.shutdown(wait=False)
|
||||
|
||||
def test_a_different_retry_policy_gets_its_own_adapter(self, global_service):
|
||||
from src.common.api_helper import APIHelper
|
||||
helper_adapter = APIHelper().session.get_adapter("https://x.test")
|
||||
assert helper_adapter is APIHelper().session.get_adapter("https://x.test")
|
||||
assert helper_adapter is not global_service.shared_adapter(0)
|
||||
assert helper_adapter.max_retries.total == 3
|
||||
assert helper_adapter.max_retries.status_forcelist == [429, 500, 502, 503, 504]
|
||||
assert APIHelper(max_retries=1).session.get_adapter("https://x.test") is not helper_adapter
|
||||
|
||||
def test_the_pooled_session_keeps_no_cookies(self, service):
|
||||
import http.client
|
||||
import io
|
||||
from types import SimpleNamespace
|
||||
from requests.cookies import extract_cookies_to_jar
|
||||
|
||||
def offer_cookie(session):
|
||||
msg = http.client.parse_headers(io.BytesIO(b"Set-Cookie: sid=1; Path=/" + b"\r\n" * 2))
|
||||
raw = SimpleNamespace(_original_response=SimpleNamespace(msg=msg))
|
||||
request = requests.Request("GET", "https://api.test/").prepare()
|
||||
extract_cookies_to_jar(session.cookies, request, raw)
|
||||
return len(session.cookies)
|
||||
|
||||
assert offer_cookie(requests.Session()) == 1 # what a private Session does
|
||||
assert offer_cookie(service.session_for("https://api.test/")) == 0
|
||||
|
||||
|
||||
# --- configuration ----------------------------------------------------------------------------------
|
||||
|
||||
class TestConfigure:
|
||||
|
||||
def test_reapplying_the_same_section_keeps_the_validator_store(self, clock):
|
||||
config = {"rate_limits": {}}
|
||||
svc = FetchService(config, clock=clock.now, sleep=clock.sleep)
|
||||
svc.get(FakeSession(Versioned()), "https://api.test/x")
|
||||
svc.configure(dict(config))
|
||||
assert svc.snapshot()["validators"]["entries"] == 1
|
||||
svc.configure({"rate_limits": {"api.test": {"per_second": 1}}})
|
||||
assert svc.snapshot()["validators"]["entries"] == 0
|
||||
|
||||
@pytest.mark.parametrize("bad", [
|
||||
"nonsense",
|
||||
{"rate_limits": "nonsense"},
|
||||
{"rate_limits": {"api.test": "fast"}},
|
||||
{"rate_limits": {"api.test": {"per_second": -1}}},
|
||||
{"max_wait_seconds": "long"},
|
||||
])
|
||||
def test_bad_values_fall_back_without_raising(self, clock, bad):
|
||||
svc = FetchService(bad, clock=clock.now, sleep=clock.sleep)
|
||||
assert svc.describe_config()["max_wait_seconds"] == 2.0
|
||||
svc.get(FakeSession(), "https://api.test/x")
|
||||
|
||||
def test_the_template_section_is_what_the_code_defaults_to(self):
|
||||
import os
|
||||
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
with open(os.path.join(root, "config", "config.template.json"), encoding="utf-8") as fh:
|
||||
template = json.load(fh)["fetch_service"]
|
||||
for key, value in template.items():
|
||||
assert fs.DEFAULT_CONFIG[key] == value
|
||||
|
||||
|
||||
# --- publishing and reading -----------------------------------------------------------------------------
|
||||
|
||||
class SharedCache:
|
||||
def __init__(self):
|
||||
self.entries = {}
|
||||
self.writes = 0
|
||||
|
||||
def get(self, key, max_age=None, memory_ttl=None):
|
||||
return self.entries.get(key)
|
||||
|
||||
def set(self, key, value, *args, **kwargs):
|
||||
self.writes += 1
|
||||
self.entries[key] = json.loads(json.dumps(value))
|
||||
|
||||
|
||||
class TestPublisher:
|
||||
|
||||
def _publisher(self, service, clock, cache):
|
||||
return FetchStatsPublisher(cache, service, clock=clock.now, wall_clock=lambda: 5000.0)
|
||||
|
||||
def test_on_change_at_most_once_a_minute(self, service, clock):
|
||||
cache = SharedCache()
|
||||
publisher = self._publisher(service, clock, cache)
|
||||
assert publisher.tick() is True # first publish
|
||||
assert publisher.tick() is False # nothing changed
|
||||
service.get(FakeSession(), "https://api.test/x")
|
||||
clock.advance(30)
|
||||
assert publisher.tick() is False # changed, but too soon
|
||||
clock.advance(30)
|
||||
assert publisher.tick() is True
|
||||
assert cache.writes == 2
|
||||
snap = cache.entries[fs.FETCH_STATS_KEY]
|
||||
assert snap["running"] is True
|
||||
assert snap["totals"]["requests"] == 1
|
||||
|
||||
def test_heartbeat_when_nothing_changes(self, service, clock):
|
||||
cache = SharedCache()
|
||||
publisher = self._publisher(service, clock, cache)
|
||||
publisher.tick()
|
||||
clock.advance(fs.REFRESH_INTERVAL - 1)
|
||||
assert publisher.tick() is False
|
||||
clock.advance(1)
|
||||
assert publisher.tick() is True
|
||||
|
||||
def test_stop_publishes_stopped(self, service, clock):
|
||||
cache = SharedCache()
|
||||
publisher = self._publisher(service, clock, cache)
|
||||
publisher.stop()
|
||||
assert cache.entries[fs.FETCH_STATS_KEY]["running"] is False
|
||||
|
||||
def test_a_failing_cache_never_raises(self, service, clock):
|
||||
class Broken(SharedCache):
|
||||
def set(self, *a, **k):
|
||||
raise OSError("disk full")
|
||||
|
||||
assert self._publisher(service, clock, Broken()).tick() is False
|
||||
|
||||
def test_reader_statuses(self, service, clock):
|
||||
cache = SharedCache()
|
||||
assert read_fetch_stats(cache)["status"] == "unknown"
|
||||
assert read_fetch_stats(None)["status"] == "unknown"
|
||||
publisher = self._publisher(service, clock, cache)
|
||||
publisher.tick()
|
||||
assert read_fetch_stats(cache, now=5010.0)["status"] == "live"
|
||||
assert read_fetch_stats(cache, now=5000.0 + fs.STALE_AFTER + 1)["status"] == "stale"
|
||||
publisher.stop()
|
||||
view = read_fetch_stats(cache, now=5010.0)
|
||||
assert view["status"] == "stopped"
|
||||
assert view["data"]["totals"]["requests"] == 0
|
||||
|
||||
|
||||
def test_the_web_route_returns_the_published_counters(clock):
|
||||
from test._api_v3_test_helpers import build_app
|
||||
from web_interface.blueprints import api_v3 as module
|
||||
|
||||
svc = FetchService({"rate_limits": {}}, clock=clock.now, sleep=clock.sleep)
|
||||
with plugin_scope("weather"):
|
||||
svc.get(FakeSession(), "https://api.test/x")
|
||||
cache = SharedCache()
|
||||
FetchStatsPublisher(cache, svc, wall_clock=time.time).tick()
|
||||
|
||||
original = getattr(module.api_v3, "cache_manager", None)
|
||||
module.api_v3.cache_manager = cache
|
||||
try:
|
||||
body = build_app(module.api_v3).test_client().get("/api/v3/plugins/fetch-stats").get_json()
|
||||
finally:
|
||||
module.api_v3.cache_manager = original
|
||||
assert body["status"] == "success"
|
||||
assert body["data"]["status"] == "live"
|
||||
assert body["data"]["data"]["plugins"]["weather"]["requests"] == 1
|
||||
@@ -0,0 +1,222 @@
|
||||
"""Golden traces of DisplayController.run(): what is shown, for how long, and why.
|
||||
|
||||
Each scenario runs the real run() loop against fake plugins on a fake clock
|
||||
(see test/_run_loop_harness.py) and compares the screens it produced with
|
||||
test/fixtures/run_loop_golden/<scenario>.json. A trace row is
|
||||
|
||||
[start_s, mode, duration_s, exit_reason, frames, force_clear_on_first_frame]
|
||||
|
||||
and ``events`` lists what else happened (requests, live changes, schedule,
|
||||
brightness) with its time.
|
||||
|
||||
These pin down today's behaviour so run() can be restructured into an
|
||||
Arbiter / ScreenRunner / Sources (docs/RUN_LOOP_REDESIGN.md) without changing
|
||||
it. A diff here is a behaviour change: if it is intended, regenerate with
|
||||
LEDMATRIX_REGEN_GOLDEN=1 and explain the change in the commit message.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("EMULATOR", "true")
|
||||
|
||||
from test._run_loop_harness import ( # noqa: E402
|
||||
FakePlugin,
|
||||
LegacyFakePlugin,
|
||||
RunLoopHarness,
|
||||
check_golden,
|
||||
)
|
||||
|
||||
|
||||
def scenario_plain_rotation(h: RunLoopHarness):
|
||||
# clock: duration from display_durations, which beats the plugin's own.
|
||||
# weather: the plugin's own duration. ticker: scrolls, so high-FPS.
|
||||
# legacy: display() without display_mode.
|
||||
h.config["display"]["display_durations"] = {"clock": 15}
|
||||
h.add_plugin(FakePlugin("clock", ["clock"], duration=99))
|
||||
h.add_plugin(FakePlugin("weather", ["weather_now", "weather_forecast"], duration=20))
|
||||
h.add_plugin(FakePlugin("ticker", ["ticker"], duration=10, enable_scrolling=True))
|
||||
h.add_plugin(LegacyFakePlugin("legacy", ["legacy"], duration=5))
|
||||
|
||||
|
||||
def scenario_empty_modes(h: RunLoopHarness):
|
||||
# empty: never has content, skipped at once. ghost: a mode with no
|
||||
# plugin behind it. flaky: content on the first frame only, so the
|
||||
# 1 s loop breaks early and the dwell is made up by sleeping.
|
||||
h.add_plugin(FakePlugin("clock", ["clock"], duration=10))
|
||||
h.add_plugin(FakePlugin("empty", ["empty"], duration=10, content=lambda t, m: False))
|
||||
h.add_mode_without_plugin("ghost")
|
||||
h.add_plugin(FakePlugin("flaky", ["flaky"], duration=12, first_frame_only=True))
|
||||
|
||||
|
||||
def scenario_all_empty(h: RunLoopHarness):
|
||||
# Nothing to show anywhere: one rotation of empty passes, then a 1 s
|
||||
# pause per pass instead of a spin.
|
||||
h.add_plugin(FakePlugin("a", ["a"], content=lambda t, m: False))
|
||||
h.add_plugin(FakePlugin("b", ["b"], content=lambda t, m: False))
|
||||
h.add_plugin(FakePlugin("c", ["c"], content=lambda t, m: t >= 6))
|
||||
|
||||
|
||||
def scenario_plugin_error(h: RunLoopHarness):
|
||||
# broken's dispatch raises (no display lock: loading failed part-way),
|
||||
# so all its modes are skipped together; two failures open the breaker.
|
||||
# crashy's display() raises inside the executor: an empty pass
|
||||
# ("raised") that also counts as a breaker failure, so after two raises
|
||||
# it is skipped by the breaker. Its modes are not skipped together.
|
||||
h.add_plugin(FakePlugin("clock", ["clock"], duration=10))
|
||||
h.add_plugin(FakePlugin("broken", ["broken_a", "broken_b"], duration=10), lock=False)
|
||||
h.add_plugin(FakePlugin("weather", ["weather"], duration=10))
|
||||
h.add_plugin(FakePlugin("crashy", ["crashy"], duration=10, raises=True))
|
||||
|
||||
|
||||
def scenario_dynamic_duration(h: RunLoopHarness):
|
||||
# Read once at startup, so set where __init__ left it.
|
||||
h.controller.global_dynamic_config = {"max_duration_seconds": 50}
|
||||
# scroller: high-FPS, completes its cycle 20 s after each reset.
|
||||
h.add_plugin(FakePlugin("scroller", ["scroller"], duration=10, needs_high_fps=True,
|
||||
dynamic={"cap": None, "complete_after": 20}))
|
||||
# news: 1 s loop, asks for 45 s but its own cap is 40; never completes.
|
||||
h.add_plugin(FakePlugin("news", ["news"], duration=10,
|
||||
dynamic={"cap": 40, "cycle": 45, "complete_after": None}))
|
||||
# board: no cap of its own, so the global 50 s applies; done after 5 s,
|
||||
# but the 10 s minimum (+0.5 s grace) holds it.
|
||||
h.add_plugin(FakePlugin("board", ["board"], duration=10,
|
||||
dynamic={"cap": None, "complete_after": 5}))
|
||||
h.add_plugin(FakePlugin("clock", ["clock"], duration=10))
|
||||
|
||||
|
||||
def scenario_live_priority(h: RunLoopHarness):
|
||||
h.add_plugin(FakePlugin("clock", ["clock"], duration=20))
|
||||
h.add_plugin(FakePlugin("weather", ["weather"], duration=20))
|
||||
h.add_plugin(FakePlugin(
|
||||
"sports", ["sports_recent", "sports_live"], duration=20,
|
||||
live=(50, 110), live_priority=True,
|
||||
content=lambda t, mode: mode != "sports_live" or 50 <= t < 110))
|
||||
|
||||
|
||||
def scenario_live_round_robin(h: RunLoopHarness):
|
||||
h.add_plugin(FakePlugin("clock", ["clock"], duration=15))
|
||||
h.add_plugin(FakePlugin("nfl", ["nfl_live"], duration=15, live=(0, 70), live_priority=True))
|
||||
h.add_plugin(FakePlugin("nhl", ["nhl_live"], duration=15, live=(20, 100), live_priority=True))
|
||||
|
||||
|
||||
def scenario_on_demand(h: RunLoopHarness):
|
||||
h.add_plugin(FakePlugin("clock", ["clock"], duration=20))
|
||||
h.add_plugin(FakePlugin("weather", ["weather"], duration=20))
|
||||
h.add_plugin(FakePlugin("sports", ["sports_recent", "sports_upcoming"], duration=15))
|
||||
# Mid-way through clock's first screen; then stopped by request.
|
||||
h.on_demand_request(25, "r1", plugin_id="sports")
|
||||
h.on_demand_request(95, "r2", action="stop")
|
||||
# A timed request that expires on its own.
|
||||
h.on_demand_request(150, "r3", plugin_id="weather", duration=30)
|
||||
|
||||
|
||||
def scenario_on_demand_pinned(h: RunLoopHarness):
|
||||
h.add_plugin(FakePlugin("clock", ["clock"], duration=20))
|
||||
h.add_plugin(FakePlugin("sports", ["sports_recent", "sports_upcoming"], duration=15))
|
||||
h.on_demand_request(12, "p1", plugin_id="sports", mode="sports_upcoming", pinned=True)
|
||||
# An on-demand mode with nothing to show is skipped like any other.
|
||||
h.add_plugin(FakePlugin("starlark", ["app_a", "app_b"], duration=10,
|
||||
content=lambda t, mode: mode != "app_a"))
|
||||
h.on_demand_request(80, "p2", plugin_id="starlark")
|
||||
h.on_demand_request(120, "p3", action="stop")
|
||||
|
||||
|
||||
def scenario_on_demand_restored(h: RunLoopHarness):
|
||||
# A restart during an on-demand session resumes it: the first screen is
|
||||
# the saved mode (with a full clear), not the rotation's first mode, and
|
||||
# the rotation starts from the top once it expires.
|
||||
h.add_plugin(FakePlugin("clock", ["clock"], duration=20))
|
||||
h.add_plugin(FakePlugin("weather", ["weather"], duration=20))
|
||||
h.add_plugin(FakePlugin("sports", ["sports_recent", "sports_upcoming"], duration=15))
|
||||
h.restore_on_demand("sports", mode="sports_upcoming", duration=40)
|
||||
|
||||
|
||||
def scenario_schedule(h: RunLoopHarness):
|
||||
# The clock starts at 22:59:30. Off from 23:01 until 23:05 (the window
|
||||
# spans midnight); dimmed from 23:00 until 23:01.
|
||||
h.config["schedule"] = {"enabled": True, "start_time": "23:05", "end_time": "23:01"}
|
||||
h.config["dim_schedule"] = {"enabled": True, "start_time": "23:00",
|
||||
"end_time": "23:01", "dim_brightness": 30}
|
||||
h.add_plugin(FakePlugin("clock", ["clock"], duration=20))
|
||||
h.add_plugin(FakePlugin("weather", ["weather"], duration=20))
|
||||
# An on-demand request during scheduled downtime overrides it; when it
|
||||
# expires the panel blanks at once, not at the next minute.
|
||||
h.on_demand_request(170, "s1", plugin_id="weather", duration=20)
|
||||
|
||||
|
||||
def scenario_wifi_notice(h: RunLoopHarness):
|
||||
h.add_plugin(FakePlugin("clock", ["clock"], duration=20))
|
||||
h.add_plugin(FakePlugin("weather", ["weather"], duration=20))
|
||||
# Posted mid-screen: it preempts the screen at its next frame, stays up
|
||||
# until it expires, and the interrupted mode then comes back in full.
|
||||
h.wifi_message(25, "Connected to HomeNet", duration=5)
|
||||
# While on-demand is active the notice waits.
|
||||
h.on_demand_request(60, "w1", plugin_id="clock", duration=20)
|
||||
h.wifi_message(65, "AP mode on", duration=30)
|
||||
|
||||
|
||||
def scenario_follower(h: RunLoopHarness):
|
||||
h.add_plugin(FakePlugin("clock", ["clock"], duration=20))
|
||||
h.add_plugin(FakePlugin("weather", ["weather"], duration=20))
|
||||
# Only checked at the top of a pass, so it takes over when the screen
|
||||
# running at t=35 ends, and hands back the pass after it ends.
|
||||
h.sync.follower_windows = [(35, 50)]
|
||||
|
||||
|
||||
def scenario_vegas(h: RunLoopHarness):
|
||||
h.add_plugin(FakePlugin("clock", ["clock"], duration=20))
|
||||
h.add_plugin(FakePlugin(
|
||||
"sports", ["sports_live"], duration=20, live=(70, 100), live_priority=True,
|
||||
content=lambda t, mode: 70 <= t < 100))
|
||||
h.enable_vegas(cycle=30)
|
||||
# On-demand takes the panel from Vegas mid-iteration, then hands back.
|
||||
h.on_demand_request(150, "v1", plugin_id="clock", duration=25)
|
||||
h.wifi_message(200, "Connected to HomeNet", duration=3)
|
||||
|
||||
|
||||
def scenario_vegas_live_in_ticker(h: RunLoopHarness):
|
||||
h.add_plugin(FakePlugin("clock", ["clock"], duration=20))
|
||||
h.add_plugin(FakePlugin("sports", ["sports_live"], duration=20, live=(10, 50),
|
||||
live_priority=True))
|
||||
h.enable_vegas(cycle=30, live_in_ticker=True)
|
||||
|
||||
|
||||
SCENARIOS = {
|
||||
"plain_rotation": (scenario_plain_rotation, 160),
|
||||
"empty_modes": (scenario_empty_modes, 90),
|
||||
"all_empty": (scenario_all_empty, 12),
|
||||
"plugin_error": (scenario_plugin_error, 90),
|
||||
"dynamic_duration": (scenario_dynamic_duration, 220),
|
||||
"live_priority": (scenario_live_priority, 200),
|
||||
"live_round_robin": (scenario_live_round_robin, 150),
|
||||
"on_demand": (scenario_on_demand, 240),
|
||||
"on_demand_pinned": (scenario_on_demand_pinned, 160),
|
||||
"on_demand_restored": (scenario_on_demand_restored, 100),
|
||||
"schedule": (scenario_schedule, 400),
|
||||
"wifi_notice": (scenario_wifi_notice, 150),
|
||||
"follower": (scenario_follower, 80),
|
||||
"vegas": (scenario_vegas, 260),
|
||||
"vegas_live_in_ticker": (scenario_vegas_live_in_ticker, 100),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", sorted(SCENARIOS))
|
||||
def test_run_loop_golden_trace(name, tmp_path):
|
||||
build, horizon = SCENARIOS[name]
|
||||
harness = RunLoopHarness(tmp_path, horizon=horizon)
|
||||
build(harness)
|
||||
trace = harness.run()
|
||||
check_golden(name, trace)
|
||||
|
||||
|
||||
def test_traces_are_repeatable(tmp_path):
|
||||
"""Two runs of the busiest scenario give the identical trace."""
|
||||
traces = []
|
||||
for i in range(2):
|
||||
(tmp_path / str(i)).mkdir()
|
||||
harness = RunLoopHarness(tmp_path / str(i), horizon=240)
|
||||
scenario_on_demand(harness)
|
||||
traces.append(harness.run())
|
||||
assert traces[0] == traces[1]
|
||||
@@ -0,0 +1,218 @@
|
||||
"""Live priority takes the panel promptly, through the real run() loop.
|
||||
|
||||
Two behaviours the golden traces recorded (docs/RUN_LOOP_REDESIGN.md):
|
||||
|
||||
* A game that went live mid-screen waited for that screen to end. Now the
|
||||
frame loops and the dwell sleep check, at most once a second, and switch.
|
||||
* When Vegas yielded to live content, one rotation screen showed before the
|
||||
game. Now the game is what shows next.
|
||||
|
||||
These run the real DisplayController.run() on the fake clock from
|
||||
test/_run_loop_harness.py. Each trace row is
|
||||
[start, mode, duration, exit_reason, frames, force_clear].
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("EMULATOR", "true")
|
||||
|
||||
from test._run_loop_harness import FakePlugin, RunLoopHarness # noqa: E402
|
||||
|
||||
|
||||
def _run(tmp_path, horizon, build):
|
||||
harness = RunLoopHarness(tmp_path, horizon=horizon)
|
||||
build(harness)
|
||||
return harness, harness.run()["screens"]
|
||||
|
||||
|
||||
def _first(rows, mode):
|
||||
return next(row for row in rows if row[1] == mode)
|
||||
|
||||
|
||||
def _counting(plugin):
|
||||
"""Count has_live_content() calls, with the fake-clock time of each."""
|
||||
calls = []
|
||||
real = plugin.has_live_content
|
||||
|
||||
def has_live_content():
|
||||
calls.append(plugin._h.clock.rel())
|
||||
return real()
|
||||
plugin.has_live_content = has_live_content
|
||||
return calls
|
||||
|
||||
|
||||
class TestMidScreenTakeover:
|
||||
def test_live_game_cuts_a_one_hz_screen_short(self, tmp_path):
|
||||
def build(h):
|
||||
h.add_plugin(FakePlugin("clock", ["clock"], duration=30))
|
||||
h.add_plugin(FakePlugin("sports", ["sports_live"], duration=20,
|
||||
live=(12.5, 100), live_priority=True))
|
||||
_, rows = _run(tmp_path, 60, build)
|
||||
clock = rows[0]
|
||||
assert clock[1] == "clock" and clock[3] == "live"
|
||||
live = _first(rows, "sports_live")
|
||||
# Taken over at the first check after 12.5 s, not at 30 s.
|
||||
assert 12.5 <= live[0] <= 13.5
|
||||
|
||||
def test_live_game_cuts_a_scrolling_screen_short(self, tmp_path):
|
||||
def build(h):
|
||||
h.add_plugin(FakePlugin("ticker", ["ticker"], duration=30, needs_high_fps=True))
|
||||
h.add_plugin(FakePlugin("sports", ["sports_live"], duration=20,
|
||||
live=(7.2, 100), live_priority=True))
|
||||
_, rows = _run(tmp_path, 40, build)
|
||||
assert rows[0][1] == "ticker" and rows[0][3] == "live"
|
||||
assert 7.2 <= _first(rows, "sports_live")[0] <= 8.3
|
||||
|
||||
def test_live_game_cuts_a_make_up_dwell_short(self, tmp_path):
|
||||
# display() returns False after the first frame, so the 1 Hz loop
|
||||
# breaks and the rest of the 30 s is a dwell sleep.
|
||||
def build(h):
|
||||
h.add_plugin(FakePlugin("flaky", ["flaky"], duration=30, first_frame_only=True))
|
||||
h.add_plugin(FakePlugin("sports", ["sports_live"], duration=20,
|
||||
live=(10, 100), live_priority=True))
|
||||
_, rows = _run(tmp_path, 50, build)
|
||||
assert rows[0][1] == "flaky"
|
||||
assert 10 <= _first(rows, "sports_live")[0] <= 11
|
||||
|
||||
def test_on_demand_is_never_preempted(self, tmp_path):
|
||||
def build(h):
|
||||
h.add_plugin(FakePlugin("clock", ["clock"], duration=20))
|
||||
h.add_plugin(FakePlugin("weather", ["weather"], duration=20))
|
||||
h.add_plugin(FakePlugin("sports", ["sports_live"], duration=20,
|
||||
live=(10, 200), live_priority=True))
|
||||
h.on_demand_request(2, "od", plugin_id="weather", duration=40)
|
||||
_, rows = _run(tmp_path, 60, build)
|
||||
on_demand = [row for row in rows if 2 <= row[0] < 42]
|
||||
assert on_demand and all(row[1] == "weather" for row in on_demand)
|
||||
# Not even interrupted and restarted: each on-demand screen runs out.
|
||||
assert all(row[3] != "live" for row in on_demand)
|
||||
assert on_demand[0][2] == 20.0
|
||||
# Once the session expires, the live game takes over.
|
||||
after = [row for row in rows if row[0] >= 42]
|
||||
assert after[0][1] == "sports_live"
|
||||
|
||||
def test_simultaneous_games_still_take_turns(self, tmp_path):
|
||||
# Both go live during the clock screen. The takeover shows the first
|
||||
# one; the next pass must not advance the round-robin past it.
|
||||
def build(h):
|
||||
h.add_plugin(FakePlugin("clock", ["clock"], duration=30))
|
||||
h.add_plugin(FakePlugin("nfl", ["nfl_live"], duration=15,
|
||||
live=(10, 200), live_priority=True))
|
||||
h.add_plugin(FakePlugin("nhl", ["nhl_live"], duration=15,
|
||||
live=(10, 200), live_priority=True))
|
||||
_, rows = _run(tmp_path, 75, build)
|
||||
modes = [row[1] for row in rows]
|
||||
assert modes[:5] == ["clock", "nfl_live", "nhl_live", "nfl_live", "nhl_live"]
|
||||
assert 10 <= rows[1][0] <= 11
|
||||
|
||||
|
||||
class TestTakeoverCheck:
|
||||
"""_check_live_takeover() on its own, on a controller built by the harness."""
|
||||
|
||||
def _controller(self, tmp_path, current="clock"):
|
||||
h = RunLoopHarness(tmp_path, horizon=10)
|
||||
h.add_plugin(FakePlugin("clock", ["clock"], duration=20))
|
||||
sports = h.add_plugin(FakePlugin("sports", ["sports_recent", "sports_live"],
|
||||
duration=20, live=(0, 100), live_priority=True))
|
||||
dc = h.controller
|
||||
dc.current_display_mode = current
|
||||
dc.current_mode_index = dc.available_modes.index(current)
|
||||
return h, dc, _counting(sports)
|
||||
|
||||
def test_switches_to_the_live_mode(self, tmp_path):
|
||||
_, dc, calls = self._controller(tmp_path)
|
||||
dc._check_live_takeover()
|
||||
assert dc.current_display_mode == "sports_live"
|
||||
assert dc.force_change is True
|
||||
assert dc._live_takeover_unshown is True
|
||||
# The rotation resumes from the screen that was cut short.
|
||||
assert dc._live_resume_index == 0
|
||||
assert len(calls) == 1 # once per plugin, not per mode key
|
||||
|
||||
def test_on_demand_session_is_left_alone(self, tmp_path):
|
||||
_, dc, calls = self._controller(tmp_path)
|
||||
dc.on_demand_active = True
|
||||
dc._check_live_takeover()
|
||||
assert dc.current_display_mode == "clock"
|
||||
assert calls == []
|
||||
|
||||
def test_scheduled_off_is_left_alone(self, tmp_path):
|
||||
_, dc, calls = self._controller(tmp_path)
|
||||
dc.is_display_active = False
|
||||
dc._check_live_takeover()
|
||||
assert dc.current_display_mode == "clock"
|
||||
assert calls == []
|
||||
|
||||
def test_vegas_keeping_live_in_the_ticker_is_left_alone(self, tmp_path):
|
||||
h, dc, calls = self._controller(tmp_path)
|
||||
h.enable_vegas(live_in_ticker=True)
|
||||
dc._check_live_takeover()
|
||||
assert dc.current_display_mode == "clock"
|
||||
assert calls == []
|
||||
|
||||
def test_live_screen_already_showing_is_not_rescanned(self, tmp_path):
|
||||
_, dc, calls = self._controller(tmp_path, current="sports_live")
|
||||
dc._collect_live_modes() # the scan that put the live mode up
|
||||
dc._last_live_scan = None # throttle out of the way
|
||||
dc._check_live_takeover()
|
||||
assert dc.current_display_mode == "sports_live"
|
||||
assert dc._live_takeover_unshown is False
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
class TestLiveContentPollingCost:
|
||||
def test_at_most_once_a_second_during_a_rotation_screen(self, tmp_path):
|
||||
holder = {}
|
||||
|
||||
def build(h):
|
||||
h.add_plugin(FakePlugin("clock", ["clock"], duration=30, needs_high_fps=True))
|
||||
# Two mode keys on one plugin: still asked once per scan.
|
||||
sports = h.add_plugin(FakePlugin("sports", ["sports_recent", "sports_live"],
|
||||
duration=20, live_priority=True,
|
||||
content=lambda t, m: m != "sports_live"))
|
||||
holder["calls"] = _counting(sports)
|
||||
_run(tmp_path, 29, build)
|
||||
calls = holder["calls"]
|
||||
# A 125 Hz screen, 29 s long: about one scan a second, never two
|
||||
# within a second of each other.
|
||||
assert len(calls) <= 30
|
||||
assert all(b - a >= 0.99 for a, b in zip(calls, calls[1:]))
|
||||
|
||||
def test_not_rescanned_while_a_live_game_is_showing(self, tmp_path):
|
||||
holder = {}
|
||||
|
||||
def build(h):
|
||||
h.add_plugin(FakePlugin("clock", ["clock"], duration=20))
|
||||
sports = h.add_plugin(FakePlugin("sports", ["sports_live"], duration=30,
|
||||
live=(0, 200), live_priority=True))
|
||||
holder["calls"] = _counting(sports)
|
||||
_, rows = _run(tmp_path, 90, build)
|
||||
assert all(row[1] == "sports_live" for row in rows)
|
||||
# Per 30 s live screen: the scan before it and the hold check after
|
||||
# it, as before -- nothing from inside the screen.
|
||||
assert len(holder["calls"]) <= 2 * len(rows)
|
||||
|
||||
|
||||
class TestVegasYieldsToLive:
|
||||
def test_live_game_shows_next_without_a_rotation_screen(self, tmp_path):
|
||||
def build(h):
|
||||
h.add_plugin(FakePlugin("clock", ["clock"], duration=20))
|
||||
h.add_plugin(FakePlugin("weather", ["weather"], duration=20))
|
||||
h.add_plugin(FakePlugin("sports", ["sports_live"], duration=20,
|
||||
live=(40, 200), live_priority=True))
|
||||
h.enable_vegas(cycle=30)
|
||||
_, rows = _run(tmp_path, 80, build)
|
||||
assert rows[0][1] == "<vegas>"
|
||||
yielded = next(i for i, row in enumerate(rows) if row[3] == "vegas-live")
|
||||
nxt = rows[yielded + 1]
|
||||
assert nxt[1] == "sports_live"
|
||||
assert nxt[0] == rows[yielded][0] + rows[yielded][2]
|
||||
|
||||
def test_live_in_ticker_keeps_the_ticker(self, tmp_path):
|
||||
def build(h):
|
||||
h.add_plugin(FakePlugin("clock", ["clock"], duration=20))
|
||||
h.add_plugin(FakePlugin("sports", ["sports_live"], duration=20,
|
||||
live=(10, 200), live_priority=True))
|
||||
h.enable_vegas(cycle=30, live_in_ticker=True)
|
||||
_, rows = _run(tmp_path, 70, build)
|
||||
assert all(row[1] == "<vegas>" for row in rows)
|
||||
@@ -0,0 +1,157 @@
|
||||
"""A WiFi notice and a live game that both want the panel at once.
|
||||
|
||||
The two preempt the current screen independently (_wifi_notice_pending and
|
||||
_check_live_takeover, both polled from the frame loops and the dwell sleep),
|
||||
so these pin down how they combine. The documented priority is follower,
|
||||
on-demand, WiFi, live, Vegas, rotation: the notice shows first, then the
|
||||
game, with no rotation screen in between, and the scheduled-off panel shows
|
||||
neither. Runs the real run() loop on the fake clock of
|
||||
test/_run_loop_harness.py. Each trace row is
|
||||
[start, mode, duration, exit_reason, frames, force_clear].
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("EMULATOR", "true")
|
||||
|
||||
from test._run_loop_harness import FakePlugin, RunLoopHarness # noqa: E402
|
||||
|
||||
|
||||
def _run(tmp_path, horizon, build):
|
||||
harness = RunLoopHarness(tmp_path, horizon=horizon)
|
||||
build(harness)
|
||||
return harness.run()
|
||||
|
||||
|
||||
def _sports(h, live, **kwargs):
|
||||
h.add_plugin(FakePlugin("sports", ["sports_live"], duration=20, live=live,
|
||||
live_priority=True, **kwargs))
|
||||
|
||||
|
||||
def _notice_then_game(rows, after, posted, expires):
|
||||
"""Check the rows from index `after` on: notice, then game, nothing else.
|
||||
|
||||
The notice is up within about a second of being posted and stays up
|
||||
until it expires (it may be redrawn across pass boundaries, so it can
|
||||
span several rows). The game follows it directly.
|
||||
"""
|
||||
wifi = []
|
||||
i = after
|
||||
while rows[i][1] == "<wifi>":
|
||||
wifi.append(rows[i])
|
||||
i += 1
|
||||
assert wifi, rows
|
||||
assert posted <= wifi[0][0] <= posted + 1.25
|
||||
# Continuous: each notice row starts where the one before it ended.
|
||||
for prev, cur in zip(wifi, wifi[1:]):
|
||||
assert cur[0] == pytest.approx(prev[0] + prev[2])
|
||||
assert wifi[-1][0] + wifi[-1][2] >= expires
|
||||
game = rows[i]
|
||||
assert game[1] == "sports_live"
|
||||
assert game[0] == pytest.approx(wifi[-1][0] + wifi[-1][2])
|
||||
return i
|
||||
|
||||
|
||||
@pytest.mark.parametrize("live_at, wifi_at", [(10.2, 10.4), (10.4, 10.2)],
|
||||
ids=["game-first", "notice-first"])
|
||||
def test_both_during_a_static_screen(tmp_path, live_at, wifi_at):
|
||||
def build(h):
|
||||
h.add_plugin(FakePlugin("clock", ["clock"], duration=30))
|
||||
h.add_plugin(FakePlugin("weather", ["weather"], duration=20))
|
||||
_sports(h, (live_at, 60))
|
||||
h.wifi_message(wifi_at, "Connected to HomeNet", duration=5)
|
||||
trace = _run(tmp_path, 90, build)
|
||||
rows = trace["screens"]
|
||||
|
||||
# The 1 Hz loop's next check after both arrive ends clock's screen.
|
||||
assert rows[0][1] == "clock" and rows[0][2] <= 11.0
|
||||
_notice_then_game(rows, 1, wifi_at, wifi_at + 5)
|
||||
# The game is never on the panel before the notice.
|
||||
first_game = next(row for row in rows if row[1] == "sports_live")
|
||||
first_wifi = next(row for row in rows if row[1] == "<wifi>")
|
||||
assert first_wifi[0] < first_game[0]
|
||||
# Once the game ends, the rotation resumes at the screen it cut short.
|
||||
after_game = next(row for row in rows if row[0] >= 60 and row[1] != "sports_live")
|
||||
assert after_game[1] == "clock"
|
||||
|
||||
|
||||
def test_both_at_once_during_a_scrolling_screen(tmp_path):
|
||||
def build(h):
|
||||
h.add_plugin(FakePlugin("ticker", ["ticker"], duration=30, enable_scrolling=True))
|
||||
h.add_plugin(FakePlugin("weather", ["weather"], duration=20))
|
||||
_sports(h, (10.0, 60))
|
||||
h.wifi_message(10.0, "AP mode on", duration=4)
|
||||
rows = _run(tmp_path, 90, build)["screens"]
|
||||
|
||||
assert rows[0][1] == "ticker" and rows[0][0] + rows[0][2] <= 11.0
|
||||
_notice_then_game(rows, 1, 10.0, 14.0)
|
||||
assert "weather" not in [row[1] for row in rows if row[0] < 60]
|
||||
|
||||
|
||||
def test_vegas_yields_to_both_with_no_rotation_screen(tmp_path):
|
||||
def build(h):
|
||||
h.add_plugin(FakePlugin("clock", ["clock"], duration=20))
|
||||
_sports(h, (40, 70), content=lambda t, mode: 40 <= t < 70)
|
||||
h.enable_vegas(cycle=30)
|
||||
h.wifi_message(40, "Connected to HomeNet", duration=3)
|
||||
trace = _run(tmp_path, 110, build)
|
||||
rows = trace["screens"]
|
||||
|
||||
yielded = next(i for i, row in enumerate(rows)
|
||||
if row[1] == "<vegas>" and row[3] in ("vegas-live", "vegas-interrupt"))
|
||||
# Vegas's live check (4 Hz) can see the game before the notice file's
|
||||
# 1 Hz stat sees the notice; then the game is up for at most a second
|
||||
# before the notice preempts it.
|
||||
i = yielded + 1
|
||||
if rows[i][1] == "sports_live":
|
||||
assert rows[i][2] <= 1.0 and rows[i][3] == "wifi"
|
||||
i += 1
|
||||
i = _notice_then_game(rows, i, 40, 43)
|
||||
# Neither the rotation nor the ticker runs while the game is live.
|
||||
during = [row[1] for row in rows[yielded + 1:] if row[0] < 70]
|
||||
assert set(during) <= {"sports_live", "<wifi>"}
|
||||
assert rows[-1][1] == "<vegas>"
|
||||
|
||||
|
||||
def test_vegas_stopped_for_a_game_shows_a_known_notice_first(tmp_path):
|
||||
# The notice is already posted when Vegas stops for the game: each Vegas
|
||||
# frame runs its live check before its interrupt check, so the game can
|
||||
# be what stops it. Without the interrupt check the notice is only
|
||||
# learned after the yield, which pins the order the yield path checks
|
||||
# them in: the notice first.
|
||||
def build(h):
|
||||
h.add_plugin(FakePlugin("clock", ["clock"], duration=20))
|
||||
_sports(h, (40, 70), content=lambda t, mode: 40 <= t < 70)
|
||||
vegas = h.enable_vegas(cycle=30)
|
||||
vegas.set_interrupt_checker(lambda: False)
|
||||
h.wifi_message(39.5, "Connected to HomeNet", duration=4)
|
||||
rows = _run(tmp_path, 110, build)["screens"]
|
||||
|
||||
yielded = next(i for i, row in enumerate(rows) if row[3] == "vegas-live")
|
||||
assert 40.0 <= rows[yielded][0] + rows[yielded][2] <= 40.3
|
||||
_notice_then_game(rows, yielded + 1, 40.0, 43.5)
|
||||
|
||||
|
||||
def test_scheduled_off_shows_neither(tmp_path):
|
||||
# The harness clock starts at 22:59:30: off from 23:00 (t=30) to 23:05
|
||||
# (t=330). The notice and the game both arrive at t=120, well inside it
|
||||
# (whichever way the end minute is counted).
|
||||
def build(h):
|
||||
h.config["schedule"] = {"enabled": True, "start_time": "23:05", "end_time": "23:00"}
|
||||
h.add_plugin(FakePlugin("clock", ["clock"], duration=20))
|
||||
h.add_plugin(FakePlugin("weather", ["weather"], duration=20))
|
||||
_sports(h, (120, 400))
|
||||
h.wifi_message(120, "AP mode on", duration=30)
|
||||
trace = _run(tmp_path, 380, build)
|
||||
rows = trace["screens"]
|
||||
|
||||
off = next(i for i, row in enumerate(rows) if row[1] == "<off>")
|
||||
assert rows[off][0] <= 90.0
|
||||
assert rows[off][0] + rows[off][2] == 330.0 and rows[off][3] == "schedule-on"
|
||||
assert not any(row[1] == "<wifi>" for row in rows)
|
||||
live_events = [e for e in trace["events"] if e[1] == "live"]
|
||||
assert live_events and all(e[0] >= 330.0 for e in live_events)
|
||||
# The game, still live when the panel comes back, is what shows.
|
||||
assert rows[off + 1][1] == "sports_live"
|
||||
+42
-3
@@ -73,6 +73,27 @@ def _frame(shade):
|
||||
return Image.new("RGB", (8, 64), (shade, shade, shade))
|
||||
|
||||
|
||||
class TestRefreshPlan:
|
||||
BANDS = [(32, 64, 1)]
|
||||
|
||||
def test_one_refresh_per_frame_is_a_plain_lag(self):
|
||||
assert scan_order.refresh_plan(self.BANDS, 1) == [((1,), 1)]
|
||||
|
||||
def test_a_held_frame_lags_only_its_first_refresh(self):
|
||||
assert scan_order.refresh_plan(self.BANDS, 2) == [((1,), 1), ((0,), 1)]
|
||||
assert scan_order.refresh_plan(self.BANDS, 5) == [((1,), 1), ((0,), 4)]
|
||||
|
||||
def test_a_lag_longer_than_the_hold_reaches_further_back(self):
|
||||
# Three halves down a stack, held for two refreshes.
|
||||
plan = scan_order.refresh_plan([(0, 8, 3)], 2)
|
||||
assert plan == [((2,), 1), ((1,), 1)]
|
||||
|
||||
def test_every_refresh_of_the_frame_is_accounted_for(self):
|
||||
for hold in range(1, 9):
|
||||
plan = scan_order.refresh_plan([(0, 8, 1), (8, 16, 2)], hold)
|
||||
assert sum(count for _, count in plan) == hold
|
||||
|
||||
|
||||
class TestCompose:
|
||||
def test_a_band_comes_from_the_frame_that_many_refreshes_back(self):
|
||||
now, previous = _frame(30), _frame(20)
|
||||
@@ -133,10 +154,28 @@ class TestUpdateDisplay:
|
||||
assert shown.getpixel((0, 0)) == (20, 0, 0)
|
||||
assert shown.getpixel((0, 31)) == (10, 0, 0)
|
||||
|
||||
def test_not_on_a_static_screen_or_a_held_frame(self, dm):
|
||||
def test_not_on_a_static_screen(self, dm):
|
||||
dm._scan_lag_bands = [(16, 32, 1)]
|
||||
self._push(dm, 10)
|
||||
assert self._push(dm, 20).getpixel((0, 31)) == (20, 0, 0) # not scrolling
|
||||
|
||||
def test_a_held_frame_is_split_so_the_lagging_half_steps_a_refresh_late(self, dm):
|
||||
dm._scan_lag_bands = [(16, 32, 1)]
|
||||
dm.set_scrolling_state(True, 2)
|
||||
self._push(dm, 30)
|
||||
assert self._push(dm, 40).getpixel((0, 31)) == (40, 0, 0) # hold 2
|
||||
self._push(dm, 10)
|
||||
before = len(dm._presented)
|
||||
self._push(dm, 20)
|
||||
first, second = dm._presented[before:]
|
||||
assert first.getpixel((0, 0)) == (20, 0, 0)
|
||||
assert first.getpixel((0, 31)) == (10, 0, 0) # lagging half: still old
|
||||
assert second.getpixel((0, 31)) == (20, 0, 0) # caught up a refresh later
|
||||
|
||||
def test_a_slow_blit_is_not_split(self, dm):
|
||||
dm._scan_lag_bands = [(16, 32, 1)]
|
||||
dm.set_scrolling_state(True, 3)
|
||||
self._push(dm, 10)
|
||||
dm._last_blit_seconds = 1.0 # far longer than a refresh
|
||||
before = len(dm._presented)
|
||||
self._push(dm, 20)
|
||||
assert len(dm._presented) - before == 1
|
||||
assert dm._presented[-1].getpixel((0, 31)) == (20, 0, 0)
|
||||
|
||||
@@ -13,6 +13,7 @@ from src.common.scroll_config import ( # noqa: E402
|
||||
MAX_PIXELS_PER_FRAME,
|
||||
crisp_ladder,
|
||||
solve_crisp,
|
||||
speed_advice,
|
||||
MAX_PIXELS_PER_SECOND,
|
||||
MIN_PIXELS_PER_SECOND,
|
||||
ScrollSettings,
|
||||
@@ -465,3 +466,41 @@ class TestFrameHoldIsReportedNotApplied:
|
||||
display_manager=dm)
|
||||
assert dm.calls == [], "configure() must not apply the hold itself"
|
||||
assert settings.frame_hold == 4, "but it must report what to apply"
|
||||
|
||||
|
||||
class TestSpeedAdvice:
|
||||
def test_default_speed_on_a_120hz_panel_is_not_left_stepped(self):
|
||||
"""50 px/s used to snap to 48 (2px every 5 refreshes, 24fps)."""
|
||||
got = solve_crisp(50, 120)
|
||||
assert got.steppiness == "smooth"
|
||||
assert got.pixels_per_frame == 1
|
||||
|
||||
def test_unchanged_choices_on_a_100hz_panel(self):
|
||||
assert solve_crisp(50, 100).pixels_per_second == pytest.approx(50.0)
|
||||
assert solve_crisp(60, 100).pixels_per_second == pytest.approx(66.667, abs=0.01)
|
||||
|
||||
def test_smooth_exact_speed_needs_no_alternatives(self):
|
||||
advice = speed_advice(60, 120)
|
||||
assert advice["exact"] and advice["smooth"]
|
||||
assert advice["alternatives"] == []
|
||||
|
||||
def test_off_ladder_speed_offers_the_nearest_smooth_ones(self):
|
||||
advice = speed_advice(50, 120, 10, 200)
|
||||
assert advice["applied"]["steppiness"] == "smooth"
|
||||
offered = [a["pixels_per_second"] for a in advice["alternatives"]]
|
||||
assert offered == [40.0, 60.0]
|
||||
assert all(a["steppiness"] == "smooth" for a in advice["alternatives"])
|
||||
|
||||
def test_alternatives_stay_inside_the_requested_range(self):
|
||||
advice = speed_advice(50, 120, 45, 200)
|
||||
assert all(45 <= a["pixels_per_second"] <= 200 for a in advice["alternatives"])
|
||||
|
||||
def test_a_whole_number_near_the_panels_speed_counts_as_exact(self):
|
||||
"""The UI sends 63 for a 62.9 px/s panel."""
|
||||
assert speed_advice(63, 125.74)["exact"]
|
||||
|
||||
def test_a_measured_rate_does_not_let_a_stepped_speed_through(self):
|
||||
"""125.74Hz: 50.3px/s is 2px every 5 refreshes at 25.1fps."""
|
||||
got = solve_crisp(50, 125.74)
|
||||
assert got.steppiness == "smooth"
|
||||
assert got.pixels_per_frame == 1
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"""A WiFi notice preempts the current screen promptly and stays up.
|
||||
|
||||
It used to be checked only between screens, so a notice shorter than the
|
||||
screen it was posted during expired unseen, and Vegas yielding for one went
|
||||
on to a rotation screen instead of the notice. Runs the real run() loop on
|
||||
the fake clock of test/_run_loop_harness.py.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("EMULATOR", "true")
|
||||
|
||||
from test._run_loop_harness import FakePlugin, RunLoopHarness # noqa: E402
|
||||
|
||||
|
||||
def _rows(trace):
|
||||
return [tuple(row[:4]) for row in trace["screens"]]
|
||||
|
||||
|
||||
def _wifi_rows(trace):
|
||||
return [row for row in trace["screens"] if row[1] == "<wifi>"]
|
||||
|
||||
|
||||
def test_notice_preempts_a_static_screen_and_the_mode_resumes(tmp_path):
|
||||
h = RunLoopHarness(tmp_path, horizon=70)
|
||||
h.add_plugin(FakePlugin("clock", ["clock"], duration=20))
|
||||
h.add_plugin(FakePlugin("weather", ["weather"], duration=20))
|
||||
h.wifi_message(25.3, "Connected to HomeNet", duration=5)
|
||||
trace = h.run()
|
||||
|
||||
(wifi,) = _wifi_rows(trace)
|
||||
# Within about a second of being posted (the 1 Hz loop's next frame),
|
||||
# and up until it expires at 30.3.
|
||||
assert 25.3 <= wifi[0] <= 26.3
|
||||
assert wifi[0] + wifi[2] >= 30.3
|
||||
rows = _rows(trace)
|
||||
i = rows.index(tuple(wifi[:4]))
|
||||
assert rows[i - 1][1] == "weather" and rows[i - 1][3] == "wifi"
|
||||
# The interrupted screen comes back in full; the rotation is not skipped.
|
||||
assert rows[i + 1][1:3] == ("weather", 20.0)
|
||||
assert rows[i + 2][1] == "clock"
|
||||
|
||||
|
||||
def test_notice_preempts_a_high_fps_screen(tmp_path):
|
||||
h = RunLoopHarness(tmp_path, horizon=60)
|
||||
h.add_plugin(FakePlugin("ticker", ["ticker"], duration=30, enable_scrolling=True))
|
||||
h.add_plugin(FakePlugin("clock", ["clock"], duration=20))
|
||||
h.wifi_message(10.0, "AP mode on", duration=4)
|
||||
trace = h.run()
|
||||
|
||||
(wifi,) = _wifi_rows(trace)
|
||||
assert 10.0 <= wifi[0] <= 11.0
|
||||
assert wifi[0] + wifi[2] >= 14.0
|
||||
assert trace["screens"][0][1:4] == ["ticker", wifi[0], "wifi"]
|
||||
|
||||
|
||||
def test_notice_cuts_the_make_up_dwell_short(tmp_path):
|
||||
# flaky returns False after its first frame, so the rest of its screen
|
||||
# is a make-up dwell rather than a frame loop.
|
||||
h = RunLoopHarness(tmp_path, horizon=40)
|
||||
h.add_plugin(FakePlugin("flaky", ["flaky"], duration=20, first_frame_only=True))
|
||||
h.add_plugin(FakePlugin("clock", ["clock"], duration=20))
|
||||
h.wifi_message(8.0, "Connected to HomeNet", duration=5)
|
||||
trace = h.run()
|
||||
|
||||
(wifi,) = _wifi_rows(trace)
|
||||
assert 8.0 <= wifi[0] <= 9.0
|
||||
assert wifi[0] + wifi[2] >= 13.0
|
||||
rows = _rows(trace)
|
||||
i = rows.index(tuple(wifi[:4]))
|
||||
# flaky was cut short, so it comes back rather than being rotated past.
|
||||
assert rows[i + 1][1] == "flaky"
|
||||
|
||||
|
||||
def test_a_screen_that_ran_its_full_time_is_not_repeated(tmp_path):
|
||||
# Posted just before clock's 20 s are up: clock ends on time, the notice
|
||||
# shows, and the rotation moves on to weather rather than clock again.
|
||||
h = RunLoopHarness(tmp_path, horizon=50)
|
||||
h.add_plugin(FakePlugin("clock", ["clock"], duration=20))
|
||||
h.add_plugin(FakePlugin("weather", ["weather"], duration=20))
|
||||
h.wifi_message(19.5, "Connected to HomeNet", duration=5)
|
||||
trace = h.run()
|
||||
|
||||
rows = _rows(trace)
|
||||
assert rows[0] == (0.0, "clock", 20.0, "wifi")
|
||||
assert rows[1][1] == "<wifi>"
|
||||
assert rows[2][1] == "weather"
|
||||
|
||||
|
||||
def test_on_demand_still_outranks_the_notice(tmp_path):
|
||||
h = RunLoopHarness(tmp_path, horizon=80)
|
||||
h.add_plugin(FakePlugin("clock", ["clock"], duration=20))
|
||||
h.add_plugin(FakePlugin("weather", ["weather"], duration=20))
|
||||
h.on_demand_request(5, "o1", plugin_id="weather", duration=30)
|
||||
h.wifi_message(15, "AP mode on", duration=30)
|
||||
trace = h.run()
|
||||
|
||||
(wifi,) = _wifi_rows(trace)
|
||||
# Held back until the on-demand session expires at about t=35.
|
||||
assert wifi[0] >= 35.0
|
||||
on_demand = [r for r in trace["screens"] if r[1] == "weather" and r[0] < 35]
|
||||
assert on_demand and all(r[3] != "wifi" for r in on_demand[:-1])
|
||||
|
||||
|
||||
def test_vegas_yields_to_the_notice_then_resumes(tmp_path):
|
||||
h = RunLoopHarness(tmp_path, horizon=80)
|
||||
h.add_plugin(FakePlugin("clock", ["clock"], duration=20))
|
||||
h.enable_vegas(cycle=30)
|
||||
h.wifi_message(40, "Connected to HomeNet", duration=3)
|
||||
trace = h.run()
|
||||
|
||||
rows = _rows(trace)
|
||||
i = next(n for n, row in enumerate(rows) if row[3] == "vegas-interrupt")
|
||||
assert rows[i][1] == "<vegas>" and 40.0 <= rows[i][0] + rows[i][2] <= 41.0
|
||||
# The notice is what shows next, for its whole 3 s, and Vegas follows.
|
||||
assert rows[i + 1][1] == "<wifi>"
|
||||
assert rows[i + 1][0] + rows[i + 1][2] >= 43.0
|
||||
assert rows[i + 2][1] == "<vegas>"
|
||||
assert "clock" not in [row[1] for row in rows[i:]]
|
||||
|
||||
|
||||
def test_pending_check_ignores_a_cached_notice_that_has_expired(tmp_path):
|
||||
h = RunLoopHarness(tmp_path, horizon=10)
|
||||
dc = h.controller
|
||||
dc._check_wifi_status_message = lambda: {"message": "x", "expires_at": 0.0}
|
||||
assert not dc._wifi_notice_pending()
|
||||
dc._check_wifi_status_message = lambda: {"message": "x", "expires_at": float("inf")}
|
||||
assert dc._wifi_notice_pending()
|
||||
dc.on_demand_active = True
|
||||
assert not dc._wifi_notice_pending()
|
||||
@@ -0,0 +1,49 @@
|
||||
"""GET /api/v3/config/scroll-speed-advice: what the panel does with a speed."""
|
||||
import json
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
from web_interface.blueprints.api_v3 import api_v3
|
||||
from web_interface.blueprints.api_v3 import config as config_routes
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch, tmp_path):
|
||||
stats = tmp_path / "stats.json"
|
||||
monkeypatch.setattr("src.common.frame_timing.default_stats_path", lambda: str(stats))
|
||||
manager = MagicMock()
|
||||
manager.load_config.return_value = {
|
||||
"display": {"hardware": {"limit_refresh_rate_hz": 120}}}
|
||||
monkeypatch.setattr(api_v3, "config_manager", manager, raising=False)
|
||||
app = Flask(__name__)
|
||||
app.register_blueprint(api_v3, url_prefix="/api/v3")
|
||||
c = app.test_client()
|
||||
c.stats_path = stats
|
||||
return c
|
||||
|
||||
|
||||
def test_uses_the_configured_cap_without_a_measurement(client):
|
||||
body = client.get("/api/v3/config/scroll-speed-advice?speed=50").get_json()
|
||||
data = body["data"]
|
||||
assert data["refresh_source"] == "configured"
|
||||
assert data["refresh_hz"] == 120.0
|
||||
assert [a["pixels_per_second"] for a in data["alternatives"]] == [40.0, 60.0]
|
||||
|
||||
|
||||
def test_prefers_the_measured_refresh(client):
|
||||
client.stats_path.write_text(json.dumps({"measured_refresh_hz": 125.74}))
|
||||
data = client.get("/api/v3/config/scroll-speed-advice?speed=50").get_json()["data"]
|
||||
assert data["refresh_source"] == "measured"
|
||||
assert data["refresh_hz"] == 125.7
|
||||
|
||||
|
||||
def test_ignores_an_implausible_stale_measurement(client):
|
||||
client.stats_path.write_text(json.dumps({"measured_refresh_hz": 40.0}))
|
||||
data = client.get("/api/v3/config/scroll-speed-advice?speed=50").get_json()["data"]
|
||||
assert data["refresh_source"] == "configured"
|
||||
|
||||
|
||||
def test_rejects_a_non_numeric_speed(client):
|
||||
assert client.get("/api/v3/config/scroll-speed-advice?speed=fast").status_code == 400
|
||||
@@ -84,6 +84,49 @@ def get_main_config():
|
||||
# any of it; /api/v3/auth/* manages it.
|
||||
return jsonify({'status': 'success',
|
||||
'data': _redact_credentials(strip_auth_section(config))})
|
||||
def _panel_refresh_hz(config):
|
||||
"""(hz, source): the rate the panel really refreshes at, else the cap.
|
||||
|
||||
The display service writes what it measured to the frame-stats file. The
|
||||
configured limit_refresh_rate_hz is only a cap (a 120Hz cap refreshes at
|
||||
~126Hz on one rig, ~95Hz on a long chain), and the speeds that look smooth
|
||||
are fractions of the real rate, so advice built on the cap can be wrong.
|
||||
"""
|
||||
from src.common import frame_timing, scroll_config
|
||||
cap = scroll_config.refresh_hz_from_config(config)
|
||||
try:
|
||||
with open(frame_timing.default_stats_path(), encoding='utf-8') as fh:
|
||||
measured = float(json.load(fh).get('measured_refresh_hz') or 0)
|
||||
except (OSError, ValueError, TypeError, AttributeError):
|
||||
measured = 0.0
|
||||
# Reject a stale file from a previous hardware config: a measurement far
|
||||
# off the cap says the config changed since it was written.
|
||||
if measured > 0 and 0.5 * cap <= measured <= 1.5 * cap:
|
||||
return measured, 'measured'
|
||||
return cap, 'configured'
|
||||
|
||||
|
||||
@api_v3.route('/config/scroll-speed-advice', methods=['GET'])
|
||||
def get_scroll_speed_advice():
|
||||
"""What this panel does with a requested scroll speed, and smooth options.
|
||||
|
||||
Backs the hint under the Vegas Scroll Speed slider.
|
||||
"""
|
||||
from src.common import scroll_config
|
||||
try:
|
||||
speed = float(request.args.get('speed', ''))
|
||||
lo = float(request.args.get('min', 10))
|
||||
hi = float(request.args.get('max', 200))
|
||||
except ValueError:
|
||||
return jsonify({'status': 'error', 'message': 'speed must be a number'}), 400
|
||||
if not api_v3.config_manager:
|
||||
return jsonify({'status': 'error', 'message': 'Config manager not initialized'}), 500
|
||||
hz, source = _panel_refresh_hz(api_v3.config_manager.load_config())
|
||||
advice = scroll_config.speed_advice(speed, hz, lo, hi)
|
||||
advice['refresh_source'] = source
|
||||
return jsonify({'status': 'success', 'data': advice})
|
||||
|
||||
|
||||
@api_v3.route('/config/schedule', methods=['GET'])
|
||||
def get_schedule_config():
|
||||
"""Get current schedule configuration"""
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Plugin health, resource metrics and resource limits.
|
||||
"""Plugin health, resource metrics, fetch statistics and resource limits.
|
||||
|
||||
The display process records health and metrics to the shared on-disk cache;
|
||||
these routes read (and reset) that published state through a tracker and a
|
||||
@@ -193,6 +193,24 @@ def reset_plugin_metrics(plugin_id):
|
||||
})
|
||||
|
||||
|
||||
@api_v3.route('/plugins/fetch-stats', methods=['GET'])
|
||||
def get_fetch_stats():
|
||||
"""Network requests per plugin and per host, as the display counts them.
|
||||
|
||||
Read-only. The display's fetch service (src/common/fetch_service.py)
|
||||
publishes its counters to the shared cache at most once a minute when
|
||||
they change; this returns that snapshot judged for staleness:
|
||||
``data.status`` is ``live``, ``stale``, ``stopped`` or ``unknown``, and
|
||||
``data.data`` the snapshot (None when unknown). Counters are cumulative
|
||||
since the display started.
|
||||
"""
|
||||
from src.common.fetch_service import read_fetch_stats
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'data': read_fetch_stats(getattr(api_v3, 'cache_manager', None)),
|
||||
})
|
||||
|
||||
|
||||
@api_v3.route('/plugins/limits/<plugin_id>', methods=['GET', 'POST'])
|
||||
def manage_plugin_limits(plugin_id):
|
||||
"""Get or set resource limits for a plugin.
|
||||
|
||||
@@ -457,7 +457,7 @@
|
||||
<div id="vegas_scroll_settings" class="space-y-4" style="{% if not main_config.display.get('vegas_scroll', {}).get('enabled', false) %}display: none;{% endif %}">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-group" id="setting-display-vegas_scroll_speed" data-setting-key="display.vegas_scroll.scroll_speed">
|
||||
<label for="vegas_scroll_speed" class="block text-sm font-medium text-gray-700">Scroll Speed (pixels/second){{ ui.help_tip('How fast the Vegas ticker scrolls (10–200 px/s).\nDefault: 50. Higher is faster but harder to read.', 'Scroll Speed') }}</label>
|
||||
<label for="vegas_scroll_speed" class="block text-sm font-medium text-gray-700">Scroll Speed (pixels/second){{ ui.help_tip('How fast the Vegas ticker scrolls (10–200 px/s).\nDefault: 50. Higher is faster but harder to read. Only some speeds look perfectly smooth on a given panel; the note below the slider shows which.', 'Scroll Speed') }}</label>
|
||||
<div class="flex items-center space-x-2">
|
||||
<input type="range"
|
||||
id="vegas_scroll_speed"
|
||||
@@ -465,10 +465,11 @@
|
||||
value="{{ main_config.display.get('vegas_scroll', {}).get('scroll_speed', 50) }}"
|
||||
min="10"
|
||||
max="200"
|
||||
step="5"
|
||||
step="1"
|
||||
class="flex-1">
|
||||
<span id="vegas_scroll_speed_value" class="text-sm font-medium w-12">{{ main_config.display.get('vegas_scroll', {}).get('scroll_speed', 50) }}</span>
|
||||
</div>
|
||||
<p id="vegas_scroll_speed_hint" class="mt-1 text-xs text-gray-600" aria-live="polite"></p>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="setting-display-vegas_separator_width" data-setting-key="display.vegas_scroll.separator_width">
|
||||
@@ -915,6 +916,10 @@ document.getElementById('brightness').addEventListener('input', function() {
|
||||
});
|
||||
}
|
||||
|
||||
// Declared before first use: let is not hoisted usably.
|
||||
let scrollHintTimer = null;
|
||||
let scrollHintSeq = 0;
|
||||
|
||||
// Update scroll speed display
|
||||
const scrollSpeedSlider = document.getElementById('vegas_scroll_speed');
|
||||
const scrollSpeedValue = document.getElementById('vegas_scroll_speed_value');
|
||||
@@ -922,6 +927,62 @@ document.getElementById('brightness').addEventListener('input', function() {
|
||||
if (scrollSpeedSlider && scrollSpeedValue) {
|
||||
scrollSpeedSlider.addEventListener('input', function() {
|
||||
scrollSpeedValue.textContent = this.value;
|
||||
refreshScrollSpeedHint();
|
||||
});
|
||||
refreshScrollSpeedHint();
|
||||
}
|
||||
|
||||
// Tell the user what the panel will really do with this speed. Only
|
||||
// speeds that advance a whole number of pixels per refresh look smooth,
|
||||
// and which those are depends on the panel, so the server works it out.
|
||||
function refreshScrollSpeedHint() {
|
||||
clearTimeout(scrollHintTimer);
|
||||
scrollHintTimer = setTimeout(function() {
|
||||
const hint = document.getElementById('vegas_scroll_speed_hint');
|
||||
if (!hint) return;
|
||||
const seq = ++scrollHintSeq;
|
||||
const q = new URLSearchParams({
|
||||
speed: scrollSpeedSlider.value,
|
||||
min: scrollSpeedSlider.min,
|
||||
max: scrollSpeedSlider.max
|
||||
});
|
||||
fetch('/api/v3/config/scroll-speed-advice?' + q)
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(body) {
|
||||
if (seq !== scrollHintSeq || body.status !== 'success') return;
|
||||
renderScrollSpeedHint(hint, body.data);
|
||||
})
|
||||
.catch(function() { hint.textContent = ''; });
|
||||
}, 150);
|
||||
}
|
||||
|
||||
function renderScrollSpeedHint(hint, a) {
|
||||
const ap = a.applied;
|
||||
const motion = ap.pixels_per_frame + ' px every ' + ap.frame_hold +
|
||||
' refresh' + (ap.frame_hold === 1 ? '' : 'es');
|
||||
hint.textContent = '';
|
||||
hint.className = 'mt-1 text-xs ' + (a.smooth && a.exact ? 'text-green-700' : 'text-amber-700');
|
||||
const line = document.createElement('span');
|
||||
if (a.smooth && a.exact) {
|
||||
line.textContent = 'Smooth on this panel (' + motion + ', ' + a.refresh_hz + ' Hz).';
|
||||
} else {
|
||||
line.textContent = a.requested + ' px/s will run as ' + ap.pixels_per_second +
|
||||
' px/s (' + motion + ', ' + ap.steppiness + ') on this ' + a.refresh_hz +
|
||||
' Hz panel.' + (a.alternatives.length ? ' Smooth speeds: ' : '');
|
||||
}
|
||||
hint.appendChild(line);
|
||||
a.alternatives.forEach(function(alt, i) {
|
||||
if (i > 0) hint.appendChild(document.createTextNode(' '));
|
||||
const value = Math.round(alt.pixels_per_second);
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'underline font-medium';
|
||||
btn.textContent = value + ' px/s';
|
||||
btn.addEventListener('click', function() {
|
||||
scrollSpeedSlider.value = value;
|
||||
scrollSpeedSlider.dispatchEvent(new Event('input', {bubbles: true}));
|
||||
});
|
||||
hint.appendChild(btn);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user