mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da9a999102 | ||
|
|
1e4c890d59 | ||
|
|
7f96075076 | ||
|
|
439013b18c | ||
|
|
e5bbfa2ae3 | ||
|
|
db49275075 | ||
|
|
a11412dabb | ||
|
|
fe5bed2886 |
@@ -19,6 +19,90 @@ accepts both, but the store flags the old spelling as deprecated
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
## 3.7.0
|
||||||
|
|
||||||
|
Sports consolidation stage 3 (#672). No behaviour change: nothing in core
|
||||||
|
uses these yet, and the scoreboards adopt them when they floor on 3.7.0.
|
||||||
|
|
||||||
|
### New modules
|
||||||
|
|
||||||
|
A plugin may import these via `src.*` (floor on 3.7.0). All three hold code
|
||||||
|
the scoreboard plugins carry as identical copies, moved without behaviour
|
||||||
|
change under the plugins' own method names; each docstring lists what the
|
||||||
|
host class must provide. The plugins delete their copies when they floor on
|
||||||
|
3.7.0.
|
||||||
|
|
||||||
|
- `src/common/sports_celebration.py` — `SportsCelebrationMixin`, the
|
||||||
|
score/win celebration takeover drawn by afl, football, hockey, nrl and
|
||||||
|
soccer (`_draw_celebration_layout` and the palette, backdrop, scenery,
|
||||||
|
confetti and crest steps behind it), plus its colour helpers as free
|
||||||
|
functions: `logo_palette`, `lift_color`, `cap_luminance`, `mix_color`,
|
||||||
|
`scale_color`, `dim_rgba`, `rgb_luminance`, `rgb_saturation`,
|
||||||
|
`color_distance`. Only the drawing: when to celebrate, the phrase and the
|
||||||
|
scenery stay in each plugin.
|
||||||
|
- `src/common/sports_fetch.py` — `SportsFetchMixin`, four `SportsCore`
|
||||||
|
methods identical in all nine scoreboards: `_fetch_season_directly`,
|
||||||
|
`_background_fetches_espn_ranges`, `_needs_previous_day` and
|
||||||
|
`_wants_live_odds` (with `_LOOKBACK_CUTOFF_HOUR` and
|
||||||
|
`_LIVE_ODDS_LOOKAHEAD`).
|
||||||
|
- `src/common/sports_card_wrappers.py` — `SportsCardWrappersMixin`, the
|
||||||
|
seventeen `sports_card` delegations the eight scoreboard game renderers
|
||||||
|
carry (`_vs_text`, `_element_color`, `_format_game_date`, ...): the methods
|
||||||
|
`SportsGameRendererMixin` expects its host to provide.
|
||||||
|
|
||||||
|
## 3.6.2
|
||||||
|
|
||||||
|
A fix to `src.common.favorite_team_check` (#670).
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- The favourite-team check no longer says the Europa League season has
|
||||||
|
finished between matchdays. Its scoreboard keeps showing the last matchday,
|
||||||
|
and its calendar is a "list" of rounds rather than match days, so neither
|
||||||
|
3.6.1 rule applied. When every event is past, a round in a list calendar
|
||||||
|
that has not started yet (outside an offseason phase) now draws no
|
||||||
|
conclusion. PLL, the World Cup and AFL, whose seasons are over, are still
|
||||||
|
reported as finished: no round of theirs is still to start. (#670)
|
||||||
|
|
||||||
|
## 3.6.1
|
||||||
|
|
||||||
|
A fix to `src.common.favorite_team_check` (#667). Plugins that drop their
|
||||||
|
bundled copy of it should floor on 3.6.1, not 3.6.0.
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- The favourite-team check no longer logs "the season has finished" for a
|
||||||
|
league that is still playing. ESPN's default scoreboard keeps showing the
|
||||||
|
last slate after it: MLB's regular-season games two days into the
|
||||||
|
postseason, a soccer league's previous matchday between rounds. When every
|
||||||
|
event is in the past, the check now looks first at the league's phase (a
|
||||||
|
regular season or postseason that has moved past the events shown draws no
|
||||||
|
conclusion) and at a match-day calendar (`calendarType` "day" with
|
||||||
|
`calendarIsWhitelist`, as soccer, the NHL and the NBA use), whose next date
|
||||||
|
becomes "nothing on until <date>". An offseason, or a payload without these
|
||||||
|
fields, is reported as before.
|
||||||
|
|
||||||
|
## 3.6.0
|
||||||
|
|
||||||
|
New modules a plugin may import via `src.*` (floor on 3.6.0). Both are
|
||||||
|
promoted from files the scoreboard plugins carry as copies; the plugins keep
|
||||||
|
their copies as a fallback until they floor on 3.6.0. No other change since
|
||||||
|
3.5.0.
|
||||||
|
|
||||||
|
- `src/common/favorite_team_check.py` — `FavoriteTeamCheck(logger, leagues)`:
|
||||||
|
checks configured favourite team codes against ESPN once per league, on a
|
||||||
|
daemon thread, and logs why a league shows nothing (a wrong code, with the
|
||||||
|
nearest real one, or a season that has not started). The seven copies
|
||||||
|
(`<sport>_favorite_check.py`) were byte-identical; this is the same code,
|
||||||
|
with type annotations added.
|
||||||
|
- `src/common/sports_timezone.py` — `resolve_timezone_name()` /
|
||||||
|
`resolve_timezone()` (plus `system_timezone_name()`): the timezone a
|
||||||
|
scoreboard draws start times in. The ten copies (`<sport>_timezone.py`)
|
||||||
|
differed only in two values, which are keyword-only arguments here:
|
||||||
|
`plugin_label` (named in the warning logged when nothing resolves) and
|
||||||
|
`writeback_fixed_in` (for a plugin that once wrote `"UTC"` back into the
|
||||||
|
saved config; `None` otherwise). Same resolution order and log messages.
|
||||||
|
|
||||||
## 3.5.0
|
## 3.5.0
|
||||||
|
|
||||||
New modules a plugin may import via `src.*` (floor on 3.5.0):
|
New modules a plugin may import via `src.*` (floor on 3.5.0):
|
||||||
|
|||||||
@@ -81,6 +81,11 @@ more. Shared sports code lives in `src/common`:
|
|||||||
| `sports_shared.py` | 3.3.0 | `SportsCoreSharedMixin`, `SportsLiveSharedMixin`, `SportsRecentSharedMixin` — the sport-independent `sports.py` methods |
|
| `sports_shared.py` | 3.3.0 | `SportsCoreSharedMixin`, `SportsLiveSharedMixin`, `SportsRecentSharedMixin` — the sport-independent `sports.py` methods |
|
||||||
| `sports_helpers.py` | 3.5.0 | clamp/logo/rotation free functions and `SportsHelpersMixin`, plus the `_favorite_key` seam |
|
| `sports_helpers.py` | 3.5.0 | clamp/logo/rotation free functions and `SportsHelpersMixin`, plus the `_favorite_key` seam |
|
||||||
| `espn_dates.py` | 3.5.0 | ESPN date-range and `limit` workarounds |
|
| `espn_dates.py` | 3.5.0 | ESPN date-range and `limit` workarounds |
|
||||||
|
| `favorite_team_check.py` | 3.6.0 | `FavoriteTeamCheck` — logs why a favourite team code shows nothing |
|
||||||
|
| `sports_timezone.py` | 3.6.0 | Which timezone start times are drawn in (`resolve_timezone_name`) |
|
||||||
|
| `sports_celebration.py` | 3.7.0 | `SportsCelebrationMixin` — draws the score/win takeover; colour helpers |
|
||||||
|
| `sports_fetch.py` | 3.7.0 | `SportsFetchMixin` — season fetch, live lookback and live-odds decisions |
|
||||||
|
| `sports_card_wrappers.py` | 3.7.0 | `SportsCardWrappersMixin` — the game renderer's `sports_card` delegations |
|
||||||
|
|
||||||
Each is described in [src/common/README.md](../src/common/README.md).
|
Each is described in [src/common/README.md](../src/common/README.md).
|
||||||
|
|
||||||
@@ -166,6 +171,15 @@ Mix it in **before** the mode class — `class SoccerLive(CelebrationMixin,
|
|||||||
SportsLive)` — so the celebration `display()` runs first and falls through to
|
SportsLive)` — so the celebration `display()` runs first and falls through to
|
||||||
the scorebug via `super()`.
|
the scorebug via `super()`.
|
||||||
|
|
||||||
|
What shipped is narrower. `src/common/sports_celebration.py`
|
||||||
|
(`SportsCelebrationMixin`) holds only the drawing, which is identical in the
|
||||||
|
five scoreboards that celebrate (afl, football, hockey, nrl, soccer — hockey
|
||||||
|
grew celebrations after this was written). Arming a celebration stays in each
|
||||||
|
plugin: the trigger bodies differ (nrl matches favourites by team id, football
|
||||||
|
folds a touchdown's extra point into one celebration and picks scenery by
|
||||||
|
points), and so does `display()`. The seams above were not needed to move the
|
||||||
|
drawing, so none was added.
|
||||||
|
|
||||||
**Rotation strategies.** The three "dialects" turned out to be one algorithm
|
**Rotation strategies.** The three "dialects" turned out to be one algorithm
|
||||||
(Smooth Weighted Round-Robin) in two shapes: an incremental picker holding state
|
(Smooth Weighted Round-Robin) in two shapes: an incremental picker holding state
|
||||||
across calls (afl/nrl/soccer) and a precomputed per-cycle list
|
across calls (afl/nrl/soccer) and a precomputed per-cycle list
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ src/common/__init__.py
|
|||||||
src/common/api_helper.py
|
src/common/api_helper.py
|
||||||
src/common/bdf_font.py
|
src/common/bdf_font.py
|
||||||
src/common/espn_dates.py
|
src/common/espn_dates.py
|
||||||
|
src/common/favorite_team_check.py
|
||||||
src/common/font_layout.py
|
src/common/font_layout.py
|
||||||
src/common/frame_timing.py
|
src/common/frame_timing.py
|
||||||
src/common/json_body.py
|
src/common/json_body.py
|
||||||
@@ -31,7 +32,11 @@ src/common/render_gate.py
|
|||||||
src/common/scroll_config.py
|
src/common/scroll_config.py
|
||||||
src/common/snapshot_policy.py
|
src/common/snapshot_policy.py
|
||||||
src/common/sports_card.py
|
src/common/sports_card.py
|
||||||
|
src/common/sports_card_wrappers.py
|
||||||
|
src/common/sports_celebration.py
|
||||||
|
src/common/sports_fetch.py
|
||||||
src/common/sports_scroll.py
|
src/common/sports_scroll.py
|
||||||
|
src/common/sports_timezone.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.5.0"
|
__version__ = "3.7.0"
|
||||||
|
|
||||||
|
|||||||
+58
-3
@@ -24,11 +24,12 @@ Rules for the package:
|
|||||||
| Module | For | Plugins import it? | Since |
|
| Module | For | Plugins import it? | Since |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| [`api_helper`](#api_helper) | HTTP GET/POST with caching and rate limiting | Yes | — |
|
| [`api_helper`](#api_helper) | HTTP GET/POST with caching and rate limiting | Yes | — |
|
||||||
| [`bdf_font`](#bdf_font) | Load and draw BDF bitmap fonts | Yes, if drawing BDF text directly | Unreleased |
|
| [`bdf_font`](#bdf_font) | Load and draw BDF bitmap fonts | Yes, if drawing BDF text directly | 3.5.0 |
|
||||||
| [`espn_dates`](#espn_dates) | Fetch ESPN scoreboards across a date range | Yes (scoreboards) | 3.5.0 |
|
| [`espn_dates`](#espn_dates) | Fetch ESPN scoreboards across a date range | Yes (scoreboards) | 3.5.0 |
|
||||||
|
| [`favorite_team_check`](#favorite_team_check) | Log why a favourite team code shows nothing | Yes (scoreboards) | 3.6.0 |
|
||||||
| [`font_layout`](#font_layout) | Reproducible TrueType loading, crisp sizes | Yes | 3.4.0 |
|
| [`font_layout`](#font_layout) | Reproducible TrueType loading, crisp sizes | Yes | 3.4.0 |
|
||||||
| [`frame_timing`](#frame_timing) | Timing of every presented frame, stall watchdog | No, core-internal | n/a |
|
| [`frame_timing`](#frame_timing) | Timing of every presented frame, stall watchdog | No, core-internal | n/a |
|
||||||
| [`json_body`](#json_body) | Parse a response body as JSON, with orjson if installed | Optional (large payloads) | Unreleased |
|
| [`json_body`](#json_body) | Parse a response body as JSON, with orjson if installed | Optional (large payloads) | 3.5.0 |
|
||||||
| [`logo_helper`](#logo_helper) | Load, resize and cache team logos | Yes | — |
|
| [`logo_helper`](#logo_helper) | Load, resize and cache team logos | Yes | — |
|
||||||
| [`path_safety`](#path_safety) | Turn request-supplied names into safe paths | No, core-internal | n/a |
|
| [`path_safety`](#path_safety) | Turn request-supplied names into safe paths | No, core-internal | n/a |
|
||||||
| [`permission_utils`](#permission_utils) | File modes and shared-group ownership | Rarely | — |
|
| [`permission_utils`](#permission_utils) | File modes and shared-group ownership | Rarely | — |
|
||||||
@@ -37,14 +38,18 @@ Rules for the package:
|
|||||||
| [`scroll_helper`](#scroll_helper) | Pre-rendered horizontal scrolling | Yes | — |
|
| [`scroll_helper`](#scroll_helper) | Pre-rendered horizontal scrolling | Yes | — |
|
||||||
| [`snapshot_policy`](#snapshot_policy) | When to write the web preview frame | No, core-internal | n/a |
|
| [`snapshot_policy`](#snapshot_policy) | When to write the web preview frame | No, core-internal | n/a |
|
||||||
| [`sports_card`](#sports_card) | Scoreboard card settings, colours, fonts, dates | Yes (scoreboards) | 3.3.0 |
|
| [`sports_card`](#sports_card) | Scoreboard card settings, colours, fonts, dates | Yes (scoreboards) | 3.3.0 |
|
||||||
|
| [`sports_card_wrappers`](#sports_card_wrappers) | The game renderer's `sports_card` delegations | Yes (scoreboards) | 3.7.0 |
|
||||||
|
| [`sports_celebration`](#sports_celebration) | Draw a scoreboard's score/win celebration | Yes (scoreboards) | 3.7.0 |
|
||||||
|
| [`sports_fetch`](#sports_fetch) | Scoreboard season fetch, lookback and live-odds decisions | Yes (scoreboards) | 3.7.0 |
|
||||||
| [`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_scroll`](#sports_scroll) | Scoreboard scroll-display orchestration | Yes (scoreboards) | 3.2.0 |
|
| [`sports_scroll`](#sports_scroll) | Scoreboard scroll-display orchestration | Yes (scoreboards) | 3.2.0 |
|
||||||
| [`sports_shared`](#sports_shared) | Sport-independent `sports.py` methods | Yes (scoreboards) | 3.3.0 |
|
| [`sports_shared`](#sports_shared) | Sport-independent `sports.py` methods | Yes (scoreboards) | 3.3.0 |
|
||||||
|
| [`sports_timezone`](#sports_timezone) | Which timezone a scoreboard draws start times in | Yes (scoreboards) | 3.6.0 |
|
||||||
| [`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 | — |
|
||||||
|
|
||||||
The four `sports_*` mixin and card modules hold code the scoreboard plugins
|
The `sports_*` mixin and card modules hold code the scoreboard plugins
|
||||||
used to carry as identical copies. Each module docstring lists what a host
|
used to carry as identical copies. Each module docstring lists what a host
|
||||||
class must provide. The plan behind them is in
|
class must provide. The plan behind them is in
|
||||||
[docs/SPORTS_UNIFICATION.md](../../docs/SPORTS_UNIFICATION.md).
|
[docs/SPORTS_UNIFICATION.md](../../docs/SPORTS_UNIFICATION.md).
|
||||||
@@ -100,6 +105,17 @@ results; `espn_date_chunks()`, `fetch_espn_date_chunks()`,
|
|||||||
`clamp_espn_limit()` and `merge_scoreboard_payloads()` are the pieces.
|
`clamp_espn_limit()` and `merge_scoreboard_payloads()` are the pieces.
|
||||||
Scoreboard plugins also bundle a copy for older cores.
|
Scoreboard plugins also bundle a copy for older cores.
|
||||||
|
|
||||||
|
### favorite_team_check
|
||||||
|
|
||||||
|
[`favorite_team_check.py`](favorite_team_check.py).
|
||||||
|
`FavoriteTeamCheck(logger, leagues)`, where `leagues` maps a league key to
|
||||||
|
`(display name, ESPN sport/league path)`. `schedule(league_key, favorites)`
|
||||||
|
checks the configured favourite team codes against ESPN's team list once per
|
||||||
|
league, on a daemon thread, and logs a bad code with the nearest real one, or
|
||||||
|
says the league has nothing on yet; `reset()` re-arms it after a config edit.
|
||||||
|
Diagnostics only: every failure is swallowed. Scoreboard plugins also bundle
|
||||||
|
a copy for older cores.
|
||||||
|
|
||||||
### font_layout
|
### font_layout
|
||||||
|
|
||||||
[`font_layout.py`](font_layout.py). `load_truetype(path, size)` is
|
[`font_layout.py`](font_layout.py). `load_truetype(path, size)` is
|
||||||
@@ -203,6 +219,33 @@ dates (`format_game_date()`, `format_game_time()`, `card_tzinfo()`) and font
|
|||||||
sizes (`schema_font_size()`, `resolve_font_size()`). A plugin keeps its own
|
sizes (`schema_font_size()`, `resolve_font_size()`). A plugin keeps its own
|
||||||
method and delegates the body.
|
method and delegates the body.
|
||||||
|
|
||||||
|
### sports_card_wrappers
|
||||||
|
|
||||||
|
[`sports_card_wrappers.py`](sports_card_wrappers.py).
|
||||||
|
`SportsCardWrappersMixin`: the one-line methods a scoreboard's game renderer
|
||||||
|
uses to call `sports_card` with its own `config` and `logger`
|
||||||
|
(`_vs_text()`, `_element_color()`, `_format_game_date()`, ... seventeen in
|
||||||
|
all), under their existing names. They are what `sports_game_renderer`'s
|
||||||
|
mixin expects its host to provide. No `__init__` and no state.
|
||||||
|
|
||||||
|
### sports_celebration
|
||||||
|
|
||||||
|
[`sports_celebration.py`](sports_celebration.py). `SportsCelebrationMixin`
|
||||||
|
draws the full-screen takeover a scoreboard shows when a team scores or wins
|
||||||
|
(`_draw_celebration_layout(celebration)`): a backdrop in the scoring team's
|
||||||
|
colours read off its crest, scenery, confetti, the headline and the score.
|
||||||
|
The colour helpers are free functions (`logo_palette()`, `lift_color()`,
|
||||||
|
`mix_color()`, ...). Deciding *when* to celebrate stays in the plugin, which
|
||||||
|
builds the celebration dict the docstring describes.
|
||||||
|
|
||||||
|
### sports_fetch
|
||||||
|
|
||||||
|
[`sports_fetch.py`](sports_fetch.py). `SportsFetchMixin`: the `SportsCore`
|
||||||
|
methods that decide which requests a scoreboard makes --
|
||||||
|
`_fetch_season_directly()` (a season, in chunks ESPN accepts),
|
||||||
|
`_background_fetches_espn_ranges()`, `_needs_previous_day()` (the live
|
||||||
|
lookback) and `_wants_live_odds()` (odds only for games near the screen).
|
||||||
|
|
||||||
### sports_game_renderer
|
### sports_game_renderer
|
||||||
|
|
||||||
[`sports_game_renderer.py`](sports_game_renderer.py).
|
[`sports_game_renderer.py`](sports_game_renderer.py).
|
||||||
@@ -237,6 +280,18 @@ fonts, colours, dates, the switch-mode upcoming card). The docstring lists
|
|||||||
the attributes the host class must have and the three methods deliberately
|
the attributes the host class must have and the three methods deliberately
|
||||||
left out.
|
left out.
|
||||||
|
|
||||||
|
### sports_timezone
|
||||||
|
|
||||||
|
[`sports_timezone.py`](sports_timezone.py).
|
||||||
|
`resolve_timezone_name(config, plugin_manager, cache_manager, log, *,
|
||||||
|
plugin_label, writeback_fixed_in=None)` and `resolve_timezone(...)` (the same
|
||||||
|
as a pytz zone): the plugin's own `timezone`, then the global one via either
|
||||||
|
manager's `config_manager`, then the host's zone (`system_timezone_name()`),
|
||||||
|
then UTC. `plugin_label` names the plugin in the warning logged when nothing
|
||||||
|
resolves; `writeback_fixed_in` is for a plugin that once wrote `"UTC"` into
|
||||||
|
the saved config (a bare plugin-level `"UTC"` is then ignored when another
|
||||||
|
source disagrees). Scoreboard plugins also bundle a copy for older cores.
|
||||||
|
|
||||||
### sync_manager
|
### sync_manager
|
||||||
|
|
||||||
[`sync_manager.py`](sync_manager.py). Core-internal. `DisplaySyncManager`
|
[`sync_manager.py`](sync_manager.py). Core-internal. `DisplaySyncManager`
|
||||||
|
|||||||
@@ -0,0 +1,409 @@
|
|||||||
|
"""
|
||||||
|
Explain an empty screen: a wrong team code, or a season that has not started.
|
||||||
|
|
||||||
|
Favourite teams are matched by exact ESPN abbreviation, so a plausible-looking
|
||||||
|
code silently matches nothing and the plugin shows an empty screen with no hint
|
||||||
|
that the code is at fault. The codes are not always guessable — ESPN calls
|
||||||
|
Alabama ``ALA`` rather than ``BAMA``, and Golden State ``GS`` rather than
|
||||||
|
``GSW``. Between seasons a perfectly correct code produces the same empty
|
||||||
|
screen for a completely different reason, and the two were indistinguishable
|
||||||
|
from the logs.
|
||||||
|
|
||||||
|
This module is diagnostics only. It runs on a daemon thread, once per league per
|
||||||
|
process, and every failure is swallowed: it must never delay a frame or change
|
||||||
|
what is displayed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import difflib
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Dict, Iterable, List, Optional, Set, Tuple
|
||||||
|
|
||||||
|
TEAMS_URL = "https://site.api.espn.com/apis/site/v2/sports/{path}/teams?limit=1000"
|
||||||
|
SCOREBOARD_URL = "https://site.api.espn.com/apis/site/v2/sports/{path}/scoreboard"
|
||||||
|
REQUEST_TIMEOUT = 15
|
||||||
|
|
||||||
|
|
||||||
|
class FavoriteTeamCheck:
|
||||||
|
"""
|
||||||
|
Validates configured favourite team codes against ESPN, and says so in the log.
|
||||||
|
|
||||||
|
``leagues`` maps the plugin's own league key to a
|
||||||
|
``(human readable name, ESPN sport/league path)`` pair, e.g.
|
||||||
|
``{'nhl': ('NHL', 'hockey/nhl')}``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# How far out the next fixture has to be before it is worth mentioning.
|
||||||
|
# An off day or two is normal mid-season and saying so would just be noise.
|
||||||
|
GAP_DAYS = 3
|
||||||
|
|
||||||
|
def __init__(self, logger: Optional[logging.Logger],
|
||||||
|
leagues: Dict[str, Tuple[str, str]]) -> None:
|
||||||
|
self.logger = logger or logging.getLogger(__name__)
|
||||||
|
self.leagues = leagues
|
||||||
|
self._checked: Set[str] = set()
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
"""Re-check on the next call, e.g. after the user edits the config."""
|
||||||
|
with self._lock:
|
||||||
|
self._checked.clear()
|
||||||
|
|
||||||
|
def schedule(self, league_key: str, favorites: Iterable[str]) -> None:
|
||||||
|
"""Check one league in the background, at most once per process."""
|
||||||
|
try:
|
||||||
|
favorites = [str(f) for f in (favorites or []) if str(f).strip()]
|
||||||
|
if not favorites or league_key not in self.leagues:
|
||||||
|
return
|
||||||
|
with self._lock:
|
||||||
|
if league_key in self._checked:
|
||||||
|
return
|
||||||
|
self._checked.add(league_key)
|
||||||
|
threading.Thread(
|
||||||
|
target=self._run, args=(league_key, favorites),
|
||||||
|
name="favorite-team-check", daemon=True,
|
||||||
|
).start()
|
||||||
|
except Exception:
|
||||||
|
pass # nosec B110 - a diagnostic must never be the reason an update fails # nosemgrep
|
||||||
|
|
||||||
|
def _run(self, league_key: str, favorites) -> None:
|
||||||
|
try:
|
||||||
|
self._check(league_key, favorites)
|
||||||
|
except Exception as exc:
|
||||||
|
self.logger.debug("Favorite team check failed for %s: %s",
|
||||||
|
league_key, exc)
|
||||||
|
|
||||||
|
def _check(self, league_key: str, favorites) -> None:
|
||||||
|
name, path = self.leagues[league_key]
|
||||||
|
|
||||||
|
try:
|
||||||
|
teams = self._fetch_teams(path)
|
||||||
|
except Exception as exc:
|
||||||
|
self.logger.debug("Could not verify %s favorite teams: %s", name, exc)
|
||||||
|
return
|
||||||
|
if not teams:
|
||||||
|
# Some ESPN endpoints (college lacrosse) return no teams at all.
|
||||||
|
# Nothing can be concluded, so say nothing.
|
||||||
|
return
|
||||||
|
|
||||||
|
# Dynamic groups like AP_TOP_25 are expanded elsewhere; they are not
|
||||||
|
# team codes and must not be reported as bad ones.
|
||||||
|
codes = [f for f in favorites if not self._is_dynamic(f)]
|
||||||
|
recognised = [f for f in codes if f in teams]
|
||||||
|
unknown = [f for f in codes if f not in teams]
|
||||||
|
|
||||||
|
for code in unknown:
|
||||||
|
self.logger.warning(
|
||||||
|
"%s favorite team %r is not a %s team code.%s "
|
||||||
|
"Every code this league accepts is listed at %s.",
|
||||||
|
name, code, name, self._suggest(code, teams),
|
||||||
|
TEAMS_URL.format(path=path),
|
||||||
|
)
|
||||||
|
|
||||||
|
if codes and not recognised:
|
||||||
|
self.logger.warning(
|
||||||
|
"%s has no recognised favorite teams, so nothing will be shown "
|
||||||
|
"for it. Codes must be ESPN abbreviations, e.g. %s.",
|
||||||
|
name, ", ".join("{} ({})".format(a, n)
|
||||||
|
for a, n in list(sorted(teams.items()))[:3]),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not recognised:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Codes are fine, so check the other cause of an empty screen.
|
||||||
|
try:
|
||||||
|
note = self._schedule_note(path)
|
||||||
|
except Exception as exc:
|
||||||
|
self.logger.debug("Could not check the %s schedule: %s", name, exc)
|
||||||
|
return
|
||||||
|
|
||||||
|
if note:
|
||||||
|
self.logger.info(
|
||||||
|
"%s favorite teams %s look correct, but %s. An empty display "
|
||||||
|
"until then is expected, not a configuration problem.",
|
||||||
|
name, ", ".join(recognised), note,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.logger.info("%s favorite teams recognised: %s",
|
||||||
|
name, ", ".join(recognised))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_dynamic(code: str) -> bool:
|
||||||
|
upper = (code or "").strip().upper()
|
||||||
|
return upper.startswith("AP_") or upper.startswith("TOP_") or "TOP_" in upper
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _fetch_teams(path: str) -> Dict[str, str]:
|
||||||
|
"""ESPN's {abbreviation: display name} for a league.
|
||||||
|
|
||||||
|
``limit=1000`` is required: the default page size truncates the NCAA
|
||||||
|
responses to roughly half their teams, which makes valid codes look wrong.
|
||||||
|
"""
|
||||||
|
import requests
|
||||||
|
|
||||||
|
payload = requests.get(TEAMS_URL.format(path=path),
|
||||||
|
timeout=REQUEST_TIMEOUT).json()
|
||||||
|
entries = payload['sports'][0]['leagues'][0]['teams']
|
||||||
|
return {
|
||||||
|
t['team']['abbreviation']: t['team']['displayName']
|
||||||
|
for t in entries if t.get('team', {}).get('abbreviation')
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _schedule_note(cls, path: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Why the league has nothing to show, as a clause, or ``None`` if it does.
|
||||||
|
|
||||||
|
Two things make this harder than reading ``events``:
|
||||||
|
|
||||||
|
* An out-of-season league does not come back empty. ESPN rolls the
|
||||||
|
scoreboard forward to the next day that has fixtures, so in July the
|
||||||
|
NHL endpoint returns seven September games. Emptiness cannot be the
|
||||||
|
signal; the date of those games is, and it is more useful anyway.
|
||||||
|
* A *finished* season rolls nowhere and returns its last game instead,
|
||||||
|
months in the past — so dates have to be filtered to the future
|
||||||
|
before the soonest one means anything.
|
||||||
|
"""
|
||||||
|
import requests
|
||||||
|
|
||||||
|
payload = requests.get(SCOREBOARD_URL.format(path=path),
|
||||||
|
timeout=REQUEST_TIMEOUT).json()
|
||||||
|
|
||||||
|
event_dates = [cls._parse_date(e.get('date'))
|
||||||
|
for e in payload.get('events') or []]
|
||||||
|
calendar_dates = []
|
||||||
|
for entry in (payload.get('leagues') or [{}])[0].get('calendar') or []:
|
||||||
|
calendar_dates.append(cls._parse_date(
|
||||||
|
entry if isinstance(entry, str) else entry.get('startDate')))
|
||||||
|
|
||||||
|
# Count the last day as current, rather than filtering on "later than
|
||||||
|
# right now": a game that began a few hours ago still means the league
|
||||||
|
# has something on, and dropping it would report a live slate as a
|
||||||
|
# finished season. A day's grace also keeps this correct whatever the
|
||||||
|
# user's timezone, since these timestamps are UTC.
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
def future(candidates):
|
||||||
|
return sorted(d for d in candidates if d and (now - d).days < 1)
|
||||||
|
|
||||||
|
# Events are fixtures; the calendar is week and phase boundaries,
|
||||||
|
# which routinely open days before their first game (an NFL week 1
|
||||||
|
# calendar entry starts the weekend before the Thursday opener).
|
||||||
|
# Reading the two together reported the earliest boundary as a game
|
||||||
|
# date -- "nothing on until 06 September" for a league whose first
|
||||||
|
# snap is the 10th. The calendar only gets a say when the scoreboard
|
||||||
|
# has no events at all to roll forward to: events that exist but are
|
||||||
|
# all in the past mean the season is over, and an offseason calendar
|
||||||
|
# phase must not be dressed up as its next game.
|
||||||
|
#
|
||||||
|
# The exception is a calendar of match days. With calendarType "day"
|
||||||
|
# and calendarIsWhitelist true, every entry is a day that has games,
|
||||||
|
# so a future entry is a real next fixture. Soccer needs it: between
|
||||||
|
# matchdays the scoreboard keeps showing the last one, so on
|
||||||
|
# 2026-09-29 every Premier League event was from 20 September and the
|
||||||
|
# next games (10 October) were only in the calendar. A day calendar
|
||||||
|
# that is not a whitelist (MLB's) lists days *without* games.
|
||||||
|
if any(event_dates):
|
||||||
|
upcoming = future(event_dates)
|
||||||
|
if not upcoming and cls._calendar_is_match_days(payload):
|
||||||
|
upcoming = future(calendar_dates)
|
||||||
|
else:
|
||||||
|
upcoming = future(calendar_dates)
|
||||||
|
if not upcoming:
|
||||||
|
if not any(event_dates) and not any(calendar_dates):
|
||||||
|
return None # Nothing published either way; draw no conclusion.
|
||||||
|
if cls._moved_to_later_phase(payload):
|
||||||
|
return None # e.g. postseason under way; see the method.
|
||||||
|
if cls._later_round_scheduled(payload, now):
|
||||||
|
return None # e.g. Europa League between matchdays.
|
||||||
|
return ("the season has finished and the next one's fixtures are "
|
||||||
|
"not published yet")
|
||||||
|
|
||||||
|
# A day or two out is just an off day, and saying so would be noise.
|
||||||
|
if (upcoming[0] - now).days < cls.GAP_DAYS:
|
||||||
|
return None
|
||||||
|
return "the league has nothing on until {}".format(
|
||||||
|
upcoming[0].strftime('%d %B %Y'))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _calendar_is_match_days(payload) -> bool:
|
||||||
|
"""Whether the league calendar lists the days that have games."""
|
||||||
|
league = (payload.get('leagues') or [{}])[0] or {}
|
||||||
|
return (league.get('calendarType') == 'day'
|
||||||
|
and league.get('calendarIsWhitelist') is True)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _moved_to_later_phase(payload) -> bool:
|
||||||
|
"""
|
||||||
|
Whether the league is in a later in-season phase than its events.
|
||||||
|
|
||||||
|
ESPN does not roll the scoreboard forward into a postseason. The day
|
||||||
|
after MLB's regular season ended, the default scoreboard still returned
|
||||||
|
that last regular-season day, while ``leagues[0].season`` already said
|
||||||
|
Postseason and the wild-card games were two days out. Past events alone
|
||||||
|
then read as a finished season while the same process's upcoming
|
||||||
|
manager was showing the favourite's playoff games.
|
||||||
|
|
||||||
|
Only regular season (2) and postseason (3) count as "later". The
|
||||||
|
offseason (4) follows the postseason too, and there past events really
|
||||||
|
do mean the season is over.
|
||||||
|
"""
|
||||||
|
season = ((payload.get('leagues') or [{}])[0] or {}).get('season') or {}
|
||||||
|
league_type = (season.get('type') or {}).get('type')
|
||||||
|
if league_type not in (2, 3):
|
||||||
|
return False
|
||||||
|
event_types = [(e.get('season') or {}).get('type')
|
||||||
|
for e in payload.get('events') or []]
|
||||||
|
known = [t for t in event_types if isinstance(t, int)]
|
||||||
|
return bool(known) and all(t < league_type for t in known)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _later_round_scheduled(cls, payload, now: datetime) -> bool:
|
||||||
|
"""
|
||||||
|
Whether a "list" calendar has a round that has not started yet.
|
||||||
|
|
||||||
|
Competitions with a list calendar (the UEFA club competitions, the
|
||||||
|
World Cup, AFL, NFL) give each phase its rounds as ``entries`` with
|
||||||
|
start and end dates. Between matchdays the Europa League scoreboard
|
||||||
|
keeps showing the last one: on 2026-09-29 every event was from 17
|
||||||
|
September, the next matchday was only days away, and the rounds from
|
||||||
|
the knockout play-offs to the final were all still to come. A round
|
||||||
|
that starts later means the season is not over, even though the
|
||||||
|
date of the next fixture is not known.
|
||||||
|
|
||||||
|
Only a round's *start* counts. End dates are padded well past the
|
||||||
|
last game -- the World Cup's final round ran to 1 August for a 19 July
|
||||||
|
final -- so a future end date is also true of a finished season.
|
||||||
|
Rounds in an offseason phase (the college football All-Star week)
|
||||||
|
are not games for the favourites and do not count either.
|
||||||
|
"""
|
||||||
|
league = (payload.get('leagues') or [{}])[0] or {}
|
||||||
|
for phase in league.get('calendar') or []:
|
||||||
|
if not isinstance(phase, dict) or cls._is_offseason(phase.get('label')):
|
||||||
|
continue
|
||||||
|
for entry in phase.get('entries') or []:
|
||||||
|
if not isinstance(entry, dict) or cls._is_offseason(entry.get('label')):
|
||||||
|
continue
|
||||||
|
start = cls._parse_date(entry.get('startDate'))
|
||||||
|
if start and start > now:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_offseason(label) -> bool:
|
||||||
|
"""'Off Season', 'Offseason', 'Off-season' ..."""
|
||||||
|
return isinstance(label, str) and 'offseason' in re.sub(
|
||||||
|
r'[^a-z]', '', label.lower())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_date(raw) -> Optional[datetime]:
|
||||||
|
if not raw or not isinstance(raw, str):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(raw.replace('Z', '+00:00'))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _suggest(cls, code: str, teams: Dict[str, str]) -> str:
|
||||||
|
"""Nearest matching code for a typo, as a ready-to-log clause."""
|
||||||
|
upper = (code or "").strip().upper()
|
||||||
|
if not upper or code in teams:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Right code, wrong case — matching is case-sensitive. Guard on the case
|
||||||
|
# actually differing, so a valid code never draws this message.
|
||||||
|
for abbr in teams:
|
||||||
|
if abbr.upper() == upper:
|
||||||
|
return " Codes are case-sensitive; use {!r} ({}).".format(
|
||||||
|
abbr, teams[abbr])
|
||||||
|
|
||||||
|
ranked = cls._rank(upper, (a for a, n in teams.items()
|
||||||
|
if cls._abbreviates(upper, n)), teams)
|
||||||
|
if not ranked:
|
||||||
|
# Nicknames are often a fragment of a word rather than its initials:
|
||||||
|
# 'BAMA' sits inside 'Alabama' but abbreviates nothing in it. Require
|
||||||
|
# three characters, since shorter fragments match far too much.
|
||||||
|
if len(upper) >= 3:
|
||||||
|
ranked = cls._rank(
|
||||||
|
upper,
|
||||||
|
(a for a, n in teams.items()
|
||||||
|
if any(upper in w for w in cls._words(n))),
|
||||||
|
teams)
|
||||||
|
|
||||||
|
if len(ranked) == 1:
|
||||||
|
return " Closest match is {!r} ({}).".format(
|
||||||
|
ranked[0], teams[ranked[0]])
|
||||||
|
if ranked:
|
||||||
|
return " Did you mean {}?".format(", ".join(
|
||||||
|
"{!r} ({})".format(a, teams[a]) for a in ranked[:3]))
|
||||||
|
|
||||||
|
# Otherwise fall back to similarity, against names before codes: a name
|
||||||
|
# gives more characters to compare and so produces fewer ties.
|
||||||
|
names = {n.upper(): a for a, n in teams.items()}
|
||||||
|
hits = difflib.get_close_matches(upper, list(names), n=1, cutoff=0.6)
|
||||||
|
if hits:
|
||||||
|
abbr = names[hits[0]]
|
||||||
|
return " Closest match is {!r} ({}).".format(abbr, teams[abbr])
|
||||||
|
|
||||||
|
code_hits = difflib.get_close_matches(upper, list(teams), n=1, cutoff=0.6)
|
||||||
|
if code_hits:
|
||||||
|
return " Closest match is {!r} ({}).".format(
|
||||||
|
code_hits[0], teams[code_hits[0]])
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _words(name: str):
|
||||||
|
return [w for w in re.split(r'[^A-Za-z0-9]+', (name or '').upper()) if w]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _rank(cls, code: str, candidates, teams: Dict[str, str]):
|
||||||
|
"""
|
||||||
|
Order candidate codes best-first.
|
||||||
|
|
||||||
|
A code that picks up the *first* word of the name wins, because that is
|
||||||
|
how people shorten team names: 'SCAR' for South Carolina starts at
|
||||||
|
'South', whereas for Rutgers Scarlet Knights it starts mid-name. Without
|
||||||
|
this the tie is broken alphabetically and the obvious answer can land
|
||||||
|
third in the list.
|
||||||
|
"""
|
||||||
|
def key(abbr):
|
||||||
|
words = cls._words(teams.get(abbr, ''))
|
||||||
|
first_word_hit = bool(words) and words[0].startswith(code[:1])
|
||||||
|
return (not first_word_hit, len(abbr), abbr)
|
||||||
|
|
||||||
|
return sorted(set(candidates), key=key)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _abbreviates(code: str, name: str) -> bool:
|
||||||
|
"""
|
||||||
|
Whether ``code`` reads as an abbreviation of ``name``.
|
||||||
|
|
||||||
|
Each part of the code must be a prefix of one of the name's words, taken
|
||||||
|
in order — which is how people actually shorten team names. Plain string
|
||||||
|
similarity is no use for three-letter codes: 'MUN' scores identically
|
||||||
|
against 'MAN' and 'SUN', so Manchester United and Sunderland tie and the
|
||||||
|
suggestion is a coin flip. This rule separates them, because 'MUN'
|
||||||
|
splits as M-anchester UN-ited while Sunderland has no word starting M.
|
||||||
|
"""
|
||||||
|
words = [w for w in re.split(r'[^A-Za-z0-9]+', (name or '').upper()) if w]
|
||||||
|
|
||||||
|
def consume(rest: str, remaining: List[str]) -> bool:
|
||||||
|
if not rest:
|
||||||
|
return True
|
||||||
|
if not remaining:
|
||||||
|
return False
|
||||||
|
head, tail = remaining[0], remaining[1:]
|
||||||
|
# Skip this word entirely, as in "Manchester United" -> "UTD".
|
||||||
|
if consume(rest, tail):
|
||||||
|
return True
|
||||||
|
for size in range(1, min(len(rest), len(head)) + 1):
|
||||||
|
if head.startswith(rest[:size]) and consume(rest[size:], tail):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
return consume((code or "").strip().upper(), words)
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
"""The ``sports_card`` delegations every scoreboard's game renderer carries.
|
||||||
|
|
||||||
|
After the card helpers moved to ``sports_card`` (3.3.0), each of the eight
|
||||||
|
scoreboards with a ``game_renderer.py`` -- afl, baseball, basketball,
|
||||||
|
football, hockey, lacrosse, nrl and soccer -- kept one-line methods that
|
||||||
|
forward to them with its own ``config`` and ``logger``. Seventeen are
|
||||||
|
identical in all eight (executable AST, docstrings stripped) or in all but
|
||||||
|
football, and were copied here from ledmatrix-plugins ``30455671``
|
||||||
|
(origin/main, 2026-09-29) under their existing names. Football's own
|
||||||
|
``_format_game_date`` and ``_upcoming_center_mode`` (they follow the
|
||||||
|
switch-mode settings when it draws the full-screen scorebug) stay in football
|
||||||
|
and override these.
|
||||||
|
|
||||||
|
``_schema_font_size`` and ``_resolve_font_size`` look the same in every copy
|
||||||
|
but are not moved: they read ``_SCHEMA_PATH``, a module global that is each
|
||||||
|
plugin's own ``config_schema.json``.
|
||||||
|
|
||||||
|
These are the methods ``SportsGameRendererMixin`` (``sports_game_renderer``)
|
||||||
|
lists among what its host must provide, so a renderer that inherits both no
|
||||||
|
longer has to write them. Like that mixin this has no ``__init__`` and no
|
||||||
|
state. It is a separate module rather than more methods there for the reason
|
||||||
|
``sports_helpers`` gives: a missing module fails at load, where the version
|
||||||
|
checks see it; a missing method fails mid-render.
|
||||||
|
|
||||||
|
WHAT A HOST MUST PROVIDE
|
||||||
|
------------------------
|
||||||
|
Derived by walking every ``self.<attr>`` the mixin reads; the host-contract
|
||||||
|
test in ``test/test_sports_card_wrappers.py`` fails if a read is added
|
||||||
|
without being listed here.
|
||||||
|
|
||||||
|
- ``config`` and ``logger``.
|
||||||
|
- ``fonts``, read with ``getattr`` -- ``_font_color``.
|
||||||
|
- ``_FONT_NAME_ALIASES`` and ``_FONT_PIXEL_GRID`` class attributes --
|
||||||
|
``_crisp_size``, which passes them to ``sports_card.crisp_size`` so a
|
||||||
|
renderer that declares extra faces keeps them.
|
||||||
|
|
||||||
|
Add it as a base of the plugin's renderer, e.g.
|
||||||
|
``class GameRenderer(SportsCardWrappersMixin, SportsGameRendererMixin)``.
|
||||||
|
The two define no name in common; a method on the plugin's own class still
|
||||||
|
wins over either.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any, ClassVar, Dict, Optional, Tuple
|
||||||
|
|
||||||
|
from src.common import sports_card as _card
|
||||||
|
|
||||||
|
|
||||||
|
class SportsCardWrappersMixin:
|
||||||
|
"""The game renderer's ``sports_card`` delegations. See module docstring."""
|
||||||
|
|
||||||
|
# The host contract, declared for type checking only: these create no
|
||||||
|
# attributes, so the host's own values are what the methods read.
|
||||||
|
config: Dict[str, Any]
|
||||||
|
logger: logging.Logger
|
||||||
|
_FONT_NAME_ALIASES: ClassVar[Dict[str, str]]
|
||||||
|
_FONT_PIXEL_GRID: ClassVar[Dict[str, Any]]
|
||||||
|
|
||||||
|
# ---- fonts ---------------------------------------------------------
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _crisp_size(cls, font_file, desired):
|
||||||
|
"""``sports_card.crisp_size`` with this renderer's font tables."""
|
||||||
|
return _card.crisp_size(font_file, desired,
|
||||||
|
cls._FONT_NAME_ALIASES, cls._FONT_PIXEL_GRID)
|
||||||
|
|
||||||
|
def _unshare_element_fonts(self, fonts):
|
||||||
|
"""``sports_card.unshare_element_fonts``."""
|
||||||
|
return _card.unshare_element_fonts(self.logger, fonts)
|
||||||
|
|
||||||
|
def _font_color(self, font, default: Tuple[int, int, int] = (255, 255, 255)):
|
||||||
|
"""``sports_card.font_color`` for one of ``self.fonts``."""
|
||||||
|
return _card.font_color(self.config, getattr(self, "fonts", None), font, default)
|
||||||
|
|
||||||
|
# ---- colours and favourites ---------------------------------------
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _coerce_rgb(value, fallback):
|
||||||
|
"""``sports_card.coerce_rgb``."""
|
||||||
|
return _card.coerce_rgb(value, fallback)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _side_is_favorite(game: Dict[str, Any], side: str, favorites: set) -> bool:
|
||||||
|
"""``sports_card.side_is_favorite``."""
|
||||||
|
return _card.side_is_favorite(game, side, favorites)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _side_score(game: Dict[str, Any], side: str) -> Optional[int]:
|
||||||
|
"""``sports_card.side_score``."""
|
||||||
|
return _card.side_score(game, side)
|
||||||
|
|
||||||
|
def _favorite_result(self, game: Dict[str, Any]) -> Optional[str]:
|
||||||
|
"""``sports_card.favorite_result``."""
|
||||||
|
return _card.favorite_result(self.config, game)
|
||||||
|
|
||||||
|
def _score_color_for(self, game: Dict[str, Any], game_type: str, default=None):
|
||||||
|
"""``sports_card.score_color_for``."""
|
||||||
|
return _card.score_color_for(self.config, self.logger, game, game_type, default)
|
||||||
|
|
||||||
|
def _recent_score_color(self, game: Dict[str, Any], default):
|
||||||
|
"""``sports_card.recent_score_color``."""
|
||||||
|
return _card.recent_score_color(self.config, self.logger, game, default)
|
||||||
|
|
||||||
|
def _element_color(self, element: str, default: Tuple[int, int, int] = (255, 255, 255)):
|
||||||
|
"""``sports_card.element_color``."""
|
||||||
|
return _card.element_color(self.config, element, default)
|
||||||
|
|
||||||
|
# ---- card options, dates and times --------------------------------
|
||||||
|
|
||||||
|
def _scroll_card_option(self, key: str, default: Any = None) -> Any:
|
||||||
|
"""``sports_card.scroll_card_option``."""
|
||||||
|
return _card.scroll_card_option(self.config, key, default)
|
||||||
|
|
||||||
|
def _upcoming_center_mode(self) -> str:
|
||||||
|
"""``sports_card.upcoming_center_mode``."""
|
||||||
|
return _card.upcoming_center_mode(self.config)
|
||||||
|
|
||||||
|
def _vs_text(self) -> str:
|
||||||
|
"""``sports_card.vs_text``."""
|
||||||
|
return _card.vs_text(self.config)
|
||||||
|
|
||||||
|
def _format_game_date(self, date_text: str, game: Optional[Dict] = None) -> str:
|
||||||
|
"""``sports_card.format_game_date``."""
|
||||||
|
return _card.format_game_date(self.config, self.logger, date_text, game)
|
||||||
|
|
||||||
|
def _weekday_for(self, game: Optional[Dict]) -> str:
|
||||||
|
"""``sports_card.weekday_for``."""
|
||||||
|
return _card.weekday_for(self.config, self.logger, game)
|
||||||
|
|
||||||
|
def _card_tzinfo(self):
|
||||||
|
"""``sports_card.card_tzinfo``."""
|
||||||
|
return _card.card_tzinfo(self.config, self.logger)
|
||||||
|
|
||||||
|
def _format_game_time(self, time_text: str) -> str:
|
||||||
|
"""``sports_card.format_game_time``."""
|
||||||
|
return _card.format_game_time(self.config, time_text)
|
||||||
@@ -0,0 +1,780 @@
|
|||||||
|
"""How the scoreboards draw a score or win celebration.
|
||||||
|
|
||||||
|
Five scoreboards -- afl, football, hockey, nrl and soccer -- take over the
|
||||||
|
panel when a team scores or wins: a backdrop in the scoring team's colours
|
||||||
|
read off its crest, scenery for the kind of score, confetti, the headline and
|
||||||
|
the score with the scoring side's digits breathing. The drawing is identical
|
||||||
|
in all five ``sports.py`` copies (executable AST, docstrings stripped), and
|
||||||
|
so are the colour helpers it uses; they were copied here from
|
||||||
|
ledmatrix-plugins ``30455671`` (origin/main, 2026-09-29).
|
||||||
|
|
||||||
|
Only the drawing moved. What *arms* a celebration stays in each plugin,
|
||||||
|
because it differs: which scores count (``_check_for_goal`` /
|
||||||
|
``_check_for_score``, and nrl matches favourites by team id), the phrase and
|
||||||
|
the scenery (``_start_celebration``), and when a win fires
|
||||||
|
(``_check_for_win``). So does ``display()``, which decides whether the
|
||||||
|
takeover or the scorebug is on screen. A plugin hands this mixin a
|
||||||
|
celebration dict and it draws it.
|
||||||
|
|
||||||
|
The colour helpers are public free functions here (``logo_palette``,
|
||||||
|
``lift_color``, ``mix_color``, ...); in the plugins they were the same
|
||||||
|
functions with a leading underscore.
|
||||||
|
|
||||||
|
THE CELEBRATION DICT
|
||||||
|
--------------------
|
||||||
|
Built by the plugin's ``_start_celebration``. Read here: ``game`` (a
|
||||||
|
view-model dict; ``<side>_id``, ``<side>_abbr``, ``<side>_logo_path`` and
|
||||||
|
``<side>_logo_url`` for the crests, ``id`` for the confetti seed),
|
||||||
|
``scored_side`` (``"away"`` or ``"home"``), ``away_score``, ``home_score``,
|
||||||
|
``phrase``, ``started_at`` (a ``time.time()`` value) and ``motif``
|
||||||
|
(``"score"``, ``"kick"``, ``"touchdown"``, ``"net"`` or ``"win"``; anything
|
||||||
|
else draws the ``"score"`` diagonals). The drawing caches what it derives in
|
||||||
|
the same dict, under ``_palette``, ``_backdrop``, ``_confetti`` and
|
||||||
|
``_crests``, so each is worked out once per celebration.
|
||||||
|
|
||||||
|
WHAT A HOST MUST PROVIDE
|
||||||
|
------------------------
|
||||||
|
Derived by walking every ``self.<attr>`` the mixin reads; the host-contract
|
||||||
|
test in ``test/test_sports_celebration.py`` fails if a read is added without
|
||||||
|
being listed here. All five scoreboards' ``SportsLive`` provide them.
|
||||||
|
|
||||||
|
- ``display_manager`` -- ``image`` is replaced with the frame, then
|
||||||
|
``update_display()``; ``clear()`` on ``force_clear``. Its ``matrix``
|
||||||
|
width and height are used when it has a matrix, else ``display_width`` /
|
||||||
|
``display_height``.
|
||||||
|
- ``fonts`` -- ``"time"`` and ``"status"`` for the headline (the first that
|
||||||
|
fits), ``"score"`` for the score.
|
||||||
|
- ``logger``.
|
||||||
|
- ``_load_and_resize_logo(team_id, abbr, logo_path, logo_url)`` -- a crest
|
||||||
|
as an RGBA image, or ``None``.
|
||||||
|
- ``_draw_text_with_outline(draw, text, position, font, fill=...)`` -- on
|
||||||
|
``SportsCoreSharedMixin``.
|
||||||
|
- ``celebration_duration``, ``celebration_team_colors`` and
|
||||||
|
``celebration_confetti``, read with ``getattr`` (defaults 8, on, on).
|
||||||
|
|
||||||
|
Mix it in ahead of the mode classes, e.g.
|
||||||
|
``class SportsLive(SportsCelebrationMixin, SportsLiveSharedMixin,
|
||||||
|
SportsCore)``. It defines nothing any of them define, so the order only
|
||||||
|
matters for a plugin that keeps its own copy of one of these methods: a
|
||||||
|
method on the plugin's class always wins over the mixin's.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import colorsys
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
import random
|
||||||
|
import time
|
||||||
|
from typing import Any, Callable, ClassVar, Dict, List, Optional, Sequence, Tuple
|
||||||
|
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
|
#: A colour as the helpers return it: three 0-255 channels.
|
||||||
|
Color = Tuple[int, ...]
|
||||||
|
#: ``deep``, ``glow``, ``headline`` and ``accent``; see ``logo_palette``.
|
||||||
|
Palette = Dict[str, Color]
|
||||||
|
#: One confetti flake: column, start height, fall speed, sway phase, size
|
||||||
|
#: in pixels, colour.
|
||||||
|
Flake = Tuple[float, float, float, float, int, Color]
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# Colour helpers for the score/win celebration
|
||||||
|
#
|
||||||
|
# Module level rather than methods: they are pure, which is what makes the
|
||||||
|
# palette testable without standing up a live manager, and they are shared by
|
||||||
|
# the takeover's backdrop, confetti and text.
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#: The crest is sampled at this resolution. Big enough that a secondary
|
||||||
|
#: colour survives (a helmet stripe, a trim), small enough that the whole
|
||||||
|
#: sample is ~1600 pixels of pure-Python work, once per team.
|
||||||
|
_PALETTE_SAMPLE_PX = 40
|
||||||
|
#: Above this, a colour carries team identity; below it, it is a grey.
|
||||||
|
_PALETTE_VIVID_SATURATION = 0.22
|
||||||
|
#: Ignore pixels this dark -- crest outlines, drop shadows, anti-aliasing.
|
||||||
|
_PALETTE_MIN_CHANNEL = 24
|
||||||
|
#: How far apart two bins must be to count as a second, different colour.
|
||||||
|
_PALETTE_DISTINCT_DISTANCE = 90.0
|
||||||
|
#: Never bleed a lifted colour below this saturation; past it a hue stops
|
||||||
|
#: being the team's colour and starts being a pastel.
|
||||||
|
_PALETTE_MIN_SATURATION = 0.42
|
||||||
|
#: Lift a headline colour until it is at least this luminous. Chosen so
|
||||||
|
#: midnight navy reaches a blue that reads at 6px on a panel without
|
||||||
|
#: becoming a different colour.
|
||||||
|
_PALETTE_HEADLINE_LUMINANCE = 112.0
|
||||||
|
#: A crest colour this luminous already reads on a panel, so it is preferred
|
||||||
|
#: over a darker one that would have to be lifted to get there. Lifting is a
|
||||||
|
#: compromise -- Green Bay's dark green only reaches legibility as a teal --
|
||||||
|
#: and most teams whose primary is dark carry a bright second colour that is
|
||||||
|
#: just as much theirs. This is what picks the Packers' gold over that teal.
|
||||||
|
_PALETTE_LEGIBLE_LUMINANCE = 90.0
|
||||||
|
#: ...but only from a colour the crest actually means. The pixels where a
|
||||||
|
#: bright edge is anti-aliased into a dark fill are luminous too, and there is
|
||||||
|
#: always a band of them: Kansas City's white-on-red outline leaves a pink at
|
||||||
|
#: luminance 90 that would otherwise be preferred over the red itself. A blend
|
||||||
|
#: is a mix, so it is markedly less saturated than either colour it sits
|
||||||
|
#: between -- that pink is 0.48 where the red is 0.96 and the Packers' gold,
|
||||||
|
#: which this must keep, is 0.89.
|
||||||
|
_PALETTE_LEGIBLE_SATURATION = 0.65
|
||||||
|
#: And it has to be a band of the crest, not a speck of one.
|
||||||
|
_PALETTE_LEGIBLE_AREA = 0.02
|
||||||
|
#: Cap the backdrop's luminance so the headline stays legible over it,
|
||||||
|
#: and the scenery's so it stays behind the headline. Both are luminance and
|
||||||
|
#: not HSV value on purpose: a silver crest -- the Raiders, or the grey
|
||||||
|
#: placeholder a failed logo download leaves behind -- has a value of ~0.95,
|
||||||
|
#: and capping that at 0.34 still yields a light grey card that white text
|
||||||
|
#: then vanishes into. Scaling the channels down is also hue-exact, which is
|
||||||
|
#: what lets this be the plain arithmetic that lifting a colour cannot be.
|
||||||
|
_PALETTE_BACKDROP_LUMINANCE = 34.0
|
||||||
|
_PALETTE_SCENERY_LUMINANCE = 70.0
|
||||||
|
|
||||||
|
|
||||||
|
def rgb_luminance(color: Sequence[float]) -> float:
|
||||||
|
"""Rec. 709 relative luminance, 0-255."""
|
||||||
|
return 0.2126 * color[0] + 0.7152 * color[1] + 0.0722 * color[2]
|
||||||
|
|
||||||
|
|
||||||
|
def rgb_saturation(color: Sequence[float]) -> float:
|
||||||
|
"""HSV saturation, 0-1."""
|
||||||
|
high = max(color)
|
||||||
|
return (high - min(color)) / high if high else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def color_distance(a: Sequence[float], b: Sequence[float]) -> float:
|
||||||
|
"""Euclidean distance between two colours in RGB."""
|
||||||
|
return math.sqrt(sum((x - y) ** 2 for x, y in zip(a, b)))
|
||||||
|
|
||||||
|
|
||||||
|
def mix_color(a: Sequence[float], b: Sequence[float], t: float) -> Color:
|
||||||
|
"""Blend ``a`` towards ``b``; t=0 is all a, t=1 is all b."""
|
||||||
|
t = min(max(t, 0.0), 1.0)
|
||||||
|
return tuple(int(round(a[i] + (b[i] - a[i]) * t)) for i in range(3))
|
||||||
|
|
||||||
|
|
||||||
|
def scale_color(color: Sequence[float], factor: float) -> Color:
|
||||||
|
"""Scale a colour's brightness, clamped to the panel's range."""
|
||||||
|
return tuple(min(255, max(0, int(round(c * factor)))) for c in color)
|
||||||
|
|
||||||
|
|
||||||
|
def lift_color(color: Sequence[float], min_luminance: float = _PALETTE_HEADLINE_LUMINANCE,
|
||||||
|
cap_saturation: float = 0.92) -> Color:
|
||||||
|
"""Raise a colour's brightness until it reads on a panel, keeping its hue.
|
||||||
|
|
||||||
|
Scaling the channels directly is what the obvious version of this does,
|
||||||
|
and it shifts hue badly on exactly the colours that need lifting: it turns
|
||||||
|
Baltimore's navy-purple into magenta. Working in HSV and raising only the
|
||||||
|
value leaves the hue where the team put it.
|
||||||
|
"""
|
||||||
|
if rgb_luminance(color) >= min_luminance:
|
||||||
|
return tuple(int(c) for c in color)
|
||||||
|
hue, saturation, value = colorsys.rgb_to_hsv(*[c / 255.0 for c in color])
|
||||||
|
if saturation < 0.12:
|
||||||
|
# A grey or a silver has no hue to preserve; just make it bright.
|
||||||
|
lifted = colorsys.hsv_to_rgb(hue, saturation, max(value, 0.85))
|
||||||
|
return tuple(int(round(c * 255)) for c in lifted)
|
||||||
|
saturation = min(saturation, cap_saturation)
|
||||||
|
|
||||||
|
def _rgb(s: float, v: float) -> Color:
|
||||||
|
return tuple(int(round(c * 255)) for c in colorsys.hsv_to_rgb(hue, s, v))
|
||||||
|
|
||||||
|
out = _rgb(saturation, value)
|
||||||
|
while value < 1.0 and rgb_luminance(out) < min_luminance:
|
||||||
|
value = min(1.0, value + 0.05)
|
||||||
|
out = _rgb(saturation, value)
|
||||||
|
# Blue carries almost no luminance -- pure blue sits at 18 of 255 -- so a
|
||||||
|
# navy or a deep purple runs out of value long before it is legible.
|
||||||
|
# Bleeding saturation out of it is the only way up, and it keeps the hue
|
||||||
|
# (Baltimore stays purple, just a lighter one) where giving up would
|
||||||
|
# leave the headline unreadable. Floored so it never washes out to white.
|
||||||
|
while saturation > _PALETTE_MIN_SATURATION and rgb_luminance(out) < min_luminance:
|
||||||
|
saturation = max(_PALETTE_MIN_SATURATION, saturation - 0.05)
|
||||||
|
out = _rgb(saturation, value)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def cap_luminance(color: Sequence[float], max_luminance: float) -> Color:
|
||||||
|
"""Darken a colour until it is no brighter than ``max_luminance``.
|
||||||
|
|
||||||
|
A straight channel scale, which is exactly hue-preserving on the way down
|
||||||
|
-- unlike lifting, where clamping at 255 is what bends the hue.
|
||||||
|
"""
|
||||||
|
luminance = rgb_luminance(color)
|
||||||
|
if luminance <= max_luminance or luminance <= 0:
|
||||||
|
return tuple(int(c) for c in color)
|
||||||
|
return scale_color(color, max_luminance / luminance)
|
||||||
|
|
||||||
|
|
||||||
|
def dim_rgba(image: Image.Image, factor: float) -> Image.Image:
|
||||||
|
"""Scale an RGBA image's colour channels, leaving its alpha alone.
|
||||||
|
|
||||||
|
ImageEnhance.Brightness would scale the alpha band too, which fades the
|
||||||
|
crest out instead of dimming it and leaves its anti-aliased edge looking
|
||||||
|
chewed against the backdrop.
|
||||||
|
"""
|
||||||
|
red, green, blue, alpha = image.split()
|
||||||
|
lut = [min(255, int(i * factor)) for i in range(256)]
|
||||||
|
return Image.merge(
|
||||||
|
"RGBA", (red.point(lut), green.point(lut), blue.point(lut), alpha)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_Buckets = Dict[Tuple[int, int, int], List[int]]
|
||||||
|
|
||||||
|
|
||||||
|
def _palette_buckets(logo: Image.Image) -> Tuple[_Buckets, _Buckets]:
|
||||||
|
"""Bucket a crest's opaque pixels into coarse colour bins.
|
||||||
|
|
||||||
|
Returns ``(vivid, neutral)``; each maps a 3-bit-per-channel key to
|
||||||
|
``[r_sum, g_sum, b_sum, count]``. Neutral holds the greys, silvers and
|
||||||
|
whites that carry no identity on their own but are all a monochrome crest
|
||||||
|
-- the Raiders' silver on black -- has to offer.
|
||||||
|
"""
|
||||||
|
sample = logo.convert("RGBA")
|
||||||
|
sample.thumbnail((_PALETTE_SAMPLE_PX, _PALETTE_SAMPLE_PX), Image.Resampling.BOX)
|
||||||
|
vivid: _Buckets = {}
|
||||||
|
neutral: _Buckets = {}
|
||||||
|
# tobytes() rather than getdata(): same pixels, no per-pixel Python
|
||||||
|
# object, and getdata() is deprecated from Pillow 14.
|
||||||
|
raw = sample.tobytes()
|
||||||
|
for i in range(0, len(raw) - 3, 4):
|
||||||
|
red, green, blue, alpha = raw[i], raw[i + 1], raw[i + 2], raw[i + 3]
|
||||||
|
if alpha < 160:
|
||||||
|
continue
|
||||||
|
high, low = max(red, green, blue), min(red, green, blue)
|
||||||
|
if high < _PALETTE_MIN_CHANNEL:
|
||||||
|
continue
|
||||||
|
target = vivid if (high - low) / high >= _PALETTE_VIVID_SATURATION else neutral
|
||||||
|
acc = target.setdefault((red >> 5, green >> 5, blue >> 5), [0, 0, 0, 0])
|
||||||
|
acc[0] += red
|
||||||
|
acc[1] += green
|
||||||
|
acc[2] += blue
|
||||||
|
acc[3] += 1
|
||||||
|
return vivid, neutral
|
||||||
|
|
||||||
|
|
||||||
|
def _bucket_mean(acc: List[int]) -> Color:
|
||||||
|
count = acc[3]
|
||||||
|
return (acc[0] // count, acc[1] // count, acc[2] // count)
|
||||||
|
|
||||||
|
|
||||||
|
def _bucket_headline_score(acc: List[int]) -> float:
|
||||||
|
"""How well a colour bin would serve as 6px of text on a panel.
|
||||||
|
|
||||||
|
Area alone picks the biggest block of colour, which on a lot of crests is
|
||||||
|
a dark navy fill -- correct as a backdrop, invisible as text. Weighting
|
||||||
|
area by saturation and by luminance picks the colour the team is loud in:
|
||||||
|
Chicago's orange over its navy, Baltimore's gold over its purple.
|
||||||
|
"""
|
||||||
|
color = _bucket_mean(acc)
|
||||||
|
return (
|
||||||
|
acc[3]
|
||||||
|
* (0.30 + 0.70 * rgb_saturation(color))
|
||||||
|
* (0.20 + 0.80 * min(1.0, rgb_luminance(color) / 120.0))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def logo_palette(logo: Image.Image) -> Optional[Palette]:
|
||||||
|
"""Pick a celebration palette out of a team crest, or None.
|
||||||
|
|
||||||
|
Two rankings, because a crest's largest colour and its most legible one
|
||||||
|
are usually not the same and the takeover needs both:
|
||||||
|
|
||||||
|
* ``deep`` -- the largest vivid area, darkened into the background wash.
|
||||||
|
This is what the team reads as at a glance: Chicago navy, Dallas navy,
|
||||||
|
Baltimore purple.
|
||||||
|
* ``headline`` -- the vivid area that best survives being shrunk to text,
|
||||||
|
then lifted until it is legible: Chicago orange, Baltimore gold.
|
||||||
|
* ``accent`` -- the next vivid colour far enough away from the headline to
|
||||||
|
be told apart, for confetti. Falls back to the headline.
|
||||||
|
|
||||||
|
A crest with no vivid pixels at all falls back to its brightest neutral,
|
||||||
|
which for the Raiders' silver-on-black is exactly the right answer.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
vivid, neutral = _palette_buckets(logo)
|
||||||
|
except Exception: # noqa: BLE001 - a crest is never worth the takeover
|
||||||
|
return None
|
||||||
|
|
||||||
|
pool = list(vivid.values())
|
||||||
|
if not pool and neutral:
|
||||||
|
pool = [
|
||||||
|
max(
|
||||||
|
neutral.values(),
|
||||||
|
key=lambda acc: acc[3]
|
||||||
|
* (0.2 + 0.8 * min(1.0, rgb_luminance(_bucket_mean(acc)) / 160.0)),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
if not pool:
|
||||||
|
return None
|
||||||
|
|
||||||
|
deep_base = _bucket_mean(max(pool, key=lambda acc: acc[3]))
|
||||||
|
ranked = sorted(pool, key=_bucket_headline_score, reverse=True)
|
||||||
|
headline_base = _bucket_mean(ranked[0])
|
||||||
|
vivid_pixels = sum(acc[3] for acc in pool)
|
||||||
|
for acc in ranked:
|
||||||
|
candidate = _bucket_mean(acc)
|
||||||
|
if (
|
||||||
|
rgb_luminance(candidate) >= _PALETTE_LEGIBLE_LUMINANCE
|
||||||
|
and rgb_saturation(candidate) >= _PALETTE_LEGIBLE_SATURATION
|
||||||
|
and acc[3] >= max(3, vivid_pixels * _PALETTE_LEGIBLE_AREA)
|
||||||
|
):
|
||||||
|
headline_base = candidate
|
||||||
|
break
|
||||||
|
headline = lift_color(headline_base)
|
||||||
|
|
||||||
|
accent = headline
|
||||||
|
for acc in ranked[1:]:
|
||||||
|
candidate = _bucket_mean(acc)
|
||||||
|
if color_distance(candidate, headline_base) > _PALETTE_DISTINCT_DISTANCE:
|
||||||
|
accent = lift_color(candidate)
|
||||||
|
break
|
||||||
|
|
||||||
|
deep = cap_luminance(deep_base, _PALETTE_BACKDROP_LUMINANCE)
|
||||||
|
return {
|
||||||
|
"deep": deep,
|
||||||
|
# Scenery is the backdrop carried a little way towards the headline:
|
||||||
|
# tied to the team's colours, and guaranteed to be visible even when
|
||||||
|
# the backdrop is nearly black.
|
||||||
|
"glow": cap_luminance(
|
||||||
|
mix_color(deep, headline, 0.22), _PALETTE_SCENERY_LUMINANCE
|
||||||
|
),
|
||||||
|
"headline": headline,
|
||||||
|
"accent": accent,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SportsCelebrationMixin:
|
||||||
|
"""Draws a score/win celebration takeover. See the module docstring."""
|
||||||
|
|
||||||
|
# The host contract, declared for type checking only: these create no
|
||||||
|
# attributes, so the host's own values are what the methods read.
|
||||||
|
display_manager: Any
|
||||||
|
display_width: int
|
||||||
|
display_height: int
|
||||||
|
fonts: Dict[str, Any]
|
||||||
|
logger: logging.Logger
|
||||||
|
_load_and_resize_logo: Callable[..., Optional[Image.Image]]
|
||||||
|
_draw_text_with_outline: Callable[..., None]
|
||||||
|
|
||||||
|
def _fit_font(self, draw, text: str, max_width: int, fonts: list):
|
||||||
|
"""Return the first font whose rendered ``text`` fits ``max_width``,
|
||||||
|
falling back to the last (smallest) font."""
|
||||||
|
for font in fonts:
|
||||||
|
if draw.textlength(text, font=font) <= max_width - 2:
|
||||||
|
return font
|
||||||
|
return fonts[-1]
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Celebration palette
|
||||||
|
#
|
||||||
|
# The takeover is drawn in the scoring team's own colours, taken from the
|
||||||
|
# pixels of its crest.
|
||||||
|
#
|
||||||
|
# ESPN does serve team.color / team.alternateColor, but only inside
|
||||||
|
# _extract_game_details_common -- a function each scoreboard lineage
|
||||||
|
# keeps its own copy of -- so reading it there would drag every one of
|
||||||
|
# them into a celebration change. The crest is already downloaded,
|
||||||
|
# decoded and sitting in the logo cache by the time a celebration draws,
|
||||||
|
# so the colours come from it instead: no extra request, no per-league
|
||||||
|
# colour table to maintain, and it works for any team ESPN can name --
|
||||||
|
# including the FCS opponents no table would list.
|
||||||
|
#
|
||||||
|
# Where a crest's colour differs from the club's published one it
|
||||||
|
# tends to differ usefully: a published primary is often a near-black
|
||||||
|
# navy, or an actual #000000, where what the crest carries is the colour
|
||||||
|
# that reads on an LED panel. Measured across all 32 clubs in
|
||||||
|
# football-scoreboard.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
#: Used when the crest yields nothing (no logo on disk yet, or the grey
|
||||||
|
#: placeholder a failed download leaves) or team colours are switched
|
||||||
|
#: off -- the navy and amber the celebration wore before it had a palette.
|
||||||
|
_DEFAULT_CELEBRATION_PALETTE: ClassVar[Palette] = {
|
||||||
|
"deep": (10, 10, 40),
|
||||||
|
"glow": (30, 30, 86),
|
||||||
|
"headline": (255, 208, 56),
|
||||||
|
"accent": (255, 255, 255),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _celebration_palette(self, celebration: Dict) -> Palette:
|
||||||
|
"""The scoring team's colours, derived once per celebration."""
|
||||||
|
cached: Optional[Palette] = celebration.get("_palette")
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
palette = dict(self._DEFAULT_CELEBRATION_PALETTE)
|
||||||
|
if getattr(self, "celebration_team_colors", True):
|
||||||
|
try:
|
||||||
|
game = celebration["game"]
|
||||||
|
side = celebration.get("scored_side") or "home"
|
||||||
|
logo = self._load_and_resize_logo(
|
||||||
|
game.get("%s_id" % side),
|
||||||
|
game.get("%s_abbr" % side),
|
||||||
|
game.get("%s_logo_path" % side),
|
||||||
|
game.get("%s_logo_url" % side),
|
||||||
|
)
|
||||||
|
derived = logo_palette(logo) if logo is not None else None
|
||||||
|
if derived:
|
||||||
|
palette = derived
|
||||||
|
except Exception as e: # noqa: BLE001 - never lose a takeover to a crest
|
||||||
|
self.logger.debug(f"Celebration palette fell back to the default: {e}")
|
||||||
|
|
||||||
|
celebration["_palette"] = palette
|
||||||
|
return palette
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Celebration choreography
|
||||||
|
#
|
||||||
|
# Every frame is a finished card. The beats below shift the emphasis --
|
||||||
|
# an opening colour hit, confetti, a breathing score -- but none of them
|
||||||
|
# leaves the panel mid-wipe, because on a switch-mode board the core
|
||||||
|
# drives this plugin at 1 FPS (display_controller reserves its high-FPS
|
||||||
|
# loop for plugins that scroll or declare needs_high_fps), so any single
|
||||||
|
# frame may be the only one a viewer ever sees of it.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
#: Fraction of the celebration spent on the opening colour hit.
|
||||||
|
_CELEBRATION_IMPACT: ClassVar[float] = 0.11
|
||||||
|
#: Fraction of it after which the takeover eases back down.
|
||||||
|
_CELEBRATION_SETTLE: ClassVar[float] = 0.80
|
||||||
|
#: Seconds per breath of the scoring side's digits. Deliberately a
|
||||||
|
#: continuous sine rather than an on/off toggle: the 4 Hz flash this
|
||||||
|
#: replaced was sampled once a second on a switch-mode board, which
|
||||||
|
#: aliases into a colour that changes at random. A ramp degrades into a
|
||||||
|
#: slow glow instead, and still reads as a pulse at 125 FPS.
|
||||||
|
_CELEBRATION_BREATH_SECONDS: ClassVar[float] = 1.7
|
||||||
|
|
||||||
|
def _celebration_backdrop(
|
||||||
|
self,
|
||||||
|
celebration: Dict,
|
||||||
|
width: int,
|
||||||
|
height: int,
|
||||||
|
palette: Palette,
|
||||||
|
) -> Image.Image:
|
||||||
|
"""The static half of the takeover: a team-colour gradient with the
|
||||||
|
scenery for this kind of score painted into it.
|
||||||
|
|
||||||
|
Built once per celebration per panel size and copied per frame, so the
|
||||||
|
per-pixel work never lands on the render path.
|
||||||
|
"""
|
||||||
|
cached: Optional[Tuple[Tuple[int, int], Image.Image]] = celebration.get("_backdrop")
|
||||||
|
if cached is not None and cached[0] == (width, height):
|
||||||
|
return cached[1]
|
||||||
|
|
||||||
|
# One column, then stretched: filling the panel pixel by pixel would
|
||||||
|
# be `width` times the work for the same image.
|
||||||
|
column = Image.new("RGB", (1, max(height, 1)))
|
||||||
|
pixels: Any = column.load()
|
||||||
|
for y in range(height):
|
||||||
|
k = y / max(height - 1, 1)
|
||||||
|
pixels[0, y] = mix_color(palette["deep"], (0, 0, 0), 0.18 + 0.82 * k)
|
||||||
|
backdrop = column.resize((width, height)).convert("RGBA")
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._draw_celebration_motif(
|
||||||
|
ImageDraw.Draw(backdrop),
|
||||||
|
celebration.get("motif") or "score",
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
palette,
|
||||||
|
)
|
||||||
|
except Exception as e: # noqa: BLE001 - scenery is never worth a blank panel
|
||||||
|
self.logger.debug(f"Celebration motif skipped: {e}")
|
||||||
|
|
||||||
|
celebration["_backdrop"] = ((width, height), backdrop)
|
||||||
|
return backdrop
|
||||||
|
|
||||||
|
def _draw_celebration_motif(
|
||||||
|
self,
|
||||||
|
draw,
|
||||||
|
motif: str,
|
||||||
|
width: int,
|
||||||
|
height: int,
|
||||||
|
palette: Palette,
|
||||||
|
) -> None:
|
||||||
|
"""Paint the scenery for one kind of score, dim enough to stay behind
|
||||||
|
the headline and the score instead of competing with them."""
|
||||||
|
glow = palette["glow"]
|
||||||
|
if motif == "kick":
|
||||||
|
# The uprights a field goal or an extra point went through,
|
||||||
|
# spread wide enough to frame the score rather than sit beside it.
|
||||||
|
half = max(8, min(width // 3, height))
|
||||||
|
mid = width // 2
|
||||||
|
crossbar = int(height * 0.60)
|
||||||
|
draw.line([(mid - half, int(height * 0.08)), (mid - half, crossbar)], fill=glow)
|
||||||
|
draw.line([(mid + half, int(height * 0.08)), (mid + half, crossbar)], fill=glow)
|
||||||
|
draw.line([(mid - half, crossbar), (mid + half, crossbar)], fill=glow)
|
||||||
|
draw.line([(mid, crossbar), (mid, height - 1)], fill=glow)
|
||||||
|
elif motif == "touchdown":
|
||||||
|
# The goal line, with its hash marks.
|
||||||
|
line_y = int(height * 0.36)
|
||||||
|
draw.line([(0, line_y), (width, line_y)], fill=glow)
|
||||||
|
for x in range(3, width, 9):
|
||||||
|
draw.line([(x, line_y - 2), (x, line_y + 2)], fill=glow)
|
||||||
|
elif motif == "net":
|
||||||
|
# The goal a puck just went into: frame, posts and mesh, sized to
|
||||||
|
# frame the score the way the uprights do.
|
||||||
|
half = max(7, min(width // 4, height))
|
||||||
|
mid = width // 2
|
||||||
|
top = int(height * 0.34)
|
||||||
|
draw.rectangle([(mid - half, top), (mid + half, height - 1)], outline=glow)
|
||||||
|
step = max(3, (half * 2) // 6)
|
||||||
|
for x in range(mid - half + step, mid + half, step):
|
||||||
|
draw.line([(x, top + 1), (x, height - 2)], fill=glow)
|
||||||
|
for y in range(top + step, height - 1, step):
|
||||||
|
draw.line([(mid - half + 1, y), (mid + half - 1, y)], fill=glow)
|
||||||
|
elif motif == "win":
|
||||||
|
# A sunburst behind the winner.
|
||||||
|
cx, cy = width // 2, height // 2
|
||||||
|
reach = max(width, height)
|
||||||
|
for i in range(10):
|
||||||
|
angle = (math.pi * 2 * i / 10) + math.pi / 20
|
||||||
|
draw.line(
|
||||||
|
[
|
||||||
|
(cx, cy),
|
||||||
|
(cx + math.cos(angle) * reach, cy + math.sin(angle) * reach),
|
||||||
|
],
|
||||||
|
fill=glow,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
for x in range(-height, width + height, 11):
|
||||||
|
draw.line([(x, height), (x + height, 0)], fill=glow)
|
||||||
|
|
||||||
|
def _celebration_confetti(
|
||||||
|
self,
|
||||||
|
celebration: Dict,
|
||||||
|
width: int,
|
||||||
|
height: int,
|
||||||
|
palette: Palette,
|
||||||
|
) -> List[Flake]:
|
||||||
|
"""Seed the confetti once per celebration.
|
||||||
|
|
||||||
|
Seeded from the game rather than the clock, so the same score always
|
||||||
|
produces the same fall -- which is what lets a golden screen lock the
|
||||||
|
effect down instead of having to tolerate it.
|
||||||
|
"""
|
||||||
|
cached: Optional[Tuple[Tuple[int, int], List[Flake]]] = celebration.get("_confetti")
|
||||||
|
if cached is not None and cached[0] == (width, height):
|
||||||
|
return cached[1]
|
||||||
|
|
||||||
|
# Sparse on purpose. At one flake per 170 square pixels a 128x32
|
||||||
|
# panel carried 24 single-pixel specks over the headline and the
|
||||||
|
# score, which reads as a dead-pixel problem rather than as confetti.
|
||||||
|
count = max(6, min(22, (width * height) // 260))
|
||||||
|
seed = "%s/%s" % (
|
||||||
|
(celebration.get("game") or {}).get("id", "?"),
|
||||||
|
celebration.get("phrase", ""),
|
||||||
|
)
|
||||||
|
rng = random.Random(seed) # nosec B311 - confetti, not security
|
||||||
|
# Team colours, plus a pale tint of the headline rather than a flat
|
||||||
|
# white, so the fall still belongs to the team that scored.
|
||||||
|
colors = [
|
||||||
|
palette["headline"],
|
||||||
|
palette["accent"],
|
||||||
|
mix_color(palette["headline"], (255, 255, 255), 0.55),
|
||||||
|
]
|
||||||
|
flakes = [
|
||||||
|
(
|
||||||
|
float(rng.randrange(max(width, 1))), # column
|
||||||
|
rng.uniform(0.0, float(height)), # start height
|
||||||
|
rng.uniform(0.40, 1.15), # fall speed
|
||||||
|
rng.uniform(0.0, math.pi * 2), # sway phase
|
||||||
|
2 if rng.random() < 0.6 else 1, # size in pixels
|
||||||
|
colors[rng.randrange(len(colors))],
|
||||||
|
)
|
||||||
|
for _ in range(count)
|
||||||
|
]
|
||||||
|
celebration["_confetti"] = ((width, height), flakes)
|
||||||
|
return flakes
|
||||||
|
|
||||||
|
def _draw_celebration_confetti(
|
||||||
|
self,
|
||||||
|
draw,
|
||||||
|
celebration: Dict,
|
||||||
|
width: int,
|
||||||
|
height: int,
|
||||||
|
palette: Palette,
|
||||||
|
elapsed: float,
|
||||||
|
progress: float,
|
||||||
|
) -> None:
|
||||||
|
"""Draw the confetti for this instant, thinning it out as the
|
||||||
|
celebration eases back towards the scorebug."""
|
||||||
|
flakes = self._celebration_confetti(celebration, width, height, palette)
|
||||||
|
fade = 1.0
|
||||||
|
if progress > self._CELEBRATION_SETTLE:
|
||||||
|
fade = max(
|
||||||
|
0.0,
|
||||||
|
1.0
|
||||||
|
- (progress - self._CELEBRATION_SETTLE)
|
||||||
|
/ (1.0 - self._CELEBRATION_SETTLE),
|
||||||
|
)
|
||||||
|
if fade <= 0.02:
|
||||||
|
return
|
||||||
|
alpha = int(235 * fade)
|
||||||
|
for column, start, speed, phase, size, color in flakes:
|
||||||
|
y = (start + speed * elapsed * height * 0.42) % (height + 4) - 2
|
||||||
|
x = column + math.sin(elapsed * 2.1 + phase) * 2.4
|
||||||
|
draw.rectangle(
|
||||||
|
[(int(x), int(y)), (int(x) + size - 1, int(y) + size - 1)],
|
||||||
|
fill=tuple(color) + (alpha,),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _celebration_crests(
|
||||||
|
self, celebration: Dict, height: int
|
||||||
|
) -> Dict[str, Optional[Image.Image]]:
|
||||||
|
"""The two crests for the takeover, with the side that did not score
|
||||||
|
dimmed so the scoring team reads at a glance."""
|
||||||
|
cached: Optional[Tuple[int, Dict[str, Optional[Image.Image]]]] = celebration.get("_crests")
|
||||||
|
if cached is not None and cached[0] == height:
|
||||||
|
return cached[1]
|
||||||
|
|
||||||
|
game = celebration["game"]
|
||||||
|
scored = celebration.get("scored_side")
|
||||||
|
crests: Dict[str, Optional[Image.Image]] = {}
|
||||||
|
for side in ("away", "home"):
|
||||||
|
logo = None
|
||||||
|
try:
|
||||||
|
logo = self._load_and_resize_logo(
|
||||||
|
game.get("%s_id" % side),
|
||||||
|
game.get("%s_abbr" % side),
|
||||||
|
game.get("%s_logo_path" % side),
|
||||||
|
game.get("%s_logo_url" % side),
|
||||||
|
)
|
||||||
|
except Exception as e: # noqa: BLE001 - a crest is never worth the panel
|
||||||
|
self.logger.debug(f"Celebration logo load failed: {e}")
|
||||||
|
if logo is not None and side != scored:
|
||||||
|
logo = dim_rgba(logo, 0.40)
|
||||||
|
crests[side] = logo
|
||||||
|
|
||||||
|
celebration["_crests"] = (height, crests)
|
||||||
|
return crests
|
||||||
|
|
||||||
|
def _draw_celebration_layout(self, celebration: Dict, force_clear: bool = False) -> None:
|
||||||
|
"""Render the full-screen goal/win takeover."""
|
||||||
|
if force_clear:
|
||||||
|
self.display_manager.clear()
|
||||||
|
|
||||||
|
display_width = (
|
||||||
|
self.display_manager.matrix.width
|
||||||
|
if hasattr(self.display_manager, "matrix") and self.display_manager.matrix
|
||||||
|
else self.display_width
|
||||||
|
)
|
||||||
|
display_height = (
|
||||||
|
self.display_manager.matrix.height
|
||||||
|
if hasattr(self.display_manager, "matrix") and self.display_manager.matrix
|
||||||
|
else self.display_height
|
||||||
|
)
|
||||||
|
|
||||||
|
elapsed = max(0.0, time.time() - celebration["started_at"])
|
||||||
|
# getattr throughout the render path: the golden-screen tests build a
|
||||||
|
# live manager through __new__ and set only what they draw with, and a
|
||||||
|
# celebration must never be lost to a missing knob.
|
||||||
|
duration = max(float(getattr(self, "celebration_duration", 8) or 8), 0.5)
|
||||||
|
progress = min(elapsed / duration, 1.0)
|
||||||
|
palette = self._celebration_palette(celebration)
|
||||||
|
|
||||||
|
main_img = self._celebration_backdrop(
|
||||||
|
celebration, display_width, display_height, palette
|
||||||
|
).copy()
|
||||||
|
|
||||||
|
# Crests at the edges, bleeding off as the scorebug's do.
|
||||||
|
crests = self._celebration_crests(celebration, display_height)
|
||||||
|
center_y = display_height // 2
|
||||||
|
home_logo, away_logo = crests.get("home"), crests.get("away")
|
||||||
|
if home_logo is not None:
|
||||||
|
main_img.paste(
|
||||||
|
home_logo,
|
||||||
|
(display_width - home_logo.width + 2, center_y - home_logo.height // 2),
|
||||||
|
home_logo,
|
||||||
|
)
|
||||||
|
if away_logo is not None:
|
||||||
|
main_img.paste(away_logo, (-2, center_y - away_logo.height // 2), away_logo)
|
||||||
|
|
||||||
|
# The opening hit: the team's headline colour washes the panel and
|
||||||
|
# decays out of it. Held below opaque so the outlined text drawn on
|
||||||
|
# top still reads in whichever frame happens to catch it.
|
||||||
|
impact = max(0.0, 1.0 - progress / self._CELEBRATION_IMPACT)
|
||||||
|
if impact > 0.0:
|
||||||
|
# Scaled by how colourful the team is. A saturated crest gets the
|
||||||
|
# full hit; a silver one -- the Raiders, or the grey placeholder a
|
||||||
|
# failed logo download leaves -- would otherwise wash the whole
|
||||||
|
# panel out to the same flat grey as its own headline colour.
|
||||||
|
punch = 0.45 + 0.55 * rgb_saturation(palette["headline"])
|
||||||
|
alpha = int(140 * punch * (impact ** 1.5))
|
||||||
|
if alpha > 0:
|
||||||
|
main_img = Image.alpha_composite(
|
||||||
|
main_img,
|
||||||
|
Image.new(
|
||||||
|
"RGBA",
|
||||||
|
(display_width, display_height),
|
||||||
|
tuple(palette["headline"]) + (alpha,),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
overlay = Image.new("RGBA", (display_width, display_height), (0, 0, 0, 0))
|
||||||
|
draw = ImageDraw.Draw(overlay)
|
||||||
|
|
||||||
|
if getattr(self, "celebration_confetti", True):
|
||||||
|
try:
|
||||||
|
self._draw_celebration_confetti(
|
||||||
|
draw,
|
||||||
|
celebration,
|
||||||
|
display_width,
|
||||||
|
display_height,
|
||||||
|
palette,
|
||||||
|
elapsed,
|
||||||
|
progress,
|
||||||
|
)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
self.logger.debug(f"Celebration confetti skipped: {e}")
|
||||||
|
|
||||||
|
# Headline across the top, shrunk to fit the panel width, struck
|
||||||
|
# white on the opening hit and settling into the team's colour.
|
||||||
|
phrase = celebration["phrase"]
|
||||||
|
phrase_font = self._fit_font(
|
||||||
|
draw, phrase, display_width, [self.fonts["time"], self.fonts["status"]]
|
||||||
|
)
|
||||||
|
phrase_width = draw.textlength(phrase, font=phrase_font)
|
||||||
|
# Eased in by colour rather than by position. Sliding it down into
|
||||||
|
# place put the first frame at y=-3 with its top row cut off, and on a
|
||||||
|
# 1 FPS board that clipped frame can be the only one anyone sees.
|
||||||
|
self._draw_text_with_outline(
|
||||||
|
draw,
|
||||||
|
phrase,
|
||||||
|
((display_width - phrase_width) // 2, 1),
|
||||||
|
phrase_font,
|
||||||
|
fill=mix_color(palette["headline"], (255, 255, 255), impact),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Score centred low, the scoring side's digits breathing in the team's
|
||||||
|
# headline colour so the change reads at a glance.
|
||||||
|
away_text = str(celebration["away_score"])
|
||||||
|
home_text = str(celebration["home_score"])
|
||||||
|
score_font = self.fonts["score"]
|
||||||
|
segments = [
|
||||||
|
(away_text, celebration["scored_side"] == "away"),
|
||||||
|
("-", False),
|
||||||
|
(home_text, celebration["scored_side"] == "home"),
|
||||||
|
]
|
||||||
|
total_width = sum(draw.textlength(seg, font=score_font) for seg, _ in segments)
|
||||||
|
breath = 0.72 + 0.28 * (
|
||||||
|
0.5
|
||||||
|
+ 0.5 * math.sin(2 * math.pi * elapsed / self._CELEBRATION_BREATH_SECONDS)
|
||||||
|
)
|
||||||
|
highlight = scale_color(palette["headline"], breath)
|
||||||
|
x = (display_width - total_width) // 2
|
||||||
|
# display_height - 14 was sized for the old fixed 8px score. #338
|
||||||
|
# scales the score with the panel (16px at 48 and 64 tall), which put
|
||||||
|
# the bottom of the digits off the panel. Lift it by the measured ink
|
||||||
|
# (+1 for the outline stroke) only when it would clip, so panels where
|
||||||
|
# it always fitted render exactly as before.
|
||||||
|
score_text = "".join(seg for seg, _ in segments)
|
||||||
|
ink_bottom = draw.textbbox((0, 0), score_text, font=score_font)[3]
|
||||||
|
y = min(display_height - 14, display_height - ink_bottom - 2)
|
||||||
|
for seg, is_highlight in segments:
|
||||||
|
color = highlight if is_highlight else (216, 216, 216)
|
||||||
|
self._draw_text_with_outline(draw, seg, (int(x), y), score_font, fill=color)
|
||||||
|
x += draw.textlength(seg, font=score_font)
|
||||||
|
|
||||||
|
main_img = Image.alpha_composite(main_img, overlay).convert("RGB")
|
||||||
|
self.display_manager.image = main_img
|
||||||
|
self.display_manager.update_display()
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
"""Which requests a scoreboard makes: season fetches, the lookback, live odds.
|
||||||
|
|
||||||
|
Four ``SportsCore`` methods are identical (executable AST, docstrings
|
||||||
|
stripped) in all nine scoreboards' ``sports.py`` -- afl, baseball,
|
||||||
|
basketball, football, hockey, lacrosse, nrl, soccer and ufc -- and were
|
||||||
|
copied here from ledmatrix-plugins ``30455671`` (origin/main, 2026-09-29)
|
||||||
|
under their existing names:
|
||||||
|
|
||||||
|
- ``_background_fetches_espn_ranges`` -- whether the core's background
|
||||||
|
service can fetch an ESPN date range, or the plugin must;
|
||||||
|
- ``_fetch_season_directly`` -- fetch and cache a season in chunks ESPN
|
||||||
|
accepts, on the calling thread;
|
||||||
|
- ``_needs_previous_day`` (with ``_LOOKBACK_CUTOFF_HOUR``) -- whether the
|
||||||
|
live fetch still has to ask for yesterday;
|
||||||
|
- ``_wants_live_odds`` (with ``_LIVE_ODDS_LOOKAHEAD``) -- whether a live
|
||||||
|
game is close enough to the screen to be worth an odds request.
|
||||||
|
|
||||||
|
Three other ``SportsCore`` methods are as identical and stay in the plugins,
|
||||||
|
for the reasons ``sports_shared`` gives: ``_get_timezone`` binds each
|
||||||
|
plugin's own ``resolve_timezone`` shim, and ``_extract_game_details`` /
|
||||||
|
``_fetch_data`` are the abstract sport-specific contract. So does
|
||||||
|
``SportsUpcoming.__init__``: the mixins in ``src/common`` hold no
|
||||||
|
constructor, so the plugins' constructor signature stays theirs.
|
||||||
|
|
||||||
|
A new module rather than more methods on ``sports_shared``, for the reason
|
||||||
|
``sports_helpers`` gives: a missing module fails at load, where the version
|
||||||
|
checks see it; a missing method fails mid-update.
|
||||||
|
|
||||||
|
WHAT A HOST MUST PROVIDE
|
||||||
|
------------------------
|
||||||
|
Derived by walking every ``self.<attr>`` the mixin reads; the host-contract
|
||||||
|
test in ``test/test_sports_fetch.py`` fails if a read is added without being
|
||||||
|
listed here.
|
||||||
|
|
||||||
|
- ``session``, ``headers``, ``cache_manager`` and ``logger`` --
|
||||||
|
``_fetch_season_directly``.
|
||||||
|
- ``_games_lock`` -- ``_wants_live_odds``, which also reads ``live_games``,
|
||||||
|
``current_game_index`` and ``_rotation_schedule`` with ``getattr``
|
||||||
|
(only ``SportsLive`` has them).
|
||||||
|
- ``live_games``, read with ``getattr`` -- ``_needs_previous_day``.
|
||||||
|
- ``background_service``, read with ``getattr`` --
|
||||||
|
``_background_fetches_espn_ranges``.
|
||||||
|
|
||||||
|
Add it as a base of the plugin's ``SportsCore``, e.g.
|
||||||
|
``class SportsCore(SportsFetchMixin, SportsCoreSharedMixin,
|
||||||
|
SportsHelpersMixin, ABC)``. It defines nothing those define; a method or
|
||||||
|
constant on the plugin's own class still wins over the mixin's.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Any, ClassVar, Dict, Optional
|
||||||
|
|
||||||
|
from src.common.espn_dates import ESPN_MAX_LIMIT, fetch_espn_scoreboard
|
||||||
|
|
||||||
|
|
||||||
|
class SportsFetchMixin:
|
||||||
|
"""Season fetch, lookback and live-odds decisions. See module docstring."""
|
||||||
|
|
||||||
|
# The host contract, declared for type checking only: these create no
|
||||||
|
# attributes, so the host's own values are what the methods read.
|
||||||
|
session: Any
|
||||||
|
headers: Dict[str, str]
|
||||||
|
cache_manager: Any
|
||||||
|
logger: logging.Logger
|
||||||
|
_games_lock: threading.RLock
|
||||||
|
|
||||||
|
#: How many games past the one on screen keep their odds warm. One is
|
||||||
|
#: enough for the line to be ready when the rotation advances; more just
|
||||||
|
#: re-creates the whole-slate fetch this replaced.
|
||||||
|
_LIVE_ODDS_LOOKAHEAD: ClassVar[int] = 1
|
||||||
|
|
||||||
|
def _wants_live_odds(self, game: Dict) -> bool:
|
||||||
|
"""Whether a live game is near enough the front of the rotation to be
|
||||||
|
worth an odds request.
|
||||||
|
|
||||||
|
Odds used to be fetched for *every* live game in the league on every
|
||||||
|
update. The renderer only ever draws ``current_game``, and a full
|
||||||
|
rotation of a big slate takes minutes while ``live_odds_update_interval``
|
||||||
|
is 60s -- so all but one of those requests expired before the game they
|
||||||
|
belonged to came round.
|
||||||
|
|
||||||
|
Measured 2026-09-19 over a full college-football slate: 11,978 odds
|
||||||
|
requests in 13h on one rig, 54% of all its ESPN traffic, across only
|
||||||
|
~140 distinct games. The eager loop also cost up to 2s of ``update()``
|
||||||
|
per live game, because ``_fetch_odds`` waits on its worker thread.
|
||||||
|
|
||||||
|
Mirrors the narrowing already applied to the upcoming path and to
|
||||||
|
``_attach_odds_to_rotated_games``: only games about to be on screen are
|
||||||
|
asked about. ``get_odds`` still caches per game, so a game re-entering
|
||||||
|
the window inside its TTL costs a cache lookup, not a request.
|
||||||
|
|
||||||
|
The rotation state read here is the previous cycle's -- the new list is
|
||||||
|
still being built -- which is exactly the question being asked: is this
|
||||||
|
game at or near the position currently on the panel?
|
||||||
|
"""
|
||||||
|
# Read defensively: this predicate lives on SportsCore so it sits
|
||||||
|
# beside _fetch_odds, but live_games/_rotation_schedule belong to
|
||||||
|
# SportsLive, which is the only caller.
|
||||||
|
with self._games_lock:
|
||||||
|
games = list(getattr(self, "live_games", ()) or ())
|
||||||
|
index = getattr(self, "current_game_index", 0)
|
||||||
|
schedule = list(getattr(self, "_rotation_schedule", ()) or ())
|
||||||
|
if not games:
|
||||||
|
# Cold start: nothing is on screen yet, so let the games seen on
|
||||||
|
# this first pass through rather than render a blank line for a
|
||||||
|
# whole cycle. Bounded -- the next pass has a rotation to narrow by.
|
||||||
|
return True
|
||||||
|
order = schedule or [g.get("id") for g in games]
|
||||||
|
if not order:
|
||||||
|
return True
|
||||||
|
start = index if 0 <= index < len(order) else 0
|
||||||
|
wanted = {
|
||||||
|
order[(start + offset) % len(order)]
|
||||||
|
for offset in range(self._LIVE_ODDS_LOOKAHEAD + 1)
|
||||||
|
}
|
||||||
|
return game.get("id") in wanted
|
||||||
|
|
||||||
|
#: Hour of the Eastern day past which last night's games are assumed over.
|
||||||
|
#:
|
||||||
|
#: The live fetch asks ESPN for a two-day window so a game that started
|
||||||
|
#: yesterday and is still running is not lost. ESPN rejects date *ranges*,
|
||||||
|
#: so that window is split into one request per day -- doubling every live
|
||||||
|
#: poll. Measured 2026-09-19: 1,858 requests per rig spent on yesterday's
|
||||||
|
#: date, which after breakfast holds nothing but final games.
|
||||||
|
#:
|
||||||
|
#: No sport on these boards runs six hours past midnight, and one that
|
||||||
|
#: somehow did is still covered: a game already being tracked keeps its own
|
||||||
|
#: day in the window regardless of the hour.
|
||||||
|
_LOOKBACK_CUTOFF_HOUR: ClassVar[int] = 6
|
||||||
|
|
||||||
|
def _needs_previous_day(self, now: datetime) -> bool:
|
||||||
|
"""Whether the previous Eastern day can still hold a live game."""
|
||||||
|
if now.hour < self._LOOKBACK_CUTOFF_HOUR:
|
||||||
|
return True
|
||||||
|
previous = (now - timedelta(days=1)).strftime("%Y%m%d")
|
||||||
|
for game in (getattr(self, "live_games", None) or []):
|
||||||
|
start: Any = game.get("start_time_utc") if hasattr(game, "get") else None
|
||||||
|
try:
|
||||||
|
if start.astimezone(now.tzinfo).strftime("%Y%m%d") == previous:
|
||||||
|
return True
|
||||||
|
except (AttributeError, ValueError, OSError, OverflowError):
|
||||||
|
continue
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _background_fetches_espn_ranges(self) -> bool:
|
||||||
|
"""Can the core's background service fetch an ESPN date range?
|
||||||
|
|
||||||
|
Cores from before the 2026-09-15 fix send a season range to ESPN as-is,
|
||||||
|
which now answers 400 for every sport. On those cores the managers fetch
|
||||||
|
the season themselves with _fetch_season_directly instead.
|
||||||
|
"""
|
||||||
|
service = getattr(self, "background_service", None)
|
||||||
|
return bool(getattr(service, "handles_espn_date_ranges", False))
|
||||||
|
|
||||||
|
def _fetch_season_directly(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
datestring: str,
|
||||||
|
cache_key: str,
|
||||||
|
label: str,
|
||||||
|
ttl: Optional[int] = None,
|
||||||
|
) -> Optional[Dict]:
|
||||||
|
"""Fetch a season schedule on this thread, in chunks ESPN accepts, and cache it.
|
||||||
|
|
||||||
|
``label`` names the schedule in log lines, e.g. ``"2026 season"``.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = fetch_espn_scoreboard(
|
||||||
|
self.session,
|
||||||
|
url,
|
||||||
|
params={"dates": datestring, "limit": ESPN_MAX_LIMIT},
|
||||||
|
headers=self.headers,
|
||||||
|
timeout=30,
|
||||||
|
logger=self.logger,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Failed to fetch {label} schedule: {e}")
|
||||||
|
return None
|
||||||
|
if ttl is None:
|
||||||
|
self.cache_manager.set(cache_key, data)
|
||||||
|
else:
|
||||||
|
self.cache_manager.set(cache_key, data, ttl=ttl)
|
||||||
|
self.logger.info(
|
||||||
|
f"Fetched {label} schedule: {len(data.get('events', []))} events"
|
||||||
|
)
|
||||||
|
return data
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
"""Timezone resolution for the scoreboard plugins.
|
||||||
|
|
||||||
|
Game start times arrive from ESPN in UTC and have to be converted to the
|
||||||
|
user's local zone before they are drawn. This module owns the "which zone?"
|
||||||
|
decision so every part of a scoreboard (its scorebug, its scroll-mode game
|
||||||
|
card and its plugin manager) agrees.
|
||||||
|
|
||||||
|
The scoreboards each carried a copy of this module as ``<sport>_timezone.py``.
|
||||||
|
The copies were identical apart from two per-plugin values, which are
|
||||||
|
keyword-only arguments here: ``plugin_label``, the name the final warning
|
||||||
|
tells the user to open, and ``writeback_fixed_in`` (see below).
|
||||||
|
|
||||||
|
Resolution order, first valid wins:
|
||||||
|
|
||||||
|
1. ``timezone`` in the plugin's own config (explicit per-plugin override)
|
||||||
|
2. The LEDMatrix global timezone via ``plugin_manager.config_manager``
|
||||||
|
3. The LEDMatrix global timezone via ``cache_manager.config_manager``
|
||||||
|
4. The host system's zone (``TZ``, ``/etc/timezone``, ``/etc/localtime``)
|
||||||
|
5. UTC
|
||||||
|
|
||||||
|
Steps 2 and 3 matter because the core does not consistently hang
|
||||||
|
``config_manager`` off both objects -- reading only one of them is what made
|
||||||
|
a scoreboard fall through to UTC while the clock plugin (which checks
|
||||||
|
``plugin_manager`` first) showed the right time on the same device. Step 4 is
|
||||||
|
the backstop for cores that expose no ``config_manager`` at all: a Pi with its
|
||||||
|
system clock set correctly should never end up rendering UTC.
|
||||||
|
|
||||||
|
Two traps this module exists to avoid, both of which render every start time
|
||||||
|
in UTC on a correctly-configured device:
|
||||||
|
|
||||||
|
* **The stale ``"UTC"`` artifact.** Some plugins once wrote
|
||||||
|
``"timezone": "UTC"`` into the *saved* config whenever resolution failed, and
|
||||||
|
that write-back stuck -- thereafter shadowing the real global timezone. Such
|
||||||
|
a plugin passes ``writeback_fixed_in`` (the release that fixed it), and step 1
|
||||||
|
then treats a bare ``"UTC"`` as suspect: it is honored only when nothing
|
||||||
|
downstream disagrees. ``Etc/UTC`` is the unambiguous spelling for "I really
|
||||||
|
do want UTC"; the bug never produced it, so it is always honored. A plugin
|
||||||
|
that never had the bug leaves ``writeback_fixed_in`` as ``None``, and its
|
||||||
|
plugin-level ``"UTC"`` is honored verbatim.
|
||||||
|
* **``get_timezone()``'s own default.** The core's
|
||||||
|
``ConfigManager.get_timezone()`` is ``self.config.get('timezone', 'UTC')``, so
|
||||||
|
it hands back ``"UTC"`` for a config that simply has no ``timezone`` key.
|
||||||
|
Steps 2 and 3 read the raw config dict instead, so an absent key falls
|
||||||
|
through to the system zone rather than latching onto that default.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Any, Dict, Iterable, Optional, Tuple
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _from_config_manager(config_manager: Any, log: logging.Logger) -> Optional[str]:
|
||||||
|
"""Pull the global timezone out of a core ConfigManager, if it has one.
|
||||||
|
|
||||||
|
Reads the raw config dict in preference to ``get_timezone()``. The core's
|
||||||
|
``ConfigManager.get_timezone()`` is ``self.config.get('timezone', 'UTC')`` --
|
||||||
|
it substitutes its own ``"UTC"`` when the key is absent, which is
|
||||||
|
indistinguishable from the user deliberately choosing UTC. Taking that at
|
||||||
|
face value would mask a missing global setting and stop resolution ever
|
||||||
|
reaching the host system zone. So: if the raw config is readable and has no
|
||||||
|
``timezone`` key, report "nothing here" and let the caller fall through.
|
||||||
|
``get_timezone()`` is only consulted for cores that expose no raw config.
|
||||||
|
"""
|
||||||
|
if config_manager is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
raw_readable = False
|
||||||
|
for loader_name in ("get_config", "load_config"):
|
||||||
|
loader = getattr(config_manager, loader_name, None)
|
||||||
|
if not callable(loader):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
main_config = loader()
|
||||||
|
except Exception:
|
||||||
|
log.debug("config_manager.%s() failed", loader_name, exc_info=True)
|
||||||
|
continue
|
||||||
|
if isinstance(main_config, dict):
|
||||||
|
raw_readable = True
|
||||||
|
name: Optional[str] = main_config.get("timezone")
|
||||||
|
if name:
|
||||||
|
return name
|
||||||
|
|
||||||
|
if raw_readable:
|
||||||
|
return None
|
||||||
|
|
||||||
|
getter = getattr(config_manager, "get_timezone", None)
|
||||||
|
if callable(getter):
|
||||||
|
try:
|
||||||
|
name = getter()
|
||||||
|
if name:
|
||||||
|
return name
|
||||||
|
except Exception:
|
||||||
|
log.debug("config_manager.get_timezone() failed", exc_info=True)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def system_timezone_name() -> Optional[str]:
|
||||||
|
"""Best-effort IANA name for the host's configured timezone."""
|
||||||
|
name = os.environ.get("TZ")
|
||||||
|
if name:
|
||||||
|
return name
|
||||||
|
|
||||||
|
# Debian / Raspberry Pi OS record the zone name here.
|
||||||
|
try:
|
||||||
|
with open("/etc/timezone", "r", encoding="utf-8") as handle:
|
||||||
|
name = handle.read().strip()
|
||||||
|
if name:
|
||||||
|
return name
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Otherwise /etc/localtime is a symlink into the zoneinfo tree.
|
||||||
|
try:
|
||||||
|
path = os.path.realpath("/etc/localtime")
|
||||||
|
marker = "zoneinfo" + os.sep
|
||||||
|
if marker in path:
|
||||||
|
return path.split(marker, 1)[1]
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _validated(name: object, source: str, log: logging.Logger) -> Optional[str]:
|
||||||
|
"""Return a usable IANA name, or None if blank/absent/not a real zone."""
|
||||||
|
if not isinstance(name, str):
|
||||||
|
return None
|
||||||
|
name = name.strip()
|
||||||
|
if not name:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
pytz.timezone(name)
|
||||||
|
except pytz.UnknownTimeZoneError:
|
||||||
|
log.warning("Ignoring invalid timezone %r from %s", name, source)
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
# Not an unknown-zone error, so something else went wrong inside pytz.
|
||||||
|
# Log it loudly rather than silently reclassifying it as "invalid" --
|
||||||
|
# but still don't propagate: this runs in the render path, and a
|
||||||
|
# mislabelled zone beats taking the whole display down.
|
||||||
|
log.warning(
|
||||||
|
"Unexpected error validating timezone %r from %s; ignoring it",
|
||||||
|
name, source, exc_info=True,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_timezone_name(
|
||||||
|
config: Optional[Dict[str, Any]] = None,
|
||||||
|
plugin_manager: Any = None,
|
||||||
|
cache_manager: Any = None,
|
||||||
|
log: Optional[logging.Logger] = None,
|
||||||
|
*,
|
||||||
|
plugin_label: str,
|
||||||
|
writeback_fixed_in: Optional[str] = None,
|
||||||
|
) -> str:
|
||||||
|
"""Return the IANA timezone name to render game times in.
|
||||||
|
|
||||||
|
Never raises and never returns an empty string; falls back to ``"UTC"``
|
||||||
|
only when every source is missing or invalid.
|
||||||
|
|
||||||
|
``plugin_label`` names the plugin in the warning logged when nothing
|
||||||
|
resolves (e.g. ``"hockey scoreboard"``). ``writeback_fixed_in`` is the
|
||||||
|
plugin release that stopped writing ``"UTC"`` back into the saved config,
|
||||||
|
for a plugin that ever did; ``None`` (the default) otherwise.
|
||||||
|
"""
|
||||||
|
log = log or logger
|
||||||
|
|
||||||
|
def downstream():
|
||||||
|
"""Yield (source, name) for everything except the plugin's own config.
|
||||||
|
|
||||||
|
Lazy: ``SportsCore._get_timezone()`` runs this once per game and the
|
||||||
|
answer is almost always already in the plugin config, so evaluating on
|
||||||
|
demand keeps the common case from calling into both config managers and
|
||||||
|
stat-ing the host timezone files every time.
|
||||||
|
"""
|
||||||
|
yield (
|
||||||
|
"plugin_manager.config_manager",
|
||||||
|
_from_config_manager(getattr(plugin_manager, "config_manager", None), log),
|
||||||
|
)
|
||||||
|
yield (
|
||||||
|
"cache_manager.config_manager",
|
||||||
|
_from_config_manager(getattr(cache_manager, "config_manager", None), log),
|
||||||
|
)
|
||||||
|
yield "system timezone", system_timezone_name()
|
||||||
|
|
||||||
|
def first_valid(
|
||||||
|
sources: Iterable[Tuple[str, Any]],
|
||||||
|
) -> Tuple[Optional[str], Optional[str]]:
|
||||||
|
for source, name in sources:
|
||||||
|
name = _validated(name, source, log)
|
||||||
|
if name:
|
||||||
|
return source, name
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
plugin_value = _validated((config or {}).get("timezone"), "plugin config", log)
|
||||||
|
|
||||||
|
if writeback_fixed_in is not None and plugin_value and plugin_value.lower() == "utc":
|
||||||
|
# Before writeback_fixed_in this plugin wrote "timezone": "UTC" into
|
||||||
|
# the saved config whenever it failed to resolve a global timezone, and
|
||||||
|
# that write-back persisted. A bare "UTC" is therefore far more likely
|
||||||
|
# to be that artifact than a deliberate choice -- it only ever appeared
|
||||||
|
# on failure. Honor it only when nothing downstream disagrees; a user
|
||||||
|
# who genuinely wants UTC writes the unambiguous "Etc/UTC", which the
|
||||||
|
# bug never produced and which falls through to the normal path below.
|
||||||
|
source, downstream_name = first_valid(downstream())
|
||||||
|
if downstream_name and downstream_name.lower() not in ("utc", "etc/utc"):
|
||||||
|
log.warning(
|
||||||
|
"Ignoring the plugin-level timezone 'UTC': it is almost "
|
||||||
|
"certainly left over from the write-back bug fixed in %s, and "
|
||||||
|
"%s says %s. Using %s. If you really do want UTC here, set "
|
||||||
|
"this plugin's timezone to 'Etc/UTC' instead.",
|
||||||
|
writeback_fixed_in, source, downstream_name, downstream_name,
|
||||||
|
)
|
||||||
|
return downstream_name
|
||||||
|
log.debug("Plugin-level timezone 'UTC' agrees with %s; using UTC", source or "no other source")
|
||||||
|
return "UTC"
|
||||||
|
|
||||||
|
if plugin_value:
|
||||||
|
log.debug("Resolved timezone %s from plugin config", plugin_value)
|
||||||
|
return plugin_value
|
||||||
|
|
||||||
|
source, name = first_valid(downstream())
|
||||||
|
if name:
|
||||||
|
log.debug("Resolved timezone %s from %s", name, source)
|
||||||
|
return name
|
||||||
|
|
||||||
|
log.warning(
|
||||||
|
"Could not determine a timezone from the plugin config, the LEDMatrix "
|
||||||
|
"config or the system; game times will be shown in UTC. Set a timezone "
|
||||||
|
"in the %s's Advanced Settings to override.",
|
||||||
|
plugin_label,
|
||||||
|
)
|
||||||
|
return "UTC"
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_timezone(
|
||||||
|
config: Optional[Dict[str, Any]] = None,
|
||||||
|
plugin_manager: Any = None,
|
||||||
|
cache_manager: Any = None,
|
||||||
|
log: Optional[logging.Logger] = None,
|
||||||
|
*,
|
||||||
|
plugin_label: str,
|
||||||
|
writeback_fixed_in: Optional[str] = None,
|
||||||
|
):
|
||||||
|
"""``resolve_timezone_name`` as a ready-to-use tzinfo object."""
|
||||||
|
return pytz.timezone(
|
||||||
|
resolve_timezone_name(
|
||||||
|
config=config,
|
||||||
|
plugin_manager=plugin_manager,
|
||||||
|
cache_manager=cache_manager,
|
||||||
|
log=log,
|
||||||
|
plugin_label=plugin_label,
|
||||||
|
writeback_fixed_in=writeback_fixed_in,
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -0,0 +1,539 @@
|
|||||||
|
"""src.common.favorite_team_check: the messages a user sees for a bad team code.
|
||||||
|
|
||||||
|
Ported from the scoreboard plugins' own tests (hockey-scoreboard's
|
||||||
|
test_favorite_check.py and football-scoreboard's
|
||||||
|
test_schedule_note_uses_game_dates.py), which test their bundled
|
||||||
|
``<sport>_favorite_check.py`` copies. Core now owns a copy that can drift on
|
||||||
|
its own, and a plugin that deletes its copy loses those tests with it.
|
||||||
|
|
||||||
|
Everything here is offline: ESPN is replaced with a fixed roster, so the tests
|
||||||
|
pin the *messages* the user actually sees. They are the point of the feature --
|
||||||
|
the whole thing exists to turn a blank screen into a sentence that says why.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from src.common.favorite_team_check import FavoriteTeamCheck
|
||||||
|
|
||||||
|
NHL = {
|
||||||
|
"BOS": "Boston Bruins",
|
||||||
|
"TB": "Tampa Bay Lightning",
|
||||||
|
"UTAH": "Utah Mammoth",
|
||||||
|
"SEA": "Seattle Kraken",
|
||||||
|
"VGK": "Vegas Golden Knights",
|
||||||
|
}
|
||||||
|
|
||||||
|
NCAA = {
|
||||||
|
"ALA": "Alabama Crimson Tide",
|
||||||
|
"UNA": "North Alabama Lions",
|
||||||
|
"CONN": "UConn Huskies",
|
||||||
|
"SC": "South Carolina Gamecocks",
|
||||||
|
"RUTG": "Rutgers Scarlet Knights",
|
||||||
|
"GS": "Golden State Warriors",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingLogger(logging.Logger):
|
||||||
|
"""Captures formatted records so tests can assert on the message text."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__("test")
|
||||||
|
self.records = []
|
||||||
|
|
||||||
|
def handle(self, record):
|
||||||
|
self.records.append((record.levelname, record.getMessage()))
|
||||||
|
|
||||||
|
def messages(self, level=None):
|
||||||
|
return [m for lvl, m in self.records if level is None or lvl == level]
|
||||||
|
|
||||||
|
|
||||||
|
class SuggestionTests(unittest.TestCase):
|
||||||
|
"""The suggestion has to name the right team, and name it first."""
|
||||||
|
|
||||||
|
def assert_first_suggestion(self, code, teams, expected):
|
||||||
|
message = FavoriteTeamCheck._suggest(code, teams)
|
||||||
|
self.assertIn(expected, message,
|
||||||
|
"{!r} did not suggest {!r}: {}".format(code, expected, message))
|
||||||
|
# Where several candidates are listed, the right one must lead, since
|
||||||
|
# users act on the first thing they read.
|
||||||
|
head = message.split("(")[0]
|
||||||
|
self.assertIn(expected, head,
|
||||||
|
"{!r} buried {!r} behind another suggestion: {}".format(
|
||||||
|
code, expected, message))
|
||||||
|
|
||||||
|
def test_retired_code_points_at_the_current_one(self):
|
||||||
|
self.assert_first_suggestion("UTA", NHL, "UTAH")
|
||||||
|
|
||||||
|
def test_common_wrong_guesses(self):
|
||||||
|
self.assert_first_suggestion("GSW", NCAA, "GS")
|
||||||
|
self.assert_first_suggestion("UCONN", NCAA, "CONN")
|
||||||
|
|
||||||
|
def test_fragment_of_a_word_is_matched(self):
|
||||||
|
# 'BAMA' abbreviates nothing in 'Alabama Crimson Tide' -- it is a chunk
|
||||||
|
# out of the middle of a word -- so the initials rule alone misses it.
|
||||||
|
self.assert_first_suggestion("BAMA", NCAA, "ALA")
|
||||||
|
|
||||||
|
def test_first_word_beats_a_mid_name_match(self):
|
||||||
|
# 'SCAR' fits Rutgers *Scar*let too, but South Carolina starts at the
|
||||||
|
# first word, which is how people actually shorten a name.
|
||||||
|
self.assert_first_suggestion("SCAR", NCAA, "SC")
|
||||||
|
|
||||||
|
def test_wrong_case_says_so_rather_than_guessing(self):
|
||||||
|
message = FavoriteTeamCheck._suggest("bos", NHL)
|
||||||
|
self.assertIn("case-sensitive", message)
|
||||||
|
self.assertIn("'BOS'", message)
|
||||||
|
|
||||||
|
def test_valid_code_draws_no_suggestion(self):
|
||||||
|
self.assertEqual(FavoriteTeamCheck._suggest("BOS", NHL), "")
|
||||||
|
|
||||||
|
def test_unmatchable_code_is_not_forced_into_a_suggestion(self):
|
||||||
|
# Nothing sensible to offer is better than something wrong.
|
||||||
|
self.assertEqual(FavoriteTeamCheck._suggest("ZZZZZZ", NHL), "")
|
||||||
|
|
||||||
|
def test_abbreviates_separates_codes_string_distance_ties(self):
|
||||||
|
# The case that motivated this: 'MUN' is equidistant from 'MAN' and
|
||||||
|
# 'SUN' by string similarity, so similarity cannot choose.
|
||||||
|
self.assertTrue(FavoriteTeamCheck._abbreviates("MUN", "Manchester United"))
|
||||||
|
self.assertFalse(FavoriteTeamCheck._abbreviates("MUN", "Sunderland"))
|
||||||
|
|
||||||
|
|
||||||
|
class CheckTests(unittest.TestCase):
|
||||||
|
"""The log output for each way a league can end up empty."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.logger = RecordingLogger()
|
||||||
|
self.checker = FavoriteTeamCheck(self.logger, {"nhl": ("NHL", "hockey/nhl")})
|
||||||
|
self.checker._fetch_teams = staticmethod(lambda path: dict(NHL))
|
||||||
|
self.checker._schedule_note = staticmethod(lambda path: None)
|
||||||
|
|
||||||
|
def test_bad_code_is_reported_with_a_suggestion(self):
|
||||||
|
self.checker._check("nhl", ["UTA", "BOS"])
|
||||||
|
warnings = self.logger.messages("WARNING")
|
||||||
|
self.assertEqual(len(warnings), 1)
|
||||||
|
self.assertIn("'UTA' is not a NHL team code", warnings[0])
|
||||||
|
self.assertIn("UTAH", warnings[0])
|
||||||
|
|
||||||
|
def test_all_codes_bad_says_nothing_will_show(self):
|
||||||
|
self.checker._check("nhl", ["UTA", "NOPE"])
|
||||||
|
joined = " ".join(self.logger.messages("WARNING"))
|
||||||
|
self.assertIn("no recognised favorite teams", joined)
|
||||||
|
self.assertIn("nothing will be shown", joined)
|
||||||
|
|
||||||
|
def test_good_codes_out_of_season_explain_the_empty_screen(self):
|
||||||
|
self.checker._schedule_note = staticmethod(
|
||||||
|
lambda path: "the league has nothing on until 07 October 2026")
|
||||||
|
self.checker._check("nhl", ["BOS"])
|
||||||
|
info = " ".join(self.logger.messages("INFO"))
|
||||||
|
self.assertIn("look correct", info)
|
||||||
|
self.assertIn("07 October 2026", info)
|
||||||
|
self.assertIn("not a configuration problem", info)
|
||||||
|
self.assertEqual(self.logger.messages("WARNING"), [])
|
||||||
|
|
||||||
|
def test_good_codes_in_season_stay_quiet(self):
|
||||||
|
self.checker._check("nhl", ["BOS", "TB"])
|
||||||
|
self.assertEqual(self.logger.messages("WARNING"), [])
|
||||||
|
self.assertIn("recognised: BOS, TB", " ".join(self.logger.messages("INFO")))
|
||||||
|
|
||||||
|
def test_dynamic_groups_are_not_treated_as_team_codes(self):
|
||||||
|
self.checker._check("nhl", ["AP_TOP_25", "NCAA_MENS_TOP_10"])
|
||||||
|
self.assertEqual(self.logger.messages("WARNING"), [])
|
||||||
|
|
||||||
|
def test_empty_roster_draws_no_conclusion(self):
|
||||||
|
# ESPN's college lacrosse endpoints return zero teams; a valid code
|
||||||
|
# must not be called wrong just because the roster is unavailable.
|
||||||
|
self.checker._fetch_teams = staticmethod(lambda path: {})
|
||||||
|
self.checker._check("nhl", ["ANYTHING"])
|
||||||
|
self.assertEqual(self.logger.records, [])
|
||||||
|
|
||||||
|
def test_fetch_failure_is_swallowed(self):
|
||||||
|
def boom(path):
|
||||||
|
raise RuntimeError("network down")
|
||||||
|
|
||||||
|
self.checker._fetch_teams = staticmethod(boom)
|
||||||
|
self.checker._check("nhl", ["BOS"]) # must not raise
|
||||||
|
self.assertEqual(self.logger.messages("WARNING"), [])
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduleNoteTests(unittest.TestCase):
|
||||||
|
"""
|
||||||
|
Reading ESPN's scoreboard for "is there anything on?".
|
||||||
|
|
||||||
|
Both traps here are real API behaviour, confirmed against live endpoints:
|
||||||
|
an out-of-season league rolls forward to its next fixtures rather than
|
||||||
|
returning nothing, and a finished season returns its *last* game instead.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def note(self, payload):
|
||||||
|
"""Run _schedule_note against a fixed payload, with no network access.
|
||||||
|
|
||||||
|
The method imports ``requests`` in its own body, so the fake has to go
|
||||||
|
into ``sys.modules`` — patching the attribute on the module has
|
||||||
|
no effect on a function-local import.
|
||||||
|
"""
|
||||||
|
import types
|
||||||
|
|
||||||
|
class Response:
|
||||||
|
@staticmethod
|
||||||
|
def json():
|
||||||
|
return payload
|
||||||
|
|
||||||
|
fake = types.ModuleType("requests")
|
||||||
|
fake.get = lambda url, timeout=None: Response()
|
||||||
|
|
||||||
|
real = sys.modules.get("requests")
|
||||||
|
sys.modules["requests"] = fake
|
||||||
|
try:
|
||||||
|
return FavoriteTeamCheck._schedule_note("hockey/nhl")
|
||||||
|
finally:
|
||||||
|
if real is None:
|
||||||
|
sys.modules.pop("requests", None)
|
||||||
|
else:
|
||||||
|
sys.modules["requests"] = real
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def iso(days):
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
return (datetime.now(timezone.utc) + timedelta(days=days)).isoformat()
|
||||||
|
|
||||||
|
def test_games_today_says_nothing(self):
|
||||||
|
self.assertIsNone(self.note({"events": [{"date": self.iso(0)}]}))
|
||||||
|
|
||||||
|
def test_a_game_already_under_way_counts_as_something_on(self):
|
||||||
|
# Games that started earlier today are in the past by the clock. Reading
|
||||||
|
# them as "not upcoming" made a live slate report the season as over.
|
||||||
|
self.assertIsNone(self.note({"events": [{"date": self.iso(-0.3)}]}))
|
||||||
|
|
||||||
|
def test_an_off_day_or_two_is_not_worth_mentioning(self):
|
||||||
|
self.assertIsNone(self.note({"events": [{"date": self.iso(1)}]}))
|
||||||
|
|
||||||
|
def test_out_of_season_reports_the_next_fixture(self):
|
||||||
|
# ESPN rolls forward, so events exist but are months away.
|
||||||
|
note = self.note({"events": [{"date": self.iso(52)}, {"date": self.iso(53)}]})
|
||||||
|
self.assertIsNotNone(note)
|
||||||
|
self.assertIn("nothing on until", note)
|
||||||
|
|
||||||
|
def test_finished_season_is_reported_as_finished(self):
|
||||||
|
# A completed season returns its last game, in the past.
|
||||||
|
note = self.note({"events": [{"date": self.iso(-120)}],
|
||||||
|
"leagues": [{"calendar": [self.iso(-300)]}]})
|
||||||
|
self.assertIsNotNone(note)
|
||||||
|
self.assertIn("season has finished", note)
|
||||||
|
|
||||||
|
def test_past_dates_never_read_as_imminent(self):
|
||||||
|
# The bug this guards: taking the soonest of *all* dates makes a game
|
||||||
|
# from last March look like one happening right now, so a finished
|
||||||
|
# season silently reports itself as in progress.
|
||||||
|
note = self.note({"events": [{"date": self.iso(-120)},
|
||||||
|
{"date": self.iso(40)}]})
|
||||||
|
self.assertIn("nothing on until", note)
|
||||||
|
|
||||||
|
def test_calendar_is_used_when_there_are_no_events(self):
|
||||||
|
note = self.note({"events": [],
|
||||||
|
"leagues": [{"calendar": [{"startDate": self.iso(30)}]}]})
|
||||||
|
self.assertIn("nothing on until", note)
|
||||||
|
|
||||||
|
def test_nothing_published_draws_no_conclusion(self):
|
||||||
|
self.assertIsNone(self.note({"events": [], "leagues": [{"calendar": []}]}))
|
||||||
|
|
||||||
|
def test_unparseable_dates_are_ignored_rather_than_fatal(self):
|
||||||
|
self.assertIsNone(self.note(
|
||||||
|
{"events": [{"date": "not a date"}, {"date": None}, {}]}))
|
||||||
|
|
||||||
|
|
||||||
|
class SchedulingTests(unittest.TestCase):
|
||||||
|
"""The check must run once, off the render path, and never raise."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.logger = RecordingLogger()
|
||||||
|
self.checker = FavoriteTeamCheck(self.logger, {"nhl": ("NHL", "hockey/nhl")})
|
||||||
|
self.calls = []
|
||||||
|
self.checker._check = lambda key, favs: self.calls.append((key, list(favs)))
|
||||||
|
|
||||||
|
def drain(self):
|
||||||
|
import threading
|
||||||
|
for thread in threading.enumerate():
|
||||||
|
if thread.name == "favorite-team-check":
|
||||||
|
thread.join(timeout=5)
|
||||||
|
|
||||||
|
def test_runs_once_per_league(self):
|
||||||
|
for _ in range(5):
|
||||||
|
self.checker.schedule("nhl", ["BOS"])
|
||||||
|
self.drain()
|
||||||
|
self.assertEqual(len(self.calls), 1)
|
||||||
|
|
||||||
|
def test_reset_allows_a_recheck_after_a_config_edit(self):
|
||||||
|
self.checker.schedule("nhl", ["BOS"])
|
||||||
|
self.drain()
|
||||||
|
self.checker.reset()
|
||||||
|
self.checker.schedule("nhl", ["TB"])
|
||||||
|
self.drain()
|
||||||
|
self.assertEqual([favs for _, favs in self.calls], [["BOS"], ["TB"]])
|
||||||
|
|
||||||
|
def test_no_favorites_configured_does_nothing(self):
|
||||||
|
self.checker.schedule("nhl", [])
|
||||||
|
self.checker.schedule("nhl", None)
|
||||||
|
self.checker.schedule("nhl", ["", " "])
|
||||||
|
self.drain()
|
||||||
|
self.assertEqual(self.calls, [])
|
||||||
|
|
||||||
|
def test_unknown_league_key_is_ignored(self):
|
||||||
|
self.checker.schedule("not-a-league", ["BOS"])
|
||||||
|
self.drain()
|
||||||
|
self.assertEqual(self.calls, [])
|
||||||
|
|
||||||
|
def test_thread_is_a_daemon_so_it_cannot_hold_up_shutdown(self):
|
||||||
|
import threading
|
||||||
|
started = threading.Event()
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
def record(key, favs):
|
||||||
|
seen["daemon"] = threading.current_thread().daemon
|
||||||
|
started.set()
|
||||||
|
|
||||||
|
self.checker._check = record
|
||||||
|
self.checker.schedule("nhl", ["BOS"])
|
||||||
|
self.assertTrue(started.wait(timeout=5))
|
||||||
|
self.assertTrue(seen["daemon"])
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduleNoteUsesGameDatesTests(unittest.TestCase):
|
||||||
|
"""The "nothing on until" note reports a game date, not a calendar boundary.
|
||||||
|
|
||||||
|
``_schedule_note`` used to pool ESPN's rolled-forward event dates with the
|
||||||
|
league calendar's week/phase startDates and take the earliest. Calendar
|
||||||
|
weeks routinely open days before their first game, so the note reported
|
||||||
|
"nothing on until 06 September" for a league whose first snap was the
|
||||||
|
10th. Events now win; the calendar only speaks when the scoreboard has no
|
||||||
|
events at all.
|
||||||
|
"""
|
||||||
|
|
||||||
|
note = ScheduleNoteTests.note
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def iso(base, days_out):
|
||||||
|
# Every date in a case derives from one captured *base*, so a UTC
|
||||||
|
# midnight crossing mid-test cannot make the payload and the expected
|
||||||
|
# strftime disagree about the day.
|
||||||
|
return (base + timedelta(days=days_out)).strftime("%Y-%m-%dT%H:%MZ")
|
||||||
|
|
||||||
|
def test_the_first_game_wins_over_an_earlier_calendar_boundary(self):
|
||||||
|
base = datetime.now(timezone.utc)
|
||||||
|
note = self.note({
|
||||||
|
"events": [{"date": self.iso(base, 10)}, {"date": self.iso(base, 14)}],
|
||||||
|
"leagues": [{"calendar": [{"startDate": self.iso(base, 6)}]}],
|
||||||
|
})
|
||||||
|
self.assertIn((base + timedelta(days=10)).strftime("%d %B %Y"), note)
|
||||||
|
self.assertNotIn((base + timedelta(days=6)).strftime("%d %B %Y"), note)
|
||||||
|
|
||||||
|
def test_with_no_events_the_calendar_still_gets_a_say(self):
|
||||||
|
base = datetime.now(timezone.utc)
|
||||||
|
note = self.note({"events": [], "leagues": [{"calendar": [self.iso(base, 20)]}]})
|
||||||
|
self.assertIn((base + timedelta(days=20)).strftime("%d %B %Y"), note)
|
||||||
|
|
||||||
|
def test_only_past_dates_reads_as_a_finished_season(self):
|
||||||
|
base = datetime.now(timezone.utc)
|
||||||
|
note = self.note({"events": [{"date": self.iso(base, -40)}],
|
||||||
|
"leagues": [{"calendar": []}]})
|
||||||
|
self.assertIn("finished", note)
|
||||||
|
|
||||||
|
def test_a_finished_season_is_not_dressed_up_by_an_offseason_calendar(self):
|
||||||
|
# Past events mean the season is over; a future calendar boundary
|
||||||
|
# (the draft, next season's week 1 shell) is not the next game.
|
||||||
|
base = datetime.now(timezone.utc)
|
||||||
|
note = self.note({"events": [{"date": self.iso(base, -40)}],
|
||||||
|
"leagues": [{"calendar": [{"startDate": self.iso(base, 45)}]}]})
|
||||||
|
self.assertIn("finished", note)
|
||||||
|
|
||||||
|
def test_an_imminent_slate_is_not_worth_a_note(self):
|
||||||
|
base = datetime.now(timezone.utc)
|
||||||
|
self.assertIsNone(self.note({
|
||||||
|
"events": [{"date": self.iso(base, 1)}],
|
||||||
|
"leagues": [{"calendar": [{"startDate": self.iso(base, 6)}]}],
|
||||||
|
}))
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduleNotePostseasonTests(unittest.TestCase):
|
||||||
|
"""Past regular-season events are not a finished season once the postseason starts.
|
||||||
|
|
||||||
|
Captured from ESPN's MLB scoreboard on 2026-09-29, two days after the
|
||||||
|
regular season ended: the default scoreboard still returned the last
|
||||||
|
regular-season day (event ``season.type`` 2), while ``leagues[0].season``
|
||||||
|
had already moved to Postseason (type 3). The check told a Rays fan the
|
||||||
|
season was over while their upcoming manager listed TB's wild-card games.
|
||||||
|
"""
|
||||||
|
|
||||||
|
note = ScheduleNoteTests.note
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def payload(league_type, event_type, days=-2):
|
||||||
|
date = (datetime.now(timezone.utc) + timedelta(days=days)).isoformat()
|
||||||
|
return {
|
||||||
|
"events": [{"date": date, "season": {"year": 2026, "type": event_type}}],
|
||||||
|
"leagues": [{"season": {"year": 2026, "type": {"type": league_type}},
|
||||||
|
"calendar": [date]}],
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_postseason_after_regular_season_events_is_not_finished(self):
|
||||||
|
self.assertIsNone(self.note(self.payload(league_type=3, event_type=2)))
|
||||||
|
|
||||||
|
def test_a_finished_postseason_is_still_reported_as_finished(self):
|
||||||
|
# AFL the week after its grand final: league and events both postseason.
|
||||||
|
note = self.note(self.payload(league_type=3, event_type=3, days=-4))
|
||||||
|
self.assertIn("season has finished", note)
|
||||||
|
|
||||||
|
def test_offseason_after_the_postseason_is_still_finished(self):
|
||||||
|
note = self.note(self.payload(league_type=4, event_type=3, days=-40))
|
||||||
|
self.assertIn("season has finished", note)
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduleNoteMatchdayTests(unittest.TestCase):
|
||||||
|
"""Between soccer matchdays the next fixture is only in the calendar.
|
||||||
|
|
||||||
|
Captured from ESPN's Premier League scoreboard on 2026-09-29: the
|
||||||
|
scoreboard still showed the 20 September matchday, the league was in the
|
||||||
|
same season phase as those events, and the next games (10 October) were
|
||||||
|
only in ``leagues[0].calendar`` -- a ``"day"`` calendar with
|
||||||
|
``calendarIsWhitelist`` true, i.e. the days that have games. The check
|
||||||
|
said the season had finished.
|
||||||
|
"""
|
||||||
|
|
||||||
|
note = ScheduleNoteTests.note
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def payload(event_days, calendar_days, whitelist=True):
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
def iso(days):
|
||||||
|
return (now + timedelta(days=days)).strftime("%Y-%m-%dT%H:%MZ")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"events": [{"date": iso(d), "season": {"type": 14308}}
|
||||||
|
for d in event_days],
|
||||||
|
"leagues": [{"season": {"type": {"type": 14308}},
|
||||||
|
"calendarType": "day",
|
||||||
|
"calendarIsWhitelist": whitelist,
|
||||||
|
"calendar": [iso(d) for d in calendar_days]}],
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_gap_between_matchdays_reports_the_next_matchday(self):
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
note = self.note(self.payload([-9], [-9, 11, 12]))
|
||||||
|
self.assertIn("nothing on until", note)
|
||||||
|
self.assertIn((datetime.now(timezone.utc) + timedelta(days=11))
|
||||||
|
.strftime("%d %B %Y"), note)
|
||||||
|
|
||||||
|
def test_next_matchday_within_a_couple_of_days_says_nothing(self):
|
||||||
|
self.assertIsNone(self.note(self.payload([-2], [-2, 1])))
|
||||||
|
|
||||||
|
def test_no_matchdays_left_is_still_finished(self):
|
||||||
|
# PLL on the same day: a match-day calendar whose last day is past.
|
||||||
|
note = self.note(self.payload([-9], [-30, -9]))
|
||||||
|
self.assertIn("season has finished", note)
|
||||||
|
|
||||||
|
def test_a_blacklist_day_calendar_is_not_read_as_fixtures(self):
|
||||||
|
# MLB's day calendar has calendarIsWhitelist false: it lists days
|
||||||
|
# without games, so a future entry there is not a next fixture.
|
||||||
|
note = self.note(self.payload([-9], [11], whitelist=False))
|
||||||
|
self.assertIn("season has finished", note)
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduleNoteListCalendarTests(unittest.TestCase):
|
||||||
|
"""A round still to start in a "list" calendar is not a finished season.
|
||||||
|
|
||||||
|
Shapes captured from ESPN on 2026-09-29, with dates kept relative to that
|
||||||
|
day. The Europa League scoreboard still showed the 17 September matchday
|
||||||
|
and its calendar is a ``"list"`` of rounds, not match days, so the check
|
||||||
|
said the season had finished -- with the knockout rounds, and the next
|
||||||
|
league-phase matchday, still to come. PLL, the World Cup and AFL really had
|
||||||
|
finished and must still say so, although each has a season or round
|
||||||
|
``endDate`` in the future.
|
||||||
|
"""
|
||||||
|
|
||||||
|
note = ScheduleNoteTests.note
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def iso(days):
|
||||||
|
return (datetime.now(timezone.utc) + timedelta(days=days)).strftime(
|
||||||
|
"%Y-%m-%dT%H:%MZ")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def list_league(cls, event_days, rounds, league_type=14540,
|
||||||
|
event_type=14540, phase_label="UEFA Europa League",
|
||||||
|
extra_phases=()):
|
||||||
|
"""``rounds`` is ``[(label, start_day, end_day), ...]`` for one phase."""
|
||||||
|
return {
|
||||||
|
"events": [{"date": cls.iso(d), "season": {"type": event_type}}
|
||||||
|
for d in event_days],
|
||||||
|
"leagues": [{
|
||||||
|
"season": {"type": {"type": league_type}},
|
||||||
|
"calendarType": "list",
|
||||||
|
"calendarIsWhitelist": True,
|
||||||
|
"calendar": [{
|
||||||
|
"label": phase_label,
|
||||||
|
"startDate": cls.iso(-90), "endDate": cls.iso(275),
|
||||||
|
"entries": [{"label": label, "startDate": cls.iso(start),
|
||||||
|
"endDate": cls.iso(end)}
|
||||||
|
for label, start, end in rounds],
|
||||||
|
}] + list(extra_phases),
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_europa_between_matchdays_is_not_finished(self):
|
||||||
|
note = self.note(self.list_league([-12], [
|
||||||
|
("League Phase", -31, 123),
|
||||||
|
("Knockout Round Playoffs", 123, 151),
|
||||||
|
("Rd of 16", 151, 172),
|
||||||
|
("Quarterfinals", 172, 200),
|
||||||
|
("Semifinals", 200, 221),
|
||||||
|
("Final", 222, 275),
|
||||||
|
]))
|
||||||
|
self.assertIsNone(note)
|
||||||
|
|
||||||
|
def test_world_cup_after_the_final_is_still_finished(self):
|
||||||
|
# The competition runs to 31 December, and the last round ended 12
|
||||||
|
# days after the final; no round is still to start.
|
||||||
|
note = self.note(self.list_league([-72], [
|
||||||
|
("Group", -110, -93),
|
||||||
|
("Semifinals", -77, -72),
|
||||||
|
("Final", -72, -59),
|
||||||
|
], league_type=13803, event_type=13803, phase_label="FIFA World Cup"))
|
||||||
|
self.assertIn("season has finished", note)
|
||||||
|
|
||||||
|
def test_afl_after_the_grand_final_is_still_finished(self):
|
||||||
|
# The Grand Final round had started but had not ended yet.
|
||||||
|
note = self.note(self.list_league([-3], [
|
||||||
|
("Preliminary Finals", -13, -6),
|
||||||
|
("Grand Final", -6, 1),
|
||||||
|
], league_type=3, event_type=3, phase_label="Postseason"))
|
||||||
|
self.assertIn("season has finished", note)
|
||||||
|
|
||||||
|
def test_an_offseason_round_does_not_count(self):
|
||||||
|
# College football's "Off Season" phase holds the All-Star week.
|
||||||
|
offseason = {"label": "Off Season", "startDate": self.iso(2),
|
||||||
|
"endDate": self.iso(6),
|
||||||
|
"entries": [{"label": "All-Star", "startDate": self.iso(2),
|
||||||
|
"endDate": self.iso(6)}]}
|
||||||
|
note = self.note(self.list_league(
|
||||||
|
[-3], [("CFP", -40, 1)], league_type=3, event_type=3,
|
||||||
|
phase_label="Postseason", extra_phases=[offseason]))
|
||||||
|
self.assertIn("season has finished", note)
|
||||||
|
|
||||||
|
def test_pll_with_a_season_end_date_in_the_future_is_still_finished(self):
|
||||||
|
# A "day" whitelist whose last match day is past; the season's own
|
||||||
|
# endDate (1 January) is ignored.
|
||||||
|
note = self.note({
|
||||||
|
"events": [{"date": self.iso(-9), "season": {"type": 2}}],
|
||||||
|
"leagues": [{
|
||||||
|
"season": {"type": {"type": 2}, "startDate": self.iso(-271),
|
||||||
|
"endDate": self.iso(94)},
|
||||||
|
"calendarType": "day",
|
||||||
|
"calendarIsWhitelist": True,
|
||||||
|
"calendarEndDate": self.iso(94),
|
||||||
|
"calendar": [self.iso(-30), self.iso(-22), self.iso(-9)],
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
self.assertIn("season has finished", note)
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
"""src.common.sports_card_wrappers: each delegation, and the host contract.
|
||||||
|
|
||||||
|
Every method here forwards to the ``sports_card`` function it names with the
|
||||||
|
host's ``config`` and ``logger``. The tests pin what each returns for a
|
||||||
|
configured card, so a delegation that passes the wrong thing -- an empty
|
||||||
|
config, the other side, a dropped default -- fails here rather than as a
|
||||||
|
wrong colour on a panel.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from PIL import ImageFont
|
||||||
|
|
||||||
|
from src.common import sports_card, sports_card_wrappers, sports_game_renderer
|
||||||
|
from src.common.sports_card_wrappers import SportsCardWrappersMixin
|
||||||
|
from src.common.sports_game_renderer import SportsGameRendererMixin
|
||||||
|
|
||||||
|
CONFIG = {
|
||||||
|
"timezone": "America/Chicago",
|
||||||
|
"favorite_teams": ["BOS"],
|
||||||
|
"scroll_card": {"vs_text": "@", "upcoming_center": "date_time",
|
||||||
|
"date_format": "weekday", "time_format": "24h"},
|
||||||
|
"customization": {
|
||||||
|
"score_text": {"text_color": [9, 9, 9]},
|
||||||
|
"favorite_result_colors": {"enabled": True, "win_color": [0, 200, 0]},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
GAME = {"home_abbr": "BOS", "away_abbr": "NYY", "home_score": "3", "away_score": "1",
|
||||||
|
"start_time_utc": "2026-09-19T23:00:00Z"}
|
||||||
|
|
||||||
|
|
||||||
|
class Host(SportsCardWrappersMixin):
|
||||||
|
"""The documented contract, and not one attribute more."""
|
||||||
|
|
||||||
|
_FONT_NAME_ALIASES = dict(sports_card.FONT_NAME_ALIASES)
|
||||||
|
_FONT_PIXEL_GRID = dict(sports_card.FONT_PIXEL_GRID)
|
||||||
|
|
||||||
|
def __init__(self, config=CONFIG):
|
||||||
|
self.config = config
|
||||||
|
self.logger = logging.getLogger("test.sports_card_wrappers")
|
||||||
|
self.fonts = {"score": ImageFont.load_default()}
|
||||||
|
|
||||||
|
|
||||||
|
class TestDelegations:
|
||||||
|
def test_card_options(self):
|
||||||
|
host = Host()
|
||||||
|
assert host._scroll_card_option("vs_text", "VS") == "@"
|
||||||
|
assert host._scroll_card_option("missing", "fallback") == "fallback"
|
||||||
|
assert host._vs_text() == "@"
|
||||||
|
assert host._upcoming_center_mode() == "date_time"
|
||||||
|
|
||||||
|
def test_dates_and_times(self):
|
||||||
|
host = Host()
|
||||||
|
assert host._card_tzinfo() == ZoneInfo("America/Chicago")
|
||||||
|
assert host._weekday_for(GAME) == "Sat" # 18:00 in Chicago
|
||||||
|
assert host._format_game_time("7:05 PM") == "19:05"
|
||||||
|
assert host._format_game_date("9/19", GAME) == sports_card.format_game_date(
|
||||||
|
CONFIG, host.logger, "9/19", GAME)
|
||||||
|
assert host._format_game_date("9/19", GAME) != "9/19"
|
||||||
|
|
||||||
|
def test_colours(self):
|
||||||
|
host = Host()
|
||||||
|
assert tuple(host._element_color("score_text")) == (9, 9, 9)
|
||||||
|
assert tuple(host._element_color("missing_element", (1, 2, 3))) == (1, 2, 3)
|
||||||
|
assert tuple(host._font_color(host.fonts["score"])) == (9, 9, 9)
|
||||||
|
assert host._coerce_rgb([300, -1, "7"], (1, 2, 3)) == (255, 0, 7)
|
||||||
|
|
||||||
|
def test_favourites(self):
|
||||||
|
host = Host()
|
||||||
|
assert host._side_is_favorite(GAME, "home", {"BOS"}) is True
|
||||||
|
assert host._side_is_favorite(GAME, "away", {"BOS"}) is False
|
||||||
|
assert host._side_score(GAME, "home") == 3
|
||||||
|
assert host._favorite_result(GAME) == "win"
|
||||||
|
assert host._recent_score_color(GAME, (1, 1, 1)) == (0, 200, 0)
|
||||||
|
assert host._score_color_for(GAME, "recent") == (0, 200, 0)
|
||||||
|
assert tuple(host._score_color_for(GAME, "live")) == (9, 9, 9)
|
||||||
|
|
||||||
|
def test_fonts(self):
|
||||||
|
host = Host()
|
||||||
|
font = host.fonts["score"]
|
||||||
|
unshared = host._unshare_element_fonts({"score": font, "time": font})
|
||||||
|
assert set(unshared) == {"score", "time"}
|
||||||
|
|
||||||
|
def test_crisp_size_uses_the_hosts_own_tables(self):
|
||||||
|
class NoTables(Host):
|
||||||
|
_FONT_NAME_ALIASES = {}
|
||||||
|
_FONT_PIXEL_GRID = {}
|
||||||
|
|
||||||
|
assert Host._crisp_size("PressStart2P-Regular.ttf", 9) == 8 # snapped
|
||||||
|
assert NoTables._crisp_size("PressStart2P-Regular.ttf", 9) == 9 # unknown face
|
||||||
|
|
||||||
|
|
||||||
|
class TestComposition:
|
||||||
|
def test_it_supplies_what_the_geometry_mixin_needs(self):
|
||||||
|
# sports_game_renderer's docstring lists these as host-provided.
|
||||||
|
for name in ("_scroll_card_option", "_upcoming_center_mode", "_vs_text",
|
||||||
|
"_element_color", "_format_game_date", "_format_game_time"):
|
||||||
|
assert f"``{name}``" in sports_game_renderer.__doc__
|
||||||
|
assert name in SportsCardWrappersMixin.__dict__
|
||||||
|
|
||||||
|
def test_the_two_mixins_share_no_names(self):
|
||||||
|
ours = {n for n in SportsCardWrappersMixin.__dict__ if not n.startswith("__")}
|
||||||
|
theirs = {n for n in SportsGameRendererMixin.__dict__ if not n.startswith("__")}
|
||||||
|
assert ours & theirs == set()
|
||||||
|
|
||||||
|
def test_a_renderers_own_method_wins(self):
|
||||||
|
class Renderer(SportsCardWrappersMixin, SportsGameRendererMixin):
|
||||||
|
def _vs_text(self):
|
||||||
|
return "v"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.config, self.logger = CONFIG, logging.getLogger("t")
|
||||||
|
|
||||||
|
assert Renderer()._vs_text() == "v"
|
||||||
|
assert Renderer()._upcoming_center_mode() == "date_time"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Host contract
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _self_reads():
|
||||||
|
"""Every ``self.X`` / ``cls.X`` / ``getattr(self, "X")`` the mixin reads."""
|
||||||
|
tree = ast.parse(Path(sports_card_wrappers.__file__).read_text(encoding="utf-8"))
|
||||||
|
cls = next(n for n in tree.body
|
||||||
|
if isinstance(n, ast.ClassDef) and n.name == "SportsCardWrappersMixin")
|
||||||
|
names = set()
|
||||||
|
for node in ast.walk(cls):
|
||||||
|
if (isinstance(node, ast.Attribute) and isinstance(node.ctx, ast.Load)
|
||||||
|
and isinstance(node.value, ast.Name) and node.value.id in ("self", "cls")):
|
||||||
|
names.add(node.attr)
|
||||||
|
if (isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
|
||||||
|
and node.func.id == "getattr" and len(node.args) >= 2
|
||||||
|
and isinstance(node.args[0], ast.Name) and node.args[0].id == "self"
|
||||||
|
and isinstance(node.args[1], ast.Constant)):
|
||||||
|
names.add(node.args[1].value)
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
class TestHostContract:
|
||||||
|
def test_every_host_read_is_documented(self):
|
||||||
|
needed = _self_reads() - set(dir(SportsCardWrappersMixin))
|
||||||
|
undocumented = sorted(n for n in needed if f"``{n}``" not in sports_card_wrappers.__doc__)
|
||||||
|
assert undocumented == [], f"read but not in the host contract: {undocumented}"
|
||||||
|
|
||||||
|
def test_the_mixin_creates_no_attributes_of_its_own(self):
|
||||||
|
for name in ("config", "logger", "fonts", "_FONT_NAME_ALIASES", "_FONT_PIXEL_GRID"):
|
||||||
|
assert not hasattr(SportsCardWrappersMixin, name)
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
"""src.common.sports_celebration: the palette, the takeover, and the host contract.
|
||||||
|
|
||||||
|
Ported from the scoreboards' own celebration tests (football's
|
||||||
|
test_score_celebration.py, hockey's and soccer's test_goal_celebration.py),
|
||||||
|
which drive the same code through a plugin's SportsLive. Here the host is a
|
||||||
|
stub carrying exactly the documented contract, and the crests are drawn by the
|
||||||
|
test, so every input is fixed. Pixel-exact goldens of every plugin's takeover
|
||||||
|
live in ledmatrix-plugins (scripts/test_celebration_renders.py).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PIL import Image, ImageChops, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
from src.common import sports_celebration
|
||||||
|
from src.common.sports_celebration import (
|
||||||
|
SportsCelebrationMixin,
|
||||||
|
cap_luminance,
|
||||||
|
lift_color,
|
||||||
|
logo_palette,
|
||||||
|
mix_color,
|
||||||
|
rgb_luminance,
|
||||||
|
rgb_saturation,
|
||||||
|
scale_color,
|
||||||
|
)
|
||||||
|
|
||||||
|
FONTS = Path(__file__).resolve().parents[1] / "assets" / "fonts"
|
||||||
|
SIZES = [(64, 32), (128, 32), (64, 64), (96, 48),
|
||||||
|
(128, 64), (256, 32), (128, 96), (256, 128)]
|
||||||
|
|
||||||
|
|
||||||
|
def crest(body, band, size=64):
|
||||||
|
"""A shield in ``body`` with a horizontal band in ``band``."""
|
||||||
|
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
s = size / 64
|
||||||
|
draw.polygon([(6 * s, 4 * s), (58 * s, 4 * s), (58 * s, 34 * s),
|
||||||
|
(32 * s, 60 * s), (6 * s, 34 * s)], fill=body + (255,))
|
||||||
|
draw.rectangle([(6 * s, 22 * s), (58 * s, 32 * s)], fill=band + (255,))
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
CRESTS = {
|
||||||
|
"RED": crest((200, 16, 46), (255, 255, 255)),
|
||||||
|
"NAV": crest((12, 35, 64), (255, 184, 28)),
|
||||||
|
"SIL": crest((165, 172, 175), (0, 0, 0)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class _DisplayManager:
|
||||||
|
def __init__(self, width, height):
|
||||||
|
self.width, self.height = width, height
|
||||||
|
self.image = Image.new("RGB", (width, height))
|
||||||
|
self.updates = 0
|
||||||
|
|
||||||
|
def clear(self):
|
||||||
|
self.image = Image.new("RGB", (self.width, self.height))
|
||||||
|
|
||||||
|
def update_display(self):
|
||||||
|
self.updates += 1
|
||||||
|
|
||||||
|
|
||||||
|
class Host(SportsCelebrationMixin):
|
||||||
|
"""The documented contract, and not one attribute more."""
|
||||||
|
|
||||||
|
def __init__(self, width=128, height=32, **knobs):
|
||||||
|
self.display_manager = _DisplayManager(width, height)
|
||||||
|
self.display_width, self.display_height = width, height
|
||||||
|
press = str(FONTS / "PressStart2P-Regular.ttf")
|
||||||
|
self.fonts = {
|
||||||
|
"time": ImageFont.truetype(press, 8),
|
||||||
|
"status": ImageFont.truetype(str(FONTS / "4x6-font.ttf"), 6),
|
||||||
|
"score": ImageFont.truetype(press, 16 if height >= 48 else 10),
|
||||||
|
}
|
||||||
|
self.logger = logging.getLogger("test.sports_celebration")
|
||||||
|
for name, value in knobs.items():
|
||||||
|
setattr(self, name, value)
|
||||||
|
|
||||||
|
def _load_and_resize_logo(self, team_id, abbr, logo_path, logo_url):
|
||||||
|
logo = CRESTS.get(abbr)
|
||||||
|
if logo is None:
|
||||||
|
return None
|
||||||
|
logo = logo.copy()
|
||||||
|
logo.thumbnail((self.display_height, self.display_height), Image.Resampling.LANCZOS)
|
||||||
|
return logo
|
||||||
|
|
||||||
|
def _draw_text_with_outline(self, draw, text, position, font,
|
||||||
|
fill=(255, 255, 255), outline_color=(0, 0, 0)):
|
||||||
|
x, y = position
|
||||||
|
for dx, dy in ((-1, 0), (1, 0), (0, -1), (0, 1)):
|
||||||
|
draw.text((x + dx, y + dy), text, font=font, fill=outline_color)
|
||||||
|
draw.text((x, y), text, font=font, fill=fill)
|
||||||
|
|
||||||
|
|
||||||
|
def celebration(scorer="RED", other="NAV", side="away", kind="score", motif="score"):
|
||||||
|
away, home = (scorer, other) if side == "away" else (other, scorer)
|
||||||
|
return {
|
||||||
|
"kind": kind, "motif": motif,
|
||||||
|
"game": {"id": "401", "away_abbr": away, "home_abbr": home},
|
||||||
|
"scored_side": side, "team_abbr": scorer,
|
||||||
|
"away_score": 3, "home_score": 2, "started_at": 1000.0,
|
||||||
|
"phrase": f"{scorer} WINS!" if kind == "win" else f"{scorer} SCORES!",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def render(host=None, elapsed=2.0, **kwargs):
|
||||||
|
host = host or Host()
|
||||||
|
with mock.patch("time.time", return_value=1000.0 + elapsed):
|
||||||
|
host._draw_celebration_layout(celebration(**kwargs), force_clear=True)
|
||||||
|
return host.display_manager.image
|
||||||
|
|
||||||
|
|
||||||
|
def brightest(img, box=None):
|
||||||
|
region = img.crop(box) if box else img
|
||||||
|
return max(region.convert("RGB").getextrema()[i][1] for i in range(3))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Colour helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestColourHelpers:
|
||||||
|
def test_mix_is_clamped_to_the_two_ends(self):
|
||||||
|
assert mix_color((0, 0, 0), (200, 100, 50), 0.5) == (100, 50, 25)
|
||||||
|
assert mix_color((0, 0, 0), (200, 100, 50), 2) == (200, 100, 50)
|
||||||
|
assert mix_color((0, 0, 0), (200, 100, 50), -1) == (0, 0, 0)
|
||||||
|
|
||||||
|
def test_scale_is_clamped_to_the_panel(self):
|
||||||
|
assert scale_color((200, 100, 0), 2) == (255, 200, 0)
|
||||||
|
|
||||||
|
def test_saturation_of_black_is_zero(self):
|
||||||
|
assert rgb_saturation((0, 0, 0)) == 0.0
|
||||||
|
|
||||||
|
def test_lifting_a_colour_keeps_its_hue(self):
|
||||||
|
# Scaling channels turns Baltimore's navy-purple magenta; HSV does not.
|
||||||
|
lifted = lift_color((39, 15, 98))
|
||||||
|
assert rgb_luminance(lifted) >= 100
|
||||||
|
assert lifted[2] > lifted[0] > lifted[1]
|
||||||
|
|
||||||
|
def test_a_colour_that_already_reads_is_left_alone(self):
|
||||||
|
assert lift_color((255, 208, 56)) == (255, 208, 56)
|
||||||
|
|
||||||
|
def test_a_grey_is_just_made_bright(self):
|
||||||
|
lifted = lift_color((40, 40, 40))
|
||||||
|
assert lifted[0] == lifted[1] == lifted[2] and rgb_luminance(lifted) > 200
|
||||||
|
|
||||||
|
def test_capping_keeps_the_hue_and_the_cap(self):
|
||||||
|
capped = cap_luminance((248, 61, 1), 34)
|
||||||
|
assert capped[0] > capped[1] > capped[2]
|
||||||
|
assert rgb_luminance(capped) <= 35
|
||||||
|
|
||||||
|
|
||||||
|
class TestLogoPalette:
|
||||||
|
def test_a_saturated_crest_is_its_own_headline(self):
|
||||||
|
palette = logo_palette(CRESTS["RED"])
|
||||||
|
r, g, b = palette["headline"]
|
||||||
|
assert r > 150 and r > 2 * g and r > 2 * b
|
||||||
|
assert rgb_luminance(palette["deep"]) <= 36
|
||||||
|
|
||||||
|
def test_a_legible_band_beats_lifting_a_dark_body(self):
|
||||||
|
palette = logo_palette(CRESTS["NAV"])
|
||||||
|
r, g, b = palette["headline"]
|
||||||
|
assert r > 150 and g > 110 and b < 110, f"{palette['headline']} is not the gold band"
|
||||||
|
assert palette["deep"][2] >= palette["deep"][0], "the backdrop lost the navy"
|
||||||
|
|
||||||
|
def test_a_crest_with_no_colour_falls_back_to_its_brightest_grey(self):
|
||||||
|
palette = logo_palette(CRESTS["SIL"])
|
||||||
|
assert palette is not None
|
||||||
|
assert rgb_saturation(palette["headline"]) < 0.12
|
||||||
|
|
||||||
|
def test_nothing_opaque_is_no_palette(self):
|
||||||
|
assert logo_palette(Image.new("RGBA", (16, 16))) is None
|
||||||
|
|
||||||
|
def test_an_unreadable_crest_is_no_palette(self):
|
||||||
|
assert logo_palette(object()) is None
|
||||||
|
|
||||||
|
def test_every_colour_has_three_channels(self):
|
||||||
|
palette = logo_palette(CRESTS["RED"])
|
||||||
|
assert set(palette) == {"deep", "glow", "headline", "accent"}
|
||||||
|
assert all(len(c) == 3 for c in palette.values())
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The celebration's palette
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestCelebrationPalette:
|
||||||
|
def test_read_off_the_scoring_side(self):
|
||||||
|
away = Host()._celebration_palette(celebration(side="away"))
|
||||||
|
home = Host()._celebration_palette(celebration(scorer="NAV", other="RED", side="home"))
|
||||||
|
assert away == logo_palette(Host()._load_and_resize_logo(None, "RED", None, None))
|
||||||
|
assert home["headline"] != away["headline"]
|
||||||
|
|
||||||
|
def test_worked_out_once_per_celebration(self):
|
||||||
|
host, c = Host(), celebration()
|
||||||
|
first = host._celebration_palette(c)
|
||||||
|
host._load_and_resize_logo = mock.Mock(side_effect=AssertionError("reloaded"))
|
||||||
|
assert host._celebration_palette(c) is first
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("loader", [lambda *a: None, mock.Mock(side_effect=OSError("bad png"))])
|
||||||
|
def test_no_usable_crest_falls_back(self, loader):
|
||||||
|
host = Host()
|
||||||
|
host._load_and_resize_logo = loader
|
||||||
|
assert host._celebration_palette(celebration()) == Host._DEFAULT_CELEBRATION_PALETTE
|
||||||
|
|
||||||
|
def test_team_colours_off_is_the_default(self):
|
||||||
|
host = Host(celebration_team_colors=False)
|
||||||
|
assert host._celebration_palette(celebration()) == Host._DEFAULT_CELEBRATION_PALETTE
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The takeover
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestTakeover:
|
||||||
|
def test_frame_is_presented(self):
|
||||||
|
host = Host()
|
||||||
|
render(host)
|
||||||
|
assert host.display_manager.updates == 1
|
||||||
|
assert host.display_manager.image.size == (128, 32)
|
||||||
|
|
||||||
|
def test_same_inputs_same_frame(self):
|
||||||
|
assert render().tobytes() == render().tobytes()
|
||||||
|
|
||||||
|
def test_the_highlight_follows_the_scoring_side(self):
|
||||||
|
away = render(side="away", scorer="RED", other="RED")
|
||||||
|
home = render(side="home", scorer="RED", other="RED")
|
||||||
|
assert ImageChops.difference(away, home).getbbox() is not None
|
||||||
|
|
||||||
|
def test_each_motif_paints_its_own_scenery(self):
|
||||||
|
shots = {m: render(Host(celebration_confetti=False), motif=m).tobytes()
|
||||||
|
for m in ("score", "kick", "touchdown", "net", "win")}
|
||||||
|
assert len(set(shots.values())) == len(shots)
|
||||||
|
|
||||||
|
def test_an_unknown_motif_draws_the_score_scenery(self):
|
||||||
|
host = Host(celebration_confetti=False)
|
||||||
|
assert render(host, motif="bogus").tobytes() == render(Host(celebration_confetti=False),
|
||||||
|
motif="score").tobytes()
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("knob", ["celebration_team_colors", "celebration_confetti"])
|
||||||
|
def test_switches_change_the_frame(self, knob):
|
||||||
|
assert render(Host(**{knob: False})).tobytes() != render(Host()).tobytes()
|
||||||
|
|
||||||
|
def test_the_matrix_size_wins_over_the_configured_one(self):
|
||||||
|
host = Host(width=64, height=32)
|
||||||
|
host.display_manager.matrix = type("M", (), {"width": 128, "height": 32})()
|
||||||
|
assert render(host).size == (128, 32)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("width,height", SIZES)
|
||||||
|
def test_every_frame_is_a_finished_card(self, width, height):
|
||||||
|
# A switch-mode board samples once a second: any frame may be the only
|
||||||
|
# one seen, so each carries the headline and nothing is blank.
|
||||||
|
for elapsed in [0.0] + [i + 0.5 for i in range(8)]:
|
||||||
|
img = render(Host(width, height), elapsed=elapsed)
|
||||||
|
assert brightest(img) > 40
|
||||||
|
assert brightest(img, (0, 0, width, max(2, height // 4))) > 60
|
||||||
|
|
||||||
|
def test_the_score_stays_on_a_tall_panel(self):
|
||||||
|
# 16px digits at 48 tall used to run off the bottom row. The goal
|
||||||
|
# line keeps the scenery off that row, so only the score could be.
|
||||||
|
img = render(Host(192, 48, celebration_confetti=False), motif="touchdown")
|
||||||
|
assert brightest(img, (48, 47, 144, 48)) < 10
|
||||||
|
assert brightest(img, (48, 24, 144, 47)) > 10
|
||||||
|
|
||||||
|
def test_the_scoring_side_breathes_rather_than_toggling(self):
|
||||||
|
frames = {render(Host(celebration_confetti=False), elapsed=t).tobytes()
|
||||||
|
for t in (0.9, 1.9, 2.9, 3.9, 4.9, 5.9)}
|
||||||
|
assert len(frames) > 2
|
||||||
|
|
||||||
|
def test_confetti_is_seeded_from_the_game_not_the_clock(self):
|
||||||
|
host, c = Host(), celebration()
|
||||||
|
palette = host._celebration_palette(c)
|
||||||
|
flakes = host._celebration_confetti(c, 128, 32, palette)
|
||||||
|
again = Host()._celebration_confetti(celebration(), 128, 32, palette)
|
||||||
|
assert flakes == again and 6 <= len(flakes) <= 22
|
||||||
|
|
||||||
|
def test_confetti_is_gone_by_the_end(self):
|
||||||
|
host, c = Host(), celebration()
|
||||||
|
palette = host._celebration_palette(c)
|
||||||
|
overlay = Image.new("RGBA", (128, 32), (0, 0, 0, 0))
|
||||||
|
host._draw_celebration_confetti(ImageDraw.Draw(overlay), c, 128, 32, palette, 8.0, 1.0)
|
||||||
|
assert overlay.getbbox() is None
|
||||||
|
|
||||||
|
def test_the_side_that_did_not_score_is_dimmed(self):
|
||||||
|
crests = Host()._celebration_crests(celebration(scorer="RED", other="RED"), 32)
|
||||||
|
assert brightest(crests["home"]) < brightest(crests["away"])
|
||||||
|
|
||||||
|
def test_a_crest_that_fails_to_load_is_left_out(self):
|
||||||
|
host = Host()
|
||||||
|
host._load_and_resize_logo = mock.Mock(side_effect=OSError("bad png"))
|
||||||
|
assert host._celebration_crests(celebration(), 32) == {"away": None, "home": None}
|
||||||
|
assert brightest(render(host)) > 40
|
||||||
|
|
||||||
|
def test_fit_font_falls_back_to_the_smallest(self):
|
||||||
|
host = Host()
|
||||||
|
draw = ImageDraw.Draw(Image.new("RGB", (8, 8)))
|
||||||
|
fonts = [host.fonts["time"], host.fonts["status"]]
|
||||||
|
assert host._fit_font(draw, "A", 128, fonts) is fonts[0]
|
||||||
|
assert host._fit_font(draw, "A VERY LONG HEADLINE", 8, fonts) is fonts[-1]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Host contract
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _self_reads():
|
||||||
|
"""Every ``self.X`` / ``getattr(self, "X")`` the mixin reads, by parsing it."""
|
||||||
|
tree = ast.parse(Path(sports_celebration.__file__).read_text(encoding="utf-8"))
|
||||||
|
cls = next(n for n in tree.body
|
||||||
|
if isinstance(n, ast.ClassDef) and n.name == "SportsCelebrationMixin")
|
||||||
|
names = set()
|
||||||
|
for node in ast.walk(cls):
|
||||||
|
if (isinstance(node, ast.Attribute) and isinstance(node.ctx, ast.Load)
|
||||||
|
and isinstance(node.value, ast.Name) and node.value.id == "self"):
|
||||||
|
names.add(node.attr)
|
||||||
|
if (isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
|
||||||
|
and node.func.id == "getattr" and len(node.args) >= 2
|
||||||
|
and isinstance(node.args[0], ast.Name) and node.args[0].id == "self"
|
||||||
|
and isinstance(node.args[1], ast.Constant)):
|
||||||
|
names.add(node.args[1].value)
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
class TestHostContract:
|
||||||
|
def test_every_host_read_is_documented(self):
|
||||||
|
needed = _self_reads() - set(dir(SportsCelebrationMixin))
|
||||||
|
undocumented = sorted(n for n in needed if f"``{n}" not in sports_celebration.__doc__)
|
||||||
|
assert undocumented == [], f"read but not in the host contract: {undocumented}"
|
||||||
|
|
||||||
|
def test_the_stub_host_is_enough(self):
|
||||||
|
# Host above sets the contract and nothing else; it drew every test.
|
||||||
|
needed = _self_reads() - set(dir(SportsCelebrationMixin))
|
||||||
|
host = Host(celebration_duration=8, celebration_team_colors=True,
|
||||||
|
celebration_confetti=True)
|
||||||
|
assert all(hasattr(host, n) for n in needed)
|
||||||
|
|
||||||
|
def test_the_mixin_creates_no_attributes_of_its_own(self):
|
||||||
|
# The annotations are for type checking; the host's values must win.
|
||||||
|
for name in ("display_manager", "fonts", "logger", "_load_and_resize_logo"):
|
||||||
|
assert not hasattr(SportsCelebrationMixin, name)
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
"""src.common.sports_fetch: behaviour and host contract.
|
||||||
|
|
||||||
|
Ported from the scoreboards' tests of the same methods (football's
|
||||||
|
test_live_odds_follow_the_rotation.py, test_lookback_only_when_it_can_matter.py
|
||||||
|
and test_espn_date_ranges.py) against a stub host carrying exactly the
|
||||||
|
documented contract.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.common import espn_dates, sports_fetch
|
||||||
|
from src.common.sports_fetch import SportsFetchMixin
|
||||||
|
|
||||||
|
ET = timezone(timedelta(hours=-5))
|
||||||
|
|
||||||
|
|
||||||
|
class _Response:
|
||||||
|
status_code = 200
|
||||||
|
content = None
|
||||||
|
|
||||||
|
def __init__(self, data):
|
||||||
|
self._data = data
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return self._data
|
||||||
|
|
||||||
|
def raise_for_status(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class _Session:
|
||||||
|
def __init__(self, data=None, error=None):
|
||||||
|
self.data, self.error, self.calls = data, error, []
|
||||||
|
|
||||||
|
def get(self, url, params=None, headers=None, timeout=None):
|
||||||
|
self.calls.append((url, dict(params or {}), headers, timeout))
|
||||||
|
if self.error:
|
||||||
|
raise self.error
|
||||||
|
return _Response(self.data)
|
||||||
|
|
||||||
|
|
||||||
|
class _Cache:
|
||||||
|
def __init__(self):
|
||||||
|
self.sets = []
|
||||||
|
|
||||||
|
def set(self, key, data, **kwargs):
|
||||||
|
self.sets.append((key, data, kwargs))
|
||||||
|
|
||||||
|
|
||||||
|
class Host(SportsFetchMixin):
|
||||||
|
"""The documented contract, and not one attribute more."""
|
||||||
|
|
||||||
|
def __init__(self, session=None):
|
||||||
|
self.session = session or _Session(data={"events": []})
|
||||||
|
self.headers = {"User-Agent": "test"}
|
||||||
|
self.cache_manager = _Cache()
|
||||||
|
self.logger = logging.getLogger("test.sports_fetch")
|
||||||
|
self._games_lock = threading.RLock()
|
||||||
|
|
||||||
|
|
||||||
|
class TestWantsLiveOdds:
|
||||||
|
def test_cold_start_asks_for_every_game(self):
|
||||||
|
assert Host()._wants_live_odds({"id": "a"}) is True
|
||||||
|
|
||||||
|
def test_only_the_game_on_screen_and_the_next(self):
|
||||||
|
host = Host()
|
||||||
|
host.live_games = [{"id": i} for i in "abcd"]
|
||||||
|
host.current_game_index = 1
|
||||||
|
assert [host._wants_live_odds({"id": i}) for i in "abcd"] == [False, True, True, False]
|
||||||
|
|
||||||
|
def test_the_rotation_schedule_is_followed_and_wraps(self):
|
||||||
|
host = Host()
|
||||||
|
host.live_games = [{"id": i} for i in "abcd"]
|
||||||
|
host._rotation_schedule = ["d", "c", "b", "a"]
|
||||||
|
host.current_game_index = 3
|
||||||
|
assert [host._wants_live_odds({"id": i}) for i in "abcd"] == [True, False, False, True]
|
||||||
|
|
||||||
|
def test_an_index_past_the_end_starts_at_the_front(self):
|
||||||
|
host = Host()
|
||||||
|
host.live_games = [{"id": i} for i in "abc"]
|
||||||
|
host.current_game_index = 9
|
||||||
|
assert [host._wants_live_odds({"id": i}) for i in "abc"] == [True, True, False]
|
||||||
|
|
||||||
|
def test_the_lookahead_is_a_class_setting(self):
|
||||||
|
class Wider(Host):
|
||||||
|
_LIVE_ODDS_LOOKAHEAD = 2
|
||||||
|
|
||||||
|
host = Wider()
|
||||||
|
host.live_games = [{"id": i} for i in "abcd"]
|
||||||
|
host.current_game_index = 0
|
||||||
|
assert [host._wants_live_odds({"id": i}) for i in "abcd"] == [True, True, True, False]
|
||||||
|
|
||||||
|
|
||||||
|
class TestNeedsPreviousDay:
|
||||||
|
def test_before_the_cutoff_yesterday_is_kept(self):
|
||||||
|
assert Host()._needs_previous_day(datetime(2026, 1, 15, 5, 59, tzinfo=ET)) is True
|
||||||
|
|
||||||
|
def test_after_it_with_nothing_live_it_is_dropped(self):
|
||||||
|
assert Host()._needs_previous_day(datetime(2026, 1, 15, 6, 0, tzinfo=ET)) is False
|
||||||
|
|
||||||
|
def test_a_live_game_from_yesterday_keeps_it(self):
|
||||||
|
host = Host()
|
||||||
|
host.live_games = [{"start_time_utc": datetime(2026, 1, 15, 3, 0, tzinfo=timezone.utc)}]
|
||||||
|
assert host._needs_previous_day(datetime(2026, 1, 15, 12, 0, tzinfo=ET)) is True
|
||||||
|
|
||||||
|
def test_todays_live_game_does_not(self):
|
||||||
|
host = Host()
|
||||||
|
host.live_games = [{"start_time_utc": datetime(2026, 1, 15, 18, 0, tzinfo=timezone.utc)}]
|
||||||
|
assert host._needs_previous_day(datetime(2026, 1, 15, 12, 0, tzinfo=ET)) is False
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("game", [{}, {"start_time_utc": "2026-01-14"}, "not a game"])
|
||||||
|
def test_unusable_start_times_are_skipped(self, game):
|
||||||
|
host = Host()
|
||||||
|
host.live_games = [game]
|
||||||
|
assert host._needs_previous_day(datetime(2026, 1, 15, 12, 0, tzinfo=ET)) is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestBackgroundFetchesEspnRanges:
|
||||||
|
def test_no_service(self):
|
||||||
|
assert Host()._background_fetches_espn_ranges() is False
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("flag,expected", [(True, True), (False, False), (None, False)])
|
||||||
|
def test_follows_the_service(self, flag, expected):
|
||||||
|
host = Host()
|
||||||
|
host.background_service = type("S", (), {"handles_espn_date_ranges": flag})()
|
||||||
|
assert host._background_fetches_espn_ranges() is expected
|
||||||
|
|
||||||
|
def test_an_old_service_without_the_flag(self):
|
||||||
|
host = Host()
|
||||||
|
host.background_service = object()
|
||||||
|
assert host._background_fetches_espn_ranges() is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestFetchSeasonDirectly:
|
||||||
|
def test_fetches_caches_and_returns(self):
|
||||||
|
host = Host(_Session(data={"events": [1, 2]}))
|
||||||
|
data = host._fetch_season_directly("http://espn/sb", "20260115", "k", "2026 season")
|
||||||
|
assert data == {"events": [1, 2]}
|
||||||
|
assert host.cache_manager.sets == [("k", data, {})]
|
||||||
|
assert host.session.calls == [
|
||||||
|
("http://espn/sb", {"dates": "20260115", "limit": espn_dates.ESPN_MAX_LIMIT},
|
||||||
|
{"User-Agent": "test"}, 30)]
|
||||||
|
|
||||||
|
def test_a_ttl_reaches_the_cache(self):
|
||||||
|
host = Host()
|
||||||
|
host._fetch_season_directly("http://espn/sb", "20260115", "k", "x", ttl=60)
|
||||||
|
assert host.cache_manager.sets[0][2] == {"ttl": 60}
|
||||||
|
|
||||||
|
def test_a_failure_returns_none_and_caches_nothing(self, caplog):
|
||||||
|
host = Host(_Session(error=OSError("down")))
|
||||||
|
with caplog.at_level(logging.ERROR):
|
||||||
|
assert host._fetch_season_directly("http://espn/sb", "20260115", "k", "2026 season") is None
|
||||||
|
assert host.cache_manager.sets == []
|
||||||
|
assert "Failed to fetch 2026 season schedule" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Host contract
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _self_reads():
|
||||||
|
"""Every ``self.X`` / ``getattr(self, "X")`` the mixin reads, by parsing it."""
|
||||||
|
tree = ast.parse(Path(sports_fetch.__file__).read_text(encoding="utf-8"))
|
||||||
|
cls = next(n for n in tree.body
|
||||||
|
if isinstance(n, ast.ClassDef) and n.name == "SportsFetchMixin")
|
||||||
|
names = set()
|
||||||
|
for node in ast.walk(cls):
|
||||||
|
if (isinstance(node, ast.Attribute) and isinstance(node.ctx, ast.Load)
|
||||||
|
and isinstance(node.value, ast.Name) and node.value.id == "self"):
|
||||||
|
names.add(node.attr)
|
||||||
|
if (isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
|
||||||
|
and node.func.id == "getattr" and len(node.args) >= 2
|
||||||
|
and isinstance(node.args[0], ast.Name) and node.args[0].id == "self"
|
||||||
|
and isinstance(node.args[1], ast.Constant)):
|
||||||
|
names.add(node.args[1].value)
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
class TestHostContract:
|
||||||
|
def test_every_host_read_is_documented(self):
|
||||||
|
needed = _self_reads() - set(dir(SportsFetchMixin))
|
||||||
|
undocumented = sorted(n for n in needed if f"``{n}``" not in sports_fetch.__doc__)
|
||||||
|
assert undocumented == [], f"read but not in the host contract: {undocumented}"
|
||||||
|
|
||||||
|
def test_the_mixin_creates_no_attributes_of_its_own(self):
|
||||||
|
for name in ("session", "headers", "cache_manager", "logger", "_games_lock"):
|
||||||
|
assert not hasattr(SportsFetchMixin, name)
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
"""The stage 3 sports modules still match every plugin copy that remains.
|
||||||
|
|
||||||
|
``sports_celebration``, ``sports_fetch`` and ``sports_card_wrappers`` were
|
||||||
|
copied from the scoreboard plugins, which delete their copies once they floor
|
||||||
|
on the release that ships these. 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 every body here is compared, as an AST with
|
||||||
|
docstrings and type annotations removed and public names folded to the
|
||||||
|
plugins' private spelling, against every plugin copy. A copy that is gone
|
||||||
|
counts as adopted. Without the variable this skips: core CI has no plugins
|
||||||
|
checkout.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.common import sports_card_wrappers, sports_celebration, sports_fetch
|
||||||
|
|
||||||
|
#: module -> (its mixin, plugin file, plugin class, carriers,
|
||||||
|
#: {plugin: names it deliberately overrides}).
|
||||||
|
MODULES = {
|
||||||
|
sports_celebration: ("SportsCelebrationMixin", "sports.py", "SportsLive",
|
||||||
|
("afl", "football", "hockey", "nrl", "soccer"), {}),
|
||||||
|
sports_fetch: ("SportsFetchMixin", "sports.py", "SportsCore",
|
||||||
|
("afl", "baseball", "basketball", "football", "hockey",
|
||||||
|
"lacrosse", "nrl", "soccer", "ufc"), {}),
|
||||||
|
sports_card_wrappers: ("SportsCardWrappersMixin", "game_renderer.py", "GameRenderer",
|
||||||
|
("afl", "baseball", "basketball", "football", "hockey",
|
||||||
|
"lacrosse", "nrl", "soccer"),
|
||||||
|
{"football": {"_format_game_date", "_upcoming_center_mode"}}),
|
||||||
|
}
|
||||||
|
|
||||||
|
#: Type aliases the modules declare for annotations; nothing to compare.
|
||||||
|
TYPE_ALIASES = {"Color", "Palette", "Flake", "_Buckets"}
|
||||||
|
|
||||||
|
#: Public here, private in the plugins.
|
||||||
|
RENAMES = {name: "_" + name for name in (
|
||||||
|
"rgb_luminance", "rgb_saturation", "color_distance", "mix_color",
|
||||||
|
"scale_color", "lift_color", "cap_luminance", "dim_rgba", "logo_palette")}
|
||||||
|
|
||||||
|
|
||||||
|
def _plugins_root():
|
||||||
|
raw = os.environ.get("LEDMATRIX_PLUGINS")
|
||||||
|
if not raw:
|
||||||
|
pytest.skip("set LEDMATRIX_PLUGINS to a ledmatrix-plugins checkout to "
|
||||||
|
"compare these modules against the plugin copies")
|
||||||
|
root = Path(raw)
|
||||||
|
if (root / "plugins").is_dir():
|
||||||
|
root = root / "plugins"
|
||||||
|
if not (root / "football-scoreboard" / "sports.py").is_file():
|
||||||
|
pytest.skip(f"LEDMATRIX_PLUGINS={raw} has no football-scoreboard/sports.py")
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
class _Normalise(ast.NodeTransformer):
|
||||||
|
"""Drop docstrings and annotations; fold public names to private ones."""
|
||||||
|
|
||||||
|
def visit_Name(self, node):
|
||||||
|
node.id = RENAMES.get(node.id, node.id)
|
||||||
|
return node
|
||||||
|
|
||||||
|
def visit_arg(self, node):
|
||||||
|
node.annotation = None
|
||||||
|
return node
|
||||||
|
|
||||||
|
def visit_AnnAssign(self, node):
|
||||||
|
return self.visit(ast.Assign(targets=[node.target], value=node.value, lineno=0))
|
||||||
|
|
||||||
|
def visit_FunctionDef(self, node):
|
||||||
|
node.name = RENAMES.get(node.name, node.name)
|
||||||
|
node.returns = None
|
||||||
|
body = node.body
|
||||||
|
if (body and isinstance(body[0], ast.Expr)
|
||||||
|
and isinstance(body[0].value, ast.Constant)
|
||||||
|
and isinstance(body[0].value.value, str)):
|
||||||
|
node.body = body[1:] or [ast.Pass()]
|
||||||
|
self.generic_visit(node)
|
||||||
|
return node
|
||||||
|
|
||||||
|
|
||||||
|
def _dump(node):
|
||||||
|
node = ast.parse(ast.unparse(node)).body[0] # detach and copy
|
||||||
|
return ast.dump(_Normalise().visit(node))
|
||||||
|
|
||||||
|
|
||||||
|
def _definitions(tree, class_name):
|
||||||
|
"""Module-level functions and assignments, plus ``class_name``'s members."""
|
||||||
|
found = {}
|
||||||
|
|
||||||
|
def add(node, owner):
|
||||||
|
if isinstance(node, ast.FunctionDef):
|
||||||
|
found[(owner, node.name)] = node
|
||||||
|
elif isinstance(node, (ast.Assign, ast.AnnAssign)):
|
||||||
|
target = node.targets[0] if isinstance(node, ast.Assign) else node.target
|
||||||
|
if isinstance(target, ast.Name) and node.value is not None:
|
||||||
|
found[(owner, target.id)] = node
|
||||||
|
|
||||||
|
for node in tree.body:
|
||||||
|
add(node, "module")
|
||||||
|
if isinstance(node, ast.ClassDef) and node.name == class_name:
|
||||||
|
for item in node.body:
|
||||||
|
add(item, "class")
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def _promoted(module, mixin):
|
||||||
|
"""What the module moved: its functions and ``_PALETTE_*``-style constants,
|
||||||
|
and its mixin's methods and constants (not the host-contract annotations)."""
|
||||||
|
tree = ast.parse(Path(module.__file__).read_text(encoding="utf-8"))
|
||||||
|
ours = {}
|
||||||
|
for (owner, name), node in _definitions(tree, mixin).items():
|
||||||
|
if name in TYPE_ALIASES:
|
||||||
|
continue
|
||||||
|
ours[(owner, RENAMES.get(name, name))] = node
|
||||||
|
return ours
|
||||||
|
|
||||||
|
|
||||||
|
CASES = [(module.__name__.rsplit(".", 1)[1], key)
|
||||||
|
for module, (mixin, *_rest) in MODULES.items()
|
||||||
|
for key in sorted(_promoted(module, mixin))]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("module_name,key", CASES, ids=lambda v: str(v))
|
||||||
|
def test_every_remaining_plugin_copy_matches(module_name, key):
|
||||||
|
root = _plugins_root()
|
||||||
|
module = next(m for m in MODULES if m.__name__.endswith("." + module_name))
|
||||||
|
mixin, filename, class_name, carriers, overrides = MODULES[module]
|
||||||
|
ours = _dump(_promoted(module, mixin)[key])
|
||||||
|
drifted, missing = [], []
|
||||||
|
for sport in carriers:
|
||||||
|
source = (root / f"{sport}-scoreboard" / filename).read_text(encoding="utf-8")
|
||||||
|
theirs = _definitions(ast.parse(source), class_name).get(key)
|
||||||
|
if key[1] in overrides.get(sport, ()):
|
||||||
|
continue
|
||||||
|
if theirs is None:
|
||||||
|
# Gone is fine once the plugin uses the module; otherwise the
|
||||||
|
# finder is not seeing its copy.
|
||||||
|
if module.__name__ not in source:
|
||||||
|
missing.append(sport)
|
||||||
|
elif _dump(theirs) != ours:
|
||||||
|
drifted.append(sport)
|
||||||
|
assert missing == [], f"{key[1]} not found in: {missing}"
|
||||||
|
assert drifted == [], (
|
||||||
|
f"{key[1]} in {module_name} differs from the copy in: {drifted}. "
|
||||||
|
f"Port the change to both, or stop treating it as shared.")
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
"""src.common.sports_timezone: which zone a scoreboard renders start times in.
|
||||||
|
|
||||||
|
Ported from the scoreboard plugins' test_timezone_resolution.py, which test
|
||||||
|
their bundled ``<sport>_timezone.py`` copies. The copies differ only in the
|
||||||
|
two values this module takes as keyword arguments, so every test runs once per
|
||||||
|
plugin with that plugin's values (``PLUGINS``).
|
||||||
|
|
||||||
|
Regression the resolution order guards: a plugin used to read the global
|
||||||
|
timezone only from ``cache_manager.config_manager``. On cores that hang
|
||||||
|
``config_manager`` off the plugin manager instead, that lookup found nothing
|
||||||
|
and every start time was drawn in UTC.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytz
|
||||||
|
|
||||||
|
from src.common import sports_timezone
|
||||||
|
from src.common.sports_timezone import resolve_timezone, resolve_timezone_name
|
||||||
|
|
||||||
|
#: (plugin_label, writeback_fixed_in) as the plugins' copies carried them.
|
||||||
|
#: Only baseball and football ever wrote "UTC" back into the saved config.
|
||||||
|
PLUGINS = [
|
||||||
|
("AFL scoreboard", None),
|
||||||
|
("baseball scoreboard", "1.20.0"),
|
||||||
|
("basketball scoreboard", None),
|
||||||
|
("F1 scoreboard", None),
|
||||||
|
("football scoreboard", "2.9.0"),
|
||||||
|
("hockey scoreboard", None),
|
||||||
|
("lacrosse scoreboard", None),
|
||||||
|
("NRL scoreboard", None),
|
||||||
|
("soccer scoreboard", None),
|
||||||
|
("UFC scoreboard", None),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(params=PLUGINS, ids=[label for label, _ in PLUGINS])
|
||||||
|
def plugin(request):
|
||||||
|
label, fixed_in = request.param
|
||||||
|
return {"plugin_label": label, "writeback_fixed_in": fixed_in}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def system_zone(monkeypatch):
|
||||||
|
"""Stub system-zone detection so results don't depend on this machine."""
|
||||||
|
def stub(value):
|
||||||
|
monkeypatch.setattr(sports_timezone, "system_timezone_name", lambda: value)
|
||||||
|
stub(None)
|
||||||
|
return stub
|
||||||
|
|
||||||
|
|
||||||
|
class _ConfigManager:
|
||||||
|
"""Core ConfigManager stand-in exposing get_timezone()."""
|
||||||
|
|
||||||
|
def __init__(self, timezone=None):
|
||||||
|
self._timezone = timezone
|
||||||
|
|
||||||
|
def get_timezone(self):
|
||||||
|
return self._timezone
|
||||||
|
|
||||||
|
|
||||||
|
class _LegacyConfigManager:
|
||||||
|
"""Older core: no get_timezone(), only load_config()."""
|
||||||
|
|
||||||
|
def __init__(self, timezone=None):
|
||||||
|
self._timezone = timezone
|
||||||
|
|
||||||
|
def load_config(self):
|
||||||
|
return {"timezone": self._timezone}
|
||||||
|
|
||||||
|
|
||||||
|
class _CountingConfigManager(_ConfigManager):
|
||||||
|
"""Records how many times the core was asked for the timezone."""
|
||||||
|
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
def get_timezone(self):
|
||||||
|
self.calls += 1
|
||||||
|
return self._timezone
|
||||||
|
|
||||||
|
|
||||||
|
class _BrokenConfigManager:
|
||||||
|
"""Core whose get_timezone() blows up -- must not take the plugin down."""
|
||||||
|
|
||||||
|
def get_timezone(self):
|
||||||
|
raise RuntimeError("config not loaded")
|
||||||
|
|
||||||
|
|
||||||
|
class _RealCoreConfigManager:
|
||||||
|
"""Faithful stand-in for the shipping core ConfigManager.
|
||||||
|
|
||||||
|
The real get_timezone() is ``self.config.get('timezone', 'UTC')`` -- it
|
||||||
|
substitutes its own "UTC" when the global config has no timezone key.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, config):
|
||||||
|
self._config = config
|
||||||
|
|
||||||
|
def get_config(self):
|
||||||
|
return self._config
|
||||||
|
|
||||||
|
def load_config(self):
|
||||||
|
return self._config
|
||||||
|
|
||||||
|
def get_timezone(self):
|
||||||
|
return self._config.get("timezone", "UTC")
|
||||||
|
|
||||||
|
|
||||||
|
class _Holder:
|
||||||
|
"""Stands in for a plugin_manager / cache_manager."""
|
||||||
|
|
||||||
|
def __init__(self, config_manager=None):
|
||||||
|
if config_manager is not None:
|
||||||
|
self.config_manager = config_manager
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_config_override_wins(plugin):
|
||||||
|
assert resolve_timezone_name(
|
||||||
|
config={"timezone": "America/Denver"},
|
||||||
|
plugin_manager=_Holder(_ConfigManager("America/New_York")),
|
||||||
|
cache_manager=_Holder(_ConfigManager("Europe/London")),
|
||||||
|
**plugin,
|
||||||
|
) == "America/Denver"
|
||||||
|
|
||||||
|
|
||||||
|
def test_lower_priority_sources_are_not_evaluated(plugin, monkeypatch):
|
||||||
|
"""SportsCore._get_timezone() runs per game; once a candidate resolves, the
|
||||||
|
remaining sources must not be touched."""
|
||||||
|
plugin_cm = _CountingConfigManager("America/New_York")
|
||||||
|
cache_cm = _CountingConfigManager("Europe/London")
|
||||||
|
system_calls = []
|
||||||
|
monkeypatch.setattr(sports_timezone, "system_timezone_name",
|
||||||
|
lambda: system_calls.append(1) or None)
|
||||||
|
name = resolve_timezone_name(
|
||||||
|
config={"timezone": "America/Chicago"},
|
||||||
|
plugin_manager=_Holder(plugin_cm),
|
||||||
|
cache_manager=_Holder(cache_cm),
|
||||||
|
**plugin,
|
||||||
|
)
|
||||||
|
assert name == "America/Chicago"
|
||||||
|
assert (plugin_cm.calls, cache_cm.calls, system_calls) == (0, 0, [])
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_manager_config_manager_is_consulted(plugin, system_zone):
|
||||||
|
"""The regression: cache_manager has no config_manager at all."""
|
||||||
|
assert resolve_timezone_name(
|
||||||
|
config={},
|
||||||
|
plugin_manager=_Holder(_ConfigManager("America/Chicago")),
|
||||||
|
cache_manager=_Holder(),
|
||||||
|
**plugin,
|
||||||
|
) == "America/Chicago"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_manager_config_manager_fallback(plugin, system_zone):
|
||||||
|
assert resolve_timezone_name(
|
||||||
|
config={},
|
||||||
|
plugin_manager=_Holder(),
|
||||||
|
cache_manager=_Holder(_ConfigManager("America/Chicago")),
|
||||||
|
**plugin,
|
||||||
|
) == "America/Chicago"
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_load_config_fallback(plugin, system_zone):
|
||||||
|
assert resolve_timezone_name(
|
||||||
|
config={},
|
||||||
|
plugin_manager=_Holder(_LegacyConfigManager("America/Chicago")),
|
||||||
|
cache_manager=_Holder(),
|
||||||
|
**plugin,
|
||||||
|
) == "America/Chicago"
|
||||||
|
|
||||||
|
|
||||||
|
def test_raising_config_manager_falls_through(plugin, system_zone):
|
||||||
|
assert resolve_timezone_name(
|
||||||
|
config={},
|
||||||
|
plugin_manager=_Holder(_BrokenConfigManager()),
|
||||||
|
cache_manager=_Holder(_ConfigManager("America/Chicago")),
|
||||||
|
**plugin,
|
||||||
|
) == "America/Chicago"
|
||||||
|
|
||||||
|
|
||||||
|
def test_blank_and_invalid_values_are_skipped(plugin, system_zone, caplog):
|
||||||
|
with caplog.at_level(logging.WARNING, logger=sports_timezone.__name__):
|
||||||
|
name = resolve_timezone_name(
|
||||||
|
config={"timezone": " "},
|
||||||
|
plugin_manager=_Holder(_ConfigManager("Not/AZone")),
|
||||||
|
cache_manager=_Holder(_ConfigManager("America/Chicago")),
|
||||||
|
**plugin,
|
||||||
|
)
|
||||||
|
assert name == "America/Chicago"
|
||||||
|
assert caplog.messages == [
|
||||||
|
"Ignoring invalid timezone 'Not/AZone' from plugin_manager.config_manager"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_system_timezone_backstop(plugin, system_zone):
|
||||||
|
system_zone("America/Chicago")
|
||||||
|
assert resolve_timezone_name(
|
||||||
|
config={}, plugin_manager=_Holder(), cache_manager=_Holder(), **plugin,
|
||||||
|
) == "America/Chicago"
|
||||||
|
|
||||||
|
|
||||||
|
def test_utc_last_resort_names_the_plugin(plugin, system_zone, caplog):
|
||||||
|
with caplog.at_level(logging.WARNING, logger=sports_timezone.__name__):
|
||||||
|
name = resolve_timezone_name(config={}, **plugin)
|
||||||
|
assert name == "UTC"
|
||||||
|
# Word for word what the plugins' copies logged, with their own name in it.
|
||||||
|
assert caplog.messages == [
|
||||||
|
"Could not determine a timezone from the plugin config, the LEDMatrix "
|
||||||
|
"config or the system; game times will be shown in UTC. Set a timezone "
|
||||||
|
f"in the {plugin['plugin_label']}'s Advanced Settings to override."]
|
||||||
|
|
||||||
|
|
||||||
|
def test_log_defaults_to_this_modules_logger_and_honours_a_given_one(plugin, system_zone, caplog):
|
||||||
|
own = logging.getLogger("test.sports_timezone.own")
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
resolve_timezone_name(config={}, **plugin)
|
||||||
|
resolve_timezone_name(config={}, log=own, **plugin)
|
||||||
|
assert [r.name for r in caplog.records] == [sports_timezone.__name__, own.name]
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_timezone_returns_tzinfo_and_converts(plugin):
|
||||||
|
tz = resolve_timezone(config={"timezone": "America/Chicago"}, **plugin)
|
||||||
|
# 2026-07-28 23:45Z is a 6:45pm CDT first pitch -- the exact symptom that
|
||||||
|
# started this: a Chicago game rendering as 11:45PM.
|
||||||
|
local = datetime(2026, 7, 28, 23, 45, tzinfo=pytz.UTC).astimezone(tz)
|
||||||
|
assert local.strftime("%I:%M%p").lstrip("0") == "6:45PM"
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_level_utc_when_the_global_config_disagrees(plugin, system_zone, caplog):
|
||||||
|
"""With the write-back bug, a bare "UTC" is its artifact and is ignored;
|
||||||
|
without it, "UTC" can only be the user's own choice and is honored."""
|
||||||
|
with caplog.at_level(logging.WARNING, logger=sports_timezone.__name__):
|
||||||
|
name = resolve_timezone_name(
|
||||||
|
config={"timezone": "UTC"},
|
||||||
|
plugin_manager=_Holder(_RealCoreConfigManager({"timezone": "America/Chicago"})),
|
||||||
|
cache_manager=_Holder(),
|
||||||
|
**plugin,
|
||||||
|
)
|
||||||
|
fixed_in = plugin["writeback_fixed_in"]
|
||||||
|
if fixed_in is None:
|
||||||
|
assert name == "UTC"
|
||||||
|
assert caplog.messages == []
|
||||||
|
else:
|
||||||
|
assert name == "America/Chicago"
|
||||||
|
assert caplog.messages == [
|
||||||
|
"Ignoring the plugin-level timezone 'UTC': it is almost certainly "
|
||||||
|
f"left over from the write-back bug fixed in {fixed_in}, and "
|
||||||
|
"plugin_manager.config_manager says America/Chicago. Using "
|
||||||
|
"America/Chicago. If you really do want UTC here, set this plugin's "
|
||||||
|
"timezone to 'Etc/UTC' instead."]
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_level_utc_against_the_system_zone(plugin, system_zone):
|
||||||
|
system_zone("America/Chicago")
|
||||||
|
name = resolve_timezone_name(
|
||||||
|
config={"timezone": "UTC"}, plugin_manager=_Holder(), cache_manager=_Holder(),
|
||||||
|
**plugin,
|
||||||
|
)
|
||||||
|
assert name == ("UTC" if plugin["writeback_fixed_in"] is None else "America/Chicago")
|
||||||
|
|
||||||
|
|
||||||
|
def test_utc_is_kept_when_nothing_disagrees(plugin, system_zone):
|
||||||
|
"""A genuinely-UTC device must not be dragged off UTC."""
|
||||||
|
system_zone("UTC")
|
||||||
|
assert resolve_timezone_name(
|
||||||
|
config={"timezone": "UTC"},
|
||||||
|
plugin_manager=_Holder(_RealCoreConfigManager({"timezone": "UTC"})),
|
||||||
|
cache_manager=_Holder(),
|
||||||
|
**plugin,
|
||||||
|
) == "UTC"
|
||||||
|
|
||||||
|
|
||||||
|
def test_etc_utc_is_always_honored(plugin):
|
||||||
|
"""The unambiguous opt-in the write-back bug could never have produced."""
|
||||||
|
assert resolve_timezone_name(
|
||||||
|
config={"timezone": "Etc/UTC"},
|
||||||
|
plugin_manager=_Holder(_RealCoreConfigManager({"timezone": "America/Chicago"})),
|
||||||
|
cache_manager=_Holder(),
|
||||||
|
**plugin,
|
||||||
|
) == "Etc/UTC"
|
||||||
|
|
||||||
|
|
||||||
|
def test_absent_global_key_falls_through_to_system_zone(plugin, system_zone):
|
||||||
|
"""The core's get_timezone() returns its own 'UTC' default for a config
|
||||||
|
with no timezone key; that must not mask the system zone."""
|
||||||
|
system_zone("America/Chicago")
|
||||||
|
assert resolve_timezone_name(
|
||||||
|
config={},
|
||||||
|
plugin_manager=_Holder(_RealCoreConfigManager({"display": {}})),
|
||||||
|
cache_manager=_Holder(),
|
||||||
|
**plugin,
|
||||||
|
) == "America/Chicago"
|
||||||
|
|
||||||
|
|
||||||
|
def test_present_global_key_still_wins_over_system_zone(plugin, system_zone):
|
||||||
|
system_zone("America/Denver")
|
||||||
|
assert resolve_timezone_name(
|
||||||
|
config={},
|
||||||
|
plugin_manager=_Holder(_RealCoreConfigManager({"timezone": "America/Chicago"})),
|
||||||
|
cache_manager=_Holder(),
|
||||||
|
**plugin,
|
||||||
|
) == "America/Chicago"
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_label_is_required():
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
resolve_timezone_name(config={}) # type: ignore[call-arg]
|
||||||
|
|
||||||
|
|
||||||
|
def test_system_timezone_name_is_a_string_or_none():
|
||||||
|
value = sports_timezone.system_timezone_name()
|
||||||
|
assert value is None or isinstance(value, str)
|
||||||
Reference in New Issue
Block a user