mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-15 19:39:14 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
456a1ac16c | ||
|
|
f4435d79e0 | ||
|
|
c5ce497c37 | ||
|
|
f9b0b237a3 | ||
|
|
69d89a5d69 | ||
|
|
886e199cae |
@@ -19,6 +19,75 @@ accepts both, but the store flags the old spelling as deprecated
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
## 3.8.7
|
||||||
|
|
||||||
|
Sports consolidation family 10: `src.common.sports_view_model`, the game
|
||||||
|
dict every scoreboard screen draws, which the scoreboards adopt by flooring
|
||||||
|
on 3.8.7 (#796).
|
||||||
|
|
||||||
|
### New modules
|
||||||
|
|
||||||
|
- `src/common/sports_view_model.py` -- sports consolidation family 10, once
|
||||||
|
the plugins made `_extract_game_details_common` (nine bodies) one:
|
||||||
|
`SportsViewModelMixin` (`SportsCore`), the game dict every scoreboard screen
|
||||||
|
draws from one ESPN event, with `_status_is_final`, `_score_text`,
|
||||||
|
`_team_logo_url`, `_extract_team_record` and the `_logo_filename` seam
|
||||||
|
beside it. One rule for a final (state "post", not `completed: false`, not
|
||||||
|
postponed, canceled, suspended, abandoned, delayed or pending; a forfeit is
|
||||||
|
final); a called-off game is never live; scores are integer text; a time
|
||||||
|
without an offset is UTC. Keys are additive only; the module docstring
|
||||||
|
lists every key, its type and default. A plugin may inherit the mixin once
|
||||||
|
it floors on 3.8.7, and deletes its copies then. (#796)
|
||||||
|
|
||||||
|
## 3.8.6
|
||||||
|
|
||||||
|
A **Start Display on Boot** toggle on the General tab (#792), two fixes to
|
||||||
|
the shared rankings code (#791), `get_espn_scoreboard()` no longer caching a
|
||||||
|
null-events answer, and sports consolidation family 9:
|
||||||
|
`src.common.sports_live_fetch`, which the scoreboards adopt by flooring on
|
||||||
|
3.8.6 (#793).
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- General tab: a **Start Display on Boot** toggle turns
|
||||||
|
`ledmatrix.service`'s autostart on or off right away (no Save needed),
|
||||||
|
through the existing `enable_autostart` / `disable_autostart` system
|
||||||
|
actions. It shows systemd's own state, so nothing is stored in
|
||||||
|
`config.json`; the default stays on (the installer enables the unit). The
|
||||||
|
web interface still starts on boot either way. Hidden when the unit's state
|
||||||
|
cannot be read (not installed, or no systemd).
|
||||||
|
|
||||||
|
### New modules
|
||||||
|
|
||||||
|
- `src/common/sports_live_fetch.py` -- sports consolidation family 9, once the
|
||||||
|
plugins made `_fetch_todays_games` (seven bodies) and `_fetch_odds` (three)
|
||||||
|
one each: `SportsLiveFetchMixin` (`SportsCore`), with the odds worker
|
||||||
|
`_drain_odds_queue` beside them. The live poll asks ESPN on every poll
|
||||||
|
(a copy another reader fetched within the last second is reused) and
|
||||||
|
leaves the answer under the shared `espn_scoreboard_cache_key` for its
|
||||||
|
dates; college basketball is asked by date like every other league; an
|
||||||
|
answer whose `events` is not a list counts as no games. Odds never block
|
||||||
|
`update()`: one daemon worker per manager answers a queue and attaches each
|
||||||
|
line in place under `_games_lock`, one batch in flight at a time. A plugin
|
||||||
|
may inherit the mixin once it floors on 3.8.6, and deletes its copies then.
|
||||||
|
(#793)
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- `get_espn_scoreboard()` no longer caches an answer whose `events` is not a
|
||||||
|
list (ESPN has sent `"events": null`): every reader of the shared scoreboard
|
||||||
|
key -- odds-ticker, the other scoreboards -- inherited it until it aged out.
|
||||||
|
The answer is still returned to the caller as it came.
|
||||||
|
|
||||||
|
- `SportsRankingsMixin` (#789's deferred review points, now that it is the
|
||||||
|
only copy): a poll entry whose `current` cannot be converted to an
|
||||||
|
integer (`None`, a non-numeric string) is skipped instead of throwing
|
||||||
|
away the whole poll -- which left
|
||||||
|
every team unranked and re-fetched the standings on every update -- and
|
||||||
|
`_ranked_positions()` decides id-or-abbreviation per side, so one side
|
||||||
|
without an id no longer drops the other side's id and lets a shared
|
||||||
|
abbreviation rank the wrong school. Well-formed ESPN data ranks as before.
|
||||||
|
|
||||||
## 3.8.5
|
## 3.8.5
|
||||||
|
|
||||||
Fewer ESPN requests on the hourly schedule refresh (#754), and sports
|
Fewer ESPN requests on the hourly schedule refresh (#754), and sports
|
||||||
|
|||||||
+123
-15
@@ -95,6 +95,8 @@ more. Shared sports code lives in `src/common`:
|
|||||||
| `sports_favorites.py` | 3.8.2 | `SportsFavoritesMixin`, `SportsUpcomingFavoritesMixin`, `SportsRecentFavoritesMixin` — `_is_favorite_game` and the favourites-only picks, on the `_favorite_key` seam (family 6) |
|
| `sports_favorites.py` | 3.8.2 | `SportsFavoritesMixin`, `SportsUpcomingFavoritesMixin`, `SportsRecentFavoritesMixin` — `_is_favorite_game` and the favourites-only picks, on the `_favorite_key` seam (family 6) |
|
||||||
| `sports_rotation.py` | 3.8.4 | `SportsRotationMixin` — the other-games rotation (importance order, the window, odds on rotated-in games), on the `_rankings_loaded` seam (family 7) |
|
| `sports_rotation.py` | 3.8.4 | `SportsRotationMixin` — the other-games rotation (importance order, the window, odds on rotated-in games), on the `_rankings_loaded` seam (family 7) |
|
||||||
| `sports_rankings.py` | 3.8.5 | `SportsRankingsMixin` — which teams are ranked (by id and abbreviation), the poll choice, stale polls hidden, the "ranked" filter; `choose_top_division_poll()` (family 8) |
|
| `sports_rankings.py` | 3.8.5 | `SportsRankingsMixin` — which teams are ranked (by id and abbreviation), the poll choice, stale polls hidden, the "ranked" filter; `choose_top_division_poll()` (family 8) |
|
||||||
|
| `sports_live_fetch.py` | 3.8.6 | `SportsLiveFetchMixin` — the live poll's scoreboard request (always fresh, shared key) and odds fetched off the update path by one worker per manager (family 9) |
|
||||||
|
| `sports_view_model.py` | 3.8.7 | `SportsViewModelMixin` — the game dict every screen draws (`_extract_game_details_common`), the final and live rules, score text, logos, records; the `_logo_filename` seam (family 10) |
|
||||||
|
|
||||||
Each is described in [src/common/README.md](../src/common/README.md).
|
Each is described in [src/common/README.md](../src/common/README.md).
|
||||||
|
|
||||||
@@ -132,6 +134,7 @@ deprecation cycle.
|
|||||||
| `win_phrase(team_abbr)` | Win-celebration wording | `"<abbr> WINS!"` — mixin only |
|
| `win_phrase(team_abbr)` | Win-celebration wording | `"<abbr> WINS!"` — mixin only |
|
||||||
| `_rankings_loaded()` | Whether a poll loaded, so `_by_importance` orders by rank (`sports_rotation`) | `_team_rankings_cache` is non-empty. football also counts its rankings keyed by team id |
|
| `_rankings_loaded()` | Whether a poll loaded, so `_by_importance` orders by rank (`sports_rotation`) | `_team_rankings_cache` is non-empty. football also counts its rankings keyed by team id |
|
||||||
| `_favorite_key(game, side)` | Which view-model field identifies a team for favorites matching. `sports_favorites` compares it, and each `favorite_teams` entry, stripped and upper-cased; a `None` matches nothing | `game["<side>_abbr"]`. nrl returns the ESPN team id, `None` when it is missing |
|
| `_favorite_key(game, side)` | Which view-model field identifies a team for favorites matching. `sports_favorites` compares it, and each `favorite_teams` entry, stripped and upper-cased; a `None` matches nothing | `game["<side>_abbr"]`. nrl returns the ESPN team id, `None` when it is missing |
|
||||||
|
| `_logo_filename(abbr, team_id)` | The file under `logo_dir` a team's logo is kept in (`sports_view_model`) | `<ABBR>.png`. nrl returns `<ABBR>_<id>.png`: its abbreviations are not unique |
|
||||||
| `_config_schema_path()` | Plugin's `config_schema.json` — returning it routes `_get_layout_offset` through the `src.element_style` resolver (and gives it the defaults to compare against) | `None`, i.e. the classic inline `customization.layout` read |
|
| `_config_schema_path()` | Plugin's `config_schema.json` — returning it routes `_get_layout_offset` through the `src.element_style` resolver (and gives it the defaults to compare against) | `None`, i.e. the classic inline `customization.layout` read |
|
||||||
| `_font_root()` | Directory to resolve `assets/fonts` against | core install root |
|
| `_font_root()` | Directory to resolve `assets/fonts` against | core install root |
|
||||||
|
|
||||||
@@ -383,7 +386,7 @@ ufc's MMA managers override `update()` and never build the pools, so the
|
|||||||
rotation is dormant there. `_best_rank`, `_is_ranked_game` and
|
rotation is dormant there. `_best_rank`, `_is_ranked_game` and
|
||||||
`_passes_other_filters` were family 8.
|
`_passes_other_filters` were family 8.
|
||||||
|
|
||||||
### Family 8: rankings (reconciled; promoted, core 3.8.5)
|
### Family 8: rankings (done: core 3.8.5, adopted)
|
||||||
|
|
||||||
ledmatrix-plugins `scripts/test_rankings.py` (#644) pinned 79 rows across the
|
ledmatrix-plugins `scripts/test_rankings.py` (#644) pinned 79 rows across the
|
||||||
nine plugins first, every ESPN request answered offline from recorded-shape
|
nine plugins first, every ESPN request answered offline from recorded-shape
|
||||||
@@ -415,6 +418,82 @@ its id table too); the two differ only for a poll whose blocks carry ids and
|
|||||||
no abbreviations. The data sources' `fetch_standings` (held with
|
no abbreviations. The data sources' `fetch_standings` (held with
|
||||||
`data_sources.py`) is still four bodies; only its endpoint order changed.
|
`data_sources.py`) is still four bodies; only its endpoint order changed.
|
||||||
|
|
||||||
|
### Family 9: live fetch and odds (reconciled; promoted, core 3.8.6)
|
||||||
|
|
||||||
|
ledmatrix-plugins `scripts/test_live_fetch_odds.py` (#649) pinned 44 rows
|
||||||
|
across the nine plugins first, every ESPN request answered offline the way
|
||||||
|
ESPN answered on 2026-10-12 (a single date 200, a range 400): the requests,
|
||||||
|
cache keys and TTLs of `_fetch_todays_games` per league and Eastern time,
|
||||||
|
the live cache over time, empty, null and failed answers, `_fetch_odds` per
|
||||||
|
game kind and odds endpoint on a virtual clock (which thread asks, how long
|
||||||
|
the call waits, when the line lands), the real `update()` of every screen
|
||||||
|
with a 3 s odds endpoint, and the league overrides (MiLB, HockeyTech, ufc's
|
||||||
|
`MMALive`, which stay per-sport). Measuring found seven bodies of
|
||||||
|
`_fetch_todays_games`, not five: the fetch service's shared key had split
|
||||||
|
the old groups. The reconcile (ledmatrix-plugins #650)
|
||||||
|
made `_fetch_todays_games` and `_fetch_odds` one body each on `SportsCore`,
|
||||||
|
with the odds worker `_drain_odds_queue` beside them, and added a test that
|
||||||
|
runs `update()` and `display()` on two threads against a slow endpoint.
|
||||||
|
Every changed cell is one of the decisions below; the harness renders were
|
||||||
|
byte-identical once the four fixtures that seeded the old live key seed the
|
||||||
|
shared one. `src/common/sports_live_fetch.py` holds the bodies in one mixin,
|
||||||
|
`SportsLiveFetchMixin`; `test/test_sports_live_fetch_parity.py` compares them
|
||||||
|
with the plugin copies.
|
||||||
|
|
||||||
|
Measured `update()` time with a 3 s odds endpoint and four games, before and
|
||||||
|
after: Upcoming 6 s (afl, baseball, football, nrl, soccer, ufc), 12 s
|
||||||
|
(hockey, lacrosse), 0 s (basketball) to 0 s everywhere; Recent 6 s / 12 s to
|
||||||
|
0 s; Live's first pass 8 s / 12 s and next poll 4 s / 6 s to 0 s. The line
|
||||||
|
now lands when ESPN answers (3 s here) instead of at the next `update()`
|
||||||
|
(15 s for a live game, an hour for an upcoming or recent one) in the six.
|
||||||
|
|
||||||
|
Left for later: ufc's MMA Recent manager never asks for odds and its Upcoming
|
||||||
|
manager asks for every fight in the window before trimming to the headliners
|
||||||
|
(both in `mma.py`'s `update()` overrides, family 13); basketball's NCAA
|
||||||
|
Recent/Upcoming fallback still caches the undated scoreboard under
|
||||||
|
`<sport_key>_scoreboard_current` with `limit=1000`, which ESPN truncates (held
|
||||||
|
with the data fetching); `_attach_odds_to_rotated_games` still starts its own
|
||||||
|
thread per rotation and can route through `_fetch_odds` once every plugin
|
||||||
|
inherits this mixin; and ESPN lists only some college basketball games
|
||||||
|
without `groups=50` (7 of 21 on 2026-03-14), dated or not.
|
||||||
|
|
||||||
|
### Family 10: the view model (reconciled; promoted, core 3.8.7)
|
||||||
|
|
||||||
|
ledmatrix-plugins `scripts/test_view_model.py` (#653) pinned both
|
||||||
|
`_extract_game_details_common` and each plugin's final `_extract_game_details`
|
||||||
|
first, key by key, over 52 ESPN-shaped events: every state, OT, postponed,
|
||||||
|
canceled, delayed and forfeit games, a missing `completed` flag, scores as
|
||||||
|
int, "21.0", a dict, None, "" or missing, the two record shapes, missing logos
|
||||||
|
or ids, colliding abbreviations, broadcast objects, and dates with offsets,
|
||||||
|
without them, "TBD", and both DST changes. The nine bodies differed in how a
|
||||||
|
final was decided (five rules), how a score was read (raw in five), which
|
||||||
|
record and logo shapes were read, where a team id came from, how a time
|
||||||
|
without an offset was read (UTC, US Eastern in hockey and lacrosse, the host's
|
||||||
|
zone in ufc), and whether `favorite_teams` and basketball's tournament keys
|
||||||
|
were built there. The reconcile (ledmatrix-plugins #654)
|
||||||
|
made the method one body on `SportsCore`, with `_status_is_final`,
|
||||||
|
`_score_text`, `_team_logo_url`, `_extract_team_record` and the
|
||||||
|
`_logo_filename` seam beside it; basketball's tournament keys moved to its own
|
||||||
|
`_extract_game_details` (its dict keeps them), football's missing-badge
|
||||||
|
request to its own, and ufc's MMA extractor, which builds its own dict, uses the
|
||||||
|
shared helpers. Every changed cell is one of the decisions below or a fix.
|
||||||
|
All 208 harness renders of the nine are pixel-identical on core main.
|
||||||
|
`src/common/sports_view_model.py` holds the bodies in one mixin,
|
||||||
|
`SportsViewModelMixin`, whose docstring lists every key, its type and default;
|
||||||
|
`test/test_sports_view_model_parity.py` compares them with the plugin copies.
|
||||||
|
|
||||||
|
`period_text` is deliberately not given a default: the renderers read its
|
||||||
|
absence (baseball's) as "draw ESPN's status text", which is how an
|
||||||
|
extra-inning final reads "Final/10"; an empty default blanked baseball's
|
||||||
|
Recent "Final". `period` (0) and `clock` ("") are, and baseball now carries
|
||||||
|
them. `is_within_window` and `broadcast` are never read but are kept: keys are
|
||||||
|
additive only.
|
||||||
|
|
||||||
|
Left for later: the period labels stay in each sport's
|
||||||
|
`_extract_game_details` (quarters, periods, halves, extra time, rounds), and
|
||||||
|
soccer still names extra time's half-time "ETH"; ufc's MMA dict keeps its own
|
||||||
|
keys and its ordinal date (`display_manager.format_date_with_ordinal`).
|
||||||
|
|
||||||
### Why the method changes
|
### Why the method changes
|
||||||
|
|
||||||
Byte-identical promotion has nearly run dry. Measured on ledmatrix-plugins
|
Byte-identical promotion has nearly run dry. Measured on ledmatrix-plugins
|
||||||
@@ -502,9 +581,9 @@ release.
|
|||||||
| 5 | Game-over check | `SportsLive._is_game_really_over` (5) | Pure logic, no pixels; one seam, `FINAL_PERIOD`. The pilot for the procedure. Reconciled to one body and promoted as `sports_game_over`; shipped in 3.8.1 and adopted. See [Family 5](#family-5-the-game-over-check-done-core-381-adopted) |
|
| 5 | Game-over check | `SportsLive._is_game_really_over` (5) | Pure logic, no pixels; one seam, `FINAL_PERIOD`. The pilot for the procedure. Reconciled to one body and promoted as `sports_game_over`; 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) |
|
| 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`; done: core 3.8.4, adopted (ledmatrix-plugins #643). See [Family 7](#family-7-the-other-games-rotation-done-core-384-adopted) |
|
| 7 | Other-games rotation | `_by_importance`, `_other_games_window`, `_advance_other_games_if_due` (2 each: football), `_rotate_other_games_on_display` (2: ufc), with `_attach_odds_to_rotated_games` (3; ufc had none) | One outlier each; football carried two fixes the other eight lacked. Reconciled to one body each, on a `_rankings_loaded` seam, and promoted as `sports_rotation`; done: core 3.8.4, adopted (ledmatrix-plugins #643). See [Family 7](#family-7-the-other-games-rotation-done-core-384-adopted) |
|
||||||
| 8 | Rankings | `_fetch_team_rankings` (3), `_choose_poll` (3), `_load_division_team_ids`, `_passes_other_filters`, `_best_rank`, `_is_ranked_game` (2 each: football) | Needs 7; the rank badge and the "ranked only" filter read it. Reconciled to one body each and promoted as `sports_rankings` (core 3.8.5). See [Family 8](#family-8-rankings-reconciled-promoted-core-385) |
|
| 8 | Rankings | `_fetch_team_rankings` (3), `_choose_poll` (3), `_load_division_team_ids`, `_passes_other_filters`, `_best_rank`, `_is_ranked_game` (2 each: football) | Needs 7; the rank badge and the "ranked only" filter read it. Reconciled to one body each and promoted as `sports_rankings`; done: core 3.8.5, adopted (ledmatrix-plugins #648). See [Family 8](#family-8-rankings-done-core-385-adopted) |
|
||||||
| 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 |
|
| 9 | Live fetch and odds | `_fetch_todays_games` (7 when measured), `_fetch_odds` (3); `_attach_odds_to_rotated_games` went with 7 | The prerequisite for one shared ESPN poller across plugins. Reconciled to one body each and promoted as `sports_live_fetch` (core 3.8.6). See [Family 9](#family-9-live-fetch-and-odds-reconciled-promoted-core-386) |
|
||||||
| 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` |
|
| 10 | View model | `_extract_game_details_common` (9 of 9) | Every renderer reads it; its keys are additive-only, so reconcile to the superset and leave sport extras in `_extract_game_details`. Reconciled to one body and promoted as `sports_view_model` (core 3.8.7). See [Family 10](#family-10-the-view-model-reconciled-promoted-core-387) |
|
||||||
| 11 | Switch scorebug | `_load_fonts` (4), `_load_custom_font_from_element_config` (6), `_get_layout_offset` (5), `_fit_score_font` (2: football), `_load_and_resize_logo` (9), `_draw_dynamic_odds` (9), then `_draw_scorebug_layout` (20: Upcoming 9, Recent 8, Live 2, ufc's Core 1) | Needs 10 and the twin decisions below. Several releases: fonts and offsets, logos, odds, then one mode's layout per release |
|
| 11 | Switch scorebug | `_load_fonts` (4), `_load_custom_font_from_element_config` (6), `_get_layout_offset` (5), `_fit_score_font` (2: football), `_load_and_resize_logo` (9), `_draw_dynamic_odds` (9), then `_draw_scorebug_layout` (20: Upcoming 9, Recent 8, Live 2, ufc's Core 1) | Needs 10 and the twin decisions below. Several releases: fonts and offsets, logos, odds, then one mode's layout per release |
|
||||||
| 12 | Scroll/Vegas card | `game_renderer.py`: `render_game_card` (6), `_load_and_resize_logo` (8), `_load_custom_font`, `preload_logos`, `__init__` (7 each), `_draw_records_or_rankings` (6), `_load_fonts`, `_draw_dynamic_odds`, `_draw_live_game_status`, `_draw_recent_game_status`, `_get_team_display_text` (5 each), `_draw_text_with_outline` (2: football) | Same decisions as 11; sequence after the scroll-performance work on pre-rendered strips lands |
|
| 12 | Scroll/Vegas card | `game_renderer.py`: `render_game_card` (6), `_load_and_resize_logo` (8), `_load_custom_font`, `preload_logos`, `__init__` (7 each), `_draw_records_or_rankings` (6), `_load_fonts`, `_draw_dynamic_odds`, `_draw_live_game_status`, `_draw_recent_game_status`, `_get_team_display_text` (5 each), `_draw_text_with_outline` (2: football) | Same decisions as 11; sequence after the scroll-performance work on pre-rendered strips lands |
|
||||||
| 13 | Mode lifecycle | `sports.py`: `update` (23), `__init__` (16), `display` (13), `_advance_live_game_if_due` (6) | Where per-sport behaviour lives; last in `sports.py`. Each difference becomes a seam or a strategy chosen by name (live rotation already has three) |
|
| 13 | Mode lifecycle | `sports.py`: `update` (23), `__init__` (16), `display` (13), `_advance_live_game_if_due` (6) | Where per-sport behaviour lives; last in `sports.py`. Each difference becomes a seam or a strategy chosen by name (live rotation already has three) |
|
||||||
@@ -598,17 +677,46 @@ suspected behaviour that needs a payload or a rig to confirm first.
|
|||||||
seasons. A block without a readable `endDate` counts as current. (f)
|
seasons. A block without a readable `endDate` counts as current. (f)
|
||||||
baseball's MLB, MiLB and NCAA Baseball managers drop their class-level
|
baseball's MLB, MiLB and NCAA Baseball managers drop their class-level
|
||||||
shared copy of the table (none of those leagues publishes a poll).
|
shared copy of the table (none of those leagues publishes a poll).
|
||||||
- **9, live fetch and odds.** (a) afl, basketball, nrl and soccer cache the
|
- **9, live fetch and odds. Decided 2026-10-12, reconciled.** (a) Live
|
||||||
live scoreboard for 30 s under `<sport_key>_scoreboard_current`; the others
|
scores are always fresh: every poll asks ESPN and leaves the answer under
|
||||||
do not (the harness fixtures seed that key, so the change shows up there).
|
the shared `espn_scoreboard_cache_key` for its dates (`max_age=1`: only a
|
||||||
(b) basketball fetches college games with no `dates` parameter, citing a
|
copy another reader fetched within the last second, the managers of one
|
||||||
404 (*verify* now that `espn_dates` handles ranges). (c) odds are fetched
|
display tick, is reused). afl, basketball, nrl and soccer had held it 30 s
|
||||||
three ways: blocking (hockey, lacrosse), a thread waited on for 1.5–2 s
|
under `<sport_key>_scoreboard_current`, so with the 15 s default poll every
|
||||||
(six plugins), or fire-and-forget for upcoming games (basketball). This
|
other poll repeated the last one; lacrosse and ufc now share their copy.
|
||||||
sets how long `update()` takes and when an odds line appears. (d) nrl
|
(b) College basketball is asked by date like every other league: ESPN
|
||||||
guards on a missing odds manager; port it.
|
answered single dates for both college leagues on 2026-10-12 (the 404 no
|
||||||
- **10, view model.** Per key, whether every sport emits it. Additive only:
|
longer happens), so the lookback now reaches last night's late games there
|
||||||
no key is renamed or removed.
|
too. `groups=50` is a separate question (left above). (c) Odds never block
|
||||||
|
`update()`: one daemon worker per manager answers a queue and attaches each
|
||||||
|
line in place under `_games_lock`; one batch in flight per manager, games
|
||||||
|
queued meanwhile join it. A card can draw once without odds and gain them;
|
||||||
|
a pre-rendered scroll strip gains them when it is next rebuilt. (d) The
|
||||||
|
missing-odds-manager guard (nrl's, already in `_attach_odds_to_rotated_games`
|
||||||
|
since family 7) is in `_fetch_odds` too, logged at debug, not ERROR. Fix
|
||||||
|
ported: an answer with `"events": null` is no games instead of a
|
||||||
|
`TypeError` out of the fetch (all nine), and `get_espn_scoreboard` no longer
|
||||||
|
caches it under the shared key.
|
||||||
|
- **10, view model. Decided 2026-10-13, reconciled.** Additive only: no key
|
||||||
|
is renamed or removed. (a) One rule for a final, in all nine: state "post",
|
||||||
|
not `completed: false` (a payload without the flag is judged on state and
|
||||||
|
name), and not a not-played status, the union of the nine lists (postponed,
|
||||||
|
canceled, suspended, abandoned, delayed, a pending forfeit or no-contest).
|
||||||
|
A forfeit is a result (basketball and soccer had it as not played). (b)
|
||||||
|
Scores are integer text everywhere ("21" for 21, "21.0" or {"value": 21};
|
||||||
|
"0" for a missing or empty one, never "None"). (c) Halftime reads "HALF",
|
||||||
|
with no period or clock, in every sport's `period_text` (basketball,
|
||||||
|
football, hockey and lacrosse showed the period while ESPN said "in"; nrl
|
||||||
|
and soccer appended "0:00"). (d) A time without an offset is UTC (hockey and
|
||||||
|
lacrosse read US Eastern, ufc the host's zone). Fixes ported: football's
|
||||||
|
postponed or canceled game says so, not "Final"; the team-schedule record
|
||||||
|
shape is read in all nine; a logo URL is `team.logo`, else `logos[0].href`;
|
||||||
|
a team id is the competitor's, else its team's (eight dropped the game);
|
||||||
|
ufc never shows a score of "None"; a called-off game ESPN still reports as
|
||||||
|
"in" is not live (baseball's guard). Superset: ufc's fights carry
|
||||||
|
`favorite_teams` ([]), baseball's games `period` and `clock`. Per-sport:
|
||||||
|
ufc's own dict and ordinal date, nrl's id-based logo file and favourites,
|
||||||
|
the period labels.
|
||||||
- **11 and 12, the scorebug and the card.** The pinned divergences in
|
- **11 and 12, the scorebug and the card.** The pinned divergences in
|
||||||
`test/test_sports_twins.py`, where switch mode and scroll mode draw the
|
`test/test_sports_twins.py`, where switch mode and scroll mode draw the
|
||||||
same game differently:
|
same game differently:
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ src/common/sports_favorites.py
|
|||||||
src/common/sports_fetch.py
|
src/common/sports_fetch.py
|
||||||
src/common/sports_font_path.py
|
src/common/sports_font_path.py
|
||||||
src/common/sports_game_over.py
|
src/common/sports_game_over.py
|
||||||
|
src/common/sports_live_fetch.py
|
||||||
src/common/sports_live_scroll.py
|
src/common/sports_live_scroll.py
|
||||||
src/common/sports_plugin_host.py
|
src/common/sports_plugin_host.py
|
||||||
src/common/sports_rankings.py
|
src/common/sports_rankings.py
|
||||||
@@ -46,6 +47,7 @@ src/common/sports_rotation.py
|
|||||||
src/common/sports_scroll.py
|
src/common/sports_scroll.py
|
||||||
src/common/sports_timezone.py
|
src/common/sports_timezone.py
|
||||||
src/common/sports_vegas.py
|
src/common/sports_vegas.py
|
||||||
|
src/common/sports_view_model.py
|
||||||
src/config_service.py
|
src/config_service.py
|
||||||
src/core_config_keys.py
|
src/core_config_keys.py
|
||||||
src/deprecation.py
|
src/deprecation.py
|
||||||
|
|||||||
+1
-1
@@ -4,5 +4,5 @@ LEDMatrix Display System
|
|||||||
Core source package for the LED Matrix Display project.
|
Core source package for the LED Matrix Display project.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "3.8.5"
|
__version__ = "3.8.7"
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ Rules for the package:
|
|||||||
| [`sports_game_over`](#sports_game_over) | Whether a game ESPN still lists as live has ended | Yes (scoreboards) | 3.8.1 |
|
| [`sports_game_over`](#sports_game_over) | Whether a game ESPN still lists as live has ended | Yes (scoreboards) | 3.8.1 |
|
||||||
| [`sports_game_renderer`](#sports_game_renderer) | Scoreboard scroll/Vegas card geometry | Yes (scoreboards) | 3.3.0 |
|
| [`sports_game_renderer`](#sports_game_renderer) | Scoreboard scroll/Vegas card geometry | Yes (scoreboards) | 3.3.0 |
|
||||||
| [`sports_helpers`](#sports_helpers) | Small helpers every scoreboard `sports.py` copies | Yes (scoreboards) | 3.5.0 |
|
| [`sports_helpers`](#sports_helpers) | Small helpers every scoreboard `sports.py` copies | Yes (scoreboards) | 3.5.0 |
|
||||||
|
| [`sports_live_fetch`](#sports_live_fetch) | The live poll's scoreboard request, and odds off the update path | Yes (scoreboards) | 3.8.6 |
|
||||||
| [`sports_live_scroll`](#sports_live_scroll) | Rebuild a live scroll strip mid-cycle without moving it | Yes (scoreboards) | 3.8.0 |
|
| [`sports_live_scroll`](#sports_live_scroll) | Rebuild a live scroll strip mid-cycle without moving it | Yes (scoreboards) | 3.8.0 |
|
||||||
| [`sports_plugin_host`](#sports_plugin_host) | Helpers of a scoreboard's plugin class (`manager.py`) | Yes (scoreboards) | 3.8.0 |
|
| [`sports_plugin_host`](#sports_plugin_host) | Helpers of a scoreboard's plugin class (`manager.py`) | Yes (scoreboards) | 3.8.0 |
|
||||||
| [`sports_rankings`](#sports_rankings) | Which teams are ranked, in which poll, and the "ranked" filter | Yes (scoreboards) | 3.8.5 |
|
| [`sports_rankings`](#sports_rankings) | Which teams are ranked, in which poll, and the "ranked" filter | Yes (scoreboards) | 3.8.5 |
|
||||||
@@ -59,6 +60,7 @@ Rules for the package:
|
|||||||
| [`sports_shared`](#sports_shared) | Sport-independent `sports.py` methods | Yes (scoreboards) | 3.3.0 |
|
| [`sports_shared`](#sports_shared) | Sport-independent `sports.py` methods | Yes (scoreboards) | 3.3.0 |
|
||||||
| [`sports_vegas`](#sports_vegas) | Live Vegas cards: keys, card cache, sticky odds, finished games | Yes (scoreboards) | 3.8.0 |
|
| [`sports_vegas`](#sports_vegas) | Live Vegas cards: keys, card cache, sticky odds, finished games | Yes (scoreboards) | 3.8.0 |
|
||||||
| [`sports_timezone`](#sports_timezone) | Which timezone a scoreboard draws start times in | Yes (scoreboards) | 3.6.0 |
|
| [`sports_timezone`](#sports_timezone) | Which timezone a scoreboard draws start times in | Yes (scoreboards) | 3.6.0 |
|
||||||
|
| [`sports_view_model`](#sports_view_model) | The game dict every scoreboard screen draws, built from one ESPN event | Yes (scoreboards) | 3.8.7 |
|
||||||
| [`sync_manager`](#sync_manager) | Leader/follower sync between two displays | No, core-internal | n/a |
|
| [`sync_manager`](#sync_manager) | Leader/follower sync between two displays | No, core-internal | n/a |
|
||||||
| [`text_helper`](#text_helper) | Outlined text, wrapping, measurement | Yes | — |
|
| [`text_helper`](#text_helper) | Outlined text, wrapping, measurement | Yes | — |
|
||||||
|
|
||||||
@@ -370,6 +372,19 @@ shape), `_dispatch_switch_refresh()` (a manager refresh on a daemon thread, so
|
|||||||
`display()` never waits on the network), `get_vegas_content_type()` and small
|
`display()` never waits on the network), `get_vegas_content_type()` and small
|
||||||
dynamic-duration helpers. List it before `BasePlugin`.
|
dynamic-duration helpers. List it before `BasePlugin`.
|
||||||
|
|
||||||
|
### sports_live_fetch
|
||||||
|
|
||||||
|
[`sports_live_fetch.py`](sports_live_fetch.py). Sports family 9:
|
||||||
|
`SportsLiveFetchMixin` (`SportsCore`). `_fetch_todays_games` asks ESPN for
|
||||||
|
today's scoreboard (and yesterday's while `_needs_previous_day()` says it can
|
||||||
|
still hold a live game) on every live poll through `get_espn_scoreboard`, and
|
||||||
|
leaves it under the shared `espn_scoreboard_cache_key` for odds-ticker and the
|
||||||
|
other scoreboards; an answer without an `events` list is no games.
|
||||||
|
`_fetch_odds` queues a game and returns at once; one daemon worker per manager
|
||||||
|
(`_drain_odds_queue`) asks the odds manager for each queued game and attaches
|
||||||
|
the line in place under `_games_lock`, so a card can draw once without odds and
|
||||||
|
gain them. Games queued while the worker is busy join its batch.
|
||||||
|
|
||||||
### sports_rankings
|
### sports_rankings
|
||||||
|
|
||||||
[`sports_rankings.py`](sports_rankings.py). Sports family 8:
|
[`sports_rankings.py`](sports_rankings.py). Sports family 8:
|
||||||
@@ -433,6 +448,20 @@ through a live poll that left them out), and `finished_games()` /
|
|||||||
FINAL). `SportsScrollDisplay.build_vegas_elements()` in `sports_scroll` puts
|
FINAL). `SportsScrollDisplay.build_vegas_elements()` in `sports_scroll` puts
|
||||||
them together; a scoreboard not built on it (UFC) uses them directly.
|
them together; a scoreboard not built on it (UFC) uses them directly.
|
||||||
|
|
||||||
|
### sports_view_model
|
||||||
|
|
||||||
|
[`sports_view_model.py`](sports_view_model.py). Sports family 10:
|
||||||
|
`SportsViewModelMixin` (`SportsCore`). `_extract_game_details_common` turns
|
||||||
|
one ESPN scoreboard event into the game dict every renderer reads (the key
|
||||||
|
table is in the module docstring; keys are additive only) and hands back the
|
||||||
|
two competitors, the status and the situation for the sport's own
|
||||||
|
`_extract_game_details`. One rule decides a final (`_status_is_final`: state
|
||||||
|
"post", not `completed: false`, not a postponed, canceled, suspended,
|
||||||
|
abandoned, delayed or pending status; a forfeit is final); a called-off game
|
||||||
|
is never live; scores are integer text (`_score_text`); a time without an
|
||||||
|
offset is UTC. `_logo_filename(abbr, team_id)` is the seam for the logo file
|
||||||
|
under `logo_dir` (NRL names it by team id too).
|
||||||
|
|
||||||
### sports_timezone
|
### sports_timezone
|
||||||
|
|
||||||
[`sports_timezone.py`](sports_timezone.py).
|
[`sports_timezone.py`](sports_timezone.py).
|
||||||
|
|||||||
@@ -947,7 +947,8 @@ def get_espn_scoreboard(
|
|||||||
split as ESPN needs -- caches the result under the canonical key and
|
split as ESPN needs -- caches the result under the canonical key and
|
||||||
returns it. ``max_age=0`` always fetches (and still caches, for other
|
returns it. ``max_age=0`` always fetches (and still caches, for other
|
||||||
readers). Errors raise exactly as :func:`fetch_espn_scoreboard` does,
|
readers). Errors raise exactly as :func:`fetch_espn_scoreboard` does,
|
||||||
and nothing is cached then. ``session=None`` uses the fetch service's
|
and nothing is cached then; nor is an answer whose ``events`` is not a
|
||||||
|
list (ESPN has sent ``"events": null``), which is returned as it came. ``session=None`` uses the fetch service's
|
||||||
pooled session for the ESPN host.
|
pooled session for the ESPN host.
|
||||||
"""
|
"""
|
||||||
key = espn_scoreboard_cache_key(sport, league, dates)
|
key = espn_scoreboard_cache_key(sport, league, dates)
|
||||||
@@ -969,5 +970,7 @@ def get_espn_scoreboard(
|
|||||||
# cache read above would have accepted.
|
# cache read above would have accepted.
|
||||||
cache_max_age=None if max_age is None else max(0.0, float(max_age)),
|
cache_max_age=None if max_age is None else max(0.0, float(max_age)),
|
||||||
)
|
)
|
||||||
store_espn_scoreboard_cache(cache_manager, key, data)
|
# Every reader of the shared key would inherit a malformed answer.
|
||||||
|
if isinstance(data, dict) and isinstance(data.get("events"), list):
|
||||||
|
store_espn_scoreboard_cache(cache_manager, key, data)
|
||||||
return data
|
return data
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
"""The live poll's scoreboard request and the odds requests (sports family 9).
|
||||||
|
|
||||||
|
The scoreboards' live fetch and odds code, reconciled in ledmatrix-plugins
|
||||||
|
(family 9) from seven and three bodies into one each, and copied here under
|
||||||
|
the existing names. All of it lives on the plugins' ``SportsCore``, so there
|
||||||
|
is one mixin, ``SportsLiveFetchMixin``:
|
||||||
|
|
||||||
|
- ``_fetch_todays_games()``: today's scoreboard for the live poll, with
|
||||||
|
yesterday's while it can still hold a live game (``_needs_previous_day``),
|
||||||
|
as ``{"events": [...]}``; None when ESPN cannot be reached.
|
||||||
|
- ``_fetch_odds(game)``: queue one game for an odds request; returns at once.
|
||||||
|
- ``_drain_odds_queue()``: the odds worker that answers the queue.
|
||||||
|
|
||||||
|
THE RULES (decided 2026-10-12)
|
||||||
|
------------------------------
|
||||||
|
Live scores are always fresh. Every poll asks ESPN (``get_espn_scoreboard``
|
||||||
|
with ``max_age=1``: only a copy another reader fetched within the last second,
|
||||||
|
the managers of one display tick, is reused) and leaves the answer under the
|
||||||
|
shared ``espn_scoreboard_cache_key`` for its dates, so odds-ticker and every
|
||||||
|
other reader of the same day share it. The plugins' old 30 s cache under
|
||||||
|
``<sport_key>_scoreboard_current`` is gone. College basketball is asked by
|
||||||
|
date like every other league; ESPN answered single dates for it on 2026-10-12
|
||||||
|
(the old no-dates request cited a 404 that no longer happens). An answer whose
|
||||||
|
``events`` is not a list counts as no games.
|
||||||
|
|
||||||
|
Odds never block ``update()``. ``_fetch_odds`` puts the game on the manager's
|
||||||
|
queue and starts one daemon worker if none is running; the worker asks the
|
||||||
|
odds manager for each queued game in turn and attaches the line to every
|
||||||
|
dict queued for that id, in place, under ``_games_lock`` (display() reads the
|
||||||
|
games under it), so a card can draw once without odds and gain them when
|
||||||
|
ESPN answers. One batch in flight per manager: a game queued while the worker
|
||||||
|
is busy joins its batch, so a slow endpoint never stacks threads or requests.
|
||||||
|
The odds manager bounds each request (core's ``BaseOddsManager``: 5 s, then a
|
||||||
|
60 s hold-off). A live game's line is cached ``live_odds_update_interval``
|
||||||
|
(60 s), any other ``odds_update_interval`` (3600 s). Without ``show_odds``, a
|
||||||
|
game id or an odds manager nothing is asked (the last logged at debug).
|
||||||
|
|
||||||
|
Before the reconcile, six plugins waited on a worker for 1.5 s (2 s for a
|
||||||
|
live game) per game and hockey and lacrosse asked on the calling thread: with
|
||||||
|
a 3 s endpoint, ``update()`` of four upcoming games took 6 s and 12 s, and in
|
||||||
|
the six the line appeared only at the next ``update()`` (an hour later for an
|
||||||
|
upcoming game).
|
||||||
|
|
||||||
|
A new module rather than more methods on ``sports_fetch``, for the reason
|
||||||
|
``sports_helpers`` gives: a missing module fails at load, where the version
|
||||||
|
checks see it; a missing method fails mid-update.
|
||||||
|
|
||||||
|
WHAT A HOST MUST PROVIDE
|
||||||
|
------------------------
|
||||||
|
Derived by walking every ``self.<attr>`` the mixin reads; the host-contract
|
||||||
|
test in ``test/test_sports_live_fetch.py`` fails if a read is added without
|
||||||
|
being listed here. Every scoreboard ``SportsCore`` supplies all of them.
|
||||||
|
|
||||||
|
- ``session``, ``headers``, ``cache_manager``, ``logger``, ``sport``,
|
||||||
|
``league`` -- the scoreboard request.
|
||||||
|
- ``_needs_previous_day`` -- from ``SportsFetchMixin``.
|
||||||
|
- ``show_odds``, ``mode_config`` (the two odds intervals), ``sport_key`` (the
|
||||||
|
worker's thread name), ``_games_lock`` (a lock display() also holds).
|
||||||
|
- ``odds_manager``, read with ``getattr`` -- ``get_odds(sport=, league=,
|
||||||
|
event_id=, update_interval_seconds=)``.
|
||||||
|
- ``_odds_pending`` and ``_odds_worker_busy`` -- the queue and whether a
|
||||||
|
worker is running; created here on first use, under ``_games_lock``.
|
||||||
|
|
||||||
|
The game dict's ``id`` and ``is_live`` are read; ``odds`` is written.
|
||||||
|
|
||||||
|
BASE ORDER
|
||||||
|
----------
|
||||||
|
No other mixin defines these names, so the position in the bases does not
|
||||||
|
change which body runs; a method on the plugin's own class still wins. The
|
||||||
|
mixin has no ``__init__``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import threading
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from src.common.espn_dates import get_espn_scoreboard
|
||||||
|
|
||||||
|
|
||||||
|
class SportsLiveFetchMixin:
|
||||||
|
"""``SportsCore``'s live fetch and odds. See module docstring."""
|
||||||
|
|
||||||
|
# The host contract, declared for type checking only.
|
||||||
|
session: Any
|
||||||
|
headers: Dict[str, str]
|
||||||
|
cache_manager: Any
|
||||||
|
logger: Any
|
||||||
|
sport: str
|
||||||
|
league: str
|
||||||
|
sport_key: str
|
||||||
|
show_odds: bool
|
||||||
|
mode_config: Dict[str, Any]
|
||||||
|
_games_lock: Any
|
||||||
|
_needs_previous_day: Callable[[datetime], bool]
|
||||||
|
odds_manager: Any
|
||||||
|
_odds_pending: Dict[Any, Tuple[List[Dict], Any]]
|
||||||
|
_odds_worker_busy: bool
|
||||||
|
|
||||||
|
def _fetch_todays_games(self) -> Optional[Dict]:
|
||||||
|
"""Today's scoreboard for the live poll, with yesterday's while it can
|
||||||
|
still hold a live game (``_needs_previous_day``)."""
|
||||||
|
# ESPN anchors its calendar to Eastern US time.
|
||||||
|
now = datetime.now(pytz.timezone("America/New_York"))
|
||||||
|
today = now.strftime("%Y%m%d")
|
||||||
|
dates = (
|
||||||
|
f"{(now - timedelta(days=1)).strftime('%Y%m%d')}-{today}"
|
||||||
|
if self._needs_previous_day(now) else today
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
# Asked on every poll and left under core's shared key for this
|
||||||
|
# scoreboard and date, so odds-ticker and every other reader of
|
||||||
|
# the same day reuse it. max_age=1: a copy another reader fetched
|
||||||
|
# within the last second (the managers of one display tick) is the
|
||||||
|
# same answer; anything older is asked again.
|
||||||
|
data = get_espn_scoreboard(
|
||||||
|
self.session, self.sport, self.league, dates,
|
||||||
|
cache_manager=self.cache_manager, max_age=1,
|
||||||
|
headers=self.headers, timeout=10, logger=self.logger,
|
||||||
|
)
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
self.logger.error(
|
||||||
|
f"API error fetching today's games for {self.sport} - {self.league}: {e}"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
events = data.get("events") if isinstance(data, dict) else None
|
||||||
|
if not isinstance(events, list):
|
||||||
|
events = [] # ESPN can answer "events": null
|
||||||
|
self.logger.info(
|
||||||
|
f"Fetched {len(events)} games for {dates} for {self.sport} - {self.league}"
|
||||||
|
)
|
||||||
|
return {"events": events}
|
||||||
|
|
||||||
|
def _fetch_odds(self, game: Dict) -> None:
|
||||||
|
"""Queue ``game`` for an odds request; never waits for the answer.
|
||||||
|
|
||||||
|
One daemon worker per manager asks the odds manager for each queued
|
||||||
|
game in turn and attaches the line to the game dict in place, under
|
||||||
|
``_games_lock`` (display() reads the games under it), so a card can
|
||||||
|
draw once without odds and gain them when ESPN answers. A game queued
|
||||||
|
while the worker is busy joins its batch, and every dict queued for one
|
||||||
|
id (update() rebuilds them; the list on screen may keep older ones)
|
||||||
|
gets the one answer, so a slow endpoint never stacks threads or
|
||||||
|
requests. The odds manager bounds each request (5 s, then a 60 s
|
||||||
|
hold-off).
|
||||||
|
"""
|
||||||
|
if not self.show_odds or game.get("id") is None:
|
||||||
|
return
|
||||||
|
if not getattr(self, "odds_manager", None):
|
||||||
|
self.logger.debug(f"No odds manager; no odds for game {game['id']}")
|
||||||
|
return
|
||||||
|
interval = (
|
||||||
|
self.mode_config.get("live_odds_update_interval", 60)
|
||||||
|
if game.get("is_live", False)
|
||||||
|
else self.mode_config.get("odds_update_interval", 3600)
|
||||||
|
)
|
||||||
|
with self._games_lock:
|
||||||
|
pending = getattr(self, "_odds_pending", None)
|
||||||
|
if pending is None:
|
||||||
|
pending = self._odds_pending = {}
|
||||||
|
games, _ = pending.get(game["id"], ([], None))
|
||||||
|
if not any(queued is game for queued in games):
|
||||||
|
games.append(game)
|
||||||
|
pending[game["id"]] = (games, interval)
|
||||||
|
if getattr(self, "_odds_worker_busy", False):
|
||||||
|
return
|
||||||
|
self._odds_worker_busy = True
|
||||||
|
try:
|
||||||
|
threading.Thread(target=self._drain_odds_queue, daemon=True,
|
||||||
|
name=f"{self.sport_key}-odds").start()
|
||||||
|
except RuntimeError as e: # no thread to be had; try again next update
|
||||||
|
with self._games_lock:
|
||||||
|
self._odds_worker_busy = False
|
||||||
|
self.logger.debug(f"Could not start the odds worker: {e}")
|
||||||
|
|
||||||
|
def _drain_odds_queue(self) -> None:
|
||||||
|
"""The odds worker: ask for each queued game until none is left."""
|
||||||
|
while True:
|
||||||
|
with self._games_lock:
|
||||||
|
if not self._odds_pending:
|
||||||
|
self._odds_worker_busy = False
|
||||||
|
return
|
||||||
|
game_id = next(iter(self._odds_pending))
|
||||||
|
games, interval = self._odds_pending.pop(game_id)
|
||||||
|
try:
|
||||||
|
odds = self.odds_manager.get_odds(
|
||||||
|
sport=self.sport,
|
||||||
|
league=self.league,
|
||||||
|
event_id=game_id,
|
||||||
|
update_interval_seconds=interval,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.debug(f"Odds fetch failed for game {game_id}: {e}")
|
||||||
|
continue
|
||||||
|
if odds:
|
||||||
|
with self._games_lock:
|
||||||
|
for game in games:
|
||||||
|
game["odds"] = odds
|
||||||
@@ -216,7 +216,12 @@ class SportsRankingsMixin:
|
|||||||
for team_data in poll.get("ranks", []):
|
for team_data in poll.get("ranks", []):
|
||||||
team_info = team_data.get("team", {})
|
team_info = team_data.get("team", {})
|
||||||
team_abbr = team_info.get("abbreviation", "")
|
team_abbr = team_info.get("abbreviation", "")
|
||||||
current_rank = team_data.get("current", 0)
|
# One malformed entry (a None or non-numeric "current") is
|
||||||
|
# skipped, not allowed to throw away the whole poll.
|
||||||
|
try:
|
||||||
|
current_rank = int(team_data.get("current"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
if current_rank <= 0:
|
if current_rank <= 0:
|
||||||
continue
|
continue
|
||||||
if team_abbr:
|
if team_abbr:
|
||||||
@@ -337,28 +342,30 @@ class SportsRankingsMixin:
|
|||||||
return self._division_team_ids
|
return self._division_team_ids
|
||||||
|
|
||||||
def _ranked_positions(self, game: Dict) -> List[int]:
|
def _ranked_positions(self, game: Dict) -> List[int]:
|
||||||
"""Poll positions held by either side: ids first, abbreviations second.
|
"""Poll positions held by either side: by id, else by abbreviation.
|
||||||
|
|
||||||
The id table is authoritative whenever the game carries ids and a poll
|
The id table is authoritative whenever a side carries an id and a poll
|
||||||
has been parsed, because it is built from a top-division poll alone --
|
has been parsed, because it is built from a top-division poll alone --
|
||||||
so an FCS side cannot appear in it however it is abbreviated, and an
|
so an FCS side cannot appear in it however it is abbreviated, and an
|
||||||
abbreviation shared across divisions cannot promote the wrong school.
|
abbreviation shared across divisions cannot promote the wrong school.
|
||||||
The abbreviation table stays as the fallback for a game dict with no
|
Decided per side: a side without a usable id falls back to its
|
||||||
ids on it.
|
abbreviation, without costing the other side its id.
|
||||||
"""
|
"""
|
||||||
by_id = getattr(self, "_ranked_team_ids", None) or {}
|
by_id = getattr(self, "_ranked_team_ids", None) or {}
|
||||||
ids = []
|
|
||||||
for side in ("home_id", "away_id"):
|
|
||||||
try:
|
|
||||||
ids.append(int(game.get(side))) # type: ignore[arg-type] # None: caught below
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
ids = []
|
|
||||||
break
|
|
||||||
if by_id and ids:
|
|
||||||
return [rank for rank in (by_id.get(i, 0) for i in ids) if rank]
|
|
||||||
by_abbr = getattr(self, "_team_rankings_cache", None) or {}
|
by_abbr = getattr(self, "_team_rankings_cache", None) or {}
|
||||||
return [rank for rank in (by_abbr.get(game.get("home_abbr"), 0),
|
found = []
|
||||||
by_abbr.get(game.get("away_abbr"), 0)) if rank]
|
for side in ("home", "away"):
|
||||||
|
rank = None
|
||||||
|
if by_id:
|
||||||
|
try:
|
||||||
|
rank = by_id.get(int(game.get(f"{side}_id")), 0) # type: ignore[arg-type] # None: caught below
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
if rank is None:
|
||||||
|
rank = by_abbr.get(game.get(f"{side}_abbr"), 0)
|
||||||
|
if rank:
|
||||||
|
found.append(rank)
|
||||||
|
return found
|
||||||
|
|
||||||
def _is_ranked_game(self, game: Dict) -> bool:
|
def _is_ranked_game(self, game: Dict) -> bool:
|
||||||
return bool(self._ranked_positions(game))
|
return bool(self._ranked_positions(game))
|
||||||
|
|||||||
@@ -0,0 +1,336 @@
|
|||||||
|
"""The game dict every scoreboard screen draws (sports family 10).
|
||||||
|
|
||||||
|
The scoreboards' ``_extract_game_details_common``, reconciled in
|
||||||
|
ledmatrix-plugins (family 10) from nine bodies into one, and copied here
|
||||||
|
under the existing names with the helpers it calls. It lives on the plugins'
|
||||||
|
``SportsCore``, so there is one mixin, ``SportsViewModelMixin``:
|
||||||
|
|
||||||
|
- ``_extract_game_details_common(game_event)``: one ESPN scoreboard event to
|
||||||
|
``(details, home competitor, away competitor, status, situation)``, or five
|
||||||
|
Nones when the event has no competition, status, date or home/away pair.
|
||||||
|
Each sport's ``_extract_game_details`` adds its extras to ``details``.
|
||||||
|
- ``_status_is_final(status_type)``: whether ``status.type`` is a result.
|
||||||
|
- ``_score_text(score)``: a competitor's score as integer text.
|
||||||
|
- ``_team_logo_url(competitor)``, ``_extract_team_record(competitor)``.
|
||||||
|
- ``_logo_filename(abbr, team_id)``: the seam below.
|
||||||
|
|
||||||
|
THE KEYS
|
||||||
|
--------
|
||||||
|
Additive only: a key is never renamed or dropped, because every renderer
|
||||||
|
(``sports.py``'s scorebug, ``game_renderer.py``'s card, ``sports_card``,
|
||||||
|
``sports_game_renderer``, ``sports_shared``, ``sports_game_over``,
|
||||||
|
``sports_vegas``...) reads them. A sport's extras (down and distance, innings,
|
||||||
|
shots, tournament seeds...) belong in its own ``_extract_game_details``.
|
||||||
|
|
||||||
|
================== ================== ==========================================
|
||||||
|
key type value (default)
|
||||||
|
================== ================== ==========================================
|
||||||
|
id str | None the event id
|
||||||
|
game_time str local start, "7:00PM" ("" without a date)
|
||||||
|
game_date str local "MM/DD"; "M/D" with
|
||||||
|
``display.use_short_date_format`` ("")
|
||||||
|
start_time_utc datetime | None aware, UTC; a time without an offset
|
||||||
|
is read as UTC (None if unparseable)
|
||||||
|
status_text str ESPN ``status.type.shortDetail``
|
||||||
|
is_live bool state "in", not a called-off status
|
||||||
|
is_final bool ``_status_is_final(status.type)``
|
||||||
|
is_upcoming bool state "pre", or a scheduled status name
|
||||||
|
is_halftime bool state "halftime" or STATUS_HALFTIME
|
||||||
|
is_period_break bool STATUS_END_PERIOD
|
||||||
|
broadcast str ``str()`` of ESPN's field (""); unread
|
||||||
|
home_abbr/away_abbr str abbreviation, else the name's first 3
|
||||||
|
home_id/away_id str | None competitor id, else its team's id
|
||||||
|
home_score/ str ``_score_text``: integer text ("0")
|
||||||
|
away_score
|
||||||
|
home_logo_path/ Path ``logo_dir / _logo_filename(abbr, id)``
|
||||||
|
away_logo_path
|
||||||
|
home_logo_url/ str | None ``_team_logo_url``: ``team.logo``, else
|
||||||
|
away_logo_url ``team.logos[0].href`` (None)
|
||||||
|
home_record/ str ``_extract_team_record``; "0-0" and
|
||||||
|
away_record "0-0-0" read as "" ("")
|
||||||
|
is_within_window bool always True; unread
|
||||||
|
favorite_teams list the resolved favourites, for the card's
|
||||||
|
result colour ([])
|
||||||
|
period int 0 until the sport sets it
|
||||||
|
clock str "" until the sport sets it
|
||||||
|
================== ================== ==========================================
|
||||||
|
|
||||||
|
``period_text`` is not set here: each sport's extractor sets it, and the
|
||||||
|
renderers read its absence (baseball's) as "draw ESPN's status text", which
|
||||||
|
is how an extra-inning final reads "Final/10".
|
||||||
|
|
||||||
|
THE RULES (decided 2026-10-13)
|
||||||
|
------------------------------
|
||||||
|
A game is final when its state is "post", ESPN has not marked it
|
||||||
|
``completed: false`` (a payload without the flag is judged on state and name
|
||||||
|
alone), and its status is not one of ``_NOT_PLAYED_STATUS_NAMES``: postponed,
|
||||||
|
canceled, suspended, abandoned (``_CALLED_OFF_STATUS_NAMES``), delayed, or a
|
||||||
|
pending forfeit or no-contest. A forfeit is a result. A called-off game is
|
||||||
|
never live, even while ESPN still reports it "in". Scores are integer text in
|
||||||
|
every sport: 21, "21.0" and {"value": 21} are "21"; a missing, empty or
|
||||||
|
non-numeric score is "0". Halftime reads "HALF" with no clock in every
|
||||||
|
sport's ``period_text`` (each sport's own extractor, not this module).
|
||||||
|
|
||||||
|
THE SEAM
|
||||||
|
--------
|
||||||
|
``_logo_filename(abbr, team_id)``: the file under ``logo_dir`` a team's logo
|
||||||
|
is kept in. Default ``<ABBR>.png`` (``LogoDownloader.normalize_abbreviation``);
|
||||||
|
NRL overrides it with ``<ABBR>_<id>.png`` because its abbreviations are not
|
||||||
|
unique ("NEW" is two clubs).
|
||||||
|
|
||||||
|
A new module rather than more methods on ``sports_fetch``, for the reason
|
||||||
|
``sports_helpers`` gives: a missing module fails at load, where the version
|
||||||
|
checks see it; a missing method fails mid-update.
|
||||||
|
|
||||||
|
WHAT A HOST MUST PROVIDE
|
||||||
|
------------------------
|
||||||
|
Derived by walking every ``self.<attr>`` the mixin reads; the host-contract
|
||||||
|
test in ``test/test_sports_view_model.py`` fails if a read is added without
|
||||||
|
being listed here. Every scoreboard ``SportsCore`` supplies all of them.
|
||||||
|
|
||||||
|
- ``logger``, ``config`` (``display.use_short_date_format``), ``logo_dir``
|
||||||
|
(a ``Path``), ``favorite_teams`` (a list or None), ``_get_timezone``
|
||||||
|
(a callable returning the zone game times are shown in).
|
||||||
|
|
||||||
|
BASE ORDER
|
||||||
|
----------
|
||||||
|
No other mixin defines these names, so the position in the bases does not
|
||||||
|
change which body runs; a method on the plugin's own class still wins. The
|
||||||
|
mixin has no ``__init__``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable, ClassVar, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
|
||||||
|
from src.logo_downloader import LogoDownloader
|
||||||
|
|
||||||
|
|
||||||
|
class SportsViewModelMixin:
|
||||||
|
"""``SportsCore``'s game dict. See module docstring."""
|
||||||
|
|
||||||
|
# The host contract, declared for type checking only.
|
||||||
|
logger: Any
|
||||||
|
config: Dict[str, Any]
|
||||||
|
logo_dir: Path
|
||||||
|
favorite_teams: Optional[List[str]]
|
||||||
|
_get_timezone: Callable[[], Any]
|
||||||
|
|
||||||
|
#: ESPN status names for a game called off: never live, never a result.
|
||||||
|
#: ESPN files a postponed or washed-out game under state "post" with both
|
||||||
|
#: scores "0" (so "post" alone drew it on Recent as "Final 0-0"), and a
|
||||||
|
#: suspended one can still read "in".
|
||||||
|
_CALLED_OFF_STATUS_NAMES: ClassVar[frozenset] = frozenset({
|
||||||
|
"STATUS_POSTPONED", "STATUS_CANCELED", "STATUS_CANCELLED",
|
||||||
|
"STATUS_SUSPENDED", "STATUS_ABANDONED",
|
||||||
|
})
|
||||||
|
#: Status names under state "post" that are not a result. A forfeit
|
||||||
|
#: (STATUS_FORFEIT) is one; a pending forfeit or no-contest is not.
|
||||||
|
_NOT_PLAYED_STATUS_NAMES: ClassVar[frozenset] = _CALLED_OFF_STATUS_NAMES | frozenset({
|
||||||
|
"STATUS_DELAYED", "STATUS_FORFEIT_PENDING", "STATUS_NO_CONTEST_PENDING",
|
||||||
|
})
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _status_is_final(cls, status_type: Any) -> bool:
|
||||||
|
"""Whether an ESPN ``status.type`` is a game played to a result.
|
||||||
|
|
||||||
|
State "post", a ``completed`` flag that is not False, and a name
|
||||||
|
outside ``_NOT_PLAYED_STATUS_NAMES``. A payload without the flag is
|
||||||
|
judged on state and name alone; ESPN sends False for a postponement.
|
||||||
|
"""
|
||||||
|
if not isinstance(status_type, dict) or status_type.get("state") != "post":
|
||||||
|
return False
|
||||||
|
if status_type.get("completed") is False:
|
||||||
|
return False
|
||||||
|
return str(status_type.get("name") or "").upper() not in cls._NOT_PLAYED_STATUS_NAMES
|
||||||
|
|
||||||
|
def _score_text(self, score: Any) -> str:
|
||||||
|
"""A competitor's score as integer text: "21" for 21, "21.0" or
|
||||||
|
{"value": 21.0}; "0" when it is missing or holds no number."""
|
||||||
|
if isinstance(score, dict):
|
||||||
|
value = score.get("value", 0)
|
||||||
|
score = value if value != 0 else score.get("displayValue", score.get("score", 0))
|
||||||
|
if isinstance(score, str):
|
||||||
|
text = score.strip()
|
||||||
|
try:
|
||||||
|
score = float(text)
|
||||||
|
except ValueError:
|
||||||
|
digits = re.findall(r"\d+", text)
|
||||||
|
if not digits:
|
||||||
|
if text:
|
||||||
|
self.logger.warning(f"Could not extract score from string: {text!r}")
|
||||||
|
return "0"
|
||||||
|
score = float(digits[0])
|
||||||
|
if score is None:
|
||||||
|
return "0"
|
||||||
|
try:
|
||||||
|
return str(int(float(score)))
|
||||||
|
except (TypeError, ValueError, OverflowError) as e:
|
||||||
|
self.logger.warning(f"Error extracting score: {e}, score value: {score!r}")
|
||||||
|
return "0"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _team_logo_url(competitor: Dict) -> Optional[str]:
|
||||||
|
"""The team's ``logo``, else the first of its ``logos``, else None."""
|
||||||
|
team = competitor.get("team") or {}
|
||||||
|
logos = team.get("logos") or []
|
||||||
|
first = logos[0] if logos and isinstance(logos[0], dict) else {}
|
||||||
|
return team.get("logo") or first.get("href")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_team_record(team_data: Dict) -> str:
|
||||||
|
"""Extract the overall record string from a competitor/team object.
|
||||||
|
|
||||||
|
The ESPN scoreboard API uses ``records`` (plural) with a ``summary``
|
||||||
|
field, while the team-schedule API uses ``record`` (singular) with a
|
||||||
|
``displayValue`` field. This helper handles both formats so that
|
||||||
|
records display correctly regardless of which API provided the data.
|
||||||
|
"""
|
||||||
|
# Scoreboard API format: records[0].summary (e.g. "10-2")
|
||||||
|
records = team_data.get("records")
|
||||||
|
if records and isinstance(records, list) and len(records) > 0:
|
||||||
|
return records[0].get("summary", "") # type: ignore[no-any-return]
|
||||||
|
|
||||||
|
# Team-schedule API format: record[0].displayValue (e.g. "7-0")
|
||||||
|
record = team_data.get("record")
|
||||||
|
if record and isinstance(record, list) and len(record) > 0:
|
||||||
|
return record[0].get("displayValue", record[0].get("summary", "")) # type: ignore[no-any-return]
|
||||||
|
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def _logo_filename(self, abbr: str, team_id: Any) -> str:
|
||||||
|
"""Seam: the file a team's logo is kept in under ``logo_dir``."""
|
||||||
|
return f"{LogoDownloader.normalize_abbreviation(abbr)}.png"
|
||||||
|
|
||||||
|
def _extract_game_details_common(
|
||||||
|
self, game_event: Dict
|
||||||
|
) -> Tuple[Optional[Dict], Optional[Dict], Optional[Dict], Optional[Dict], Optional[Dict]]:
|
||||||
|
"""The game dict every renderer reads, built from one ESPN event.
|
||||||
|
|
||||||
|
Returns (details, home competitor, away competitor, status,
|
||||||
|
situation), or five Nones when the event has no competition, status,
|
||||||
|
date or home/away pair. Keys are additive only: never rename or drop
|
||||||
|
one. Sport extras belong in the sport's ``_extract_game_details``.
|
||||||
|
"""
|
||||||
|
if not game_event:
|
||||||
|
return None, None, None, None, None
|
||||||
|
try:
|
||||||
|
competitions = game_event.get("competitions", [])
|
||||||
|
if not competitions:
|
||||||
|
self.logger.warning(f"No competitions data for game {game_event.get('id', 'unknown')}")
|
||||||
|
return None, None, None, None, None
|
||||||
|
competition = competitions[0]
|
||||||
|
status = competition.get("status")
|
||||||
|
if not status:
|
||||||
|
self.logger.warning(f"No status data for game {game_event.get('id', 'unknown')}")
|
||||||
|
return None, None, None, None, None
|
||||||
|
competitors = competition.get("competitors", [])
|
||||||
|
game_date_str = game_event["date"]
|
||||||
|
situation = competition.get("situation")
|
||||||
|
start_time_utc = None
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(game_date_str.replace("Z", "+00:00"))
|
||||||
|
# A time without an offset is read as UTC, as ESPN sends it.
|
||||||
|
start_time_utc = (dt.replace(tzinfo=pytz.UTC) if dt.tzinfo is None
|
||||||
|
else dt.astimezone(pytz.UTC))
|
||||||
|
except ValueError:
|
||||||
|
self.logger.warning(f"Could not parse game date: {game_date_str}")
|
||||||
|
|
||||||
|
home_team = next(
|
||||||
|
(c for c in competitors if c.get("homeAway") == "home"), None
|
||||||
|
)
|
||||||
|
away_team = next(
|
||||||
|
(c for c in competitors if c.get("homeAway") == "away"), None
|
||||||
|
)
|
||||||
|
if not home_team or not away_team:
|
||||||
|
self.logger.warning(
|
||||||
|
f"Could not find home or away team in event: {game_event.get('id')}"
|
||||||
|
)
|
||||||
|
return None, None, None, None, None
|
||||||
|
|
||||||
|
try:
|
||||||
|
home_abbr = home_team["team"]["abbreviation"]
|
||||||
|
except KeyError:
|
||||||
|
home_abbr = home_team["team"]["name"][:3]
|
||||||
|
try:
|
||||||
|
away_abbr = away_team["team"]["abbreviation"]
|
||||||
|
except KeyError:
|
||||||
|
away_abbr = away_team["team"]["name"][:3]
|
||||||
|
home_id = home_team.get("id") or home_team["team"].get("id")
|
||||||
|
away_id = away_team.get("id") or away_team["team"].get("id")
|
||||||
|
|
||||||
|
game_time, game_date = "", ""
|
||||||
|
if start_time_utc:
|
||||||
|
local_time = start_time_utc.astimezone(self._get_timezone())
|
||||||
|
game_time = local_time.strftime("%I:%M%p").lstrip("0")
|
||||||
|
if self.config.get("display", {}).get("use_short_date_format", False):
|
||||||
|
# %-m/%-d are glibc extensions: strftime raises ValueError on
|
||||||
|
# Windows and musl. Build the same text portably instead.
|
||||||
|
game_date = f"{local_time.month}/{local_time.day}"
|
||||||
|
else:
|
||||||
|
game_date = local_time.strftime("%m/%d")
|
||||||
|
|
||||||
|
home_record = self._extract_team_record(home_team)
|
||||||
|
away_record = self._extract_team_record(away_team)
|
||||||
|
# Don't show "0-0" records - set to blank instead
|
||||||
|
if home_record in {"0-0", "0-0-0"}:
|
||||||
|
home_record = ""
|
||||||
|
if away_record in {"0-0", "0-0-0"}:
|
||||||
|
away_record = ""
|
||||||
|
|
||||||
|
status_type = status["type"]
|
||||||
|
status_name = str(status_type.get("name") or "").upper()
|
||||||
|
details = {
|
||||||
|
"id": game_event.get("id"),
|
||||||
|
"game_time": game_time,
|
||||||
|
"game_date": game_date,
|
||||||
|
"start_time_utc": start_time_utc,
|
||||||
|
"status_text": status_type["shortDetail"], # e.g. "Final", "7:30 PM", "Q1 12:34"
|
||||||
|
"is_live": (status_type["state"] == "in"
|
||||||
|
and status_name not in self._CALLED_OFF_STATUS_NAMES),
|
||||||
|
"is_final": self._status_is_final(status_type),
|
||||||
|
"is_upcoming": (
|
||||||
|
status_type["state"] == "pre"
|
||||||
|
or status_type["name"].lower()
|
||||||
|
in ["scheduled", "pre-game", "status_scheduled"]
|
||||||
|
),
|
||||||
|
"is_halftime": status_type["state"] == "halftime"
|
||||||
|
or status_type["name"] == "STATUS_HALFTIME",
|
||||||
|
"is_period_break": status_type["name"] == "STATUS_END_PERIOD",
|
||||||
|
# str(): ESPN's undocumented payloads have served this field as
|
||||||
|
# an object in some sports. Plain game metadata: nothing reads it.
|
||||||
|
"broadcast": str(competition.get("broadcast") or ""),
|
||||||
|
"home_abbr": home_abbr,
|
||||||
|
"home_id": home_id,
|
||||||
|
"home_score": self._score_text(home_team.get("score")),
|
||||||
|
"home_logo_path": self.logo_dir / Path(self._logo_filename(home_abbr, home_id)),
|
||||||
|
"home_logo_url": self._team_logo_url(home_team),
|
||||||
|
"home_record": home_record,
|
||||||
|
"away_record": away_record,
|
||||||
|
"away_abbr": away_abbr,
|
||||||
|
"away_id": away_id,
|
||||||
|
"away_score": self._score_text(away_team.get("score")),
|
||||||
|
"away_logo_path": self.logo_dir / Path(self._logo_filename(away_abbr, away_id)),
|
||||||
|
"away_logo_url": self._team_logo_url(away_team),
|
||||||
|
"is_within_window": True, # Whether game is within display window
|
||||||
|
# The resolved favorites for this league (dynamic groups such
|
||||||
|
# as AP_TOP_25 already expanded). Carried on the game so the
|
||||||
|
# scroll/Vegas renderer, which only ever sees the game dict and
|
||||||
|
# the raw config, can color a final score by the result.
|
||||||
|
"favorite_teams": list(self.favorite_teams or []),
|
||||||
|
# Defaults; a sport's _extract_game_details sets its own. No
|
||||||
|
# period_text: the renderers read its absence (baseball) as
|
||||||
|
# "draw ESPN's status text", e.g. "Final/10".
|
||||||
|
"period": 0,
|
||||||
|
"clock": "",
|
||||||
|
}
|
||||||
|
return details, home_team, away_team, status, situation
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(
|
||||||
|
f"Error extracting game details: {e} from event: {game_event.get('id')}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return None, None, None, None, None
|
||||||
+2
-1
@@ -32,7 +32,8 @@ const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.js',
|
|||||||
'unit/test_page_registry.js', 'unit/test_core_modules.js',
|
'unit/test_page_registry.js', 'unit/test_core_modules.js',
|
||||||
'unit/test_overview_reconciliation_poll.js',
|
'unit/test_overview_reconciliation_poll.js',
|
||||||
'unit/test_display_partial_ids.js',
|
'unit/test_display_partial_ids.js',
|
||||||
'unit/test_general_web_login_token.js'];
|
'unit/test_general_web_login_token.js',
|
||||||
|
'unit/test_general_display_autostart.js'];
|
||||||
const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js',
|
const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js',
|
||||||
'dom/test_tools_sections.js', 'dom/test_cache_page.js',
|
'dom/test_tools_sections.js', 'dom/test_cache_page.js',
|
||||||
'dom/test_durations_page.js', 'dom/test_operation_history_page.js',
|
'dom/test_durations_page.js', 'dom/test_operation_history_page.js',
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// The General tab's "Start Display on Boot" checkbox (setDisplayAutostart in
|
||||||
|
// static/v3/js/pages/general.js) posts enable_autostart / disable_autostart
|
||||||
|
// to /api/v3/system/action and puts the box back when that is refused --
|
||||||
|
// including the route's HTTP-200 {status: "error"} answer for a failed
|
||||||
|
// systemctl (e.g. the sudo rule is missing).
|
||||||
|
//
|
||||||
|
// Fake fetch, no jsdom and no server needed.
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const { pathToFileURL } = require('url');
|
||||||
|
|
||||||
|
const JS = path.resolve(__dirname, '../../../web_interface/static/v3/js');
|
||||||
|
const load = f => import(pathToFileURL(path.join(JS, f)).href);
|
||||||
|
|
||||||
|
let pass = 0, fail = 0;
|
||||||
|
const ok = (label, cond, extra) => cond
|
||||||
|
? (pass++, console.log(' ok ' + label))
|
||||||
|
: (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' ' + JSON.stringify(extra) : '')));
|
||||||
|
|
||||||
|
async function run(checked, answer) {
|
||||||
|
const { createApi } = await load('core/api.js');
|
||||||
|
const general = await load('pages/general.js');
|
||||||
|
const sent = [];
|
||||||
|
const notes = [];
|
||||||
|
const fetch = (url, init) => {
|
||||||
|
sent.push([url, JSON.parse(init.body)]);
|
||||||
|
if (answer === 'network') return Promise.reject(new TypeError('Failed to fetch'));
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: answer.ok, status: answer.ok ? 200 : 500,
|
||||||
|
headers: { get: () => null },
|
||||||
|
text: () => Promise.resolve(JSON.stringify(answer.body)),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const ctx = { root: {}, state: {}, signal: { aborted: false },
|
||||||
|
api: createApi({ fetch }), notify: (m, t) => notes.push([m, t]) };
|
||||||
|
const box = { checked, disabled: false };
|
||||||
|
await general.setDisplayAutostart(ctx, box);
|
||||||
|
return { box, sent, notes };
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
console.log('\n── General tab: Start Display on Boot ──');
|
||||||
|
|
||||||
|
{
|
||||||
|
const t = await run(false, { ok: true, body: { status: 'success', message: 'ok' } });
|
||||||
|
ok('unchecking posts disable_autostart',
|
||||||
|
t.sent.length === 1 && t.sent[0][0] === '/api/v3/system/action' && t.sent[0][1].action === 'disable_autostart', t.sent);
|
||||||
|
ok('...stays unchecked and re-enabled', t.box.checked === false && t.box.disabled === false);
|
||||||
|
ok('...and reports success', t.notes.some(([, type]) => type === 'success'), t.notes);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const t = await run(true, { ok: true, body: { status: 'success' } });
|
||||||
|
ok('checking posts enable_autostart', t.sent[0][1].action === 'enable_autostart', t.sent);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const t = await run(false, { ok: true, body: { status: 'error', message: 'sudo: a password is required' } });
|
||||||
|
ok('a failed systemctl (HTTP 200, status error) puts the box back', t.box.checked === true && !t.box.disabled);
|
||||||
|
ok('...and shows the route\'s message',
|
||||||
|
t.notes.some(([m, type]) => type === 'error' && /sudo/.test(m)), t.notes);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const t = await run(true, { ok: false, body: { status: 'error', message: 'boom' } });
|
||||||
|
ok('an HTTP error puts the box back', t.box.checked === false && !t.box.disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const t = await run(true, 'network');
|
||||||
|
ok('a network failure puts the box back', t.box.checked === false && !t.box.disabled);
|
||||||
|
ok('...and reports it', t.notes.some(([, type]) => type === 'error'), t.notes);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n${pass} passed, ${fail} failed\n`);
|
||||||
|
process.exit(fail ? 1 : 0);
|
||||||
|
})().catch(e => { console.log('HARNESS ERROR: ' + e.stack); process.exit(1); });
|
||||||
@@ -392,6 +392,15 @@ class TestGetEspnScoreboard:
|
|||||||
"20261004", cache_manager=cache)
|
"20261004", cache_manager=cache)
|
||||||
assert cache.records == {}
|
assert cache.records == {}
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("payload", [{"events": None}, {}, {"events": "x"}, []])
|
||||||
|
def test_an_answer_without_an_events_list_is_returned_not_cached(self, service, payload):
|
||||||
|
espn = Espn()
|
||||||
|
espn.get = lambda url, **kwargs: _response(payload, url, None)
|
||||||
|
cache = RecordCache()
|
||||||
|
assert get_espn_scoreboard(espn, "football", "nfl", "20261004",
|
||||||
|
cache_manager=cache) == payload
|
||||||
|
assert cache.records == {}
|
||||||
|
|
||||||
def test_no_cache_manager_just_fetches(self, service):
|
def test_no_cache_manager_just_fetches(self, service):
|
||||||
espn = Espn()
|
espn = Espn()
|
||||||
get_espn_scoreboard(espn, "football", "nfl", "20261004")
|
get_espn_scoreboard(espn, "football", "nfl", "20261004")
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""The General tab's "Start Display on Boot" toggle reads systemd's state.
|
||||||
|
|
||||||
|
ledmatrix.service enabled -> checked, disabled -> unchecked, unreadable (no
|
||||||
|
systemctl, unit not installed) -> no toggle. The toggle itself posts the
|
||||||
|
existing enable_autostart / disable_autostart system actions, whose sudo
|
||||||
|
grants test_systemctl_sudoers_alignment.py already checks.
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).parent.parent
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from test.test_web_settings_ui import client # noqa: F401,E402
|
||||||
|
from web_interface.blueprints import pages_v3 # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
REAL_RUN = subprocess.run
|
||||||
|
UNIT_QUERY = ['systemctl', 'is-enabled', 'ledmatrix.service']
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_systemctl(monkeypatch, answer):
|
||||||
|
"""Answer the unit query with `answer` (stdout, or an exception to
|
||||||
|
raise); every other command (the update channel's git) runs for real."""
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def run(cmd, *args, **kwargs):
|
||||||
|
if cmd != UNIT_QUERY:
|
||||||
|
return REAL_RUN(cmd, *args, **kwargs)
|
||||||
|
calls.append(cmd)
|
||||||
|
if isinstance(answer, Exception):
|
||||||
|
raise answer
|
||||||
|
return subprocess.CompletedProcess(cmd, 0, stdout=answer, stderr='')
|
||||||
|
monkeypatch.setattr(pages_v3.subprocess, 'run', run)
|
||||||
|
return calls
|
||||||
|
|
||||||
|
|
||||||
|
def _toggle(body):
|
||||||
|
start = body.find('data-action="display-autostart"')
|
||||||
|
return None if start < 0 else body[start:body.find('>', start)]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('stdout, checked', [('enabled\n', True), ('disabled\n', False)])
|
||||||
|
def test_toggle_reflects_the_unit(client, monkeypatch, stdout, checked):
|
||||||
|
calls = _fake_systemctl(monkeypatch, stdout)
|
||||||
|
tag = _toggle(client.get('/v3/partials/general').get_data(as_text=True))
|
||||||
|
assert calls == [UNIT_QUERY]
|
||||||
|
assert tag is not None
|
||||||
|
assert ('checked' in tag) is checked
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('outcome', [FileNotFoundError(), 'not-found\n'])
|
||||||
|
def test_toggle_hidden_when_state_unreadable(client, monkeypatch, outcome):
|
||||||
|
_fake_systemctl(monkeypatch, outcome)
|
||||||
|
body = client.get('/v3/partials/general').get_data(as_text=True)
|
||||||
|
assert _toggle(body) is None
|
||||||
|
assert 'name="plugins_directory"' in body # rest of the tab still renders
|
||||||
@@ -0,0 +1,399 @@
|
|||||||
|
"""src.common.sports_live_fetch: the live poll's request and the odds queue.
|
||||||
|
|
||||||
|
The cases follow ledmatrix-plugins' ``scripts/test_live_fetch_odds.py`` (the
|
||||||
|
tables the family 9 reconcile was checked against); the expected values are
|
||||||
|
its columns, which agree across all nine scoreboards. The host is the mixin
|
||||||
|
over core's ``SportsFetchMixin`` (``_needs_previous_day``), with a session
|
||||||
|
answering like ESPN did on 2026-10-12 (a single date is 200, a range 400) and
|
||||||
|
a cache that ages entries on a test clock. Nothing reaches the network. The
|
||||||
|
odds endpoint is held on an event, so "update() never waits" is shown by the
|
||||||
|
call returning while the request is still open.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from src.common import espn_dates, sports_live_fetch
|
||||||
|
from src.common import fetch_service as fs
|
||||||
|
from src.common.fetch_service import FetchService
|
||||||
|
from src.common.sports_fetch import SportsFetchMixin
|
||||||
|
from src.common.sports_live_fetch import SportsLiveFetchMixin
|
||||||
|
|
||||||
|
LOG = logging.getLogger("test_sports_live_fetch")
|
||||||
|
SITE = "https://site.api.espn.com/apis/site/v2/sports/"
|
||||||
|
|
||||||
|
|
||||||
|
def _response(url, answer):
|
||||||
|
resp = requests.Response()
|
||||||
|
resp.url = url
|
||||||
|
resp.status_code = answer if isinstance(answer, int) else 200
|
||||||
|
resp._content = answer if isinstance(answer, bytes) else json.dumps(
|
||||||
|
{} if isinstance(answer, int) else answer).encode()
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
class Espn(requests.Session):
|
||||||
|
"""Two games a day for a single date; a range is 400, as ESPN answers."""
|
||||||
|
|
||||||
|
def __init__(self, answer=None):
|
||||||
|
super().__init__()
|
||||||
|
self.answer, self.asked = answer, []
|
||||||
|
|
||||||
|
def get(self, url, params=None, **_kwargs):
|
||||||
|
dates = (params or {}).get("dates")
|
||||||
|
self.asked.append((url[len(SITE):], dates))
|
||||||
|
if dates and "-" in dates:
|
||||||
|
return _response(url, 400)
|
||||||
|
if isinstance(self.answer, type):
|
||||||
|
raise self.answer("refused by the test")
|
||||||
|
if self.answer is not None:
|
||||||
|
return _response(url, self.answer)
|
||||||
|
return _response(url, {"events": [{"id": dates[4:] + s} for s in "ab"]})
|
||||||
|
|
||||||
|
|
||||||
|
class Cache:
|
||||||
|
"""``get(key, max_age)`` against entries stamped on ``clock``."""
|
||||||
|
|
||||||
|
def __init__(self, clock):
|
||||||
|
self.clock, self.store, self.reads = clock, {}, []
|
||||||
|
|
||||||
|
def get(self, key, max_age=None):
|
||||||
|
self.reads.append((key, max_age))
|
||||||
|
if key not in self.store:
|
||||||
|
return None
|
||||||
|
data, stamp = self.store[key]
|
||||||
|
return data if max_age is None or self.clock() - stamp <= max_age else None
|
||||||
|
|
||||||
|
def set(self, key, data, ttl=None):
|
||||||
|
self.store[key] = (data, self.clock())
|
||||||
|
|
||||||
|
|
||||||
|
class Clock:
|
||||||
|
def __init__(self):
|
||||||
|
self.now = 100_000.0
|
||||||
|
|
||||||
|
def __call__(self):
|
||||||
|
return self.now
|
||||||
|
|
||||||
|
|
||||||
|
class Frozen(datetime):
|
||||||
|
at = datetime(2026, 10, 12, 10, 0)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def now(cls, tz=None):
|
||||||
|
return tz.localize(cls.at) if tz is not None else cls.at
|
||||||
|
|
||||||
|
|
||||||
|
class Host(SportsLiveFetchMixin, SportsFetchMixin):
|
||||||
|
"""A scoreboard SportsCore as far as the family reaches."""
|
||||||
|
|
||||||
|
sport, league, sport_key = "basketball", "nba", "nba"
|
||||||
|
|
||||||
|
def __init__(self, session=None, cache=None, odds=None, show_odds=True):
|
||||||
|
self.session, self.headers, self.logger = session or Espn(), {}, LOG
|
||||||
|
self.cache_manager = cache
|
||||||
|
self.odds_manager, self.show_odds = odds, show_odds
|
||||||
|
self.mode_config = {}
|
||||||
|
self._games_lock = threading.RLock()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def offline(monkeypatch):
|
||||||
|
clock = Clock()
|
||||||
|
monkeypatch.setattr(fs, "_service", FetchService({"rate_limits": {}}))
|
||||||
|
monkeypatch.setattr(espn_dates, "_ranges_rejected_until", 0.0)
|
||||||
|
monkeypatch.setattr(sports_live_fetch, "datetime", Frozen)
|
||||||
|
monkeypatch.setattr(time, "time", clock)
|
||||||
|
Frozen.at = datetime(2026, 10, 12, 10, 0)
|
||||||
|
return clock
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The live poll's request
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("hour, asked, key, ids", [
|
||||||
|
(10, [("20261012",)], "espn_scoreboard_basketball_nba_20261012", ["1012a", "1012b"]),
|
||||||
|
(2, [("20261011-20261012",), ("20261011",), ("20261012",)],
|
||||||
|
"espn_scoreboard_basketball_nba_20261011-20261012",
|
||||||
|
["1011a", "1011b", "1012a", "1012b"]),
|
||||||
|
])
|
||||||
|
def test_what_the_poll_asks_and_keeps(offline, hour, asked, key, ids):
|
||||||
|
Frozen.at = datetime(2026, 10, 12, hour, 0)
|
||||||
|
espn, cache = Espn(), Cache(offline)
|
||||||
|
data = Host(espn, cache)._fetch_todays_games()
|
||||||
|
assert [(d,) for _, d in espn.asked] == asked
|
||||||
|
assert {path for path, _ in espn.asked} == {"basketball/nba/scoreboard"}
|
||||||
|
assert cache.reads == [(key, 1)]
|
||||||
|
assert list(cache.store) == [key]
|
||||||
|
assert [e["id"] for e in data["events"]] == ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_college_basketball_is_asked_by_date(offline):
|
||||||
|
espn, host = Espn(), Host()
|
||||||
|
host.session, host.league = espn, "mens-college-basketball"
|
||||||
|
host._fetch_todays_games()
|
||||||
|
assert espn.asked == [("basketball/mens-college-basketball/scoreboard", "20261012")]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("seed_age, later, requests_made", [
|
||||||
|
(None, 20, [1, 1]), # every poll asks ESPN; nothing is held 30 s
|
||||||
|
(None, 0.5, [1, 0]), # a copy from this second is the same answer
|
||||||
|
(0, None, [0]), # another reader's copy from this second
|
||||||
|
(2, None, [1]), # ... and one two seconds old is asked again
|
||||||
|
])
|
||||||
|
def test_every_poll_is_fresh(offline, seed_age, later, requests_made):
|
||||||
|
espn, cache = Espn(), Cache(offline)
|
||||||
|
if seed_age is not None:
|
||||||
|
cache.store["espn_scoreboard_basketball_nba_20261012"] = (
|
||||||
|
{"events": [{"id": "seed"}]}, offline.now - seed_age)
|
||||||
|
host, made = Host(espn, cache), []
|
||||||
|
for step in ([0] if later is None else [0, later]):
|
||||||
|
offline.now += step
|
||||||
|
before = len(espn.asked)
|
||||||
|
host._fetch_todays_games()
|
||||||
|
made.append(len(espn.asked) - before)
|
||||||
|
assert made == requests_made
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_old_per_plugin_key_is_not_read(offline):
|
||||||
|
cache = Cache(offline)
|
||||||
|
cache.store["nba_scoreboard_current"] = ({"events": [{"id": "old"}]}, offline.now)
|
||||||
|
data = Host(Espn(), cache)._fetch_todays_games()
|
||||||
|
assert [e["id"] for e in data["events"]] == ["1012a", "1012b"]
|
||||||
|
assert "nba_scoreboard_current" not in {k for k, _ in cache.reads}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("answer, result, cached", [
|
||||||
|
({}, {"events": []}, False),
|
||||||
|
({"events": None}, {"events": []}, False),
|
||||||
|
({"events": []}, {"events": []}, True),
|
||||||
|
(404, None, False),
|
||||||
|
(500, None, False),
|
||||||
|
(requests.ConnectionError, None, False),
|
||||||
|
(requests.Timeout, None, False),
|
||||||
|
(b"<html>", None, False),
|
||||||
|
])
|
||||||
|
def test_what_a_failed_or_malformed_answer_becomes(offline, answer, result, cached):
|
||||||
|
cache = Cache(offline)
|
||||||
|
assert Host(Espn(answer), cache)._fetch_todays_games() == result
|
||||||
|
assert bool(cache.store) is cached
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Odds
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class Odds:
|
||||||
|
"""An odds endpoint held open until ``release()``, counting what is in
|
||||||
|
flight and how many odds workers are alive while it answers."""
|
||||||
|
|
||||||
|
def __init__(self, answer="odds", held=True):
|
||||||
|
self.answer, self.calls, self.gate = answer, [], threading.Event()
|
||||||
|
if not held:
|
||||||
|
self.gate.set()
|
||||||
|
self.lock, self.active, self.most_active, self.most_workers = threading.Lock(), 0, 0, 0
|
||||||
|
|
||||||
|
def get_odds(self, sport, league, event_id, update_interval_seconds=None):
|
||||||
|
workers = sum(t.name == "nba-odds" and t.is_alive() for t in threading.enumerate())
|
||||||
|
with self.lock:
|
||||||
|
self.calls.append((event_id, update_interval_seconds))
|
||||||
|
self.active += 1
|
||||||
|
self.most_active = max(self.most_active, self.active)
|
||||||
|
self.most_workers = max(self.most_workers, workers)
|
||||||
|
self.gate.wait(10)
|
||||||
|
with self.lock:
|
||||||
|
self.active -= 1
|
||||||
|
if isinstance(self.answer, type):
|
||||||
|
raise self.answer("odds endpoint failed")
|
||||||
|
return {"details": f"{event_id} -3.5"} if self.answer else None
|
||||||
|
|
||||||
|
def release(self):
|
||||||
|
self.gate.set()
|
||||||
|
|
||||||
|
|
||||||
|
def settle():
|
||||||
|
for thread in threading.enumerate():
|
||||||
|
if thread.name == "nba-odds":
|
||||||
|
thread.join(10)
|
||||||
|
|
||||||
|
|
||||||
|
def game(gid="g1", **flags):
|
||||||
|
return {"id": gid, **flags}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("flags, interval", [({"is_live": True}, 60), ({"is_upcoming": True}, 3600),
|
||||||
|
({"is_final": True}, 3600)])
|
||||||
|
def test_the_call_never_waits_and_the_line_lands_later(flags, interval):
|
||||||
|
odds = Odds()
|
||||||
|
host, g = Host(odds=odds), game(**flags)
|
||||||
|
host._fetch_odds(g) # returns while the request is held open
|
||||||
|
assert "odds" not in g
|
||||||
|
odds.release()
|
||||||
|
settle()
|
||||||
|
assert g["odds"] == {"details": "g1 -3.5"}
|
||||||
|
assert odds.calls == [("g1", interval)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_intervals_come_from_the_mode_config():
|
||||||
|
odds = Odds(held=False)
|
||||||
|
host = Host(odds=odds)
|
||||||
|
host.mode_config = {"live_odds_update_interval": 30, "odds_update_interval": 900}
|
||||||
|
host._fetch_odds(game("l", is_live=True))
|
||||||
|
settle()
|
||||||
|
host._fetch_odds(game("u"))
|
||||||
|
settle()
|
||||||
|
assert odds.calls == [("l", 30), ("u", 900)]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("answer", [None, RuntimeError])
|
||||||
|
def test_no_line_or_a_failed_request_leaves_the_game_bare(caplog, answer):
|
||||||
|
odds = Odds(answer, held=False)
|
||||||
|
host, g = Host(odds=odds), game()
|
||||||
|
with caplog.at_level(logging.DEBUG, logger=LOG.name):
|
||||||
|
host._fetch_odds(g)
|
||||||
|
settle()
|
||||||
|
assert "odds" not in g and host._odds_worker_busy is False
|
||||||
|
assert [r for r in caplog.records if r.levelno >= logging.WARNING] == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("host", [
|
||||||
|
Host(odds=Odds(held=False), show_odds=False),
|
||||||
|
Host(odds=None),
|
||||||
|
], ids=["show_odds off", "no odds manager"])
|
||||||
|
def test_nothing_is_asked(caplog, host):
|
||||||
|
with caplog.at_level(logging.DEBUG, logger=LOG.name):
|
||||||
|
host._fetch_odds(game())
|
||||||
|
assert not [t for t in threading.enumerate() if t.name == "nba-odds"]
|
||||||
|
assert [r.levelname for r in caplog.records] in ([], ["DEBUG"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_game_without_an_id_is_not_asked():
|
||||||
|
odds = Odds(held=False)
|
||||||
|
Host(odds=odds)._fetch_odds({"is_live": True})
|
||||||
|
settle()
|
||||||
|
assert odds.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_batch_per_manager_and_every_dict_gets_the_answer():
|
||||||
|
odds = Odds()
|
||||||
|
host = Host(odds=odds)
|
||||||
|
first, again, other = game("g1"), game("g1"), game("g2")
|
||||||
|
with host._games_lock: # queued before the worker takes one
|
||||||
|
for g in (first, again, other): # update() rebuilds the dicts
|
||||||
|
host._fetch_odds(g)
|
||||||
|
odds.release()
|
||||||
|
settle()
|
||||||
|
assert odds.calls == [("g1", 3600), ("g2", 3600)]
|
||||||
|
assert odds.most_workers == 1
|
||||||
|
assert first["odds"] == again["odds"] == {"details": "g1 -3.5"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_thread_that_will_not_start_is_tried_again(monkeypatch):
|
||||||
|
class NoThread:
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
raise RuntimeError("can't start new thread")
|
||||||
|
|
||||||
|
odds = Odds(held=False)
|
||||||
|
host = Host(odds=odds)
|
||||||
|
monkeypatch.setattr(sports_live_fetch.threading, "Thread", NoThread)
|
||||||
|
host._fetch_odds(game("g1"))
|
||||||
|
assert host._odds_worker_busy is False
|
||||||
|
monkeypatch.undo()
|
||||||
|
host._fetch_odds(game("g2"))
|
||||||
|
settle()
|
||||||
|
assert [c[0] for c in odds.calls] == ["g1", "g2"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_and_display_on_two_threads():
|
||||||
|
"""Two updaters queueing fresh dicts while a reader walks the games under
|
||||||
|
the lock, as display() does: no exception, one worker, one request at a
|
||||||
|
time, and every dict ends with its line."""
|
||||||
|
odds = Odds(held=False)
|
||||||
|
odds.get_odds = _slowed(odds.get_odds)
|
||||||
|
host, shown, raised = Host(odds=odds), [], []
|
||||||
|
|
||||||
|
def updater():
|
||||||
|
try:
|
||||||
|
for n in range(30):
|
||||||
|
g = game(f"g{n % 4}")
|
||||||
|
with host._games_lock:
|
||||||
|
shown.append(g)
|
||||||
|
host._fetch_odds(g)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
raised.append(exc)
|
||||||
|
|
||||||
|
def display():
|
||||||
|
try:
|
||||||
|
for _ in range(300):
|
||||||
|
with host._games_lock:
|
||||||
|
[g.get("odds") for g in shown]
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
raised.append(exc)
|
||||||
|
|
||||||
|
threads = [threading.Thread(target=updater) for _ in range(2)]
|
||||||
|
threads.append(threading.Thread(target=display))
|
||||||
|
for t in threads:
|
||||||
|
t.start()
|
||||||
|
for t in threads:
|
||||||
|
t.join(30)
|
||||||
|
settle()
|
||||||
|
assert raised == []
|
||||||
|
assert odds.most_active == 1 and odds.most_workers == 1
|
||||||
|
assert all(g.get("odds") for g in shown)
|
||||||
|
|
||||||
|
|
||||||
|
def _slowed(get_odds):
|
||||||
|
def slow(**kwargs):
|
||||||
|
time.sleep(0.005)
|
||||||
|
return get_odds(**kwargs)
|
||||||
|
return slow
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Carrier and host contract
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
MEMBERS = ["_drain_odds_queue", "_fetch_odds", "_fetch_todays_games"]
|
||||||
|
|
||||||
|
|
||||||
|
def _self_reads():
|
||||||
|
tree = ast.parse(Path(sports_live_fetch.__file__).read_text(encoding="utf-8"))
|
||||||
|
reads = {node.attr for node in ast.walk(tree)
|
||||||
|
if isinstance(node, ast.Attribute) and isinstance(node.ctx, ast.Load)
|
||||||
|
and isinstance(node.value, ast.Name) and node.value.id == "self"}
|
||||||
|
gets = {node.args[1].value for node in ast.walk(tree)
|
||||||
|
if isinstance(node, ast.Call) and getattr(node.func, "id", None) == "getattr"
|
||||||
|
and len(node.args) >= 2 and isinstance(node.args[1], ast.Constant)}
|
||||||
|
return reads | gets
|
||||||
|
|
||||||
|
|
||||||
|
class TestHostContract:
|
||||||
|
def test_the_mixin_carries_only_the_family(self):
|
||||||
|
assert sorted(n for n in vars(SportsLiveFetchMixin) if not n.startswith("__")) == MEMBERS
|
||||||
|
|
||||||
|
def test_every_host_read_is_documented(self):
|
||||||
|
undocumented = sorted(n for n in _self_reads() - set(MEMBERS)
|
||||||
|
if f"``{n}``" not in sports_live_fetch.__doc__)
|
||||||
|
assert undocumented == [], f"read but not in the host contract: {undocumented}"
|
||||||
|
|
||||||
|
def test_no_other_shared_mixin_defines_these(self):
|
||||||
|
from src.common import (sports_display_rules, sports_favorites, sports_helpers,
|
||||||
|
sports_rankings, sports_rotation, sports_shared)
|
||||||
|
others = [sports_shared.SportsCoreSharedMixin, sports_shared.SportsRecentSharedMixin,
|
||||||
|
sports_shared.SportsLiveSharedMixin, sports_helpers.SportsHelpersMixin,
|
||||||
|
sports_favorites.SportsFavoritesMixin, sports_rotation.SportsRotationMixin,
|
||||||
|
sports_rankings.SportsRankingsMixin, SportsFetchMixin,
|
||||||
|
sports_display_rules.SportsGameRulesMixin]
|
||||||
|
assert [m for m in MEMBERS if any(m in vars(o) for o in others)] == []
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"""sports_live_fetch still matches every plugin copy.
|
||||||
|
|
||||||
|
``src.common.sports_live_fetch`` was copied from the scoreboards once family 9
|
||||||
|
had made each method one body in all nine ``SportsCore`` classes:
|
||||||
|
``_fetch_todays_games``, ``_fetch_odds`` and ``_drain_odds_queue``. The plugins delete their copies once they floor on the release that
|
||||||
|
ships this module. Until each has, a copy that changes on its own is a fix one
|
||||||
|
side has and the other lacks.
|
||||||
|
|
||||||
|
Point LEDMATRIX_PLUGINS at a ledmatrix-plugins checkout and each method is
|
||||||
|
compared with every plugin copy using ``scripts/sports_drift_report.py``'s own
|
||||||
|
normalisation (the AST with docstrings and annotations dropped), plus the
|
||||||
|
decorators. A copy that is gone counts as adopted when the plugin's ``sports.py`` names the module.
|
||||||
|
Without the variable this skips: core CI has no plugins checkout.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import importlib.util
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.common import sports_live_fetch
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parents[1]
|
||||||
|
SPORTS = ("afl", "baseball", "basketball", "football", "hockey", "lacrosse",
|
||||||
|
"nrl", "soccer", "ufc")
|
||||||
|
CARRIER, MIXIN = "SportsCore", "SportsLiveFetchMixin"
|
||||||
|
METHODS = ("_fetch_todays_games", "_fetch_odds", "_drain_odds_queue")
|
||||||
|
|
||||||
|
|
||||||
|
def _drift_report():
|
||||||
|
"""scripts/sports_drift_report.py, loaded by path (scripts/ is no package)."""
|
||||||
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
"sports_drift_report", REPO / "scripts" / "sports_drift_report.py")
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
DRIFT = _drift_report()
|
||||||
|
|
||||||
|
|
||||||
|
def _plugins_root():
|
||||||
|
root = DRIFT.resolve_plugins_dir(os.environ.get("LEDMATRIX_PLUGINS"))
|
||||||
|
if root is None:
|
||||||
|
pytest.skip("set LEDMATRIX_PLUGINS to a ledmatrix-plugins checkout to "
|
||||||
|
"compare this module against the plugin copies")
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
def _class(tree, name):
|
||||||
|
return next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == name)
|
||||||
|
|
||||||
|
|
||||||
|
def _method(cls, name):
|
||||||
|
return next((n for n in cls.body
|
||||||
|
if isinstance(n, ast.FunctionDef) and n.name == name), None)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _fingerprint(node):
|
||||||
|
return (DRIFT._digest(node, DRIFT._Canonical()),
|
||||||
|
tuple(ast.unparse(d) for d in node.decorator_list))
|
||||||
|
|
||||||
|
|
||||||
|
def _plugin_tree(root, sport):
|
||||||
|
source = (root / f"{sport}-scoreboard" / "sports.py").read_text(encoding="utf-8")
|
||||||
|
return source, ast.parse(source)
|
||||||
|
|
||||||
|
|
||||||
|
OURS = _class(ast.parse(Path(sports_live_fetch.__file__).read_text(encoding="utf-8")), MIXIN)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("sport, name", [(s, n) for s in SPORTS for n in METHODS])
|
||||||
|
def test_every_remaining_plugin_copy_matches(sport, name):
|
||||||
|
source, tree = _plugin_tree(_plugins_root(), sport)
|
||||||
|
copy = _method(_class(tree, CARRIER), name)
|
||||||
|
if copy is None:
|
||||||
|
assert sports_live_fetch.__name__ in source, (
|
||||||
|
f"{sport}: no {name} on {CARRIER} and no {sports_live_fetch.__name__} import")
|
||||||
|
else:
|
||||||
|
assert _fingerprint(copy) == _fingerprint(_method(OURS, name)), (
|
||||||
|
f"{CARRIER}.{name} in {sport} differs from sports_live_fetch. "
|
||||||
|
f"Port the change to both, or stop treating it as shared.")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("sport", SPORTS)
|
||||||
|
def test_no_other_plugin_class_carries_a_copy(sport):
|
||||||
|
"""A copy on another class would shadow the shared one for that class."""
|
||||||
|
_, tree = _plugin_tree(_plugins_root(), sport)
|
||||||
|
strays = [f"{node.name}.{name}" for node in tree.body if isinstance(node, ast.ClassDef)
|
||||||
|
for name in METHODS if node.name != CARRIER and _method(node, name) is not None]
|
||||||
|
assert strays == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_drift_report_calls_them_identical():
|
||||||
|
families = DRIFT.build(_plugins_root(), ("sports.py",))
|
||||||
|
rows = {(r["file"], r["family"]): r
|
||||||
|
for r in (DRIFT.summarise(k, v) for k, v in families.items())}
|
||||||
|
for name in METHODS:
|
||||||
|
row = rows.get(("sports.py", name))
|
||||||
|
assert row is None or row["worst_class_variants"] == 1, name
|
||||||
@@ -173,6 +173,11 @@ def test_only_a_league_with_a_poll_is_asked(clock, league, asked):
|
|||||||
{"current": 3, "team": {"id": "abc", "abbreviation": "OSU"}},
|
{"current": 3, "team": {"id": "abc", "abbreviation": "OSU"}},
|
||||||
{"current": 4, "team": {"abbreviation": "TEX"}}]}]},
|
{"current": 4, "team": {"abbreviation": "TEX"}}]}]},
|
||||||
{"OSU": 3, "TEX": 4}, {87: 2}),
|
{"OSU": 3, "TEX": 4}, {87: 2}),
|
||||||
|
({"rankings": [{"name": "AP Top 25", "type": "ap", "ranks": [ # #789: one bad entry
|
||||||
|
{"current": None, "team": {"id": "61", "abbreviation": "UGA"}},
|
||||||
|
{"current": "n/a", "team": {"id": "87", "abbreviation": "ND"}},
|
||||||
|
{"current": 3, "team": {"id": "194", "abbreviation": "OSU"}}]}]},
|
||||||
|
{"OSU": 3}, {194: 3}),
|
||||||
])
|
])
|
||||||
def test_payload_shapes(clock, payload, abbr, ids):
|
def test_payload_shapes(clock, payload, abbr, ids):
|
||||||
host = Host(payload=payload)
|
host = Host(payload=payload)
|
||||||
@@ -320,6 +325,17 @@ def test_ranked_and_best_rank(clock, label, best):
|
|||||||
assert host._is_ranked_game(game(label)) is (best != 99)
|
assert host._is_ranked_game(game(label)) is (best != 99)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("home, away, best", [
|
||||||
|
(NEWARK, (None, "VAN"), 99), # Newark's id is unranked; "OSU" must not rank it
|
||||||
|
(("61", None), (None, "VAN"), 1), # UGA's id still counts with no id on the other side
|
||||||
|
])
|
||||||
|
def test_each_side_is_ranked_by_its_own_id(clock, home, away, best):
|
||||||
|
"""#789: one side's missing id no longer sends both sides to abbreviations."""
|
||||||
|
g = {f"{side}_{key}": value for side, team in (("home", home), ("away", away))
|
||||||
|
for key, value in zip(("id", "abbr"), team) if value is not None}
|
||||||
|
assert loaded()._best_rank(g) == best
|
||||||
|
|
||||||
|
|
||||||
#: filter -> (quality, divisions, poll loaded?, kept: one Y/. per GAMES entry)
|
#: filter -> (quality, divisions, poll loaded?, kept: one Y/. per GAMES entry)
|
||||||
FILTERS = {
|
FILTERS = {
|
||||||
"any": ("any", (), True, "YYYYYYYY"),
|
"any": ("any", (), True, "YYYYYYYY"),
|
||||||
|
|||||||
@@ -0,0 +1,346 @@
|
|||||||
|
"""src.common.sports_view_model: the game dict built from one ESPN event.
|
||||||
|
|
||||||
|
The cases follow ledmatrix-plugins' ``scripts/test_view_model.py`` (the table
|
||||||
|
the family 10 reconcile was checked against); the expected values are its
|
||||||
|
``EXPECTED_COMMON`` cells, which agree across all nine scoreboards once the
|
||||||
|
reconcile landed (nrl differs only through the ``_logo_filename`` seam and
|
||||||
|
its id favourites, which are host data). Each row is one change to one base
|
||||||
|
event, a scheduled AAA (home, id 1) v BBB (away, id 2) at 7:00 PM EDT, and
|
||||||
|
lists only the keys whose value differs from the base game. The host shows
|
||||||
|
times in America/New_York. Nothing reaches the network.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import copy
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytz
|
||||||
|
|
||||||
|
from src.common import sports_view_model
|
||||||
|
from src.common.sports_view_model import SportsViewModelMixin
|
||||||
|
|
||||||
|
LOGO_DIR = Path("logos")
|
||||||
|
|
||||||
|
|
||||||
|
class Host(SportsViewModelMixin):
|
||||||
|
def __init__(self, config=None):
|
||||||
|
self.logger = logging.getLogger("test_sports_view_model")
|
||||||
|
self.config = config or {}
|
||||||
|
self.logo_dir = LOGO_DIR
|
||||||
|
self.favorite_teams = ["AAA"]
|
||||||
|
|
||||||
|
def _get_timezone(self):
|
||||||
|
return pytz.timezone("America/New_York")
|
||||||
|
|
||||||
|
|
||||||
|
def competitor(home_away, tid, abbr, name, record):
|
||||||
|
return {"id": tid, "homeAway": home_away, "score": "0",
|
||||||
|
"team": {"id": tid, "abbreviation": abbr, "name": name,
|
||||||
|
"logo": f"https://a.espncdn.com/i/teamlogos/{abbr.lower()}.png"},
|
||||||
|
"records": [{"type": "total", "summary": record}]}
|
||||||
|
|
||||||
|
|
||||||
|
def base_event():
|
||||||
|
return {"id": "401", "date": "2026-10-12T23:00Z", "competitions": [{
|
||||||
|
"id": "4011", "broadcast": "ESPN",
|
||||||
|
"status": {"displayClock": "0:00", "period": 0, "type": {
|
||||||
|
"name": "STATUS_SCHEDULED", "state": "pre", "completed": False,
|
||||||
|
"shortDetail": "10/12 - 7:00 PM EDT"}},
|
||||||
|
"competitors": [competitor("home", "1", "AAA", "Alpha", "3-1"),
|
||||||
|
competitor("away", "2", "BBB", "Bravo", "2-2")]}]}
|
||||||
|
|
||||||
|
|
||||||
|
BASE = {
|
||||||
|
"id": "401", "game_time": "7:00PM", "game_date": "10/12",
|
||||||
|
"start_time_utc": datetime(2026, 10, 12, 23, 0, tzinfo=pytz.UTC),
|
||||||
|
"status_text": "10/12 - 7:00 PM EDT", "is_live": False, "is_final": False,
|
||||||
|
"is_upcoming": True, "is_halftime": False, "is_period_break": False,
|
||||||
|
"broadcast": "ESPN", "home_abbr": "AAA", "home_id": "1", "home_score": "0",
|
||||||
|
"home_logo_path": LOGO_DIR / "AAA.png",
|
||||||
|
"home_logo_url": "https://a.espncdn.com/i/teamlogos/aaa.png",
|
||||||
|
"home_record": "3-1", "away_record": "2-2", "away_abbr": "BBB", "away_id": "2",
|
||||||
|
"away_score": "0", "away_logo_path": LOGO_DIR / "BBB.png",
|
||||||
|
"away_logo_url": "https://a.espncdn.com/i/teamlogos/bbb.png",
|
||||||
|
"is_within_window": True, "favorite_teams": ["AAA"],
|
||||||
|
"period": 0, "clock": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def comp(e):
|
||||||
|
return e["competitions"][0]
|
||||||
|
|
||||||
|
|
||||||
|
def side(e, home_away):
|
||||||
|
return next(c for c in comp(e)["competitors"] if c["homeAway"] == home_away)
|
||||||
|
|
||||||
|
|
||||||
|
def status(state, name, short, completed=False, drop_completed=False):
|
||||||
|
def edit(e):
|
||||||
|
st = {"name": name, "state": state, "completed": completed, "shortDetail": short}
|
||||||
|
if drop_completed:
|
||||||
|
del st["completed"]
|
||||||
|
comp(e)["status"] = {"displayClock": "5:32", "period": 2, "type": st}
|
||||||
|
return edit
|
||||||
|
|
||||||
|
|
||||||
|
def scores(home, away):
|
||||||
|
def edit(e):
|
||||||
|
side(e, "home")["score"], side(e, "away")["score"] = home, away
|
||||||
|
return edit
|
||||||
|
|
||||||
|
|
||||||
|
def drop_scores(e):
|
||||||
|
for c in comp(e)["competitors"]:
|
||||||
|
del c["score"]
|
||||||
|
|
||||||
|
|
||||||
|
def both(fn):
|
||||||
|
def edit(e):
|
||||||
|
for c in comp(e)["competitors"]:
|
||||||
|
fn(c)
|
||||||
|
return edit
|
||||||
|
|
||||||
|
|
||||||
|
def dated(date):
|
||||||
|
def edit(e):
|
||||||
|
e["date"] = date
|
||||||
|
return edit
|
||||||
|
|
||||||
|
|
||||||
|
def chain(*edits):
|
||||||
|
def edit(e):
|
||||||
|
for fn in edits:
|
||||||
|
fn(e)
|
||||||
|
return edit
|
||||||
|
|
||||||
|
|
||||||
|
def utc(*args):
|
||||||
|
return datetime(*args, tzinfo=pytz.UTC)
|
||||||
|
|
||||||
|
|
||||||
|
FINAL = status("post", "STATUS_FINAL", "Final", completed=True)
|
||||||
|
POST = {"is_upcoming": False, "is_final": True, "status_text": "Final"}
|
||||||
|
|
||||||
|
#: label -> (edit, extra host config, keys that differ from BASE; None: no game)
|
||||||
|
ROWS = {
|
||||||
|
"pre": (None, None, {}),
|
||||||
|
"short date format": (dated("2026-03-05T23:00Z"), {"display": {"use_short_date_format": True}},
|
||||||
|
{"game_date": "3/5", "game_time": "6:00PM",
|
||||||
|
"start_time_utc": utc(2026, 3, 5, 23, 0)}),
|
||||||
|
"in progress": (status("in", "STATUS_IN_PROGRESS", "5:32 - 2nd"), None,
|
||||||
|
{"is_live": True, "is_upcoming": False, "status_text": "5:32 - 2nd"}),
|
||||||
|
"halftime": (status("in", "STATUS_HALFTIME", "Halftime"), None,
|
||||||
|
{"is_live": True, "is_halftime": True, "is_upcoming": False,
|
||||||
|
"status_text": "Halftime"}),
|
||||||
|
"end of period": (status("in", "STATUS_END_PERIOD", "End of 1st"), None,
|
||||||
|
{"is_live": True, "is_period_break": True, "is_upcoming": False,
|
||||||
|
"status_text": "End of 1st"}),
|
||||||
|
"suspended while in": (status("in", "STATUS_SUSPENDED", "Suspended"), None,
|
||||||
|
{"is_upcoming": False, "status_text": "Suspended"}),
|
||||||
|
"final": (FINAL, None, POST),
|
||||||
|
"final, no completed flag": (status("post", "STATUS_FINAL", "Final", drop_completed=True),
|
||||||
|
None, POST),
|
||||||
|
"final, completed false": (status("post", "STATUS_FINAL", "Final"), None,
|
||||||
|
{"is_upcoming": False, "status_text": "Final"}),
|
||||||
|
"postponed": (status("post", "STATUS_POSTPONED", "Postponed"), None,
|
||||||
|
{"is_upcoming": False, "status_text": "Postponed"}),
|
||||||
|
"canceled": (status("post", "STATUS_CANCELED", "Canceled"), None,
|
||||||
|
{"is_upcoming": False, "status_text": "Canceled"}),
|
||||||
|
"delayed, completed": (status("post", "STATUS_DELAYED", "Delayed", completed=True), None,
|
||||||
|
{"is_upcoming": False, "status_text": "Delayed"}),
|
||||||
|
"forfeit, completed": (status("post", "STATUS_FORFEIT", "Forfeit", completed=True), None,
|
||||||
|
{**POST, "status_text": "Forfeit"}),
|
||||||
|
"forfeit pending": (status("post", "STATUS_FORFEIT_PENDING", "Forfeit", completed=True),
|
||||||
|
None, {"is_upcoming": False, "status_text": "Forfeit"}),
|
||||||
|
"scores int": (chain(FINAL, scores(21, 3)), None, {**POST, "home_score": "21", "away_score": "3"}),
|
||||||
|
"scores '21.0'": (chain(FINAL, scores("21.0", " 3 ")), None,
|
||||||
|
{**POST, "home_score": "21", "away_score": "3"}),
|
||||||
|
"scores dict": (chain(FINAL, scores({"value": 21.0, "displayValue": "21"},
|
||||||
|
{"value": 0, "displayValue": "4"})), None,
|
||||||
|
{**POST, "home_score": "21", "away_score": "4"}),
|
||||||
|
"scores None and ''": (chain(FINAL, scores(None, "")), None, POST),
|
||||||
|
"scores missing": (chain(FINAL, drop_scores), None, POST),
|
||||||
|
"scores not numbers": (chain(FINAL, scores("SO", "12 (3)")), None, {**POST, "away_score": "12"}),
|
||||||
|
"records 0-0 and 0-0-0": (lambda e: (side(e, "home")["records"][0].update(summary="0-0"),
|
||||||
|
side(e, "away")["records"][0].update(summary="0-0-0")),
|
||||||
|
None, {"home_record": "", "away_record": ""}),
|
||||||
|
"records missing": (both(lambda c: c.pop("records")), None,
|
||||||
|
{"home_record": "", "away_record": ""}),
|
||||||
|
"record, team-schedule shape": (both(lambda c: (c.pop("records"),
|
||||||
|
c.update(record=[{"displayValue": "7-0"}]))),
|
||||||
|
None, {"home_record": "7-0", "away_record": "7-0"}),
|
||||||
|
"no abbreviation": (both(lambda c: c["team"].pop("abbreviation")), None,
|
||||||
|
{"home_abbr": "Alp", "away_abbr": "Bra",
|
||||||
|
"home_logo_path": LOGO_DIR / "ALP.png",
|
||||||
|
"away_logo_path": LOGO_DIR / "BRA.png"}),
|
||||||
|
"logos array only": (both(lambda c: c["team"].update(
|
||||||
|
logo=None, logos=[{"href": "https://a.espncdn.com/x.png"}])), None,
|
||||||
|
{"home_logo_url": "https://a.espncdn.com/x.png", "away_logo_url": "https://a.espncdn.com/x.png"}),
|
||||||
|
"logo and logos": (both(lambda c: c["team"].update(logos=[{"href": "https://a.espncdn.com/x.png"}])),
|
||||||
|
None, {}),
|
||||||
|
"no logos": (both(lambda c: c["team"].pop("logo")), None,
|
||||||
|
{"home_logo_url": None, "away_logo_url": None}),
|
||||||
|
"competitor without id": (lambda e: side(e, "home").pop("id"), None, {}),
|
||||||
|
"no ids at all": (lambda e: (side(e, "home").pop("id"), side(e, "home")["team"].pop("id")),
|
||||||
|
None, {"home_id": None}),
|
||||||
|
"broadcast object": (lambda e: comp(e).update(broadcast={"names": ["ESPN"]}), None,
|
||||||
|
{"broadcast": "{'names': ['ESPN']}"}),
|
||||||
|
"broadcast missing": (lambda e: comp(e).pop("broadcast"), None, {"broadcast": ""}),
|
||||||
|
"date 'TBD'": (dated("TBD"), None, {"game_date": "", "game_time": "", "start_time_utc": None}),
|
||||||
|
"date +10:00": (dated("2026-10-13T19:40+10:00"), None,
|
||||||
|
{"game_date": "10/13", "game_time": "5:40AM",
|
||||||
|
"start_time_utc": utc(2026, 10, 13, 9, 40)}),
|
||||||
|
"date naive, read as UTC": (dated("2026-10-12T23:00:00"), None, {}),
|
||||||
|
"late UTC, previous local day": (dated("2026-10-13T03:30Z"), None,
|
||||||
|
{"game_time": "11:30PM", "start_time_utc": utc(2026, 10, 13, 3, 30)}),
|
||||||
|
"DST ends, 05:30Z (EDT)": (dated("2026-11-01T05:30Z"), None,
|
||||||
|
{"game_date": "11/01", "game_time": "1:30AM",
|
||||||
|
"start_time_utc": utc(2026, 11, 1, 5, 30)}),
|
||||||
|
"DST ends, 06:30Z (EST)": (dated("2026-11-01T06:30Z"), None,
|
||||||
|
{"game_date": "11/01", "game_time": "1:30AM",
|
||||||
|
"start_time_utc": utc(2026, 11, 1, 6, 30)}),
|
||||||
|
"DST starts, 07:30Z": (dated("2026-03-08T07:30Z"), None,
|
||||||
|
{"game_date": "03/08", "game_time": "3:30AM",
|
||||||
|
"start_time_utc": utc(2026, 3, 8, 7, 30)}),
|
||||||
|
"naive, repeated DST hour": (dated("2026-11-01T01:30:00"), None,
|
||||||
|
{"game_date": "10/31", "game_time": "9:30PM",
|
||||||
|
"start_time_utc": utc(2026, 11, 1, 1, 30)}),
|
||||||
|
"one competitor": (lambda e: comp(e)["competitors"].pop(), None, None),
|
||||||
|
"no competitors": (lambda e: comp(e).pop("competitors"), None, None),
|
||||||
|
"no competitions": (lambda e: e.update(competitions=[]), None, None),
|
||||||
|
"no status": (lambda e: comp(e).pop("status"), None, None),
|
||||||
|
"no date": (lambda e: e.pop("date"), None, None),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("label", ROWS)
|
||||||
|
def test_the_game_dict(label):
|
||||||
|
edit, config, changed = ROWS[label]
|
||||||
|
event = base_event()
|
||||||
|
if edit:
|
||||||
|
edit(event)
|
||||||
|
details, home, away, st, situation = Host(config)._extract_game_details_common(event)
|
||||||
|
if changed is None:
|
||||||
|
assert (details, home, away, st, situation) == (None,) * 5
|
||||||
|
return
|
||||||
|
assert details == {**BASE, **changed}
|
||||||
|
assert home is side(event, "home") and away is side(event, "away")
|
||||||
|
assert st is comp(event)["status"] and situation is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_empty_event_is_no_game():
|
||||||
|
assert Host()._extract_game_details_common({}) == (None,) * 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_situation_is_handed_back():
|
||||||
|
event = base_event()
|
||||||
|
comp(event)["situation"] = {"possession": "1"}
|
||||||
|
assert Host()._extract_game_details_common(event)[4] == {"possession": "1"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_logo_filename_seam():
|
||||||
|
class ById(Host):
|
||||||
|
def _logo_filename(self, abbr, team_id):
|
||||||
|
return f"{abbr}_{team_id}.png"
|
||||||
|
|
||||||
|
details = ById()._extract_game_details_common(base_event())[0]
|
||||||
|
assert (details["home_logo_path"], details["away_logo_path"]) == (
|
||||||
|
LOGO_DIR / "AAA_1.png", LOGO_DIR / "BBB_2.png")
|
||||||
|
|
||||||
|
|
||||||
|
def test_favourites_are_a_copy():
|
||||||
|
host = Host()
|
||||||
|
host.favorite_teams = None
|
||||||
|
assert host._extract_game_details_common(base_event())[0]["favorite_teams"] == []
|
||||||
|
host.favorite_teams = ["AAA"]
|
||||||
|
details = host._extract_game_details_common(base_event())[0]
|
||||||
|
assert details["favorite_teams"] == ["AAA"] and details["favorite_teams"] is not host.favorite_teams
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("status_type, final", [
|
||||||
|
({"state": "post", "name": "STATUS_FINAL", "completed": True}, True),
|
||||||
|
({"state": "post", "name": "STATUS_FINAL"}, True),
|
||||||
|
({"state": "post", "name": "status_final", "completed": True}, True),
|
||||||
|
({"state": "post", "name": "STATUS_FINAL", "completed": False}, False),
|
||||||
|
({"state": "post", "name": "STATUS_FORFEIT", "completed": True}, True),
|
||||||
|
({"state": "in", "name": "STATUS_IN_PROGRESS"}, False),
|
||||||
|
({"state": "post"}, True),
|
||||||
|
(None, False),
|
||||||
|
("post", False),
|
||||||
|
] + [({"state": "post", "name": n, "completed": True}, False)
|
||||||
|
for n in sorted(SportsViewModelMixin._NOT_PLAYED_STATUS_NAMES)])
|
||||||
|
def test_status_is_final(status_type, final):
|
||||||
|
assert SportsViewModelMixin._status_is_final(status_type) is final
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_status_lists():
|
||||||
|
assert SportsViewModelMixin._CALLED_OFF_STATUS_NAMES == {
|
||||||
|
"STATUS_POSTPONED", "STATUS_CANCELED", "STATUS_CANCELLED",
|
||||||
|
"STATUS_SUSPENDED", "STATUS_ABANDONED"}
|
||||||
|
assert SportsViewModelMixin._NOT_PLAYED_STATUS_NAMES == (
|
||||||
|
SportsViewModelMixin._CALLED_OFF_STATUS_NAMES
|
||||||
|
| {"STATUS_DELAYED", "STATUS_FORFEIT_PENDING", "STATUS_NO_CONTEST_PENDING"})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("score, text", [
|
||||||
|
(21, "21"), (21.9, "21"), ("21", "21"), ("21.0", "21"), (" 7 ", "7"), ("", "0"),
|
||||||
|
(None, "0"), ("SO", "0"), ("12 (3)", "12"), ({"value": 21.0}, "21"),
|
||||||
|
({"value": 0, "displayValue": "4"}, "4"), ({"value": None}, "0"), ([], "0"),
|
||||||
|
(float("nan"), "0"), (float("inf"), "0"),
|
||||||
|
])
|
||||||
|
def test_score_text(score, text):
|
||||||
|
assert Host()._score_text(score) == text
|
||||||
|
|
||||||
|
|
||||||
|
def test_records_do_not_mutate_the_event():
|
||||||
|
event = base_event()
|
||||||
|
before = copy.deepcopy(event)
|
||||||
|
Host()._extract_game_details_common(event)
|
||||||
|
assert event == before
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Carrier and host contract
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
MEMBERS = ["_CALLED_OFF_STATUS_NAMES", "_NOT_PLAYED_STATUS_NAMES",
|
||||||
|
"_extract_game_details_common", "_extract_team_record", "_logo_filename",
|
||||||
|
"_score_text", "_status_is_final", "_team_logo_url"]
|
||||||
|
|
||||||
|
|
||||||
|
def _self_reads():
|
||||||
|
tree = ast.parse(Path(sports_view_model.__file__).read_text(encoding="utf-8"))
|
||||||
|
return {node.attr for node in ast.walk(tree)
|
||||||
|
if isinstance(node, ast.Attribute) and isinstance(node.ctx, ast.Load)
|
||||||
|
and isinstance(node.value, ast.Name) and node.value.id in ("self", "cls")}
|
||||||
|
|
||||||
|
|
||||||
|
class TestHostContract:
|
||||||
|
def test_the_mixin_carries_only_the_family(self):
|
||||||
|
assert sorted(n for n in vars(SportsViewModelMixin) if not n.startswith("__")) == MEMBERS
|
||||||
|
|
||||||
|
def test_every_host_read_is_documented(self):
|
||||||
|
undocumented = sorted(n for n in _self_reads() - set(MEMBERS)
|
||||||
|
if f"``{n}``" not in sports_view_model.__doc__)
|
||||||
|
assert undocumented == [], f"read but not in the host contract: {undocumented}"
|
||||||
|
|
||||||
|
def test_every_key_is_in_the_key_table(self):
|
||||||
|
doc = sports_view_model.__doc__
|
||||||
|
table = doc[doc.index("THE KEYS"):doc.index("THE RULES")]
|
||||||
|
missing = [k for k in BASE if k not in table]
|
||||||
|
assert missing == []
|
||||||
|
|
||||||
|
def test_no_other_shared_mixin_defines_these(self):
|
||||||
|
from src.common import (sports_display_rules, sports_favorites, sports_fetch,
|
||||||
|
sports_helpers, sports_live_fetch, sports_rankings,
|
||||||
|
sports_rotation, sports_shared)
|
||||||
|
others = [sports_shared.SportsCoreSharedMixin, sports_shared.SportsRecentSharedMixin,
|
||||||
|
sports_shared.SportsLiveSharedMixin, sports_helpers.SportsHelpersMixin,
|
||||||
|
sports_favorites.SportsFavoritesMixin, sports_rotation.SportsRotationMixin,
|
||||||
|
sports_rankings.SportsRankingsMixin, sports_fetch.SportsFetchMixin,
|
||||||
|
sports_live_fetch.SportsLiveFetchMixin,
|
||||||
|
sports_display_rules.SportsGameRulesMixin]
|
||||||
|
assert [m for m in MEMBERS if any(m in vars(o) for o in others)] == []
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"""sports_view_model still matches every plugin copy.
|
||||||
|
|
||||||
|
``src.common.sports_view_model`` was copied from the scoreboards once family
|
||||||
|
10 had made ``_extract_game_details_common`` one body in all nine
|
||||||
|
``SportsCore`` classes, with the helpers it calls (``_status_is_final``,
|
||||||
|
``_score_text``, ``_team_logo_url``, ``_extract_team_record``, the
|
||||||
|
``_logo_filename`` seam) and the two status-name sets. The plugins delete
|
||||||
|
their copies once they floor on the release that ships this module. nrl
|
||||||
|
overrides the seam on ``BaseNrlManager``, not on ``SportsCore``. Until each has, a copy that changes on its own is a fix one
|
||||||
|
side has and the other lacks.
|
||||||
|
|
||||||
|
Point LEDMATRIX_PLUGINS at a ledmatrix-plugins checkout and each method is
|
||||||
|
compared with every plugin copy using ``scripts/sports_drift_report.py``'s own
|
||||||
|
normalisation (the AST with docstrings and annotations dropped), plus the
|
||||||
|
decorators. A copy that is gone counts as adopted when the plugin's ``sports.py`` names the module.
|
||||||
|
Without the variable this skips: core CI has no plugins checkout.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import importlib.util
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.common import sports_view_model
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parents[1]
|
||||||
|
SPORTS = ("afl", "baseball", "basketball", "football", "hockey", "lacrosse",
|
||||||
|
"nrl", "soccer", "ufc")
|
||||||
|
CARRIER, MIXIN = "SportsCore", "SportsViewModelMixin"
|
||||||
|
METHODS = ("_extract_game_details_common", "_status_is_final", "_score_text",
|
||||||
|
"_team_logo_url", "_extract_team_record", "_logo_filename")
|
||||||
|
CONSTANTS = ("_CALLED_OFF_STATUS_NAMES", "_NOT_PLAYED_STATUS_NAMES")
|
||||||
|
|
||||||
|
|
||||||
|
def _drift_report():
|
||||||
|
"""scripts/sports_drift_report.py, loaded by path (scripts/ is no package)."""
|
||||||
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
"sports_drift_report", REPO / "scripts" / "sports_drift_report.py")
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
DRIFT = _drift_report()
|
||||||
|
|
||||||
|
|
||||||
|
def _plugins_root():
|
||||||
|
root = DRIFT.resolve_plugins_dir(os.environ.get("LEDMATRIX_PLUGINS"))
|
||||||
|
if root is None:
|
||||||
|
pytest.skip("set LEDMATRIX_PLUGINS to a ledmatrix-plugins checkout to "
|
||||||
|
"compare this module against the plugin copies")
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
def _class(tree, name):
|
||||||
|
return next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == name)
|
||||||
|
|
||||||
|
|
||||||
|
def _method(cls, name):
|
||||||
|
return next((n for n in cls.body
|
||||||
|
if isinstance(n, ast.FunctionDef) and n.name == name), None)
|
||||||
|
|
||||||
|
|
||||||
|
def _constant(cls, name):
|
||||||
|
"""The value assigned to ``name`` in the class body, as an AST dump."""
|
||||||
|
return next((ast.dump(n.value) for n in cls.body
|
||||||
|
if isinstance(n, ast.AnnAssign) and getattr(n.target, "id", None) == name),
|
||||||
|
None)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _adopted(source, tree):
|
||||||
|
"""The plugin imports core's module and its carrier inherits the mixin."""
|
||||||
|
bases = _class(tree, CARRIER).bases
|
||||||
|
return (sports_view_model.__name__ in source
|
||||||
|
and any(getattr(b, "id", getattr(b, "attr", None)) == MIXIN for b in bases))
|
||||||
|
|
||||||
|
|
||||||
|
def _fingerprint(node):
|
||||||
|
return (DRIFT._digest(node, DRIFT._Canonical()),
|
||||||
|
tuple(ast.unparse(d) for d in node.decorator_list))
|
||||||
|
|
||||||
|
|
||||||
|
def _plugin_tree(root, sport):
|
||||||
|
source = (root / f"{sport}-scoreboard" / "sports.py").read_text(encoding="utf-8")
|
||||||
|
return source, ast.parse(source)
|
||||||
|
|
||||||
|
|
||||||
|
OURS = _class(ast.parse(Path(sports_view_model.__file__).read_text(encoding="utf-8")), MIXIN)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("sport, name", [(s, n) for s in SPORTS for n in METHODS])
|
||||||
|
def test_every_remaining_plugin_copy_matches(sport, name):
|
||||||
|
source, tree = _plugin_tree(_plugins_root(), sport)
|
||||||
|
copy = _method(_class(tree, CARRIER), name)
|
||||||
|
if copy is None:
|
||||||
|
assert _adopted(source, tree), (
|
||||||
|
f"{sport}: no {name} on {CARRIER}, and {CARRIER} does not inherit {MIXIN}")
|
||||||
|
else:
|
||||||
|
assert _fingerprint(copy) == _fingerprint(_method(OURS, name)), (
|
||||||
|
f"{CARRIER}.{name} in {sport} differs from sports_view_model. "
|
||||||
|
f"Port the change to both, or stop treating it as shared.")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("sport, name", [(s, n) for s in SPORTS for n in CONSTANTS])
|
||||||
|
def test_every_remaining_status_set_matches(sport, name):
|
||||||
|
source, tree = _plugin_tree(_plugins_root(), sport)
|
||||||
|
copy = _constant(_class(tree, CARRIER), name)
|
||||||
|
if copy is None:
|
||||||
|
assert _adopted(source, tree), (
|
||||||
|
f"{sport}: no {name} on {CARRIER}, and {CARRIER} does not inherit {MIXIN}")
|
||||||
|
else:
|
||||||
|
assert copy == _constant(OURS, name), f"{CARRIER}.{name} in {sport} differs"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("sport", SPORTS)
|
||||||
|
def test_no_other_plugin_class_carries_a_copy(sport):
|
||||||
|
"""A copy on another class would shadow the shared one for that class."""
|
||||||
|
_, tree = _plugin_tree(_plugins_root(), sport)
|
||||||
|
strays = [f"{node.name}.{name}" for node in tree.body if isinstance(node, ast.ClassDef)
|
||||||
|
for name in METHODS if node.name != CARRIER and _method(node, name) is not None]
|
||||||
|
assert strays == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_drift_report_calls_them_identical():
|
||||||
|
families = DRIFT.build(_plugins_root(), ("sports.py",))
|
||||||
|
rows = {(r["file"], r["family"]): r
|
||||||
|
for r in (DRIFT.summarise(k, v) for k, v in families.items())}
|
||||||
|
for name in METHODS:
|
||||||
|
row = rows.get(("sports.py", name))
|
||||||
|
assert row is None or row["worst_class_variants"] == 1, name
|
||||||
@@ -4,6 +4,7 @@ from html.parser import HTMLParser
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Strict allowlists for URL-derived values used in path and script operations.
|
# Strict allowlists for URL-derived values used in path and script operations.
|
||||||
@@ -487,9 +488,25 @@ def _load_general_partial():
|
|||||||
main_config=main_config,
|
main_config=main_config,
|
||||||
auto_update_status=auto_update_status,
|
auto_update_status=auto_update_status,
|
||||||
update_channel_status=update_channel_status,
|
update_channel_status=update_channel_status,
|
||||||
|
display_autostart=_display_autostart_state(),
|
||||||
web_login=_web_login_state())
|
web_login=_web_login_state())
|
||||||
|
|
||||||
|
|
||||||
|
def _display_autostart_state():
|
||||||
|
"""Whether ledmatrix.service starts at boot; None hides the toggle.
|
||||||
|
|
||||||
|
systemd's enablement is the setting itself (the installer enables the
|
||||||
|
unit), so nothing is stored in config.json. None when it cannot be read:
|
||||||
|
no systemctl (a dev machine) or the unit is not installed.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(['systemctl', 'is-enabled', 'ledmatrix.service'],
|
||||||
|
capture_output=True, text=True, timeout=5)
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
return None
|
||||||
|
return {'enabled': True, 'disabled': False}.get(result.stdout.strip())
|
||||||
|
|
||||||
|
|
||||||
def _web_login_state():
|
def _web_login_state():
|
||||||
"""What the General tab's Security section shows; None hides it.
|
"""What the General tab's Security section shows; None hides it.
|
||||||
|
|
||||||
|
|||||||
@@ -191,6 +191,28 @@ export function revokeToken(ctx, id, name) {
|
|||||||
}).catch(failed(ctx));
|
}).catch(failed(ctx));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The "Start Display on Boot" checkbox: enable or disable ledmatrix.service
|
||||||
|
* at once. A refusal (e.g. the sudo rule is missing) puts the box back.
|
||||||
|
*/
|
||||||
|
export function setDisplayAutostart(ctx, box) {
|
||||||
|
const on = box.checked;
|
||||||
|
box.disabled = true;
|
||||||
|
return send(ctx, 'POST', '/api/v3/system/action', { action: on ? 'enable_autostart' : 'disable_autostart' }).then(function(res) {
|
||||||
|
if (!res) return false;
|
||||||
|
const ok = res.ok && res.d.status === 'success';
|
||||||
|
if (!ok) box.checked = !on;
|
||||||
|
ctx.notify(ok ? (on ? 'The display will start on boot' : 'The display will not start on boot')
|
||||||
|
: (res.d.message || 'Could not change display autostart'), ok ? 'success' : 'error');
|
||||||
|
return ok;
|
||||||
|
}).catch(function(err) {
|
||||||
|
box.checked = !on;
|
||||||
|
failed(ctx)(err);
|
||||||
|
}).finally(function() {
|
||||||
|
box.disabled = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const FORM_ACTIONS = { 'set-password': setPassword, 'disable-login': disableLogin, 'create-token': createToken };
|
const FORM_ACTIONS = { 'set-password': setPassword, 'disable-login': disableLogin, 'create-token': createToken };
|
||||||
|
|
||||||
function drawTimezone(root, ctx) {
|
function drawTimezone(root, ctx) {
|
||||||
@@ -238,6 +260,10 @@ export function init(root, ctx) {
|
|||||||
if (row) revokeToken(ctx, row.dataset.tokenId, button.dataset.tokenName);
|
if (row) revokeToken(ctx, row.dataset.tokenId, button.dataset.tokenName);
|
||||||
}
|
}
|
||||||
}, on);
|
}, on);
|
||||||
|
root.addEventListener('change', function(event) {
|
||||||
|
const box = event.target;
|
||||||
|
if (box && box.dataset && box.dataset.action === 'display-autostart') setDisplayAutostart(ctx, box);
|
||||||
|
}, on);
|
||||||
drawTimezone(root, ctx);
|
drawTimezone(root, ctx);
|
||||||
active = ctx;
|
active = ctx;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,22 @@
|
|||||||
|
|
||||||
{{ ui.settings_filter() }}
|
{{ ui.settings_filter() }}
|
||||||
|
|
||||||
|
{# Display autostart is systemd's enable/disable of ledmatrix.service, not a
|
||||||
|
config.json key: outside the form so Save never posts it. general.js
|
||||||
|
applies it on change. Hidden when the unit's state cannot be read. #}
|
||||||
|
{% if display_autostart is not none %}
|
||||||
|
<div class="form-group mb-6" id="setting-general-display_autostart" data-setting-key="display_autostart">
|
||||||
|
<label class="flex items-center">
|
||||||
|
<input type="checkbox"
|
||||||
|
data-action="display-autostart"
|
||||||
|
{% if display_autostart %}checked{% endif %}
|
||||||
|
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||||||
|
<span class="ml-2 text-sm font-medium text-gray-900">Start Display on Boot</span>
|
||||||
|
{{ ui.help_tip('Start the LED display automatically when the device powers on.\nApplies right away; no Save needed.\nWhen off, start it with Start Display on the Overview tab. The web interface still starts on boot.\nDefault: on.', 'Start Display on Boot') }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<form hx-post="/api/v3/config/main"
|
<form hx-post="/api/v3/config/main"
|
||||||
hx-ext="json-enc"
|
hx-ext="json-enc"
|
||||||
hx-headers='{"Content-Type": "application/json"}'
|
hx-headers='{"Content-Type": "application/json"}'
|
||||||
|
|||||||
Reference in New Issue
Block a user