Compare commits

...
8 Commits
Author SHA1 Message Date
ChuckandClaude Opus 5.5 41db488c73 fix(display): schedule windows end at the end time; on-demand ending in off hours blanks at once (#714)
Schedule and dim windows are half-open [start, end): on from the start time, off at exactly the end time, whatever second the check runs. An on-demand session that ends in scheduled-off hours (expiry or stop) clears the once-a-minute schedule gate, so the panel blanks within about a second. Golden: schedule; two test_display_pending_changes.py tests now say end_time 23:00.

Merged with #712 and #713: with all three in, docs/RUN_LOOP_REDESIGN.md's 'may be wrong' list is empty, so that section now records that all six items are fixed and by which PR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 16:10:04 -04:00
ChuckandClaude Opus 5.5 77e0ea91ae fix(display): live games take over within a second, and straight from Vegas (#713)
A game that goes live takes over within about a second (_check_live_takeover in the frame loops and the dwell sleep, throttled to 1 s, never during on-demand, scheduled-off, live_in_ticker or an already-live screen); an interrupted Vegas iteration switches straight to the game; has_live_content() is asked once per plugin per scan. Goldens: live_priority, vegas.

Merged with #712: after an interrupted Vegas iteration the WiFi-notice check runs before the live switch (WiFi outranks live). Adds test/test_run_loop_wifi_and_live.py, pinning that a notice and a game arriving during the same screen (1 Hz, 125 Hz, Vegas) show the notice first, then the game, and neither while scheduled off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 15:59:14 -04:00
ChuckandClaude Opus 5.5 6c6394a1a7 fix(display): a WiFi notice preempts the current screen and Vegas yields to it (#712)
A WiFi notice preempts the current screen within about a second (frame loops, post-loop check, make-up dwell), and an interrupted Vegas iteration that yielded for a notice ends the pass so the notice shows next. Goldens: wifi_notice, vegas. First of three run-loop fixes (#712, #713, #714), pre-tested together.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 15:51:00 -04:00
ChuckandClaude Opus 5.5 4be53d048b feat(fetch): shared fetch service, stage 1 (pooling, merging, host budgets, counters) (#702)
Core's own HTTP fetch paths (APIHelper, fetch_espn_scoreboard and its date chunks, BackgroundDataService, BaseOddsManager.get_odds) go through one service in src/common/fetch_service.py: shared connection pools per retry policy, merged identical in-flight GETs, per-host token-bucket budgets (fetch_service.rate_limits), and per-plugin request counters published to GET /api/v3/plugins/fetch-stats. Return values, exceptions, cache keys, TTLs and retry policies are unchanged. Core-internal in this release; plugins should not import it directly yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 15:38:11 -04:00
ChuckandClaude Opus 5.5 9edeb6da14 fix(display): a raising display() counts as a circuit-breaker failure (#707)
The first-frame dispatch (_dispatch_first_frame) now asks PluginExecutor.execute_display() to re-raise (raise_errors=True) and records a raise inside the executor as a breaker failure, with the original exception as last_error, instead of a success. The screen is still an empty pass and rotation is unchanged; a hung display() is still recorded once, as a hang. The run-loop golden trace plugin_error.json is regenerated (crashy now records health failures and is skipped by the breaker), and behaviour 7 is dropped from docs/RUN_LOOP_REDESIGN.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 14:51:38 -04:00
ChuckandClaude Opus 5.5 a21650e746 refactor(display): run() stage 1 - golden traces and extracted helpers, no behaviour change (#704)
Adds golden trace tests for DisplayController.run() (test/test_run_loop_golden.py on a fake clock with fake plugins, 15 scenarios, fixtures in test/fixtures/run_loop_golden/) and moves twelve blocks of run() into named helpers (_dispatch_first_frame, _resolve_durations, _resolve_active_mode, _needs_high_fps, _advance_after_screen and others) with the traces identical before and after. docs/RUN_LOOP_REDESIGN.md describes the target structure. Hardware-checked on hdpi: Vegas late-frame rate unchanged in an ABBA A/B.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 14:41:43 -04:00
ChuckandClaude Sonnet 5.5 eb8128a981 feat(display): cancel the half-panel scan offset on slower, held-frame scrolls (#711)
Scan-order compensation only ran at one frame per refresh, so a crisp scroll
like 60 px/s on a 120 Hz panel (1px every 2 refreshes) showed a half-pixel
step across the middle of the panel. A held frame is now presented as a
sequence of swaps (scan_order.refresh_plan): the lagging half shows the
previous frame for its first refresh and the new one for the rest, so it
steps one refresh after the rest. Skipped when a blit takes over half a
refresh, since the second blit has to land before the next vsync.

Soaked on ledpi (60 px/s, 120 Hz): 0.16% late frames, as before the change.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 14:32:24 -04:00
ChuckandClaude Sonnet 5.5 16b566e14f feat(scroll): show which scroll speeds are smooth on this panel (#710)
* feat(scroll): show which scroll speeds are smooth on this panel

The Vegas Scroll Speed slider now says what the panel will do with the
chosen speed and offers the nearest smooth ones to click. Backed by
scroll_config.speed_advice() and GET /api/v3/config/scroll-speed-advice,
which uses the refresh the display measured rather than the cap.

Also stops the default 50 px/s snapping to a stepped 48 px/s (2px every 5
refreshes, 24fps) on a 120Hz panel: the low-fps penalty in solve_crisp()
now loses to 60 or 40 px/s. 100Hz panels are unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* fix(scroll): hint threw before its timer variables existed; count 25-30fps as stepped

The Vegas speed hint called refreshScrollSpeedHint() before the let
declarations it uses, so it never rendered (found on ledpi). And the
solver's low-fps penalty stopped at 25fps, which let a measured 125.7Hz
panel keep a 25.1fps 2px-every-5-refreshes scroll.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* test: add the scroll-speed-advice route to the /api/v3 URL map snapshot

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 14:15:22 -04:00
53 changed files with 6289 additions and 549 deletions
+117
View File
@@ -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,
+10
View File
@@ -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
+7 -2
View File
@@ -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
+10 -1
View File
@@ -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
+58
View File
@@ -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
+57
View File
@@ -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>`
+277
View File
@@ -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.
+13 -6
View File
@@ -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).
+1
View File
@@ -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
+40 -4
View File
@@ -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
+8 -1
View File
@@ -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
View File
@@ -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
+14 -7
View File
@@ -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,
+29 -3
View File
@@ -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
+66 -2
View File
@@ -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],
}
+1
View File
@@ -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',
+737 -454
View File
File diff suppressed because it is too large Load Diff
+50 -25
View File
@@ -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."""
+18 -2
View File
@@ -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(
+22 -13
View File
@@ -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
@@ -424,6 +425,11 @@ class PluginManager:
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:
full_config = self.config_manager.load_config()
@@ -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
View File
@@ -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:
+821
View File
@@ -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}")
+18
View File
@@ -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
View File
@@ -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": []
}
+24
View File
@@ -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
View File
@@ -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
View File
@@ -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": []
}
+21
View File
@@ -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"]
]
}
+20
View File
@@ -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
View File
@@ -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"]
]
}
+29
View File
@@ -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"]
]
}
+14
View File
@@ -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"]
]
}
+17
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"]
]
}
+198 -8
View File
@@ -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()
+303
View File
@@ -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
+2 -2
View File
@@ -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:
+878
View File
@@ -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
+222
View File
@@ -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]
+218
View File
@@ -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)
+157
View File
@@ -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
View File
@@ -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)
+39
View File
@@ -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
+130
View File
@@ -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
+43
View File
@@ -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);
});
}