Compare commits

...
5 Commits
Author SHA1 Message Date
ChuckandClaude Opus 5.5 aa6d0a213d chore: prepare the 3.8.4 release (#787)
Bumps src.__version__ to 3.8.4 and turns Unreleased into ## 3.8.4: the
refresh-cap report (#759), a failed on-demand request ending its own session
(#779), reason codes instead of exception messages in API errors (#778),
and src.common.sports_rotation (#786, sports family 7), which the
scoreboards adopt by flooring on 3.8.4. src/common/README.md says 3.8.4 for
it, and the SPORTS_UNIFICATION module table gains its row.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:47:37 -04:00
ChuckandClaude Opus 5.5 e186ae8b24 feat(common): sports_rotation -- the reconciled other-games rotation (sports family 7) (#786)
* feat(common): sports_rotation -- the reconciled other-games rotation (sports family 7)

New hardware-free module src/common/sports_rotation.py, copied from
ledmatrix-plugins claude/family7-reconcile once the nine scoreboards made
_by_importance, _other_games_window, _advance_other_games_if_due (two bodies
each), _rotate_other_games_on_display (two) and _attach_odds_to_rotated_games
(three; ufc had none) one body each. All of them live on the plugins'
SportsCore, so one mixin, SportsRotationMixin, carries exactly those five plus
the default _rankings_loaded, and no manager gains a method it lacks today.

- The window advances under _games_lock: update() and display() both advance
  it, and interleaved, each added a width and skipped a window.
- The display path's due-check reads the pool _compose_selection will cut,
  unfiltered fallback included.
- Rotated-in games get odds when show_odds is on (decided 2026-10-09: ufc
  too), skipped without an odds manager.
- _rankings_loaded() is the seam _by_importance asks: the abbreviation table
  by default; football overrides it to count its by-id rankings.

- test/test_sports_rotation.py: the plugins' pinned cases (importance order
  per rankings table and the seam, the window over time, the display-path
  rotation incl. the card on screen, redraws and recompose counts, update()
  and display() in sequence and interleaved on two threads -- with and
  without the lock -- and rotated-in odds), plus the host contract.
- test/test_sports_rotation_parity.py: with LEDMATRIX_PLUGINS, compares each
  body with every plugin copy (drift-report normalisation plus decorators),
  checks no other plugin class carries a copy, and that only football
  overrides _rankings_loaded. Passes against claude/family7-reconcile; fails
  against the pre-reconcile tree, as it should.
- mypy ratchet, src/common/README.md, CHANGELOG (Unreleased, New modules).
- sports_shared docstring: _by_importance and _other_games_window (and the
  long-promoted _is_favorite_game) leave the "stay per-plugin" list.
- docs/SPORTS_UNIFICATION.md: family 7 status, decisions, the
  _rankings_loaded seam; families 5 and 6 marked done and adopted (3.8.1 /
  ledmatrix-plugins #631, 3.8.2 / #637) instead of "adoption waits".

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

* docs(sports): stage 4 is adopted (core 3.8.0, ledmatrix-plugins #594)

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

* docs: cite ledmatrix-plugins #641 for the family 7 reconcile

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:31:27 -04:00
ChuckandClaude Opus 5.5 65ede54cba fix(web): keep exception messages out of API responses (py/stack-trace-exposure) (#778)
CodeQL had ~40 open py/stack-trace-exposure alerts on main. Almost all
flowed through describe_exception(), which returned "TypeName: message"
(redacted, capped); the rest through _run_systemctl_command's str(err),
WiFiManager's `return False, str(e)`, unit_refresh's f-strings and two
str(e)/f"{err}" messages in api_v3/__init__.py.

describe_exception() now returns a reason code -- the type, plus the
errno symbol for an OSError ("OSError:EIO", "PermissionError:EACCES") --
and logs the redacted message itself. That keeps what #538 wanted (a
failing disk still says EIO in the response) without quoting paths,
URLs or library internals, and fixes every call site at once; the
test_no_api_v3_handler_discards_its_exception policy still holds.

Service results: _get_display_service_status returns active/returncode
only, and the on-demand start/stop `service` result keeps
returncode/active/started/status but drops systemctl stdout/stderr
(logged on failure). Nothing in web_interface/static, the templates or
the MQTT bridge reads those fields. The Starlark SIGKILL-restart error
no longer returns systemctl stderr as `details`.

WiFi, unit-refresh, config-save and plugin-removal failures now say
what failed with the reason code and point at the log. display.py is
untouched (draft #773 edits it).

Tests: test_api_v3_no_exception_text.py drives one route per affected
file with a marker in the exception message and asserts it never
reaches the body; all 13 fail on origin/main, and targeted mutations
(drop the service filter, put stderr back, str(e) in WiFiManager,
{e} in unit_refresh, {install_err} in system.py, message back in
describe_exception) each fail at least one. Tests that asserted the old
message-in-details contract now assert the reason code.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:15:40 -04:00
ChuckandClaude Opus 5.5 20bae8b609 fix(display): a failed on-demand request drops the session it ended (#779)
_set_on_demand_error ends any running session (_reset_on_demand_fields)
but left its saved copy, display_on_demand_config, in the cache. A failed
request that replaced a running session therefore made the next restart
resume the session that had already ended.

Clear the saved copy where every error path goes through, and drop the
two restore-failed callers' own clears, which this now covers.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:05:22 -04:00
ChuckandClaude Sonnet 5.5 1bcb524fc3 feat(scroll): report a panel that cannot reach its refresh cap, and suggest one it can hold (#759)
* feat(scroll): report a panel that cannot reach its refresh cap, and suggest one it can hold

Scroll speeds are solved against display.hardware.limit_refresh_rate_hz,
which is only a ceiling. A panel that cannot reach it still moves whole
pixels per frame, but every scroll runs slow by the shortfall and the
"smooth" ladder is the cap's, not the panel's. A user rig (Pi 4, 2x128x64,
adafruit-hat-pwm, pwm_bits 9, gpio_slowdown 5) measured 107.6-113.1 Hz under
a 120 Hz cap: 60 px/s ran at 55, and nothing said why.

- scroll_config: refresh_shortfall() (more than 3% under the planned rate),
  holdable_cap() (a multiple of 10, 5% under the measurement, since the
  measurement is the fast end of an uncapped panel's drift), and
  describe_refresh_shortfall().
- FrameTimingRecorder.plan_refresh(): once the measured period has held for
  three trusted windows, a shortfall is logged once as a warning naming the
  cap to use. DisplayManager calls it only for a real panel, not the
  emulator or the fallback canvas. The stats file records
  planned_refresh_hz (additive).
- GET /api/v3/config/refresh-rate, plus a hint under the Display tab's
  Limit Refresh Rate field (js/pages/display.js) with a button that fills in
  the suggested cap.
- _panel_refresh_hz (behind the Vegas slider's advice) ignores a measurement
  written under a different cap, so a changed cap stops being advised from
  the old rate before the display restarts.

Verified on ledpi with a temporary 200 Hz cap: the warning logged about a
minute after the restart ("about 132 Hz ... Set Limit Refresh Rate to
120 Hz"), the endpoint returned the same shortfall, and the Display tab
showed the hint; its button filled in 120. ledpi was restored afterwards.
Rebased onto main after the Display tab became an ES-module page (#771); the
hint moved from inline script into display.js, with a jsdom test.

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

* fix(scroll): count only agreeing windows toward the refresh shortfall; ignore a null planned rate

Review fixes on #759.

- A window the period estimate rejects (more than MAX_REFRESH_DROP faster
  than the adopted period) no longer counts toward the shortfall check, and
  it restarts the run. After a loaded start fixed a slow period, later
  windows at the real, faster rate were rejected yet still counted, so the
  warning could name the slow rate against a cap the panel was meeting. It
  now needs REFRESH_CHECK_WINDOWS consecutive windows that agree with the
  period.
- _panel_refresh_hz treats a stats file whose planned_refresh_hz key is
  present but null as no measurement. The emulator and the fallback canvas
  write it that way (DisplayManager never plans a refresh for them), and
  their frame rate says nothing about the cap. A file with no such key (an
  older display) keeps the old behaviour.

Three new tests fail on the previous code and pass now.

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 10:40:08 -04:00
37 changed files with 1780 additions and 122 deletions
+61
View File
@@ -19,6 +19,60 @@ accepts both, but the store flags the old spelling as deprecated
## Unreleased
## 3.8.4
A panel that cannot reach its refresh cap is reported with a cap it can hold
(#759); a failed on-demand request ends its own session (#779); exception
messages stay out of API responses (#778); and sports consolidation family 7,
`src.common.sports_rotation`, which the scoreboards adopt by flooring on
3.8.4 (#786).
### Scroll speed: a panel slower than its refresh cap is reported
- Scroll speeds are solved against `limit_refresh_rate_hz`, so a panel that
cannot reach its cap ran every scroll slow by the shortfall, with no sign
why (one Pi 4 on a 120 Hz cap refreshed at ~110 Hz: 60 px/s ran at 55).
Once the display has measured the real rate over three windows of
scrolling, a panel more than 3% short of the cap is logged once, as a
warning from `src.common.frame_timing` that names a cap it can hold (a
multiple of 10, 5% under the measurement). The Display tab shows the same
under Limit Refresh Rate, with a button that fills it in, from the new
`GET /api/v3/config/refresh-rate`. Not checked in the emulator or on the
fallback canvas.
- The frame-stats file records `planned_refresh_hz` (additive), and the
scroll-speed advice behind the Vegas slider ignores a measurement written
under a different cap. Until now, after the cap changed, the slider kept
advising from the old rate until the display restarted.
- New in `src.common.scroll_config`: `refresh_shortfall()`, `holdable_cap()`
and `describe_refresh_shortfall()`.
### Fixes
- Web API error responses no longer carry an exception's message (CodeQL
`py/stack-trace-exposure`). `describe_exception()` now returns a reason
code -- the exception type, plus the errno for an `OSError`
(`OSError:EIO`, `PermissionError:EACCES`) -- and logs the message
instead, so `details` still names the fault without quoting paths, URLs
or library internals. The display service status and the on-demand
start/stop `service` results keep `active`, `returncode` and `started`
but drop systemctl's `stdout`/`stderr`; WiFi, unit-refresh and
config-save failures say what failed and point at the log.
### New modules
- `src/common/sports_rotation.py` -- sports consolidation family 7, once the
plugins made `_by_importance`, `_other_games_window`,
`_advance_other_games_if_due` (two bodies each), `_rotate_other_games_on_display`
(two) and `_attach_odds_to_rotated_games` (three; ufc had none) one each:
`SportsRotationMixin` (`SportsCore`), the other-games rotation. The window
advances under `_games_lock`, so `update()` and `display()` advancing it at
once no longer skip a window; the display path's due-check reads the pool
`_compose_selection` will cut, unfiltered fallback included; rotated-in
games get odds when `show_odds` is on. `_rankings_loaded()` is the seam
`_by_importance` asks (default: the abbreviation table is non-empty;
football also counts its by-id table). A plugin may inherit the mixin once
it floors on 3.8.4, and deletes its copies then. (#786)
## 3.8.3
Fresh installs on Raspberry Pi OS Lite work again: 3.8.2's installer reported
@@ -149,6 +203,13 @@ plugin-facing methods only get `@deprecated` (see below).
- Unused pins dropped: `markupsafe` (Flask still installs it) and
`pytest-mock`.
### Fixes
- A failed on-demand request no longer comes back after a restart as the
session it ended. A failed request ends any running session, but the
saved copy of that session (`display_on_demand_config`) was left behind,
so the next restart of the display resumed it.
## 3.8.2
The display hands freed memory back to the OS (#774), and sports consolidation
+25
View File
@@ -77,6 +77,31 @@ 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.
### A panel that cannot reach its cap
Speeds are solved against `limit_refresh_rate_hz`, the configured cap, but a
cap is only a ceiling: a long chain, a high `pwm_bits` or a big
`gpio_slowdown` can leave the panel below it. One Pi 4 driving 2×128×64 on
`adafruit-hat-pwm` with `pwm_bits 9` and `gpio_slowdown 5` measured
107.6–113.1 Hz under a 120 Hz cap. Frames still move whole pixels, but
every scroll runs that much slower than configured (60 px/s ran at 55 px/s),
and the smooth speeds are the cap's rather than the panel's.
The display measures the real rate from its own frames. About a minute
into scrolling, a panel more than 3% short of its cap is logged once:
```
WARNING - src.common.frame_timing - The panel refreshes at about 113 Hz, below
the 120 Hz that scroll speeds are planned for ... Set Limit Refresh Rate to
100 Hz (web UI, Display tab), which this panel can hold, and restart.
```
The Display tab says the same under **Limit Refresh Rate**, with a button
that fills in the suggested cap (`GET /api/v3/config/refresh-rate`). The
suggestion is a multiple of 10 at least 5% under the measurement, because
an uncapped panel drifts and the measurement is the fast end of it. A cap the
panel holds also stops the drift.
### How a slow speed stays crisp
`SwapOnVSync(canvas, framerate_fraction)` holds each frame for N panel
+60 -14
View File
@@ -93,6 +93,7 @@ more. Shared sports code lives in `src/common`:
| `sports_font_path.py` | 3.8.0 | `resolve_font_path` — what the plugins' `_resolve_font_path` copies return |
| `sports_game_over.py` | 3.8.1 | `SportsGameOverMixin` — `_is_game_really_over`, with the `FINAL_PERIOD` seam (family 5) |
| `sports_favorites.py` | 3.8.2 | `SportsFavoritesMixin`, `SportsUpcomingFavoritesMixin`, `SportsRecentFavoritesMixin` — `_is_favorite_game` and the favourites-only picks, on the `_favorite_key` seam (family 6) |
| `sports_rotation.py` | 3.8.4 | `SportsRotationMixin` — the other-games rotation (importance order, the window, odds on rotated-in games), on the `_rankings_loaded` seam (family 7) |
Each is described in [src/common/README.md](../src/common/README.md).
@@ -128,6 +129,7 @@ deprecation cycle.
| `_custom_scorebug_layout(game, draw)` | Per-sport overlay on the base layout | no-op |
| `score_phrase(points, team_abbr)` | Celebration wording (`"GOOOOAAALLL!"` vs `"TOUCHDOWN!"`). `points` is the score delta, which sports with variable-value scores use to name the play | `"<abbr> SCORES!"` — only consulted when `CelebrationMixin` is present |
| `win_phrase(team_abbr)` | Win-celebration wording | `"<abbr> WINS!"` — mixin only |
| `_rankings_loaded()` | Whether a poll loaded, so `_by_importance` orders by rank (`sports_rotation`) | `_team_rankings_cache` is non-empty. football also counts its rankings keyed by team id |
| `_favorite_key(game, side)` | Which view-model field identifies a team for favorites matching. `sports_favorites` compares it, and each `favorite_teams` entry, stripped and upper-cased; a `None` matches nothing | `game["<side>_abbr"]`. nrl returns the ESPN team id, `None` when it is missing |
| `_config_schema_path()` | Plugin's `config_schema.json` — returning it routes `_get_layout_offset` through the `src.element_style` resolver (and gives it the defaults to compare against) | `None`, i.e. the classic inline `customization.layout` read |
| `_font_root()` | Directory to resolve `assets/fonts` against | core install root |
@@ -268,7 +270,7 @@ gave pixel-identical output for all 399 frames (192 harness screens across the
nine plugins at the eight default sizes, 72 scroll/Vegas cards, 135
celebration frames), with a parent-vs-parent rerun as the determinism control.
### Stage 4: the identical sweep (core done; adoption waits for a release)
### Stage 4: the identical sweep (done: core 3.8.0, adopted)
Re-measured on ledmatrix-plugins `56c4f15` (2026-09-30) the report still
lists 58 families identical in every copy. Stage 4 moves the ones that are
@@ -303,7 +305,7 @@ Left in the plugins, though identical:
renderers) is already core's, in `SportsHelpersMixin`; a renderer that
wants it can inherit that.
### Family 5: the game-over check (core done; adoption waits for a release)
### Family 5: the game-over check (done: core 3.8.1, adopted)
The pilot of the method below. ledmatrix-plugins `scripts/test_game_over_check.py`
(#621) pinned 3,115 answers across the nine plugins first; the reconcile
@@ -315,9 +317,10 @@ clock rule (65 cells), baseball's dormant one (53, every one a game with a
cells in hockey, basketball, football and lacrosse). The harness renders
were pixel-identical. `src/common/sports_game_over.py` holds the body;
`test/test_sports_game_over_parity.py` compares it, and each plugin's
`FINAL_PERIOD`, with the plugin copies.
`FINAL_PERIOD`, with the plugin copies. Core 3.8.1 shipped it, and all nine
scoreboards inherit it and floor on 3.8.1 (ledmatrix-plugins #631).
### Family 6: favourite matching (core done; adoption waits for a release)
### Family 6: favourite matching (done: core 3.8.2, adopted)
ledmatrix-plugins `scripts/test_favourite_matching.py` (#634) pinned 204 rows
across the nine plugins first: `_is_favorite_game` on each manager role, the
@@ -337,7 +340,8 @@ each), nrl's key (6) and its "None" match (6), and the INFO line in baseball,
football and ufc. The harness renders were byte-identical. `src/common/sports_favorites.py` holds the
bodies, one mixin per carrying class; `test/test_sports_favorites_parity.py`
compares them with the plugin copies and checks that only nrl overrides
`_favorite_key`.
`_favorite_key`. Core 3.8.2 shipped it, and all nine scoreboards inherit it and
floor on 3.8.2 (ledmatrix-plugins #637).
Left for later families: the live screens' favourites-only filter
(`_classify_live_game` and its inline copies) and favourites-first sort still
@@ -346,6 +350,38 @@ compare abbreviations exactly, and
abbreviation (or by `_team_in` where a plugin has one) instead of through
`_favorite_key`. The result-colour helpers also wait (decision above).
### Family 7: the other-games rotation (core done; adoption waits for a release)
ledmatrix-plugins `scripts/test_other_games_rotation.py` (#640) pinned 96 rows
across the nine plugins first: `_by_importance` per rankings table, core's
`_favorites_first` pools per favourites, quality, divisions and rankings, the
window over time, the real `update()` followed by `display()`'s rotation call
(the list, the card on screen, redraws and how often the list is recomposed),
`update()` and `display()` advancing the window in sequence and interleaved on
two threads, odds for rotated-in games, and `favorite_rotation_boost`'s
switch order. The reconcile (ledmatrix-plugins #641)
made `_by_importance`, `_other_games_window`, `_advance_other_games_if_due`,
`_rotate_other_games_on_display` and `_attach_odds_to_rotated_games` one body
on `SportsCore` (ufc gains the odds helper), with `_rankings_loaded` as the
seam `_by_importance` asks: the abbreviation table by default, football's
override also counts its rankings keyed by team id. Of 864 cells only those
the decisions below explain changed: the window lock (the interleaved row, in
the seven plugins with a reachable `update()` other than football), the
due-check's fallback pool (two rows in the same seven) and ufc's rotated-in
odds (one cell). The harness renders were byte-identical (208 PNGs).
`src/common/sports_rotation.py` holds the bodies in one mixin,
`SportsRotationMixin`; `test/test_sports_rotation_parity.py` compares them
with the plugin copies and checks that only football overrides
`_rankings_loaded`.
Left for later families: in eight plugins the no-favourites branch of
`update()` still picks a fixed "next N" through `_filtered_or_all` and never
builds the pools, so nothing rotates on a board with no favourites; football
routes it through `_favorites_first(games, 0, N)` (decision below, family 13).
ufc's MMA managers override `update()` and never build the pools, so the
rotation is dormant there. `_best_rank`, `_is_ranked_game` and
`_passes_other_filters` are family 8.
### Why the method changes
Byte-identical promotion has nearly run dry. Measured on ledmatrix-plugins
@@ -429,10 +465,10 @@ release.
| # | Family | Methods (variants) | Why here |
|---|---|---|---|
| 4 | Identical sweep | `manager.py`: `_dispatch_switch_refresh`, `_favorite_team_is_live`, `get_vegas_priority_weight`, `_game_involves`, `_favorite_scan_targets`, `_favorite_scan_games`, `_get_total_games_for_manager` (all nine, 1); the live-scroll helpers `_preserving_scroll_position`, `_refresh_live_scroll_managers`, `_live_scroll_managers`, `_note_live_scroll_built`, `_live_scroll_needs_rebuild`, `_live_scroll_fields` (eight, 1). `sports.py`: `_card_option`, `_filtered_or_all`, `_effective_live_duration`, `_recent_date_text` (eight, 1). 58 identical families in all | Nothing to decide; brings `manager.py` into core as a `SportsPluginHostMixin`. `_resolve_font_path` (identical in nine `sports.py` and eight renderers) becomes `sports_font_path.resolve_font_path`, not `font_layout.resolve_asset_path`, which skips the cwd. Core side done; see [Stage 4](#stage-4-the-identical-sweep-core-done-adoption-waits-for-a-release) |
| 5 | Game-over check | `SportsLive._is_game_really_over` (5) | Pure logic, no pixels; one seam, `FINAL_PERIOD`. The pilot for the procedure. Reconciled to one body and promoted as `sports_game_over`; adoption waits for the release that ships it. See [Family 5](#family-5-the-game-over-check-core-done-adoption-waits-for-a-release) |
| 6 | Favourite matching | `_is_favorite_game` (7 across three classes), `_select_games_for_display` (2: nrl), `_select_recent_games_for_display` (3) | Everything that asks "is this a favourite" goes through the 3.5.0 `_favorite_key` seam. Reconciled to one body each and promoted as `sports_favorites`; adoption waits for the release that ships it. See [Family 6](#family-6-favourite-matching-core-done-adoption-waits-for-a-release) |
| 7 | Other-games rotation | `_by_importance`, `_other_games_window`, `_advance_other_games_if_due` (2 each: football), `_rotate_other_games_on_display` (2: ufc) | One outlier each; football carries two fixes the other eight lack |
| 4 | Identical sweep | `manager.py`: `_dispatch_switch_refresh`, `_favorite_team_is_live`, `get_vegas_priority_weight`, `_game_involves`, `_favorite_scan_targets`, `_favorite_scan_games`, `_get_total_games_for_manager` (all nine, 1); the live-scroll helpers `_preserving_scroll_position`, `_refresh_live_scroll_managers`, `_live_scroll_managers`, `_note_live_scroll_built`, `_live_scroll_needs_rebuild`, `_live_scroll_fields` (eight, 1). `sports.py`: `_card_option`, `_filtered_or_all`, `_effective_live_duration`, `_recent_date_text` (eight, 1). 58 identical families in all | Nothing to decide; brings `manager.py` into core as a `SportsPluginHostMixin`. `_resolve_font_path` (identical in nine `sports.py` and eight renderers) becomes `sports_font_path.resolve_font_path`, not `font_layout.resolve_asset_path`, which skips the cwd. Done: core 3.8.0, adopted (ledmatrix-plugins #594); see [Stage 4](#stage-4-the-identical-sweep-done-core-380-adopted) |
| 5 | Game-over check | `SportsLive._is_game_really_over` (5) | Pure logic, no pixels; one seam, `FINAL_PERIOD`. The pilot for the procedure. Reconciled to one body and promoted as `sports_game_over`; shipped in 3.8.1 and adopted. See [Family 5](#family-5-the-game-over-check-done-core-381-adopted) |
| 6 | Favourite matching | `_is_favorite_game` (7 across three classes), `_select_games_for_display` (2: nrl), `_select_recent_games_for_display` (3) | Everything that asks "is this a favourite" goes through the 3.5.0 `_favorite_key` seam. Reconciled to one body each and promoted as `sports_favorites`; shipped in 3.8.2 and adopted. See [Family 6](#family-6-favourite-matching-done-core-382-adopted) |
| 7 | Other-games rotation | `_by_importance`, `_other_games_window`, `_advance_other_games_if_due` (2 each: football), `_rotate_other_games_on_display` (2: ufc), with `_attach_odds_to_rotated_games` (3; ufc had none) | One outlier each; football carried two fixes the other eight lacked. Reconciled to one body each, on a `_rankings_loaded` seam, and promoted as `sports_rotation`; adoption waits for the release that ships it. See [Family 7](#family-7-the-other-games-rotation-core-done-adoption-waits-for-a-release) |
| 8 | Rankings | `_fetch_team_rankings` (3), `_choose_poll` (3), `_load_division_team_ids`, `_passes_other_filters`, `_best_rank`, `_is_ranked_game` (2 each: football) | Needs 7; the rank badge and the "ranked only" filter read it |
| 9 | Live fetch and odds | `_fetch_todays_games` (5), `_fetch_odds` (3), `_attach_odds_to_rotated_games` (3) | The prerequisite for one shared ESPN poller across plugins |
| 10 | View model | `_extract_game_details_common` (9 of 9) | Every renderer reads it; its keys are additive-only, so reconcile to the superset and leave sport extras in `_extract_game_details` |
@@ -486,11 +522,21 @@ suspected behaviour that needs a payload or a rig to confirm first.
nine. ufc stays on the shared body, dormant: its favourites are fighters,
which its MMA managers match themselves (a follow-up). Fix ported: only a
game with an id can be a duplicate in the selection methods.
- **7, other-games rotation.** football advances the rotation window under
`_games_lock` (update() and display() both advance it; interleaved, a
window of games is skipped) and fixes a favourites-only pool that recomposed
the list on every frame. Port both. ufc does not attach odds to fights
rotated in: decide whether rotated fights show odds.
- **7, other-games rotation. Decided 2026-10-09, done:** two fixes ported
from football. The window advances under `_games_lock`: `update()` and
`display()` both advance it, and interleaved, each added a width and a
window of games was never shown. The display path's due-check looks at the
pool `_compose_selection` actually cuts from, including the unfiltered
fallback when nothing else survived. The other eight never rotated that
fallback between fetches (it moved only when `update()` ran, then several
windows at once); guessing it whenever the filtered pool was empty would
recompose an identical list on every frame while a favourite played.
Rotated-in fights in ufc follow its `show_odds` like every other fight (no
separate toggle; the rotation is dormant in ufc today, so no board
changes). `_rankings_loaded` is a seam (default: the abbreviation table is
non-empty; football counts its by-id table too). Kept for family 13: the
no-favourites branch of `update()` keeps its fixed "next N" in the eight
plugins that have it, rather than football's rotating pools.
- **8, rankings.** (a) afl, basketball, nrl and soccer turn a *standings*
payload into ranks (a pro league's standings position becomes the rank
badge); baseball, hockey, lacrosse, ufc and football do not. Which is
+1
View File
@@ -41,6 +41,7 @@ src/common/sports_font_path.py
src/common/sports_game_over.py
src/common/sports_live_scroll.py
src/common/sports_plugin_host.py
src/common/sports_rotation.py
src/common/sports_scroll.py
src/common/sports_timezone.py
src/common/sports_vegas.py
+1 -1
View File
@@ -4,5 +4,5 @@ LEDMatrix Display System
Core source package for the LED Matrix Display project.
"""
__version__ = "3.8.3"
__version__ = "3.8.4"
+16
View File
@@ -53,6 +53,7 @@ Rules for the package:
| [`sports_helpers`](#sports_helpers) | Small helpers every scoreboard `sports.py` copies | Yes (scoreboards) | 3.5.0 |
| [`sports_live_scroll`](#sports_live_scroll) | Rebuild a live scroll strip mid-cycle without moving it | Yes (scoreboards) | 3.8.0 |
| [`sports_plugin_host`](#sports_plugin_host) | Helpers of a scoreboard's plugin class (`manager.py`) | Yes (scoreboards) | 3.8.0 |
| [`sports_rotation`](#sports_rotation) | Which non-favourite games a scoreboard shows, and when the slice moves | Yes (scoreboards) | 3.8.4 |
| [`sports_scroll`](#sports_scroll) | Scoreboard scroll-display orchestration | Yes (scoreboards) | 3.2.0 |
| [`sports_shared`](#sports_shared) | Sport-independent `sports.py` methods | Yes (scoreboards) | 3.3.0 |
| [`sports_vegas`](#sports_vegas) | Live Vegas cards: keys, card cache, sticky odds, finished games | Yes (scoreboards) | 3.8.0 |
@@ -368,6 +369,21 @@ shape), `_dispatch_switch_refresh()` (a manager refresh on a daemon thread, so
`display()` never waits on the network), `get_vegas_content_type()` and small
dynamic-duration helpers. List it before `BasePlugin`.
### sports_rotation
[`sports_rotation.py`](sports_rotation.py). Sports family 7:
`SportsRotationMixin` (`SportsCore`), the other-games rotation.
`_by_importance` orders the non-favourite pool best matchup first, one game per
team, when `_rankings_loaded()` says a poll loaded (football overrides that to
count its by-id rankings). `_other_games_window` cuts the slice on screen,
advancing by its width every `other_rotation_interval_seconds` under
`_games_lock`, catching up on missed intervals and wrapping.
`_rotate_other_games_on_display` (with `_advance_other_games_if_due`) re-cuts
it from `display()` between fetches, looking at the pool `_compose_selection`
will cut, unfiltered fallback included, and keeps the card on screen when it
survives; `_attach_odds_to_rotated_games` fetches odds for the games it brought
in when `show_odds` is on.
### sports_scroll
[`sports_scroll.py`](sports_scroll.py). `SportsScrollDisplay` and
+49
View File
@@ -135,6 +135,8 @@ import time
import traceback
from typing import Any, Callable, Dict, List, Optional, Tuple, TypedDict
from src.common import scroll_config
logger = logging.getLogger(__name__)
#: Bumped when a field changes meaning, so a reader can refuse stale files.
@@ -179,6 +181,12 @@ MAX_REFRESH_DROP = 0.2
#: trusted -- about a second of scrolling.
MIN_FRAMES_FOR_REFRESH = 90
#: Consecutive windows that agree with the adopted refresh period (the one
#: that adopted it counts) before a panel slower than its cap is reported. The
#: estimate can still fall by up to MAX_REFRESH_DROP per window early on; the
#: warning should not name a rate one more window would have corrected.
REFRESH_CHECK_WINDOWS = 3
FLUSH_INTERVAL = 10.0
#: A scroll's last frame older than this is a stall worth a stack dump.
@@ -441,6 +449,12 @@ class FrameTimingRecorder:
1.0 / refresh_hz if refresh_hz and refresh_hz > 0 else None)
# The first estimate, until a second window agrees with it.
self._refresh_candidate: Optional[float] = None
# The rate scroll speeds are solved against; see plan_refresh().
self.planned_refresh_hz: Optional[float] = None
# Trusted windows seen since the period was adopted, until the
# shortfall check has run.
self._refresh_windows = 0
self._shortfall_checked = True
self.totals: Dict[str, Any] = {
"static_frames": 0,
"scroll_frames": 0,
@@ -639,7 +653,20 @@ class FrameTimingRecorder:
self._refresh_candidate = estimate
elif current * (1.0 - MAX_REFRESH_DROP) <= estimate < current:
self.refresh_period = estimate
# Only a run of windows that agree with the period counts toward
# the shortfall check. One that disagrees (a faster one than the
# period may fall to, say, after a loaded start fixed a slow one)
# was rejected above, so the period does not reflect it, and a
# warning built on that period would name a rate the panel is not
# at. It resets the run; the check then waits for three that agree.
current = self.refresh_period
if current is not None:
agrees = abs(estimate - current) <= current * MAX_REFRESH_DROP
self._refresh_windows = self._refresh_windows + 1 if agrees else 0
period = self.refresh_period
if (period and not self._shortfall_checked
and self._refresh_windows >= REFRESH_CHECK_WINDOWS):
self._check_refresh_shortfall(1.0 / period)
histograms = self.histograms
for frame in batch:
@@ -688,6 +715,25 @@ class FrameTimingRecorder:
elif missed <= -1:
totals["early_frames"] += 1
def plan_refresh(self, hz: Optional[float]) -> None:
"""Say what rate scroll speeds are solved against, before frames arrive.
``DisplayManager.refresh_hz``: the configured cap. Once the measured
rate has held for :data:`REFRESH_CHECK_WINDOWS` windows in a row, a panel that
falls short of it is logged once, with a cap it can hold (see
:func:`src.common.scroll_config.refresh_shortfall`). The display
manager calls this only for a real panel.
"""
self.planned_refresh_hz = hz
self._shortfall_checked = not hz
def _check_refresh_shortfall(self, measured_hz: float) -> None:
"""Log, once, a panel that cannot reach the rate speeds assume."""
self._shortfall_checked = True
shortfall = scroll_config.refresh_shortfall(measured_hz, self.planned_refresh_hz)
if shortfall:
logger.warning(scroll_config.describe_refresh_shortfall(shortfall))
def snapshot(self) -> Dict[str, Any]:
"""The JSON document: cumulative since this process started."""
if not self._binding_checked:
@@ -704,6 +750,9 @@ class FrameTimingRecorder:
"bucket_ms": BUCKET_MS,
"freeze_seconds": FREEZE_SECONDS,
"measured_refresh_hz": round(1.0 / period, 2) if period else None,
# Additive: what scroll speeds were solved against, so a reader can
# tell a stale file (written under another cap) from this one.
"planned_refresh_hz": self.planned_refresh_hz,
"binding_releases_gil": self._binding_gil,
"info": info,
"totals": copy.deepcopy(self.totals),
+63
View File
@@ -538,3 +538,66 @@ def speed_advice(
"smooth": smooth,
"alternatives": [as_dict(c) for c in alternatives],
}
#: A measured refresh this far below the rate speeds are planned for means
#: the panel cannot reach its cap. Smaller gaps are the cap's own slack and
#: the estimate's: one rig measured 99.95 Hz under a 100 Hz cap.
REFRESH_SHORTFALL = 0.03
#: How far under the measured rate a suggested cap sits. The measurement is
#: the fast end of the panel's refreshes (frame_timing takes the 10th
#: percentile of intervals), and an uncapped panel drifts: one read
#: 107.6-113.1 Hz over 15 seconds. A cap inside that band would not hold.
CAP_HEADROOM = 0.05
def holdable_cap(measured_hz: Any) -> Optional[int]:
"""A refresh cap the panel can hold: a multiple of 10, 5% under what it measured.
A multiple of 10 because its whole-pixel speeds are round numbers (a
100 Hz cap gives 50 and 100 px/s). None without a usable measurement, or
when the panel is too slow for any cap of 10 Hz or more.
"""
hz = _coerce(measured_hz)
if hz is None:
return None
cap = int(hz * (1.0 - CAP_HEADROOM) // 10) * 10
return cap if cap >= 10 else None
def refresh_shortfall(measured_hz: Any, planned_hz: Any) -> Optional[Dict[str, Any]]:
"""When the panel refreshes measurably slower than speeds are planned for.
``planned_hz`` is what :func:`configure` solves against -- the
``limit_refresh_rate_hz`` cap, or :data:`DEFAULT_REFRESH_HZ` when it is 0.
A panel that cannot reach it still moves whole pixels per frame, but every
speed runs slow by the shortfall and the ladder of smooth speeds is the
cap's, not the panel's. None when there is no measurement, or the panel
reaches the cap (or beats it, as some do by a few Hz).
"""
measured, planned = _coerce(measured_hz), _coerce(planned_hz)
if measured is None or planned is None:
return None
if measured >= planned * (1.0 - REFRESH_SHORTFALL):
return None
return {
"measured_hz": round(measured, 1),
"planned_hz": round(planned, 1),
"suggested_cap_hz": holdable_cap(measured),
"slow_percent": round((1.0 - measured / planned) * 100),
}
def describe_refresh_shortfall(shortfall: Dict[str, Any]) -> str:
"""One log line for :func:`refresh_shortfall`'s answer."""
text = (
f"The panel refreshes at about {shortfall['measured_hz']:.0f} Hz, below "
f"the {shortfall['planned_hz']:.0f} Hz that scroll speeds are planned "
f"for (display.hardware.limit_refresh_rate_hz), so every scroll runs "
f"about {shortfall['slow_percent']}% slower than configured and the "
f"smooth speeds are worked out for a rate this panel never reaches.")
if shortfall.get("suggested_cap_hz"):
text += (f" Set Limit Refresh Rate to {shortfall['suggested_cap_hz']} Hz "
f"(web UI, Display tab), which this panel can hold, and restart.")
return text
+342
View File
@@ -0,0 +1,342 @@
"""Which non-favourite games a scoreboard shows, and when the slice moves (sports family 7).
The scoreboards' other-games rotation, reconciled in ledmatrix-plugins
(family 7) from two bodies each into one, and copied here under the existing
names. All of it lives on the plugins' ``SportsCore``, so there is one mixin,
``SportsRotationMixin``:
- ``_by_importance(games, newest_first)``: the non-favourite pool, best
matchup first and one game per team, when a poll has loaded; kickoff order
otherwise. ``SportsCoreSharedMixin._favorites_first`` asks it for both the
filtered and the unfiltered pool.
- ``_other_games_window(others, limit)``: the slice of a pool on screen now.
It advances by its own width every ``other_rotation_interval_seconds``
(catching up on intervals that passed unseen) and wraps.
``SportsCoreSharedMixin._compose_selection`` cuts with it.
- ``_advance_other_games_if_due`` and ``_rotate_other_games_on_display``:
the display path's re-cut between fetches. The plugins' ``display()`` calls
``_rotate_other_games_on_display`` before its dwell check; the card on
screen keeps its place if it survived the cut.
- ``_attach_odds_to_rotated_games``: odds for the games a rotation
brought in, on a daemon thread, when ``show_odds`` is on and there is an
odds manager.
- ``_rankings_loaded()``: the override point (below).
THE RULES
---------
``update()`` (through ``_favorites_first``) and ``display()`` (through
``_rotate_other_games_on_display``) both advance the window, so the advance
holds ``_games_lock`` (the plugins' RLock): interleaved without it, both saw
the interval elapse and each added a width, skipping a window nobody saw.
The display path's due-check looks at the pool ``_compose_selection`` will
actually cut from: the filtered others, or, when nothing survived at all (no
favourite to show either), the unfiltered fallback. Guessing the unfiltered
pool whenever the others were empty recomposed an identical list on every
frame while a favourite was playing; never looking at it left the fallback
moving only when ``update()`` ran.
Rotated-in games get odds by the same rule as the games ``update()`` picks:
``show_odds`` on (decided 2026-10-09, which brought ufc-scoreboard in line).
OVERRIDE POINT
--------------
``_rankings_loaded()`` -- did a poll load? ``_by_importance`` keeps kickoff
order when not. The default is ``_team_rankings_cache`` being non-empty.
football-scoreboard overrides it to count its rankings keyed by ESPN team id
(``_ranked_team_ids``) as well, the table its ``_best_rank`` reads first.
A new module rather than more methods on ``sports_shared``, for the reason
``sports_helpers`` gives: a missing module fails at load, where the version
checks see it; a missing method fails mid-update.
WHAT A HOST MUST PROVIDE
------------------------
Derived by walking every ``self.<attr>`` the mixin reads; the host-contract
test in ``test/test_sports_rotation.py`` fails if a read is added without being
listed here. Every scoreboard ``SportsCore`` supplies all of them.
- ``other_rotation_interval_seconds`` -- seconds per window; 0 pins it.
- ``_other_window_start`` and ``_other_window_rotated_at`` -- the window's
position and the monotonic time it last moved (0: never cut). Written here.
- ``_games_lock`` -- the re-entrant lock around ``games_list``.
- ``_selection_pools`` -- what ``_favorites_first`` settled (``favorites``,
``others``, ``unfiltered``, ``favorite_limit``, ``other_limit``); read with
getattr, so a manager that never built it does not rotate.
- ``_compose_selection`` -- from ``SportsCoreSharedMixin``.
- ``_best_rank`` -- a game's better poll position, 99 if neither side ranks
(family 8, still per-plugin).
- ``_team_rankings_cache`` -- read with getattr by the default
``_rankings_loaded``.
- ``games_list``, ``current_game``, ``current_game_index``,
``last_game_switch`` -- the switch-mode state a rotation swaps.
- ``logger`` -- one INFO line per rotation.
- ``show_odds`` and ``odds_manager`` (read with getattr), ``mode_config``
(``odds_update_interval``), ``sport``, ``league`` and ``sport_key`` -- the
rotated-in odds fetch.
The methods read the game dict's ``id``, ``start_time_utc``, ``home_abbr``,
``away_abbr`` and ``odds``; any may be missing.
BASE ORDER
----------
No other mixin defines these methods, so the position in the bases does not
change which body runs; a method on the plugin's own class (football's
``_rankings_loaded``) still wins. The mixin has no ``__init__``; the state it
writes is the host's.
"""
import threading
import time
from datetime import datetime, timezone
from typing import Any, Callable, Dict, List, Optional
class SportsRotationMixin:
"""``SportsCore``'s other-games rotation. See module docstring."""
# The host contract, declared for type checking only.
other_rotation_interval_seconds: int
_other_window_start: int
_other_window_rotated_at: float
_games_lock: Any
_compose_selection: Callable[[], List[Dict]]
_best_rank: Callable[[Dict], int]
games_list: List[Dict]
current_game: Optional[Dict]
current_game_index: int
last_game_switch: float
logger: Any
odds_manager: Any
mode_config: Dict[str, Any]
sport: str
league: str
sport_key: str
def _rankings_loaded(self) -> bool:
"""Did a poll load at all? The ranking reads fail open when not.
The seam ``_by_importance`` asks before ordering by rank: by default
the abbreviation table (``_team_rankings_cache``). football-scoreboard
overrides it to count its table keyed by ESPN team id as well.
"""
return bool(getattr(self, "_team_rankings_cache", None))
def _by_importance(self, games: List[Dict], newest_first: bool = False) -> List[Dict]:
"""Non-favourite games, best matchup first.
The quality filter already declares the poll to be the thing worth
showing -- and then selection ignored the number entirely. #1 against #2
and #25 against an unranked side were interchangeable, and whichever
kicked off sooner took the slot, so the biggest game of the week had no
better chance of being seen than any other.
The rotation still walks the entire pool, so nothing is lost and
coverage is unchanged; it now walks DOWN the ladder instead of along the
clock. The first window after a restart holds the best games available
rather than the earliest ones, which is the case that matters -- a board
is far more often freshly started or freshly updated than three hours
into a lap.
Ties fall back to kickoff order, and a league with no poll keeps the
chronological order it had, because there is nothing to sort on.
One game per team, which is the part rank ordering cannot do without.
The upcoming pool is not a week of fixtures -- for college football it
is the whole season, 947 games on a real board -- so ordering by rank
alone put all twelve of the #1 team's games above the #2 team's first
one, and the board walked one team's season. Measured on ledpi the
moment this shipped: KENT@OSU, ILL@OSU, then OSU@IOWA, MD@OSU. Keeping
only the soonest game per team makes the pool "what each team has
next", which is both what an upcoming board means and inherently
near-term, since a team's next game is by definition the closest one.
"""
if not self._rankings_loaded():
return games
if newest_first:
def key(game):
when = game.get("start_time_utc") or datetime.min.replace(tzinfo=timezone.utc)
return (self._best_rank(game), -when.timestamp())
else:
def key(game):
when = game.get("start_time_utc") or datetime.max.replace(tzinfo=timezone.utc)
return (self._best_rank(game), when.timestamp())
# Soonest-first so "one per team" keeps each team's NEXT game, then
# re-ordered by rank. Doing it the other way round would keep whichever
# of a team's games happened to sort first by rank, which for a game
# between two ranked sides is not necessarily the next one.
soonest_first = sorted(
games,
key=lambda g: (g.get("start_time_utc")
or datetime.max.replace(tzinfo=timezone.utc)).timestamp(),
reverse=newest_first,
)
seen, once_each = set(), [] # type: ignore[var-annotated]
for game in soonest_first:
sides = (game.get("home_abbr"), game.get("away_abbr"))
if any(side in seen for side in sides):
continue
seen.update(s for s in sides if s)
once_each.append(game)
return sorted(once_each, key=key)
def _other_games_window(self, others: List[Dict], limit: int) -> List[Dict]:
"""A rotating slice of the non-favourite games.
The window advances by its own width, so consecutive windows are
disjoint and the board walks the schedule rather than resampling the
same front of it. It wraps, so a short list still cycles.
Advancing is time-based, not per-update. update() runs every 30s; if
the window moved with it the games list would change identity on every
pass, reset the display index, and no card past the first would ever be
reached.
"""
if limit <= 0 or not others:
return []
if len(others) <= limit:
return others[:limit]
interval = self.other_rotation_interval_seconds
# Under the lock: update() advances this window through
# _favorites_first, and display() advances it through
# _rotate_other_games_on_display, so the read-modify-write below has two
# writers. Interleaved, both can see the interval elapsed and each add a
# width, skipping a window of games nobody ever sees. _games_lock is an
# RLock and the display path takes it again straight after, which is
# why this can be the same lock rather than another one to reason about.
with self._games_lock:
if interval > 0:
now = time.monotonic()
if not self._other_window_rotated_at:
self._other_window_rotated_at = now
elapsed = now - self._other_window_rotated_at
if elapsed >= interval:
# Advance by however many intervals actually passed. The
# board is not guaranteed to be running -- or this mode
# displayed -- for every one of them, and stepping once
# would let a plugin that sat idle crawl a step at a time.
steps = int(elapsed // interval)
self._other_window_start += steps * limit
self._other_window_rotated_at = now
start = self._other_window_start % len(others)
window = others[start:start + limit]
if len(window) < limit:
window += others[:limit - len(window)]
return window
def _rotate_other_games_on_display(self) -> bool:
"""Swap in a freshly cut slice when the rotation interval has passed.
Returns True when the list changed, so the caller forces a redraw.
The card currently on screen keeps its place if it survived the cut:
rotating the pool should change what comes NEXT, not interrupt whatever
someone is reading. Only when it is gone does the index reset, and then
the dwell resets with it so the replacement gets a full turn rather than
the tail of its predecessor's.
"""
rebuilt = self._advance_other_games_if_due()
if not rebuilt:
return False
with self._games_lock:
if [g.get("id") for g in rebuilt] == [g.get("id") for g in self.games_list]:
return False
current_id = (self.current_game or {}).get("id")
self.games_list = rebuilt
for index, game in enumerate(rebuilt):
if game.get("id") == current_id:
self.current_game_index = index
self.current_game = game
break
else:
self.current_game_index = 0
self.current_game = rebuilt[0]
self.last_game_switch = time.time()
self.logger.info(
"Rotated the other-games slice to: %s",
", ".join("%s@%s" % (g.get("away_abbr"), g.get("home_abbr"))
for g in rebuilt),
)
self._attach_odds_to_rotated_games(rebuilt)
return True
def _attach_odds_to_rotated_games(self, games: List[Dict]) -> None:
"""Fetch odds for freshly rotated-in games off the display path.
The rotation deliberately does no network work, but odds are only
attached in update(), and for an upcoming list that runs hourly --
far longer than any rotated-in card stays on screen. Every slice cut
between updates therefore rendered without a line even though ESPN
had one, while the favourites, which survive every cut, kept the
odds update() gave them.
One daemon thread per rotation, bounded by the slice size rather
than the pool's: only games actually going on screen are asked
about, and get_odds caches per game, so one re-entering the window
inside its TTL costs a cache lookup rather than a request. The
thread mutates each game dict in place; the renderer re-reads
game["odds"] every frame, so a line appears as soon as its fetch
lands, mid-dwell included. Same as football-scoreboard #343.
"""
# getattr: managers are built partially in places (the plugin tests
# among them) that never set show_odds or an odds manager.
if not getattr(self, "show_odds", False) or not getattr(self, "odds_manager", None):
return
pending = [g for g in games if not g.get("odds")]
if not pending:
return
interval = self.mode_config.get("odds_update_interval", 3600)
def fetch() -> None:
for game in pending:
try:
odds = self.odds_manager.get_odds(
sport=self.sport,
league=self.league,
event_id=game["id"],
update_interval_seconds=interval,
)
if odds:
game["odds"] = odds
except Exception as exc:
self.logger.debug(
"Odds fetch for rotated-in game %s failed: %s",
game.get("id"), exc)
threading.Thread(
target=fetch, daemon=True,
name="%s-rotated-odds" % self.sport_key).start()
def _advance_other_games_if_due(self) -> List[Dict]:
"""Re-cut the non-favourite slice on the display path, or [] if not due.
Costs one list slice and a sort of at most a few games -- no fetch, no
parsing, no network. Returns the new list rather than assigning it,
because the two callers keep different bookkeeping around games_list
and both hold their own lock while they swap it in.
"""
pools = getattr(self, "_selection_pools", None)
if not pools:
return []
interval = self.other_rotation_interval_seconds
limit = max(0, pools["other_limit"])
# Whichever pool _compose_selection will actually slice. It falls back
# to the unfiltered list only when NOTHING survived -- favourites
# included. With a favourite playing and the filters rejecting every
# other game, compose keeps the favourites-only list, so guessing the
# unfiltered pool here made the due-check fire on every display() call
# forever, recomposing an identical list each frame.
others = pools["others"]
favorites_fill = pools["favorites"] and pools["favorite_limit"] > 0
if not others and limit > 0 and not favorites_fill:
others = pools["unfiltered"]
if interval <= 0 or limit <= 0 or len(others) <= limit:
return [] # pinned, favourites-only, or nothing to rotate through
if not self._other_window_rotated_at:
return [] # no window has been cut yet; update() does the first
if time.monotonic() - self._other_window_rotated_at < interval:
return []
return self._compose_selection()
+8 -6
View File
@@ -54,12 +54,14 @@ State: ``config``, ``fonts``, ``logger``, ``display_width``, ``display_height``,
Methods that stay per-plugin, because they are not identical across the eight
(or, for ``_get_timezone``, because they bind per-plugin modules):
``_get_layout_offset``, ``_by_importance``, ``_other_games_window``,
``_upcoming_date_and_time_text``, ``_extract_game_details_common``,
``_load_division_team_ids``, ``_get_timezone``, ``_is_favorite_game``,
``_is_ranked_game``, ``_passes_other_filters``. (``_is_game_really_over``,
which ``_detect_stale_games`` below calls, was here too until the plugins
reconciled it; it is now ``src.common.sports_game_over``.)
``_get_layout_offset``, ``_upcoming_date_and_time_text``,
``_extract_game_details_common``, ``_load_division_team_ids``,
``_get_timezone``, ``_is_ranked_game``, ``_passes_other_filters``. Three
families were here until the plugins reconciled them: ``_is_game_really_over``,
which ``_detect_stale_games`` below calls, is now ``src.common.sports_game_over``;
``_is_favorite_game`` is ``src.common.sports_favorites``; and
``_by_importance`` and ``_other_games_window``, which ``_favorites_first`` and
``_compose_selection`` below call, are ``src.common.sports_rotation``.
Of the fourteen shared class constants, thirteen are identical everywhere and
live here. Only ``_SCORE_PROBE_TEXT`` varies -- afl and basketball reach three digits
+7 -3
View File
@@ -655,7 +655,6 @@ class DisplayController:
except Exception: # pylint: disable=broad-except
cached_session = None
if self.on_demand_active or cached_session:
self.cache_manager.clear_cache('display_on_demand_config')
self._set_on_demand_error('restore-failed')
# Its state machine no longer describes what runs; let the last
# snapshot go stale (readers then say unknown) rather than keep
@@ -1804,8 +1803,14 @@ class DisplayController:
logger.error("Failed to publish on-demand state: %s", err, exc_info=True)
def _set_on_demand_error(self, message: str) -> None:
"""Set on-demand state to error and publish."""
"""Set on-demand state to error and publish.
Ends any running session, so its saved copy goes too: a failed
request that replaced a session left display_on_demand_config
behind, and the next restart resumed the session that had ended.
"""
self._reset_on_demand_fields()
self.cache_manager.clear_cache('display_on_demand_config')
self.on_demand_status = 'error'
self.on_demand_last_error = message
self.on_demand_last_event = None
@@ -2743,7 +2748,6 @@ class DisplayController:
logger.error("On-demand session for plugin '%s' cannot resume after the "
"restart: the plugin has no loaded display modes (did it "
"fail to load?); ending it", plugin_id)
self.cache_manager.clear_cache('display_on_demand_config')
self._set_on_demand_error('restore-failed')
return
+8 -1
View File
@@ -393,6 +393,11 @@ class DisplayManager:
self._setup_matrix()
logger.info("Matrix setup completed in %.3f seconds", time.time() - start_time)
# Only a real panel's swaps wait on its refresh: the emulator and the
# fallback canvas pace themselves, so "slower than the cap" would be
# noise there.
if self.matrix is not None and os.environ.get('EMULATOR', 'false') != 'true':
self.frame_timing.plan_refresh(self.refresh_hz)
self._setup_scan_order_compensation()
font_time = time.time()
@@ -1501,7 +1506,9 @@ class DisplayManager:
fractional-pixel motion. See src/common/scroll_config.py.
Note this is the configured *cap*, not necessarily what the panel
achieves -- scripts/scroll_speeds.py --measure reports the real rate.
achieves -- scripts/scroll_speeds.py --measure reports the real rate,
and the frame-timing recorder logs a warning, with a cap the panel can
hold, once it has measured a panel that falls short of this.
"""
hardware = (self.config.get('display') or {}).get('hardware') or {}
try:
+19 -11
View File
@@ -5,18 +5,22 @@ Safe exception descriptions and the bodies for exceptions no route handled.
The standard success/error responses are in api_helpers.
"""
import errno
from src.logging_config import get_logger
from src.redaction import redact_credentials
logger = get_logger(__name__)
# Long enough for an errno string with a path, short enough not to dump a
# parser's worth of context into a JSON field.
_MAX_DETAIL_LENGTH = 400
def describe_exception(exc: BaseException,
max_length: int = _MAX_DETAIL_LENGTH) -> str:
def describe_exception(exc: BaseException) -> str:
"""
One-line, safe-to-return description of an exception.
Machine-readable reason code for an exception, safe to return over HTTP.
The generic "an error occurred; see logs for details" tells a user nothing
and, when the failure is bad enough, the logs are unreachable too: a device
@@ -24,20 +28,24 @@ def describe_exception(exc: BaseException,
*including* the log viewer, because journalctl could not be executed. The
underlying `[Errno 5] Input/output error` named the fault immediately.
Returns "TypeName: message", credentials redacted and length capped. The
type alone is worth carrying -- a bare PermissionError says more than any
generic sentence.
So the type and errno still go back -- "OSError:EIO", "PermissionError:
EACCES", "TimeoutExpired" -- but never the exception's message, which can
quote paths, URLs, credentials or a library's internals (CodeQL
py/stack-trace-exposure). The message is logged here instead, so every
reason code a client sees has its full text in the log.
Args:
exc: The exception to describe
max_length: Truncate beyond this many characters
Returns:
A single-line description, never empty
"TypeName" or "TypeName:ERRNO", never empty
"""
message = str(exc).strip()
text = f"{type(exc).__name__}: {message}" if message else type(exc).__name__
return redact_text(text, max_length)
code = type(exc).__name__
exc_errno = getattr(exc, 'errno', None)
if isinstance(exc_errno, int) and exc_errno in errno.errorcode:
code = f"{code}:{errno.errorcode[exc_errno]}"
logger.warning("Error reported to the client as %s: %s", code, redact_text(str(exc)))
return code
def redact_text(text: str, max_length: int = _MAX_DETAIL_LENGTH) -> str:
+9 -9
View File
@@ -1369,7 +1369,7 @@ class WiFiManager:
self.enable_ap_mode(force=True)
except Exception as ap_error: # nosec B110 - last-resort; do not re-raise, but log for debugging
logger.error("Last-resort AP mode enable failed in recovery path: %s", ap_error, exc_info=True)
return False, str(e)
return False, f"Connection failed ({type(e).__name__}); see logs for details"
def _failsafe_ap(self, enabled_msg: str, failed_msg: str) -> Tuple[bool, str]:
"""Force the setup AP up after a connect that left no working network,
@@ -1585,7 +1585,7 @@ class WiFiManager:
except Exception as e:
logger.error(f"Error connecting with nmcli: {e}")
self._show_led_message("Connection error", duration=5)
return False, str(e)
return False, f"Connection failed ({type(e).__name__}); see logs for details"
# 802.11 caps an SSID at 32 octets. Control characters cannot appear in a
# real one, and a leading "-" would be read by nmcli as an option rather
@@ -1725,7 +1725,7 @@ class WiFiManager:
return False, "nmcli is required to disconnect from WiFi"
except Exception as e:
logger.error(f"Error disconnecting from WiFi: {e}")
return False, str(e)
return False, f"Disconnect failed ({type(e).__name__}); see logs for details"
def _ensure_wifi_radio_enabled(self, max_retries: int = 3) -> bool:
"""
@@ -2004,7 +2004,7 @@ class WiFiManager:
return False, "No WiFi tools available (nmcli, hostapd, or dnsmasq required)"
except Exception as e:
logger.error(f"Error in enable_ap_mode: {e}")
return False, str(e)
return False, f"Could not enable AP mode ({type(e).__name__}); see logs for details"
def _mark_forced(self) -> None:
"""Record that AP mode was forced on, so the periodic check leaves it
@@ -2099,10 +2099,10 @@ class WiFiManager:
return True, "AP mode enabled"
except Exception as e:
logger.error(f"Error starting AP services: {e}")
return False, str(e)
return False, f"Could not enable AP mode ({type(e).__name__}); see logs for details"
except Exception as e:
logger.error(f"Error enabling AP mode: {e}")
return False, str(e)
return False, f"Could not enable AP mode ({type(e).__name__}); see logs for details"
def _enable_ap_mode_nmcli_hotspot(self) -> Tuple[bool, str]:
"""
@@ -2227,7 +2227,7 @@ class WiFiManager:
logger.error(f"Error starting AP mode with nmcli: {e}")
self._remove_nm_dnsmasq_captive_conf()
self._show_led_message("Setup mode error", duration=5)
return False, str(e)
return False, f"Could not enable AP mode ({type(e).__name__}); see logs for details"
def _get_ap_status_nmcli(self) -> Dict:
"""
@@ -2409,10 +2409,10 @@ class WiFiManager:
return True, "AP mode disabled"
except Exception as e:
logger.error(f"Error stopping AP services: {e}")
return False, str(e)
return False, f"Could not disable AP mode ({type(e).__name__}); see logs for details"
except Exception as e:
logger.error(f"Error disabling AP mode: {e}")
return False, str(e)
return False, f"Could not disable AP mode ({type(e).__name__}); see logs for details"
def _create_hostapd_config(self):
"""Create hostapd configuration file"""
+9
View File
@@ -175,6 +175,15 @@
"POST"
]
],
[
"/api/v3/config/refresh-rate",
"api_v3.get_refresh_rate",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/config/schedule",
"api_v3.get_schedule_config",
+1 -1
View File
@@ -71,7 +71,7 @@ server has none.
| `dom/test_raw_json_page.js` | yes | The Config Editor tab (`js/pages/raw-json.js`): one POST per Save after repeated swaps, Format/Validate, invalid JSON never sent, a save survives a swap, the old global entry points |
| `dom/test_schedule_page.js` | yes | The Schedule tab (`js/pages/schedule.js`) with the real `schedule-picker` widget: both pickers drawn once per swap from the saved config, one notification per save answer after repeated swaps, the brightness label, a late widget waited for, the old global entry points |
| `dom/test_visibility_service.js` | yes (no server) | `js/core/visibility.js` with the real `LEDVisibility` from `app-shell.js` and the real registry: start/stop with the active tab and the browser tab's visibility, no interval while hidden or after a swap-out, registrations independent, the no-`LEDVisibility` fallback |
| `dom/test_display_page.js` | yes | The Display tab (`js/pages/display.js`) with the real `plugin-order-list` widget and `LEDVisibility`: one page, one sync interval and one action per control after repeated swaps, the sync poll only while on screen and never after a swap-out, sync states as text, the debounced scroll-speed hint, `updateSyncUI`'s entry point |
| `dom/test_display_page.js` | yes | The Display tab (`js/pages/display.js`) with the real `plugin-order-list` widget and `LEDVisibility`: one page, one sync interval and one action per control after repeated swaps, the sync poll only while on screen and never after a swap-out, sync states as text, the debounced scroll-speed hint, the refresh-cap hint and its "Use N Hz" button, `updateSyncUI`'s entry point |
| `dom/test_general_page.js` | yes | The General tab (`js/pages/general.js`) with the real `timezone-selector` widget: the picker drawn once per swap, one request per Security action after repeated swaps, hostile token names stay text, refused/network/login answers, a write survives a swap, `webLogin`'s entry points |
| `dom/test_backup_restore_page.js` | yes | The Backup & Restore tab (`js/pages/backup-restore.js`): one request per action after repeated swaps, the upload and restore options, reads cancelled and writes not on a swap, hostile names stay text, the old global entry points |
| `dom/test_tools_sections.js` | yes | The Tools tab's MQTT bridge and Pixlet editor sections: form prefill, the write-only password (blank means unchanged), the running-session banner and countdown, and that the editor link points at the host you loaded the page from |
+11
View File
@@ -88,6 +88,8 @@ const ok = (l, c, x) => c ? (pass++, console.log(' ok ' + l))
let syncAnswer = { status: 'success', data: { role: 'leader', state: 'no_peer' } };
let syncMode = 'ok';
let advice = smooth;
const shortfall = { measured_hz: 110.4, planned_hz: 120, suggested_cap_hz: 100, slow_percent: 8 };
let refreshAnswer = { status: 'success', data: { planned_hz: 120, measured_hz: 110.4, shortfall } };
const requests = [];
function fakeFetch(url, init = {}) {
requests.push(url);
@@ -99,6 +101,7 @@ const ok = (l, c, x) => c ? (pass++, console.log(' ok ' + l))
});
if (url === '/api/v3/plugins/installed') return respond(200, { status: 'success', data: { plugins } });
if (url.startsWith('/api/v3/config/scroll-speed-advice?')) return respond(200, advice);
if (url === '/api/v3/config/refresh-rate') return respond(200, refreshAnswer);
if (url === '/api/v3/sync/status') {
if (syncMode === 'network') return Promise.reject(new TypeError('Failed to fetch'));
if (syncMode === 'login') return respond(401, { status: 'error' }, { 'X-LEDMatrix-Login': '/login' });
@@ -148,6 +151,14 @@ const ok = (l, c, x) => c ? (pass++, console.log(' ok ' + l))
// ── first load ──────────────────────────────────────────────────────────
ok('one plugin-list request on start', count('/api/v3/plugins/installed') === 1, requests);
ok('one scroll-speed hint request on start (after the debounce)', count('/api/v3/config/scroll-speed-advice') === 1, requests);
const refreshHint = $('limit_refresh_rate_hz_hint');
ok('a panel short of its cap says so, as text, with a button for a cap it can hold',
/about 110 Hz, below this 120 Hz cap.*8% slower/.test(refreshHint.textContent)
&& refreshHint.querySelector('button').textContent === 'Use 100 Hz', refreshHint.textContent);
refreshHint.querySelector('button').click();
ok('the button fills the field and says to save and restart',
$('limit_refresh_rate_hz').value === '100' && /Save, then restart/.test(refreshHint.textContent),
[$('limit_refresh_rate_hz').value, refreshHint.textContent]);
ok('the saved role is standalone: no sync request, no interval work',
$('sync_role').value === 'standalone' && syncPolls() === 0, [$('sync_role').value, syncPolls()]);
ok('the sync poll interval runs while the tab is on screen', intervals.size === 1
+2 -1
View File
@@ -147,7 +147,8 @@ class TestOneBadConfigSectionDoesNotBlankTheList:
side_effect=RuntimeError("disk is gone"))
resp = api_v3_client.get('/api/v3/display/modes')
assert resp.status_code == 500
assert 'disk is gone' in resp.get_json()['details']
assert resp.get_json()['details'] == 'RuntimeError'
assert 'disk is gone' not in json.dumps(resp.get_json())
def test_credentials_in_the_exception_are_redacted(self, api_v3_module, api_v3_client):
"""describe_exception is what makes returning detail safe."""
+147
View File
@@ -0,0 +1,147 @@
"""No API response carries an exception's message (CodeQL py/stack-trace-exposure).
One representative route per file that had open alerts. Each forces a failure
whose message holds a marker and asserts the marker is nowhere in the body:
the message goes to the log, the client gets a fixed message plus a reason
code (describe_exception: the type, and the errno for an OSError).
"""
import json
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent))
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
LEAK = "LEAKED-/home/pi/secret token=abc123"
API = "web_interface.blueprints.api_v3"
def _assert_no_leak(response):
body = response.get_data(as_text=True)
assert "LEAKED" not in body, body
assert "abc123" not in body, body
return json.loads(body)
def test_display_service_status_drops_systemctl_output(api_v3_module, api_v3_client,
monkeypatch):
"""display.py: the on-demand routes return the service status verbatim."""
api_v3_module.api_v3.cache_manager.get.return_value = None
monkeypatch.setattr(f"{API}.display.display_state.read_state", lambda: None)
with patch(f"{API}.subprocess.run", side_effect=OSError(13, LEAK)):
body = _assert_no_leak(api_v3_client.get("/api/v3/display/on-demand/status"))
assert body["data"]["service"] == {"active": False, "returncode": -1}
@pytest.mark.parametrize("helper", ["_ensure_display_service_running",
"_stop_display_service"])
def test_service_results_keep_returncode_but_not_output(api_v3_module, helper):
"""display.py start/stop: returncode/active/started stay, stdout/stderr go."""
failed = MagicMock(returncode=1, stdout=LEAK, stderr=LEAK)
with patch(f"{API}.subprocess.run", return_value=failed):
result = getattr(api_v3_module, helper)()
assert "LEAKED" not in json.dumps(result)
assert result["returncode"] == 1 and result["active"] is False
assert "stdout" not in result and "stderr" not in result
def test_wifi_connect_failure(api_v3_client):
"""wifi.py: a raising connect, and the attempt /wifi/status reports after."""
with patch("src.wifi_manager.WiFiManager") as cls:
cls.return_value._is_ap_mode_active.return_value = False
cls.return_value.connect_to_network.side_effect = RuntimeError(LEAK)
body = _assert_no_leak(api_v3_client.post(
"/api/v3/wifi/connect", json={"ssid": "HomeNet", "password": "pw"}))
assert body["details"] == "RuntimeError"
cls.return_value.get_wifi_status.return_value = MagicMock(
connected=False, ssid=None, ip_address=None, signal=0, ap_mode_active=False)
cls.return_value.config = {}
status = _assert_no_leak(api_v3_client.get("/api/v3/wifi/status"))
assert status["data"]["last_connect_attempt"]["message"] == (
"Failed to connect to network (RuntimeError)")
def test_wifi_manager_messages_carry_no_exception_text():
"""src/wifi_manager.py: its (success, message) is what the wifi routes return."""
from src.wifi_manager import WiFiManager
manager = WiFiManager.__new__(WiFiManager) # no __init__: no host access
manager.get_wifi_status = MagicMock(side_effect=OSError(5, LEAK))
success, message = manager.disconnect_from_network()
assert success is False
assert "LEAKED" not in message and "OSError" in message
def test_system_action_exception(api_v3_client):
"""system.py: execute_system_action's catch-all."""
with patch("subprocess.run", side_effect=OSError(5, LEAK)):
body = _assert_no_leak(api_v3_client.post(
"/api/v3/system/action", json={"action": "stop_display"}))
assert body["details"] == "OSError:EIO"
def test_calendar_registration_failure(api_v3_client, tmp_path, monkeypatch):
"""plugin_calendar.py: the auth script could not be run."""
plugin_dir = tmp_path / "calendar"
plugin_dir.mkdir()
(plugin_dir / "credentials.json").write_text("{}", encoding="utf-8")
(plugin_dir / "calendar_registration.py").write_text("", encoding="utf-8")
monkeypatch.setattr(f"{API}._calendar_plugin_dir", lambda: plugin_dir)
with patch(f"{API}.subprocess.run", side_effect=OSError(13, LEAK)):
body = _assert_no_leak(api_v3_client.post(
"/api/v3/plugins/calendar/authenticate", json={"code": "x"}))
assert "EACCES" in body["message"]
def test_health_failure(api_v3_client, monkeypatch):
"""misc.py: get_health's catch-all."""
def boom():
raise RuntimeError(LEAK)
monkeypatch.setattr(f"{API}.misc._get_display_service_status", boom)
body = _assert_no_leak(api_v3_client.get("/api/v3/health"))
assert body["details"] == "RuntimeError"
def test_config_route_failure(api_v3_module, api_v3_client):
"""error_handler.py: create_error_response, as config.py's routes use it."""
api_v3_module.api_v3.config_manager.load_config.side_effect = RuntimeError(LEAK)
body = _assert_no_leak(api_v3_client.get("/api/v3/config/schedule"))
assert body["details"] == "RuntimeError"
def test_plugin_route_failure(api_v3_module, api_v3_client):
"""plugins.py: an unhandled error in a plugin route."""
api_v3_module.api_v3.plugin_catalog.get_all_plugin_info.side_effect = RuntimeError(LEAK)
body = _assert_no_leak(api_v3_client.get("/api/v3/plugins/installed"))
assert body["details"] == "RuntimeError"
def test_starlark_route_failure(api_v3_client):
"""starlark.py: one of its catch-alls."""
with patch(f"{API}._get_starlark_plugin", side_effect=RuntimeError(LEAK)):
body = _assert_no_leak(api_v3_client.get("/api/v3/starlark/status"))
assert body["details"] == "RuntimeError"
def test_unit_refresh_failure(monkeypatch):
"""system.py git_pull: perform_core_update appends unit_refresh's message."""
from web_interface import unit_refresh
def boom(*_a, **_k):
raise RuntimeError(LEAK)
monkeypatch.setattr(unit_refresh, "stale_units", boom)
result = unit_refresh.refresh_after_update()
assert result["status"] == unit_refresh.FAILED
assert "LEAKED" not in result["message"]
def test_install_base_requirements_failure(api_v3_client):
"""system.py: a pip install that could not start, in the action's output."""
with patch(f"{API}.system._pip_install_requirements", side_effect=OSError(5, LEAK)):
body = _assert_no_leak(api_v3_client.post(
"/api/v3/system/action", json={"action": "install_base_requirements"}))
assert "Failed: OSError:EIO" in body["output"]
+4 -3
View File
@@ -95,9 +95,10 @@ class TestRefreshPluginStore:
RuntimeError("failed at /home/user/LEDMatrix/src/secret.py line 42"))
body = api_v3_client.post(self.URL, json={}).get_json()
assert "Traceback" not in str(body)
# `details` is describe_exception output: one line, type-named,
# credential-redacted. It may quote the message, but never a stack.
assert body["details"].startswith("RuntimeError:")
# `details` is describe_exception output: the type, never the
# message or a stack.
assert body["details"] == "RuntimeError"
assert "secret.py" not in str(body)
assert "\n" not in body["details"]
+77
View File
@@ -807,3 +807,80 @@ def test_a_process_with_the_gc_monitor_exits_cleanly():
assert proc.returncode == 0, proc.stderr
assert "Exception ignored" not in proc.stderr
assert "installed at exit: False" in proc.stdout
SLOW = 1 / 110.0 # a panel that cannot reach a 120 Hz cap
def _windows(recorder, n, interval, start=0.0):
for i in range(n):
_feed(recorder, [interval] * 200, start=start + 50.0 * i)
_aggregate(recorder)
def _shortfall_warnings(caplog):
return [r for r in caplog.records
if r.name == "src.common.frame_timing" and "Limit Refresh Rate" in r.getMessage()]
def test_a_panel_slower_than_its_cap_is_reported_once(tmp_path, caplog):
r = _recorder(tmp_path)
r.plan_refresh(120.0)
caplog.set_level("WARNING")
_windows(r, 3, SLOW) # adopted on the 2nd window, checked on the 4th
assert _shortfall_warnings(caplog) == []
_windows(r, 3, SLOW, start=1000.0)
warnings = _shortfall_warnings(caplog)
assert len(warnings) == 1
assert "about 110 Hz" in warnings[0].getMessage()
assert "to 100 Hz" in warnings[0].getMessage()
def test_a_panel_that_reaches_its_cap_is_not_reported(tmp_path, caplog):
r = _recorder(tmp_path)
r.plan_refresh(100.0)
caplog.set_level("WARNING")
_windows(r, 6, PERIOD)
assert _shortfall_warnings(caplog) == []
def test_without_a_planned_rate_nothing_is_checked(tmp_path, caplog):
# The emulator and the fallback canvas: DisplayManager never calls
# plan_refresh(), since their frames are not paced by a panel.
r = _recorder(tmp_path)
caplog.set_level("WARNING")
_windows(r, 6, SLOW)
assert _shortfall_warnings(caplog) == []
def test_the_snapshot_records_the_planned_rate(tmp_path):
r = _recorder(tmp_path)
assert r.snapshot()["planned_refresh_hz"] is None
r.plan_refresh(120.0)
assert r.snapshot()["planned_refresh_hz"] == 120.0
def test_windows_the_period_rejected_do_not_count_toward_the_warning(tmp_path, caplog):
# A loaded start fixed 60 Hz (two windows agreed); the panel really runs at
# 100 Hz, but a window that much faster is ignored by the estimate, so the
# period stays 60 Hz. Warning "60 Hz is under your 100 Hz cap" would be wrong.
r = _recorder(tmp_path)
r.plan_refresh(100.0)
caplog.set_level("WARNING")
_windows(r, 2, 1 / 60.0)
assert abs(1.0 / r.refresh_period - 60.0) < 0.5
_windows(r, 6, PERIOD, start=1000.0)
assert abs(1.0 / r.refresh_period - 60.0) < 0.5 # still ignored
assert _shortfall_warnings(caplog) == []
def test_one_disagreeing_window_restarts_the_run(tmp_path, caplog):
r = _recorder(tmp_path)
r.plan_refresh(120.0)
caplog.set_level("WARNING")
_windows(r, 3, SLOW) # two windows toward three
_windows(r, 1, 1 / 250.0, start=1000.0) # far faster: rejected, resets
_windows(r, 1, SLOW, start=2000.0)
assert _shortfall_warnings(caplog) == []
_windows(r, 2, SLOW, start=3000.0) # three in a row now
assert len(_shortfall_warnings(caplog)) == 1
+9
View File
@@ -217,6 +217,15 @@ class TestReleasingThePlugin:
assert controller.current_display_mode == 'clock'
assert controller.force_change is True
def test_a_failed_request_that_ends_the_session_drops_its_saved_copy(self, controller):
"""Otherwise the next restart resumes the session that just ended."""
_start(controller, plugin_id='clock')
controller.cache_manager.clear_cache.reset_mock()
_start(controller, plugin_id='uninstalled')
controller.cache_manager.clear_cache.assert_called_once_with('display_on_demand_config')
def test_a_plugin_enabled_during_the_session_stays_loaded(self, controller):
_start(controller)
controller.test_config['preview-me'] = {'enabled': True}
+44
View File
@@ -21,6 +21,7 @@ from src.common.scroll_config import ( # noqa: E402
refresh_hz_from_config,
resolve,
)
from src.common import scroll_config # noqa: E402
class FakeHelper:
@@ -504,3 +505,46 @@ class TestSpeedAdvice:
got = solve_crisp(50, 125.74)
assert got.steppiness == "smooth"
assert got.pixels_per_frame == 1
class TestRefreshShortfall:
"""A panel that cannot reach its cap runs every scroll slow."""
def test_the_ledmatrix_rig_is_told_to_cap_at_100(self):
# Pi 4, 2x128x64 on adafruit-hat-pwm under a 120 Hz cap: measured
# 107.6-113.1 Hz, and frame_timing reports the fast end.
s = scroll_config.refresh_shortfall(113.1, 120)
assert s == {"measured_hz": 113.1, "planned_hz": 120.0,
"suggested_cap_hz": 100, "slow_percent": 6}
def test_a_panel_that_holds_its_cap_is_fine(self):
assert scroll_config.refresh_shortfall(99.95, 100) is None
assert scroll_config.refresh_shortfall(97.5, 100) is None
def test_a_panel_that_beats_its_cap_is_fine(self):
assert scroll_config.refresh_shortfall(125.7, 120) is None
def test_nothing_measured_says_nothing(self):
assert scroll_config.refresh_shortfall(None, 120) is None
assert scroll_config.refresh_shortfall(0, 120) is None
assert scroll_config.refresh_shortfall("fast", 120) is None
def test_the_suggestion_leaves_headroom_under_the_measurement(self):
assert scroll_config.holdable_cap(113.1) == 100
assert scroll_config.holdable_cap(95.0) == 90
# 5% under 105 is 99.75: 100 would sit inside the panel's drift.
assert scroll_config.holdable_cap(105.0) == 90
assert scroll_config.holdable_cap(9.0) is None
assert scroll_config.holdable_cap(None) is None
def test_the_log_line_names_the_cap_to_use(self):
text = scroll_config.describe_refresh_shortfall(
scroll_config.refresh_shortfall(113.1, 120))
assert "about 113 Hz" in text and "120 Hz" in text
assert "6% slower" in text
assert "Set Limit Refresh Rate to 100 Hz" in text
def test_no_suggestion_for_a_panel_too_slow_for_any_cap(self):
text = scroll_config.describe_refresh_shortfall(
scroll_config.refresh_shortfall(9.0, 100))
assert "Set Limit Refresh Rate" not in text
+439
View File
@@ -0,0 +1,439 @@
"""src.common.sports_rotation: behaviour, the lock, the seam, host contract.
The cases follow ledmatrix-plugins' ``scripts/test_other_games_rotation.py``
(the tables the family 7 reconcile was checked against): the expected values
are its columns, for the abbreviation-ranked sports and, where the
``_rankings_loaded`` seam is overridden, for football. The host is the mixin
over core's ``SportsCoreSharedMixin`` (``_favorites_first`` and
``_compose_selection`` call into it) with the family-8 ranking reads copied in,
and ``_favorites_first`` stands in for the plugins' ``update()``.
"""
import ast
import logging
import threading
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from src.common import sports_rotation
from src.common.sports_favorites import SportsFavoritesMixin
from src.common.sports_helpers import SportsHelpersMixin
from src.common.sports_rotation import SportsRotationMixin
from src.common.sports_shared import SportsCoreSharedMixin
LOG = logging.getLogger("test_sports_rotation")
NOW = datetime(2026, 10, 9, 12, tzinfo=timezone.utc)
T0 = 100_000.0 # not 0: the window reads a zero stamp as "never cut"
TEAM = {str(n): chr(64 + n) * 3 for n in range(1, 17)} # 1 AAA ... 16 PPP
ID_OF = {abbr: int(tid) for tid, abbr in TEAM.items()}
def game(gid, home, away, hours, recent=False):
g = {"id": gid, "home_id": home, "away_id": away,
"home_abbr": TEAM[home], "away_abbr": TEAM[away]}
if hours is not None:
g["start_time_utc"] = NOW + timedelta(hours=-hours if recent else hours)
return g
class Host(SportsRotationMixin, SportsCoreSharedMixin, SportsFavoritesMixin,
SportsHelpersMixin):
"""A scoreboard SportsCore as far as the rotation reaches."""
league = sport = "test"
sport_key = "test"
def __init__(self, favorites=(), interval=60, quality="any", rankings=None):
self.logger = LOG
self.favorite_teams = list(favorites)
self.other_rotation_interval_seconds = interval
self.other_games_min_quality = quality
self.other_games_divisions = []
self._team_rankings_cache = dict(rankings or {})
self._ranking_coverage_logged_at = 0.0
self._other_window_start = 0
self._other_window_rotated_at = 0.0
self._games_lock = threading.RLock()
self.games_list, self.current_game, self.current_game_index = [], None, 0
self.last_game_switch = 0.0
self.mode_config = {}
# Family 8, still per plugin: the abbreviation sports' bodies.
def _is_ranked_game(self, game):
rankings = self._team_rankings_cache
return bool(rankings.get(game.get("home_abbr"), 0)
or rankings.get(game.get("away_abbr"), 0))
def _best_rank(self, game):
rankings = self._team_rankings_cache
ranked = [r for r in (rankings.get(game.get("home_abbr"), 0),
rankings.get(game.get("away_abbr"), 0)) if r]
return min(ranked) if ranked else 99
def _passes_other_filters(self, game):
return not (self.other_games_min_quality == "ranked"
and self._team_rankings_cache and not self._is_ranked_game(game))
class IdRankedHost(Host):
"""football's override of the seam, and its by-id first rank read."""
def __init__(self, ranked_ids=None, **kw):
super().__init__(**kw)
self._ranked_team_ids = dict(ranked_ids or {})
def _rankings_loaded(self):
return bool(self._ranked_team_ids or self._team_rankings_cache)
def _best_rank(self, game):
try:
ids = [int(game["home_id"]), int(game["away_id"])]
except (KeyError, TypeError, ValueError):
ids = []
if self._ranked_team_ids and ids:
found = [r for r in (self._ranked_team_ids.get(i, 0) for i in ids) if r]
return min(found) if found else 99
return super()._best_rank(game)
class Clock:
def __init__(self):
self.now = T0
def __call__(self):
return self.now
@pytest.fixture
def clock(monkeypatch):
c = Clock()
monkeypatch.setattr(sports_rotation.time, "monotonic", c)
monkeypatch.setattr(sports_rotation.time, "time", c)
return c
def ids(games):
return ",".join(g["id"] for g in games) or "none"
# ---------------------------------------------------------------------------
# _by_importance and the _rankings_loaded seam
# ---------------------------------------------------------------------------
def importance_slate():
no_abbr = game("n9", "15", "16", 9)
del no_abbr["home_abbr"], no_abbr["away_abbr"]
return [game("i3", "6", "7", 3), game("i1", "1", "3", 1), game("i2", "4", "5", 2),
game("i4", "8", "9", 4), game("i5", "2", "10", 5), game("i6", "11", "12", 6),
game("i7", "4", "11", 7), game("i8", "13", "14", None), no_abbr,
game("i10", "2", "15", 10)]
RANKED = {"HHH": 1, "FFF": 2, "DDD": 3, "BBB": 4}
TIED = {"HHH": 1, "FFF": 2, "DDD": 2, "BBB": 4}
AS_LISTED = "i3,i1,i2,i4,i5,i6,i7,i8,n9,i10"
@pytest.mark.parametrize("rankings, newest, expected", [
({}, False, AS_LISTED),
({}, True, AS_LISTED),
(RANKED, False, "i4,i3,i2,i5,i1,i6,n9,i8"),
(RANKED, True, "i4,i3,i7,i10,n9,i1,i8"),
(TIED, False, "i4,i2,i3,i5,i1,i6,n9,i8"), # a tie keeps kickoff order
(TIED, True, "i4,i7,i3,i10,n9,i1,i8"),
])
def test_by_importance(rankings, newest, expected):
assert ids(Host(rankings=rankings)._by_importance(importance_slate(), newest)) == expected
BY_ID = {ID_OF[a]: r for a, r in RANKED.items()}
@pytest.mark.parametrize("abbr, by_id, newest, default, overridden", [
({}, BY_ID, False, AS_LISTED, "i4,i3,i2,i5,i1,i6,n9,i8"),
({}, BY_ID, True, AS_LISTED, "i4,i3,i7,i10,n9,i1,i8"),
({"HHH": 1, "FFF": 2}, {ID_OF["CCC"]: 1}, False,
"i4,i3,i1,i2,i5,i6,n9,i8", "i1,i2,i3,i4,i5,i6,n9,i8"),
({"HHH": 1, "FFF": 2}, {ID_OF["CCC"]: 1}, True,
"i4,i3,i10,n9,i7,i1,i8", "i1,i10,n9,i7,i4,i3,i8"),
])
def test_the_rankings_loaded_seam(abbr, by_id, newest, default, overridden):
"""The default asks the abbreviation table; football counts its id table too."""
plain = Host(rankings=abbr)
plain._ranked_team_ids = by_id # read by nothing in the default
assert ids(plain._by_importance(importance_slate(), newest)) == default
football = IdRankedHost(ranked_ids=by_id, rankings=abbr)
assert ids(football._by_importance(importance_slate(), newest)) == overridden
def test_rankings_loaded_default():
assert Host()._rankings_loaded() is False
assert Host(rankings={"AAA": 1})._rankings_loaded() is True
# ---------------------------------------------------------------------------
# _other_games_window
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("size, limit, interval, expected", [
(0, 3, 60, "none / none / none / none / none / none"),
(7, 0, 60, "none / none / none / none / none / none"),
(1, 3, 60, "o1 / o1 / o1 / o1 / o1 / o1"),
(2, 2, 60, "o1,o2 / o1,o2 / o1,o2 / o1,o2 / o1,o2 / o1,o2"),
(7, 3, 0, "o1,o2,o3 / o1,o2,o3 / o1,o2,o3 / o1,o2,o3 / o1,o2,o3 / o1,o2,o3"),
(7, 2, 60, "o1,o2 / o1,o2 / o3,o4 / o3,o4 / o5,o6 / o4,o5"),
(7, 3, 60, "o1,o2,o3 / o1,o2,o3 / o4,o5,o6 / o4,o5,o6 / o7,o1,o2 / o2,o3,o4"),
])
def test_the_window_advances_catches_up_and_wraps(clock, size, limit, interval, expected):
"""At 0, 30, 60, 119, 120 and 300 s: 300 is three intervals after 120."""
host = Host(interval=interval)
pool = [{"id": f"o{n}"} for n in range(1, size + 1)]
seen = []
for t in (0, 30, 60, 119, 120, 300):
clock.now = T0 + t
seen.append(ids(host._other_games_window(pool, limit)))
assert " / ".join(seen) == expected
# ---------------------------------------------------------------------------
# The display path: _advance_other_games_if_due, _rotate_other_games_on_display
# ---------------------------------------------------------------------------
UPDATE_SLATE = (("u1", "1", "2", 1), ("u2", "3", "4", 2), ("u3", "5", "6", 3),
("u4", "7", "8", 4), ("u5", "2", "3", 5), ("u6", "4", "5", 6),
("u7", "6", "7", 7), ("u8", "1", "8", 8))
def updated(clock, favorites, favorite_limit=2, other_limit=2, recent=False, **kw):
"""What the plugins' update() leaves: _favorites_first's pick, its last card on screen."""
host = Host(favorites=favorites, **kw)
clock.now = T0
games = [game(*row, recent=recent) for row in UPDATE_SLATE]
host.games_list = host._favorites_first(games, favorite_limit, other_limit,
newest_first=recent)
if host.games_list:
host.current_game_index = len(host.games_list) - 1
host.current_game = host.games_list[-1]
return host
def ticks(clock, host):
"""games_list@on screen after update() and each tick (^: redraw), and #composes."""
calls = []
compose = host._compose_selection
def counted():
calls.append(1)
return compose()
host._compose_selection = counted
def shown():
return f"{ids(host.games_list)}@{(host.current_game or {}).get('id')}"
out = [shown()]
for t in (30, 60, 90, 125, 305):
clock.now = T0 + t
redraw = host._rotate_other_games_on_display()
out.append(shown() + ("^" if redraw else ""))
return f"{' > '.join(out)} #{len(calls)}"
ROTATION = {
"AAA, 2 others, 60s": (
{"favorites": ["AAA"]},
"u1,u2,u3,u8@u8 > u1,u2,u3,u8@u8 > u1,u4,u5,u8@u8^ > u1,u4,u5,u8@u8 > "
"u1,u6,u7,u8@u8^ > u1,u6,u7,u8@u8 #3"),
"pinned (0s)": (
{"favorites": ["AAA"], "interval": 0},
"u1,u2,u3,u8@u8 > u1,u2,u3,u8@u8 > u1,u2,u3,u8@u8 > u1,u2,u3,u8@u8 > "
"u1,u2,u3,u8@u8 > u1,u2,u3,u8@u8 #0"),
"0 others": (
{"favorites": ["AAA"], "other_limit": 0},
"u1,u8@u8 > u1,u8@u8 > u1,u8@u8 > u1,u8@u8 > u1,u8@u8 > u1,u8@u8 #0"),
"no favourite slots, 3 others": (
{"favorites": [], "favorite_limit": 0, "other_limit": 3},
"u1,u2,u3@u3 > u1,u2,u3@u3 > u4,u5,u6@u4^ > u4,u5,u6@u4 > u1,u7,u8@u1^ > "
"u1,u2,u8@u1^ #3"),
"ranked, poll on the slate": (
{"favorites": ["AAA"], "quality": "ranked", "rankings": RANKED},
"u1,u3,u4,u8@u8 > u1,u3,u4,u8@u8 > u1,u2,u4,u8@u8^ > u1,u2,u4,u8@u8 > "
"u1,u2,u3,u8@u8^ > u1,u2,u3,u8@u8 #3"),
# The favourite still fills the board: no recompose on any frame.
"ranked, poll matches nothing, favourite playing": (
{"favorites": ["AAA"], "quality": "ranked", "rankings": {"ZZZ": 1}},
"u1,u8@u8 > u1,u8@u8 > u1,u8@u8 > u1,u8@u8 > u1,u8@u8 > u1,u8@u8 #0"),
# Nothing survived, so compose cuts the unfiltered fallback: it rotates too.
"ranked, poll matches nothing, favourite not playing": (
{"favorites": ["ZZZ"], "quality": "ranked", "rankings": {"ZZZ": 1}},
"u1,u2@u2 > u1,u2@u2 > u3,u4@u3^ > u3,u4@u3 > u1,u2@u1^ > u3,u4@u3^ #3"),
}
@pytest.mark.parametrize("recent", [False, True], ids=["upcoming", "recent"])
@pytest.mark.parametrize("scenario", list(ROTATION))
def test_the_display_path_rotation(clock, scenario, recent):
settings, expected = ROTATION[scenario]
assert ticks(clock, updated(clock, recent=recent, **settings)) == expected
def test_no_pools_no_rotation(clock):
"""A manager whose update() never built the pools (ufc's MMA ones) never rotates."""
host = Host()
clock.now = T0 + 3600
assert host._advance_other_games_if_due() == []
assert host._rotate_other_games_on_display() is False
# ---------------------------------------------------------------------------
# update() and display() both advancing: the lock
# ---------------------------------------------------------------------------
def _racing(host):
"""Run display()'s rotation on a second thread inside update()'s advance.
The first time this thread reads ``_other_window_start`` -- the advance's
read-modify-write, after the interval was found elapsed -- the display path
starts; it either finishes first (no lock) or waits on ``_games_lock``.
"""
owner, threads = threading.get_ident(), []
class Interleaved(type(host)):
@property
def _other_window_start(self):
if threading.get_ident() == owner and not threads:
display = threading.Thread(target=self._rotate_other_games_on_display,
daemon=True)
threads.append(display)
display.start()
display.join(2.0)
return self.__dict__["_other_window_start"]
@_other_window_start.setter
def _other_window_start(self, value):
self.__dict__["_other_window_start"] = value
host.__class__ = Interleaved
return threads
class _NoLock:
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def _update_again(host):
host.games_list = host._favorites_first(
[game(*row) for row in UPDATE_SLATE], 2, 2)
@pytest.mark.parametrize("lock, windows, shown", [
(True, 1, "u1,u4,u5,u8"),
(False, 2, "u1,u6,u7,u8"), # what the eight plugins did before
])
def test_display_inside_updates_advance(clock, lock, windows, shown):
host = updated(clock, ["AAA"])
if not lock:
host._games_lock = _NoLock()
clock.now = T0 + 61
threads = _racing(host)
_update_again(host)
for t in threads:
t.join(10)
assert threads, "update() never advanced the window"
assert (host.__dict__["_other_window_start"] // 2, ids(host.games_list)) == (windows, shown)
@pytest.mark.parametrize("display_first", [False, True])
def test_update_and_display_in_sequence(clock, display_first):
host = updated(clock, ["AAA"])
clock.now = T0 + 61
if display_first:
host._rotate_other_games_on_display()
_update_again(host)
if not display_first:
host._rotate_other_games_on_display()
assert (host._other_window_start // 2, ids(host.games_list)) == (1, "u1,u4,u5,u8")
# ---------------------------------------------------------------------------
# Odds for rotated-in games
# ---------------------------------------------------------------------------
def _rotate_with_odds(clock, show_odds=True, manager=True, preset=None):
host = updated(clock, ["AAA"])
host.show_odds = show_odds
host.odds_manager = MagicMock() if manager else None
if manager:
host.odds_manager.get_odds.side_effect = lambda **k: {"details": k["event_id"]}
for g in host._selection_pools["favorites"] + host._selection_pools["others"]:
if g["id"] in (preset or ()):
g["odds"] = {"details": "already"}
clock.now = T0 + 61
assert host._rotate_other_games_on_display()
for t in threading.enumerate():
if t.name == "test-rotated-odds":
t.join(10)
return host
def test_rotated_in_games_get_odds(clock):
host = _rotate_with_odds(clock, preset={"u1"})
assert ids(host.games_list) == "u1,u4,u5,u8"
assert {g["id"]: g["odds"]["details"] for g in host.games_list} == {
"u1": "already", "u4": "u4", "u5": "u5", "u8": "u8"}
asked = [c.kwargs["event_id"] for c in host.odds_manager.get_odds.call_args_list]
assert asked == ["u4", "u5", "u8"]
@pytest.mark.parametrize("show_odds, manager", [(False, True), (True, False)])
def test_no_odds_without_show_odds_or_a_manager(clock, show_odds, manager):
host = _rotate_with_odds(clock, show_odds=show_odds, manager=manager)
assert not any(g.get("odds") for g in host.games_list)
if manager:
host.odds_manager.get_odds.assert_not_called()
# ---------------------------------------------------------------------------
# Carrier and host contract
# ---------------------------------------------------------------------------
METHODS = ["_advance_other_games_if_due", "_attach_odds_to_rotated_games",
"_by_importance", "_other_games_window", "_rankings_loaded",
"_rotate_other_games_on_display"]
def _self_reads():
tree = ast.parse(Path(sports_rotation.__file__).read_text(encoding="utf-8"))
return {node.attr for node in ast.walk(tree)
if isinstance(node, ast.Attribute) and isinstance(node.ctx, ast.Load)
and isinstance(node.value, ast.Name) and node.value.id == "self"}
class TestHostContract:
def test_the_mixin_carries_only_the_family(self):
"""Every scoreboard's SportsCore has all six, so adopting it adds none."""
assert sorted(n for n in vars(SportsRotationMixin)
if not n.startswith("__")) == METHODS
def test_every_host_read_is_documented(self):
undocumented = sorted(n for n in _self_reads()
if f"``{n}``" not in sports_rotation.__doc__)
assert undocumented == [], f"read but not in the host contract: {undocumented}"
def test_no_other_shared_mixin_defines_these(self):
from src.common import sports_display_rules, sports_shared
others = [sports_shared.SportsCoreSharedMixin, sports_shared.SportsRecentSharedMixin,
sports_shared.SportsLiveSharedMixin, SportsHelpersMixin,
SportsFavoritesMixin, sports_display_rules.SportsGameRulesMixin]
assert [m for m in METHODS if any(m in vars(o) for o in others)] == []
def test_the_host_class_wins(self):
"""football keeps its _rankings_loaded; the plugin's own method runs."""
assert IdRankedHost(ranked_ids={1: 1})._rankings_loaded() is True
assert Host()._rankings_loaded() is False
+134
View File
@@ -0,0 +1,134 @@
"""sports_rotation still matches every plugin copy, and only football overrides the seam.
``src.common.sports_rotation`` was copied from the scoreboards once family 7
had made each method one body in all nine ``SportsCore`` classes:
``_by_importance``, ``_other_games_window``, ``_advance_other_games_if_due``,
``_rotate_other_games_on_display``, ``_attach_odds_to_rotated_games`` and the
default ``_rankings_loaded``. The plugins delete their copies once they floor
on the release that ships this module. Until each has, a copy that changes on
its own is a fix one side has and the other lacks.
Point LEDMATRIX_PLUGINS at a ledmatrix-plugins checkout and each method is
compared with every plugin copy using ``scripts/sports_drift_report.py``'s own
normalisation (the AST with docstrings and annotations dropped), plus the
decorators. A copy that is gone counts as adopted when the plugin's
``sports.py`` names the module. football's ``_rankings_loaded`` is the
decided override (it counts its rankings keyed by team id), checked as the only
one; it stays in the plugin after adoption. Without the variable this skips:
core CI has no plugins checkout.
"""
import ast
import importlib.util
import os
from pathlib import Path
import pytest
from src.common import sports_rotation
REPO = Path(__file__).resolve().parents[1]
SPORTS = ("afl", "baseball", "basketball", "football", "hockey", "lacrosse",
"nrl", "soccer", "ufc")
CARRIER, MIXIN = "SportsCore", "SportsRotationMixin"
METHODS = ("_by_importance", "_other_games_window", "_advance_other_games_if_due",
"_rotate_other_games_on_display", "_attach_odds_to_rotated_games",
"_rankings_loaded")
SEAM = "_rankings_loaded"
#: The owner's decision (docs/SPORTS_UNIFICATION.md, family 7): the sports
#: whose own _rankings_loaded replaces the default.
OVERRIDES_RANKINGS_LOADED = {"football"}
def _drift_report():
"""scripts/sports_drift_report.py, loaded by path (scripts/ is no package)."""
spec = importlib.util.spec_from_file_location(
"sports_drift_report", REPO / "scripts" / "sports_drift_report.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
DRIFT = _drift_report()
def _plugins_root():
root = DRIFT.resolve_plugins_dir(os.environ.get("LEDMATRIX_PLUGINS"))
if root is None:
pytest.skip("set LEDMATRIX_PLUGINS to a ledmatrix-plugins checkout to "
"compare this module against the plugin copies")
return root
def _class(tree, name):
return next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == name)
def _method(cls, name):
return next((n for n in cls.body
if isinstance(n, ast.FunctionDef) and n.name == name), None)
def _fingerprint(node):
return (DRIFT._digest(node, DRIFT._Canonical()),
tuple(ast.unparse(d) for d in node.decorator_list))
def _ours(name):
tree = ast.parse(Path(sports_rotation.__file__).read_text(encoding="utf-8"))
return _method(_class(tree, MIXIN), name)
def _plugin_tree(root, sport):
source = (root / f"{sport}-scoreboard" / "sports.py").read_text(encoding="utf-8")
return source, ast.parse(source)
CASES = [(sport, name) for sport in SPORTS for name in METHODS
if not (name == SEAM and sport in OVERRIDES_RANKINGS_LOADED)]
@pytest.mark.parametrize("sport, name", CASES)
def test_every_remaining_plugin_copy_matches(sport, name):
source, tree = _plugin_tree(_plugins_root(), sport)
copy = _method(_class(tree, CARRIER), name)
if copy is None:
assert sports_rotation.__name__ in source, (
f"{sport}: no {name} on {CARRIER} and no {sports_rotation.__name__} import")
else:
assert _fingerprint(copy) == _fingerprint(_ours(name)), (
f"{CARRIER}.{name} in {sport} differs from sports_rotation. "
f"Port the change to both, or stop treating it as shared.")
@pytest.mark.parametrize("sport", SPORTS)
def test_no_other_plugin_class_carries_a_copy(sport):
"""A copy on another class would shadow the shared one for that class."""
_, tree = _plugin_tree(_plugins_root(), sport)
strays = [f"{node.name}.{name}" for node in tree.body if isinstance(node, ast.ClassDef)
for name in METHODS if node.name != CARRIER and _method(node, name) is not None]
assert strays == []
def test_only_the_decided_sports_override_the_seam():
root = _plugins_root()
ours = _fingerprint(_ours(SEAM))
overriding = set()
for sport in SPORTS:
copy = _method(_class(_plugin_tree(root, sport)[1], CARRIER), SEAM)
if copy is not None and _fingerprint(copy) != ours:
overriding.add(sport)
assert overriding == OVERRIDES_RANKINGS_LOADED
def test_the_drift_report_still_calls_them_identical():
root = _plugins_root()
families = DRIFT.build(root, ("sports.py",))
rows = {(r["file"], r["family"]): r
for r in (DRIFT.summarise(k, v) for k, v in families.items())}
for name in METHODS:
if name == SEAM:
continue # the default and football's override: two by design
row = rows.get(("sports.py", name))
assert row is None or row["worst_class_variants"] == 1, name
+31 -27
View File
@@ -11,26 +11,32 @@ than even logging it.
import pytest
from src.web_interface.error_handler import describe_exception
from src.web_interface.error_handler import describe_exception, redact_text
class TestDescribeException:
def test_names_the_type_and_message(self):
detail = describe_exception(OSError(5, "Input/output error", "systemctl"))
assert detail == "OSError: [Errno 5] Input/output error: 'systemctl'"
"""describe_exception is a reason code: type and errno, never the message.
def test_the_reported_failure_is_legible(self):
# The whole point: this string is the diagnosis.
assert "Input/output error" in describe_exception(
OSError(5, "Input/output error", "systemctl"))
The message can quote paths, URLs or credentials (CodeQL
py/stack-trace-exposure), so it goes to the log; the code still names the
fault, as "[Errno 5]" did.
"""
def test_an_oserror_names_its_errno(self):
assert describe_exception(
OSError(5, "Input/output error", "systemctl")) == "OSError:EIO"
def test_a_bare_exception_still_names_its_type(self):
# A PermissionError with no message still says more than "unknown".
assert describe_exception(PermissionError()) == "PermissionError"
assert describe_exception(Exception()) == "Exception"
def test_message_is_kept_when_present(self):
assert describe_exception(ValueError("bad port")) == "ValueError: bad port"
def test_the_message_never_reaches_the_code(self):
assert describe_exception(ValueError("bad port /etc/secret")) == "ValueError"
def test_the_message_is_logged_instead(self, caplog):
describe_exception(RuntimeError("disk on fire token=abc123"))
assert "disk on fire" in caplog.text
assert "abc123" not in caplog.text
class TestCredentialRedaction:
@@ -56,47 +62,44 @@ class TestCredentialRedaction:
("authorization: barecredential", "barecredential"),
])
def test_credentials_never_reach_the_response(self, secret_text, leaked):
detail = describe_exception(RuntimeError(secret_text))
detail = redact_text(secret_text)
assert leaked not in detail
assert "<redacted>" in detail
def test_the_parameter_name_survives_redaction(self):
# Knowing *which* credential was involved is part of the diagnosis.
detail = describe_exception(RuntimeError("https://x/y?api_key=SEC123"))
detail = redact_text("https://x/y?api_key=SEC123")
assert "api_key" in detail
def test_unknown_schemes_keep_their_name(self):
for scheme in ("ApiKey", "Negotiate", "NTLM", "AWS4-HMAC-SHA256"):
detail = describe_exception(
RuntimeError("Authorization: %s SECRETVALUE" % scheme))
detail = redact_text("Authorization: %s SECRETVALUE" % scheme)
assert scheme in detail, detail
assert "SECRETVALUE" not in detail, detail
def test_auth_scheme_and_username_survive(self):
# Which kind of credential, and whose, without the credential itself.
assert "Bearer" in describe_exception(
RuntimeError("Authorization: Bearer eyJ.SECRET.sig"))
assert "user" in describe_exception(
RuntimeError("https://user:hunter2@example.com"))
assert "Bearer" in redact_text("Authorization: Bearer eyJ.SECRET.sig")
assert "user" in redact_text("https://user:hunter2@example.com")
def test_non_secret_context_is_preserved(self):
detail = describe_exception(RuntimeError("https://api.x.com/v1?city=Tampa"))
detail = redact_text("https://api.x.com/v1?city=Tampa")
assert "city=Tampa" in detail
assert "<redacted>" not in detail
class TestBounds:
def test_long_messages_are_truncated(self):
detail = describe_exception(ValueError("x" * 5000))
detail = redact_text("x" * 5000)
assert len(detail) <= 400
def test_newlines_are_collapsed_to_one_line(self):
detail = describe_exception(ValueError("line one\nline two\tthree"))
detail = redact_text("line one\nline two\tthree")
assert "\n" not in detail and "\t" not in detail
assert detail == "ValueError: line one line two three"
assert detail == "line one line two three"
def test_custom_length_is_honoured(self):
assert len(describe_exception(ValueError("y" * 500), max_length=50)) <= 50
assert len(redact_text("y" * 500, max_length=50)) <= 50
class TestHandlersCarryDetail:
@@ -319,10 +322,10 @@ class TestHandlersCarryDetail:
assert resp.status_code == 405, "a wrong method must stay a 405"
assert resp.get_json()["error_code"] == "METHOD_NOT_ALLOWED"
# A genuine server fault still reports as one, with its detail.
# A genuine server fault still reports as one, with its reason code.
resp = client.get("/boom")
assert resp.status_code == 500
assert "Input/output error" in resp.get_json()["details"]
assert resp.get_json()["details"] == "OSError:EIO"
def test_global_handler_reports_the_underlying_error(self):
from flask import Flask, jsonify
@@ -345,4 +348,5 @@ class TestHandlersCarryDetail:
client = app.test_client()
body = client.get("/boom").get_json()
assert body["error_code"] == "UNKNOWN_ERROR"
assert "Input/output error" in body["details"]
assert body["details"] == "OSError:EIO"
assert "Input/output error" not in str(body)
@@ -0,0 +1,73 @@
"""GET /api/v3/config/refresh-rate: the cap, the measured rate, a cap to hold."""
import json
from unittest.mock import MagicMock
import pytest
from flask import Flask
from web_interface.blueprints.api_v3 import api_v3
@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 _get(client):
body = client.get("/api/v3/config/refresh-rate").get_json()
assert body["status"] == "success"
return body["data"]
def test_nothing_measured_yet(client):
data = _get(client)
assert data == {"planned_hz": 120.0, "measured_hz": None, "shortfall": None}
def test_a_panel_short_of_its_cap_gets_a_cap_it_can_hold(client):
client.stats_path.write_text(json.dumps(
{"measured_refresh_hz": 110.4, "planned_refresh_hz": 120.0}))
data = _get(client)
assert data["measured_hz"] == 110.4
assert data["shortfall"]["suggested_cap_hz"] == 100
assert data["shortfall"]["slow_percent"] == 8
def test_a_panel_at_its_cap_has_no_shortfall(client):
client.stats_path.write_text(json.dumps(
{"measured_refresh_hz": 121.3, "planned_refresh_hz": 120.0}))
assert _get(client)["shortfall"] is None
def test_a_file_written_under_another_cap_is_stale(client):
# The cap was changed to 120 but the display still runs under 100 Hz.
client.stats_path.write_text(json.dumps(
{"measured_refresh_hz": 99.9, "planned_refresh_hz": 100.0}))
data = _get(client)
assert data["measured_hz"] is None
assert data["shortfall"] is None
def test_a_file_from_a_display_too_old_to_record_its_cap_still_counts(client):
client.stats_path.write_text(json.dumps({"measured_refresh_hz": 110.4}))
assert _get(client)["shortfall"]["suggested_cap_hz"] == 100
def test_a_measurement_recorded_without_a_planned_rate_is_not_a_panel(client):
# The emulator and the fallback canvas write the key as null: their frames
# are not paced by a panel, so a rate under the cap is no shortfall.
client.stats_path.write_text(json.dumps(
{"measured_refresh_hz": 60.0, "planned_refresh_hz": None}))
data = _get(client)
assert data["measured_hz"] is None
assert data["shortfall"] is None
@@ -114,12 +114,11 @@ def test_the_answer_is_what_the_catch_all_returned(client, caplog, method, url,
assert records[-1].exc_info[1] is FORCED
def test_credentials_are_redacted_from_the_detail(client):
def test_the_exception_message_never_reaches_the_detail(client):
body = client.get("/api/v3/plugins/installed").get_json()
for secret in ("SECRET123", "pw1", "K1"):
assert secret not in body["details"]
assert "<redacted>" in body["details"]
assert body["details"].startswith("RuntimeError: forced failure")
for secret in ("SECRET123", "pw1", "K1", "forced failure"):
assert secret not in str(body)
assert body["details"] == "RuntimeError"
def _raise_415():
@@ -218,7 +217,7 @@ class TestPluginActionStep1:
encoding="utf-8")
return d
def test_the_script_error_reaches_the_response(self, plugin_dir, monkeypatch):
def test_the_script_error_is_reported_by_type(self, plugin_dir, monkeypatch):
from unittest.mock import MagicMock
manager = MagicMock()
manager.get_plugin_directory.return_value = str(plugin_dir)
@@ -232,5 +231,6 @@ class TestPluginActionStep1:
assert resp.status_code == 500
body = resp.get_json()
assert body["details"] == "RuntimeError: the auth script failed"
assert body["details"] == "RuntimeError"
assert "the auth script failed" not in str(body)
assert body["message"] == 'An error occurred; see logs for details'
@@ -946,21 +946,27 @@ class TestTheStoreReportsWhyItIsEmpty:
class TestACrashCarriesItsDetail:
"""Seventeen Starlark handlers answered 5xx with no detail at all."""
"""Seventeen Starlark handlers answered 5xx with no detail at all.
def test_browse_returns_the_exception_detail(self, client):
The detail is a reason code (the exception type), not the exception's
message, which stays in the log (CodeQL py/stack-trace-exposure).
"""
def test_browse_returns_the_reason_code(self, client):
with patch('web_interface.blueprints.api_v3._get_tronbyte_repository_class',
side_effect=ImportError("No module named 'yaml'")):
body = client.get('/api/v3/starlark/repository/browse').get_json()
assert 'yaml' in body.get('details', ''), body
assert body.get('details') == 'ImportError', body
assert 'yaml' not in str(body), body
def test_status_returns_the_exception_detail(self, client):
def test_status_returns_the_reason_code(self, client):
with patch('web_interface.blueprints.api_v3._get_starlark_plugin',
side_effect=RuntimeError("plugin manager is not attached")):
body = client.get('/api/v3/starlark/status').get_json()
assert 'plugin manager is not attached' in body.get('details', ''), body
assert body.get('details') == 'RuntimeError', body
assert 'plugin manager is not attached' not in str(body), body
class TestTheListingIsNotCappedAtOneThousand:
+26 -21
View File
@@ -223,7 +223,7 @@ def _save_config_atomic(config_manager, config_data, create_backup=True):
config_manager.save_config(config_data)
return True, None
except Exception as e:
return False, str(e)
return False, f"Failed to save configuration ({describe_exception(e)})"
def _coerce_to_bool(value):
"""
Coerce a form value to a proper Python boolean.
@@ -247,7 +247,11 @@ def _coerce_to_bool(value):
return value.lower() in ('true', 'on', '1', 'yes')
return False
def _get_display_service_status():
"""Return status information about the ledmatrix service."""
"""Return status information about the ledmatrix service.
active/returncode only: this goes back in API responses, and systemctl's
output (or an exception's text) is logged rather than returned.
"""
try:
result = subprocess.run(
['systemctl', 'is-active', 'ledmatrix'],
@@ -255,26 +259,18 @@ def _get_display_service_status():
text=True,
timeout=3
)
if result.stderr.strip():
logger.debug('systemctl is-active ledmatrix: %s', result.stderr.strip())
return {
'active': result.stdout.strip() == 'active',
'returncode': result.returncode,
'stdout': result.stdout.strip(),
'stderr': result.stderr.strip()
}
except subprocess.TimeoutExpired:
return {
'active': False,
'returncode': -1,
'stdout': '',
'stderr': 'timeout'
}
except Exception as err:
return {
'active': False,
'returncode': -1,
'stdout': '',
'stderr': str(err)
}
logger.warning('systemctl is-active ledmatrix timed out')
return {'active': False, 'returncode': -1}
except Exception:
logger.warning('Could not query ledmatrix.service status', exc_info=True)
return {'active': False, 'returncode': -1}
def _run_systemctl_command(args):
"""Run a systemctl command safely."""
try:
@@ -296,18 +292,26 @@ def _run_systemctl_command(args):
'stderr': 'timeout'
}
except Exception as err:
logger.warning('%s failed', ' '.join(args), exc_info=True)
return {
'returncode': -1,
'stdout': '',
'stderr': str(err)
'stderr': describe_exception(err)
}
def _public_service_result(result):
"""A _run_systemctl_command result fit for a response: no stdout/stderr."""
if result.get('returncode') != 0:
logger.error('systemctl exited %s: %s', result.get('returncode'),
(result.get('stderr') or '').strip())
return {k: v for k, v in result.items() if k not in ('stdout', 'stderr')}
def _ensure_display_service_running():
"""Ensure the ledmatrix display service is running."""
status = _get_display_service_status()
if status.get('active'):
status['started'] = False
return status
result = _run_systemctl_command(['sudo', 'systemctl', 'start', 'ledmatrix.service'])
result = _public_service_result(
_run_systemctl_command(['sudo', 'systemctl', 'start', 'ledmatrix.service']))
service_status = _get_display_service_status()
result['started'] = result.get('returncode') == 0
result['active'] = service_status.get('active')
@@ -315,7 +319,8 @@ def _ensure_display_service_running():
return result
def _stop_display_service():
"""Stop the ledmatrix display service."""
result = _run_systemctl_command(['sudo', 'systemctl', 'stop', 'ledmatrix.service'])
result = _public_service_result(
_run_systemctl_command(['sudo', 'systemctl', 'stop', 'ledmatrix.service']))
status = _get_display_service_status()
result['active'] = status.get('active')
result['status'] = status
@@ -713,7 +718,7 @@ def _do_transactional_uninstall(plugin_id, preserve_config):
success = api_v3.plugin_store_manager.uninstall_plugin(plugin_id)
except Exception as remove_err:
_rollback()
return False, f"Failed to remove plugin {plugin_id}: {remove_err}"
return False, f"Failed to remove plugin {plugin_id} ({describe_exception(remove_err)})"
if not success:
_rollback()
+38 -3
View File
@@ -158,11 +158,23 @@ def _panel_refresh_hz(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)
stats = json.load(fh)
measured = float(stats.get('measured_refresh_hz') or 0)
recorded = 'planned_refresh_hz' in stats
planned = float(stats.get('planned_refresh_hz') or 0)
except (OSError, ValueError, TypeError, AttributeError):
measured = planned = 0.0
recorded = False
# Reject a stale file from a previous hardware config: one written under
# another cap (the display has not restarted since it changed), or, from
# a display too old to record its cap (no such key), a measurement far
# off this one. A key that is present but null means the display's frames
# are not paced by a panel (the emulator, the fallback canvas): its
# "refresh rate" says nothing about the cap.
if recorded and not planned:
measured = 0.0
elif planned and abs(planned - cap) > 0.5:
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'
@@ -189,6 +201,29 @@ def get_scroll_speed_advice():
return jsonify({'status': 'success', 'data': advice})
@api_v3.route('/config/refresh-rate', methods=['GET'])
def get_refresh_rate():
"""The refresh cap, what the panel measured, and a cap it can hold.
Backs the hint under the Display tab's Limit Refresh Rate field. Scroll
speeds are solved against the cap, so a panel that cannot reach it runs
every scroll slow; ``shortfall`` (None when the panel keeps up, or nothing
has been measured yet) says by how much and suggests a cap.
"""
from src.common import scroll_config
if not api_v3.config_manager:
return jsonify({'status': 'error', 'message': 'Config manager not initialized'}), 500
config = api_v3.config_manager.load_config()
planned = scroll_config.refresh_hz_from_config(config)
hz, source = _panel_refresh_hz(config)
measured = hz if source == 'measured' else None
return jsonify({'status': 'success', 'data': {
'planned_hz': planned,
'measured_hz': round(measured, 1) if measured else None,
'shortfall': scroll_config.refresh_shortfall(measured, planned),
}})
@api_v3.route('/config/schedule', methods=['GET'])
def get_schedule_config():
"""Get current schedule configuration"""
@@ -983,7 +983,6 @@ def stop_pixlet_editor():
'status': 'error',
'message': 'Editor force-stopped, but the display could not be '
'restarted automatically - start it manually.',
'details': (result.get('stderr') or '').strip(),
'data': {'running': False}}), 500
return jsonify({'status': 'success',
'message': 'Editor force-stopped; the display has been '
+3 -4
View File
@@ -689,7 +689,7 @@ def execute_system_action():
logger.warning("install_base_requirements timed out for %s", label)
except OSError as install_err:
all_ok = False
outputs.append(f"== {label} ==\nFailed: {install_err}")
outputs.append(f"== {label} ==\nFailed: {describe_exception(install_err)}")
logger.warning("install_base_requirements errored for %s: %s", label, install_err)
return jsonify({
'status': 'success' if all_ok else 'error',
@@ -784,11 +784,10 @@ def execute_system_action():
return jsonify({'status': 'error', 'message': 'Command timed out', 'returncode': -1, 'stderr': 'timeout'})
except Exception as e:
logger.error("execute_system_action failed: %s", e, exc_info=True)
detail = describe_exception(e)
resp = {
'status': 'error',
'message': _sudo_hint_for(detail) or 'Action failed; see logs for details',
'details': detail,
'message': _sudo_hint_for(str(e)) or 'Action failed; see logs for details',
'details': describe_exception(e),
}
return jsonify(resp), 500
@api_v3.route('/system/git-info', methods=['GET'])
+2 -2
View File
@@ -67,7 +67,7 @@ def _run_background_connect(ssid, password):
payload = _connect_result_payload(ssid, success, message)
except Exception as e:
logger.error("Background WiFi connect failed", exc_info=True)
payload = {'status': 'error', 'message': describe_exception(e)}
payload = {'status': 'error', 'message': f'Failed to connect to network ({describe_exception(e)})'}
_record_connect_result(ssid, payload)
@@ -276,7 +276,7 @@ def connect_wifi():
try:
success, message = wifi_manager.connect_to_network(ssid, password)
except Exception as e:
_record_connect_result(ssid, {'status': 'error', 'message': describe_exception(e)})
_record_connect_result(ssid, {'status': 'error', 'message': f'Failed to connect to network ({describe_exception(e)})'})
raise
payload = _connect_result_payload(ssid, success, message)
_record_connect_result(ssid, payload)
@@ -92,6 +92,45 @@ function refreshScrollSpeedHint(root, ctx) {
}, HINT_DELAY_MS);
}
// ── the refresh-cap hint ─────────────────────────────────────────────────────
// Scroll speeds are worked out against the cap, so a panel that cannot reach
// it runs every scroll slow. The display has measured a cap it can hold.
function showRefreshRateHint(root, ctx) {
const hint = root.querySelector('#limit_refresh_rate_hz_hint');
const input = root.querySelector('#limit_refresh_rate_hz');
if (!hint || !input) return;
const doc = root.ownerDocument;
const win = doc.defaultView;
ctx.api.get('/api/v3/config/refresh-rate', { signal: ctx.signal })
.then(function(body) {
const s = body.status === 'success' && body.data.shortfall;
hint.textContent = '';
if (!s) return;
hint.appendChild(doc.createTextNode(
'This panel refreshes at about ' + Math.round(s.measured_hz) +
' Hz, below this ' + Math.round(s.planned_hz) + ' Hz cap, so scrolls run about ' +
s.slow_percent + '% slower than set.' + (s.suggested_cap_hz ? ' ' : '')));
if (!s.suggested_cap_hz) return;
const btn = doc.createElement('button');
btn.type = 'button';
btn.className = 'underline font-medium';
btn.textContent = 'Use ' + s.suggested_cap_hz + ' Hz';
btn.addEventListener('click', function() {
input.value = s.suggested_cap_hz;
input.dispatchEvent(new win.Event('input', { bubbles: true }));
input.dispatchEvent(new win.Event('change', { bubbles: true }));
hint.textContent = 'Save, then restart the display, to apply ' +
s.suggested_cap_hz + ' Hz.';
});
hint.appendChild(btn);
hint.appendChild(doc.createTextNode(', a cap it can hold.'));
})
.catch(function(error) {
if (quiet(error) || error.body) return;
hint.textContent = '';
});
}
function renderScrollSpeedHint(root, hint, slider, a) {
const doc = root.ownerDocument;
const win = doc.defaultView;
@@ -303,6 +342,7 @@ export function init(root, ctx) {
// when it already is), then every 5 s while it stays there.
ctx.visibility.every(SYNC_POLL_MS, function() { pollSyncStatus(root, ctx); });
showRefreshRateHint(root, ctx);
startPluginOrder(root, ctx);
active = ctx;
}
@@ -319,6 +319,7 @@
min="0"
max="1000"
class="form-control">
<p id="limit_refresh_rate_hz_hint" class="mt-1 text-xs text-amber-700" aria-live="polite"></p>
</div>
</div>
+2 -2
View File
@@ -86,7 +86,7 @@ def refresh_after_update(run=None, systemd_dir=None, helper_source=None, helper_
except Exception as e: # a broken template must not fail the update itself
if type(e).__name__ != 'UnitsUnreadable':
logger.warning("Could not compare the installed systemd units with the new templates: %s", e)
return _result(FAILED, f'The service settings could not be checked: {e}.')
return _result(FAILED, 'The service settings could not be checked; see logs for details.')
# Units installed mode 0600 (install_service.sh run on its own, before
# it set 0644): only root can compare them, so let the helper decide.
stale = []
@@ -110,7 +110,7 @@ def refresh_after_update(run=None, systemd_dir=None, helper_source=None, helper_
timeout=TIMEOUT_SECONDS)
except (subprocess.SubprocessError, OSError) as e:
logger.warning("Refreshing the systemd units failed: %s", e)
return _result(FAILED, f'Updating the service settings ({names}) failed: {e}.', stale)
return _result(FAILED, f'Updating the service settings ({names}) failed; see logs for details.', stale)
if result.returncode == 0:
# The helper says what it did: "units refreshed: a b" or "units: up to date".
done = next((line.split(':', 1)[1].split() for line in (result.stdout or '').splitlines()