mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-10 00:56:35 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa6d0a213d | ||
|
|
e186ae8b24 | ||
|
|
65ede54cba | ||
|
|
20bae8b609 | ||
|
|
1bcb524fc3 |
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -4,5 +4,5 @@ LEDMatrix Display System
|
||||
Core source package for the LED Matrix Display project.
|
||||
"""
|
||||
|
||||
__version__ = "3.8.3"
|
||||
__version__ = "3.8.4"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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"""
|
||||
|
||||
Vendored
+9
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 '
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user