mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-14 11:06:39 +00:00
Compare commits
5
Commits
cd5a4e2251
...
f3894916a9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3894916a9 | ||
|
|
9a1f94f793 | ||
|
|
61e462c635 | ||
|
|
4fe3cdd906 | ||
|
|
4a1fd7464a |
@@ -52,7 +52,6 @@ config/backups/
|
|||||||
|
|
||||||
# Starlark apps runtime storage (installed .star files and cached renders)
|
# Starlark apps runtime storage (installed .star files and cached renders)
|
||||||
/starlark-apps/
|
/starlark-apps/
|
||||||
skin_renders/
|
|
||||||
|
|
||||||
# JS test deps (test/js)
|
# JS test deps (test/js)
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|||||||
@@ -25,6 +25,21 @@ accepts both, but the store flags the old spelling as deprecated
|
|||||||
that will now render the font it asked for.
|
that will now render the font it asked for.
|
||||||
- `src.wifi_manager.get_wifi_status_path()` — where WiFi status messages for
|
- `src.wifi_manager.get_wifi_status_path()` — where WiFi status messages for
|
||||||
the display are written (`config/wifi_status.json`).
|
the display are written (`config/wifi_status.json`).
|
||||||
|
- `src.device_location` — a blank `Location` field on a Starlark (Tidbyt) app
|
||||||
|
now renders at the device's City / State / Country (geocoded once via
|
||||||
|
Open-Meteo and cached) instead of the app author's hard-coded default,
|
||||||
|
usually San Francisco. A location saved on the app still wins. With no
|
||||||
|
device city set, or when the lookup fails or finds no match, the app keeps
|
||||||
|
its own default (a failed lookup is retried after 30 minutes). Clearing an
|
||||||
|
app's location in the web UI now actually clears it; the save used to drop
|
||||||
|
the blank field, so the old value stayed.
|
||||||
|
|
||||||
|
- The web UI's Fonts tab has a **Used by** column: the loaded plugins that
|
||||||
|
registered each font with `FontManager.register_manager_font()`, published
|
||||||
|
by the display service to the shared cache (`src/font_usage.py`) and merged
|
||||||
|
into `GET /api/v3/fonts/catalog` as `used_by`. Deleting a font a plugin
|
||||||
|
uses now names those plugins in the confirmation (it is not blocked).
|
||||||
|
`FontManager.forget_manager_fonts()` is new; unloading a plugin calls it.
|
||||||
|
|
||||||
Deprecated, removed in 3.7.0 (each logs a warning on first use; see
|
Deprecated, removed in 3.7.0 (each logs a warning on first use; see
|
||||||
`docs/PLUGIN_API_REFERENCE.md#deprecated-apis` for replacements). Nothing in
|
`docs/PLUGIN_API_REFERENCE.md#deprecated-apis` for replacements). Nothing in
|
||||||
@@ -89,6 +104,38 @@ floor on the release that ships them):
|
|||||||
longer set `Accept-Encoding: ... br` by hand (brotli is not installed, so a
|
longer set `Accept-Encoding: ... br` by hand (brotli is not installed, so a
|
||||||
`br` response could not be decoded); requests picks the encodings.
|
`br` response could not be decoded); requests picks the encodings.
|
||||||
|
|
||||||
|
### Plugin error reporting
|
||||||
|
|
||||||
|
- `/api/v3/errors/summary` and `/api/v3/errors/plugin/<id>` report the errors
|
||||||
|
the display service recorded. They used to read the web process's own error
|
||||||
|
aggregator, which never records anything, so they always answered "no
|
||||||
|
errors". The display service now publishes a bounded snapshot to the shared
|
||||||
|
cache (`plugin_error_snapshot`, at most every 10 seconds and only on change;
|
||||||
|
`src/error_aggregator.py`, started from `DisplayController.__init__`).
|
||||||
|
Responses keep their shape and add `snapshot_available`, `generated_at` and
|
||||||
|
`clear_pending`; exception text has credentials redacted.
|
||||||
|
- `POST /api/v3/errors/clear` records a request (`plugin_error_clear_request`)
|
||||||
|
the display service applies within about 5 seconds; reads hide the cleared
|
||||||
|
errors at once. It accepts `"all": true`, and `cleared_count` can be `null`
|
||||||
|
when the count is only known to the display service.
|
||||||
|
- The Logs tab has a **Plugin errors** panel: per-plugin counts, repeating
|
||||||
|
errors and a Clear button.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- **The skin system.** Skins never rendered with the current scoreboard
|
||||||
|
plugins, so they are gone rather than "not supported yet": `src/skin_system/`,
|
||||||
|
`skins/`, `scripts/validate_skin.py`, `GET /api/v3/skins`, the store's
|
||||||
|
`"type": "skin"` handling and `docs/SKIN_SYSTEM.md` / `docs/CREATING_SKINS.md`.
|
||||||
|
A `skin` or `skin_options` key left in a plugin's saved config still loads
|
||||||
|
and saves without a validation error; it is ignored, and the next save of
|
||||||
|
that plugin's settings removes it (unless the plugin's own schema declares
|
||||||
|
the key).
|
||||||
|
- **`src/base_classes/`** (`SportsCore`, the sport and mode classes,
|
||||||
|
`CelebrationMixin`, the rotation strategies, `data_sources`,
|
||||||
|
`api_extractors`). No known plugin imports it. A plugin that does must use
|
||||||
|
`src.common` or its own copy of the code.
|
||||||
|
|
||||||
## 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):
|
||||||
|
|||||||
@@ -45,17 +45,6 @@
|
|||||||
- Plugin configs stored in `config/config.json`, NOT in plugin directories — safe across reinstalls
|
- Plugin configs stored in `config/config.json`, NOT in plugin directories — safe across reinstalls
|
||||||
- Third-party plugins can use their own repo URL with empty `plugin_path`
|
- Third-party plugins can use their own repo URL with empty `plugin_path`
|
||||||
|
|
||||||
## Skin System (visual overlays for sports scoreboards) — NOT SUPPORTED YET
|
|
||||||
- Skins do not render with the current scoreboard plugins: the only hook is `SportsCore._render_game()` in `src/base_classes/sports/core.py`, and no current scoreboard plugin (monorepo or third-party registry) builds on `src.base_classes`
|
|
||||||
- So core doesn't offer them: no Visual Skin dropdown (`get_plugin_schema` skips `inject_skin_selector`), the store hides/refuses `"type": "skin"` entries, `GET /api/v3/skins` reports `"supported": false`. Switch: `SKINS_RENDER_SUPPORTED` in `src/skin_system/__init__.py`
|
|
||||||
- Stored `skin` / `skin_options` config values must keep loading and saving (base schema allows them; form saves deep-merge over the stored section)
|
|
||||||
- Skins live in `skins/<skin-id>/` (skin.json + skin.py), NOT in plugin dirs — plugin reinstall deletes plugin dirs
|
|
||||||
- Core: `src/skin_system/` (ScoreboardSkin, SkinContext, runtime); keep it and its tests
|
|
||||||
- Skins render onto `ctx.canvas` only; fallback to built-in renderer on `False`/exception (3 strikes disables for session)
|
|
||||||
- View-model guaranteed keys are frozen (see `test/test_skin_system.py::TestViewModelContract`) — renaming keys in `_extract_game_details_common` or sport extractors breaks published skins
|
|
||||||
- Validate skins headlessly: `python scripts/validate_skin.py --skin <id>`; docs: `docs/SKIN_SYSTEM.md`, `docs/CREATING_SKINS.md`
|
|
||||||
- Skins are NOT monorepo plugins: no manifest bump / update_registry.py needed
|
|
||||||
|
|
||||||
## Common Pitfalls
|
## Common Pitfalls
|
||||||
- paho-mqtt 2.x needs `callback_api_version=mqtt.CallbackAPIVersion.VERSION1` for v1 compat
|
- paho-mqtt 2.x needs `callback_api_version=mqtt.CallbackAPIVersion.VERSION1` for v1 compat
|
||||||
- BasePlugin uses `get_logger()` from `src.logging_config`, not standard `logging.getLogger()`
|
- BasePlugin uses `get_logger()` from `src.logging_config`, not standard `logging.getLogger()`
|
||||||
|
|||||||
+1
-1
@@ -44,7 +44,7 @@ Four strengths define LEDMatrix, and future work must protect all of them:
|
|||||||
- **Hard constraint: plugin UI compatibility.** Third-party plugins rely on JSON Schema (Draft-7) generated config forms, the widget registry (`static/v3/js/widgets/`), `x-secret` fields, and plugin web-UI actions. UI changes must keep these working.
|
- **Hard constraint: plugin UI compatibility.** Third-party plugins rely on JSON Schema (Draft-7) generated config forms, the widget registry (`static/v3/js/widgets/`), `x-secret` fields, and plugin web-UI actions. UI changes must keep these working.
|
||||||
- **Config storage.** Plugin configuration lives in `config/config.json` and secrets in `config/config_secrets.json`, never in plugin directories, so configs survive reinstalls.
|
- **Config storage.** Plugin configuration lives in `config/config.json` and secrets in `config/config_secrets.json`, never in plugin directories, so configs survive reinstalls.
|
||||||
- **Stack.** An existing Flask + HTMX + Alpine.js app with Jinja templates (`web_interface/templates/v3/`) and static JS/CSS (`web_interface/static/v3/`), with self-hosted vendor assets.
|
- **Stack.** An existing Flask + HTMX + Alpine.js app with Jinja templates (`web_interface/templates/v3/`) and static JS/CSS (`web_interface/static/v3/`), with self-hosted vendor assets.
|
||||||
- **Terminology.** Plugin, Plugin Store, Starlark app, rotation, display duration, Vegas Scroll Mode, skin, on-demand, AP mode.
|
- **Terminology.** Plugin, Plugin Store, Starlark app, rotation, display duration, Vegas Scroll Mode, on-demand, AP mode.
|
||||||
- **Open decisions** (offered during init, not adopted as constraints):
|
- **Open decisions** (offered during init, not adopted as constraints):
|
||||||
- Whether the UI must work fully offline, with no CDN fallbacks at runtime.
|
- Whether the UI must work fully offline, with no CDN fallbacks at runtime.
|
||||||
- Whether a Node/CSS build step is acceptable for contributors.
|
- Whether a Node/CSS build step is acceptable for contributors.
|
||||||
|
|||||||
@@ -460,15 +460,6 @@ See the [Plugin Store documentation](https://github.com/ChuckBuilds/ledmatrix-pl
|
|||||||
|
|
||||||
For plugin development, the `plugins/hello-world/` plugin in the [ledmatrix-plugins](https://github.com/ChuckBuilds/ledmatrix-plugins) repository is a starter template.
|
For plugin development, the `plugins/hello-world/` plugin in the [ledmatrix-plugins](https://github.com/ChuckBuilds/ledmatrix-plugins) repository is a starter template.
|
||||||
|
|
||||||
### Visual Skins for Scoreboards
|
|
||||||
|
|
||||||
**Not supported yet.** Skins are meant to restyle a sports scoreboard's
|
|
||||||
live/recent/upcoming screens without forking the plugin, but the current
|
|
||||||
scoreboard plugins don't render them: a selected skin has no effect. The web
|
|
||||||
UI doesn't offer skin install or selection for that reason. The skin system
|
|
||||||
and its docs stay in place for when scoreboards adopt it; see
|
|
||||||
[docs/SKIN_SYSTEM.md](docs/SKIN_SYSTEM.md) for why.
|
|
||||||
|
|
||||||
**Built-in Managers Deprecated**: The built-in managers (hockey, football, stocks, etc.) are now deprecated and have been moved to the plugin system. **You must install replacement plugins from the Plugin Store** in the web interface instead. The plugin system provides the same functionality with better maintainability and extensibility.
|
**Built-in Managers Deprecated**: The built-in managers (hockey, football, stocks, etc.) are now deprecated and have been moved to the plugin system. **You must install replacement plugins from the Plugin Store** in the web interface instead. The plugin system provides the same functionality with better maintainability and extensibility.
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ tooling against it.
|
|||||||
| `auto_update.enabled` | bool, `false` | Weekly automatic updates: LEDMatrix code first (health-checked, rolled back on failure), then installed plugins. Toggle in the General tab or install with `first_time_install.sh --enable-auto-update` | `web_interface/auto_update.py`, `src/auto_update_setup.py` (`is_enabled()`) |
|
| `auto_update.enabled` | bool, `false` | Weekly automatic updates: LEDMatrix code first (health-checked, rolled back on failure), then installed plugins. Toggle in the General tab or install with `first_time_install.sh --enable-auto-update` | `web_interface/auto_update.py`, `src/auto_update_setup.py` (`is_enabled()`) |
|
||||||
| `timezone` | string, `"America/New_York"` | IANA timezone for schedules and displays | `ConfigManager.get_timezone()` |
|
| `timezone` | string, `"America/New_York"` | IANA timezone for schedules and displays | `ConfigManager.get_timezone()` |
|
||||||
| `target_fps` | int, `100` | Legacy "Scroll Frame Rate". Core scrolling no longer reads it: scroll frames are presented at `display.hardware.limit_refresh_rate_hz` divided by each scroll's frame hold, and speed comes from each plugin's scroll settings. Still exposed to plugins via `BasePlugin.global_config` | `src/plugin_system/base_plugin.py` |
|
| `target_fps` | int, `100` | Legacy "Scroll Frame Rate". Core scrolling no longer reads it: scroll frames are presented at `display.hardware.limit_refresh_rate_hz` divided by each scroll's frame hold, and speed comes from each plugin's scroll settings. Still exposed to plugins via `BasePlugin.global_config` | `src/plugin_system/base_plugin.py` |
|
||||||
| `location` | object | `city` / `state` / `country`. Supplies the **default** for a plugin's own `location_city` / `location_state` / `location_country` setting, so weather, radar and friends follow this device without being configured twice. A value saved on the plugin itself still overrides it. | `SchemaManager.apply_device_location()`, then plugins via merged config |
|
| `location` | object | `city` / `state` / `country`. Supplies the **default** for a plugin's own `location_city` / `location_state` / `location_country` setting, so weather, radar and friends follow this device without being configured twice. A value saved on the plugin itself still overrides it. Starlark (Tidbyt) apps get the same treatment: a `Location` field left blank on the app renders at this city (geocoded once via Open-Meteo, coordinates cached permanently) instead of the app author's default, which is usually San Francisco. If the city can't be looked up (no match, or the geocoder is unreachable; retried after 30 minutes), the app keeps its own default. | `SchemaManager.apply_device_location()`, then plugins via merged config; `src/device_location.py` for Starlark apps |
|
||||||
|
|
||||||
## `schedule` — display on/off hours
|
## `schedule` — display on/off hours
|
||||||
|
|
||||||
@@ -104,7 +104,7 @@ logical image to multiple chained physical panels.
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `display_durations` | object, `{}` | Per-plugin display duration in seconds, keyed by plugin id (e.g. `"clock": 15`) | `DisplayController._get_display_duration()` (`src/display_controller.py`) |
|
| `display_durations` | object, `{}` | Per-plugin display duration in seconds, keyed by plugin id (e.g. `"clock": 15`) | `DisplayController._get_display_duration()` (`src/display_controller.py`) |
|
||||||
| `plugin_rotation_order` | array, `[]` | Explicit rotation order of plugin ids; empty = all enabled plugins in discovery order | `DisplayController._apply_plugin_rotation_order()` (`src/display_controller.py`) |
|
| `plugin_rotation_order` | array, `[]` | Explicit rotation order of plugin ids; empty = all enabled plugins in discovery order | `DisplayController._apply_plugin_rotation_order()` (`src/display_controller.py`) |
|
||||||
| `use_short_date_format` | bool, `true` | Compact date rendering in sports scoreboards | `src/base_classes/sports/core.py` |
|
| `use_short_date_format` | bool, `true` | Compact date rendering in sports scoreboards | Nothing since `src/base_classes` was removed; scoreboards read `display.use_short_date_format` from their own plugin config |
|
||||||
| `dynamic_duration.max_duration_seconds` | int, optional | Cap for plugins that request dynamic display time | `DisplayController._get_global_dynamic_cap()` (`src/display_controller.py`) |
|
| `dynamic_duration.max_duration_seconds` | int, optional | Cap for plugins that request dynamic display time | `DisplayController._get_global_dynamic_cap()` (`src/display_controller.py`) |
|
||||||
|
|
||||||
## `display.vegas_scroll` — continuous scroll mode
|
## `display.vegas_scroll` — continuous scroll mode
|
||||||
|
|||||||
@@ -1,254 +0,0 @@
|
|||||||
# Creating Skins
|
|
||||||
|
|
||||||
> **Not supported yet: skins don't render with the current scoreboard
|
|
||||||
> plugins.** The only render hook is `SportsCore._render_game()` in
|
|
||||||
> `src/base_classes/sports/core.py`, and no current scoreboard (monorepo or
|
|
||||||
> third-party) builds on `src.base_classes`, so a skin you build here passes
|
|
||||||
> `validate_skin.py` but never appears on the matrix. The web UI and Plugin
|
|
||||||
> Store don't offer skins for that reason. Details:
|
|
||||||
> [SKIN_SYSTEM.md](SKIN_SYSTEM.md#status-not-supported-yet). The guide below
|
|
||||||
> stays accurate for the skin API itself.
|
|
||||||
|
|
||||||
A skin restyles a sports scoreboard (live / recent / upcoming) without
|
|
||||||
forking the plugin: the plugin keeps fetching data, scheduling, caching, and
|
|
||||||
doing vegas mode; your skin only draws. Architecture background:
|
|
||||||
[SKIN_SYSTEM.md](SKIN_SYSTEM.md).
|
|
||||||
|
|
||||||
## Quick start
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp -r skins/example-classic-baseball skins/my-skin
|
|
||||||
# edit skins/my-skin/skin.json -> set id ("my-skin"), name, author, class_name
|
|
||||||
# edit skins/my-skin/skin.py -> rename the class, start restyling
|
|
||||||
python scripts/validate_skin.py --skin my-skin
|
|
||||||
```
|
|
||||||
|
|
||||||
The validator renders your skin against bundled fixture games at several
|
|
||||||
panel sizes with **no hardware, no network, no running service**, saves PNGs
|
|
||||||
(plus 4x previews) to `skin_renders/`, and fails loudly on errors. Iterate:
|
|
||||||
edit → validate → look at the PNGs.
|
|
||||||
|
|
||||||
To select it, add to your plugin's section in `config/config.json` (this is
|
|
||||||
stored and validated, but has no visible effect until a scoreboard uses the
|
|
||||||
skin hook — see the note at the top):
|
|
||||||
|
|
||||||
```json
|
|
||||||
"baseball-scoreboard": {
|
|
||||||
"skin": "my-skin",
|
|
||||||
"skin_options": { }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The web UI's **Visual Skin** dropdown is hidden while skins are unsupported.
|
|
||||||
`"skin"` also accepts a per-mode mapping:
|
|
||||||
`{"live": "my-skin", "recent": "built-in"}`.
|
|
||||||
|
|
||||||
## The manifest (`skin.json`)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "my-skin",
|
|
||||||
"name": "My Skin",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"author": "you",
|
|
||||||
"description": "What it looks like",
|
|
||||||
"skin_api_version": "1.0.0",
|
|
||||||
"targets": {
|
|
||||||
"sports": ["baseball"],
|
|
||||||
"sport_keys": ["mlb", "milb"],
|
|
||||||
"plugins": []
|
|
||||||
},
|
|
||||||
"entry_point": "skin.py",
|
|
||||||
"class_name": "MySkin",
|
|
||||||
"modes": ["live", "recent", "upcoming"],
|
|
||||||
"preview": "preview.png"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Field notes: `id` must equal the directory name; `skin_api_version`'s major
|
|
||||||
version must match the host's `SKIN_API_VERSION` or the skin is refused at
|
|
||||||
load; `targets` takes sport families (`sports`), exact sport keys
|
|
||||||
(`sport_keys`), and/or exact plugin ids (`plugins`) — any match applies.
|
|
||||||
|
|
||||||
## The renderer (`skin.py`)
|
|
||||||
|
|
||||||
```python
|
|
||||||
from src.skin_system.skin_base import ScoreboardSkin, SkinContext
|
|
||||||
|
|
||||||
class MySkin(ScoreboardSkin):
|
|
||||||
def render_live(self, ctx: SkinContext, game: dict) -> bool:
|
|
||||||
score = f"{game.get('away_score', '0')}-{game.get('home_score', '0')}"
|
|
||||||
fit = ctx.layout.fit_text(score, ctx.layout.bounds)
|
|
||||||
ctx.draw_fit(fit, ctx.layout.bounds)
|
|
||||||
return True # True = "I drew it"; False = use the built-in layout
|
|
||||||
```
|
|
||||||
|
|
||||||
Implement only the modes you care about — anything else falls back to the
|
|
||||||
plugin's built-in rendering. Return `False` to decline a specific game (e.g.
|
|
||||||
a layout that only makes sense while a game is live).
|
|
||||||
|
|
||||||
### The rules (they keep your skin from breaking the display)
|
|
||||||
|
|
||||||
1. **Draw only onto `ctx.canvas`** (via the helpers or `ctx.draw`). Never
|
|
||||||
reassign `ctx.canvas`, never touch the display or call any update method.
|
|
||||||
2. **No I/O in render paths.** No network, no file loads per frame —
|
|
||||||
`render_live` runs every display pass, and a slow render stalls the whole
|
|
||||||
matrix (the host warns at >150 ms). Use `ctx.load_logo` (cached) and
|
|
||||||
`cache_key=` for images.
|
|
||||||
3. **Derive everything from `(ctx, game)`.** Skins must be stateless: the
|
|
||||||
live/recent/upcoming modes each get their own instance.
|
|
||||||
4. **Always `.get()` optional keys.** Only the guaranteed keys below are
|
|
||||||
promised to exist.
|
|
||||||
5. **Never hardcode pixel positions for the panel.** Use `ctx.width`/
|
|
||||||
`ctx.height`, `ctx.layout` regions and `fit_text` — your skin will be run
|
|
||||||
at sizes you didn't test (64x32, 128x64, vegas cards).
|
|
||||||
6. **No third-party dependencies.** Stdlib + PIL + what `ctx` provides.
|
|
||||||
|
|
||||||
A skin that raises 3 renders in a row is disabled until the service restarts
|
|
||||||
(the built-in layout takes over), so a bug is cosmetic — but check your logs.
|
|
||||||
|
|
||||||
## SkinContext reference
|
|
||||||
|
|
||||||
| Member | What it is |
|
|
||||||
|---|---|
|
|
||||||
| `ctx.canvas` / `ctx.draw` | Fresh RGB `PIL.Image` at display size + its `ImageDraw` (raw-PIL escape hatch) |
|
|
||||||
| `ctx.width`, `ctx.height` | Canvas size — the only size truth |
|
|
||||||
| `ctx.layout` | `LayoutContext` (see [ADAPTIVE_LAYOUT.md](ADAPTIVE_LAYOUT.md)): `bounds`, `fit_text`, `fit_text_proportional`, `fit_image`, `px`, `by_tier` |
|
|
||||||
| `ctx.draw_fit(fit, box, color, align, valign)` | Draw a `fit_text` result aligned in a `Region` (handles BDF fonts) |
|
|
||||||
| `ctx.draw_text(text, x, y, color, font)` | Positioned text (handles BDF fonts) |
|
|
||||||
| `ctx.draw_image(img, box, mode, align, valign, cache_key)` | Fit + paste an image with alpha; no-ops on `None` |
|
|
||||||
| `ctx.load_logo("home" \| "away")` | Team logo as RGBA, or `None` (always handle `None`). Cached after first use; see note below |
|
|
||||||
| `ctx.draw_text_outlined(text, (x, y), font, fill, outline_color)` | The classic scorebug outlined text (TTF fonts only) |
|
|
||||||
| `ctx.fonts` | The host's font dict — keys `score`, `time`, `team`, `status`, `detail`, `rank` |
|
|
||||||
| `ctx.options` | Your user's `skin_options` from config |
|
|
||||||
| `ctx.sport`, `ctx.view_model_version`, `ctx.logger` | Context metadata + logger |
|
|
||||||
|
|
||||||
**A note on `ctx.load_logo` vs the no-I/O rule:** `load_logo` is the one
|
|
||||||
sanctioned exception. It goes through the host's logo cache — after the
|
|
||||||
first call per team it's a pure in-memory lookup. If a logo file is missing
|
|
||||||
on disk, the *first* call may download it, exactly like the built-in
|
|
||||||
renderer does for the same game (a skin is never worse than built-in here).
|
|
||||||
Always pass a stable `cache_key` when drawing it, never load image files
|
|
||||||
yourself in a render path, and always handle `None`.
|
|
||||||
|
|
||||||
The default layout idiom — carve regions, then fit text into them:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from src.adaptive_layout import scoreboard_regions
|
|
||||||
|
|
||||||
regions = scoreboard_regions(ctx.layout.bounds, ctx=ctx.layout)
|
|
||||||
ctx.draw_image(ctx.load_logo("away"), regions.away_slot, cache_key=f"logo:{game.get('away_abbr')}")
|
|
||||||
ctx.draw_image(ctx.load_logo("home"), regions.home_slot, cache_key=f"logo:{game.get('home_abbr')}")
|
|
||||||
fit = ctx.layout.fit_text("3-5", regions.score_area)
|
|
||||||
ctx.draw_fit(fit, regions.score_area)
|
|
||||||
```
|
|
||||||
|
|
||||||
`Region` supports `split_h`/`split_v`/`inset`/`top_band`/`bottom_band`/
|
|
||||||
`left_col`/`right_col` for custom carves. Raw `ctx.draw.rectangle/polygon/
|
|
||||||
ellipse/...` is always available for custom marks (see the bases diamond in
|
|
||||||
the example skin).
|
|
||||||
|
|
||||||
## The game view model
|
|
||||||
|
|
||||||
Guaranteed for every sport (view model v1.0 — renaming these breaks skins and
|
|
||||||
is treated as a breaking change upstream):
|
|
||||||
|
|
||||||
| Key | Notes |
|
|
||||||
|---|---|
|
|
||||||
| `id` | Event id (string) |
|
|
||||||
| `status_text` | Display-ready status, e.g. `"Final"`, `"7:30 PM"`, `"Bot 7th"` |
|
|
||||||
| `is_live`, `is_final`, `is_upcoming`, `is_halftime` | Booleans |
|
|
||||||
| `game_date`, `game_time` | Pre-formatted local date/time strings |
|
|
||||||
| `start_time_utc` | UTC `datetime` |
|
|
||||||
| `home_abbr`, `away_abbr` | Team abbreviations (can be 2–5 chars — fit, don't assume) |
|
|
||||||
| `home_id`, `away_id` | Team ids |
|
|
||||||
| `home_score`, `away_score` | **Strings**, not ints |
|
|
||||||
| `home_record`, `away_record` | `"58-33"` or `""` (0-0 records are blanked) |
|
|
||||||
| `home_logo_path`, `away_logo_path` | Prefer `ctx.load_logo` over touching these |
|
|
||||||
|
|
||||||
Sport extras (present for that sport, still `.get()` defensively):
|
|
||||||
|
|
||||||
- **baseball**: `inning` (int), `inning_half` (`"top"`/`"bottom"`), `balls`,
|
|
||||||
`strikes`, `outs` (ints), `bases_occupied` (`[first, second, third]`
|
|
||||||
booleans), `series_summary` (str)
|
|
||||||
- **football**: `period`, `period_text`, `clock`, `home_timeouts`,
|
|
||||||
`away_timeouts`, `down_distance_text`, `down_distance_text_long`,
|
|
||||||
`is_redzone`, `possession`, `possession_indicator` (`"home"`/`"away"`),
|
|
||||||
`scoring_event`
|
|
||||||
- **basketball**: `period`, `period_text`, `clock`
|
|
||||||
- **hockey**: `period`, `period_text`, `clock`, `power_play`, `penalties`,
|
|
||||||
`home_shots`, `away_shots`
|
|
||||||
|
|
||||||
Optional everywhere (only when the user enabled the feature): `odds` (dict),
|
|
||||||
`series_summary`, rankings-related fields.
|
|
||||||
|
|
||||||
Fixture copies of these dicts live in `src/skin_system/fixtures/` — that's
|
|
||||||
exactly what the validator feeds your skin.
|
|
||||||
|
|
||||||
## Vegas mode
|
|
||||||
|
|
||||||
You get vegas support for free: vegas captures the normal display output,
|
|
||||||
which is already your skin's rendering. Optionally implement
|
|
||||||
`render_vegas_card(ctx, game)` to return a purpose-built card at
|
|
||||||
`ctx.width x ctx.height` (sizes vary — never assume 128x32).
|
|
||||||
|
|
||||||
## Building a skin with Claude Code
|
|
||||||
|
|
||||||
Skins are ideal Claude Code projects: small, isolated, and verifiable with
|
|
||||||
one command. Paste this to start:
|
|
||||||
|
|
||||||
> You are building a **display skin** for LEDMatrix — a visual overlay for a
|
|
||||||
> sports scoreboard on a small LED matrix (commonly 128x32 or 64x32 pixels).
|
|
||||||
> First read `docs/CREATING_SKINS.md` and the reference skin in
|
|
||||||
> `skins/example-classic-baseball/`.
|
|
||||||
>
|
|
||||||
> Rules:
|
|
||||||
> - Create/modify files ONLY under `skins/<my-skin-id>/`. Do NOT modify
|
|
||||||
> anything in `src/`, `scripts/`, the plugins, or any other skin.
|
|
||||||
> - Render only from the `game` dict and `ctx` helpers. No network calls, no
|
|
||||||
> per-frame file I/O, no new pip dependencies, no touching the display —
|
|
||||||
> draw onto `ctx.canvas` and return True.
|
|
||||||
> - Use `ctx.layout` regions and `fit_text` for positioning so the skin works
|
|
||||||
> at any panel size; use `.get()` for every optional game key.
|
|
||||||
> - After every change run
|
|
||||||
> `python scripts/validate_skin.py --skin <my-skin-id>` and LOOK at the
|
|
||||||
> PNGs it writes to `skin_renders/` (the `_x4.png` files are easiest to
|
|
||||||
> read). Iterate until it passes and looks right at both 128x32 and 64x32.
|
|
||||||
>
|
|
||||||
> What I want it to look like: <describe your layout — where logos, score,
|
|
||||||
> status go; colors; what shows during live vs upcoming vs final>
|
|
||||||
|
|
||||||
Tips that keep Claude (and you) out of trouble:
|
|
||||||
|
|
||||||
- One mode at a time: get `render_live` right before touching the others —
|
|
||||||
unimplemented modes automatically use the built-in look.
|
|
||||||
- Ask for edge-case renders: long team abbreviations, missing logos
|
|
||||||
(`ctx.load_logo` returning `None`), 0-0 records, extra innings/OT.
|
|
||||||
- If the render looks cramped at 64x32, ask Claude to use
|
|
||||||
`ctx.layout.by_tier(...)` to drop elements on small panels rather than
|
|
||||||
shrinking everything.
|
|
||||||
- Never let it "fix" a problem by editing `src/` — if the skin can't do
|
|
||||||
something within its directory, that's a feature request, not a workaround.
|
|
||||||
|
|
||||||
## Pre-publish checklist
|
|
||||||
|
|
||||||
- [ ] `python scripts/validate_skin.py --skin <id> --size 128x32 --size 64x32 --size 128x64` passes
|
|
||||||
- [ ] Looked at every PNG in `skin_renders/` — nothing clipped or overlapping
|
|
||||||
- [ ] Handles a missing logo (`None`) without crashing — temporarily point a
|
|
||||||
fixture's logo path at a nonexistent file to test
|
|
||||||
- [ ] Long abbreviations (`"TA&M"`, 4–5 chars) don't overflow
|
|
||||||
- [ ] No render warning above the time budget
|
|
||||||
- [ ] `skin.json`: `id` matches the directory, `version` set,
|
|
||||||
`skin_api_version` matches the host, targets correct
|
|
||||||
- [ ] `preview.png` added (grab your favorite `_x4` render)
|
|
||||||
- [ ] Tested on real hardware if you have it — a Pi is much slower than your
|
|
||||||
dev machine
|
|
||||||
|
|
||||||
Distribute by publishing the directory as a git repo (users
|
|
||||||
`git clone <repo> skins/<id>`). Registry entries with `"type": "skin"` are
|
|
||||||
hidden and refused by the Plugin Store while skins are unsupported (see
|
|
||||||
[SKIN_SYSTEM.md](SKIN_SYSTEM.md) §Distribution).
|
|
||||||
|
|
||||||
**Trust note:** a skin is Python running inside the display service — the
|
|
||||||
same trust level as a plugin. Review code before installing skins from
|
|
||||||
others.
|
|
||||||
+31
-5
@@ -173,8 +173,10 @@ font = self.font_manager.resolve_font(
|
|||||||
> install directory.
|
> install directory.
|
||||||
>
|
>
|
||||||
> The web UI's **Fonts** tab lists, uploads, previews and deletes the
|
> The web UI's **Fonts** tab lists, uploads, previews and deletes the
|
||||||
> font files in `assets/fonts/`. It does not show fonts registered
|
> font files in `assets/fonts/`. Its **Used by** column shows which
|
||||||
> through `register_manager_font()` and has no override editor (the
|
> loaded plugins registered each file through `register_manager_font()`
|
||||||
|
> (see [Font usage in the web UI](#font-usage-in-the-web-ui)), and it
|
||||||
|
> warns before deleting one of them. It has no override editor (the
|
||||||
> override panels and `/api/v3/fonts/overrides` endpoints were removed).
|
> override panels and `/api/v3/fonts/overrides` endpoints were removed).
|
||||||
> The programmatic override workflow in
|
> The programmatic override workflow in
|
||||||
> [Manual Font Overrides](#manual-font-overrides) below still works.
|
> [Manual Font Overrides](#manual-font-overrides) below still works.
|
||||||
@@ -276,6 +278,31 @@ Place font files in `assets/fonts/` directory:
|
|||||||
- Font family name is derived from filename (without extension)
|
- Font family name is derived from filename (without extension)
|
||||||
- Will be automatically discovered on next initialization
|
- Will be automatically discovered on next initialization
|
||||||
|
|
||||||
|
## Font usage in the web UI
|
||||||
|
|
||||||
|
The web interface runs in its own process and has no FontManager, so the
|
||||||
|
display service publishes which plugin uses which font
|
||||||
|
(`src/font_usage.py`), and the Fonts tab's **Used by** column reads it:
|
||||||
|
|
||||||
|
- **Source**: `register_manager_font()` registrations of the loaded
|
||||||
|
plugins. `get_font()` and `resolve_font()` do not know the calling plugin
|
||||||
|
and are not counted, and neither is a plugin that opens a font file
|
||||||
|
directly with PIL — register the fonts your plugin draws with if you want
|
||||||
|
them listed.
|
||||||
|
- **Names**: a family, alias (`press_start`, `four_by_six`,
|
||||||
|
`five_by_seven`, `tom_thumb`) or path is resolved through
|
||||||
|
`font_catalog` to the file it loads and reported under that file's name
|
||||||
|
without extension (`PressStart2P-Regular`, `4x6-font`, `5x7`,
|
||||||
|
`tom-thumb`), which is how the Fonts tab keys its rows. Fonts outside
|
||||||
|
`assets/fonts/` (a plugin's own `plugin_id::family` fonts) and families
|
||||||
|
that resolve to nothing are left out.
|
||||||
|
- **When**: a daemon thread started once plugins have loaded checks every
|
||||||
|
10 seconds and writes the `font_usage_snapshot` cache key only when the
|
||||||
|
usage changed (and once a day, so the cache's cleanup never expires it).
|
||||||
|
Unloading a plugin drops its registrations (`forget_manager_fonts`).
|
||||||
|
- **Unknown**: until the display service has published, the column reads
|
||||||
|
"unknown" and `GET /api/v3/fonts/catalog` returns `used_by: null`.
|
||||||
|
|
||||||
## Performance Monitoring
|
## Performance Monitoring
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@@ -374,6 +401,7 @@ self.font = self.font_manager.resolve_font(
|
|||||||
### FontManager Methods
|
### FontManager Methods
|
||||||
|
|
||||||
- `register_manager_font(manager_id, element_key, family, size_px, color=None)` - Register font usage
|
- `register_manager_font(manager_id, element_key, family, size_px, color=None)` - Register font usage
|
||||||
|
- `forget_manager_fonts(manager_id)` - Drop a manager's registrations (core calls it when a plugin unloads)
|
||||||
- `resolve_font(element_key, family, size_px, plugin_id=None)` - Get font with override support
|
- `resolve_font(element_key, family, size_px, plugin_id=None)` - Get font with override support
|
||||||
- `get_font(family, size_px)` - Get font directly (bypasses overrides)
|
- `get_font(family, size_px)` - Get font directly (bypasses overrides)
|
||||||
- `measure_text(text, font)` - Measure text dimensions
|
- `measure_text(text, font)` - Measure text dimensions
|
||||||
@@ -393,7 +421,5 @@ self.font = self.font_manager.resolve_font(
|
|||||||
## Example: Complete Manager Implementation
|
## Example: Complete Manager Implementation
|
||||||
|
|
||||||
For a working example of the font manager API in use, see
|
For a working example of the font manager API in use, see
|
||||||
`src/font_manager.py` itself and the bundled scoreboard base classes
|
`src/font_manager.py` itself.
|
||||||
in `src/base_classes/` (e.g., `hockey.py`, `football.py`) which
|
|
||||||
register and resolve fonts via the patterns documented above.
|
|
||||||
|
|
||||||
|
|||||||
@@ -430,9 +430,7 @@ self.display_manager.image.paste(icon, (5, 5), icon)
|
|||||||
self.display_manager.update_display()
|
self.display_manager.update_display()
|
||||||
```
|
```
|
||||||
|
|
||||||
This is the same pattern the bundled scoreboard base classes
|
This is the canonical way to render arbitrary images.
|
||||||
(`src/base_classes/baseball.py`, `basketball.py`, `football.py`,
|
|
||||||
`hockey.py`) use, so it's the canonical way to render arbitrary images.
|
|
||||||
|
|
||||||
### Weather Icons
|
### Weather Icons
|
||||||
|
|
||||||
|
|||||||
@@ -12,9 +12,9 @@
|
|||||||
> in `web_interface/app.py`.
|
> in `web_interface/app.py`.
|
||||||
> - The default plugin location is `plugin-repos/` (configurable via
|
> - The default plugin location is `plugin-repos/` (configurable via
|
||||||
> `plugin_system.plugins_directory`), not `./plugins/`.
|
> `plugin_system.plugins_directory`), not `./plugins/`.
|
||||||
> - Example imports use `src/plugin_system/base_classes/*_plugin.py`;
|
> - Example imports use `src/plugin_system/base_classes/*_plugin.py`,
|
||||||
> the shipped base classes live in `src/base_classes/` (e.g.
|
> which do not exist. The old `src/base_classes/` package has been
|
||||||
> `src.base_classes.sports.SportsCore`, `src.base_classes.hockey.Hockey`).
|
> removed; shared sports code lives in `src/common/`.
|
||||||
> - The "Migration Strategy" and "Implementation Roadmap" sections
|
> - The "Migration Strategy" and "Implementation Roadmap" sections
|
||||||
> describe work that has now shipped.
|
> describe work that has now shipped.
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -25,16 +25,7 @@ is `CORE_PLUGIN_PROPERTIES` in `src/plugin_system/schema_manager.py`):
|
|||||||
- Description: Enable live priority takeover when plugin has live content
|
- Description: Enable live priority takeover when plugin has live content
|
||||||
- Used by DisplayController for priority scheduling
|
- Used by DisplayController for priority scheduling
|
||||||
|
|
||||||
4. **`skin`** (string, object or null; no default)
|
4. **`vegas_width_pct`**, **`vegas_overflow`**, **`vegas_max_width_screens`**
|
||||||
- Description: Visual skin id, or a per-mode mapping like `{"live": "my-skin"}`
|
|
||||||
- Not an enum, so a stored value keeps validating after the skin is
|
|
||||||
uninstalled. Skins do not render with the current scoreboard plugins;
|
|
||||||
the key is kept so stored values keep loading and saving
|
|
||||||
|
|
||||||
5. **`skin_options`** (object; no default)
|
|
||||||
- Description: Options passed through to the selected skin
|
|
||||||
|
|
||||||
6. **`vegas_width_pct`**, **`vegas_overflow`**, **`vegas_max_width_screens`**
|
|
||||||
(untyped; no default)
|
(untyped; no default)
|
||||||
- Description: Vegas mode tuning for this plugin — card width as a
|
- Description: Vegas mode tuning for this plugin — card width as a
|
||||||
percentage of the panel, `"rotate"` or `"truncate"` on overflow, and the
|
percentage of the panel, `"rotate"` or `"truncate"` on overflow, and the
|
||||||
@@ -42,6 +33,10 @@ is `CORE_PLUGIN_PROPERTIES` in `src/plugin_system/schema_manager.py`):
|
|||||||
- Read by `src/vegas_mode/plugin_adapter.py` and `BasePlugin`, which
|
- Read by `src/vegas_mode/plugin_adapter.py` and `BasePlugin`, which
|
||||||
validate the values themselves and ignore a bad one with a log line
|
validate the values themselves and ignore a bad one with a log line
|
||||||
|
|
||||||
|
`skin` and `skin_options` were core properties until the skin system was
|
||||||
|
removed. A plugin config saved with them still loads and saves; the keys are
|
||||||
|
dropped on the next save (see `RETIRED_PLUGIN_KEYS` in `schema_manager.py`).
|
||||||
|
|
||||||
## How Core Properties Work
|
## How Core Properties Work
|
||||||
|
|
||||||
### Schema Validation
|
### Schema Validation
|
||||||
|
|||||||
@@ -12,12 +12,6 @@ This guide explains how to set up a development workflow for plugins that are ma
|
|||||||
> scale. Existing plugins keep their classic rendering unless they adopt
|
> scale. Existing plugins keep their classic rendering unless they adopt
|
||||||
> those APIs; nothing migrates automatically.
|
> those APIs; nothing migrates automatically.
|
||||||
|
|
||||||
> **Want a different look for an existing sports scoreboard?** Skins are
|
|
||||||
> meant for that, but they are **not supported yet**: the current scoreboard
|
|
||||||
> plugins don't render them (see [SKIN_SYSTEM.md](SKIN_SYSTEM.md#status-not-supported-yet)).
|
|
||||||
> For now, change the look through the plugin's own display settings or its
|
|
||||||
> code.
|
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
When developing plugins in separate repositories, you need a way to:
|
When developing plugins in separate repositories, you need a way to:
|
||||||
|
|||||||
@@ -146,7 +146,10 @@ def display(self, force_clear: bool = False) -> bool:
|
|||||||
|
|
||||||
## Error Aggregation
|
## Error Aggregation
|
||||||
|
|
||||||
LEDMatrix automatically tracks plugin errors. Access error data via the API:
|
LEDMatrix automatically tracks plugin errors: every exception or timeout from
|
||||||
|
a plugin's `update()` or `display()` is recorded by the display service,
|
||||||
|
which runs the plugins. See them in the web interface under **Logs → Plugin
|
||||||
|
errors**, or through the API:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Get error summary
|
# Get error summary
|
||||||
@@ -155,10 +158,21 @@ curl http://localhost:5000/api/v3/errors/summary
|
|||||||
# Get plugin-specific health
|
# Get plugin-specific health
|
||||||
curl http://localhost:5000/api/v3/errors/plugin/my-plugin
|
curl http://localhost:5000/api/v3/errors/plugin/my-plugin
|
||||||
|
|
||||||
# Clear old errors
|
# Clear errors older than 24 hours (the default), or all of them
|
||||||
curl -X POST http://localhost:5000/api/v3/errors/clear
|
curl -X POST http://localhost:5000/api/v3/errors/clear
|
||||||
|
curl -X POST -H 'Content-Type: application/json' -d '{"all": true}' \
|
||||||
|
http://localhost:5000/api/v3/errors/clear
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The web interface is a separate process, so it reads a snapshot the display
|
||||||
|
service writes to the shared cache directory (`plugin_error_snapshot`): at most
|
||||||
|
every 10 seconds, and only when something changed. Expect the numbers to lag
|
||||||
|
by up to about 15 seconds, and to start from zero when the display service
|
||||||
|
restarts. `snapshot_available` is `false` until the display service has
|
||||||
|
reported. A clear is a request the display service applies within about 5
|
||||||
|
seconds; the API hides the cleared errors immediately. Details and response
|
||||||
|
shapes: [REST API reference](REST_API_REFERENCE.md#error-tracking).
|
||||||
|
|
||||||
### Error Patterns
|
### Error Patterns
|
||||||
|
|
||||||
When the same error occurs repeatedly (5+ times in 60 minutes), it's detected as a pattern and logged as a warning. This helps identify systemic issues.
|
When the same error occurs repeatedly (5+ times in 60 minutes), it's detected as a pattern and logged as a warning. This helps identify systemic issues.
|
||||||
|
|||||||
@@ -56,8 +56,6 @@ Going deeper:
|
|||||||
- [ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) — Vegas scroll, on-demand display,
|
- [ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) — Vegas scroll, on-demand display,
|
||||||
cache management, background services, permissions
|
cache management, background services, permissions
|
||||||
- [FONT_MANAGER.md](FONT_MANAGER.md) — font system
|
- [FONT_MANAGER.md](FONT_MANAGER.md) — font system
|
||||||
- [SKIN_SYSTEM.md](SKIN_SYSTEM.md) — skin architecture for sports scoreboards (not supported yet: current scoreboards don't render skins)
|
|
||||||
- [CREATING_SKINS.md](CREATING_SKINS.md) — writing and validating a skin (same caveat)
|
|
||||||
|
|
||||||
## Reference
|
## Reference
|
||||||
|
|
||||||
|
|||||||
+100
-22
@@ -37,7 +37,6 @@ the entry below says so.
|
|||||||
- [Integrations](#integrations)
|
- [Integrations](#integrations)
|
||||||
- [Plugin-specific endpoints](#plugin-specific-endpoints)
|
- [Plugin-specific endpoints](#plugin-specific-endpoints)
|
||||||
- [Starlark Apps](#starlark-apps)
|
- [Starlark Apps](#starlark-apps)
|
||||||
- [Skins](#skins)
|
|
||||||
|
|
||||||
> The API blueprint is the `api_v3` package in
|
> The API blueprint is the `api_v3` package in
|
||||||
> `web_interface/blueprints/api_v3/` (one module per area: `config.py`,
|
> `web_interface/blueprints/api_v3/` (one module per area: `config.py`,
|
||||||
@@ -1490,23 +1489,37 @@ Delete a stored backup.
|
|||||||
|
|
||||||
Fonts in `assets/fonts/`, keyed by file name without extension.
|
Fonts in `assets/fonts/`, keyed by file name without extension.
|
||||||
|
|
||||||
|
`used_by` lists the plugins that use the font, as the display service last
|
||||||
|
reported: the loaded plugins that registered it through
|
||||||
|
`FontManager.register_manager_font()`, under whatever family, alias
|
||||||
|
(`press_start`, `four_by_six`, ...) or path they gave. It is `[]` when no
|
||||||
|
loaded plugin registered it and `null` when the display service has not
|
||||||
|
reported yet (`font_usage.available` is then `false`). A plugin that opens a
|
||||||
|
font file directly is not counted, so `[]` does not prove a font is unused.
|
||||||
|
The file scan is cached for 5 minutes; `used_by` is read on every request.
|
||||||
|
|
||||||
**Response**:
|
**Response**:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"data": {
|
"data": {
|
||||||
"catalog": {
|
"catalog": {
|
||||||
"press_start": {
|
"PressStart2P-Regular": {
|
||||||
"filename": "press_start.ttf",
|
"filename": "PressStart2P-Regular.ttf",
|
||||||
"family_name": "Press Start 2P",
|
"family_name": "Press Start 2P",
|
||||||
"display_name": "Press Start 2P",
|
"display_name": "Press Start 2P",
|
||||||
"path": "assets/fonts/press_start.ttf",
|
"path": "assets/fonts/PressStart2P-Regular.ttf",
|
||||||
"type": "ttf",
|
"type": "ttf",
|
||||||
"is_system": true,
|
"is_system": true,
|
||||||
"scalable": true,
|
"scalable": true,
|
||||||
"native_size": null,
|
"native_size": null,
|
||||||
"metadata": { ... }
|
"metadata": { ... },
|
||||||
|
"used_by": ["calendar", "hello-world"]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"font_usage": {
|
||||||
|
"available": true,
|
||||||
|
"generated_at": "2026-09-23T10:00:00"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1561,7 +1574,9 @@ Upload a custom font file. It is saved as `assets/fonts/<font_family><ext>`.
|
|||||||
**DELETE** `/api/v3/fonts/<font_family>`
|
**DELETE** `/api/v3/fonts/<font_family>`
|
||||||
|
|
||||||
Delete an uploaded font (`<font_family>` is the file name without
|
Delete an uploaded font (`<font_family>` is the file name without
|
||||||
extension). System fonts answer `403`.
|
extension). System fonts answer `403`. A font a plugin uses is not refused;
|
||||||
|
the Fonts tab names those plugins (the catalog's `used_by`) in its
|
||||||
|
confirmation first.
|
||||||
|
|
||||||
### Font Preview
|
### Font Preview
|
||||||
|
|
||||||
@@ -1815,25 +1830,73 @@ The last 100 journal lines for `ledmatrix.service` and
|
|||||||
|
|
||||||
## Error tracking
|
## Error tracking
|
||||||
|
|
||||||
|
Plugin errors are recorded by the display service (`ledmatrix.service`),
|
||||||
|
which runs the plugins. It publishes a snapshot to the shared cache directory
|
||||||
|
(`plugin_error_snapshot`) at most every 10 seconds, and only when something
|
||||||
|
changed, so these endpoints lag the display by up to about 15 seconds. The
|
||||||
|
counts cover the display service's current run: they start at zero when it
|
||||||
|
restarts. Error messages and stack traces have credentials redacted, and
|
||||||
|
messages, traces and context values are truncated in the snapshot.
|
||||||
|
|
||||||
|
Every response below adds three fields to the shape it always had:
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `snapshot_available` | `false` until the display service has reported (for example, it is not running). Counts are then zero. |
|
||||||
|
| `generated_at` | When the display service produced the snapshot (ISO, the Pi's local time), or `null`. |
|
||||||
|
| `clear_pending` | A clear has been requested and the display service has not applied it yet. |
|
||||||
|
|
||||||
### Get Error Summary
|
### Get Error Summary
|
||||||
|
|
||||||
**GET** `/api/v3/errors/summary`
|
**GET** `/api/v3/errors/summary`
|
||||||
|
|
||||||
Aggregated counts, detected patterns and recent errors across plugins and
|
Aggregated counts, detected patterns and recent errors (the last 20).
|
||||||
core components.
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"data": {
|
||||||
|
"session_start": "2026-09-23T09:40:02.118000",
|
||||||
|
"total_errors": 13,
|
||||||
|
"error_rate_per_hour": 41.2,
|
||||||
|
"error_counts_by_type": {"ConnectionError": 12, "ValueError": 1},
|
||||||
|
"plugin_error_counts": {"weather": {"ConnectionError": 12}, "stocks": {"ValueError": 1}},
|
||||||
|
"active_patterns": {
|
||||||
|
"ConnectionError": {
|
||||||
|
"error_type": "ConnectionError", "count": 12,
|
||||||
|
"first_seen": "2026-09-23T09:41:10.500000", "last_seen": "2026-09-23T09:58:36.020000",
|
||||||
|
"affected_plugins": ["weather"], "sample_messages": ["Read timed out."],
|
||||||
|
"severity": "error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"recent_errors": [
|
||||||
|
{"error_type": "ValueError", "message": "could not parse price",
|
||||||
|
"timestamp": "2026-09-23T09:58:36.100000", "context": {},
|
||||||
|
"plugin_id": "stocks", "operation": "update", "stack_trace": "Traceback ..."}
|
||||||
|
],
|
||||||
|
"generated_at": "2026-09-23T09:58:40.000000",
|
||||||
|
"snapshot_available": true,
|
||||||
|
"clear_pending": false
|
||||||
|
},
|
||||||
|
"message": "Error summary retrieved"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### Get Plugin Errors
|
### Get Plugin Errors
|
||||||
|
|
||||||
**GET** `/api/v3/errors/plugin/<plugin_id>`
|
**GET** `/api/v3/errors/plugin/<plugin_id>`
|
||||||
|
|
||||||
Error health and statistics for one plugin.
|
Error health and statistics for one plugin: `plugin_id`, `status`
|
||||||
|
(`healthy`, `degraded` or `unhealthy`), `total_errors`, `error_types`,
|
||||||
|
`recent_error_count`, `last_error` (a `recent_errors` entry or `null`), plus
|
||||||
|
the three fields above. A plugin with no recorded errors is `healthy`.
|
||||||
|
|
||||||
### Clear Errors
|
### Clear Errors
|
||||||
|
|
||||||
**POST** `/api/v3/errors/clear`
|
**POST** `/api/v3/errors/clear`
|
||||||
|
|
||||||
Clear error records older than `max_age_hours` (default 24, 1-8760).
|
Clear error records older than `max_age_hours` (default 24, 1-8760), or every
|
||||||
Returns `data.cleared_count`.
|
error with `"all": true` (`max_age_hours` is then ignored).
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -1841,6 +1904,32 @@ Returns `data.cleared_count`.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The clear is asynchronous. The web interface records a request
|
||||||
|
(`plugin_error_clear_request` in the shared cache), and the display service
|
||||||
|
applies it within about 5 seconds, rebuilding its counts from the errors it
|
||||||
|
keeps and republishing. Reads hide the cleared errors from the moment the
|
||||||
|
request is recorded. Until the display service applies an age-based clear,
|
||||||
|
`recent_errors` and `active_patterns` are already filtered but the counts
|
||||||
|
are the old ones, and `clear_pending` is `true`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"data": {
|
||||||
|
"cleared_count": 13,
|
||||||
|
"clear_requested": true,
|
||||||
|
"request_id": "5f0c1e...",
|
||||||
|
"cutoff": "2026-09-23T09:59:02.310000"
|
||||||
|
},
|
||||||
|
"message": "Clear of all errors requested; the display service applies it within about 5 seconds"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`cleared_count` is how many of the reported errors the clear hides. It is
|
||||||
|
`null` when that cannot be known before the display service applies it (an
|
||||||
|
age-based clear over more errors than the report lists). A request that
|
||||||
|
could not be written to the shared cache answers `500`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Health and Status
|
## Health and Status
|
||||||
@@ -2010,17 +2099,6 @@ runs.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Skins
|
|
||||||
|
|
||||||
**GET** `/api/v3/skins`
|
|
||||||
|
|
||||||
Installed scoreboard skins (optional `?plugin_id=` filter). Skins are not
|
|
||||||
supported by the current scoreboard plugins, so the response carries
|
|
||||||
`data.supported: false` and a `data.message`; clients must not offer these
|
|
||||||
as selectable. See [SKIN_SYSTEM.md](SKIN_SYSTEM.md).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Error Responses
|
## Error Responses
|
||||||
|
|
||||||
Errors use one of two shapes. Most endpoints answer:
|
Errors use one of two shapes. Most endpoints answer:
|
||||||
|
|||||||
@@ -1,206 +0,0 @@
|
|||||||
# Skin System Architecture
|
|
||||||
|
|
||||||
## Status: not supported yet
|
|
||||||
|
|
||||||
**Skins don't render with the current scoreboard plugins.** The skin system
|
|
||||||
below works in isolation (it loads, validates and renders skins in
|
|
||||||
`scripts/validate_skin.py` and `test/test_skin_system.py`), but nothing on a
|
|
||||||
running display calls it:
|
|
||||||
|
|
||||||
- The only render hook is `SportsCore._render_game()` in
|
|
||||||
`src/base_classes/sports/core.py`.
|
|
||||||
- None of the current scoreboard plugins build on `src.base_classes`. The
|
|
||||||
official scoreboards in the `ledmatrix-plugins` monorepo, and the
|
|
||||||
third-party scoreboards in the plugin registry, carry their own sports and
|
|
||||||
rendering code (with the shared `src/common/sports_*` helpers) and never
|
|
||||||
reach `SportsCore._render_game()`.
|
|
||||||
|
|
||||||
So a skin can be dropped into `skins/` and named in a plugin's config, but the
|
|
||||||
scoreboard keeps drawing its built-in layout. Until a scoreboard adopts the
|
|
||||||
hook, core does not offer skins to users:
|
|
||||||
|
|
||||||
- The plugin config page shows no **Visual Skin** dropdown.
|
|
||||||
- The Plugin Store hides registry entries with `"type": "skin"` and refuses
|
|
||||||
to install one (`POST /api/v3/plugins/install` answers 400 with the reason).
|
|
||||||
- `GET /api/v3/skins` still lists what is in `skins/`, with
|
|
||||||
`"supported": false` and a `message`.
|
|
||||||
- A config that already contains `"skin"` / `"skin_options"` still loads,
|
|
||||||
validates and saves unchanged; the value is simply unused.
|
|
||||||
|
|
||||||
The rest of this document describes the design as built, for whoever wires a
|
|
||||||
scoreboard to it.
|
|
||||||
|
|
||||||
Skins are user-installable **visual overlays** for the sports scoreboards.
|
|
||||||
A skin replaces only the *look* of a scoreboard — the host plugin keeps doing
|
|
||||||
data fetching, scheduling, caching, dedup, live-priority takeover, and vegas
|
|
||||||
mode. If you only want to **build** a skin, read
|
|
||||||
[CREATING_SKINS.md](CREATING_SKINS.md); this document explains how the system
|
|
||||||
works and why it is shaped this way.
|
|
||||||
|
|
||||||
## Why skins instead of forks
|
|
||||||
|
|
||||||
Before skins, changing a scoreboard's layout meant forking the whole plugin
|
|
||||||
(e.g. the community MLB scoreboard fork). The fork gets the new look but loses
|
|
||||||
everything the maintained plugin keeps earning: duration/scheduling behavior,
|
|
||||||
vegas mode support, caching and background-fetch improvements, bug fixes. It
|
|
||||||
also silently drifts: every upstream improvement now has to be re-ported by
|
|
||||||
hand.
|
|
||||||
|
|
||||||
A skin inverts that trade. The plugin remains stock and keeps updating through
|
|
||||||
the store; the skin is ~100 lines of pure rendering code that receives the
|
|
||||||
plugin's already-fetched data each frame. Uninstalling the skin (or the skin
|
|
||||||
crashing) simply restores the built-in look.
|
|
||||||
|
|
||||||
```text
|
|
||||||
(unchanged) (the skin seam)
|
|
||||||
ESPN API ──► update() ──► game view model ──► _render_game() ──► display
|
|
||||||
fetching (a dict) │ │
|
|
||||||
caching │ └─ built-in
|
|
||||||
scheduling └─ skin.render_<mode>(ctx, game)
|
|
||||||
live priority draws onto ctx.canvas
|
|
||||||
```
|
|
||||||
|
|
||||||
## The render funnel
|
|
||||||
|
|
||||||
A sports scoreboard built on the `src/base_classes/sports/` package
|
|
||||||
(`core.py`) renders through exactly one seam. No current scoreboard plugin is
|
|
||||||
built on it (see [Status](#status-not-supported-yet)), so for them this seam is
|
|
||||||
never reached:
|
|
||||||
`SportsCore._render_game(game, force_clear)`.
|
|
||||||
|
|
||||||
1. The mode class's `display()` (live, `SportsUpcoming`, `SportsRecent`)
|
|
||||||
picks `self.current_game` and calls `_render_game`.
|
|
||||||
2. `_render_game` lazily loads the configured skin (once, on first render —
|
|
||||||
a broken skin can never block plugin startup).
|
|
||||||
3. If a skin is active, the host builds a `SkinContext` — a fresh black
|
|
||||||
canvas at the current display size plus layout/font/logo helpers — and
|
|
||||||
calls the skin's `render_live` / `render_recent` / `render_upcoming`
|
|
||||||
with a **copy** of the game dict.
|
|
||||||
4. If the skin returns `True`, the canvas is composited onto the display.
|
|
||||||
If it returns `False`, isn't implemented for that mode, or raises, the
|
|
||||||
built-in `_draw_scorebug_layout` runs instead.
|
|
||||||
|
|
||||||
Key properties that fall out of this design:
|
|
||||||
|
|
||||||
- **Per-mode fallback.** A skin that only implements `render_live` gets the
|
|
||||||
stock recent/upcoming screens for free.
|
|
||||||
- **Three strikes.** A skin that raises 3 times in a row is disabled for the
|
|
||||||
rest of the session (one loud error log per failure); the display never
|
|
||||||
goes dark. Restarting the service re-arms it.
|
|
||||||
- **Copies, not references.** Skins receive a shallow copy of the game dict,
|
|
||||||
so a buggy skin cannot corrupt the plugin's scheduling state.
|
|
||||||
- **Vegas mode works untouched.** Vegas capture falls back to grabbing the
|
|
||||||
regular `display()` output, which is already skin-rendered. Skins can
|
|
||||||
additionally implement `render_vegas_card` for purpose-built scroll cards,
|
|
||||||
and hosts can call `SportsCore.render_skin_card(game, size)` to use it.
|
|
||||||
- **Hot-loop caution.** `render_live` runs every display-loop pass during a
|
|
||||||
live game. The host logs a warning when a skin render exceeds 150 ms, and
|
|
||||||
`scripts/validate_skin.py` enforces a budget at development time — but
|
|
||||||
Python cannot forcibly time-out a stuck render, so a skin that blocks
|
|
||||||
(network I/O, giant image ops) stalls the display. This is why the rules
|
|
||||||
in CREATING_SKINS.md ban I/O in render paths.
|
|
||||||
|
|
||||||
## The view model contract
|
|
||||||
|
|
||||||
The `game` dict a skin receives is the plugin's already-extracted view model
|
|
||||||
(`SportsCore._extract_game_details_common` plus per-sport extras from
|
|
||||||
`src/base_classes/{baseball,basketball,football,hockey}.py`).
|
|
||||||
|
|
||||||
- **Guaranteed keys (view model v1.0)** — always present for every sport:
|
|
||||||
`id`, `game_time`, `game_date`, `start_time_utc` (a UTC `datetime`),
|
|
||||||
`status_text`, `is_live`, `is_final`, `is_upcoming`, `is_halftime`,
|
|
||||||
`home_abbr`/`away_abbr`, `home_id`/`away_id`, `home_score`/`away_score`
|
|
||||||
(**strings**), `home_logo_path`/`away_logo_path`, `home_record`/`away_record`.
|
|
||||||
- **Sport extras** — documented per sport in CREATING_SKINS.md (e.g. baseball
|
|
||||||
adds `inning`, `inning_half`, `balls`, `strikes`, `outs`, `bases_occupied`).
|
|
||||||
- **Optional keys** (`odds`, rankings, `series_summary`, …) are present only
|
|
||||||
when the feature is enabled — skins must always use `.get()`.
|
|
||||||
|
|
||||||
Versioning policy: additive changes bump the minor version
|
|
||||||
(`VIEW_MODEL_VERSION` in `src/skin_system/skin_base.py`, surfaced to skins as
|
|
||||||
`ctx.view_model_version`); renaming or removing a guaranteed key requires a
|
|
||||||
major bump plus a compat shim. `test/test_skin_system.py::TestViewModelContract`
|
|
||||||
fails CI if a guaranteed key disappears from the extractor.
|
|
||||||
|
|
||||||
Separately, `SKIN_API_VERSION` versions the Python API (`ScoreboardSkin`,
|
|
||||||
`SkinContext`). The loader refuses a skin whose manifest declares a different
|
|
||||||
major version and falls back to the built-in renderer with a clear
|
|
||||||
"skin needs an update" log line.
|
|
||||||
|
|
||||||
## Package layout and lifecycle
|
|
||||||
|
|
||||||
```text
|
|
||||||
skins/<skin-id>/
|
|
||||||
skin.json # manifest (required)
|
|
||||||
skin.py # ScoreboardSkin subclass (required)
|
|
||||||
preview.png # optional, shown by the web UI
|
|
||||||
assets/ # optional skin-local images
|
|
||||||
helpers.py ... # optional extra modules (namespaced per skin at import)
|
|
||||||
```
|
|
||||||
|
|
||||||
Skins live in the central `skins/` directory — deliberately **not** inside the
|
|
||||||
plugin's directory, because plugin reinstall/update deletes the whole plugin
|
|
||||||
directory and a skin must survive that. One skin can also target several
|
|
||||||
plugins (mlb + milb).
|
|
||||||
|
|
||||||
Lifecycle: discovered lazily on first render → manifest validated → API major
|
|
||||||
version gated → module imported under a namespaced `sys.modules` key (two
|
|
||||||
skins can both ship a `helpers.py`, same scheme plugins use) → instantiated
|
|
||||||
with `(manifest, options)`. Every failure logs and falls back to built-in.
|
|
||||||
|
|
||||||
Skins should be **stateless**: the live, recent, and upcoming mode classes
|
|
||||||
each hold their own skin instance, so derive everything from `(ctx, game)`.
|
|
||||||
|
|
||||||
## Selection and configuration
|
|
||||||
|
|
||||||
Inside the plugin's own config section in `config/config.json`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
"baseball-scoreboard": {
|
|
||||||
"skin": "retro-baseball",
|
|
||||||
"skin_options": { "accent_color": [255, 80, 0] }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`"skin"` is either one id for all modes or a per-mode mapping
|
|
||||||
(`{"live": "retro-baseball", "recent": "built-in"}`). Absent, empty, or
|
|
||||||
`"built-in"` means the stock renderer. Because this rides the plugin's config
|
|
||||||
section, it persists across plugin reinstalls like every other setting.
|
|
||||||
|
|
||||||
`SchemaManager.inject_skin_selector` can add a **Visual Skin** enum to the
|
|
||||||
*served* schema for plugins with matching skins installed. While skins are
|
|
||||||
unsupported the plugin schema endpoint does not call it, so the dropdown is
|
|
||||||
not shown. Validation never sees the enum either way: the base schema allows
|
|
||||||
any `skin` value, so a config that references an uninstalled skin stays valid.
|
|
||||||
`GET /api/v3/skins` lists installed skins (optionally filtered by
|
|
||||||
`?plugin_id=`) and reports `"supported": false`.
|
|
||||||
|
|
||||||
## Distribution
|
|
||||||
|
|
||||||
- **Manual:** `git clone <skin repo> skins/<skin-id>` — that's the whole
|
|
||||||
install. No manifest bumps, no `update_registry.py`; skins are not monorepo
|
|
||||||
plugins.
|
|
||||||
- **Store (disabled while unsupported):** registry entries with
|
|
||||||
`"type": "skin"` are hidden from the store list and refused on install.
|
|
||||||
`PluginStoreManager._install_skin_from_info` is kept: once
|
|
||||||
`SKINS_RENDER_SUPPORTED` in `src/skin_system/__init__.py` is true, such
|
|
||||||
entries install through the same `plugins.json` pipeline, land in `skins/`,
|
|
||||||
are validated against `skin.json` (including the API major version) instead
|
|
||||||
of `manifest.json`, and never install dependencies — skins are render-only
|
|
||||||
(stdlib + PIL + the provided context, no third-party packages in v1).
|
|
||||||
|
|
||||||
## Trust model
|
|
||||||
|
|
||||||
A skin is Python executing inside the display service — **exactly the same
|
|
||||||
trust level as a plugin**, even though "skin" sounds cosmetic. Only install
|
|
||||||
skins from sources you'd be willing to install a plugin from.
|
|
||||||
|
|
||||||
## v2 directions (not in v1)
|
|
||||||
|
|
||||||
- A generic `BasePlugin` opt-in (`render_with_skin()`) so non-sports plugins
|
|
||||||
(weather, music) can offer skinnable layouts; `skin_runtime` is already
|
|
||||||
sports-agnostic in anticipation.
|
|
||||||
- Store UI: preview gallery, one-click install from the skin browser.
|
|
||||||
- An update path for git-cloned skins (today: re-clone or store reinstall).
|
|
||||||
- Animation support in skins (today the API is one frame per render call;
|
|
||||||
stateful tricks work but are at-your-own-risk).
|
|
||||||
+21
-27
@@ -7,9 +7,9 @@ becoming nine clients of a god class.
|
|||||||
|
|
||||||
Nine plugins (`afl`, `baseball`, `basketball`, `football`, `hockey`, `lacrosse`,
|
Nine plugins (`afl`, `baseball`, `basketball`, `football`, `hockey`, `lacrosse`,
|
||||||
`nrl`, `soccer`, `ufc`) each ship a ~3,000-line `sports.py` descended from this
|
`nrl`, `soccer`, `ufc`) each ship a ~3,000-line `sports.py` descended from this
|
||||||
repo's `src/base_classes/sports.py`. They have drifted into three lineages, and
|
repo's former `src/base_classes/sports.py` (since removed). They have drifted
|
||||||
only 28 of the 66 methods appearing across them are present in all nine. One
|
into three lineages, and only 28 of the 66 methods appearing across them are
|
||||||
logical fix (the UTC start-time bug) cost 75 files.
|
present in all nine. One logical fix (the UTC start-time bug) cost 75 files.
|
||||||
|
|
||||||
Merging everything into one base class would fix the duplication and create a
|
Merging everything into one base class would fix the duplication and create a
|
||||||
worse problem: a single 2,500-line class that all nine plugins inherit, where any
|
worse problem: a single 2,500-line class that all nine plugins inherit, where any
|
||||||
@@ -26,7 +26,7 @@ These are independent concerns. Conflating them is what produces god classes.
|
|||||||
|---|---|
|
|---|---|
|
||||||
| Plugin loads on a core that predates a module | Guarded import with a bundled fallback (`try: from src.X import Y / except ModuleNotFoundError: from y import Y`) |
|
| Plugin loads on a core that predates a module | Guarded import with a bundled fallback (`try: from src.X import Y / except ModuleNotFoundError: from y import Y`) |
|
||||||
| Plugin loads on a core that predates a *method* | Capability probing — `hasattr(SportsCore, "_detect_stale_games")` — never a version comparison. The loader's compat check is advisory-only (it logs and continues), so probing is the real protection. |
|
| Plugin loads on a core that predates a *method* | Capability probing — `hasattr(SportsCore, "_detect_stale_games")` — never a version comparison. The loader's compat check is advisory-only (it logs and continues), so probing is the real protection. |
|
||||||
| Core changes never break a plugin's rendering | The **view-model contract**: `_extract_game_details_common` returns a dict whose `GUARANTEED_KEYS` are frozen by `test/test_skin_system.py::TestViewModelContract`. Keys may be added, never renamed or removed. |
|
| Core changes never break a plugin's rendering | The **view-model contract**: the game dict each plugin's `_extract_game_details_common` builds is read by the shared `src/common` renderers, so its keys may be added, never renamed or removed. |
|
||||||
| A plugin can drop its bundled copy safely | The **sunset rule**: its manifest must floor `ledmatrix_min_version` at the first core release shipping the module (recorded in `CHANGELOG.md`) — *necessary but not sufficient*. The store enforces that floor on every registry-managed install and on both supported update paths (sideloading via `install_from_url` is not gated), but a floor cannot reach a user who never updates, so the copy also waits for the B6 gate below. |
|
| A plugin can drop its bundled copy safely | The **sunset rule**: its manifest must floor `ledmatrix_min_version` at the first core release shipping the module (recorded in `CHANGELOG.md`) — *necessary but not sufficient*. The store enforces that floor on every registry-managed install and on both supported update paths (sideloading via `install_from_url` is not gated), but a floor cannot reach a user who never updates, so the copy also waits for the B6 gate below. |
|
||||||
|
|
||||||
The core API is **additive-only**. A method the plugins call is never removed or
|
The core API is **additive-only**. A method the plugins call is never removed or
|
||||||
@@ -67,16 +67,13 @@ This is the property the naive merge destroys, and it is enforced structurally:
|
|||||||
|
|
||||||
## Layering
|
## Layering
|
||||||
|
|
||||||
```
|
`src/base_classes/` has been removed: no scoreboard plugin built on it. B1 and
|
||||||
src/base_classes/sports/
|
B2 below promoted code into it (`SportsCore`, the mode classes,
|
||||||
__init__.py re-exports the public API (import path unchanged)
|
`CelebrationMixin`, the rotation strategies); the override points and
|
||||||
core.py SportsCore — fetch, cache, config, logos, fonts, odds,
|
capabilities sections record that design, but none of it ships in core any
|
||||||
view-model extraction, the skin seam
|
more. Shared sports code lives in `src/common`:
|
||||||
modes.py SportsUpcoming / SportsRecent / SportsLive
|
|
||||||
capabilities/
|
|
||||||
celebrations.py CelebrationMixin (opt-in: 4 of 9 plugins)
|
|
||||||
rotation.py RotationStrategy + registry
|
|
||||||
|
|
||||||
|
```
|
||||||
src/common/
|
src/common/
|
||||||
sports_scroll.py SportsScrollDisplay / …Manager — scroll orchestration
|
sports_scroll.py SportsScrollDisplay / …Manager — scroll orchestration
|
||||||
(content building stays in the plugins)
|
(content building stays in the plugins)
|
||||||
@@ -85,13 +82,10 @@ src/common/
|
|||||||
plugins' sports.py, and the _favorite_key seam
|
plugins' sports.py, and the _favorite_key seam
|
||||||
```
|
```
|
||||||
|
|
||||||
`from src.base_classes.sports import SportsCore` keeps working — the package
|
|
||||||
`__init__` re-exports, so the conversion is invisible to every existing importer.
|
|
||||||
|
|
||||||
### Converging on `src/common`
|
### Converging on `src/common`
|
||||||
|
|
||||||
The scoreboards do not build on `src/base_classes`; their own `sports.py` copies
|
The scoreboards never built on `src/base_classes` (now removed); their own
|
||||||
have moved past it. So shared code now lands in hardware-free `src/common`
|
`sports.py` copies had moved past it. So shared code now lands in hardware-free `src/common`
|
||||||
modules taken from the plugin copies, each a **new module** rather than growth
|
modules taken from the plugin copies, each a **new module** rather than growth
|
||||||
on an existing one: a plugin that deletes a method copy and relies on an older
|
on an existing one: a plugin that deletes a method copy and relies on an older
|
||||||
module having gained it fails at runtime with an `AttributeError`, while a
|
module having gained it fails at runtime with an `AttributeError`, while a
|
||||||
@@ -100,7 +94,7 @@ missing module fails at load, where the version checks can see it.
|
|||||||
listed below, for later phases); its parity test compares every body against
|
listed below, for later phases); its parity test compares every body against
|
||||||
the plugin copies when `LEDMATRIX_PLUGINS` points at a checkout, and
|
the plugin copies when `LEDMATRIX_PLUGINS` points at a checkout, and
|
||||||
`test/test_common_is_hardware_free.py` keeps `src/common` free of
|
`test/test_common_is_hardware_free.py` keeps `src/common` free of
|
||||||
`rgbmatrix`, `src.base_classes` and `src.plugin_system`. How a plugin adopts a
|
`rgbmatrix`, `src.display_manager` and `src.plugin_system`. How a plugin adopts a
|
||||||
module and drops its copy is documented in the plugins repo's
|
module and drops its copy is documented in the plugins repo's
|
||||||
`docs/plugin-development/08-shared-sports-code.md`.
|
`docs/plugin-development/08-shared-sports-code.md`.
|
||||||
|
|
||||||
@@ -116,7 +110,6 @@ deprecation cycle.
|
|||||||
| `_extract_game_details(event)` | Sport-specific view-model fields on top of the common ones | delegates to `_extract_game_details_common` |
|
| `_extract_game_details(event)` | Sport-specific view-model fields on top of the common ones | delegates to `_extract_game_details_common` |
|
||||||
| `_draw_scorebug_layout(game, force_clear)` | Sport's card rendering | base layout |
|
| `_draw_scorebug_layout(game, force_clear)` | Sport's card rendering | base layout |
|
||||||
| `_custom_scorebug_layout(game, draw)` | Per-sport overlay on the base layout | no-op |
|
| `_custom_scorebug_layout(game, draw)` | Per-sport overlay on the base layout | no-op |
|
||||||
| `render_skin_card(game, size)` | Skin-system entry point | built-in fallback |
|
|
||||||
| `score_phrase(points, team_abbr)` | Celebration wording (`"GOOOOAAALLL!"` vs `"TOUCHDOWN!"`). `points` is the score delta, which sports with variable-value scores use to name the play | `"<abbr> SCORES!"` — only consulted when `CelebrationMixin` is present |
|
| `score_phrase(points, team_abbr)` | Celebration wording (`"GOOOOAAALLL!"` vs `"TOUCHDOWN!"`). `points` is the score delta, which sports with variable-value scores use to name the play | `"<abbr> SCORES!"` — only consulted when `CelebrationMixin` is present |
|
||||||
| `win_phrase(team_abbr)` | Win-celebration wording | `"<abbr> WINS!"` — mixin only |
|
| `win_phrase(team_abbr)` | Win-celebration wording | `"<abbr> WINS!"` — mixin only |
|
||||||
| `_favorite_key(game, side)` | Which view-model field identifies a team for favorites matching | `game["<side>_abbr"]` |
|
| `_favorite_key(game, side)` | Which view-model field identifies a team for favorites matching | `game["<side>_abbr"]` |
|
||||||
@@ -189,9 +182,9 @@ and a typo should cost the boost, not the scoreboard. When a plugin needs an
|
|||||||
ordering that core does not ship, it calls `register_rotation_strategy` to add
|
ordering that core does not ship, it calls `register_rotation_strategy` to add
|
||||||
its own — rather than core growing a branch for it.
|
its own — rather than core growing a branch for it.
|
||||||
|
|
||||||
`test_sports_capabilities.py` checks each strategy against a **verbatim
|
`test_sports_capabilities.py` (removed with `src/base_classes`) checked each
|
||||||
transcription** of the plugin code it replaces, over every live-game shape up to
|
strategy against a **verbatim transcription** of the plugin code it replaces,
|
||||||
four games. That differential is what B5 deletes the bundled copies on the
|
over every live-game shape up to four games. That differential is what B5 deletes the bundled copies on the
|
||||||
strength of.
|
strength of.
|
||||||
|
|
||||||
## Scroll display — where the promotion line falls
|
## Scroll display — where the promotion line falls
|
||||||
@@ -507,9 +500,9 @@ What actually remains, smallest first:
|
|||||||
3. **Reconsider the held modules** (`data_sources.py`, `game_renderer.py`,
|
3. **Reconsider the held modules** (`data_sources.py`, `game_renderer.py`,
|
||||||
`base_odds_manager.py`) now that the sunset has closed. `game_renderer.py` is
|
`base_odds_manager.py`) now that the sunset has closed. `game_renderer.py` is
|
||||||
the largest single duplication left: ~11,500 lines across eight plugins, with
|
the largest single duplication left: ~11,500 lines across eight plugins, with
|
||||||
~36,500 more in the eight `sports.py`. Note that core already ships
|
~36,500 more in the eight `sports.py`. The `src/base_classes/sports/`
|
||||||
`src/base_classes/sports/` (~143KB, promoted in B1/B2) that **no plugin
|
package promoted in B1/B2 was never imported by a plugin and has been
|
||||||
imports** — check whether it has drifted before treating it as the target.
|
removed, so the plugin copies are the only starting point.
|
||||||
|
|
||||||
## How to keep this project healthy
|
## How to keep this project healthy
|
||||||
|
|
||||||
@@ -542,6 +535,7 @@ Lessons this migration paid for, worth applying beyond it:
|
|||||||
- **A capability that is not opted into must not execute.** If you find yourself
|
- **A capability that is not opted into must not execute.** If you find yourself
|
||||||
writing `if self.<capability>_enabled` inside a base class, it belongs in a
|
writing `if self.<capability>_enabled` inside a base class, it belongs in a
|
||||||
mixin.
|
mixin.
|
||||||
- **Touch the view-model keys only additively.** Published skins depend on them.
|
- **Touch the view-model keys only additively.** The shared `src/common`
|
||||||
|
renderers read them.
|
||||||
- **Every promotion lands with the characterization suite green**, and every
|
- **Every promotion lands with the characterization suite green**, and every
|
||||||
pilot adoption lands with that plugin's harness and golden suites green.
|
pilot adoption lands with that plugin's harness and golden suites green.
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ Verify installation:
|
|||||||
3. Filter by category: Weather, Sports, Finance, Games, Clocks, etc.
|
3. Filter by category: Weather, Sports, Finance, Games, Clocks, etc.
|
||||||
4. Click **Install** on desired apps
|
4. Click **Install** on desired apps
|
||||||
5. Configure each app:
|
5. Configure each app:
|
||||||
- Set location/timezone
|
- Set location/timezone (optional: blank uses this device's location)
|
||||||
- Enter API keys if required
|
- Enter API keys if required
|
||||||
- Customize display preferences
|
- Customize display preferences
|
||||||
|
|
||||||
@@ -137,7 +137,12 @@ Each app may have different configuration options:
|
|||||||
|
|
||||||
#### Common Configuration Types
|
#### Common Configuration Types
|
||||||
|
|
||||||
- **Location** (lat/lng/timezone): For weather, clocks, transit
|
- **Location** (lat/lng/timezone): For weather, clocks, transit. Left blank,
|
||||||
|
the app renders at this device's location (City / State / Country under
|
||||||
|
General settings). If no city is set there, or the city can't be looked up
|
||||||
|
(no match, or the geocoder is unreachable -- retried after 30 minutes), the
|
||||||
|
app gets no location and falls back to its author's hard-coded default,
|
||||||
|
usually San Francisco. Fill it in only to point one app somewhere else.
|
||||||
- **API Keys**: For services like weather, stocks, sports scores
|
- **API Keys**: For services like weather, stocks, sports scores
|
||||||
- **Display Preferences**: Colors, units, layouts
|
- **Display Preferences**: Colors, units, layouts
|
||||||
- **Dropdown Options**: Team selections, language, themes
|
- **Dropdown Options**: Team selections, language, themes
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ API Version: 1.0.0
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import stat
|
||||||
import time
|
import time
|
||||||
import fcntl
|
import fcntl
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -18,6 +19,7 @@ from PIL import Image
|
|||||||
|
|
||||||
from src.plugin_system.base_plugin import BasePlugin, VegasDisplayMode
|
from src.plugin_system.base_plugin import BasePlugin, VegasDisplayMode
|
||||||
from src.logging_config import get_logger
|
from src.logging_config import get_logger
|
||||||
|
from src.device_location import DeviceLocationResolver, apply_device_location
|
||||||
from pixlet_renderer import PixletRenderer
|
from pixlet_renderer import PixletRenderer
|
||||||
from frame_extractor import FrameExtractor
|
from frame_extractor import FrameExtractor
|
||||||
|
|
||||||
@@ -228,6 +230,10 @@ class StarlarkAppsPlugin(BasePlugin):
|
|||||||
self.current_app: Optional[StarlarkApp] = None
|
self.current_app: Optional[StarlarkApp] = None
|
||||||
self.last_update_check = 0
|
self.last_update_check = 0
|
||||||
|
|
||||||
|
# Unset location fields render at the device's location, not the
|
||||||
|
# app author's default (usually San Francisco).
|
||||||
|
self.device_location = DeviceLocationResolver(cache_manager, self.logger)
|
||||||
|
|
||||||
# Check Pixlet availability
|
# Check Pixlet availability
|
||||||
if not self.pixlet.is_available():
|
if not self.pixlet.is_available():
|
||||||
self.logger.error("Pixlet not available - Starlark apps will not work")
|
self.logger.error("Pixlet not available - Starlark apps will not work")
|
||||||
@@ -457,12 +463,77 @@ class StarlarkAppsPlugin(BasePlugin):
|
|||||||
apps_dir = project_root / "starlark-apps"
|
apps_dir = project_root / "starlark-apps"
|
||||||
except Exception:
|
except Exception:
|
||||||
# Fallback to current working directory
|
# Fallback to current working directory
|
||||||
apps_dir = Path.cwd() / "starlark-apps"
|
project_root = Path.cwd()
|
||||||
|
apps_dir = project_root / "starlark-apps"
|
||||||
|
|
||||||
# Create directory if it doesn't exist
|
# Create directory if it doesn't exist
|
||||||
apps_dir.mkdir(parents=True, exist_ok=True)
|
apps_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._hand_apps_dir_to_checkout_owner(apps_dir, project_root)
|
||||||
return apps_dir
|
return apps_dir
|
||||||
|
|
||||||
|
def _hand_apps_dir_to_checkout_owner(self, apps_dir: Path, project_root: Path) -> None:
|
||||||
|
"""Give the apps directory to whoever owns the checkout.
|
||||||
|
|
||||||
|
This directory is not in the repository, so it is created lazily by
|
||||||
|
whichever process reaches it first -- and the two that do run as
|
||||||
|
different users. The display service is `User=root`
|
||||||
|
(systemd/ledmatrix.service) and instantiates this plugin at startup,
|
||||||
|
which is where `_get_apps_directory` is called from. The web interface
|
||||||
|
is `User=<login user>` (systemd/ledmatrix-web.service) and is what
|
||||||
|
actually installs apps.
|
||||||
|
|
||||||
|
On a fresh install the display service usually wins that race -- the
|
||||||
|
documented first step is to install pixlet and reboot -- so the
|
||||||
|
directory lands root-owned, and every subsequent install from the web
|
||||||
|
UI fails on PermissionError. The user sees only "Failed to install
|
||||||
|
from repository", with nothing pointing at ownership.
|
||||||
|
|
||||||
|
The web user cannot repair this; it lacks permission to chown. Root
|
||||||
|
can, so root does it here, on every startup. That also heals installs
|
||||||
|
already broken by this, without the user having to find the chown.
|
||||||
|
"""
|
||||||
|
geteuid = getattr(os, "geteuid", None)
|
||||||
|
chown = getattr(os, "chown", None)
|
||||||
|
if geteuid is None or chown is None or geteuid() != 0:
|
||||||
|
# Not root, or not a platform with POSIX ownership. If the
|
||||||
|
# directory is wrong we cannot fix it, and must not pretend to.
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
owner = project_root.stat()
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
if owner.st_uid == 0:
|
||||||
|
# The checkout genuinely belongs to root, so root owning the apps
|
||||||
|
# directory is correct and there is nobody to hand it to.
|
||||||
|
return
|
||||||
|
|
||||||
|
# Deepest first, with the directory itself last. Handing over the
|
||||||
|
# container before its contents would briefly let a local user rename
|
||||||
|
# entries underneath a repair that is still running.
|
||||||
|
descendants = sorted(apps_dir.rglob("*"),
|
||||||
|
key=lambda p: len(p.parts), reverse=True)
|
||||||
|
for path in (*descendants, apps_dir):
|
||||||
|
try:
|
||||||
|
st = os.lstat(path)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if stat.S_ISLNK(st.st_mode):
|
||||||
|
# Never hand over a link's target. Anyone able to write in
|
||||||
|
# this directory could otherwise point a symlink at a
|
||||||
|
# root-owned file and have this give it away -- the whole
|
||||||
|
# point of the loop is that it runs as root.
|
||||||
|
continue
|
||||||
|
if st.st_uid == owner.st_uid and st.st_gid == owner.st_gid:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
chown(path, owner.st_uid, owner.st_gid, follow_symlinks=False)
|
||||||
|
except OSError as e:
|
||||||
|
self.logger.warning(
|
||||||
|
"Could not hand %s to uid %s: %s -- installs from the web "
|
||||||
|
"interface will fail until this is chowned manually",
|
||||||
|
path, owner.st_uid, e,
|
||||||
|
)
|
||||||
|
|
||||||
def _sanitize_app_id(self, app_id: str) -> str:
|
def _sanitize_app_id(self, app_id: str) -> str:
|
||||||
"""
|
"""
|
||||||
Sanitize app_id into a safe slug for use in file paths.
|
Sanitize app_id into a safe slug for use in file paths.
|
||||||
@@ -816,6 +887,10 @@ class StarlarkAppsPlugin(BasePlugin):
|
|||||||
# Filter out LEDMatrix-internal timing/sizing keys before passing to pixlet
|
# Filter out LEDMatrix-internal timing/sizing keys before passing to pixlet
|
||||||
INTERNAL_KEYS = {'render_interval', 'display_duration', 'render_width', 'render_height'}
|
INTERNAL_KEYS = {'render_interval', 'display_duration', 'render_width', 'render_height'}
|
||||||
pixlet_config = {k: v for k, v in app.config.items() if k not in INTERNAL_KEYS}
|
pixlet_config = {k: v for k, v in app.config.items() if k not in INTERNAL_KEYS}
|
||||||
|
# Applied here rather than saved into config.json, so a later
|
||||||
|
# change to the device location reaches the next render.
|
||||||
|
pixlet_config = apply_device_location(
|
||||||
|
pixlet_config, app.schema, self.device_location, self.global_config)
|
||||||
|
|
||||||
success, error = self.pixlet.render(
|
success, error = self.pixlet.render(
|
||||||
star_file=str(app.star_file),
|
star_file=str(app.star_file),
|
||||||
@@ -1011,6 +1086,13 @@ class StarlarkAppsPlugin(BasePlugin):
|
|||||||
self.logger.info(f"Installed Starlark app: {app_id} (sanitized: {safe_app_id})")
|
self.logger.info(f"Installed Starlark app: {app_id} (sanitized: {safe_app_id})")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
except PermissionError:
|
||||||
|
# Deliberately not folded into the False below. A False here is
|
||||||
|
# reported as a generic install failure, which is how the
|
||||||
|
# directory-ownership bug stayed invisible: the caller could not
|
||||||
|
# tell "this app is broken" from "this process cannot write here".
|
||||||
|
# The routes turn this into a message that names the fix.
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Error installing app {app_id}: {e}")
|
self.logger.error(f"Error installing app {app_id}: {e}")
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -1,250 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Headless skin validator — render a skin against bundled fixture games at
|
|
||||||
multiple panel sizes without hardware, a network, or a running service.
|
|
||||||
|
|
||||||
python scripts/validate_skin.py --skin my-skin
|
|
||||||
python scripts/validate_skin.py --skin my-skin --sport baseball \
|
|
||||||
--size 128x32 --size 64x32 --output-dir /tmp/skin_renders
|
|
||||||
|
|
||||||
For each (mode x size) it checks: the manifest loads and its API version
|
|
||||||
matches, the render raises no exception, the canvas isn't blank, and the
|
|
||||||
render finishes inside a time budget (warn — the live renderer runs every
|
|
||||||
display-loop pass, and a Pi is far slower than your dev machine). PNGs are
|
|
||||||
saved (native plus 4x nearest-neighbor previews) so you can eyeball the
|
|
||||||
result. Exit code is non-zero when any check fails.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
sys.path.insert(0, str(PROJECT_ROOT))
|
|
||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont # noqa: E402
|
|
||||||
|
|
||||||
from src.common.font_layout import load_truetype # noqa: E402
|
|
||||||
|
|
||||||
FIXTURES_DIR = PROJECT_ROOT / "src" / "skin_system" / "fixtures"
|
|
||||||
MODES = ("live", "recent", "upcoming")
|
|
||||||
SPORTS = ("baseball", "basketball", "football", "hockey")
|
|
||||||
RENDER_BUDGET_S = 0.100
|
|
||||||
|
|
||||||
|
|
||||||
class FixtureHost:
|
|
||||||
"""Stands in for a SportsCore instance: fonts, logger, logo loading,
|
|
||||||
outlined text — everything build_context needs, no network."""
|
|
||||||
|
|
||||||
def __init__(self, sport: str, skin_options: dict) -> None:
|
|
||||||
self.sport = sport
|
|
||||||
self.sport_key = sport
|
|
||||||
self.skin_options = skin_options
|
|
||||||
self.logger = logging.getLogger(f"validate_skin.{sport}")
|
|
||||||
self.fonts = self._load_fonts()
|
|
||||||
self._logo_cache = {}
|
|
||||||
self.display_manager = None # build_context is always given a size
|
|
||||||
|
|
||||||
def _load_fonts(self) -> dict:
|
|
||||||
"""Load the SportsCore font set (TTF, with PIL default fallback)."""
|
|
||||||
fonts = {}
|
|
||||||
try:
|
|
||||||
press = str(PROJECT_ROOT / "assets/fonts/PressStart2P-Regular.ttf")
|
|
||||||
small = str(PROJECT_ROOT / "assets/fonts/4x6-font.ttf")
|
|
||||||
fonts['score'] = load_truetype(press, 10)
|
|
||||||
fonts['time'] = load_truetype(press, 8)
|
|
||||||
fonts['team'] = load_truetype(press, 8)
|
|
||||||
fonts['status'] = load_truetype(small, 6)
|
|
||||||
fonts['detail'] = load_truetype(small, 6)
|
|
||||||
fonts['rank'] = load_truetype(press, 10)
|
|
||||||
except IOError:
|
|
||||||
default = ImageFont.load_default()
|
|
||||||
for key in ('score', 'time', 'team', 'status', 'detail', 'rank'):
|
|
||||||
fonts[key] = default
|
|
||||||
return fonts
|
|
||||||
|
|
||||||
def _load_and_resize_logo(self, team_id: str, team_abbrev: str,
|
|
||||||
logo_path, logo_url) -> "Image.Image | None":
|
|
||||||
"""Load a fixture logo from disk (no downloads), cached per team."""
|
|
||||||
if team_abbrev in self._logo_cache:
|
|
||||||
return self._logo_cache[team_abbrev]
|
|
||||||
path = Path(logo_path)
|
|
||||||
if not path.is_absolute():
|
|
||||||
path = PROJECT_ROOT / path
|
|
||||||
if not path.exists():
|
|
||||||
return None
|
|
||||||
logo = Image.open(path).convert('RGBA')
|
|
||||||
self._logo_cache[team_abbrev] = logo
|
|
||||||
return logo
|
|
||||||
|
|
||||||
def _draw_text_with_outline(self, draw: "ImageDraw.ImageDraw", text: str,
|
|
||||||
position: tuple, font,
|
|
||||||
fill: tuple = (255, 255, 255),
|
|
||||||
outline_color: tuple = (0, 0, 0)) -> None:
|
|
||||||
"""Classic outlined scorebug text, same as SportsCore's helper."""
|
|
||||||
x, y = position
|
|
||||||
for dx, dy in [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1),
|
|
||||||
(1, -1), (1, 0), (1, 1)]:
|
|
||||||
draw.text((x + dx, y + dy), text, font=font, fill=outline_color)
|
|
||||||
draw.text((x, y), text, font=font, fill=fill)
|
|
||||||
|
|
||||||
|
|
||||||
def load_fixture(sport: str, mode: str) -> dict:
|
|
||||||
with open(FIXTURES_DIR / f"{sport}_{mode}.json", encoding="utf-8") as f:
|
|
||||||
game = json.load(f)
|
|
||||||
# Real view models carry start_time_utc as a UTC datetime, not a string.
|
|
||||||
if isinstance(game.get("start_time_utc"), str):
|
|
||||||
from datetime import datetime
|
|
||||||
game["start_time_utc"] = datetime.fromisoformat(game["start_time_utc"])
|
|
||||||
return game
|
|
||||||
|
|
||||||
|
|
||||||
def parse_size(value: str) -> "tuple[int, int]":
|
|
||||||
try:
|
|
||||||
w_text, h_text = value.lower().split("x")
|
|
||||||
w, h = int(w_text), int(h_text)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise argparse.ArgumentTypeError(f"size must look like 128x32, got {value!r}") from exc
|
|
||||||
if w <= 0 or h <= 0:
|
|
||||||
raise argparse.ArgumentTypeError(f"size dimensions must be positive, got {value!r}")
|
|
||||||
return w, h
|
|
||||||
|
|
||||||
|
|
||||||
def parse_options(value: str) -> dict:
|
|
||||||
try:
|
|
||||||
options = json.loads(value)
|
|
||||||
except json.JSONDecodeError as exc:
|
|
||||||
raise argparse.ArgumentTypeError(f"options must be valid JSON: {exc.msg}") from exc
|
|
||||||
if not isinstance(options, dict):
|
|
||||||
raise argparse.ArgumentTypeError("options must be a JSON object")
|
|
||||||
return options
|
|
||||||
|
|
||||||
|
|
||||||
def display_path(path: Path) -> str:
|
|
||||||
"""Repo-relative when inside the repo, absolute otherwise (--output-dir
|
|
||||||
may point anywhere, e.g. /tmp/skin_renders)."""
|
|
||||||
try:
|
|
||||||
return str(path.relative_to(PROJECT_ROOT))
|
|
||||||
except ValueError:
|
|
||||||
return str(path)
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
parser = argparse.ArgumentParser(description=__doc__,
|
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
||||||
parser.add_argument("--skin", required=True, help="skin id (directory name under skins/)")
|
|
||||||
parser.add_argument("--sport", choices=SPORTS,
|
|
||||||
help="fixture sport (default: first sport the skin targets, else baseball)")
|
|
||||||
parser.add_argument("--size", action="append", type=parse_size, dest="sizes",
|
|
||||||
metavar="WxH", help="panel size to render at (repeatable; default 128x32 and 64x32)")
|
|
||||||
parser.add_argument("--output-dir", type=Path,
|
|
||||||
default=PROJECT_ROOT / "skin_renders",
|
|
||||||
help="where rendered PNGs are written")
|
|
||||||
parser.add_argument("--options", type=parse_options, default={},
|
|
||||||
help="skin_options JSON to pass the skin")
|
|
||||||
args = parser.parse_args()
|
|
||||||
sizes = args.sizes or [(128, 32), (64, 32)]
|
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
|
||||||
|
|
||||||
from src.skin_system import skin_runtime
|
|
||||||
from src.skin_system.skin_base import SKIN_API_VERSION
|
|
||||||
|
|
||||||
skins = skin_runtime.discover_skins()
|
|
||||||
manifest = skins.get(args.skin)
|
|
||||||
if manifest is None:
|
|
||||||
print(f"FAIL: skin '{args.skin}' not found under {skin_runtime.get_skins_directory()}")
|
|
||||||
if skins:
|
|
||||||
print(f" installed skins: {', '.join(sorted(skins))}")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
sport = args.sport
|
|
||||||
if sport is None:
|
|
||||||
declared = skin_runtime.skin_targets(manifest)[0]
|
|
||||||
sport = next((s for s in declared if s in SPORTS), "baseball")
|
|
||||||
|
|
||||||
skin = skin_runtime.load_skin(args.skin, sport=sport, sport_key=sport,
|
|
||||||
options=args.options)
|
|
||||||
if skin is None:
|
|
||||||
print(f"FAIL: skin '{args.skin}' did not load "
|
|
||||||
f"(see log above; host API is {SKIN_API_VERSION})")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
host = FixtureHost(sport, args.options)
|
|
||||||
args.output_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
failures = 0
|
|
||||||
rendered = 0
|
|
||||||
|
|
||||||
for mode in MODES:
|
|
||||||
game = load_fixture(sport, mode)
|
|
||||||
render = getattr(skin, f"render_{mode}")
|
|
||||||
for width, height in sizes:
|
|
||||||
label = f"{mode}@{width}x{height}"
|
|
||||||
try:
|
|
||||||
# Warm-up render absorbs one-time font/image loads, second
|
|
||||||
# render is the one timed against the budget.
|
|
||||||
ctx = skin_runtime.build_context(host, game, size=(width, height))
|
|
||||||
handled = render(ctx, dict(game))
|
|
||||||
if handled:
|
|
||||||
ctx = skin_runtime.build_context(host, game, size=(width, height))
|
|
||||||
started = time.monotonic()
|
|
||||||
handled = render(ctx, dict(game))
|
|
||||||
elapsed = time.monotonic() - started
|
|
||||||
else:
|
|
||||||
elapsed = 0.0
|
|
||||||
except Exception as e:
|
|
||||||
print(f"FAIL {label}: render raised {type(e).__name__}: {e}")
|
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
|
||||||
failures += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not handled:
|
|
||||||
print(f"skip {label}: render_{mode} returned False (built-in renderer would be used)")
|
|
||||||
continue
|
|
||||||
|
|
||||||
if ctx.canvas.size != (width, height):
|
|
||||||
print(f"FAIL {label}: canvas was replaced/resized to {ctx.canvas.size} — draw onto ctx.canvas, never reassign it")
|
|
||||||
failures += 1
|
|
||||||
continue
|
|
||||||
if ctx.canvas.convert("L").getbbox() is None:
|
|
||||||
print(f"FAIL {label}: canvas is blank — render returned True but drew nothing")
|
|
||||||
failures += 1
|
|
||||||
continue
|
|
||||||
if elapsed > RENDER_BUDGET_S:
|
|
||||||
print(f"WARN {label}: render took {elapsed * 1000:.0f}ms "
|
|
||||||
f"(budget {RENDER_BUDGET_S * 1000:.0f}ms; a Pi is much slower than this machine)")
|
|
||||||
|
|
||||||
out = args.output_dir / f"{args.skin}_{sport}_{mode}_{width}x{height}.png"
|
|
||||||
ctx.canvas.save(out)
|
|
||||||
preview = ctx.canvas.resize((width * 4, height * 4), Image.NEAREST)
|
|
||||||
preview.save(out.with_name(out.stem + "_x4.png"))
|
|
||||||
print(f"ok {label}: {elapsed * 1000:.0f}ms -> {display_path(out)}")
|
|
||||||
rendered += 1
|
|
||||||
|
|
||||||
# Vegas card, once per mode at the first size (optional API)
|
|
||||||
try:
|
|
||||||
width, height = sizes[0]
|
|
||||||
ctx = skin_runtime.build_context(host, game, size=(width, height))
|
|
||||||
card = skin.render_vegas_card(ctx, dict(game))
|
|
||||||
if card is not None:
|
|
||||||
out = args.output_dir / f"{args.skin}_{sport}_{mode}_vegas.png"
|
|
||||||
card.save(out)
|
|
||||||
print(f"ok {mode} vegas card -> {display_path(out)}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"FAIL {mode} vegas card: {type(e).__name__}: {e}")
|
|
||||||
failures += 1
|
|
||||||
|
|
||||||
if rendered == 0 and failures == 0:
|
|
||||||
print(f"FAIL: skin '{args.skin}' rendered nothing — no render_<mode> returned True")
|
|
||||||
return 1
|
|
||||||
print(f"\n{'FAILED' if failures else 'PASSED'}: {rendered} renders, {failures} failures "
|
|
||||||
f"(PNGs in {args.output_dir})")
|
|
||||||
return 1 if failures else 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
# skins/
|
|
||||||
|
|
||||||
> **Not supported yet.** The current scoreboard plugins don't render skins,
|
|
||||||
> so a skin placed here and selected in config has no effect, and the web UI
|
|
||||||
> and Plugin Store don't offer them. See
|
|
||||||
> [docs/SKIN_SYSTEM.md](../docs/SKIN_SYSTEM.md#status-not-supported-yet).
|
|
||||||
|
|
||||||
User-installable **visual skins** for the sports scoreboards. Each
|
|
||||||
subdirectory is one skin:
|
|
||||||
|
|
||||||
```text
|
|
||||||
skins/<skin-id>/
|
|
||||||
skin.json # manifest
|
|
||||||
skin.py # renderer (a ScoreboardSkin subclass)
|
|
||||||
preview.png # optional
|
|
||||||
```
|
|
||||||
|
|
||||||
- Install a skin: `git clone <skin repo> skins/<skin-id>`. The Plugin Store
|
|
||||||
refuses registry entries with `"type": "skin"` while skins don't render.
|
|
||||||
- Select it: set `"skin": "<skin-id>"` in the plugin's section of
|
|
||||||
`config/config.json`. The web UI no longer shows a Visual Skin dropdown.
|
|
||||||
- Build one: start from `example-classic-baseball/` and read
|
|
||||||
[docs/CREATING_SKINS.md](../docs/CREATING_SKINS.md). Validate with
|
|
||||||
`python scripts/validate_skin.py --skin <skin-id>`.
|
|
||||||
|
|
||||||
Skins survive plugin reinstalls/updates (that's why they live here and not in
|
|
||||||
the plugin's directory). A skin is Python at the same trust level as a
|
|
||||||
plugin — review before installing.
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 5.3 KiB |
@@ -1,25 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "example-classic-baseball",
|
|
||||||
"name": "Example: Classic Baseball",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"author": "LEDMatrix",
|
|
||||||
"description": "Reference skin: a restyled baseball scorebug demonstrating the skin API. Copy this directory to start your own skin.",
|
|
||||||
"skin_api_version": "1.0.0",
|
|
||||||
"targets": {
|
|
||||||
"sports": [
|
|
||||||
"baseball"
|
|
||||||
],
|
|
||||||
"sport_keys": [
|
|
||||||
"mlb",
|
|
||||||
"milb"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"entry_point": "skin.py",
|
|
||||||
"class_name": "ClassicBaseballSkin",
|
|
||||||
"modes": [
|
|
||||||
"live",
|
|
||||||
"recent",
|
|
||||||
"upcoming"
|
|
||||||
],
|
|
||||||
"preview": "preview.png"
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
"""
|
|
||||||
Example: Classic Baseball — the reference skin.
|
|
||||||
|
|
||||||
Shows the whole skin API surface on purpose: adaptive regions
|
|
||||||
(scoreboard_regions), fitted text (ctx.layout.fit_text + ctx.draw_fit),
|
|
||||||
logos (ctx.load_logo + ctx.draw_image), raw PIL (ctx.draw for the bases
|
|
||||||
diamond), and per-user options (ctx.options). Everything is derived from
|
|
||||||
ctx and the game dict — a skin holds no state, does no I/O, and never
|
|
||||||
touches the display.
|
|
||||||
|
|
||||||
Copy this directory to skins/<your-skin-id>/, rename the class and the
|
|
||||||
manifest fields, and run:
|
|
||||||
|
|
||||||
python scripts/validate_skin.py --skin <your-skin-id>
|
|
||||||
"""
|
|
||||||
|
|
||||||
from src.adaptive_layout import LADDER_GRID, scoreboard_regions
|
|
||||||
from src.skin_system.skin_base import ScoreboardSkin, SkinContext
|
|
||||||
|
|
||||||
DEFAULT_ACCENT = (255, 200, 0)
|
|
||||||
|
|
||||||
|
|
||||||
class ClassicBaseballSkin(ScoreboardSkin):
|
|
||||||
"""Reference baseball skin: classic scorebug with bases/outs/count."""
|
|
||||||
|
|
||||||
def __init__(self, manifest: dict, options: dict):
|
|
||||||
super().__init__(manifest, options)
|
|
||||||
# Validate user options once at load time (fail fast, fall back
|
|
||||||
# gracefully) rather than surprising every render.
|
|
||||||
accent = self.options.get("accent_color", DEFAULT_ACCENT)
|
|
||||||
if (isinstance(accent, (list, tuple)) and len(accent) == 3
|
|
||||||
and all(isinstance(c, int) and 0 <= c <= 255 for c in accent)):
|
|
||||||
self._accent_color = tuple(accent)
|
|
||||||
else:
|
|
||||||
import logging
|
|
||||||
logging.getLogger(__name__).error(
|
|
||||||
"accent_color must be three 0-255 integers, got %r; using default", accent)
|
|
||||||
self._accent_color = DEFAULT_ACCENT
|
|
||||||
|
|
||||||
# -- shared pieces ----------------------------------------------------
|
|
||||||
|
|
||||||
def _accent(self, ctx: SkinContext) -> tuple:
|
|
||||||
"""Users can recolor the skin from config via skin_options."""
|
|
||||||
return self._accent_color
|
|
||||||
|
|
||||||
def _draw_card(self, ctx: SkinContext, game: dict, status: str,
|
|
||||||
center_lines: list, detail: str) -> None:
|
|
||||||
"""The common card: logos left/right, status on top, the given
|
|
||||||
center content, detail along the bottom."""
|
|
||||||
regions = scoreboard_regions(ctx.layout.bounds, ctx=ctx.layout)
|
|
||||||
|
|
||||||
ctx.draw_image(ctx.load_logo("away"), regions.away_slot,
|
|
||||||
cache_key=f"logo:{game.get('away_abbr')}")
|
|
||||||
ctx.draw_image(ctx.load_logo("home"), regions.home_slot,
|
|
||||||
cache_key=f"logo:{game.get('home_abbr')}")
|
|
||||||
|
|
||||||
if status:
|
|
||||||
fit = ctx.layout.fit_text(status, regions.status_band, LADDER_GRID)
|
|
||||||
ctx.draw_fit(fit, regions.status_band, color=self._accent(ctx))
|
|
||||||
|
|
||||||
if center_lines:
|
|
||||||
rows = regions.score_area.split_v(*[1] * len(center_lines))
|
|
||||||
for line, row in zip(center_lines, rows):
|
|
||||||
if line:
|
|
||||||
fit = ctx.layout.fit_text(line, row, LADDER_GRID)
|
|
||||||
ctx.draw_fit(fit, row)
|
|
||||||
|
|
||||||
if detail:
|
|
||||||
fit = ctx.layout.fit_text(detail, regions.detail_band, LADDER_GRID)
|
|
||||||
ctx.draw_fit(fit, regions.detail_band, color=(160, 160, 160))
|
|
||||||
|
|
||||||
def _draw_bases_and_outs(self, ctx: SkinContext, game: dict) -> None:
|
|
||||||
"""Raw-PIL escape hatch: a bases diamond + out dots in the bottom
|
|
||||||
band, sized from the layout scale so it works on any panel."""
|
|
||||||
size = ctx.layout.px(3, minimum=2) # half-diagonal of one base
|
|
||||||
gap = ctx.layout.px(1)
|
|
||||||
cx = ctx.width // 2
|
|
||||||
cy = ctx.height - (size * 2) - 1
|
|
||||||
|
|
||||||
bases = game.get("bases_occupied") or [False, False, False]
|
|
||||||
# (dx, dy) per base: first (right), second (top), third (left)
|
|
||||||
offsets = [(size + gap, 0), (0, -(size + gap)), (-(size + gap), 0)]
|
|
||||||
for occupied, (dx, dy) in zip(bases, offsets):
|
|
||||||
x, y = cx + dx, cy + dy
|
|
||||||
diamond = [(x, y - size), (x + size, y), (x, y + size), (x - size, y)]
|
|
||||||
if occupied:
|
|
||||||
ctx.draw.polygon(diamond, fill=self._accent(ctx))
|
|
||||||
else:
|
|
||||||
ctx.draw.polygon(diamond, outline=(110, 110, 110))
|
|
||||||
|
|
||||||
outs = min(int(game.get("outs") or 0), 3)
|
|
||||||
r = max(1, size - 1)
|
|
||||||
for i in range(3):
|
|
||||||
x = cx + (i - 1) * (2 * r + 2 * gap)
|
|
||||||
y = ctx.height - r - 1
|
|
||||||
dot = [x - r, y - r, x + r, y + r]
|
|
||||||
if i < outs:
|
|
||||||
ctx.draw.ellipse(dot, fill=(255, 255, 255))
|
|
||||||
else:
|
|
||||||
ctx.draw.ellipse(dot, outline=(110, 110, 110))
|
|
||||||
|
|
||||||
# -- the three modes --------------------------------------------------
|
|
||||||
|
|
||||||
def render_live(self, ctx: SkinContext, game: dict) -> bool:
|
|
||||||
half = "▲" if game.get("inning_half") == "top" else "▼"
|
|
||||||
inning = game.get("inning") or ""
|
|
||||||
status = f"{half}{inning}" if inning else game.get("status_text", "")
|
|
||||||
score = f"{game.get('away_score', '0')}-{game.get('home_score', '0')}"
|
|
||||||
count = f"{game.get('balls', 0)}-{game.get('strikes', 0)}"
|
|
||||||
|
|
||||||
self._draw_card(ctx, game, status, [score], "")
|
|
||||||
self._draw_bases_and_outs(ctx, game)
|
|
||||||
|
|
||||||
# Ball-strike count in the top-left corner, over the away logo.
|
|
||||||
fit = ctx.layout.fit_text(count, (ctx.width // 4, ctx.layout.px(8, minimum=6)), LADDER_GRID)
|
|
||||||
ctx.draw_fit(fit, ctx.layout.bounds.top_band(fit.height + 1).left_col(fit.width + 2),
|
|
||||||
color=(200, 200, 200))
|
|
||||||
return True
|
|
||||||
|
|
||||||
def render_recent(self, ctx: SkinContext, game: dict) -> bool:
|
|
||||||
score = f"{game.get('away_score', '0')}-{game.get('home_score', '0')}"
|
|
||||||
self._draw_card(ctx, game, game.get("status_text", "Final"),
|
|
||||||
[score], game.get("series_summary", ""))
|
|
||||||
return True
|
|
||||||
|
|
||||||
def render_upcoming(self, ctx: SkinContext, game: dict) -> bool:
|
|
||||||
matchup = f"{game.get('away_abbr', '')}@{game.get('home_abbr', '')}"
|
|
||||||
self._draw_card(ctx, game, game.get("game_date", ""),
|
|
||||||
[matchup, game.get("game_time", "")],
|
|
||||||
f"{game.get('away_record', '')} {game.get('home_record', '')}".strip())
|
|
||||||
return True
|
|
||||||
@@ -1,371 +0,0 @@
|
|||||||
"""
|
|
||||||
Abstract API Data Extraction Layer
|
|
||||||
|
|
||||||
This module provides a pluggable system for extracting game data from different
|
|
||||||
sports APIs. Each sport can have its own extractor that handles sport-specific
|
|
||||||
fields and data structures.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
|
||||||
from typing import Dict, Optional
|
|
||||||
import logging
|
|
||||||
from datetime import datetime
|
|
||||||
import pytz
|
|
||||||
|
|
||||||
class APIDataExtractor(ABC):
|
|
||||||
"""Abstract base class for API data extraction."""
|
|
||||||
|
|
||||||
def __init__(self, logger: logging.Logger):
|
|
||||||
self.logger = logger
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def extract_game_details(self, game_event: Dict) -> Optional[Dict]:
|
|
||||||
"""Extract common game details from raw API data."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def get_sport_specific_fields(self, game_event: Dict) -> Dict:
|
|
||||||
"""Extract sport-specific fields (downs, innings, periods, etc.)."""
|
|
||||||
|
|
||||||
def _extract_common_details(self, game_event: Dict) -> tuple[Dict | None, Dict | None, Dict | None, Dict | None, Dict | None]:
|
|
||||||
"""Extract common game details that work across all sports."""
|
|
||||||
if not game_event:
|
|
||||||
return None, None, None, None, None
|
|
||||||
|
|
||||||
try:
|
|
||||||
competition = game_event["competitions"][0]
|
|
||||||
status = competition["status"]
|
|
||||||
competitors = competition["competitors"]
|
|
||||||
game_date_str = game_event["date"]
|
|
||||||
situation = competition.get("situation")
|
|
||||||
|
|
||||||
# Parse game time
|
|
||||||
start_time_utc = None
|
|
||||||
try:
|
|
||||||
# Parse the datetime string
|
|
||||||
if game_date_str.endswith('Z'):
|
|
||||||
game_date_str = game_date_str.replace('Z', '+00:00')
|
|
||||||
dt = datetime.fromisoformat(game_date_str)
|
|
||||||
# Ensure the datetime is UTC-aware (fromisoformat may create timezone-aware but not pytz.UTC)
|
|
||||||
if dt.tzinfo is None:
|
|
||||||
# If naive, assume it's UTC
|
|
||||||
start_time_utc = dt.replace(tzinfo=pytz.UTC)
|
|
||||||
else:
|
|
||||||
# Convert to pytz.UTC for consistency
|
|
||||||
start_time_utc = dt.astimezone(pytz.UTC)
|
|
||||||
except ValueError:
|
|
||||||
self.logger.warning(f"Could not parse game date: {game_date_str}")
|
|
||||||
|
|
||||||
# Extract teams
|
|
||||||
home_team = next((c for c in competitors if c.get("homeAway") == "home"), None)
|
|
||||||
away_team = next((c for c in competitors if c.get("homeAway") == "away"), None)
|
|
||||||
|
|
||||||
if not home_team or not away_team:
|
|
||||||
self.logger.warning(f"Could not find home or away team in event: {game_event.get('id')}")
|
|
||||||
return None, None, None, None, None
|
|
||||||
|
|
||||||
return {
|
|
||||||
"game_event": game_event,
|
|
||||||
"competition": competition,
|
|
||||||
"status": status,
|
|
||||||
"situation": situation,
|
|
||||||
"start_time_utc": start_time_utc,
|
|
||||||
"home_team": home_team,
|
|
||||||
"away_team": away_team
|
|
||||||
}, home_team, away_team, status, situation
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Error extracting common details: {e}")
|
|
||||||
return None, None, None, None, None
|
|
||||||
|
|
||||||
|
|
||||||
class ESPNFootballExtractor(APIDataExtractor):
|
|
||||||
"""ESPN API extractor for football (NFL/NCAA)."""
|
|
||||||
|
|
||||||
def extract_game_details(self, game_event: Dict) -> Optional[Dict]:
|
|
||||||
"""Extract football game details from ESPN API."""
|
|
||||||
common_data, home_team, away_team, status, situation = self._extract_common_details(game_event)
|
|
||||||
if not common_data:
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Extract basic team info
|
|
||||||
home_abbr = home_team["team"]["abbreviation"]
|
|
||||||
away_abbr = away_team["team"]["abbreviation"]
|
|
||||||
home_score = home_team.get("score", "0")
|
|
||||||
away_score = away_team.get("score", "0")
|
|
||||||
|
|
||||||
# Extract sport-specific fields
|
|
||||||
sport_fields = self.get_sport_specific_fields(game_event)
|
|
||||||
|
|
||||||
# Build game details
|
|
||||||
details = {
|
|
||||||
"id": game_event.get("id"),
|
|
||||||
"home_abbr": home_abbr,
|
|
||||||
"away_abbr": away_abbr,
|
|
||||||
"home_score": str(home_score),
|
|
||||||
"away_score": str(away_score),
|
|
||||||
"home_team_name": home_team["team"].get("displayName", ""),
|
|
||||||
"away_team_name": away_team["team"].get("displayName", ""),
|
|
||||||
"status_text": status["type"].get("shortDetail", ""),
|
|
||||||
"is_live": status["type"]["state"] == "in",
|
|
||||||
"is_final": status["type"]["state"] == "post",
|
|
||||||
"is_upcoming": status["type"]["state"] == "pre",
|
|
||||||
**sport_fields # Add sport-specific fields
|
|
||||||
}
|
|
||||||
|
|
||||||
return details
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Error extracting football game details: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_sport_specific_fields(self, game_event: Dict) -> Dict:
|
|
||||||
"""Extract football-specific fields."""
|
|
||||||
try:
|
|
||||||
competition = game_event["competitions"][0]
|
|
||||||
status = competition["status"]
|
|
||||||
situation = competition.get("situation", {})
|
|
||||||
|
|
||||||
sport_fields = {
|
|
||||||
"down": "",
|
|
||||||
"distance": "",
|
|
||||||
"possession": "",
|
|
||||||
"is_redzone": False,
|
|
||||||
"home_timeouts": 0,
|
|
||||||
"away_timeouts": 0,
|
|
||||||
"scoring_event": ""
|
|
||||||
}
|
|
||||||
|
|
||||||
if situation and status["type"]["state"] == "in":
|
|
||||||
sport_fields.update({
|
|
||||||
"down": situation.get("down", ""),
|
|
||||||
"distance": situation.get("distance", ""),
|
|
||||||
"possession": situation.get("possession", ""),
|
|
||||||
"is_redzone": situation.get("isRedZone", False),
|
|
||||||
"home_timeouts": situation.get("homeTimeouts", 0),
|
|
||||||
"away_timeouts": situation.get("awayTimeouts", 0)
|
|
||||||
})
|
|
||||||
|
|
||||||
# Detect scoring events
|
|
||||||
status_detail = status["type"].get("detail", "").lower()
|
|
||||||
if "touchdown" in status_detail or "field goal" in status_detail:
|
|
||||||
sport_fields["scoring_event"] = status_detail
|
|
||||||
|
|
||||||
return sport_fields
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Error extracting football-specific fields: {e}")
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
class ESPNBaseballExtractor(APIDataExtractor):
|
|
||||||
"""ESPN API extractor for baseball (MLB)."""
|
|
||||||
|
|
||||||
def extract_game_details(self, game_event: Dict) -> Optional[Dict]:
|
|
||||||
"""Extract baseball game details from ESPN API."""
|
|
||||||
common_data, home_team, away_team, status, situation = self._extract_common_details(game_event)
|
|
||||||
if not common_data:
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Extract basic team info
|
|
||||||
home_abbr = home_team["team"]["abbreviation"]
|
|
||||||
away_abbr = away_team["team"]["abbreviation"]
|
|
||||||
home_score = home_team.get("score", "0")
|
|
||||||
away_score = away_team.get("score", "0")
|
|
||||||
|
|
||||||
# Extract sport-specific fields
|
|
||||||
sport_fields = self.get_sport_specific_fields(game_event)
|
|
||||||
|
|
||||||
# Build game details
|
|
||||||
details = {
|
|
||||||
"id": game_event.get("id"),
|
|
||||||
"home_abbr": home_abbr,
|
|
||||||
"away_abbr": away_abbr,
|
|
||||||
"home_score": str(home_score),
|
|
||||||
"away_score": str(away_score),
|
|
||||||
"home_team_name": home_team["team"].get("displayName", ""),
|
|
||||||
"away_team_name": away_team["team"].get("displayName", ""),
|
|
||||||
"status_text": status["type"].get("shortDetail", ""),
|
|
||||||
"is_live": status["type"]["state"] == "in",
|
|
||||||
"is_final": status["type"]["state"] == "post",
|
|
||||||
"is_upcoming": status["type"]["state"] == "pre",
|
|
||||||
**sport_fields # Add sport-specific fields
|
|
||||||
}
|
|
||||||
|
|
||||||
return details
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Error extracting baseball game details: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_sport_specific_fields(self, game_event: Dict) -> Dict:
|
|
||||||
"""Extract baseball-specific fields."""
|
|
||||||
try:
|
|
||||||
competition = game_event["competitions"][0]
|
|
||||||
status = competition["status"]
|
|
||||||
situation = competition.get("situation", {})
|
|
||||||
|
|
||||||
sport_fields = {
|
|
||||||
"inning": "",
|
|
||||||
"outs": 0,
|
|
||||||
"bases": "",
|
|
||||||
"strikes": 0,
|
|
||||||
"balls": 0,
|
|
||||||
"pitcher": "",
|
|
||||||
"batter": ""
|
|
||||||
}
|
|
||||||
|
|
||||||
if situation and status["type"]["state"] == "in":
|
|
||||||
sport_fields.update({
|
|
||||||
"inning": situation.get("inning", ""),
|
|
||||||
"outs": situation.get("outs", 0),
|
|
||||||
"bases": situation.get("bases", ""),
|
|
||||||
"strikes": situation.get("strikes", 0),
|
|
||||||
"balls": situation.get("balls", 0),
|
|
||||||
"pitcher": situation.get("pitcher", ""),
|
|
||||||
"batter": situation.get("batter", "")
|
|
||||||
})
|
|
||||||
|
|
||||||
return sport_fields
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Error extracting baseball-specific fields: {e}")
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
class ESPNHockeyExtractor(APIDataExtractor):
|
|
||||||
"""ESPN API extractor for hockey (NHL/NCAA)."""
|
|
||||||
|
|
||||||
def extract_game_details(self, game_event: Dict) -> Optional[Dict]:
|
|
||||||
"""Extract hockey game details from ESPN API."""
|
|
||||||
common_data, home_team, away_team, status, situation = self._extract_common_details(game_event)
|
|
||||||
if not common_data:
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Extract basic team info
|
|
||||||
home_abbr = home_team["team"]["abbreviation"]
|
|
||||||
away_abbr = away_team["team"]["abbreviation"]
|
|
||||||
home_score = home_team.get("score", "0")
|
|
||||||
away_score = away_team.get("score", "0")
|
|
||||||
|
|
||||||
# Extract sport-specific fields
|
|
||||||
sport_fields = self.get_sport_specific_fields(game_event)
|
|
||||||
|
|
||||||
# Build game details
|
|
||||||
details = {
|
|
||||||
"id": game_event.get("id"),
|
|
||||||
"home_abbr": home_abbr,
|
|
||||||
"away_abbr": away_abbr,
|
|
||||||
"home_score": str(home_score),
|
|
||||||
"away_score": str(away_score),
|
|
||||||
"home_team_name": home_team["team"].get("displayName", ""),
|
|
||||||
"away_team_name": away_team["team"].get("displayName", ""),
|
|
||||||
"status_text": status["type"].get("shortDetail", ""),
|
|
||||||
"is_live": status["type"]["state"] == "in",
|
|
||||||
"is_final": status["type"]["state"] == "post",
|
|
||||||
"is_upcoming": status["type"]["state"] == "pre",
|
|
||||||
**sport_fields # Add sport-specific fields
|
|
||||||
}
|
|
||||||
|
|
||||||
return details
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Error extracting hockey game details: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_sport_specific_fields(self, game_event: Dict) -> Dict:
|
|
||||||
"""Extract hockey-specific fields."""
|
|
||||||
try:
|
|
||||||
competition = game_event["competitions"][0]
|
|
||||||
status = competition["status"]
|
|
||||||
situation = competition.get("situation", {})
|
|
||||||
|
|
||||||
sport_fields = {
|
|
||||||
"period": "",
|
|
||||||
"period_text": "",
|
|
||||||
"power_play": False,
|
|
||||||
"penalties": "",
|
|
||||||
"shots_on_goal": {"home": 0, "away": 0}
|
|
||||||
}
|
|
||||||
|
|
||||||
if situation and status["type"]["state"] == "in":
|
|
||||||
period = status.get("period", 0)
|
|
||||||
period_text = ""
|
|
||||||
if period == 1:
|
|
||||||
period_text = "P1"
|
|
||||||
elif period == 2:
|
|
||||||
period_text = "P2"
|
|
||||||
elif period == 3:
|
|
||||||
period_text = "P3"
|
|
||||||
elif period > 3:
|
|
||||||
period_text = f"OT{period-3}"
|
|
||||||
|
|
||||||
sport_fields.update({
|
|
||||||
"period": str(period),
|
|
||||||
"period_text": period_text,
|
|
||||||
"power_play": situation.get("isPowerPlay", False),
|
|
||||||
"penalties": situation.get("penalties", ""),
|
|
||||||
"shots_on_goal": {
|
|
||||||
"home": situation.get("homeShots", 0),
|
|
||||||
"away": situation.get("awayShots", 0)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return sport_fields
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Error extracting hockey-specific fields: {e}")
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
class SoccerAPIExtractor(APIDataExtractor):
|
|
||||||
"""Generic extractor for soccer APIs (different structure than ESPN)."""
|
|
||||||
|
|
||||||
def extract_game_details(self, game_event: Dict) -> Optional[Dict]:
|
|
||||||
"""Extract soccer game details from various soccer APIs."""
|
|
||||||
# This would need to be adapted based on the specific soccer API being used
|
|
||||||
# For now, return a basic structure
|
|
||||||
try:
|
|
||||||
return {
|
|
||||||
"id": game_event.get("id"),
|
|
||||||
"home_abbr": game_event.get("home_team", {}).get("abbreviation", ""),
|
|
||||||
"away_abbr": game_event.get("away_team", {}).get("abbreviation", ""),
|
|
||||||
"home_score": str(game_event.get("home_score", "0")),
|
|
||||||
"away_score": str(game_event.get("away_score", "0")),
|
|
||||||
"home_team_name": game_event.get("home_team", {}).get("name", ""),
|
|
||||||
"away_team_name": game_event.get("away_team", {}).get("name", ""),
|
|
||||||
"status_text": game_event.get("status", ""),
|
|
||||||
"is_live": game_event.get("is_live", False),
|
|
||||||
"is_final": game_event.get("is_final", False),
|
|
||||||
"is_upcoming": game_event.get("is_upcoming", False),
|
|
||||||
**self.get_sport_specific_fields(game_event)
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Error extracting soccer game details: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_sport_specific_fields(self, game_event: Dict) -> Dict:
|
|
||||||
"""Extract soccer-specific fields."""
|
|
||||||
try:
|
|
||||||
return {
|
|
||||||
"half": game_event.get("half", ""),
|
|
||||||
"stoppage_time": game_event.get("stoppage_time", ""),
|
|
||||||
"cards": {
|
|
||||||
"home_yellow": game_event.get("home_yellow_cards", 0),
|
|
||||||
"away_yellow": game_event.get("away_yellow_cards", 0),
|
|
||||||
"home_red": game_event.get("home_red_cards", 0),
|
|
||||||
"away_red": game_event.get("away_red_cards", 0)
|
|
||||||
},
|
|
||||||
"possession": {
|
|
||||||
"home": game_event.get("home_possession", 0),
|
|
||||||
"away": game_event.get("away_possession", 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Error extracting soccer-specific fields: {e}")
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
# Factory function removed - sport classes now instantiate extractors directly
|
|
||||||
@@ -1,692 +0,0 @@
|
|||||||
"""
|
|
||||||
Baseball Base Classes
|
|
||||||
|
|
||||||
This module provides baseball-specific base classes that extend the core sports functionality
|
|
||||||
with baseball-specific logic for innings, outs, bases, strikes, balls, etc.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
|
||||||
|
|
||||||
from src.base_classes.data_sources import ESPNDataSource
|
|
||||||
from src.base_classes.sports import SportsCore, SportsLive, SportsRecent
|
|
||||||
|
|
||||||
|
|
||||||
class Baseball(SportsCore):
|
|
||||||
"""Base class for baseball sports with common functionality."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
config: Dict[str, Any],
|
|
||||||
display_manager,
|
|
||||||
cache_manager,
|
|
||||||
logger: logging.Logger,
|
|
||||||
sport_key: str,
|
|
||||||
):
|
|
||||||
super().__init__(config, display_manager, cache_manager, logger, sport_key)
|
|
||||||
# Baseball-specific configuration
|
|
||||||
self.show_innings = self.mode_config.get("show_innings", True)
|
|
||||||
self.show_outs = self.mode_config.get("show_outs", True)
|
|
||||||
self.show_bases = self.mode_config.get("show_bases", True)
|
|
||||||
self.show_count = self.mode_config.get("show_count", True)
|
|
||||||
self.show_pitcher_batter = self.mode_config.get("show_pitcher_batter", False)
|
|
||||||
self.show_series_summary = self.mode_config.get("show_series_summary", False)
|
|
||||||
self.data_source = ESPNDataSource(logger)
|
|
||||||
self.sport = "baseball"
|
|
||||||
|
|
||||||
def _is_baseball_game_live(self, game: Dict) -> bool:
|
|
||||||
"""Check if a baseball game is currently live."""
|
|
||||||
try:
|
|
||||||
# Check if game is marked as live
|
|
||||||
is_live = game.get("is_live", False)
|
|
||||||
if is_live:
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Check inning to determine if game is active
|
|
||||||
inning = game.get("inning", "")
|
|
||||||
if inning and inning != "Final":
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Error checking if baseball game is live: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _get_baseball_game_status(self, game: Dict) -> str:
|
|
||||||
"""Get baseball-specific game status."""
|
|
||||||
try:
|
|
||||||
status = game.get("status_text", "")
|
|
||||||
inning = game.get("inning", "")
|
|
||||||
|
|
||||||
if self._is_baseball_game_live(game):
|
|
||||||
if inning:
|
|
||||||
return f"Live - {inning}"
|
|
||||||
else:
|
|
||||||
return "Live"
|
|
||||||
elif game.get("is_final", False):
|
|
||||||
return "Final"
|
|
||||||
elif game.get("is_upcoming", False):
|
|
||||||
return "Upcoming"
|
|
||||||
else:
|
|
||||||
return status
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Error getting baseball game status: {e}")
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def _extract_game_details(self, game_event: Dict) -> Optional[Dict]:
|
|
||||||
"""Extract relevant game details from ESPN NCAA FB API response."""
|
|
||||||
details, home_team, away_team, status, situation = (
|
|
||||||
self._extract_game_details_common(game_event)
|
|
||||||
)
|
|
||||||
if details is None or home_team is None or away_team is None or status is None:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
# print(status["type"]["state"])
|
|
||||||
# exit()
|
|
||||||
game_status = status["type"]["name"].lower()
|
|
||||||
status_state = status["type"]["state"].lower()
|
|
||||||
# Get team abbreviations
|
|
||||||
home_abbr = home_team["team"]["abbreviation"]
|
|
||||||
away_abbr = away_team["team"]["abbreviation"]
|
|
||||||
|
|
||||||
# Check if this is a favorite team game
|
|
||||||
is_favorite_game = (
|
|
||||||
home_abbr in self.favorite_teams or away_abbr in self.favorite_teams
|
|
||||||
)
|
|
||||||
|
|
||||||
# Log all teams found for debugging
|
|
||||||
self.logger.debug(
|
|
||||||
f"Found game: {away_abbr} @ {home_abbr} (Status: {game_status}, State: {status_state})"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Only log detailed information for favorite teams
|
|
||||||
if is_favorite_game:
|
|
||||||
# Use the validated competition-level `status` here too. MiLB
|
|
||||||
# events carry no event-level one, so this debug line raised a
|
|
||||||
# KeyError and dropped the very games it was meant to help
|
|
||||||
# diagnose -- and only for favourites, which is the worst way
|
|
||||||
# for it to fail.
|
|
||||||
self.logger.debug(f"Full status data: {status}")
|
|
||||||
self.logger.debug(f"Status type: {game_status}, State: {status_state}")
|
|
||||||
self.logger.debug(f"Status detail: {status['type'].get('detail', '')}")
|
|
||||||
self.logger.debug(
|
|
||||||
f"Status shortDetail: {status['type'].get('shortDetail', '')}"
|
|
||||||
)
|
|
||||||
series = game_event["competitions"][0].get("series", None)
|
|
||||||
series_summary = ""
|
|
||||||
if series:
|
|
||||||
series_summary = series.get("summary", "")
|
|
||||||
# Get game state information
|
|
||||||
if status_state == "in":
|
|
||||||
# For live games, get detailed state
|
|
||||||
# Use the competition-level `status` already validated by
|
|
||||||
# _extract_game_details_common. Real ESPN events duplicate
|
|
||||||
# status at the event top level, but MiLB events (synthesized
|
|
||||||
# from the MLB Stats API into an ESPN-like shape) populate
|
|
||||||
# only the competition-level one, so the top-level lookup
|
|
||||||
# raised a bare KeyError and dropped the event.
|
|
||||||
inning = status.get(
|
|
||||||
"period", 1
|
|
||||||
) # Get inning from status period
|
|
||||||
|
|
||||||
# Get inning information from status
|
|
||||||
status_detail = status["type"].get("detail", "").lower()
|
|
||||||
status_short = status["type"].get("shortDetail", "").lower()
|
|
||||||
|
|
||||||
if is_favorite_game:
|
|
||||||
self.logger.debug(
|
|
||||||
f"Raw status detail: {status['type'].get('detail')}"
|
|
||||||
)
|
|
||||||
self.logger.debug(
|
|
||||||
f"Raw status short: {status['type'].get('shortDetail')}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Determine inning half from status information
|
|
||||||
inning_half = "top" # Default
|
|
||||||
|
|
||||||
# Handle end of inning: next inning is top
|
|
||||||
if "end" in status_detail or "end" in status_short:
|
|
||||||
inning_half = "top"
|
|
||||||
inning = (
|
|
||||||
status.get("period", 1) + 1
|
|
||||||
) # Use period and increment for next inning
|
|
||||||
if is_favorite_game:
|
|
||||||
self.logger.debug(
|
|
||||||
f"Detected end of inning. Setting to Top {inning}"
|
|
||||||
)
|
|
||||||
# Handle middle of inning: next is bottom of current inning
|
|
||||||
elif "mid" in status_detail or "mid" in status_short:
|
|
||||||
inning_half = "bottom"
|
|
||||||
if is_favorite_game:
|
|
||||||
self.logger.debug(
|
|
||||||
f"Detected middle of inning. Setting to Bottom {inning}"
|
|
||||||
)
|
|
||||||
# Handle bottom of inning
|
|
||||||
elif (
|
|
||||||
"bottom" in status_detail
|
|
||||||
or "bot" in status_detail
|
|
||||||
or "bottom" in status_short
|
|
||||||
or "bot" in status_short
|
|
||||||
):
|
|
||||||
inning_half = "bottom"
|
|
||||||
if is_favorite_game:
|
|
||||||
self.logger.debug(f"Detected bottom of inning: {inning}")
|
|
||||||
# Handle top of inning
|
|
||||||
elif "top" in status_detail or "top" in status_short:
|
|
||||||
inning_half = "top"
|
|
||||||
if is_favorite_game:
|
|
||||||
self.logger.debug(f"Detected top of inning: {inning}")
|
|
||||||
|
|
||||||
if is_favorite_game:
|
|
||||||
self.logger.debug(f"Status detail: {status_detail}")
|
|
||||||
self.logger.debug(f"Status short: {status_short}")
|
|
||||||
self.logger.debug(f"Determined inning: {inning_half} {inning}")
|
|
||||||
|
|
||||||
# Get count and bases from situation
|
|
||||||
situation = game_event["competitions"][0].get("situation", {})
|
|
||||||
|
|
||||||
if is_favorite_game:
|
|
||||||
self.logger.debug(f"Full situation data: {situation}")
|
|
||||||
|
|
||||||
# Get count from the correct location in the API response
|
|
||||||
count = situation.get("count", {})
|
|
||||||
balls = count.get("balls", 0)
|
|
||||||
strikes = count.get("strikes", 0)
|
|
||||||
outs = situation.get("outs", 0)
|
|
||||||
|
|
||||||
# Add detailed logging for favorite team games
|
|
||||||
if is_favorite_game:
|
|
||||||
self.logger.debug(f"Full situation data: {situation}")
|
|
||||||
self.logger.debug(f"Count object: {count}")
|
|
||||||
self.logger.debug(
|
|
||||||
f"Raw count values - balls: {balls}, strikes: {strikes}"
|
|
||||||
)
|
|
||||||
self.logger.debug(f"Raw outs value: {outs}")
|
|
||||||
|
|
||||||
# Try alternative locations for count data
|
|
||||||
if balls == 0 and strikes == 0:
|
|
||||||
# First try the summary field
|
|
||||||
if "summary" in situation:
|
|
||||||
try:
|
|
||||||
count_summary = situation["summary"]
|
|
||||||
balls, strikes = map(int, count_summary.split("-"))
|
|
||||||
if is_favorite_game:
|
|
||||||
self.logger.debug(
|
|
||||||
f"Using summary count: {count_summary}"
|
|
||||||
)
|
|
||||||
except (ValueError, AttributeError):
|
|
||||||
if is_favorite_game:
|
|
||||||
self.logger.debug("Could not parse summary count")
|
|
||||||
else:
|
|
||||||
# Check if count is directly in situation
|
|
||||||
balls = situation.get("balls", 0)
|
|
||||||
strikes = situation.get("strikes", 0)
|
|
||||||
if is_favorite_game:
|
|
||||||
self.logger.debug(
|
|
||||||
f"Using direct situation count: balls={balls}, strikes={strikes}"
|
|
||||||
)
|
|
||||||
self.logger.debug(
|
|
||||||
f"Full situation keys: {list(situation.keys())}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if is_favorite_game:
|
|
||||||
self.logger.debug(f"Final count: balls={balls}, strikes={strikes}")
|
|
||||||
|
|
||||||
# Get base runners
|
|
||||||
bases_occupied = [
|
|
||||||
situation.get("onFirst", False),
|
|
||||||
situation.get("onSecond", False),
|
|
||||||
situation.get("onThird", False),
|
|
||||||
]
|
|
||||||
|
|
||||||
if is_favorite_game:
|
|
||||||
self.logger.debug(f"Bases occupied: {bases_occupied}")
|
|
||||||
else:
|
|
||||||
# Default values for non-live games
|
|
||||||
inning = 1
|
|
||||||
inning_half = "top"
|
|
||||||
balls = 0
|
|
||||||
strikes = 0
|
|
||||||
outs = 0
|
|
||||||
bases_occupied = [False, False, False]
|
|
||||||
|
|
||||||
details.update(
|
|
||||||
{
|
|
||||||
"status": game_status,
|
|
||||||
"status_state": status_state,
|
|
||||||
"inning": inning,
|
|
||||||
"inning_half": inning_half,
|
|
||||||
"balls": balls,
|
|
||||||
"strikes": strikes,
|
|
||||||
"outs": outs,
|
|
||||||
"bases_occupied": bases_occupied,
|
|
||||||
"start_time": game_event["date"],
|
|
||||||
"series_summary": series_summary,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Basic validation (can be expanded)
|
|
||||||
if not details["home_abbr"] or not details["away_abbr"]:
|
|
||||||
self.logger.warning(
|
|
||||||
f"Missing team abbreviation in event: {details['id']}"
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
self.logger.debug(
|
|
||||||
f"Extracted: {details['away_abbr']}@{details['home_abbr']}, Status: {status['type']['name']}, Live: {details['is_live']}, Final: {details['is_final']}, Upcoming: {details['is_upcoming']}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return details
|
|
||||||
except Exception as e:
|
|
||||||
# Log the problematic event structure if possible
|
|
||||||
self.logger.error(
|
|
||||||
f"Error extracting game details: {e} from event: {game_event.get('id')}",
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def display_series_summary(self, game: dict, draw_overlay: ImageDraw.ImageDraw):
|
|
||||||
if not self.show_series_summary:
|
|
||||||
return
|
|
||||||
|
|
||||||
series_summary = game.get("series_summary", "")
|
|
||||||
bbox = draw_overlay.textbbox((0, 0), series_summary, font=self.fonts['time'])
|
|
||||||
height = bbox[3] - bbox[1]
|
|
||||||
shots_y = (self.display_height - height) // 2
|
|
||||||
shots_width = draw_overlay.textlength(series_summary, font=self.fonts['time'])
|
|
||||||
shots_x = (self.display_width - shots_width) // 2
|
|
||||||
self._draw_text_with_outline(
|
|
||||||
draw_overlay, series_summary, (shots_x, shots_y), self.fonts['time']
|
|
||||||
)
|
|
||||||
|
|
||||||
class BaseballRecent(Baseball, SportsRecent):
|
|
||||||
"""Base class for recent baseball games."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
config: Dict[str, Any],
|
|
||||||
display_manager,
|
|
||||||
cache_manager,
|
|
||||||
logger: logging.Logger,
|
|
||||||
sport_key: str,
|
|
||||||
):
|
|
||||||
super().__init__(config, display_manager, cache_manager, logger, sport_key)
|
|
||||||
|
|
||||||
|
|
||||||
def _custom_scorebug_layout(self, game: dict, draw_overlay: ImageDraw.ImageDraw):
|
|
||||||
self.display_series_summary(game, draw_overlay)
|
|
||||||
|
|
||||||
|
|
||||||
class BaseballLive(Baseball, SportsLive):
|
|
||||||
"""Base class for live baseball games."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
config: Dict[str, Any],
|
|
||||||
display_manager,
|
|
||||||
cache_manager,
|
|
||||||
logger: logging.Logger,
|
|
||||||
sport_key: str,
|
|
||||||
):
|
|
||||||
super().__init__(config, display_manager, cache_manager, logger, sport_key)
|
|
||||||
|
|
||||||
def _test_mode_update(self):
|
|
||||||
if self.current_game and self.current_game["is_live"]:
|
|
||||||
# self.current_game["bases_occupied"] = [
|
|
||||||
# random.choice([True, False]) for _ in range(3)
|
|
||||||
# ]
|
|
||||||
# self.current_game["balls"] = random.choice([1, 2, 3])
|
|
||||||
# self.current_game["strikes"] = random.choice([1, 2])
|
|
||||||
# self.current_game["outs"] = random.choice([1, 2])
|
|
||||||
if self.current_game["inning_half"] == "top":
|
|
||||||
self.current_game["inning_half"] = "bottom"
|
|
||||||
else:
|
|
||||||
self.current_game["inning_half"] = "top"
|
|
||||||
self.current_game["inning"] += 1
|
|
||||||
self.current_game["balls"] = (self.current_game["balls"] + 1) % 4
|
|
||||||
self.current_game["strikes"] = (self.current_game["strikes"] + 1) % 3
|
|
||||||
self.current_game["outs"] = (self.current_game["outs"] + 1) % 3
|
|
||||||
self.current_game["bases_occupied"] = [
|
|
||||||
not b for b in self.current_game["bases_occupied"]
|
|
||||||
]
|
|
||||||
if self.current_game["inning"] % 2 == 0:
|
|
||||||
self.current_game["home_score"] = str(
|
|
||||||
int(self.current_game["home_score"]) + 1
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.current_game["away_score"] = str(
|
|
||||||
int(self.current_game["away_score"]) + 1
|
|
||||||
)
|
|
||||||
|
|
||||||
def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None:
|
|
||||||
"""Draw the detailed scorebug layout for a live NCAA FB game.""" # Updated docstring
|
|
||||||
try:
|
|
||||||
main_img = Image.new(
|
|
||||||
"RGBA", (self.display_width, self.display_height), (0, 0, 0, 255)
|
|
||||||
)
|
|
||||||
overlay = Image.new(
|
|
||||||
"RGBA", (self.display_width, self.display_height), (0, 0, 0, 0)
|
|
||||||
)
|
|
||||||
draw_overlay = ImageDraw.Draw(
|
|
||||||
overlay
|
|
||||||
) # Draw text elements on overlay first
|
|
||||||
|
|
||||||
home_logo = self._load_and_resize_logo(
|
|
||||||
game["home_id"],
|
|
||||||
game["home_abbr"],
|
|
||||||
game["home_logo_path"],
|
|
||||||
game.get("home_logo_url"),
|
|
||||||
)
|
|
||||||
away_logo = self._load_and_resize_logo(
|
|
||||||
game["away_id"],
|
|
||||||
game["away_abbr"],
|
|
||||||
game["away_logo_path"],
|
|
||||||
game.get("away_logo_url"),
|
|
||||||
)
|
|
||||||
|
|
||||||
if not home_logo or not away_logo:
|
|
||||||
self.logger.error(
|
|
||||||
f"Failed to load logos for live game: {game.get('id')}"
|
|
||||||
) # Changed log prefix
|
|
||||||
# Draw placeholder text if logos fail
|
|
||||||
draw_final = ImageDraw.Draw(main_img.convert("RGB"))
|
|
||||||
self._draw_text_with_outline(
|
|
||||||
draw_final, "Logo Error", (5, 5), self.fonts["status"]
|
|
||||||
)
|
|
||||||
self.display_manager.image.paste(main_img.convert("RGB"), (0, 0))
|
|
||||||
self.display_manager.update_display()
|
|
||||||
return
|
|
||||||
|
|
||||||
center_y = self.display_height // 2
|
|
||||||
|
|
||||||
# Draw logos (shifted slightly more inward than NHL perhaps)
|
|
||||||
home_x = (
|
|
||||||
self.display_width - home_logo.width + 10
|
|
||||||
) # adjusted from 18 # Adjust position as needed
|
|
||||||
home_y = center_y - (home_logo.height // 2)
|
|
||||||
main_img.paste(home_logo, (home_x, home_y), home_logo)
|
|
||||||
|
|
||||||
away_x = -10 # adjusted from 18 # Adjust position as needed
|
|
||||||
away_y = center_y - (away_logo.height // 2)
|
|
||||||
main_img.paste(away_logo, (away_x, away_y), away_logo)
|
|
||||||
|
|
||||||
# --- Live Game Specific Elements ---
|
|
||||||
|
|
||||||
# Define default text color
|
|
||||||
text_color = (255, 255, 255)
|
|
||||||
|
|
||||||
# Draw Inning (Top Center)
|
|
||||||
inning_half = game["inning_half"]
|
|
||||||
inning_num = game["inning"]
|
|
||||||
if game["is_final"]:
|
|
||||||
inning_text = "FINAL"
|
|
||||||
else:
|
|
||||||
inning_half_indicator = (
|
|
||||||
"▲" if game["inning_half"].lower() == "top" else "▼"
|
|
||||||
)
|
|
||||||
inning_num = game["inning"]
|
|
||||||
inning_text = f"{inning_half_indicator}{inning_num}"
|
|
||||||
|
|
||||||
inning_bbox = draw_overlay.textbbox(
|
|
||||||
(0, 0), inning_text, font=self.display_manager.font
|
|
||||||
)
|
|
||||||
inning_width = inning_bbox[2] - inning_bbox[0]
|
|
||||||
inning_x = (self.display_width - inning_width) // 2
|
|
||||||
inning_y = 1 # Position near top center
|
|
||||||
# draw_overlay.text((inning_x, inning_y), inning_text, fill=(255, 255, 255), font=self.display_manager.font)
|
|
||||||
self._draw_text_with_outline(
|
|
||||||
draw_overlay,
|
|
||||||
inning_text,
|
|
||||||
(inning_x, inning_y),
|
|
||||||
self.display_manager.font,
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- REVISED BASES AND OUTS DRAWING ---
|
|
||||||
bases_occupied = game["bases_occupied"] # [1st, 2nd, 3rd]
|
|
||||||
outs = game.get("outs", 0)
|
|
||||||
inning_half = game["inning_half"]
|
|
||||||
|
|
||||||
# Define geometry
|
|
||||||
base_diamond_size = 7
|
|
||||||
out_circle_diameter = 3
|
|
||||||
out_vertical_spacing = 2 # Space between out circles
|
|
||||||
spacing_between_bases_outs = (
|
|
||||||
3 # Horizontal space between base cluster and out column
|
|
||||||
)
|
|
||||||
base_vert_spacing = 1 # Internal vertical space in base cluster
|
|
||||||
base_horiz_spacing = 1 # Internal horizontal space in base cluster
|
|
||||||
|
|
||||||
# Calculate cluster dimensions
|
|
||||||
base_cluster_height = (
|
|
||||||
base_diamond_size + base_vert_spacing + base_diamond_size
|
|
||||||
)
|
|
||||||
base_cluster_width = (
|
|
||||||
base_diamond_size + base_horiz_spacing + base_diamond_size
|
|
||||||
)
|
|
||||||
out_cluster_height = 3 * out_circle_diameter + 2 * out_vertical_spacing
|
|
||||||
out_cluster_width = out_circle_diameter
|
|
||||||
|
|
||||||
# Calculate overall start positions
|
|
||||||
overall_start_y = (
|
|
||||||
inning_bbox[3] + 0
|
|
||||||
) # Start immediately below inning text (moved up 3 pixels)
|
|
||||||
|
|
||||||
# Center the BASE cluster horizontally
|
|
||||||
bases_origin_x = (self.display_width - base_cluster_width) // 2
|
|
||||||
|
|
||||||
# Determine relative positions for outs based on inning half
|
|
||||||
if inning_half == "top": # Away batting, outs on left
|
|
||||||
outs_column_x = (
|
|
||||||
bases_origin_x - spacing_between_bases_outs - out_cluster_width
|
|
||||||
)
|
|
||||||
else: # Home batting, outs on right
|
|
||||||
outs_column_x = (
|
|
||||||
bases_origin_x + base_cluster_width + spacing_between_bases_outs
|
|
||||||
)
|
|
||||||
|
|
||||||
# Calculate vertical alignment offset for outs column (center align with bases cluster)
|
|
||||||
outs_column_start_y = (
|
|
||||||
overall_start_y + (base_cluster_height // 2) - (out_cluster_height // 2)
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- Draw Bases (Diamonds) ---
|
|
||||||
base_color_occupied = (255, 255, 255)
|
|
||||||
base_color_empty = (255, 255, 255) # Outline color
|
|
||||||
h_d = base_diamond_size // 2
|
|
||||||
|
|
||||||
# 2nd Base (Top center relative to bases_origin_x)
|
|
||||||
c2x = bases_origin_x + base_cluster_width // 2
|
|
||||||
c2y = overall_start_y + h_d
|
|
||||||
poly2 = [
|
|
||||||
(c2x, overall_start_y),
|
|
||||||
(c2x + h_d, c2y),
|
|
||||||
(c2x, c2y + h_d),
|
|
||||||
(c2x - h_d, c2y),
|
|
||||||
]
|
|
||||||
if bases_occupied[1]:
|
|
||||||
draw_overlay.polygon(poly2, fill=base_color_occupied)
|
|
||||||
else:
|
|
||||||
draw_overlay.polygon(poly2, outline=base_color_empty)
|
|
||||||
|
|
||||||
base_bottom_y = c2y + h_d # Bottom Y of 2nd base diamond
|
|
||||||
|
|
||||||
# 3rd Base (Bottom left relative to bases_origin_x)
|
|
||||||
c3x = bases_origin_x + h_d
|
|
||||||
c3y = base_bottom_y + base_vert_spacing + h_d
|
|
||||||
poly3 = [
|
|
||||||
(c3x, base_bottom_y + base_vert_spacing),
|
|
||||||
(c3x + h_d, c3y),
|
|
||||||
(c3x, c3y + h_d),
|
|
||||||
(c3x - h_d, c3y),
|
|
||||||
]
|
|
||||||
if bases_occupied[2]:
|
|
||||||
draw_overlay.polygon(poly3, fill=base_color_occupied)
|
|
||||||
else:
|
|
||||||
draw_overlay.polygon(poly3, outline=base_color_empty)
|
|
||||||
|
|
||||||
# 1st Base (Bottom right relative to bases_origin_x)
|
|
||||||
c1x = bases_origin_x + base_cluster_width - h_d
|
|
||||||
c1y = base_bottom_y + base_vert_spacing + h_d
|
|
||||||
poly1 = [
|
|
||||||
(c1x, base_bottom_y + base_vert_spacing),
|
|
||||||
(c1x + h_d, c1y),
|
|
||||||
(c1x, c1y + h_d),
|
|
||||||
(c1x - h_d, c1y),
|
|
||||||
]
|
|
||||||
if bases_occupied[0]:
|
|
||||||
draw_overlay.polygon(poly1, fill=base_color_occupied)
|
|
||||||
else:
|
|
||||||
draw_overlay.polygon(poly1, outline=base_color_empty)
|
|
||||||
|
|
||||||
# --- Draw Outs (Vertical Circles) ---
|
|
||||||
circle_color_out = (255, 255, 255)
|
|
||||||
circle_color_empty_outline = (100, 100, 100)
|
|
||||||
|
|
||||||
for i in range(3):
|
|
||||||
cx = outs_column_x
|
|
||||||
cy = outs_column_start_y + i * (
|
|
||||||
out_circle_diameter + out_vertical_spacing
|
|
||||||
)
|
|
||||||
coords = [cx, cy, cx + out_circle_diameter, cy + out_circle_diameter]
|
|
||||||
if i < outs:
|
|
||||||
draw_overlay.ellipse(coords, fill=circle_color_out)
|
|
||||||
else:
|
|
||||||
draw_overlay.ellipse(coords, outline=circle_color_empty_outline)
|
|
||||||
|
|
||||||
# --- Draw Balls-Strikes Count (BDF Font) ---
|
|
||||||
balls = game.get("balls", 0)
|
|
||||||
strikes = game.get("strikes", 0)
|
|
||||||
|
|
||||||
# Add debug logging for count with cooldown
|
|
||||||
current_time = time.time()
|
|
||||||
if (
|
|
||||||
game["home_abbr"] in self.favorite_teams
|
|
||||||
or game["away_abbr"] in self.favorite_teams
|
|
||||||
) and current_time - self.last_count_log_time >= self.count_log_interval:
|
|
||||||
self.logger.debug(f"Displaying count: {balls}-{strikes}")
|
|
||||||
self.logger.debug(
|
|
||||||
f"Raw count data: balls={game.get('balls')}, strikes={game.get('strikes')}"
|
|
||||||
)
|
|
||||||
self.last_count_log_time = current_time
|
|
||||||
|
|
||||||
count_text = f"{balls}-{strikes}"
|
|
||||||
bdf_font = self.display_manager.calendar_font
|
|
||||||
bdf_font.set_char_size(height=7 * 64) # Set 7px height
|
|
||||||
count_text_width = self.display_manager.get_text_width(count_text, bdf_font)
|
|
||||||
|
|
||||||
# Position below the base/out cluster
|
|
||||||
cluster_bottom_y = (
|
|
||||||
overall_start_y + base_cluster_height
|
|
||||||
) # Find the bottom of the taller part (bases)
|
|
||||||
count_y = cluster_bottom_y + 2 # Start 2 pixels below cluster
|
|
||||||
|
|
||||||
# Center horizontally within the BASE cluster width
|
|
||||||
count_x = bases_origin_x + (base_cluster_width - count_text_width) // 2
|
|
||||||
|
|
||||||
# Ensure draw object is set and draw text
|
|
||||||
self.display_manager.draw = draw_overlay
|
|
||||||
# self.display_manager._draw_bdf_text(count_text, count_x, count_y, text_color, font=bdf_font)
|
|
||||||
# Use _draw_text_with_outline for count text
|
|
||||||
# self._draw_text_with_outline(draw, count_text, (count_x, count_y), bdf_font, fill=text_color)
|
|
||||||
|
|
||||||
# Draw Balls-Strikes Count with outline using BDF font
|
|
||||||
# Define outline color (consistent with _draw_text_with_outline default)
|
|
||||||
outline_color_for_bdf = (0, 0, 0)
|
|
||||||
|
|
||||||
# Draw outline
|
|
||||||
for dx_offset, dy_offset in [
|
|
||||||
(-1, -1),
|
|
||||||
(-1, 0),
|
|
||||||
(-1, 1),
|
|
||||||
(0, -1),
|
|
||||||
(0, 1),
|
|
||||||
(1, -1),
|
|
||||||
(1, 0),
|
|
||||||
(1, 1),
|
|
||||||
]:
|
|
||||||
self.display_manager._draw_bdf_text(
|
|
||||||
count_text,
|
|
||||||
count_x + dx_offset,
|
|
||||||
count_y + dy_offset,
|
|
||||||
color=outline_color_for_bdf,
|
|
||||||
font=bdf_font,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Draw main text
|
|
||||||
self.display_manager._draw_bdf_text(
|
|
||||||
count_text, count_x, count_y, color=text_color, font=bdf_font
|
|
||||||
)
|
|
||||||
|
|
||||||
# Draw Team:Score at the bottom (matching main branch format)
|
|
||||||
score_font = self.display_manager.font # Use PressStart2P
|
|
||||||
outline_color = (0, 0, 0)
|
|
||||||
score_text_color = (
|
|
||||||
255,
|
|
||||||
255,
|
|
||||||
255,
|
|
||||||
) # Use a specific name for score text color
|
|
||||||
|
|
||||||
# Helper function for outlined text
|
|
||||||
def draw_bottom_outlined_text(x, y, text):
|
|
||||||
self._draw_text_with_outline(
|
|
||||||
draw_overlay,
|
|
||||||
text,
|
|
||||||
(x, y),
|
|
||||||
score_font,
|
|
||||||
fill=score_text_color,
|
|
||||||
outline_color=outline_color,
|
|
||||||
)
|
|
||||||
|
|
||||||
away_abbr = game["away_abbr"]
|
|
||||||
home_abbr = game["home_abbr"]
|
|
||||||
away_score_str = str(game["away_score"])
|
|
||||||
home_score_str = str(game["home_score"])
|
|
||||||
|
|
||||||
away_text = f"{away_abbr}:{away_score_str}"
|
|
||||||
home_text = f"{home_abbr}:{home_score_str}"
|
|
||||||
|
|
||||||
# Calculate Y position (bottom edge)
|
|
||||||
# Get font height (approximate or precise)
|
|
||||||
try:
|
|
||||||
font_height = score_font.getbbox("A")[3] - score_font.getbbox("A")[1]
|
|
||||||
except AttributeError:
|
|
||||||
font_height = 8 # Fallback for default font
|
|
||||||
score_y = (
|
|
||||||
self.display_height - font_height - 2
|
|
||||||
) # 2 pixels padding from bottom
|
|
||||||
|
|
||||||
# Away Team:Score (Bottom Left)
|
|
||||||
away_score_x = 2 # 2 pixels padding from left
|
|
||||||
draw_bottom_outlined_text(away_score_x, score_y, away_text)
|
|
||||||
|
|
||||||
# Home Team:Score (Bottom Right)
|
|
||||||
home_text_bbox = draw_overlay.textbbox((0, 0), home_text, font=score_font)
|
|
||||||
home_text_width = home_text_bbox[2] - home_text_bbox[0]
|
|
||||||
home_score_x = (
|
|
||||||
self.display_width - home_text_width - 2
|
|
||||||
) # 2 pixels padding from right
|
|
||||||
draw_bottom_outlined_text(home_score_x, score_y, home_text)
|
|
||||||
|
|
||||||
# Draw gambling odds if available
|
|
||||||
if "odds" in game and game["odds"]:
|
|
||||||
self._draw_dynamic_odds(
|
|
||||||
draw_overlay, game["odds"], self.display_width, self.display_height
|
|
||||||
)
|
|
||||||
|
|
||||||
# Composite the text overlay onto the main image
|
|
||||||
main_img = Image.alpha_composite(main_img, overlay)
|
|
||||||
main_img = main_img.convert("RGB") # Convert for display
|
|
||||||
|
|
||||||
# Display the final image
|
|
||||||
self.display_manager.image.paste(main_img, (0, 0))
|
|
||||||
self.display_manager.update_display() # Update display here for live
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(
|
|
||||||
f"Error displaying live Baseball game: {e}", exc_info=True
|
|
||||||
) # Changed log prefix
|
|
||||||
@@ -1,300 +0,0 @@
|
|||||||
import logging
|
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
|
||||||
|
|
||||||
from src.base_classes.data_sources import ESPNDataSource
|
|
||||||
from src.base_classes.sports import SportsCore, SportsLive
|
|
||||||
from src.cache_manager import CacheManager
|
|
||||||
from src.display_manager import DisplayManager
|
|
||||||
|
|
||||||
|
|
||||||
class Basketball(SportsCore):
|
|
||||||
"""Base class for basketball sports with common functionality."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
config: Dict[str, Any],
|
|
||||||
display_manager: DisplayManager,
|
|
||||||
cache_manager: CacheManager,
|
|
||||||
logger: logging.Logger,
|
|
||||||
sport_key: str,
|
|
||||||
):
|
|
||||||
super().__init__(config, display_manager, cache_manager, logger, sport_key)
|
|
||||||
self.data_source = ESPNDataSource(logger)
|
|
||||||
self.sport = "basketball"
|
|
||||||
|
|
||||||
def _extract_game_details(self, game_event: Dict) -> Optional[Dict]:
|
|
||||||
"""Extract relevant game details from ESPN NCAA FB API response."""
|
|
||||||
# --- THIS METHOD MAY NEED ADJUSTMENTS FOR NCAA FB API DIFFERENCES ---
|
|
||||||
details, home_team, away_team, status, situation = (
|
|
||||||
self._extract_game_details_common(game_event)
|
|
||||||
)
|
|
||||||
if details is None or home_team is None or away_team is None or status is None:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
# Format period/quarter
|
|
||||||
period = status.get("period", 0)
|
|
||||||
period_text = ""
|
|
||||||
if status["type"]["state"] == "in":
|
|
||||||
if period == 0:
|
|
||||||
period_text = "Start" # Before kickoff
|
|
||||||
elif period >= 1 and period <= 4:
|
|
||||||
period_text = f"Q{period}" # OT starts after Q4
|
|
||||||
elif period > 4:
|
|
||||||
period_text = f"OT{period - 4}" # OT starts after Q4
|
|
||||||
elif status["type"]["state"] == "halftime" or status["type"]["name"] == "STATUS_HALFTIME": # Check explicit halftime state
|
|
||||||
period_text = "HALF"
|
|
||||||
elif status["type"]["state"] == "post":
|
|
||||||
if period > 4 : period_text = "Final/OT"
|
|
||||||
else: period_text = "Final"
|
|
||||||
elif status["type"]["state"] == "pre":
|
|
||||||
period_text = details.get("game_time", "") # Show time for upcoming
|
|
||||||
|
|
||||||
details.update({
|
|
||||||
"period": period,
|
|
||||||
"period_text": period_text, # Formatted quarter/status
|
|
||||||
"clock": status.get("displayClock", "0:00"),
|
|
||||||
})
|
|
||||||
|
|
||||||
# Basic validation (can be expanded)
|
|
||||||
if not details["home_abbr"] or not details["away_abbr"]:
|
|
||||||
self.logger.warning(
|
|
||||||
f"Missing team abbreviation in event: {details['id']}"
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
self.logger.debug(
|
|
||||||
f"Extracted: {details['away_abbr']}@{details['home_abbr']}, Status: {status['type']['name']}, Live: {details['is_live']}, Final: {details['is_final']}, Upcoming: {details['is_upcoming']}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return details
|
|
||||||
except Exception as e:
|
|
||||||
# Log the problematic event structure if possible
|
|
||||||
self.logger.error(
|
|
||||||
f"Error extracting game details: {e} from event: {game_event.get('id')}",
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class BasketballLive(Basketball, SportsLive):
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
config: Dict[str, Any],
|
|
||||||
display_manager: DisplayManager,
|
|
||||||
cache_manager: CacheManager,
|
|
||||||
logger: logging.Logger,
|
|
||||||
sport_key: str,
|
|
||||||
):
|
|
||||||
super().__init__(config, display_manager, cache_manager, logger, sport_key)
|
|
||||||
|
|
||||||
def _test_mode_update(self):
|
|
||||||
if self.current_game and self.current_game["is_live"]:
|
|
||||||
# For testing, we'll just update the clock to show it's working
|
|
||||||
minutes = int(self.current_game["clock"].split(":")[0])
|
|
||||||
seconds = int(self.current_game["clock"].split(":")[1])
|
|
||||||
seconds -= 1
|
|
||||||
if seconds < 0:
|
|
||||||
seconds = 59
|
|
||||||
minutes -= 1
|
|
||||||
if minutes < 0:
|
|
||||||
minutes = 19
|
|
||||||
if self.current_game["period"] < 3:
|
|
||||||
self.current_game["period"] += 1
|
|
||||||
else:
|
|
||||||
self.current_game["period"] = 1
|
|
||||||
self.current_game["clock"] = f"{minutes:02d}:{seconds:02d}"
|
|
||||||
# Always update display in test mode
|
|
||||||
|
|
||||||
def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None:
|
|
||||||
"""Draw the detailed scorebug layout for a live Basketball game.""" # Updated docstring
|
|
||||||
try:
|
|
||||||
main_img = Image.new(
|
|
||||||
"RGBA", (self.display_width, self.display_height), (0, 0, 0, 255)
|
|
||||||
)
|
|
||||||
overlay = Image.new(
|
|
||||||
"RGBA", (self.display_width, self.display_height), (0, 0, 0, 0)
|
|
||||||
)
|
|
||||||
draw_overlay = ImageDraw.Draw(
|
|
||||||
overlay
|
|
||||||
) # Draw text elements on overlay first
|
|
||||||
home_logo = self._load_and_resize_logo(
|
|
||||||
game["home_id"],
|
|
||||||
game["home_abbr"],
|
|
||||||
game["home_logo_path"],
|
|
||||||
game.get("home_logo_url"),
|
|
||||||
)
|
|
||||||
away_logo = self._load_and_resize_logo(
|
|
||||||
game["away_id"],
|
|
||||||
game["away_abbr"],
|
|
||||||
game["away_logo_path"],
|
|
||||||
game.get("away_logo_url"),
|
|
||||||
)
|
|
||||||
|
|
||||||
if not home_logo or not away_logo:
|
|
||||||
self.logger.error(
|
|
||||||
f"Failed to load logos for live game: {game.get('id')}"
|
|
||||||
) # Changed log prefix
|
|
||||||
# Draw placeholder text if logos fail
|
|
||||||
draw_final = ImageDraw.Draw(main_img.convert("RGB"))
|
|
||||||
self._draw_text_with_outline(
|
|
||||||
draw_final, "Logo Error", (5, 5), self.fonts["status"]
|
|
||||||
)
|
|
||||||
self.display_manager.image.paste(main_img.convert("RGB"), (0, 0))
|
|
||||||
self.display_manager.update_display()
|
|
||||||
return
|
|
||||||
|
|
||||||
center_y = self.display_height // 2
|
|
||||||
|
|
||||||
# Draw logos (shifted slightly more inward than NHL perhaps)
|
|
||||||
home_x = (
|
|
||||||
self.display_width - home_logo.width + 10
|
|
||||||
) # adjusted from 18 # Adjust position as needed
|
|
||||||
home_y = center_y - (home_logo.height // 2)
|
|
||||||
main_img.paste(home_logo, (home_x, home_y), home_logo)
|
|
||||||
|
|
||||||
away_x = -10 # adjusted from 18 # Adjust position as needed
|
|
||||||
away_y = center_y - (away_logo.height // 2)
|
|
||||||
main_img.paste(away_logo, (away_x, away_y), away_logo)
|
|
||||||
|
|
||||||
# --- Draw Text Elements on Overlay ---
|
|
||||||
# Note: Rankings are now handled in the records/rankings section below
|
|
||||||
|
|
||||||
# Period/Quarter and Clock (Top center)
|
|
||||||
period_clock_text = (
|
|
||||||
f"{game.get('period_text', '')} {game.get('clock', '')}".strip()
|
|
||||||
)
|
|
||||||
|
|
||||||
status_width = draw_overlay.textlength(
|
|
||||||
period_clock_text, font=self.fonts["time"]
|
|
||||||
)
|
|
||||||
status_x = (self.display_width - status_width) // 2
|
|
||||||
status_y = 1 # Position at top
|
|
||||||
self._draw_text_with_outline(
|
|
||||||
draw_overlay,
|
|
||||||
period_clock_text,
|
|
||||||
(status_x, status_y),
|
|
||||||
self.fonts["time"],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Scores (centered, slightly above bottom)
|
|
||||||
home_score = str(game.get("home_score", "0"))
|
|
||||||
away_score = str(game.get("away_score", "0"))
|
|
||||||
score_text = f"{away_score}-{home_score}"
|
|
||||||
score_width = draw_overlay.textlength(score_text, font=self.fonts["score"])
|
|
||||||
score_x = (self.display_width - score_width) // 2
|
|
||||||
score_y = (
|
|
||||||
self.display_height // 2
|
|
||||||
) - 3 # centered #from 14 # Position score higher
|
|
||||||
self._draw_text_with_outline(
|
|
||||||
draw_overlay, score_text, (score_x, score_y), self.fonts["score"]
|
|
||||||
)
|
|
||||||
|
|
||||||
# Draw odds if available
|
|
||||||
if "odds" in game and game["odds"]:
|
|
||||||
self._draw_dynamic_odds(
|
|
||||||
draw_overlay, game["odds"], self.display_width, self.display_height
|
|
||||||
)
|
|
||||||
|
|
||||||
# Draw records or rankings if enabled
|
|
||||||
if self.show_records or self.show_ranking:
|
|
||||||
record_font = self.fonts.get('detail', ImageFont.load_default())
|
|
||||||
|
|
||||||
# Get team abbreviations
|
|
||||||
away_abbr = game.get("away_abbr", "")
|
|
||||||
home_abbr = game.get("home_abbr", "")
|
|
||||||
|
|
||||||
record_bbox = draw_overlay.textbbox((0, 0), "0-0", font=record_font)
|
|
||||||
record_height = record_bbox[3] - record_bbox[1]
|
|
||||||
record_y = self.display_height - record_height - 1
|
|
||||||
self.logger.debug(
|
|
||||||
f"Record positioning: height={record_height}, record_y={record_y}, display_height={self.display_height}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Display away team info
|
|
||||||
if away_abbr:
|
|
||||||
if self.show_ranking and self.show_records:
|
|
||||||
# When both rankings and records are enabled, rankings replace records completely
|
|
||||||
away_rank = self._team_rankings_cache.get(away_abbr, 0)
|
|
||||||
if away_rank > 0:
|
|
||||||
away_text = f"#{away_rank}"
|
|
||||||
else:
|
|
||||||
# Show nothing for unranked teams when rankings are prioritized
|
|
||||||
away_text = ""
|
|
||||||
elif self.show_ranking:
|
|
||||||
# Show ranking only if available
|
|
||||||
away_rank = self._team_rankings_cache.get(away_abbr, 0)
|
|
||||||
if away_rank > 0:
|
|
||||||
away_text = f"#{away_rank}"
|
|
||||||
else:
|
|
||||||
away_text = ""
|
|
||||||
elif self.show_records:
|
|
||||||
# Show record only when rankings are disabled
|
|
||||||
away_text = game.get("away_record", "")
|
|
||||||
else:
|
|
||||||
away_text = ""
|
|
||||||
|
|
||||||
if away_text:
|
|
||||||
away_record_x = 3
|
|
||||||
self.logger.debug(
|
|
||||||
f"Drawing away ranking '{away_text}' at ({away_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}"
|
|
||||||
)
|
|
||||||
self._draw_text_with_outline(
|
|
||||||
draw_overlay,
|
|
||||||
away_text,
|
|
||||||
(away_record_x, record_y),
|
|
||||||
record_font,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Display home team info
|
|
||||||
if home_abbr:
|
|
||||||
if self.show_ranking and self.show_records:
|
|
||||||
# When both rankings and records are enabled, rankings replace records completely
|
|
||||||
home_rank = self._team_rankings_cache.get(home_abbr, 0)
|
|
||||||
if home_rank > 0:
|
|
||||||
home_text = f"#{home_rank}"
|
|
||||||
else:
|
|
||||||
# Show nothing for unranked teams when rankings are prioritized
|
|
||||||
home_text = ""
|
|
||||||
elif self.show_ranking:
|
|
||||||
# Show ranking only if available
|
|
||||||
home_rank = self._team_rankings_cache.get(home_abbr, 0)
|
|
||||||
if home_rank > 0:
|
|
||||||
home_text = f"#{home_rank}"
|
|
||||||
else:
|
|
||||||
home_text = ""
|
|
||||||
elif self.show_records:
|
|
||||||
# Show record only when rankings are disabled
|
|
||||||
home_text = game.get("home_record", "")
|
|
||||||
else:
|
|
||||||
home_text = ""
|
|
||||||
|
|
||||||
if home_text:
|
|
||||||
home_record_bbox = draw_overlay.textbbox(
|
|
||||||
(0, 0), home_text, font=record_font
|
|
||||||
)
|
|
||||||
home_record_width = home_record_bbox[2] - home_record_bbox[0]
|
|
||||||
home_record_x = self.display_width - home_record_width - 3
|
|
||||||
self.logger.debug(
|
|
||||||
f"Drawing home ranking '{home_text}' at ({home_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}"
|
|
||||||
)
|
|
||||||
self._draw_text_with_outline(
|
|
||||||
draw_overlay,
|
|
||||||
home_text,
|
|
||||||
(home_record_x, record_y),
|
|
||||||
record_font,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Composite the text overlay onto the main image
|
|
||||||
main_img = Image.alpha_composite(main_img, overlay)
|
|
||||||
main_img = main_img.convert("RGB") # Convert for display
|
|
||||||
|
|
||||||
# Display the final image
|
|
||||||
self.display_manager.image.paste(main_img, (0, 0))
|
|
||||||
self.display_manager.update_display() # Update display here for live
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(
|
|
||||||
f"Error displaying live Hockey game: {e}", exc_info=True
|
|
||||||
) # Changed log prefix
|
|
||||||
@@ -1,344 +0,0 @@
|
|||||||
"""
|
|
||||||
Pluggable Data Source Architecture
|
|
||||||
|
|
||||||
This module provides abstract data sources that can be plugged into the sports system
|
|
||||||
to support different APIs and data providers.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
|
||||||
from typing import Dict, List
|
|
||||||
import requests
|
|
||||||
import logging
|
|
||||||
from datetime import datetime
|
|
||||||
from src.common.espn_dates import fetch_espn_scoreboard
|
|
||||||
|
|
||||||
class DataSource(ABC):
|
|
||||||
"""Abstract base class for data sources."""
|
|
||||||
|
|
||||||
def __init__(self, logger: logging.Logger):
|
|
||||||
self.logger = logger
|
|
||||||
self.session = requests.Session()
|
|
||||||
|
|
||||||
# Configure retry strategy
|
|
||||||
from requests.adapters import HTTPAdapter
|
|
||||||
from urllib3.util.retry import Retry
|
|
||||||
|
|
||||||
retry_strategy = Retry(
|
|
||||||
total=5,
|
|
||||||
backoff_factor=1,
|
|
||||||
status_forcelist=[429, 500, 502, 503, 504],
|
|
||||||
)
|
|
||||||
adapter = HTTPAdapter(max_retries=retry_strategy)
|
|
||||||
self.session.mount("http://", adapter)
|
|
||||||
self.session.mount("https://", adapter)
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def fetch_live_games(self, sport: str, league: str) -> List[Dict]:
|
|
||||||
"""Fetch live games for a sport/league."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def fetch_schedule(self, sport: str, league: str, date_range: tuple) -> List[Dict]:
|
|
||||||
"""Fetch schedule for a sport/league within date range."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def fetch_standings(self, sport: str, league: str) -> Dict:
|
|
||||||
"""Fetch standings for a sport/league."""
|
|
||||||
|
|
||||||
def get_headers(self) -> Dict[str, str]:
|
|
||||||
"""Get headers for API requests.
|
|
||||||
|
|
||||||
The agent carries the project URL deliberately. Around 2026-08-04 ESPN
|
|
||||||
began returning 403 for bare custom tokens like 'LEDMatrix/1.0' — and
|
|
||||||
for browser-style strings — while accepting an agent that identifies
|
|
||||||
the client and links to it. An Accept header alone does not rescue the
|
|
||||||
bare form when the request goes out through requests.
|
|
||||||
"""
|
|
||||||
return {
|
|
||||||
'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)',
|
|
||||||
'Accept': 'application/json'
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class ESPNDataSource(DataSource):
|
|
||||||
"""ESPN API data source."""
|
|
||||||
|
|
||||||
def __init__(self, logger: logging.Logger):
|
|
||||||
super().__init__(logger)
|
|
||||||
self.base_url = "https://site.api.espn.com/apis/site/v2/sports"
|
|
||||||
|
|
||||||
def fetch_live_games(self, sport: str, league: str) -> List[Dict]:
|
|
||||||
"""Fetch live games from ESPN API."""
|
|
||||||
try:
|
|
||||||
now = datetime.now()
|
|
||||||
formatted_date = now.strftime("%Y%m%d")
|
|
||||||
url = f"{self.base_url}/{sport}/{league}/scoreboard"
|
|
||||||
data = fetch_espn_scoreboard(
|
|
||||||
self.session, url, params={"dates": formatted_date, "limit": 1000},
|
|
||||||
headers=self.get_headers(), timeout=15, logger=self.logger,
|
|
||||||
)
|
|
||||||
events = data.get('events', [])
|
|
||||||
|
|
||||||
# Filter for live games
|
|
||||||
live_events = [event for event in events
|
|
||||||
if event.get('competitions', [{}])[0].get('status', {}).get('type', {}).get('state') == 'in']
|
|
||||||
|
|
||||||
self.logger.debug(f"Fetched {len(live_events)} live games for {sport}/{league}")
|
|
||||||
return live_events
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Error fetching live games from ESPN: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
def fetch_schedule(self, sport: str, league: str, date_range: tuple) -> List[Dict]:
|
|
||||||
"""Fetch schedule from ESPN API."""
|
|
||||||
try:
|
|
||||||
start_date, end_date = date_range
|
|
||||||
url = f"{self.base_url}/{sport}/{league}/scoreboard"
|
|
||||||
|
|
||||||
params = {
|
|
||||||
'dates': f"{start_date.strftime('%Y%m%d')}-{end_date.strftime('%Y%m%d')}",
|
|
||||||
"limit": 1000
|
|
||||||
}
|
|
||||||
|
|
||||||
data = fetch_espn_scoreboard(
|
|
||||||
self.session, url, params=params,
|
|
||||||
headers=self.get_headers(), timeout=15, logger=self.logger,
|
|
||||||
)
|
|
||||||
events = data.get('events', [])
|
|
||||||
|
|
||||||
self.logger.debug(f"Fetched {len(events)} scheduled games for {sport}/{league}")
|
|
||||||
return events
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Error fetching schedule from ESPN: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
def fetch_standings(self, sport: str, league: str) -> Dict:
|
|
||||||
"""Fetch standings, or the poll for leagues that have one.
|
|
||||||
|
|
||||||
Order matters and used to be wrong. College leagues publish a poll at
|
|
||||||
/rankings and a records table at /standings; professional leagues have
|
|
||||||
only /standings. The old code tried /standings first and fell back to
|
|
||||||
/rankings only on a 404 -- but college /standings answers 200, so the
|
|
||||||
fallback never fired and college rankings came back empty forever.
|
|
||||||
Nothing failed; the AP rank badge simply never appeared, and anything
|
|
||||||
else keyed off rankings quietly did nothing.
|
|
||||||
|
|
||||||
A 200 that lacks the key is treated as a miss, so a league answering
|
|
||||||
both endpoints still ends up with whichever one actually carries a poll.
|
|
||||||
"""
|
|
||||||
league_name = (league or "").lower()
|
|
||||||
wants_poll = "college" in league_name or "ncaa" in league_name
|
|
||||||
endpoints = ["rankings", "standings"] if wants_poll else ["standings", "rankings"]
|
|
||||||
|
|
||||||
for endpoint in endpoints:
|
|
||||||
url = f"{self.base_url}/{sport}/{league}/{endpoint}"
|
|
||||||
# Only the request is guarded. Inspecting the payload happens
|
|
||||||
# below, outside the handler, so that a bug in this method cannot
|
|
||||||
# be mistaken for an endpoint that failed -- that mistake would
|
|
||||||
# silently drop rankings for a league that has them, which is the
|
|
||||||
# exact failure this function was written to fix.
|
|
||||||
try:
|
|
||||||
response = self.session.get(
|
|
||||||
url, headers=self.get_headers(), timeout=15
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
data = response.json()
|
|
||||||
except (requests.RequestException, ValueError) as e:
|
|
||||||
status = getattr(getattr(e, "response", None), "status_code", None)
|
|
||||||
# Only a 404 is routine -- it is how a league says "no poll
|
|
||||||
# here". Everything else is worth an error, and `status is
|
|
||||||
# None` covers the ones that matter most: ConnectionError,
|
|
||||||
# Timeout, a body that would not parse. Silencing those left a
|
|
||||||
# board that could not reach ESPN with one debug line, and the
|
|
||||||
# ranked filter running on an empty table.
|
|
||||||
if status != 404:
|
|
||||||
self.logger.error(
|
|
||||||
f"Error fetching {endpoint} from ESPN for "
|
|
||||||
f"{sport}/{league}: {e}"
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
# A list or a bare string is not something the callers can
|
|
||||||
# read. Treat it as a miss so the other endpoint still gets a
|
|
||||||
# turn, but say so -- this means ESPN changed shape.
|
|
||||||
self.logger.error(
|
|
||||||
f"Unexpected {endpoint} payload for {sport}/{league}: "
|
|
||||||
f"got {type(data).__name__}, expected an object"
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
if endpoint == "rankings" and not data.get("rankings"):
|
|
||||||
continue
|
|
||||||
self.logger.debug(f"Fetched {endpoint} for {sport}/{league}")
|
|
||||||
return data
|
|
||||||
self.logger.debug(
|
|
||||||
f"Standings/rankings not available for {sport}/{league} from ESPN API"
|
|
||||||
)
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
class MLBAPIDataSource(DataSource):
|
|
||||||
"""MLB API data source."""
|
|
||||||
|
|
||||||
def __init__(self, logger: logging.Logger):
|
|
||||||
super().__init__(logger)
|
|
||||||
self.base_url = "https://statsapi.mlb.com/api/v1"
|
|
||||||
|
|
||||||
def fetch_live_games(self, sport: str, league: str) -> List[Dict]:
|
|
||||||
"""Fetch live games from MLB API."""
|
|
||||||
try:
|
|
||||||
url = f"{self.base_url}/schedule"
|
|
||||||
params = {
|
|
||||||
'sportId': 1, # MLB
|
|
||||||
'date': datetime.now().strftime('%Y-%m-%d'),
|
|
||||||
'hydrate': 'game,team,venue,weather'
|
|
||||||
}
|
|
||||||
|
|
||||||
response = self.session.get(url, headers=self.get_headers(), params=params, timeout=15)
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
data = response.json()
|
|
||||||
games = data.get('dates', [{}])[0].get('games', [])
|
|
||||||
|
|
||||||
# Filter for live games
|
|
||||||
live_games = [game for game in games
|
|
||||||
if game.get('status', {}).get('abstractGameState') == 'Live']
|
|
||||||
|
|
||||||
self.logger.debug(f"Fetched {len(live_games)} live games from MLB API")
|
|
||||||
return live_games
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Error fetching live games from MLB API: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
def fetch_schedule(self, sport: str, league: str, date_range: tuple) -> List[Dict]:
|
|
||||||
"""Fetch schedule from MLB API."""
|
|
||||||
try:
|
|
||||||
start_date, end_date = date_range
|
|
||||||
url = f"{self.base_url}/schedule"
|
|
||||||
|
|
||||||
params = {
|
|
||||||
'sportId': 1, # MLB
|
|
||||||
'startDate': start_date.strftime('%Y-%m-%d'),
|
|
||||||
'endDate': end_date.strftime('%Y-%m-%d'),
|
|
||||||
'hydrate': 'game,team,venue'
|
|
||||||
}
|
|
||||||
|
|
||||||
response = self.session.get(url, headers=self.get_headers(), params=params, timeout=15)
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
data = response.json()
|
|
||||||
all_games = []
|
|
||||||
for date_data in data.get('dates', []):
|
|
||||||
all_games.extend(date_data.get('games', []))
|
|
||||||
|
|
||||||
self.logger.debug(f"Fetched {len(all_games)} scheduled games from MLB API")
|
|
||||||
return all_games
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Error fetching schedule from MLB API: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
def fetch_standings(self, sport: str, league: str) -> Dict:
|
|
||||||
"""Fetch standings from MLB API."""
|
|
||||||
try:
|
|
||||||
url = f"{self.base_url}/standings"
|
|
||||||
params = {
|
|
||||||
'leagueId': 103, # American League
|
|
||||||
'season': datetime.now().year,
|
|
||||||
'standingsType': 'regularSeason'
|
|
||||||
}
|
|
||||||
|
|
||||||
response = self.session.get(url, headers=self.get_headers(), params=params, timeout=15)
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
data = response.json()
|
|
||||||
self.logger.debug("Fetched standings from MLB API")
|
|
||||||
return data
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Error fetching standings from MLB API: {e}")
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
class SoccerAPIDataSource(DataSource):
|
|
||||||
"""Soccer API data source (generic structure)."""
|
|
||||||
|
|
||||||
def __init__(self, logger: logging.Logger, api_key: str = None):
|
|
||||||
super().__init__(logger)
|
|
||||||
self.api_key = api_key
|
|
||||||
self.base_url = "https://api.football-data.org/v4" # Example API
|
|
||||||
|
|
||||||
def get_headers(self) -> Dict[str, str]:
|
|
||||||
"""Get headers with API key for soccer API."""
|
|
||||||
headers = super().get_headers()
|
|
||||||
if self.api_key:
|
|
||||||
headers['X-Auth-Token'] = self.api_key
|
|
||||||
return headers
|
|
||||||
|
|
||||||
def fetch_live_games(self, sport: str, league: str) -> List[Dict]:
|
|
||||||
"""Fetch live games from soccer API."""
|
|
||||||
try:
|
|
||||||
# This would need to be adapted based on the specific soccer API
|
|
||||||
url = f"{self.base_url}/matches"
|
|
||||||
params = {
|
|
||||||
'status': 'LIVE',
|
|
||||||
'competition': league
|
|
||||||
}
|
|
||||||
|
|
||||||
response = self.session.get(url, headers=self.get_headers(), params=params, timeout=15)
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
data = response.json()
|
|
||||||
matches = data.get('matches', [])
|
|
||||||
|
|
||||||
self.logger.debug(f"Fetched {len(matches)} live games from soccer API")
|
|
||||||
return matches
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Error fetching live games from soccer API: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
def fetch_schedule(self, sport: str, league: str, date_range: tuple) -> List[Dict]:
|
|
||||||
"""Fetch schedule from soccer API."""
|
|
||||||
try:
|
|
||||||
start_date, end_date = date_range
|
|
||||||
url = f"{self.base_url}/matches"
|
|
||||||
|
|
||||||
params = {
|
|
||||||
'competition': league,
|
|
||||||
'dateFrom': start_date.strftime('%Y-%m-%d'),
|
|
||||||
'dateTo': end_date.strftime('%Y-%m-%d')
|
|
||||||
}
|
|
||||||
|
|
||||||
response = self.session.get(url, headers=self.get_headers(), params=params, timeout=15)
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
data = response.json()
|
|
||||||
matches = data.get('matches', [])
|
|
||||||
|
|
||||||
self.logger.debug(f"Fetched {len(matches)} scheduled games from soccer API")
|
|
||||||
return matches
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Error fetching schedule from soccer API: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
def fetch_standings(self, sport: str, league: str) -> Dict:
|
|
||||||
"""Fetch standings from soccer API."""
|
|
||||||
try:
|
|
||||||
url = f"{self.base_url}/competitions/{league}/standings"
|
|
||||||
response = self.session.get(url, headers=self.get_headers(), timeout=15)
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
data = response.json()
|
|
||||||
self.logger.debug("Fetched standings from soccer API")
|
|
||||||
return data
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Error fetching standings from soccer API: {e}")
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
# Factory function removed - sport classes now instantiate data sources directly
|
|
||||||
@@ -1,387 +0,0 @@
|
|||||||
from typing import Dict, Any, Optional
|
|
||||||
from src.display_manager import DisplayManager
|
|
||||||
from src.cache_manager import CacheManager
|
|
||||||
import logging
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
|
||||||
from src.base_classes.data_sources import ESPNDataSource
|
|
||||||
from src.base_classes.sports import SportsCore, SportsLive
|
|
||||||
|
|
||||||
class Football(SportsCore):
|
|
||||||
"""Base class for football sports with common functionality."""
|
|
||||||
|
|
||||||
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str):
|
|
||||||
super().__init__(config, display_manager, cache_manager, logger, sport_key)
|
|
||||||
self.data_source = ESPNDataSource(logger)
|
|
||||||
self.sport = "football"
|
|
||||||
|
|
||||||
def _extract_game_details(self, game_event: Dict) -> Optional[Dict]:
|
|
||||||
"""Extract relevant game details from ESPN NCAA FB API response."""
|
|
||||||
details, home_team, away_team, status, situation = self._extract_game_details_common(game_event)
|
|
||||||
if details is None or home_team is None or away_team is None or status is None:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
competition = game_event["competitions"][0]
|
|
||||||
status = competition["status"]
|
|
||||||
|
|
||||||
# --- Football Specific Details (Likely same for NFL/NCAAFB) ---
|
|
||||||
down_distance_text = ""
|
|
||||||
down_distance_text_long = ""
|
|
||||||
possession_indicator = None # Default to None
|
|
||||||
scoring_event = "" # Track scoring events
|
|
||||||
home_timeouts = 0
|
|
||||||
away_timeouts = 0
|
|
||||||
is_redzone = False
|
|
||||||
posession = None
|
|
||||||
|
|
||||||
if situation and status["type"]["state"] == "in":
|
|
||||||
# down = situation.get("down")
|
|
||||||
down_distance_text = situation.get("shortDownDistanceText")
|
|
||||||
down_distance_text_long = situation.get("downDistanceText")
|
|
||||||
# distance = situation.get("distance")
|
|
||||||
|
|
||||||
# Detect scoring events from status detail
|
|
||||||
status_detail = status["type"].get("detail", "").lower()
|
|
||||||
status_short = status["type"].get("shortDetail", "").lower()
|
|
||||||
is_redzone = situation.get("isRedZone")
|
|
||||||
posession = situation.get("possession")
|
|
||||||
|
|
||||||
# Check for scoring events in status text
|
|
||||||
if any(keyword in status_detail for keyword in ["touchdown", "td"]):
|
|
||||||
scoring_event = "TOUCHDOWN"
|
|
||||||
elif any(keyword in status_detail for keyword in ["field goal", "fg"]):
|
|
||||||
scoring_event = "FIELD GOAL"
|
|
||||||
elif any(keyword in status_detail for keyword in ["extra point", "pat", "point after"]):
|
|
||||||
scoring_event = "PAT"
|
|
||||||
elif any(keyword in status_short for keyword in ["touchdown", "td"]):
|
|
||||||
scoring_event = "TOUCHDOWN"
|
|
||||||
elif any(keyword in status_short for keyword in ["field goal", "fg"]):
|
|
||||||
scoring_event = "FIELD GOAL"
|
|
||||||
elif any(keyword in status_short for keyword in ["extra point", "pat"]):
|
|
||||||
scoring_event = "PAT"
|
|
||||||
|
|
||||||
# Determine possession based on team ID
|
|
||||||
possession_team_id = situation.get("possession")
|
|
||||||
if possession_team_id:
|
|
||||||
if possession_team_id == home_team.get("id"):
|
|
||||||
possession_indicator = "home"
|
|
||||||
elif possession_team_id == away_team.get("id"):
|
|
||||||
possession_indicator = "away"
|
|
||||||
|
|
||||||
home_timeouts = situation.get("homeTimeouts", 3) # Default to 3 if not specified
|
|
||||||
away_timeouts = situation.get("awayTimeouts", 3) # Default to 3 if not specified
|
|
||||||
|
|
||||||
|
|
||||||
# Format period/quarter
|
|
||||||
period = status.get("period", 0)
|
|
||||||
period_text = ""
|
|
||||||
if status["type"]["state"] == "in":
|
|
||||||
if period == 0:
|
|
||||||
period_text = "Start" # Before kickoff
|
|
||||||
elif period >= 1 and period <= 4:
|
|
||||||
period_text = f"Q{period}" # OT starts after Q4
|
|
||||||
elif period > 4:
|
|
||||||
period_text = f"OT{period - 4}" # OT starts after Q4
|
|
||||||
elif status["type"]["state"] == "halftime" or status["type"]["name"] == "STATUS_HALFTIME": # Check explicit halftime state
|
|
||||||
period_text = "HALF"
|
|
||||||
elif status["type"]["state"] == "post":
|
|
||||||
if period > 4 : period_text = "Final/OT"
|
|
||||||
else: period_text = "Final"
|
|
||||||
elif status["type"]["state"] == "pre":
|
|
||||||
period_text = details.get("game_time", "") # Show time for upcoming
|
|
||||||
|
|
||||||
details.update({
|
|
||||||
"period": period,
|
|
||||||
"period_text": period_text, # Formatted quarter/status
|
|
||||||
"clock": status.get("displayClock", "0:00"),
|
|
||||||
"home_timeouts": home_timeouts,
|
|
||||||
"away_timeouts": away_timeouts,
|
|
||||||
"down_distance_text": down_distance_text, # Added Down/Distance
|
|
||||||
"down_distance_text_long": down_distance_text_long,
|
|
||||||
"is_redzone": is_redzone,
|
|
||||||
"possession": posession, # ID of team with possession
|
|
||||||
"possession_indicator": possession_indicator, # Added for easy home/away check
|
|
||||||
"scoring_event": scoring_event, # Track scoring events (TOUCHDOWN, FIELD GOAL, PAT)
|
|
||||||
})
|
|
||||||
|
|
||||||
# Basic validation (can be expanded)
|
|
||||||
if not details['home_abbr'] or not details['away_abbr']:
|
|
||||||
self.logger.warning(f"Missing team abbreviation in event: {details['id']}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
self.logger.debug(f"Extracted: {details['away_abbr']}@{details['home_abbr']}, Status: {status['type']['name']}, Live: {details['is_live']}, Final: {details['is_final']}, Upcoming: {details['is_upcoming']}")
|
|
||||||
|
|
||||||
return details
|
|
||||||
except Exception as e:
|
|
||||||
# Log the problematic event structure if possible
|
|
||||||
logging.error(f"Error extracting game details: {e} from event: {game_event.get('id')}", exc_info=True)
|
|
||||||
return None
|
|
||||||
|
|
||||||
class FootballLive(Football, SportsLive):
|
|
||||||
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str):
|
|
||||||
super().__init__(config, display_manager, cache_manager, logger, sport_key)
|
|
||||||
|
|
||||||
def _test_mode_update(self):
|
|
||||||
if self.current_game and self.current_game["is_live"]:
|
|
||||||
try:
|
|
||||||
minutes, seconds = map(int, self.current_game["clock"].split(':'))
|
|
||||||
seconds -= 1
|
|
||||||
if seconds < 0:
|
|
||||||
seconds = 59
|
|
||||||
minutes -= 1
|
|
||||||
if minutes < 0:
|
|
||||||
# Simulate end of quarter/game
|
|
||||||
if self.current_game["period"] < 4: # Q4 is period 4
|
|
||||||
self.current_game["period"] += 1
|
|
||||||
# Update period_text based on new period
|
|
||||||
if self.current_game["period"] == 1: self.current_game["period_text"] = "Q1"
|
|
||||||
elif self.current_game["period"] == 2: self.current_game["period_text"] = "Q2"
|
|
||||||
elif self.current_game["period"] == 3: self.current_game["period_text"] = "Q3"
|
|
||||||
elif self.current_game["period"] == 4: self.current_game["period_text"] = "Q4"
|
|
||||||
# Reset clock for next quarter (e.g., 15:00)
|
|
||||||
minutes, seconds = 15, 0
|
|
||||||
else:
|
|
||||||
# Simulate game end
|
|
||||||
self.current_game["is_live"] = False
|
|
||||||
self.current_game["is_final"] = True
|
|
||||||
self.current_game["period_text"] = "Final"
|
|
||||||
minutes, seconds = 0, 0
|
|
||||||
self.current_game["clock"] = f"{minutes:02d}:{seconds:02d}"
|
|
||||||
# Simulate down change occasionally
|
|
||||||
if seconds % 15 == 0:
|
|
||||||
self.current_game["down_distance_text"] = f"{['1st','2nd','3rd','4th'][seconds % 4]} & {seconds % 10 + 1}"
|
|
||||||
self.current_game["status_text"] = f"{self.current_game['period_text']} {self.current_game['clock']}"
|
|
||||||
|
|
||||||
# Display update handled by main loop or explicit call if needed immediately
|
|
||||||
# self.display(force_clear=True) # Only if immediate update is desired here
|
|
||||||
|
|
||||||
except ValueError:
|
|
||||||
self.logger.warning("Test mode: Could not parse clock") # Changed log prefix
|
|
||||||
# No actual display call here, let main loop handle it
|
|
||||||
|
|
||||||
|
|
||||||
def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None:
|
|
||||||
"""Draw the detailed scorebug layout for a live NCAA FB game.""" # Updated docstring
|
|
||||||
try:
|
|
||||||
main_img = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 255))
|
|
||||||
overlay = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 0))
|
|
||||||
draw_overlay = ImageDraw.Draw(overlay) # Draw text elements on overlay first
|
|
||||||
|
|
||||||
home_logo = self._load_and_resize_logo(game["home_id"], game["home_abbr"], game["home_logo_path"], game.get("home_logo_url"))
|
|
||||||
away_logo = self._load_and_resize_logo(game["away_id"], game["away_abbr"], game["away_logo_path"], game.get("away_logo_url"))
|
|
||||||
|
|
||||||
if not home_logo or not away_logo:
|
|
||||||
self.logger.error(f"Failed to load logos for live game: {game.get('id')}") # Changed log prefix
|
|
||||||
# Draw placeholder text if logos fail
|
|
||||||
draw_final = ImageDraw.Draw(main_img.convert('RGB'))
|
|
||||||
self._draw_text_with_outline(draw_final, "Logo Error", (5,5), self.fonts['status'])
|
|
||||||
self.display_manager.image.paste(main_img.convert('RGB'), (0, 0))
|
|
||||||
self.display_manager.update_display()
|
|
||||||
return
|
|
||||||
|
|
||||||
center_y = self.display_height // 2
|
|
||||||
|
|
||||||
# Draw logos (shifted slightly more inward than NHL perhaps)
|
|
||||||
home_x = self.display_width - home_logo.width + 10 #adjusted from 18 # Adjust position as needed
|
|
||||||
home_y = center_y - (home_logo.height // 2)
|
|
||||||
main_img.paste(home_logo, (home_x, home_y), home_logo)
|
|
||||||
|
|
||||||
away_x = -10 #adjusted from 18 # Adjust position as needed
|
|
||||||
away_y = center_y - (away_logo.height // 2)
|
|
||||||
main_img.paste(away_logo, (away_x, away_y), away_logo)
|
|
||||||
|
|
||||||
# --- Draw Text Elements on Overlay ---
|
|
||||||
# Note: Rankings are now handled in the records/rankings section below
|
|
||||||
|
|
||||||
# Scores (centered, slightly above bottom)
|
|
||||||
home_score = str(game.get("home_score", "0"))
|
|
||||||
away_score = str(game.get("away_score", "0"))
|
|
||||||
score_text = f"{away_score}-{home_score}"
|
|
||||||
score_width = draw_overlay.textlength(score_text, font=self.fonts['score'])
|
|
||||||
score_x = (self.display_width - score_width) // 2
|
|
||||||
score_y = (self.display_height // 2) - 3 #centered #from 14 # Position score higher
|
|
||||||
self._draw_text_with_outline(draw_overlay, score_text, (score_x, score_y), self.fonts['score'])
|
|
||||||
|
|
||||||
# Period/Quarter and Clock (Top center)
|
|
||||||
period_clock_text = f"{game.get('period_text', '')} {game.get('clock', '')}".strip()
|
|
||||||
if game.get("is_halftime"): \
|
|
||||||
period_clock_text = "Halftime" # Override for halftime
|
|
||||||
elif game.get("is_period_break"):
|
|
||||||
period_clock_text = game.get("status_text", "Period Break")
|
|
||||||
|
|
||||||
status_width = draw_overlay.textlength(period_clock_text, font=self.fonts['time'])
|
|
||||||
status_x = (self.display_width - status_width) // 2
|
|
||||||
status_y = 1 # Position at top
|
|
||||||
self._draw_text_with_outline(draw_overlay, period_clock_text, (status_x, status_y), self.fonts['time'])
|
|
||||||
|
|
||||||
# Down & Distance or Scoring Event (Below Period/Clock)
|
|
||||||
scoring_event = game.get("scoring_event", "")
|
|
||||||
down_distance = game.get("down_distance_text", "")
|
|
||||||
if self.display_width > 128:
|
|
||||||
down_distance = game.get("down_distance_text_long", "")
|
|
||||||
|
|
||||||
# Show scoring event if detected, otherwise show down & distance
|
|
||||||
if scoring_event and game.get("is_live"):
|
|
||||||
# Display scoring event with special formatting
|
|
||||||
event_width = draw_overlay.textlength(scoring_event, font=self.fonts['detail'])
|
|
||||||
event_x = (self.display_width - event_width) // 2
|
|
||||||
event_y = (self.display_height) - 7
|
|
||||||
|
|
||||||
# Color coding for different scoring events
|
|
||||||
if scoring_event == "TOUCHDOWN":
|
|
||||||
event_color = (255, 215, 0) # Gold
|
|
||||||
elif scoring_event == "FIELD GOAL":
|
|
||||||
event_color = (0, 255, 0) # Green
|
|
||||||
elif scoring_event == "PAT":
|
|
||||||
event_color = (255, 165, 0) # Orange
|
|
||||||
else:
|
|
||||||
event_color = (255, 255, 255) # White
|
|
||||||
|
|
||||||
self._draw_text_with_outline(draw_overlay, scoring_event, (event_x, event_y), self.fonts['detail'], fill=event_color)
|
|
||||||
elif down_distance and game.get("is_live"): # Only show if live and available
|
|
||||||
dd_width = draw_overlay.textlength(down_distance, font=self.fonts['detail'])
|
|
||||||
dd_x = (self.display_width - dd_width) // 2
|
|
||||||
dd_y = (self.display_height)- 7 # Top of D&D text
|
|
||||||
down_color = (200, 200, 0) if not game.get("is_redzone", False) else (255,0,0) # Yellowish text
|
|
||||||
self._draw_text_with_outline(draw_overlay, down_distance, (dd_x, dd_y), self.fonts['detail'], fill=down_color)
|
|
||||||
|
|
||||||
# Possession Indicator (small football icon)
|
|
||||||
possession = game.get("possession_indicator")
|
|
||||||
if possession: # Only draw if possession is known
|
|
||||||
ball_radius_x = 3 # Wider for football shape
|
|
||||||
ball_radius_y = 2 # Shorter for football shape
|
|
||||||
ball_color = (139, 69, 19) # Brown color for the football
|
|
||||||
lace_color = (255, 255, 255) # White for laces
|
|
||||||
|
|
||||||
# Approximate height of the detail font (4x6 font at size 6 is roughly 6px tall)
|
|
||||||
detail_font_height_approx = 6
|
|
||||||
ball_y_center = dd_y + (detail_font_height_approx // 2) # Center ball vertically with D&D text
|
|
||||||
|
|
||||||
possession_ball_padding = 3 # Pixels between D&D text and ball
|
|
||||||
|
|
||||||
if possession == "away":
|
|
||||||
# Position ball to the left of D&D text
|
|
||||||
ball_x_center = dd_x - possession_ball_padding - ball_radius_x
|
|
||||||
elif possession == "home":
|
|
||||||
# Position ball to the right of D&D text
|
|
||||||
ball_x_center = dd_x + dd_width + possession_ball_padding + ball_radius_x
|
|
||||||
else:
|
|
||||||
ball_x_center = 0 # Should not happen / no indicator
|
|
||||||
|
|
||||||
if ball_x_center > 0: # Draw if position is valid
|
|
||||||
# Draw the football shape (ellipse)
|
|
||||||
draw_overlay.ellipse(
|
|
||||||
(ball_x_center - ball_radius_x, ball_y_center - ball_radius_y, # x0, y0
|
|
||||||
ball_x_center + ball_radius_x, ball_y_center + ball_radius_y), # x1, y1
|
|
||||||
fill=ball_color, outline=(0,0,0)
|
|
||||||
)
|
|
||||||
# Draw a simple horizontal lace
|
|
||||||
draw_overlay.line(
|
|
||||||
(ball_x_center - 1, ball_y_center, ball_x_center + 1, ball_y_center),
|
|
||||||
fill=lace_color, width=1
|
|
||||||
)
|
|
||||||
|
|
||||||
# Timeouts (Bottom corners) - 3 small bars per team
|
|
||||||
timeout_bar_width = 4
|
|
||||||
timeout_bar_height = 2
|
|
||||||
timeout_spacing = 1
|
|
||||||
timeout_y = self.display_height - timeout_bar_height - 1 # Bottom edge
|
|
||||||
|
|
||||||
# Away Timeouts (Bottom Left)
|
|
||||||
away_timeouts_remaining = game.get("away_timeouts", 0)
|
|
||||||
for i in range(3):
|
|
||||||
to_x = 2 + i * (timeout_bar_width + timeout_spacing)
|
|
||||||
color = (255, 255, 255) if i < away_timeouts_remaining else (80, 80, 80) # White if available, gray if used
|
|
||||||
draw_overlay.rectangle([to_x, timeout_y, to_x + timeout_bar_width, timeout_y + timeout_bar_height], fill=color, outline=(0,0,0))
|
|
||||||
|
|
||||||
# Home Timeouts (Bottom Right)
|
|
||||||
home_timeouts_remaining = game.get("home_timeouts", 0)
|
|
||||||
for i in range(3):
|
|
||||||
to_x = self.display_width - 2 - timeout_bar_width - (2-i) * (timeout_bar_width + timeout_spacing)
|
|
||||||
color = (255, 255, 255) if i < home_timeouts_remaining else (80, 80, 80) # White if available, gray if used
|
|
||||||
draw_overlay.rectangle([to_x, timeout_y, to_x + timeout_bar_width, timeout_y + timeout_bar_height], fill=color, outline=(0,0,0))
|
|
||||||
|
|
||||||
# Draw odds if available
|
|
||||||
if 'odds' in game and game['odds']:
|
|
||||||
self._draw_dynamic_odds(draw_overlay, game['odds'], self.display_width, self.display_height)
|
|
||||||
|
|
||||||
# Draw records or rankings if enabled
|
|
||||||
if self.show_records or self.show_ranking:
|
|
||||||
record_font = self.fonts.get('detail', ImageFont.load_default())
|
|
||||||
|
|
||||||
# Get team abbreviations
|
|
||||||
away_abbr = game.get('away_abbr', '')
|
|
||||||
home_abbr = game.get('home_abbr', '')
|
|
||||||
|
|
||||||
record_bbox = draw_overlay.textbbox((0,0), "0-0", font=record_font)
|
|
||||||
record_height = record_bbox[3] - record_bbox[1]
|
|
||||||
record_y = self.display_height - record_height - 4
|
|
||||||
self.logger.debug(f"Record positioning: height={record_height}, record_y={record_y}, display_height={self.display_height}")
|
|
||||||
|
|
||||||
# Display away team info
|
|
||||||
if away_abbr:
|
|
||||||
if self.show_ranking and self.show_records:
|
|
||||||
# When both rankings and records are enabled, rankings replace records completely
|
|
||||||
away_rank = self._team_rankings_cache.get(away_abbr, 0)
|
|
||||||
if away_rank > 0:
|
|
||||||
away_text = f"#{away_rank}"
|
|
||||||
else:
|
|
||||||
# Show nothing for unranked teams when rankings are prioritized
|
|
||||||
away_text = ''
|
|
||||||
elif self.show_ranking:
|
|
||||||
# Show ranking only if available
|
|
||||||
away_rank = self._team_rankings_cache.get(away_abbr, 0)
|
|
||||||
if away_rank > 0:
|
|
||||||
away_text = f"#{away_rank}"
|
|
||||||
else:
|
|
||||||
away_text = ''
|
|
||||||
elif self.show_records:
|
|
||||||
# Show record only when rankings are disabled
|
|
||||||
away_text = game.get('away_record', '')
|
|
||||||
else:
|
|
||||||
away_text = ''
|
|
||||||
|
|
||||||
if away_text:
|
|
||||||
away_record_x = 3
|
|
||||||
self.logger.debug(f"Drawing away ranking '{away_text}' at ({away_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}")
|
|
||||||
self._draw_text_with_outline(draw_overlay, away_text, (away_record_x, record_y), record_font)
|
|
||||||
|
|
||||||
# Display home team info
|
|
||||||
if home_abbr:
|
|
||||||
if self.show_ranking and self.show_records:
|
|
||||||
# When both rankings and records are enabled, rankings replace records completely
|
|
||||||
home_rank = self._team_rankings_cache.get(home_abbr, 0)
|
|
||||||
if home_rank > 0:
|
|
||||||
home_text = f"#{home_rank}"
|
|
||||||
else:
|
|
||||||
# Show nothing for unranked teams when rankings are prioritized
|
|
||||||
home_text = ''
|
|
||||||
elif self.show_ranking:
|
|
||||||
# Show ranking only if available
|
|
||||||
home_rank = self._team_rankings_cache.get(home_abbr, 0)
|
|
||||||
if home_rank > 0:
|
|
||||||
home_text = f"#{home_rank}"
|
|
||||||
else:
|
|
||||||
home_text = ''
|
|
||||||
elif self.show_records:
|
|
||||||
# Show record only when rankings are disabled
|
|
||||||
home_text = game.get('home_record', '')
|
|
||||||
else:
|
|
||||||
home_text = ''
|
|
||||||
|
|
||||||
if home_text:
|
|
||||||
home_record_bbox = draw_overlay.textbbox((0,0), home_text, font=record_font)
|
|
||||||
home_record_width = home_record_bbox[2] - home_record_bbox[0]
|
|
||||||
home_record_x = self.display_width - home_record_width - 3
|
|
||||||
self.logger.debug(f"Drawing home ranking '{home_text}' at ({home_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}")
|
|
||||||
self._draw_text_with_outline(draw_overlay, home_text, (home_record_x, record_y), record_font)
|
|
||||||
|
|
||||||
# Composite the text overlay onto the main image
|
|
||||||
main_img = Image.alpha_composite(main_img, overlay)
|
|
||||||
main_img = main_img.convert('RGB') # Convert for display
|
|
||||||
|
|
||||||
# Display the final image
|
|
||||||
self.display_manager.image.paste(main_img, (0, 0))
|
|
||||||
self.display_manager.update_display() # Update display here for live
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Error displaying live Football game: {e}", exc_info=True) # Changed log prefix
|
|
||||||
@@ -1,380 +0,0 @@
|
|||||||
import logging
|
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
|
||||||
|
|
||||||
from src.base_classes.data_sources import ESPNDataSource
|
|
||||||
from src.base_classes.sports import SportsCore, SportsLive
|
|
||||||
from src.cache_manager import CacheManager
|
|
||||||
from src.display_manager import DisplayManager
|
|
||||||
|
|
||||||
|
|
||||||
class Hockey(SportsCore):
|
|
||||||
"""Base class for hockey sports with common functionality."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
config: Dict[str, Any],
|
|
||||||
display_manager: DisplayManager,
|
|
||||||
cache_manager: CacheManager,
|
|
||||||
logger: logging.Logger,
|
|
||||||
sport_key: str,
|
|
||||||
):
|
|
||||||
super().__init__(config, display_manager, cache_manager, logger, sport_key)
|
|
||||||
self.data_source = ESPNDataSource(logger)
|
|
||||||
self.sport = "hockey"
|
|
||||||
self.show_shots_on_goal = self.mode_config.get("show_shots_on_goal", False)
|
|
||||||
|
|
||||||
def _extract_game_details(self, game_event: Dict) -> Optional[Dict]:
|
|
||||||
"""Extract relevant game details from ESPN NCAA FB API response."""
|
|
||||||
# --- THIS METHOD MAY NEED ADJUSTMENTS FOR NCAA FB API DIFFERENCES ---
|
|
||||||
details, home_team, away_team, status, situation = (
|
|
||||||
self._extract_game_details_common(game_event)
|
|
||||||
)
|
|
||||||
if details is None or home_team is None or away_team is None or status is None:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
competition = game_event["competitions"][0]
|
|
||||||
status = competition["status"]
|
|
||||||
powerplay = False
|
|
||||||
penalties = ""
|
|
||||||
# A competitor may legitimately arrive without a "statistics"
|
|
||||||
# array (pre-game feeds, and some in-progress ones). Reading it
|
|
||||||
# unguarded raised KeyError inside the generator and dropped the
|
|
||||||
# WHOLE event, discarding valid scores and status. Default to an
|
|
||||||
# empty list so the saves/shots figures fall back to 0 instead.
|
|
||||||
home_stats = home_team.get("statistics", [])
|
|
||||||
away_stats = away_team.get("statistics", [])
|
|
||||||
home_team_saves = next(
|
|
||||||
(
|
|
||||||
int(c["displayValue"])
|
|
||||||
for c in home_stats
|
|
||||||
if c.get("name") == "saves"
|
|
||||||
),
|
|
||||||
0,
|
|
||||||
)
|
|
||||||
home_team_saves_per = next(
|
|
||||||
(
|
|
||||||
float(c["displayValue"])
|
|
||||||
for c in home_stats
|
|
||||||
if c.get("name") == "savePct"
|
|
||||||
),
|
|
||||||
0.0,
|
|
||||||
)
|
|
||||||
away_team_saves = next(
|
|
||||||
(
|
|
||||||
int(c["displayValue"])
|
|
||||||
for c in away_stats
|
|
||||||
if c.get("name") == "saves"
|
|
||||||
),
|
|
||||||
0,
|
|
||||||
)
|
|
||||||
away_team_saves_per = next(
|
|
||||||
(
|
|
||||||
float(c["displayValue"])
|
|
||||||
for c in away_stats
|
|
||||||
if c.get("name") == "savePct"
|
|
||||||
),
|
|
||||||
0.0,
|
|
||||||
)
|
|
||||||
|
|
||||||
home_shots = 0
|
|
||||||
away_shots = 0
|
|
||||||
if home_team_saves_per > 0:
|
|
||||||
away_shots = round(home_team_saves / home_team_saves_per)
|
|
||||||
if away_team_saves_per > 0:
|
|
||||||
home_shots = round(away_team_saves / away_team_saves_per)
|
|
||||||
if situation and status["type"]["state"] == "in":
|
|
||||||
# Detect scoring events from status detail
|
|
||||||
# status_detail = status["type"].get("detail", "")
|
|
||||||
powerplay = situation.get("isPowerPlay", False)
|
|
||||||
penalties = situation.get("penalties", "")
|
|
||||||
|
|
||||||
# Format period/quarter
|
|
||||||
period = status.get("period", 0)
|
|
||||||
period_text = ""
|
|
||||||
if status["type"]["state"] == "in":
|
|
||||||
if period == 0:
|
|
||||||
period_text = "Start" # Before kickoff
|
|
||||||
elif period >= 1 and period <= 3:
|
|
||||||
period_text = f"P{period}" # OT starts after Q4
|
|
||||||
elif period > 3:
|
|
||||||
period_text = f"OT{period - 3}" # OT starts after Q4
|
|
||||||
elif status["type"]["state"] == "post":
|
|
||||||
if period > 3:
|
|
||||||
period_text = "Final/OT"
|
|
||||||
else:
|
|
||||||
period_text = "Final"
|
|
||||||
elif status["type"]["state"] == "pre":
|
|
||||||
period_text = details.get("game_time", "") # Show time for upcoming
|
|
||||||
|
|
||||||
details.update(
|
|
||||||
{
|
|
||||||
"period": period,
|
|
||||||
"period_text": period_text, # Formatted quarter/status
|
|
||||||
"clock": status.get("displayClock", "0:00"),
|
|
||||||
"power_play": powerplay,
|
|
||||||
"penalties": penalties,
|
|
||||||
"home_shots": home_shots,
|
|
||||||
"away_shots": away_shots,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Basic validation (can be expanded)
|
|
||||||
if not details["home_abbr"] or not details["away_abbr"]:
|
|
||||||
self.logger.warning(
|
|
||||||
f"Missing team abbreviation in event: {details['id']}"
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
self.logger.debug(
|
|
||||||
f"Extracted: {details['away_abbr']}@{details['home_abbr']}, Status: {status['type']['name']}, Live: {details['is_live']}, Final: {details['is_final']}, Upcoming: {details['is_upcoming']}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return details
|
|
||||||
except Exception as e:
|
|
||||||
# Log the problematic event structure if possible
|
|
||||||
self.logger.error(
|
|
||||||
f"Error extracting game details: {e} from event: {game_event.get('id')}",
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class HockeyLive(Hockey, SportsLive):
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
config: Dict[str, Any],
|
|
||||||
display_manager: DisplayManager,
|
|
||||||
cache_manager: CacheManager,
|
|
||||||
logger: logging.Logger,
|
|
||||||
sport_key: str,
|
|
||||||
):
|
|
||||||
super().__init__(config, display_manager, cache_manager, logger, sport_key)
|
|
||||||
|
|
||||||
def _test_mode_update(self):
|
|
||||||
if self.current_game and self.current_game["is_live"]:
|
|
||||||
# For testing, we'll just update the clock to show it's working
|
|
||||||
minutes = int(self.current_game["clock"].split(":")[0])
|
|
||||||
seconds = int(self.current_game["clock"].split(":")[1])
|
|
||||||
seconds -= 1
|
|
||||||
if seconds < 0:
|
|
||||||
seconds = 59
|
|
||||||
minutes -= 1
|
|
||||||
if minutes < 0:
|
|
||||||
minutes = 19
|
|
||||||
if self.current_game["period"] < 3:
|
|
||||||
self.current_game["period"] += 1
|
|
||||||
else:
|
|
||||||
self.current_game["period"] = 1
|
|
||||||
self.current_game["clock"] = f"{minutes:02d}:{seconds:02d}"
|
|
||||||
# Always update display in test mode
|
|
||||||
|
|
||||||
def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None:
|
|
||||||
"""Draw the detailed scorebug layout for a live NCAA FB game.""" # Updated docstring
|
|
||||||
try:
|
|
||||||
main_img = Image.new(
|
|
||||||
"RGBA", (self.display_width, self.display_height), (0, 0, 0, 255)
|
|
||||||
)
|
|
||||||
overlay = Image.new(
|
|
||||||
"RGBA", (self.display_width, self.display_height), (0, 0, 0, 0)
|
|
||||||
)
|
|
||||||
draw_overlay = ImageDraw.Draw(
|
|
||||||
overlay
|
|
||||||
) # Draw text elements on overlay first
|
|
||||||
home_logo = self._load_and_resize_logo(
|
|
||||||
game["home_id"],
|
|
||||||
game["home_abbr"],
|
|
||||||
game["home_logo_path"],
|
|
||||||
game.get("home_logo_url"),
|
|
||||||
)
|
|
||||||
away_logo = self._load_and_resize_logo(
|
|
||||||
game["away_id"],
|
|
||||||
game["away_abbr"],
|
|
||||||
game["away_logo_path"],
|
|
||||||
game.get("away_logo_url"),
|
|
||||||
)
|
|
||||||
|
|
||||||
if not home_logo or not away_logo:
|
|
||||||
self.logger.error(
|
|
||||||
f"Failed to load logos for live game: {game.get('id')}"
|
|
||||||
) # Changed log prefix
|
|
||||||
# Draw placeholder text if logos fail
|
|
||||||
draw_final = ImageDraw.Draw(main_img.convert("RGB"))
|
|
||||||
self._draw_text_with_outline(
|
|
||||||
draw_final, "Logo Error", (5, 5), self.fonts["status"]
|
|
||||||
)
|
|
||||||
self.display_manager.image.paste(main_img.convert("RGB"), (0, 0))
|
|
||||||
self.display_manager.update_display()
|
|
||||||
return
|
|
||||||
|
|
||||||
center_y = self.display_height // 2
|
|
||||||
|
|
||||||
# Draw logos (shifted slightly more inward than NHL perhaps)
|
|
||||||
home_x = (
|
|
||||||
self.display_width - home_logo.width + 10
|
|
||||||
) # adjusted from 18 # Adjust position as needed
|
|
||||||
home_y = center_y - (home_logo.height // 2)
|
|
||||||
main_img.paste(home_logo, (home_x, home_y), home_logo)
|
|
||||||
|
|
||||||
away_x = -10 # adjusted from 18 # Adjust position as needed
|
|
||||||
away_y = center_y - (away_logo.height // 2)
|
|
||||||
main_img.paste(away_logo, (away_x, away_y), away_logo)
|
|
||||||
|
|
||||||
# --- Draw Text Elements on Overlay ---
|
|
||||||
# Note: Rankings are now handled in the records/rankings section below
|
|
||||||
|
|
||||||
# Period/Quarter and Clock (Top center)
|
|
||||||
period_clock_text = (
|
|
||||||
f"{game.get('period_text', '')} {game.get('clock', '')}".strip()
|
|
||||||
)
|
|
||||||
if game.get("is_period_break"):
|
|
||||||
period_clock_text = game.get("status_text", "Period Break")
|
|
||||||
|
|
||||||
status_width = draw_overlay.textlength(
|
|
||||||
period_clock_text, font=self.fonts["time"]
|
|
||||||
)
|
|
||||||
status_x = (self.display_width - status_width) // 2
|
|
||||||
status_y = 1 # Position at top
|
|
||||||
self._draw_text_with_outline(
|
|
||||||
draw_overlay,
|
|
||||||
period_clock_text,
|
|
||||||
(status_x, status_y),
|
|
||||||
self.fonts["time"],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Scores (centered, slightly above bottom)
|
|
||||||
home_score = str(game.get("home_score", "0"))
|
|
||||||
away_score = str(game.get("away_score", "0"))
|
|
||||||
score_text = f"{away_score}-{home_score}"
|
|
||||||
score_width = draw_overlay.textlength(score_text, font=self.fonts["score"])
|
|
||||||
score_x = (self.display_width - score_width) // 2
|
|
||||||
score_y = (
|
|
||||||
self.display_height // 2
|
|
||||||
) - 3 # centered #from 14 # Position score higher
|
|
||||||
self._draw_text_with_outline(
|
|
||||||
draw_overlay, score_text, (score_x, score_y), self.fonts["score"]
|
|
||||||
)
|
|
||||||
|
|
||||||
# Shots on Goal
|
|
||||||
if self.show_shots_on_goal:
|
|
||||||
shots_font = self.fonts.get('detail', ImageFont.load_default())
|
|
||||||
home_shots = str(game.get("home_shots", "0"))
|
|
||||||
away_shots = str(game.get("away_shots", "0"))
|
|
||||||
shots_text = f"{away_shots} SHOTS {home_shots}"
|
|
||||||
shots_bbox = draw_overlay.textbbox((0, 0), shots_text, font=shots_font)
|
|
||||||
shots_height = shots_bbox[3] - shots_bbox[1]
|
|
||||||
shots_y = self.display_height - shots_height - 1
|
|
||||||
shots_width = draw_overlay.textlength(shots_text, font=shots_font)
|
|
||||||
shots_x = (self.display_width - shots_width) // 2
|
|
||||||
self._draw_text_with_outline(
|
|
||||||
draw_overlay, shots_text, (shots_x, shots_y), shots_font
|
|
||||||
)
|
|
||||||
|
|
||||||
# Draw odds if available
|
|
||||||
if "odds" in game and game["odds"]:
|
|
||||||
self._draw_dynamic_odds(
|
|
||||||
draw_overlay, game["odds"], self.display_width, self.display_height
|
|
||||||
)
|
|
||||||
|
|
||||||
# Draw records or rankings if enabled
|
|
||||||
if self.show_records or self.show_ranking:
|
|
||||||
record_font = self.fonts.get('detail', ImageFont.load_default())
|
|
||||||
|
|
||||||
# Get team abbreviations
|
|
||||||
away_abbr = game.get("away_abbr", "")
|
|
||||||
home_abbr = game.get("home_abbr", "")
|
|
||||||
|
|
||||||
record_bbox = draw_overlay.textbbox((0, 0), "0-0", font=record_font)
|
|
||||||
record_height = record_bbox[3] - record_bbox[1]
|
|
||||||
record_y = self.display_height - record_height - 1
|
|
||||||
self.logger.debug(
|
|
||||||
f"Record positioning: height={record_height}, record_y={record_y}, display_height={self.display_height}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Display away team info
|
|
||||||
if away_abbr:
|
|
||||||
if self.show_ranking and self.show_records:
|
|
||||||
# When both rankings and records are enabled, rankings replace records completely
|
|
||||||
away_rank = self._team_rankings_cache.get(away_abbr, 0)
|
|
||||||
if away_rank > 0:
|
|
||||||
away_text = f"#{away_rank}"
|
|
||||||
else:
|
|
||||||
# Show nothing for unranked teams when rankings are prioritized
|
|
||||||
away_text = ""
|
|
||||||
elif self.show_ranking:
|
|
||||||
# Show ranking only if available
|
|
||||||
away_rank = self._team_rankings_cache.get(away_abbr, 0)
|
|
||||||
if away_rank > 0:
|
|
||||||
away_text = f"#{away_rank}"
|
|
||||||
else:
|
|
||||||
away_text = ""
|
|
||||||
elif self.show_records:
|
|
||||||
# Show record only when rankings are disabled
|
|
||||||
away_text = game.get("away_record", "")
|
|
||||||
else:
|
|
||||||
away_text = ""
|
|
||||||
|
|
||||||
if away_text:
|
|
||||||
away_record_x = 3
|
|
||||||
self.logger.debug(
|
|
||||||
f"Drawing away ranking '{away_text}' at ({away_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}"
|
|
||||||
)
|
|
||||||
self._draw_text_with_outline(
|
|
||||||
draw_overlay,
|
|
||||||
away_text,
|
|
||||||
(away_record_x, record_y),
|
|
||||||
record_font,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Display home team info
|
|
||||||
if home_abbr:
|
|
||||||
if self.show_ranking and self.show_records:
|
|
||||||
# When both rankings and records are enabled, rankings replace records completely
|
|
||||||
home_rank = self._team_rankings_cache.get(home_abbr, 0)
|
|
||||||
if home_rank > 0:
|
|
||||||
home_text = f"#{home_rank}"
|
|
||||||
else:
|
|
||||||
# Show nothing for unranked teams when rankings are prioritized
|
|
||||||
home_text = ""
|
|
||||||
elif self.show_ranking:
|
|
||||||
# Show ranking only if available
|
|
||||||
home_rank = self._team_rankings_cache.get(home_abbr, 0)
|
|
||||||
if home_rank > 0:
|
|
||||||
home_text = f"#{home_rank}"
|
|
||||||
else:
|
|
||||||
home_text = ""
|
|
||||||
elif self.show_records:
|
|
||||||
# Show record only when rankings are disabled
|
|
||||||
home_text = game.get("home_record", "")
|
|
||||||
else:
|
|
||||||
home_text = ""
|
|
||||||
|
|
||||||
if home_text:
|
|
||||||
home_record_bbox = draw_overlay.textbbox(
|
|
||||||
(0, 0), home_text, font=record_font
|
|
||||||
)
|
|
||||||
home_record_width = home_record_bbox[2] - home_record_bbox[0]
|
|
||||||
home_record_x = self.display_width - home_record_width - 3
|
|
||||||
self.logger.debug(
|
|
||||||
f"Drawing home ranking '{home_text}' at ({home_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}"
|
|
||||||
)
|
|
||||||
self._draw_text_with_outline(
|
|
||||||
draw_overlay,
|
|
||||||
home_text,
|
|
||||||
(home_record_x, record_y),
|
|
||||||
record_font,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Composite the text overlay onto the main image
|
|
||||||
main_img = Image.alpha_composite(main_img, overlay)
|
|
||||||
main_img = main_img.convert("RGB") # Convert for display
|
|
||||||
|
|
||||||
# Display the final image
|
|
||||||
self.display_manager.image.paste(main_img, (0, 0))
|
|
||||||
self.display_manager.update_display() # Update display here for live
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(
|
|
||||||
f"Error displaying live Hockey game: {e}", exc_info=True
|
|
||||||
) # Changed log prefix
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
"""Sports scoreboard base classes.
|
|
||||||
|
|
||||||
Formerly the single module ``src/base_classes/sports.py``; now a package so
|
|
||||||
capabilities can be composed instead of accumulating in one class. See
|
|
||||||
docs/SPORTS_UNIFICATION.md for the architecture. The import path is
|
|
||||||
unchanged: ``from src.base_classes.sports import SportsCore`` still works.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from .core import SportsCore
|
|
||||||
from .modes import SportsLive, SportsRecent, SportsUpcoming
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"SportsCore",
|
|
||||||
"SportsUpcoming",
|
|
||||||
"SportsRecent",
|
|
||||||
"SportsLive",
|
|
||||||
]
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
"""Opt-in capabilities for the sports scoreboards.
|
|
||||||
|
|
||||||
Each module here is a feature that only *some* sports want. They are composed
|
|
||||||
by inheritance (mixins) or selected by name (strategies) — never enabled by an
|
|
||||||
``if self.<feature>_enabled:`` branch inside the base classes.
|
|
||||||
|
|
||||||
The distinction matters: hockey has no celebrations, so ``HockeyLive`` does not
|
|
||||||
inherit :class:`~.celebrations.CelebrationMixin` and the celebration code is not
|
|
||||||
in hockey's MRO at all. A bug in it cannot reach a plugin that never opted in.
|
|
||||||
|
|
||||||
See ``docs/SPORTS_UNIFICATION.md`` for the full rationale.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from .celebrations import CelebrationMixin
|
|
||||||
from .rotation import (
|
|
||||||
RotationStrategy,
|
|
||||||
SimpleRotation,
|
|
||||||
SmoothWeightedRotation,
|
|
||||||
WeightedCycleRotation,
|
|
||||||
get_rotation_strategy,
|
|
||||||
register_rotation_strategy,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"CelebrationMixin",
|
|
||||||
"RotationStrategy",
|
|
||||||
"SimpleRotation",
|
|
||||||
"SmoothWeightedRotation",
|
|
||||||
"WeightedCycleRotation",
|
|
||||||
"get_rotation_strategy",
|
|
||||||
"register_rotation_strategy",
|
|
||||||
]
|
|
||||||
@@ -1,418 +0,0 @@
|
|||||||
"""Score / win celebration takeover — an opt-in capability.
|
|
||||||
|
|
||||||
Four of the nine scoreboards celebrate (afl, nrl, soccer, football); the other
|
|
||||||
five do not. This is a **mixin** rather than a flag inside ``SportsLive`` so the
|
|
||||||
five that do not opt in have none of this code in their MRO: a bug here cannot
|
|
||||||
reach hockey, and hockey's config never grows keys it ignores.
|
|
||||||
|
|
||||||
Usage — mix in *before* the mode class so its ``display`` runs first::
|
|
||||||
|
|
||||||
class SoccerLive(CelebrationMixin, SportsLive):
|
|
||||||
def score_phrase(self, points, team_abbr):
|
|
||||||
return secrets.choice(("GOOOOAAALLL!", f"{team_abbr} SCORES!"))
|
|
||||||
|
|
||||||
The two lineages spelled this differently (``_check_for_goal`` /
|
|
||||||
``celebrate_opponent_goals`` in the soccer lineage, ``_check_for_score`` /
|
|
||||||
``celebrate_opponent_scores`` in football) but the bodies were identical apart
|
|
||||||
from three things, each of which is a seam here rather than a branch:
|
|
||||||
|
|
||||||
* **wording** — :meth:`score_phrase`, the hook football uses to say "TOUCHDOWN"
|
|
||||||
from the points delta and soccer uses to say "GOOOOAAALLL";
|
|
||||||
* **follow-up suppression** — :attr:`COALESCE_SCORING_SEQUENCE`, on for football
|
|
||||||
where a touchdown lands as +6 then +1 a few seconds later, off elsewhere where
|
|
||||||
two quick goals are two real events;
|
|
||||||
* **team identity** — matching goes through ``_favorite_key``, so nrl can match
|
|
||||||
on team id (its abbreviations are ambiguous) without core knowing why.
|
|
||||||
|
|
||||||
The config keys are read under both spellings, so a plugin adopting the mixin
|
|
||||||
keeps working with the ``*_goals`` keys already in its published schema.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
import time
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
|
|
||||||
from PIL import Image, ImageDraw
|
|
||||||
|
|
||||||
|
|
||||||
class CelebrationMixin:
|
|
||||||
"""Full-screen takeover when a tracked team scores or wins."""
|
|
||||||
|
|
||||||
#: Collapse increments that land while a celebration is already on screen
|
|
||||||
#: into that one celebration. True for sports where a single scoring play
|
|
||||||
#: arrives as more than one score update (football: touchdown +6, then the
|
|
||||||
#: extra point +1). False where consecutive increments are distinct events —
|
|
||||||
#: suppressing there would swallow a real goal.
|
|
||||||
COALESCE_SCORING_SEQUENCE = False
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
mode_config = getattr(self, "mode_config", {}) or {}
|
|
||||||
self.celebration_enabled = mode_config.get("celebration_enabled", True)
|
|
||||||
# Coerced and floored at init: this value is compared numerically on the
|
|
||||||
# display path, where a string from a hand-edited config would raise
|
|
||||||
# TypeError outside any try block, and a zero or negative value would
|
|
||||||
# arm a celebration that can never render.
|
|
||||||
raw_duration = mode_config.get("celebration_duration", 8)
|
|
||||||
try:
|
|
||||||
self.celebration_duration = max(1.0, float(raw_duration))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
self.logger.warning(
|
|
||||||
"[Celebrations] Unusable celebration_duration %r; using 8s. "
|
|
||||||
"Set a positive number of seconds.",
|
|
||||||
raw_duration,
|
|
||||||
)
|
|
||||||
self.celebration_duration = 8.0
|
|
||||||
# Both spellings: the soccer lineage ships `celebrate_opponent_goals`,
|
|
||||||
# football ships `celebrate_opponent_scores`. Whichever the plugin's
|
|
||||||
# schema declares is the one its users have set.
|
|
||||||
self.celebrate_opponent_scores = mode_config.get(
|
|
||||||
"celebrate_opponent_scores",
|
|
||||||
mode_config.get("celebrate_opponent_goals", False),
|
|
||||||
)
|
|
||||||
# Per-game score baselines: {game_id: {"away": int, "home": int}}
|
|
||||||
self._score_baselines: Dict[str, Dict[str, int]] = {}
|
|
||||||
# The active celebration (a game *snapshot*, so a win survives the game
|
|
||||||
# leaving live_games) or None. See _start_celebration for the shape.
|
|
||||||
self.active_celebration: Optional[Dict[str, Any]] = None
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Override points
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def score_phrase(self, points: int, team_abbr: str) -> str:
|
|
||||||
"""The wording for a score celebration.
|
|
||||||
|
|
||||||
``points`` is the score delta that triggered it, which sports with
|
|
||||||
variable-value scores use to name the play. The default is deliberately
|
|
||||||
sport-neutral; every celebrating plugin overrides it.
|
|
||||||
"""
|
|
||||||
return f"{team_abbr} SCORES!"
|
|
||||||
|
|
||||||
def win_phrase(self, team_abbr: str) -> str:
|
|
||||||
"""The wording for a win celebration."""
|
|
||||||
return f"{team_abbr} WINS!"
|
|
||||||
|
|
||||||
def _is_favorite(self, key: Optional[str]) -> bool:
|
|
||||||
"""Whether ``key`` (whatever ``_favorite_key`` returns) is a favorite."""
|
|
||||||
return bool(self.favorite_teams) and key in self.favorite_teams
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Detection
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _score_to_int(score) -> Optional[int]:
|
|
||||||
"""Coerce an ESPN score value (str / int / dict) to an int, or None."""
|
|
||||||
try:
|
|
||||||
if score is None:
|
|
||||||
return None
|
|
||||||
if isinstance(score, str):
|
|
||||||
s = score.strip()
|
|
||||||
if not s:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return int(float(s))
|
|
||||||
except ValueError:
|
|
||||||
numbers = re.findall(r"\d+", s)
|
|
||||||
return int(numbers[0]) if numbers else None
|
|
||||||
if isinstance(score, dict):
|
|
||||||
return int(float(score.get("value", score.get("displayValue", 0))))
|
|
||||||
return int(float(score))
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _should_celebrate_for(self, game: Dict, side: str) -> bool:
|
|
||||||
"""Whether a score by ``side`` in ``game`` should trigger a celebration."""
|
|
||||||
if self._is_favorite(self._favorite_key(game, side)):
|
|
||||||
return True
|
|
||||||
if not self.favorite_teams:
|
|
||||||
# No favorites configured: the user opted to show this game, so
|
|
||||||
# celebrate any score in it.
|
|
||||||
return True
|
|
||||||
# Favorites exist but this team isn't one -> it's the opponent.
|
|
||||||
return self.celebrate_opponent_scores
|
|
||||||
|
|
||||||
def prune_score_baselines(self, live_games: List[Dict]) -> None:
|
|
||||||
"""Drop baselines for games no longer live.
|
|
||||||
|
|
||||||
Only :meth:`_check_for_win` removes entries, and it only fires for games
|
|
||||||
seen to go final. A game that vanishes from the live list any other way
|
|
||||||
— postponed, dropped by the feed, or simply still live when the board
|
|
||||||
restarts — leaves its baseline behind forever, so on a board that runs
|
|
||||||
all season the dict grows without bound.
|
|
||||||
|
|
||||||
Call this from ``update()`` with the current live set, alongside the
|
|
||||||
equivalent pruning in :meth:`SmoothWeightedRotation.next_game`.
|
|
||||||
"""
|
|
||||||
live_ids = {g.get("id") for g in live_games}
|
|
||||||
self._score_baselines = {
|
|
||||||
gid: baseline
|
|
||||||
for gid, baseline in self._score_baselines.items()
|
|
||||||
if gid in live_ids
|
|
||||||
}
|
|
||||||
|
|
||||||
def has_active_celebration(self) -> bool:
|
|
||||||
"""True while a celebration is within its display window."""
|
|
||||||
celebration = self.active_celebration
|
|
||||||
return bool(celebration) and (
|
|
||||||
time.time() - celebration["started_at"] < self.celebration_duration
|
|
||||||
)
|
|
||||||
|
|
||||||
def _check_for_score(self, game: Dict) -> None:
|
|
||||||
"""Compare a live game's score against its baseline and arm a
|
|
||||||
celebration when a celebratable team's score increases."""
|
|
||||||
if not self.celebration_enabled:
|
|
||||||
return
|
|
||||||
game_id = game.get("id")
|
|
||||||
if not game_id:
|
|
||||||
return
|
|
||||||
away = self._score_to_int(game.get("away_score"))
|
|
||||||
home = self._score_to_int(game.get("home_score"))
|
|
||||||
if away is None or home is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
baseline = self._score_baselines.get(game_id)
|
|
||||||
# Always refresh the baseline: a first sighting must never celebrate (a
|
|
||||||
# game already in progress at boot would false-fire), and a decrement
|
|
||||||
# (VAR, a correction) just re-bases silently.
|
|
||||||
self._score_baselines[game_id] = {"away": away, "home": home}
|
|
||||||
if baseline is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
away_delta = away - baseline["away"]
|
|
||||||
home_delta = home - baseline["home"]
|
|
||||||
if away_delta <= 0 and home_delta <= 0:
|
|
||||||
return
|
|
||||||
|
|
||||||
# One takeover per scoring sequence, where the sport has such a thing.
|
|
||||||
# The baseline is already advanced above, so nothing re-fires later.
|
|
||||||
if self.COALESCE_SCORING_SEQUENCE and self.has_active_celebration():
|
|
||||||
return
|
|
||||||
|
|
||||||
scored_side = None
|
|
||||||
points = 0
|
|
||||||
if away_delta > 0 and self._should_celebrate_for(game, "away"):
|
|
||||||
scored_side, points = "away", away_delta
|
|
||||||
if scored_side is None and home_delta > 0 and self._should_celebrate_for(
|
|
||||||
game, "home"
|
|
||||||
):
|
|
||||||
scored_side, points = "home", home_delta
|
|
||||||
if scored_side is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
self._start_celebration(
|
|
||||||
game,
|
|
||||||
"score",
|
|
||||||
scored_side=scored_side,
|
|
||||||
team_abbr=game.get(f"{scored_side}_abbr", ""),
|
|
||||||
away_score=away,
|
|
||||||
home_score=home,
|
|
||||||
points=points,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _check_for_win(self, game: Dict) -> None:
|
|
||||||
"""When a game we were tracking live goes final, arm a win celebration
|
|
||||||
if a favorite won. Fires at most once per game."""
|
|
||||||
if not self.celebration_enabled:
|
|
||||||
return
|
|
||||||
game_id = game.get("id")
|
|
||||||
if not game_id:
|
|
||||||
return
|
|
||||||
# Only celebrate wins for games we actually watched go live: one seen
|
|
||||||
# for the first time already-final (the board started after full time)
|
|
||||||
# has no baseline and must not fire.
|
|
||||||
if game_id not in self._score_baselines:
|
|
||||||
return
|
|
||||||
# Consume the baseline so this can only fire once.
|
|
||||||
self._score_baselines.pop(game_id, None)
|
|
||||||
|
|
||||||
away = self._score_to_int(game.get("away_score"))
|
|
||||||
home = self._score_to_int(game.get("home_score"))
|
|
||||||
if away is None or home is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
if away > home:
|
|
||||||
winner_side = "away"
|
|
||||||
elif home > away:
|
|
||||||
winner_side = "home"
|
|
||||||
else:
|
|
||||||
return # draw -> no win celebration
|
|
||||||
|
|
||||||
# Wins are gated strictly on favorites: every game ends, so the
|
|
||||||
# "no favorites -> celebrate all" score fallback would be far too noisy.
|
|
||||||
if not self._is_favorite(self._favorite_key(game, winner_side)):
|
|
||||||
return
|
|
||||||
|
|
||||||
self._start_celebration(
|
|
||||||
game,
|
|
||||||
"win",
|
|
||||||
scored_side=winner_side,
|
|
||||||
team_abbr=game.get(f"{winner_side}_abbr", ""),
|
|
||||||
away_score=away,
|
|
||||||
home_score=home,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _start_celebration(
|
|
||||||
self,
|
|
||||||
game: Dict,
|
|
||||||
kind: str,
|
|
||||||
scored_side: str,
|
|
||||||
team_abbr: str,
|
|
||||||
away_score: int,
|
|
||||||
home_score: int,
|
|
||||||
points: int = 0,
|
|
||||||
) -> None:
|
|
||||||
"""Arm a celebration. ``scored_side`` ('away'/'home') is the side whose
|
|
||||||
score digit gets highlighted."""
|
|
||||||
phrase = (
|
|
||||||
self.win_phrase(team_abbr)
|
|
||||||
if kind == "win"
|
|
||||||
else self.score_phrase(points, team_abbr)
|
|
||||||
)
|
|
||||||
|
|
||||||
self.active_celebration = {
|
|
||||||
"kind": kind,
|
|
||||||
"game": dict(game), # snapshot: survives the game leaving live_games
|
|
||||||
"scored_side": scored_side,
|
|
||||||
"team_abbr": team_abbr,
|
|
||||||
"away_score": away_score,
|
|
||||||
"home_score": home_score,
|
|
||||||
"started_at": time.time(),
|
|
||||||
"phrase": phrase,
|
|
||||||
}
|
|
||||||
# Pin focus to the involved game so the post-celebration scorebug
|
|
||||||
# resumes on it.
|
|
||||||
self.current_game = dict(game)
|
|
||||||
self.logger.info(
|
|
||||||
f"[Celebrations] {kind} armed: {phrase} "
|
|
||||||
f"[{game.get('away_abbr')} {away_score}-{home_score} {game.get('home_abbr')}]"
|
|
||||||
)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Rendering
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _fit_font(self, draw, text: str, max_width: int, fonts: List):
|
|
||||||
"""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]
|
|
||||||
|
|
||||||
def _draw_celebration_layout(
|
|
||||||
self, celebration: Dict, force_clear: bool = False
|
|
||||||
) -> None:
|
|
||||||
"""Render the full-screen score/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 = time.time() - celebration["started_at"]
|
|
||||||
game = celebration["game"]
|
|
||||||
|
|
||||||
# Background: a brief color flash for the first ~1.2s, then black.
|
|
||||||
bg = (0, 0, 0, 255)
|
|
||||||
if elapsed < 1.2 and int(elapsed / 0.2) % 2 == 0:
|
|
||||||
bg = (12, 12, 48, 255)
|
|
||||||
main_img = Image.new("RGBA", (display_width, display_height), bg)
|
|
||||||
overlay = Image.new("RGBA", (display_width, display_height), (0, 0, 0, 0))
|
|
||||||
draw = ImageDraw.Draw(overlay)
|
|
||||||
|
|
||||||
# Logos at the edges (best-effort: a logo failure must not blank the
|
|
||||||
# celebration).
|
|
||||||
try:
|
|
||||||
center_y = display_height // 2
|
|
||||||
home_logo = self._load_and_resize_logo(
|
|
||||||
game.get("home_id"), game.get("home_abbr"),
|
|
||||||
game.get("home_logo_path"), game.get("home_logo_url"),
|
|
||||||
)
|
|
||||||
away_logo = self._load_and_resize_logo(
|
|
||||||
game.get("away_id"), game.get("away_abbr"),
|
|
||||||
game.get("away_logo_path"), game.get("away_logo_url"),
|
|
||||||
)
|
|
||||||
if home_logo:
|
|
||||||
main_img.paste(
|
|
||||||
home_logo,
|
|
||||||
(display_width - home_logo.width + 2, center_y - home_logo.height // 2),
|
|
||||||
home_logo,
|
|
||||||
)
|
|
||||||
if away_logo:
|
|
||||||
main_img.paste(
|
|
||||||
away_logo, (-2, center_y - away_logo.height // 2), away_logo
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.debug(f"[Celebrations] Logo load failed: {e}")
|
|
||||||
|
|
||||||
# Phrase across the top, shrunk to fit the panel width.
|
|
||||||
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)
|
|
||||||
self._draw_text_with_outline(
|
|
||||||
draw, phrase, ((display_width - phrase_width) // 2, 1), phrase_font
|
|
||||||
)
|
|
||||||
|
|
||||||
# Score centered low, with the scoring/winning side's digit pulsing in a
|
|
||||||
# highlight color 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)
|
|
||||||
highlight = (255, 255, 0) if int(elapsed * 4) % 2 == 0 else (255, 170, 0)
|
|
||||||
x = (display_width - total_width) // 2
|
|
||||||
y = display_height - 14
|
|
||||||
for seg, is_highlight in segments:
|
|
||||||
color = highlight if is_highlight else (255, 255, 255)
|
|
||||||
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()
|
|
||||||
|
|
||||||
def display(self, force_clear: bool = False) -> bool:
|
|
||||||
"""Render an active celebration as a full-screen takeover; otherwise
|
|
||||||
defer to the normal live scorebug."""
|
|
||||||
if not self.is_enabled:
|
|
||||||
return False
|
|
||||||
celebration = self.active_celebration
|
|
||||||
if celebration:
|
|
||||||
if self.has_active_celebration():
|
|
||||||
try:
|
|
||||||
self._draw_celebration_layout(celebration, force_clear)
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(
|
|
||||||
f"[Celebrations] Error drawing celebration: {e}", exc_info=True
|
|
||||||
)
|
|
||||||
# Disarm rather than retry: the same render would fail on
|
|
||||||
# every frame for the rest of the window, logging a
|
|
||||||
# traceback each time and leaving the scorebug off screen.
|
|
||||||
self.active_celebration = None
|
|
||||||
self.last_game_switch = time.time()
|
|
||||||
else:
|
|
||||||
self.active_celebration = None
|
|
||||||
# Reset the dwell so the scorebug resumes on the scoring/winning
|
|
||||||
# game for a full duration before rotation can move on.
|
|
||||||
self.last_game_switch = time.time()
|
|
||||||
return super().display(force_clear)
|
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
"""Live-rotation strategies — which live game to show next.
|
|
||||||
|
|
||||||
The nine plugin copies grew three spellings of this, and the survey behind
|
|
||||||
``docs/SPORTS_UNIFICATION.md`` found they are all the *same* Smooth Weighted
|
|
||||||
Round-Robin algorithm in two shapes:
|
|
||||||
|
|
||||||
* an **incremental picker** that holds weight state across calls and answers
|
|
||||||
"what next?" one game at a time (afl / nrl / soccer's ``_swrr_advance``), and
|
|
||||||
* a **precomputed cycle** that returns a full list of game ids up front
|
|
||||||
(football / baseball / basketball's ``_build_weighted_schedule`` and hockey's
|
|
||||||
``_build_rotation_schedule``, which differ only in loop shape).
|
|
||||||
|
|
||||||
They agree *within* a cycle — SWRR is deterministic — and differ only at cycle
|
|
||||||
boundaries, where the incremental form has no seam and the precomputed form
|
|
||||||
restarts. That is a real behavioral difference, so core ships both rather than
|
|
||||||
declaring a winner, and a plugin picks one by name:
|
|
||||||
|
|
||||||
self.rotation = get_rotation_strategy("swrr", weight_for=self._live_weight)
|
|
||||||
|
|
||||||
Core never learns which sport is asking. A plugin with a genuinely novel
|
|
||||||
ordering registers its own strategy instead of core growing a branch::
|
|
||||||
|
|
||||||
register_rotation_strategy("my-order", MyRotation)
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Callable, Dict, List, Optional, Type
|
|
||||||
|
|
||||||
|
|
||||||
def _game_id(game: Dict) -> Optional[str]:
|
|
||||||
"""The rotation key for a game, or None if it has no usable id."""
|
|
||||||
return game.get("id")
|
|
||||||
|
|
||||||
|
|
||||||
class RotationStrategy:
|
|
||||||
"""Base class for live-rotation ordering.
|
|
||||||
|
|
||||||
Subclasses implement :meth:`schedule`; :meth:`next_game` has a working
|
|
||||||
default derived from it. Strategies whose natural shape is incremental
|
|
||||||
override :meth:`next_game` instead and derive :meth:`schedule`.
|
|
||||||
|
|
||||||
:param weight_for: callable mapping a game dict to a positive integer
|
|
||||||
weight — how many turns it gets per turn of a weight-1 game. Supplied by
|
|
||||||
the host so the *favorites* policy stays with the plugin and this module
|
|
||||||
stays free of any notion of what a favorite is. Defaults to equal
|
|
||||||
weights, which makes every strategy a plain round robin.
|
|
||||||
"""
|
|
||||||
|
|
||||||
#: Name this strategy is registered under. Set by :func:`register_rotation_strategy`.
|
|
||||||
name: str = ""
|
|
||||||
|
|
||||||
#: Ceiling on a per-game weight. A cycle is ``sum(weights)`` long and each
|
|
||||||
#: step scans every game, so an unbounded weight — a misread config field,
|
|
||||||
#: say — would spin the display thread for an unbounded time. On a Pi that
|
|
||||||
#: stalls rendering outright, so the bound is clamped like the floor is.
|
|
||||||
MAX_WEIGHT = 16
|
|
||||||
|
|
||||||
def __init__(self, weight_for: Optional[Callable[[Dict], int]] = None):
|
|
||||||
self._weight_for = weight_for or (lambda game: 1)
|
|
||||||
|
|
||||||
def weights(self, games: List[Dict]) -> Dict[str, int]:
|
|
||||||
"""``{game_id: weight}`` for games that have an id, in ``games`` order.
|
|
||||||
|
|
||||||
A weight below 1 is clamped up: a zero or negative weight would starve
|
|
||||||
a game out of the rotation entirely, which no caller means to express
|
|
||||||
and which would make ``total_weight`` collapse. It is clamped down at
|
|
||||||
:attr:`MAX_WEIGHT` for the reason documented there.
|
|
||||||
"""
|
|
||||||
weights: Dict[str, int] = {}
|
|
||||||
for game in games:
|
|
||||||
gid = _game_id(game)
|
|
||||||
if gid is None:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
weight = int(self._weight_for(game))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
weight = 1
|
|
||||||
weights[gid] = min(self.MAX_WEIGHT, max(1, weight))
|
|
||||||
return weights
|
|
||||||
|
|
||||||
def schedule(self, games: List[Dict]) -> List[str]:
|
|
||||||
"""Game ids in display order for one cycle. Ids may repeat."""
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
def next_game(self, games: List[Dict]) -> Optional[Dict]:
|
|
||||||
"""The next game to display, or None when there is nothing to show."""
|
|
||||||
order = self.schedule(games)
|
|
||||||
if not order:
|
|
||||||
return None
|
|
||||||
by_id = {gid: g for g in games if (gid := _game_id(g)) is not None}
|
|
||||||
return by_id.get(order[0])
|
|
||||||
|
|
||||||
def reset(self) -> None:
|
|
||||||
"""Drop any accumulated state. Stateless strategies need do nothing."""
|
|
||||||
|
|
||||||
|
|
||||||
class SimpleRotation(RotationStrategy):
|
|
||||||
"""Plain round robin: every live game once per cycle, weights ignored.
|
|
||||||
|
|
||||||
The fallback for a plugin that wants strictly even rotation regardless of
|
|
||||||
favorites.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def schedule(self, games: List[Dict]) -> List[str]:
|
|
||||||
return [gid for g in games if (gid := _game_id(g)) is not None]
|
|
||||||
|
|
||||||
|
|
||||||
class WeightedCycleRotation(RotationStrategy):
|
|
||||||
"""Precomputed SWRR cycle — the football / baseball / basketball / hockey shape.
|
|
||||||
|
|
||||||
Returns a full cycle of ``sum(weights)`` ids with repeats spaced evenly
|
|
||||||
rather than clumped, highest weight scheduled first. When no game carries a
|
|
||||||
boost the cycle degenerates to a single pass in ``games`` order, which is
|
|
||||||
exactly the plain round robin it replaced.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def schedule(self, games: List[Dict]) -> List[str]:
|
|
||||||
weights = self.weights(games)
|
|
||||||
if not weights:
|
|
||||||
return []
|
|
||||||
total_weight = sum(weights.values())
|
|
||||||
if total_weight <= len(weights):
|
|
||||||
# No boost in effect — plain order, one pass. (Also the guard that
|
|
||||||
# keeps the loop below from being O(total_weight) for nothing.)
|
|
||||||
return list(weights)
|
|
||||||
|
|
||||||
current = {gid: 0 for gid in weights}
|
|
||||||
order: List[str] = []
|
|
||||||
for _ in range(total_weight):
|
|
||||||
for gid, weight in weights.items():
|
|
||||||
current[gid] += weight
|
|
||||||
picked = max(current, key=lambda gid: current[gid])
|
|
||||||
current[picked] -= total_weight
|
|
||||||
order.append(picked)
|
|
||||||
return order
|
|
||||||
|
|
||||||
|
|
||||||
class SmoothWeightedRotation(RotationStrategy):
|
|
||||||
"""Incremental SWRR — the afl / nrl / soccer shape.
|
|
||||||
|
|
||||||
Weight state persists across calls, so there is no fixed-length cycle and
|
|
||||||
therefore no clustering seam at a cycle boundary. A game seen for the first
|
|
||||||
time starts at weight 0 and receives its full weight on the next call, so a
|
|
||||||
favorite's game that has just gone live naturally wins the first pick after
|
|
||||||
it appears — "queued first on refresh" without a special-cased branch.
|
|
||||||
|
|
||||||
State for games no longer live is dropped on each call, so a long-running
|
|
||||||
board does not accumulate entries for finished games.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, weight_for: Optional[Callable[[Dict], int]] = None):
|
|
||||||
super().__init__(weight_for)
|
|
||||||
self._current: Dict[str, int] = {}
|
|
||||||
|
|
||||||
def reset(self) -> None:
|
|
||||||
self._current = {}
|
|
||||||
|
|
||||||
def next_game(self, games: List[Dict]) -> Optional[Dict]:
|
|
||||||
if not games:
|
|
||||||
return None
|
|
||||||
weights = self.weights(games)
|
|
||||||
if not weights:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Keep state only for games still live.
|
|
||||||
self._current = {
|
|
||||||
gid: value for gid, value in self._current.items() if gid in weights
|
|
||||||
}
|
|
||||||
for gid, weight in weights.items():
|
|
||||||
self._current[gid] = self._current.get(gid, 0) + weight
|
|
||||||
|
|
||||||
total_weight = sum(weights.values())
|
|
||||||
# Iterate in `games` order so ties break toward the feed's ordering,
|
|
||||||
# which is what the plugin copies did and what makes the no-boost case
|
|
||||||
# identical to a plain round robin.
|
|
||||||
ids_in_order = [gid for g in games if (gid := _game_id(g)) in weights]
|
|
||||||
best = max(ids_in_order, key=lambda gid: self._current[gid])
|
|
||||||
self._current[best] -= total_weight
|
|
||||||
return next(g for g in games if _game_id(g) == best)
|
|
||||||
|
|
||||||
def schedule(self, games: List[Dict]) -> List[str]:
|
|
||||||
"""One cycle's worth of picks, without disturbing live state.
|
|
||||||
|
|
||||||
Derived by running the picker forward on a copy, so the returned order
|
|
||||||
is exactly what repeated :meth:`next_game` calls would produce from the
|
|
||||||
current state — callers can use it to preview or log the rotation
|
|
||||||
without perturbing it.
|
|
||||||
"""
|
|
||||||
weights = self.weights(games)
|
|
||||||
if not weights:
|
|
||||||
return []
|
|
||||||
# type(self), not this class: a subclass that overrides next_game must
|
|
||||||
# be previewed through its own ordering, or the returned order is not
|
|
||||||
# the one repeated next_game calls would produce — which is exactly
|
|
||||||
# what this method promises.
|
|
||||||
preview = type(self)(self._weight_for)
|
|
||||||
preview._current = dict(self._current)
|
|
||||||
order: List[str] = []
|
|
||||||
for _ in range(sum(weights.values())):
|
|
||||||
picked = preview.next_game(games)
|
|
||||||
if picked is None:
|
|
||||||
break
|
|
||||||
order.append(_game_id(picked))
|
|
||||||
return order
|
|
||||||
|
|
||||||
|
|
||||||
_REGISTRY: Dict[str, Type[RotationStrategy]] = {}
|
|
||||||
|
|
||||||
|
|
||||||
def register_rotation_strategy(name: str, factory: Type[RotationStrategy]) -> None:
|
|
||||||
"""Register a rotation strategy under ``name``.
|
|
||||||
|
|
||||||
When a plugin needs an ordering that core does not ship, it registers its
|
|
||||||
own here instead of core growing a sport-specific branch. Re-registering a
|
|
||||||
name replaces it, so a plugin may also override a built-in for itself.
|
|
||||||
"""
|
|
||||||
if not name:
|
|
||||||
raise ValueError("rotation strategy name must be a non-empty string")
|
|
||||||
# Fail at registration, not at the first schedule() call several frames
|
|
||||||
# later, where the cause is no longer on the stack.
|
|
||||||
if not (isinstance(factory, type) and issubclass(factory, RotationStrategy)):
|
|
||||||
raise TypeError(
|
|
||||||
f"rotation strategy {name!r} must be a RotationStrategy subclass, "
|
|
||||||
f"got {factory!r}"
|
|
||||||
)
|
|
||||||
factory.name = name
|
|
||||||
_REGISTRY[name] = factory
|
|
||||||
|
|
||||||
|
|
||||||
def get_rotation_strategy(
|
|
||||||
name: str, weight_for: Optional[Callable[[Dict], int]] = None
|
|
||||||
) -> RotationStrategy:
|
|
||||||
"""Build the strategy registered under ``name``.
|
|
||||||
|
|
||||||
Falls back to ``"simple"`` for an unknown name rather than raising: the name
|
|
||||||
arrives from user config, and a typo should cost the boost, not the
|
|
||||||
scoreboard.
|
|
||||||
"""
|
|
||||||
factory = _REGISTRY.get(name) or _REGISTRY["simple"]
|
|
||||||
return factory(weight_for=weight_for)
|
|
||||||
|
|
||||||
|
|
||||||
register_rotation_strategy("simple", SimpleRotation)
|
|
||||||
register_rotation_strategy("weighted", WeightedCycleRotation)
|
|
||||||
register_rotation_strategy("swrr", SmoothWeightedRotation)
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -50,8 +50,7 @@ class BaseOddsManager:
|
|||||||
# 2026-08-04 it began 403ing browser strings and bare custom tokens
|
# 2026-08-04 it began 403ing browser strings and bare custom tokens
|
||||||
# alike; what it accepts is a token with a URL that says who is
|
# alike; what it accepts is a token with a URL that says who is
|
||||||
# calling. Every other ESPN caller in the tree already sends this
|
# calling. Every other ESPN caller in the tree already sends this
|
||||||
# (src/common/api_helper.py, src/base_classes/data_sources.py); the
|
# (src/common/api_helper.py); the odds path was simply missed, and it is the one whose failures cost
|
||||||
# odds path was simply missed, and it is the one whose failures cost
|
|
||||||
# the caller its whole update budget.
|
# the caller its whole update budget.
|
||||||
#
|
#
|
||||||
# Deliberately no retry adapter, unlike api_helper: retries multiply
|
# Deliberately no retry adapter, unlike api_helper: retries multiply
|
||||||
|
|||||||
@@ -2,9 +2,8 @@
|
|||||||
|
|
||||||
Ten helpers are byte-identical (executable AST, docstrings stripped) in the
|
Ten helpers are byte-identical (executable AST, docstrings stripped) in the
|
||||||
scoreboard plugins' ``sports.py`` and have no equivalent elsewhere in
|
scoreboard plugins' ``sports.py`` and have no equivalent elsewhere in
|
||||||
``src/common``. The bodies below were copied from those plugin copies -- not
|
``src/common``. The bodies below were copied from those plugin copies at
|
||||||
from ``src/base_classes`` -- at ledmatrix-plugins ``f09bff2`` (origin/main,
|
ledmatrix-plugins ``f09bff2`` (origin/main, 2026-09-14):
|
||||||
2026-09-14):
|
|
||||||
|
|
||||||
- In all nine (afl, baseball, basketball, football, hockey, lacrosse, nrl,
|
- In all nine (afl, baseball, basketball, football, hockey, lacrosse, nrl,
|
||||||
soccer, ufc): ``_clamp_window``, ``_clamp_seconds``, ``_logo_needs_refresh``
|
soccer, ufc): ``_clamp_window``, ``_clamp_seconds``, ``_logo_needs_refresh``
|
||||||
@@ -34,9 +33,9 @@ where both can. Plugins import this and floor ``ledmatrix_min_version`` on the
|
|||||||
first core release that ships it (see ``CHANGELOG.md``).
|
first core release that ships it (see ``CHANGELOG.md``).
|
||||||
|
|
||||||
``_favorite_key`` is the one method not taken from the plugins: it is the
|
``_favorite_key`` is the one method not taken from the plugins: it is the
|
||||||
override point from ``src/base_classes/sports/core.py``, carried here so later
|
override point from the since-removed ``src/base_classes`` sports core,
|
||||||
phases (shared celebrations and game selection) have a hardware-free home for
|
carried here so later phases (shared celebrations and game selection) have a
|
||||||
the seam. No plugin defines it today and nothing in this module calls it.
|
hardware-free home for the seam. No plugin defines it today and nothing in this module calls it.
|
||||||
|
|
||||||
WHAT A HOST MUST PROVIDE
|
WHAT A HOST MUST PROVIDE
|
||||||
------------------------
|
------------------------
|
||||||
@@ -80,8 +79,8 @@ runs. By convention list it after ``SportsCoreSharedMixin``::
|
|||||||
|
|
||||||
HARDWARE-FREE
|
HARDWARE-FREE
|
||||||
-------------
|
-------------
|
||||||
Nothing here may import ``src.display_manager``, ``src.base_classes``,
|
Nothing here may import ``src.display_manager``, ``src.plugin_system`` or
|
||||||
``src.plugin_system`` or anything else that reaches ``rgbmatrix`` at module
|
anything else that reaches ``rgbmatrix`` at module
|
||||||
level; ``test/test_common_is_hardware_free.py`` enforces that for all of
|
level; ``test/test_common_is_hardware_free.py`` enforces that for all of
|
||||||
``src/common``. ``logo_needs_refresh`` imports ``src.logo_downloader`` lazily,
|
``src/common``. ``logo_needs_refresh`` imports ``src.logo_downloader`` lazily,
|
||||||
exactly as the plugin copy does.
|
exactly as the plugin copy does.
|
||||||
@@ -224,8 +223,8 @@ class SportsHelpersMixin:
|
|||||||
containing that string. The default returns ``None`` for a missing
|
containing that string. The default returns ``None`` for a missing
|
||||||
abbreviation, which never matches.
|
abbreviation, which never matches.
|
||||||
|
|
||||||
Carried from ``src/base_classes/sports/core.py`` for later phases;
|
Carried from the since-removed ``src/base_classes`` sports core for
|
||||||
nothing in this module calls it yet.
|
later phases; nothing in this module calls it yet.
|
||||||
"""
|
"""
|
||||||
return game.get(f"{side}_abbr")
|
return game.get(f"{side}_abbr")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,330 @@
|
|||||||
|
"""
|
||||||
|
The device's own location, in the shape a Starlark (Tidbyt/Pixlet) app expects.
|
||||||
|
|
||||||
|
A Pixlet ``schema.Location`` field is a JSON string -- ``{"lat": "35.2271",
|
||||||
|
"lng": "-80.8431", "timezone": "America/New_York", ...}`` -- and an app whose
|
||||||
|
field is left unset falls back to whatever its author hard-coded. Most
|
||||||
|
community apps hard-code San Francisco, so a user who set Charlotte under
|
||||||
|
General settings got San Francisco weather and a San Francisco radar map with
|
||||||
|
nothing in config.json to explain it.
|
||||||
|
|
||||||
|
Regular plugins already default their ``location_city``/``location_state``/
|
||||||
|
``location_country`` keys to the device location
|
||||||
|
(``SchemaManager.apply_device_location``). This module is the Starlark
|
||||||
|
equivalent. The device ``location`` block only has city/state/country, so the
|
||||||
|
city is geocoded once (Open-Meteo, the same service ledmatrix-weather uses)
|
||||||
|
and the coordinates are cached permanently -- cities don't move, so the
|
||||||
|
geocoder is only hit on a cache miss.
|
||||||
|
|
||||||
|
The substitution is applied at render time and never written into an app's
|
||||||
|
config.json, so a later change to the device location is picked up by the
|
||||||
|
next render. A location saved on the app itself always wins.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Any, Callable, Dict, Iterable, List, Optional
|
||||||
|
|
||||||
|
GEOCODE_URL = "https://geocoding-api.open-meteo.com/v1/search"
|
||||||
|
GEOCODE_TIMEOUT = 10
|
||||||
|
#: More than the handful ledmatrix-weather asks for: a common name
|
||||||
|
#: (Springfield, Charlotte, Portland) has several US matches, and the one in
|
||||||
|
#: the configured state has to be among the results to be picked.
|
||||||
|
GEOCODE_RESULT_COUNT = 10
|
||||||
|
|
||||||
|
#: Coordinates for a fixed city never go stale.
|
||||||
|
COORDS_MAX_AGE = 10 * 365 * 24 * 3600
|
||||||
|
#: After a failed lookup, renders use the app's own default until this has
|
||||||
|
#: passed, so a geocoder outage costs one timeout, not one per render.
|
||||||
|
FAILURE_RETRY_SECONDS = 30 * 60
|
||||||
|
|
||||||
|
CACHE_KEY_PREFIX = "device_location:coords"
|
||||||
|
|
||||||
|
LOCATION_FIELD_TYPES = ("location",)
|
||||||
|
|
||||||
|
# Open-Meteo reports the full state name in ``admin1``; the device state may be
|
||||||
|
# typed either way.
|
||||||
|
US_STATE_NAMES = {
|
||||||
|
"AL": "alabama", "AK": "alaska", "AZ": "arizona", "AR": "arkansas",
|
||||||
|
"CA": "california", "CO": "colorado", "CT": "connecticut",
|
||||||
|
"DE": "delaware", "DC": "district of columbia", "FL": "florida",
|
||||||
|
"GA": "georgia", "HI": "hawaii", "ID": "idaho", "IL": "illinois",
|
||||||
|
"IN": "indiana", "IA": "iowa", "KS": "kansas", "KY": "kentucky",
|
||||||
|
"LA": "louisiana", "ME": "maine", "MD": "maryland",
|
||||||
|
"MA": "massachusetts", "MI": "michigan", "MN": "minnesota",
|
||||||
|
"MS": "mississippi", "MO": "missouri", "MT": "montana",
|
||||||
|
"NE": "nebraska", "NV": "nevada", "NH": "new hampshire",
|
||||||
|
"NJ": "new jersey", "NM": "new mexico", "NY": "new york",
|
||||||
|
"NC": "north carolina", "ND": "north dakota", "OH": "ohio",
|
||||||
|
"OK": "oklahoma", "OR": "oregon", "PA": "pennsylvania",
|
||||||
|
"PR": "puerto rico", "RI": "rhode island", "SC": "south carolina",
|
||||||
|
"SD": "south dakota", "TN": "tennessee", "TX": "texas", "UT": "utah",
|
||||||
|
"VT": "vermont", "VA": "virginia", "WA": "washington",
|
||||||
|
"WV": "west virginia", "WI": "wisconsin", "WY": "wyoming",
|
||||||
|
}
|
||||||
|
|
||||||
|
_COUNTRY_ALIASES = {"usa": "us", "united states": "us",
|
||||||
|
"united states of america": "us", "uk": "gb",
|
||||||
|
"united kingdom": "gb"}
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _norm(value: Any) -> str:
|
||||||
|
"""Lower-case, with ``_``/``-`` read as spaces ("North_Carolina")."""
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return ""
|
||||||
|
return " ".join(value.replace("_", " ").replace("-", " ").lower().split())
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_state(value: Any) -> str:
|
||||||
|
state = _norm(value)
|
||||||
|
return US_STATE_NAMES.get(state.upper(), state)
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_country(value: Any) -> str:
|
||||||
|
country = _norm(value)
|
||||||
|
return _COUNTRY_ALIASES.get(country, country)
|
||||||
|
|
||||||
|
|
||||||
|
def location_field_ids(schema: Optional[Dict[str, Any]]) -> List[str]:
|
||||||
|
"""Ids of a Starlark app schema's ``location`` fields."""
|
||||||
|
if not isinstance(schema, dict):
|
||||||
|
return []
|
||||||
|
fields = schema.get("fields") or schema.get("schema") or []
|
||||||
|
ids = []
|
||||||
|
for field in fields:
|
||||||
|
if not isinstance(field, dict) or not field.get("id"):
|
||||||
|
continue
|
||||||
|
# "typeOf" from both extractors; "type" if a raw pixlet schema slipped
|
||||||
|
# through unremapped.
|
||||||
|
field_type = field.get("typeOf", field.get("type"))
|
||||||
|
if isinstance(field_type, str) and field_type.lower() in LOCATION_FIELD_TYPES:
|
||||||
|
ids.append(field["id"])
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
def parse_location(value: Any) -> Optional[Dict[str, Any]]:
|
||||||
|
"""The saved location as a dict, or None if it has no usable lat/lng.
|
||||||
|
|
||||||
|
Blank, missing, unparseable, or lat/lng-less values (the config form sends
|
||||||
|
``{"timezone": ...}`` when only the timezone box is filled) all mean the
|
||||||
|
user has not given the app a place.
|
||||||
|
"""
|
||||||
|
if isinstance(value, dict):
|
||||||
|
loc = value
|
||||||
|
elif isinstance(value, str) and value.strip():
|
||||||
|
try:
|
||||||
|
loc = json.loads(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
if not isinstance(loc, dict):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
float(loc["lat"])
|
||||||
|
float(loc["lng"])
|
||||||
|
except (KeyError, TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
return loc
|
||||||
|
|
||||||
|
|
||||||
|
def pick_geocode_result(results: Iterable[Dict[str, Any]], state: Any,
|
||||||
|
country: Any) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Best geocoder hit: same country and state, then same country, then first."""
|
||||||
|
results = [r for r in results if isinstance(r, dict)
|
||||||
|
and "latitude" in r and "longitude" in r]
|
||||||
|
if not results:
|
||||||
|
return None
|
||||||
|
want_state = _norm_state(state)
|
||||||
|
want_country = _norm_country(country)
|
||||||
|
|
||||||
|
def country_matches(r):
|
||||||
|
return bool(want_country) and want_country in (
|
||||||
|
_norm_country(r.get("country_code")), _norm_country(r.get("country")))
|
||||||
|
|
||||||
|
def state_matches(r):
|
||||||
|
return bool(want_state) and _norm_state(r.get("admin1")) == want_state
|
||||||
|
|
||||||
|
for test in (lambda r: country_matches(r) and state_matches(r),
|
||||||
|
country_matches,
|
||||||
|
state_matches):
|
||||||
|
for r in results:
|
||||||
|
if test(r):
|
||||||
|
return r
|
||||||
|
return results[0]
|
||||||
|
|
||||||
|
|
||||||
|
def geocode(city: str, state: Any = None, country: Any = None,
|
||||||
|
timeout: float = GEOCODE_TIMEOUT) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Look the city up on Open-Meteo. Raises on a network/HTTP failure."""
|
||||||
|
import requests
|
||||||
|
|
||||||
|
response = requests.get(GEOCODE_URL, params={
|
||||||
|
"name": city, "count": GEOCODE_RESULT_COUNT,
|
||||||
|
"language": "en", "format": "json",
|
||||||
|
}, timeout=timeout)
|
||||||
|
response.raise_for_status()
|
||||||
|
best = pick_geocode_result(response.json().get("results") or [], state, country)
|
||||||
|
if best is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"lat": best["latitude"],
|
||||||
|
"lng": best["longitude"],
|
||||||
|
"timezone": best.get("timezone"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceLocationResolver:
|
||||||
|
"""Resolves the device location to a Pixlet location JSON string.
|
||||||
|
|
||||||
|
Cached coordinates live in ``cache_manager`` (shared on disk by the
|
||||||
|
display and web processes) and in memory. A failed lookup is remembered
|
||||||
|
for ``FAILURE_RETRY_SECONDS`` so renders in the meantime fall straight
|
||||||
|
back to the app's own default.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, cache_manager: Any = None,
|
||||||
|
log: Optional[logging.Logger] = None,
|
||||||
|
geocoder: Callable[..., Optional[Dict[str, Any]]] = geocode,
|
||||||
|
clock: Callable[[], float] = time.time):
|
||||||
|
self.cache_manager = cache_manager
|
||||||
|
self.logger = log or logger
|
||||||
|
self._geocode = geocoder
|
||||||
|
self._clock = clock
|
||||||
|
self._coords: Dict[str, Dict[str, Any]] = {}
|
||||||
|
self._failed_at: Dict[str, float] = {}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _cache_key(city: str, state: str, country: str) -> str:
|
||||||
|
# The key is a filename on disk: no spaces.
|
||||||
|
parts = (_norm(city), _norm_state(state), _norm_country(country))
|
||||||
|
return ":".join((CACHE_KEY_PREFIX,) + tuple(p.replace(" ", "_") for p in parts))
|
||||||
|
|
||||||
|
def _cached(self, key: str) -> Optional[Dict[str, Any]]:
|
||||||
|
if key in self._coords:
|
||||||
|
return self._coords[key]
|
||||||
|
if self.cache_manager is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
cached = self.cache_manager.get(key, max_age=COORDS_MAX_AGE)
|
||||||
|
except Exception:
|
||||||
|
self.logger.debug("Could not read cached device coordinates", exc_info=True)
|
||||||
|
return None
|
||||||
|
if isinstance(cached, dict) and "lat" in cached and "lng" in cached:
|
||||||
|
self._coords[key] = cached
|
||||||
|
return cached
|
||||||
|
return None
|
||||||
|
|
||||||
|
def coordinates(self, device_location: Any) -> Optional[Dict[str, Any]]:
|
||||||
|
"""``{"lat", "lng", "timezone"}`` for the device city, or None."""
|
||||||
|
if not isinstance(device_location, dict):
|
||||||
|
return None
|
||||||
|
city = device_location.get("city")
|
||||||
|
if not isinstance(city, str) or not city.strip():
|
||||||
|
return None
|
||||||
|
city = city.strip()
|
||||||
|
state = device_location.get("state") or ""
|
||||||
|
country = device_location.get("country") or ""
|
||||||
|
key = self._cache_key(city, state, country)
|
||||||
|
|
||||||
|
cached = self._cached(key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
failed_at = self._failed_at.get(key)
|
||||||
|
if failed_at is not None and self._clock() - failed_at < FAILURE_RETRY_SECONDS:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
coords = self._geocode(city, state, country)
|
||||||
|
except Exception as e:
|
||||||
|
self._failed_at[key] = self._clock()
|
||||||
|
self.logger.warning(
|
||||||
|
"Could not geocode device location %r: %s - Starlark apps "
|
||||||
|
"without a saved location use their own default", city, e)
|
||||||
|
return None
|
||||||
|
if not coords:
|
||||||
|
self._failed_at[key] = self._clock()
|
||||||
|
self.logger.warning(
|
||||||
|
"Geocoder found no match for device location %r, %r, %r - "
|
||||||
|
"Starlark apps without a saved location use their own default",
|
||||||
|
city, state, country)
|
||||||
|
return None
|
||||||
|
|
||||||
|
self._failed_at.pop(key, None)
|
||||||
|
self._coords[key] = coords
|
||||||
|
if self.cache_manager is not None:
|
||||||
|
try:
|
||||||
|
self.cache_manager.set(key, coords, ttl=COORDS_MAX_AGE)
|
||||||
|
except Exception:
|
||||||
|
self.logger.debug("Could not cache device coordinates", exc_info=True)
|
||||||
|
return coords
|
||||||
|
|
||||||
|
def location_json(self, device_location: Any,
|
||||||
|
device_timezone: Optional[str] = None,
|
||||||
|
saved: Optional[Dict[str, Any]] = None) -> Optional[str]:
|
||||||
|
"""The device location as a Pixlet location string, or None.
|
||||||
|
|
||||||
|
The timezone is the city's own (from the geocoder) when known, since
|
||||||
|
it belongs to the coordinates; the device timezone is the fallback. A
|
||||||
|
timezone the user typed into the app's location form (with no lat/lng)
|
||||||
|
is kept.
|
||||||
|
"""
|
||||||
|
coords = self.coordinates(device_location)
|
||||||
|
if coords is None:
|
||||||
|
return None
|
||||||
|
city = str(device_location.get("city", "")).strip()
|
||||||
|
state = str(device_location.get("state") or "").replace("_", " ").strip()
|
||||||
|
country = str(device_location.get("country") or "").strip()
|
||||||
|
timezone = ((saved or {}).get("timezone") or coords.get("timezone")
|
||||||
|
or device_timezone or "UTC")
|
||||||
|
return json.dumps({
|
||||||
|
"lat": f"{float(coords['lat']):.4f}",
|
||||||
|
"lng": f"{float(coords['lng']):.4f}",
|
||||||
|
"locality": city,
|
||||||
|
"description": ", ".join(p for p in (city, state, country) if p),
|
||||||
|
"timezone": timezone,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def apply_device_location(pixlet_config: Dict[str, Any],
|
||||||
|
schema: Optional[Dict[str, Any]],
|
||||||
|
resolver: DeviceLocationResolver,
|
||||||
|
device_config: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||||
|
"""Fill a Starlark app's unset location fields with the device location.
|
||||||
|
|
||||||
|
Returns a new dict. A usable saved location is left alone. An unset field
|
||||||
|
gets the device location, or -- when that can't be resolved -- is dropped,
|
||||||
|
so the app sees no value and uses its own default instead of failing to
|
||||||
|
decode an empty string. Never raises.
|
||||||
|
"""
|
||||||
|
config = dict(pixlet_config)
|
||||||
|
unset = [fid for fid in location_field_ids(schema)
|
||||||
|
if parse_location(config.get(fid)) is None]
|
||||||
|
if not unset:
|
||||||
|
return config
|
||||||
|
|
||||||
|
device_config = device_config if isinstance(device_config, dict) else {}
|
||||||
|
for field_id in unset:
|
||||||
|
saved = config.get(field_id)
|
||||||
|
partial = None
|
||||||
|
if isinstance(saved, str) and saved.strip():
|
||||||
|
try:
|
||||||
|
partial = json.loads(saved)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
partial = None
|
||||||
|
try:
|
||||||
|
value = resolver.location_json(
|
||||||
|
device_config.get("location"), device_config.get("timezone"),
|
||||||
|
partial if isinstance(partial, dict) else None)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Could not build device location for %s", field_id, exc_info=True)
|
||||||
|
value = None
|
||||||
|
if value is None:
|
||||||
|
config.pop(field_id, None)
|
||||||
|
else:
|
||||||
|
config[field_id] = value
|
||||||
|
return config
|
||||||
@@ -95,6 +95,9 @@ class DisplayController:
|
|||||||
self.config_manager = config_manager # Keep for backward compatibility
|
self.config_manager = config_manager # Keep for backward compatibility
|
||||||
self.config = self.config_service.get_config()
|
self.config = self.config_service.get_config()
|
||||||
self.cache_manager = CacheManager()
|
self.cache_manager = CacheManager()
|
||||||
|
# The web interface's /api/v3/errors/* read what this publishes.
|
||||||
|
from src.error_aggregator import start_error_snapshot_publisher
|
||||||
|
start_error_snapshot_publisher(self.cache_manager)
|
||||||
logger.info("Config loaded in %.3f seconds (hot-reload: %s)", time.time() - start_time, enable_hot_reload)
|
logger.info("Config loaded in %.3f seconds (hot-reload: %s)", time.time() - start_time, enable_hot_reload)
|
||||||
|
|
||||||
# Validate startup configuration
|
# Validate startup configuration
|
||||||
@@ -403,6 +406,11 @@ class DisplayController:
|
|||||||
logger.exception("Plugin system initialization failed")
|
logger.exception("Plugin system initialization failed")
|
||||||
self.plugin_manager = None
|
self.plugin_manager = None
|
||||||
|
|
||||||
|
# The web UI's Fonts tab ("Used by") reads what this publishes.
|
||||||
|
from src.font_usage import start_font_usage_publisher
|
||||||
|
self._font_usage_publisher = start_font_usage_publisher(
|
||||||
|
self.cache_manager, self.font_manager, self.plugin_manager)
|
||||||
|
|
||||||
# Display rotation state
|
# Display rotation state
|
||||||
self.current_mode_index = 0
|
self.current_mode_index = 0
|
||||||
self.current_display_mode = None
|
self.current_display_mode = None
|
||||||
@@ -3338,6 +3346,8 @@ class DisplayController:
|
|||||||
self.config_service.shutdown()
|
self.config_service.shutdown()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Error shutting down config service: %s", e)
|
logger.warning("Error shutting down config service: %s", e)
|
||||||
|
if getattr(self, '_font_usage_publisher', None) is not None:
|
||||||
|
self._font_usage_publisher.stop()
|
||||||
logger.info("Cleaning up display controller...")
|
logger.info("Cleaning up display controller...")
|
||||||
if hasattr(self, 'display_manager'):
|
if hasattr(self, 'display_manager'):
|
||||||
self.display_manager.cleanup()
|
self.display_manager.cleanup()
|
||||||
|
|||||||
+469
-1
@@ -9,17 +9,21 @@ This is a local-only implementation with no external dependencies.
|
|||||||
Errors are stored in memory with optional JSON export.
|
Errors are stored in memory with optional JSON export.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
import json
|
import json
|
||||||
|
import uuid
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, List, Optional, Any, Callable
|
from typing import Dict, List, Optional, Any, Callable, Tuple
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from src.exceptions import LEDMatrixError
|
from src.exceptions import LEDMatrixError
|
||||||
|
from src.redaction import redact_credentials
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -115,6 +119,10 @@ class ErrorAggregator:
|
|||||||
# Track session start for relative timing
|
# Track session start for relative timing
|
||||||
self._session_start = datetime.now()
|
self._session_start = datetime.now()
|
||||||
|
|
||||||
|
# Bumped on every change, so a publisher can tell "nothing new since
|
||||||
|
# the last snapshot" without comparing snapshots.
|
||||||
|
self._version = 0
|
||||||
|
|
||||||
def record_error(
|
def record_error(
|
||||||
self,
|
self,
|
||||||
error: Exception,
|
error: Exception,
|
||||||
@@ -161,6 +169,7 @@ class ErrorAggregator:
|
|||||||
self._error_counts[error_type] += 1
|
self._error_counts[error_type] += 1
|
||||||
if plugin_id:
|
if plugin_id:
|
||||||
self._plugin_error_counts[plugin_id][error_type] += 1
|
self._plugin_error_counts[plugin_id][error_type] += 1
|
||||||
|
self._version += 1
|
||||||
|
|
||||||
# Check for patterns
|
# Check for patterns
|
||||||
self._detect_pattern(record)
|
self._detect_pattern(record)
|
||||||
@@ -331,6 +340,77 @@ class ErrorAggregator:
|
|||||||
|
|
||||||
return cleared
|
return cleared
|
||||||
|
|
||||||
|
@property
|
||||||
|
def version(self) -> int:
|
||||||
|
"""Changes whenever the recorded errors do (see ErrorSnapshotPublisher)."""
|
||||||
|
return self._version
|
||||||
|
|
||||||
|
def clear_before(self, cutoff: datetime) -> int:
|
||||||
|
"""Forget every error recorded at or before ``cutoff``.
|
||||||
|
|
||||||
|
Unlike clear_old_records, this also resets what the summary reports:
|
||||||
|
the per-type and per-plugin counts are rebuilt from the records that
|
||||||
|
remain, and detected patterns that began before the cutoff are dropped
|
||||||
|
(one that is still happening is detected again on its next
|
||||||
|
occurrence). Errors recorded after the
|
||||||
|
cutoff are kept, so a clear that is applied a few seconds after it was
|
||||||
|
requested does not swallow what happened in between.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of records removed
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
kept = [r for r in self._records if r.timestamp > cutoff]
|
||||||
|
cleared = len(self._records) - len(kept)
|
||||||
|
self._records = kept
|
||||||
|
self._error_counts = defaultdict(int)
|
||||||
|
self._plugin_error_counts = defaultdict(lambda: defaultdict(int))
|
||||||
|
for r in kept:
|
||||||
|
self._error_counts[r.error_type] += 1
|
||||||
|
if r.plugin_id:
|
||||||
|
self._plugin_error_counts[r.plugin_id][r.error_type] += 1
|
||||||
|
# A pattern that began after the cutoff is made only of kept
|
||||||
|
# errors; any other would carry cleared ones in its count.
|
||||||
|
self._patterns = {k: p for k, p in self._patterns.items() if p.first_seen > cutoff}
|
||||||
|
self._version += 1
|
||||||
|
return cleared
|
||||||
|
|
||||||
|
def build_snapshot(self) -> Dict[str, Any]:
|
||||||
|
"""A bounded, JSON-safe copy of the summary for another process.
|
||||||
|
|
||||||
|
The shape of get_error_summary() plus ``generated_at`` and
|
||||||
|
``plugin_health`` (get_plugin_health() for every plugin with errors).
|
||||||
|
Messages, stack traces and context are clipped so that one plugin
|
||||||
|
raising a huge exception cannot make the snapshot large.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
summary = self.get_error_summary()
|
||||||
|
summary["generated_at"] = datetime.now().isoformat()
|
||||||
|
summary["recent_errors"] = [
|
||||||
|
_compact_record(r) for r in summary["recent_errors"][-_SNAPSHOT_RECENT_ERRORS:]
|
||||||
|
]
|
||||||
|
patterns = {}
|
||||||
|
for key, pattern in list(summary["active_patterns"].items())[:_SNAPSHOT_MAX_PATTERNS]:
|
||||||
|
pattern = dict(pattern)
|
||||||
|
pattern["affected_plugins"] = [
|
||||||
|
_clip(p, _SNAPSHOT_ID_CHARS) for p in pattern.get("affected_plugins", [])
|
||||||
|
][:_SNAPSHOT_MAX_AFFECTED_PLUGINS]
|
||||||
|
pattern["sample_messages"] = [
|
||||||
|
_redacted_clip(m, _SNAPSHOT_SAMPLE_CHARS) for m in pattern.get("sample_messages", [])
|
||||||
|
][:3]
|
||||||
|
patterns[key] = pattern
|
||||||
|
summary["active_patterns"] = patterns
|
||||||
|
health = {}
|
||||||
|
for plugin_id in summary["plugin_error_counts"]:
|
||||||
|
entry = self.get_plugin_health(plugin_id)
|
||||||
|
if entry["last_error"] is not None:
|
||||||
|
entry["last_error"] = _compact_record(entry["last_error"])
|
||||||
|
health[plugin_id] = entry
|
||||||
|
summary["plugin_health"] = health
|
||||||
|
# Round-trip so a context value the cache's encoder cannot handle is
|
||||||
|
# turned into a string here rather than failing the write.
|
||||||
|
return json.loads(json.dumps(summary, default=str))
|
||||||
|
|
||||||
def export_to_file(self, filepath: Path) -> None:
|
def export_to_file(self, filepath: Path) -> None:
|
||||||
"""
|
"""
|
||||||
Export error data to JSON file.
|
Export error data to JSON file.
|
||||||
@@ -419,3 +499,391 @@ def record_error(
|
|||||||
plugin_id=plugin_id,
|
plugin_id=plugin_id,
|
||||||
operation=operation
|
operation=operation
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Sharing the display service's errors with the web interface
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# The two services are separate processes, so each has its own aggregator,
|
||||||
|
# and only the display service's ever records anything (plugin_executor runs
|
||||||
|
# the plugins there). The web interface therefore reads a snapshot the display
|
||||||
|
# service publishes to the shared cache directory -- the same channel, and the
|
||||||
|
# same file permissions, as display_current_state and plugin_metrics:*: files
|
||||||
|
# are 0660 and carry the cache directory's group, so root writes and the web
|
||||||
|
# user reads, and the other way round for the clear request.
|
||||||
|
#
|
||||||
|
# ERROR_SNAPSHOT_KEY written by the display service only
|
||||||
|
# ERROR_CLEAR_REQUEST_KEY written by the web interface only
|
||||||
|
#
|
||||||
|
# A clear is asynchronous: the web interface records a request, and the
|
||||||
|
# display service applies it (clear_before) on its next tick and republishes.
|
||||||
|
# Until it has, the web interface hides whatever the snapshot shows from
|
||||||
|
# before the cutoff, so a clear takes effect for readers immediately and a
|
||||||
|
# snapshot published just before the request cannot bring old errors back.
|
||||||
|
# The web interface never writes the snapshot itself: two writers would race,
|
||||||
|
# and a snapshot owned by the web user is one more file root's write has to
|
||||||
|
# replace.
|
||||||
|
|
||||||
|
ERROR_SNAPSHOT_KEY = "plugin_error_snapshot"
|
||||||
|
ERROR_CLEAR_REQUEST_KEY = "plugin_error_clear_request"
|
||||||
|
|
||||||
|
#: Shortest gap between two snapshot writes, in seconds. A plugin failing in
|
||||||
|
#: a tight loop changes the aggregator many times a second; the snapshot is
|
||||||
|
#: rewritten at most this often, and only when something changed.
|
||||||
|
SNAPSHOT_MIN_INTERVAL = 10.0
|
||||||
|
|
||||||
|
#: How often the display service checks for changes and clear requests. A
|
||||||
|
#: check is an in-memory comparison plus reading one small file.
|
||||||
|
SNAPSHOT_TICK_INTERVAL = 5.0
|
||||||
|
|
||||||
|
_SNAPSHOT_RECENT_ERRORS = 20
|
||||||
|
_SNAPSHOT_MAX_PATTERNS = 50
|
||||||
|
_SNAPSHOT_MAX_AFFECTED_PLUGINS = 50
|
||||||
|
_SNAPSHOT_MESSAGE_CHARS = 300
|
||||||
|
_SNAPSHOT_SAMPLE_CHARS = 200
|
||||||
|
_SNAPSHOT_TRACE_CHARS = 1200
|
||||||
|
_SNAPSHOT_ID_CHARS = 100
|
||||||
|
_SNAPSHOT_CONTEXT_KEYS = 20
|
||||||
|
_SNAPSHOT_CONTEXT_VALUE_CHARS = 200
|
||||||
|
|
||||||
|
#: Fields of get_error_summary(), which is what /errors/summary has always
|
||||||
|
#: returned. The snapshot's extra bookkeeping stays out of that response.
|
||||||
|
_SUMMARY_FIELDS = (
|
||||||
|
"session_start", "total_errors", "error_rate_per_hour",
|
||||||
|
"error_counts_by_type", "plugin_error_counts", "active_patterns",
|
||||||
|
"recent_errors",
|
||||||
|
)
|
||||||
|
|
||||||
|
_snapshot_logger = logging.getLogger(__name__ + ".snapshot")
|
||||||
|
|
||||||
|
|
||||||
|
def _clip(value: Any, limit: int) -> str:
|
||||||
|
text = value if isinstance(value, str) else str(value)
|
||||||
|
return text if len(text) <= limit else text[:limit - 3] + "..."
|
||||||
|
|
||||||
|
|
||||||
|
def _redacted_clip(value: Any, limit: int) -> str:
|
||||||
|
"""Redact, then clip. Clipping first could cut a ``token=`` marker off
|
||||||
|
while keeping the secret after it, and the web side's redaction would then
|
||||||
|
have nothing to match."""
|
||||||
|
return _clip(redact_credentials(value if isinstance(value, str) else str(value)), limit)
|
||||||
|
|
||||||
|
|
||||||
|
def _compact_record(record: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""An ErrorRecord dict with every free-text field redacted and bounded."""
|
||||||
|
compact = dict(record)
|
||||||
|
compact["message"] = _redacted_clip(record.get("message") or "", _SNAPSHOT_MESSAGE_CHARS)
|
||||||
|
if record.get("plugin_id") is not None:
|
||||||
|
compact["plugin_id"] = _clip(record["plugin_id"], _SNAPSHOT_ID_CHARS)
|
||||||
|
trace = record.get("stack_trace")
|
||||||
|
if isinstance(trace, str):
|
||||||
|
trace = redact_credentials(trace)
|
||||||
|
if len(trace) > _SNAPSHOT_TRACE_CHARS:
|
||||||
|
# The end of a traceback is the part that says what went wrong.
|
||||||
|
trace = "..." + trace[-(_SNAPSHOT_TRACE_CHARS - 3):]
|
||||||
|
compact["stack_trace"] = trace
|
||||||
|
context = record.get("context")
|
||||||
|
if isinstance(context, dict):
|
||||||
|
compact["context"] = {
|
||||||
|
_clip(k, _SNAPSHOT_ID_CHARS): (
|
||||||
|
v if v is None or isinstance(v, (bool, int, float))
|
||||||
|
else _redacted_clip(v, _SNAPSHOT_CONTEXT_VALUE_CHARS)
|
||||||
|
)
|
||||||
|
for k, v in list(context.items())[:_SNAPSHOT_CONTEXT_KEYS]
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
compact["context"] = {}
|
||||||
|
return compact
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorSnapshotPublisher:
|
||||||
|
"""Publishes the display service's aggregator to the shared cache.
|
||||||
|
|
||||||
|
Runs in the display service only. tick() is the whole job; start() just
|
||||||
|
calls it from a daemon thread every SNAPSHOT_TICK_INTERVAL seconds, which
|
||||||
|
also means errors recorded while a write was being throttled still reach
|
||||||
|
the cache once the interval has passed, and a clear request is applied
|
||||||
|
even when no new error arrives to trigger a publish.
|
||||||
|
|
||||||
|
Nothing here raises: a failure to read or write the cache is logged at
|
||||||
|
debug and retried on a later tick.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, cache_manager: Any, aggregator: Optional[ErrorAggregator] = None,
|
||||||
|
min_interval: float = SNAPSHOT_MIN_INTERVAL,
|
||||||
|
clock: Callable[[], float] = time.monotonic) -> None:
|
||||||
|
self.cache_manager = cache_manager
|
||||||
|
self.aggregator = aggregator or get_error_aggregator()
|
||||||
|
self.min_interval = min_interval
|
||||||
|
self._clock = clock
|
||||||
|
# None forces a first publish, which replaces a snapshot left behind
|
||||||
|
# by a previous run of the service with this run's (empty) one.
|
||||||
|
self._published_version: Optional[int] = None
|
||||||
|
self._last_attempt: Optional[float] = None
|
||||||
|
self._applied_clear_id: Optional[str] = None
|
||||||
|
self._tick_lock = threading.Lock()
|
||||||
|
self._stop = threading.Event()
|
||||||
|
self._thread: Optional[threading.Thread] = None
|
||||||
|
|
||||||
|
def _apply_clear_request(self) -> bool:
|
||||||
|
"""Honour a clear request we have not applied yet. True if one was."""
|
||||||
|
request = self.cache_manager.get(ERROR_CLEAR_REQUEST_KEY, max_age=None, memory_ttl=0)
|
||||||
|
if not isinstance(request, dict):
|
||||||
|
return False
|
||||||
|
request_id = request.get("request_id")
|
||||||
|
if not isinstance(request_id, str) or not request_id or request_id == self._applied_clear_id:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
cutoff = float(request.get("cutoff"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
cutoff = float("nan")
|
||||||
|
if math.isfinite(cutoff):
|
||||||
|
cleared = self.aggregator.clear_before(datetime.fromtimestamp(cutoff))
|
||||||
|
_snapshot_logger.info("Cleared %d plugin error record(s) as requested (%s)",
|
||||||
|
cleared, request_id)
|
||||||
|
# A malformed request is acknowledged too, so it is not retried forever.
|
||||||
|
self._applied_clear_id = request_id
|
||||||
|
return True
|
||||||
|
|
||||||
|
def tick(self) -> bool:
|
||||||
|
"""Apply a pending clear and publish if due. True if a snapshot was written."""
|
||||||
|
with self._tick_lock:
|
||||||
|
try:
|
||||||
|
cleared = self._apply_clear_request()
|
||||||
|
version = self.aggregator.version
|
||||||
|
now = self._clock()
|
||||||
|
if not cleared:
|
||||||
|
if version == self._published_version:
|
||||||
|
return False
|
||||||
|
if (self._last_attempt is not None
|
||||||
|
and now - self._last_attempt < self.min_interval):
|
||||||
|
return False
|
||||||
|
# Stamp the attempt before writing: a cache that keeps failing
|
||||||
|
# is retried at the throttled rate, not on every tick.
|
||||||
|
self._last_attempt = now
|
||||||
|
snapshot = self.aggregator.build_snapshot()
|
||||||
|
snapshot["applied_clear_id"] = self._applied_clear_id
|
||||||
|
self.cache_manager.set(ERROR_SNAPSHOT_KEY, snapshot)
|
||||||
|
self._published_version = version
|
||||||
|
return True
|
||||||
|
except Exception as err: # never let reporting break the display
|
||||||
|
_snapshot_logger.debug("Could not publish the plugin error snapshot: %s",
|
||||||
|
err, exc_info=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def start(self, interval: float = SNAPSHOT_TICK_INTERVAL) -> None:
|
||||||
|
"""Tick from a daemon thread until stop(). A no-op while running."""
|
||||||
|
if self._thread is not None and self._thread.is_alive():
|
||||||
|
return
|
||||||
|
self._stop.clear()
|
||||||
|
|
||||||
|
def run() -> None:
|
||||||
|
self.tick()
|
||||||
|
while not self._stop.wait(interval):
|
||||||
|
self.tick()
|
||||||
|
|
||||||
|
self._thread = threading.Thread(target=run, name="error-snapshot-publisher", daemon=True)
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self._stop.set()
|
||||||
|
if self._thread is not None:
|
||||||
|
self._thread.join(timeout=2)
|
||||||
|
self._thread = None
|
||||||
|
|
||||||
|
|
||||||
|
_snapshot_publisher: Optional[ErrorSnapshotPublisher] = None
|
||||||
|
_snapshot_publisher_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def start_error_snapshot_publisher(cache_manager: Any) -> Optional[ErrorSnapshotPublisher]:
|
||||||
|
"""Start publishing this process's errors for the web interface.
|
||||||
|
|
||||||
|
Call from the display service only: whichever process calls it becomes
|
||||||
|
the source of /api/v3/errors/*. Idempotent; never raises.
|
||||||
|
"""
|
||||||
|
global _snapshot_publisher
|
||||||
|
try:
|
||||||
|
with _snapshot_publisher_lock:
|
||||||
|
if _snapshot_publisher is None:
|
||||||
|
_snapshot_publisher = ErrorSnapshotPublisher(cache_manager)
|
||||||
|
else:
|
||||||
|
_snapshot_publisher.cache_manager = cache_manager
|
||||||
|
_snapshot_publisher.start()
|
||||||
|
return _snapshot_publisher
|
||||||
|
except Exception as err:
|
||||||
|
_snapshot_logger.warning("Plugin error reporting to the web interface is unavailable: %s", err)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# --- Reading side (web interface) -------------------------------------------
|
||||||
|
|
||||||
|
def read_error_report(cache_manager: Any) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
||||||
|
"""The display service's latest snapshot and the latest clear request.
|
||||||
|
|
||||||
|
memory_ttl=0: both keys are written by the other process, so only the
|
||||||
|
file is current.
|
||||||
|
"""
|
||||||
|
snapshot = cache_manager.get(ERROR_SNAPSHOT_KEY, max_age=None, memory_ttl=0)
|
||||||
|
clear_request = cache_manager.get(ERROR_CLEAR_REQUEST_KEY, max_age=None, memory_ttl=0)
|
||||||
|
return (snapshot if isinstance(snapshot, dict) else None,
|
||||||
|
clear_request if isinstance(clear_request, dict) else None)
|
||||||
|
|
||||||
|
|
||||||
|
def _epoch(iso: Any) -> Optional[float]:
|
||||||
|
"""Seconds since the epoch for an aggregator timestamp (local, naive)."""
|
||||||
|
if not isinstance(iso, str):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(iso).timestamp()
|
||||||
|
except (ValueError, OverflowError, OSError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _pending_cutoff(snapshot: Optional[Dict[str, Any]],
|
||||||
|
clear_request: Optional[Dict[str, Any]]) -> Optional[float]:
|
||||||
|
"""The cutoff of a clear the snapshot has not applied yet, if any."""
|
||||||
|
if not clear_request:
|
||||||
|
return None
|
||||||
|
request_id = clear_request.get("request_id")
|
||||||
|
if not request_id:
|
||||||
|
return None
|
||||||
|
if snapshot is not None and snapshot.get("applied_clear_id") == request_id:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
cutoff = float(clear_request.get("cutoff"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
return cutoff if math.isfinite(cutoff) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_after(item: Any, field_name: str, cutoff: float) -> bool:
|
||||||
|
when = _epoch(item.get(field_name)) if isinstance(item, dict) else None
|
||||||
|
return when is not None and when > cutoff
|
||||||
|
|
||||||
|
|
||||||
|
def _empty_summary(snapshot: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"session_start": snapshot.get("session_start") if snapshot else None,
|
||||||
|
"total_errors": 0,
|
||||||
|
"error_rate_per_hour": 0.0,
|
||||||
|
"error_counts_by_type": {},
|
||||||
|
"plugin_error_counts": {},
|
||||||
|
"active_patterns": {},
|
||||||
|
"recent_errors": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def error_summary_from_report(snapshot: Optional[Dict[str, Any]],
|
||||||
|
clear_request: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||||
|
"""The /errors/summary payload: get_error_summary()'s shape plus
|
||||||
|
``generated_at``, ``snapshot_available`` and ``clear_pending``."""
|
||||||
|
cutoff = _pending_cutoff(snapshot, clear_request)
|
||||||
|
summary = _empty_summary(snapshot)
|
||||||
|
if snapshot is not None:
|
||||||
|
for name, default in summary.items():
|
||||||
|
value = snapshot.get(name)
|
||||||
|
if isinstance(value, type(default)) or (
|
||||||
|
isinstance(default, float) and isinstance(value, int)) or (
|
||||||
|
name == "session_start" and isinstance(value, str)):
|
||||||
|
summary[name] = value
|
||||||
|
if cutoff is not None:
|
||||||
|
recent = summary["recent_errors"]
|
||||||
|
newest = _epoch(recent[-1].get("timestamp")) if recent and isinstance(recent[-1], dict) else None
|
||||||
|
if newest is None or newest <= cutoff:
|
||||||
|
# Everything the display has reported predates the clear.
|
||||||
|
summary = _empty_summary(snapshot)
|
||||||
|
else:
|
||||||
|
# Only part of it does. The lists can be filtered exactly; the
|
||||||
|
# counts cannot, and stay as reported until the display
|
||||||
|
# applies the clear (clear_pending says so).
|
||||||
|
summary["recent_errors"] = [r for r in recent if _is_after(r, "timestamp", cutoff)]
|
||||||
|
summary["active_patterns"] = {
|
||||||
|
k: p for k, p in summary["active_patterns"].items()
|
||||||
|
if _is_after(p, "last_seen", cutoff)
|
||||||
|
}
|
||||||
|
summary["generated_at"] = snapshot.get("generated_at") if snapshot else None
|
||||||
|
summary["snapshot_available"] = snapshot is not None
|
||||||
|
summary["clear_pending"] = cutoff is not None
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
def plugin_health_from_report(snapshot: Optional[Dict[str, Any]],
|
||||||
|
clear_request: Optional[Dict[str, Any]],
|
||||||
|
plugin_id: str) -> Dict[str, Any]:
|
||||||
|
"""The /errors/plugin/<id> payload: get_plugin_health()'s shape plus
|
||||||
|
``generated_at``, ``snapshot_available`` and ``clear_pending``."""
|
||||||
|
health: Dict[str, Any] = {
|
||||||
|
"plugin_id": plugin_id,
|
||||||
|
"status": "healthy",
|
||||||
|
"total_errors": 0,
|
||||||
|
"error_types": {},
|
||||||
|
"recent_error_count": 0,
|
||||||
|
"last_error": None,
|
||||||
|
}
|
||||||
|
cutoff = _pending_cutoff(snapshot, clear_request)
|
||||||
|
table = snapshot.get("plugin_health") if snapshot else None
|
||||||
|
entry = table.get(plugin_id) if isinstance(table, dict) else None
|
||||||
|
if isinstance(entry, dict):
|
||||||
|
# last_error is the plugin's newest error: if even that predates a
|
||||||
|
# pending clear, so does everything else the display reported for it.
|
||||||
|
if cutoff is None or _is_after(entry.get("last_error"), "timestamp", cutoff):
|
||||||
|
for name in ("status", "total_errors", "error_types", "recent_error_count", "last_error"):
|
||||||
|
if name in entry:
|
||||||
|
health[name] = entry[name]
|
||||||
|
health["generated_at"] = snapshot.get("generated_at") if snapshot else None
|
||||||
|
health["snapshot_available"] = snapshot is not None
|
||||||
|
health["clear_pending"] = cutoff is not None
|
||||||
|
return health
|
||||||
|
|
||||||
|
|
||||||
|
def _count_cleared(summary: Dict[str, Any], cutoff: float) -> Optional[int]:
|
||||||
|
"""How many of the reported errors a clear at ``cutoff`` hides, if known.
|
||||||
|
|
||||||
|
Exact when every reported error predates the cutoff (always the case for
|
||||||
|
a clear of everything) or when the report lists every error; otherwise
|
||||||
|
only the display service knows, and None says so.
|
||||||
|
"""
|
||||||
|
total = summary["total_errors"]
|
||||||
|
times = [_epoch(r.get("timestamp")) if isinstance(r, dict) else None
|
||||||
|
for r in summary["recent_errors"]]
|
||||||
|
if total == 0 or (times and times[-1] is not None and times[-1] <= cutoff):
|
||||||
|
return total
|
||||||
|
if len(times) >= total and None not in times:
|
||||||
|
return sum(1 for t in times if t <= cutoff)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def request_error_clear(cache_manager: Any, cutoff: float) -> Dict[str, Any]:
|
||||||
|
"""Ask the display service to forget errors recorded at or before ``cutoff``.
|
||||||
|
|
||||||
|
Returns ``request_id``, ``cutoff`` (ISO, local time), ``cleared_count``
|
||||||
|
(see _count_cleared) and ``clear_requested``. Raises OSError when the
|
||||||
|
request did not reach the shared cache, since a cache without a usable
|
||||||
|
directory accepts set() and keeps nothing.
|
||||||
|
|
||||||
|
A request the display has not applied yet is only ever widened: a later,
|
||||||
|
narrower one ("older than 24 hours" after "everything") overwriting it
|
||||||
|
would otherwise bring back the errors the first one hid.
|
||||||
|
"""
|
||||||
|
snapshot, clear_request = read_error_report(cache_manager)
|
||||||
|
pending = _pending_cutoff(snapshot, clear_request)
|
||||||
|
if pending is not None:
|
||||||
|
cutoff = max(cutoff, pending)
|
||||||
|
before = error_summary_from_report(snapshot, clear_request)
|
||||||
|
request = {
|
||||||
|
"request_id": uuid.uuid4().hex,
|
||||||
|
"cutoff": cutoff,
|
||||||
|
"requested_at": time.time(),
|
||||||
|
}
|
||||||
|
cache_manager.set(ERROR_CLEAR_REQUEST_KEY, request)
|
||||||
|
stored = cache_manager.get(ERROR_CLEAR_REQUEST_KEY, max_age=None, memory_ttl=0)
|
||||||
|
if not isinstance(stored, dict) or stored.get("request_id") != request["request_id"]:
|
||||||
|
raise OSError("the clear request was not stored in the shared cache")
|
||||||
|
return {
|
||||||
|
"cleared_count": _count_cleared(before, cutoff),
|
||||||
|
"clear_requested": True,
|
||||||
|
"request_id": request["request_id"],
|
||||||
|
"cutoff": datetime.fromtimestamp(cutoff).isoformat(),
|
||||||
|
}
|
||||||
|
|||||||
@@ -78,6 +78,10 @@ class FontManager:
|
|||||||
# Manager font registration - NEW for manager-centric model
|
# Manager font registration - NEW for manager-centric model
|
||||||
self.manager_fonts: Dict[str, Dict[str, Any]] = {} # manager_id -> {element_key: {family, size_px, color}}
|
self.manager_fonts: Dict[str, Dict[str, Any]] = {} # manager_id -> {element_key: {family, size_px, color}}
|
||||||
self.detected_fonts: Dict[str, Dict[str, Any]] = {} # element_key -> {family, size_px, color, manager_id, usage_count}
|
self.detected_fonts: Dict[str, Dict[str, Any]] = {} # element_key -> {family, size_px, color, manager_id, usage_count}
|
||||||
|
# Bumped when a manager's registered families change (not when one
|
||||||
|
# re-registers what it already had), so src/font_usage.py can tell
|
||||||
|
# "nothing new" without rebuilding its snapshot.
|
||||||
|
self.manager_fonts_version = 0
|
||||||
|
|
||||||
# Dynamic font loading
|
# Dynamic font loading
|
||||||
self.temp_font_dir = Path(tempfile.gettempdir()) / "ledmatrix_fonts"
|
self.temp_font_dir = Path(tempfile.gettempdir()) / "ledmatrix_fonts"
|
||||||
@@ -157,6 +161,9 @@ class FontManager:
|
|||||||
if color:
|
if color:
|
||||||
font_spec["color"] = color
|
font_spec["color"] = color
|
||||||
|
|
||||||
|
previous = self.manager_fonts[manager_id].get(element_key)
|
||||||
|
if previous is None or previous.get("family") != family:
|
||||||
|
self.manager_fonts_version += 1
|
||||||
self.manager_fonts[manager_id][element_key] = font_spec
|
self.manager_fonts[manager_id][element_key] = font_spec
|
||||||
|
|
||||||
# Track usage in detected_fonts
|
# Track usage in detected_fonts
|
||||||
@@ -168,6 +175,17 @@ class FontManager:
|
|||||||
|
|
||||||
logger.debug(f"Registered font for {manager_id}.{element_key}: {family}@{size_px}px")
|
logger.debug(f"Registered font for {manager_id}.{element_key}: {family}@{size_px}px")
|
||||||
|
|
||||||
|
def forget_manager_fonts(self, manager_id: str) -> None:
|
||||||
|
"""Drop every registration ``manager_id`` made. Called by core when a
|
||||||
|
plugin is unloaded, so a reloaded plugin starts from what its new
|
||||||
|
instance registers and the web UI's Fonts tab stops listing it."""
|
||||||
|
removed = self.manager_fonts.pop(manager_id, None)
|
||||||
|
for element_key, spec in list(self.detected_fonts.items()):
|
||||||
|
if spec.get("manager_id") == manager_id:
|
||||||
|
self.detected_fonts.pop(element_key, None)
|
||||||
|
if removed:
|
||||||
|
self.manager_fonts_version += 1
|
||||||
|
|
||||||
@deprecated("3.7.0")
|
@deprecated("3.7.0")
|
||||||
def get_manager_fonts(self, manager_id: Optional[str] = None) -> Dict[str, Any]:
|
def get_manager_fonts(self, manager_id: Optional[str] = None) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
"""Which plugins use which font, published for the web interface.
|
||||||
|
|
||||||
|
The display service and the web interface are separate processes. Plugins
|
||||||
|
run, and register their fonts with ``FontManager.register_manager_font()``,
|
||||||
|
in the display service only; the web interface's Fonts tab lists the files in
|
||||||
|
``assets/fonts/`` from its own scan and has no FontManager to ask. So the
|
||||||
|
display service publishes a small snapshot to the shared cache directory --
|
||||||
|
the same channel, and the same file permissions, as ``display_current_state``
|
||||||
|
and ``plugin_error_snapshot``: files are 0660 and carry the cache directory's
|
||||||
|
group, so root writes and the web user reads.
|
||||||
|
|
||||||
|
FONT_USAGE_KEY written by the display service only
|
||||||
|
|
||||||
|
The snapshot::
|
||||||
|
|
||||||
|
{"generated_at": "2026-09-23T10:00:00",
|
||||||
|
"fonts": {"PressStart2P-Regular": ["calendar", "hello-world"],
|
||||||
|
"4x6-font": ["calendar"]}}
|
||||||
|
|
||||||
|
``fonts`` is keyed the way ``GET /api/v3/fonts/catalog`` keys its rows: the
|
||||||
|
file name in ``assets/fonts/`` without its extension, as it is on disk. A
|
||||||
|
plugin names a font by FontManager family ("press_start", "5x7", "6x13b"), by
|
||||||
|
alias, or by path; :func:`catalog_key_for` resolves each through FontManager's
|
||||||
|
own catalog to the file it loads and keys it by that file's stem. Fonts that
|
||||||
|
live outside ``assets/fonts/`` (a plugin's own ``plugin_id::family`` fonts)
|
||||||
|
and families FontManager cannot resolve are left out: the Fonts tab has no
|
||||||
|
row for them.
|
||||||
|
|
||||||
|
Only registrations are counted. ``get_font()`` does not know which plugin is
|
||||||
|
calling it, and many plugins open font files directly with PIL, so a font no
|
||||||
|
plugin registered may still be in use; the web interface says so.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Callable, Dict, FrozenSet, List, Optional, Tuple
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
FONT_USAGE_KEY = "font_usage_snapshot"
|
||||||
|
|
||||||
|
#: How often the display service checks for a change. A check compares two
|
||||||
|
#: integers and a set of plugin ids; the snapshot is rebuilt and written only
|
||||||
|
#: when one of them moved, so plugins that register fonts every frame (the
|
||||||
|
#: countdown plugin registers per countdown at render time) cost nothing.
|
||||||
|
TICK_INTERVAL = 10.0
|
||||||
|
|
||||||
|
#: Rewrite an unchanged snapshot this often anyway. The cache's disk cleanup
|
||||||
|
#: deletes entries older than its default retention (30 days), and a display
|
||||||
|
#: service that runs longer than that without a font change would otherwise
|
||||||
|
#: turn the Fonts tab back to "unknown".
|
||||||
|
REFRESH_INTERVAL = 24 * 3600.0
|
||||||
|
|
||||||
|
FONT_EXTENSIONS = ('.ttf', '.otf', '.bdf')
|
||||||
|
|
||||||
|
# Bounds on what the web interface accepts from the snapshot file.
|
||||||
|
_MAX_FONTS = 1000
|
||||||
|
_MAX_PLUGINS_PER_FONT = 200
|
||||||
|
_MAX_ID_CHARS = 200
|
||||||
|
|
||||||
|
|
||||||
|
def _fonts_dir() -> str:
|
||||||
|
from src.common.font_layout import resolve_asset_path
|
||||||
|
return resolve_asset_path("assets/fonts")
|
||||||
|
|
||||||
|
|
||||||
|
def _same_dir(a: str, b: str) -> bool:
|
||||||
|
try:
|
||||||
|
return os.path.normcase(os.path.realpath(a)) == os.path.normcase(os.path.realpath(b))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def catalog_key_for(family: Any, font_catalog: Dict[str, str],
|
||||||
|
fonts_dir: Optional[str] = None) -> Optional[str]:
|
||||||
|
"""The Fonts-tab catalog key for a family a plugin registered, or None.
|
||||||
|
|
||||||
|
Resolution, in order:
|
||||||
|
|
||||||
|
1. ``family`` as a FontManager family -- a scanned file ("5x7",
|
||||||
|
"pressstart2p-regular"), an alias ("press_start", "four_by_six",
|
||||||
|
"five_by_seven", "tom_thumb"), or a plugin's ``plugin_id::family``.
|
||||||
|
FontManager keys scanned files in lower case, so the lookup falls back
|
||||||
|
to ``family.lower()``.
|
||||||
|
2. ``family`` as a file name or path ("5x7.bdf",
|
||||||
|
"assets/fonts/4x6-font.ttf", an absolute path).
|
||||||
|
|
||||||
|
The key is the resolved file's name without its extension, and only for a
|
||||||
|
file directly in ``assets/fonts/`` -- anything else has no catalog row.
|
||||||
|
"""
|
||||||
|
if not isinstance(family, str):
|
||||||
|
return None
|
||||||
|
name = family.strip()
|
||||||
|
if not name:
|
||||||
|
return None
|
||||||
|
fonts_dir = fonts_dir or _fonts_dir()
|
||||||
|
|
||||||
|
path = font_catalog.get(name) or font_catalog.get(name.lower())
|
||||||
|
if not path:
|
||||||
|
if not name.lower().endswith(FONT_EXTENSIONS):
|
||||||
|
return None
|
||||||
|
path = name
|
||||||
|
directory, filename = os.path.split(str(path))
|
||||||
|
stem, ext = os.path.splitext(filename)
|
||||||
|
if not stem or ext.lower() not in FONT_EXTENSIONS:
|
||||||
|
return None
|
||||||
|
if directory:
|
||||||
|
if not os.path.isabs(directory):
|
||||||
|
from src.common.font_layout import resolve_asset_path
|
||||||
|
directory = resolve_asset_path(directory)
|
||||||
|
if not _same_dir(directory, fonts_dir):
|
||||||
|
return None
|
||||||
|
return stem
|
||||||
|
|
||||||
|
|
||||||
|
def build_font_usage(font_manager: Any,
|
||||||
|
plugin_ids: Optional[FrozenSet[str]] = None,
|
||||||
|
fonts_dir: Optional[str] = None) -> Dict[str, List[str]]:
|
||||||
|
"""``{catalog key: sorted plugin ids}`` from FontManager's registrations.
|
||||||
|
|
||||||
|
``plugin_ids`` limits it to loaded plugins: a plugin whose constructor
|
||||||
|
registered fonts and which then failed validation is not using them.
|
||||||
|
"""
|
||||||
|
fonts_dir = fonts_dir or _fonts_dir()
|
||||||
|
# list() copies in one step under the GIL; register_manager_font() runs
|
||||||
|
# on the render thread while this runs on the publisher's.
|
||||||
|
registrations = list(getattr(font_manager, 'manager_fonts', {}).items())
|
||||||
|
font_catalog = dict(getattr(font_manager, 'font_catalog', {}))
|
||||||
|
usage: Dict[str, set] = {}
|
||||||
|
for manager_id, elements in registrations:
|
||||||
|
if not isinstance(manager_id, str):
|
||||||
|
continue
|
||||||
|
if plugin_ids is not None and manager_id not in plugin_ids:
|
||||||
|
continue
|
||||||
|
for spec in list(elements.values()) if isinstance(elements, dict) else ():
|
||||||
|
family = spec.get('family') if isinstance(spec, dict) else None
|
||||||
|
key = catalog_key_for(family, font_catalog, fonts_dir)
|
||||||
|
if key is not None:
|
||||||
|
usage.setdefault(key, set()).add(manager_id)
|
||||||
|
return {key: sorted(ids) for key, ids in sorted(usage.items())}
|
||||||
|
|
||||||
|
|
||||||
|
class FontUsagePublisher:
|
||||||
|
"""Publishes the display service's font usage to the shared cache.
|
||||||
|
|
||||||
|
tick() is the whole job; start() calls it from a daemon thread every
|
||||||
|
TICK_INTERVAL seconds. Nothing here raises: a failure is logged at debug
|
||||||
|
and retried on the next tick.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, cache_manager: Any, font_manager: Any, plugin_manager: Any = None,
|
||||||
|
clock: Callable[[], float] = time.monotonic) -> None:
|
||||||
|
self.cache_manager = cache_manager
|
||||||
|
self.font_manager = font_manager
|
||||||
|
self.plugin_manager = plugin_manager
|
||||||
|
self._clock = clock
|
||||||
|
self._signature: Optional[Tuple[Any, ...]] = None
|
||||||
|
# None forces a first write, replacing whatever a previous run of the
|
||||||
|
# service left in the cache with this run's usage.
|
||||||
|
self._published: Optional[Dict[str, List[str]]] = None
|
||||||
|
self._published_at: Optional[float] = None
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._stop = threading.Event()
|
||||||
|
self._thread: Optional[threading.Thread] = None
|
||||||
|
|
||||||
|
def _loaded_plugin_ids(self) -> Optional[FrozenSet[str]]:
|
||||||
|
plugins = getattr(self.plugin_manager, 'plugins', None)
|
||||||
|
if not isinstance(plugins, dict):
|
||||||
|
return None
|
||||||
|
return frozenset(list(plugins))
|
||||||
|
|
||||||
|
def tick(self) -> bool:
|
||||||
|
"""Publish if the usage changed. True if a snapshot was written."""
|
||||||
|
with self._lock:
|
||||||
|
try:
|
||||||
|
plugin_ids = self._loaded_plugin_ids()
|
||||||
|
signature = (
|
||||||
|
getattr(self.font_manager, 'manager_fonts_version', None),
|
||||||
|
getattr(self.font_manager, 'cache_generation', None),
|
||||||
|
plugin_ids,
|
||||||
|
)
|
||||||
|
now = self._clock()
|
||||||
|
refresh_due = (self._published_at is not None
|
||||||
|
and now - self._published_at >= REFRESH_INTERVAL)
|
||||||
|
if signature == self._signature and not refresh_due:
|
||||||
|
return False
|
||||||
|
usage = build_font_usage(self.font_manager, plugin_ids)
|
||||||
|
if usage == self._published and not refresh_due:
|
||||||
|
self._signature = signature
|
||||||
|
return False
|
||||||
|
self.cache_manager.set(FONT_USAGE_KEY, {
|
||||||
|
'generated_at': datetime.now().isoformat(timespec='seconds'),
|
||||||
|
'fonts': usage,
|
||||||
|
})
|
||||||
|
self._signature = signature
|
||||||
|
self._published = usage
|
||||||
|
self._published_at = now
|
||||||
|
return True
|
||||||
|
except Exception as err: # never let reporting break the display
|
||||||
|
logger.debug("Could not publish font usage: %s", err, exc_info=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def start(self, interval: float = TICK_INTERVAL) -> None:
|
||||||
|
"""Tick now, then from a daemon thread until stop(). No-op while running."""
|
||||||
|
if self._thread is not None and self._thread.is_alive():
|
||||||
|
return
|
||||||
|
self._stop.clear()
|
||||||
|
|
||||||
|
def run() -> None:
|
||||||
|
self.tick()
|
||||||
|
while not self._stop.wait(interval):
|
||||||
|
self.tick()
|
||||||
|
|
||||||
|
self._thread = threading.Thread(target=run, name="font-usage-publisher", daemon=True)
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self._stop.set()
|
||||||
|
if self._thread is not None:
|
||||||
|
self._thread.join(timeout=2)
|
||||||
|
self._thread = None
|
||||||
|
|
||||||
|
|
||||||
|
def start_font_usage_publisher(cache_manager: Any, font_manager: Any,
|
||||||
|
plugin_manager: Any) -> Optional[FontUsagePublisher]:
|
||||||
|
"""Start publishing font usage for the web interface. Never raises.
|
||||||
|
|
||||||
|
Call from the display service, once its plugins are loaded.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if cache_manager is None or font_manager is None or plugin_manager is None:
|
||||||
|
return None
|
||||||
|
publisher = FontUsagePublisher(cache_manager, font_manager, plugin_manager)
|
||||||
|
publisher.start()
|
||||||
|
return publisher
|
||||||
|
except Exception as err:
|
||||||
|
logger.warning("Font usage reporting to the web interface is unavailable: %s", err)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# --- Reading side (web interface) -------------------------------------------
|
||||||
|
|
||||||
|
def read_font_usage(cache_manager: Any) -> Optional[Dict[str, Any]]:
|
||||||
|
"""The display service's latest snapshot, or None if it has not published.
|
||||||
|
|
||||||
|
memory_ttl=0: the display service writes this key, so only the file is
|
||||||
|
current. The contents are checked and bounded -- they arrive from another
|
||||||
|
process through a shared directory.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
snapshot = cache_manager.get(FONT_USAGE_KEY, max_age=None, memory_ttl=0)
|
||||||
|
except Exception as err:
|
||||||
|
logger.debug("Could not read font usage: %s", err, exc_info=True)
|
||||||
|
return None
|
||||||
|
if not isinstance(snapshot, dict) or not isinstance(snapshot.get('fonts'), dict):
|
||||||
|
return None
|
||||||
|
fonts: Dict[str, List[str]] = {}
|
||||||
|
for key, ids in list(snapshot['fonts'].items())[:_MAX_FONTS]:
|
||||||
|
if not isinstance(key, str) or not isinstance(ids, list):
|
||||||
|
continue
|
||||||
|
clean = sorted({i[:_MAX_ID_CHARS] for i in ids[:_MAX_PLUGINS_PER_FONT]
|
||||||
|
if isinstance(i, str) and i})
|
||||||
|
if clean:
|
||||||
|
fonts[key[:_MAX_ID_CHARS]] = clean
|
||||||
|
generated_at = snapshot.get('generated_at')
|
||||||
|
return {
|
||||||
|
'generated_at': generated_at if isinstance(generated_at, str) else None,
|
||||||
|
'fonts': fonts,
|
||||||
|
}
|
||||||
@@ -624,6 +624,13 @@ class PluginManager:
|
|||||||
# Delegate sub-module and cached-module cleanup to the loader
|
# Delegate sub-module and cached-module cleanup to the loader
|
||||||
self.plugin_loader.unregister_plugin_modules(plugin_id)
|
self.plugin_loader.unregister_plugin_modules(plugin_id)
|
||||||
|
|
||||||
|
# Its font registrations go with it (the Fonts tab's "Used by").
|
||||||
|
try:
|
||||||
|
if self.font_manager is not None and hasattr(self.font_manager, 'forget_manager_fonts'):
|
||||||
|
self.font_manager.forget_manager_fonts(plugin_id)
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.debug("Could not forget fonts of %s: %s", plugin_id, e)
|
||||||
|
|
||||||
# Update state
|
# Update state
|
||||||
self.state_manager.set_state(plugin_id, PluginState.UNLOADED)
|
self.state_manager.set_state(plugin_id, PluginState.UNLOADED)
|
||||||
self.state_manager.clear_state(plugin_id)
|
self.state_manager.clear_state(plugin_id)
|
||||||
|
|||||||
@@ -99,8 +99,7 @@ def normalize_legacy_booleans(config: Any, schema: Any,
|
|||||||
#: config section, so they are allowed in every plugin's config whether or not
|
#: config section, so they are allowed in every plugin's config whether or not
|
||||||
#: the plugin's schema declares them. The one list for validation, for the web
|
#: the plugin's schema declares them. The one list for validation, for the web
|
||||||
#: save filter and for the load-time checks -- a private copy is how JSON saves
|
#: save filter and for the load-time checks -- a private copy is how JSON saves
|
||||||
#: came to drop ``skin`` and the ``vegas_*`` keys while the validator accepted
|
#: came to drop the ``vegas_*`` keys while the validator accepted them.
|
||||||
#: them.
|
|
||||||
#:
|
#:
|
||||||
#: Values are the schema used when the plugin does not declare the property.
|
#: Values are the schema used when the plugin does not declare the property.
|
||||||
CORE_PLUGIN_PROPERTIES: Dict[str, Dict[str, Any]] = {
|
CORE_PLUGIN_PROPERTIES: Dict[str, Dict[str, Any]] = {
|
||||||
@@ -123,19 +122,6 @@ CORE_PLUGIN_PROPERTIES: Dict[str, Dict[str, Any]] = {
|
|||||||
"default": False,
|
"default": False,
|
||||||
"description": "Enable live priority takeover when plugin has live content"
|
"description": "Enable live priority takeover when plugin has live content"
|
||||||
},
|
},
|
||||||
# Skin selection (docs/SKIN_SYSTEM.md). Deliberately NOT an enum here:
|
|
||||||
# validation must keep passing when a configured skin gets uninstalled
|
|
||||||
# (rendering falls back to built-in). The install-dependent enum is
|
|
||||||
# injected only at serve time (inject_skin_selector) for the web UI
|
|
||||||
# dropdown.
|
|
||||||
"skin": {
|
|
||||||
"type": ["string", "object", "null"],
|
|
||||||
"description": "Visual skin id, or a per-mode mapping like {\"live\": \"my-skin\"}"
|
|
||||||
},
|
|
||||||
"skin_options": {
|
|
||||||
"type": "object",
|
|
||||||
"description": "Options passed through to the selected skin"
|
|
||||||
},
|
|
||||||
# Vegas tuning read by vegas_mode/plugin_adapter.py and base_plugin.py.
|
# Vegas tuning read by vegas_mode/plugin_adapter.py and base_plugin.py.
|
||||||
# Left untyped: the adapter validates them itself and ignores a bad
|
# Left untyped: the adapter validates them itself and ignores a bad
|
||||||
# value with a log line, so a stored one must never block a save.
|
# value with a log line, so a stored one must never block a save.
|
||||||
@@ -158,6 +144,32 @@ CORE_VEGAS_TUNING_KEYS = frozenset({
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
#: Per-plugin keys the core used to own and no longer reads. ``skin`` and
|
||||||
|
#: ``skin_options`` belonged to the skin system, which was removed; a
|
||||||
|
#: config.json written before then can still carry them in any plugin section,
|
||||||
|
#: and most plugin schemas set ``additionalProperties: false``. They are
|
||||||
|
#: dropped wherever a section is prepared (prepare_plugin_config) or validated,
|
||||||
|
#: and the web saves drop them from the stored section, so an old config loads
|
||||||
|
#: and saves without a validation error and loses them on its next save.
|
||||||
|
RETIRED_PLUGIN_KEYS = frozenset({'skin', 'skin_options'})
|
||||||
|
|
||||||
|
|
||||||
|
def drop_retired_plugin_keys(config: Any, schema: Any) -> Any:
|
||||||
|
"""``config`` without the RETIRED_PLUGIN_KEYS its plugin's schema leaves undeclared.
|
||||||
|
|
||||||
|
A plugin whose schema declares one of these names owns it and keeps it;
|
||||||
|
without a schema nothing is dropped. Never mutates ``config``, and returns
|
||||||
|
it unchanged when there is nothing to drop.
|
||||||
|
"""
|
||||||
|
if not isinstance(config, dict) or not isinstance(schema, dict) \
|
||||||
|
or RETIRED_PLUGIN_KEYS.isdisjoint(config):
|
||||||
|
return config
|
||||||
|
declared = schema.get('properties')
|
||||||
|
declared = declared if isinstance(declared, dict) else {}
|
||||||
|
return {key: value for key, value in config.items()
|
||||||
|
if key not in RETIRED_PLUGIN_KEYS or key in declared}
|
||||||
|
|
||||||
|
|
||||||
def with_core_plugin_properties(schema: Dict[str, Any]) -> Dict[str, Any]:
|
def with_core_plugin_properties(schema: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""A deep copy of a plugin schema with CORE_PLUGIN_PROPERTIES allowed.
|
"""A deep copy of a plugin schema with CORE_PLUGIN_PROPERTIES allowed.
|
||||||
|
|
||||||
@@ -299,14 +311,15 @@ def prepare_plugin_config(config: Any, schema: Optional[Dict[str, Any]],
|
|||||||
changed_paths: Optional[List[str]] = None) -> Dict[str, Any]:
|
changed_paths: Optional[List[str]] = None) -> Dict[str, Any]:
|
||||||
"""The config a plugin runs with, from its stored (or submitted) section.
|
"""The config a plugin runs with, from its stored (or submitted) section.
|
||||||
|
|
||||||
Legacy booleans are read as ``{"enabled": ...}`` objects
|
Retired core keys are dropped (drop_retired_plugin_keys), legacy booleans
|
||||||
(normalize_legacy_booleans), then schema defaults fill in whatever is
|
are read as ``{"enabled": ...}`` objects (normalize_legacy_booleans), then
|
||||||
missing. Loading a plugin, both config saves, GET /plugins/config, hot
|
schema defaults fill in whatever is missing. Loading a plugin, both config
|
||||||
reload and the dev tools all go through this, so a plugin sees the same
|
saves, GET /plugins/config, hot reload and the dev tools all go through
|
||||||
shape however its config reached it.
|
this, so a plugin sees the same shape however its config reached it.
|
||||||
"""
|
"""
|
||||||
config = config if isinstance(config, dict) else {}
|
config = config if isinstance(config, dict) else {}
|
||||||
if schema:
|
if schema:
|
||||||
|
config = drop_retired_plugin_keys(config, schema)
|
||||||
config = normalize_legacy_booleans(config, schema, changed_paths)
|
config = normalize_legacy_booleans(config, schema, changed_paths)
|
||||||
return merge_config_defaults(config, defaults)
|
return merge_config_defaults(config, defaults)
|
||||||
|
|
||||||
@@ -620,7 +633,8 @@ class SchemaManager:
|
|||||||
# Core plugin properties (CORE_PLUGIN_PROPERTIES) are handled by
|
# Core plugin properties (CORE_PLUGIN_PROPERTIES) are handled by
|
||||||
# the base plugin system and should not cause validation failures:
|
# the base plugin system and should not cause validation failures:
|
||||||
# they are allowed even when the plugin's schema doesn't declare
|
# they are allowed even when the plugin's schema doesn't declare
|
||||||
# them, and never required.
|
# them, and never required. Retired ones are ignored.
|
||||||
|
config = drop_retired_plugin_keys(config, schema)
|
||||||
enhanced_schema = with_core_plugin_properties(schema)
|
enhanced_schema = with_core_plugin_properties(schema)
|
||||||
if plugin_id:
|
if plugin_id:
|
||||||
declared = schema.get("properties", {}) if isinstance(schema, dict) else {}
|
declared = schema.get("properties", {}) if isinstance(schema, dict) else {}
|
||||||
@@ -658,53 +672,6 @@ class SchemaManager:
|
|||||||
self.logger.error(error_msg)
|
self.logger.error(error_msg)
|
||||||
return False, [error_msg]
|
return False, [error_msg]
|
||||||
|
|
||||||
def inject_skin_selector(self, schema: Dict[str, Any], plugin_id: str,
|
|
||||||
current_value: Any = None) -> Dict[str, Any]:
|
|
||||||
"""Return a copy of a plugin's schema with a "skin" dropdown added
|
|
||||||
when installed skins target this plugin (docs/SKIN_SYSTEM.md).
|
|
||||||
|
|
||||||
Serve-time only — validation never sees this enum, so a config
|
|
||||||
referencing an uninstalled skin stays valid (rendering falls back
|
|
||||||
to the built-in layout). The currently-configured value is always
|
|
||||||
included in the enum for the same reason: the dropdown must be able
|
|
||||||
to display a selection whose skin was removed.
|
|
||||||
"""
|
|
||||||
# A per-mode mapping ({"live": ..., "recent": ...}) can't be edited
|
|
||||||
# through a string dropdown — injecting one would let the form save
|
|
||||||
# a string over the mapping. Leave the schema alone; per-mode users
|
|
||||||
# edit via the raw JSON config editor.
|
|
||||||
if isinstance(current_value, dict):
|
|
||||||
return schema
|
|
||||||
|
|
||||||
try:
|
|
||||||
from src.skin_system import skin_runtime
|
|
||||||
matching = skin_runtime.skins_for_plugin(plugin_id)
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.debug(f"Skin discovery failed for {plugin_id}: {e}")
|
|
||||||
return schema
|
|
||||||
|
|
||||||
choices = sorted(matching.keys())
|
|
||||||
if isinstance(current_value, str) and current_value and \
|
|
||||||
current_value != "built-in" and current_value not in choices:
|
|
||||||
choices.append(current_value)
|
|
||||||
if not choices:
|
|
||||||
return schema
|
|
||||||
|
|
||||||
enhanced = copy.deepcopy(schema)
|
|
||||||
enhanced.setdefault("properties", {})
|
|
||||||
if "skin" not in enhanced["properties"]:
|
|
||||||
names = {sid: (matching.get(sid, {}).get("name") or sid) for sid in choices}
|
|
||||||
enhanced["properties"]["skin"] = {
|
|
||||||
"type": "string",
|
|
||||||
"title": "Visual Skin",
|
|
||||||
"description": "Replace this scoreboard's look with an installed skin "
|
|
||||||
"(data, scheduling, and vegas mode are unaffected)",
|
|
||||||
"enum": ["built-in", *choices],
|
|
||||||
"enumNames": ["Built-in", *(names[sid] for sid in choices)],
|
|
||||||
"default": "built-in"
|
|
||||||
}
|
|
||||||
return enhanced
|
|
||||||
|
|
||||||
def _format_validation_error(self, error: ValidationError, plugin_id: Optional[str] = None) -> str:
|
def _format_validation_error(self, error: ValidationError, plugin_id: Optional[str] = None) -> str:
|
||||||
"""
|
"""
|
||||||
Format a validation error into a readable message.
|
Format a validation error into a readable message.
|
||||||
|
|||||||
@@ -54,6 +54,17 @@ class PluginStoreManager:
|
|||||||
# would delete — an empty id resolves to the plugins root itself.
|
# would delete — an empty id resolves to the plugins root itself.
|
||||||
_PLUGIN_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
_PLUGIN_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_plugin_entry(entry) -> bool:
|
||||||
|
"""Whether a registry entry is a plugin core can install.
|
||||||
|
|
||||||
|
A missing ``type`` means plugin. Anything else (registries used to
|
||||||
|
carry ``"type": "skin"`` entries, and a custom registry still can) is
|
||||||
|
hidden from the store and refused at install, rather than being
|
||||||
|
unpacked into the plugins directory as if it were a plugin.
|
||||||
|
"""
|
||||||
|
return isinstance(entry, dict) and (entry.get('type') or 'plugin') == 'plugin'
|
||||||
|
|
||||||
def __init__(self, plugins_dir: str = "plugins",
|
def __init__(self, plugins_dir: str = "plugins",
|
||||||
uninstalled_registry_path: Optional[str] = None):
|
uninstalled_registry_path: Optional[str] = None):
|
||||||
"""
|
"""
|
||||||
@@ -1314,18 +1325,10 @@ class PluginStoreManager:
|
|||||||
if not plugin_info:
|
if not plugin_info:
|
||||||
self.logger.error(f"Plugin not found in registry: {plugin_id}")
|
self.logger.error(f"Plugin not found in registry: {plugin_id}")
|
||||||
return False
|
return False
|
||||||
|
if not self.is_plugin_entry(plugin_info):
|
||||||
# Visual skins share the registry. _install_skin_from_info can put one
|
self.logger.error(f"Not installing {plugin_id}: registry entry type "
|
||||||
# in skins/, but no current scoreboard plugin renders skins, so the
|
f"{plugin_info.get('type')!r} is not a plugin")
|
||||||
# store refuses them rather than installing something that does
|
return False
|
||||||
# nothing (docs/SKIN_SYSTEM.md). Manual installs under skins/ and
|
|
||||||
# uninstall_skin are unaffected.
|
|
||||||
if (plugin_info.get('type') or 'plugin') == 'skin':
|
|
||||||
from src.skin_system import SKINS_RENDER_SUPPORTED, SKINS_UNSUPPORTED_MESSAGE
|
|
||||||
if not SKINS_RENDER_SUPPORTED:
|
|
||||||
self.logger.error(f"Not installing skin {plugin_id}: {SKINS_UNSUPPORTED_MESSAGE}")
|
|
||||||
return False
|
|
||||||
return self._install_skin_from_info(plugin_id, plugin_info, branch)
|
|
||||||
|
|
||||||
repo_url = plugin_info.get('repo')
|
repo_url = plugin_info.get('repo')
|
||||||
if not repo_url:
|
if not repo_url:
|
||||||
@@ -2479,152 +2482,6 @@ class PluginStoreManager:
|
|||||||
continue
|
continue
|
||||||
return None
|
return None
|
||||||
|
|
||||||
_SKIN_ID_PATTERN = re.compile(r'^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$')
|
|
||||||
|
|
||||||
def _resolve_skin_target(self, skin_id: str) -> Optional[Path]:
|
|
||||||
"""Validate an externally-supplied skin id and resolve it to a path
|
|
||||||
strictly inside the skins directory. Returns None (after logging)
|
|
||||||
for ids that are malformed or would escape the directory — registry
|
|
||||||
entries and manifests are external input and must not be able to
|
|
||||||
write or delete outside skins/."""
|
|
||||||
from src.skin_system import skin_runtime
|
|
||||||
|
|
||||||
if not isinstance(skin_id, str) or not self._SKIN_ID_PATTERN.match(skin_id) \
|
|
||||||
or '..' in skin_id:
|
|
||||||
self.logger.error(f"Rejecting unsafe skin id: {skin_id!r}")
|
|
||||||
return None
|
|
||||||
skins_dir = skin_runtime.get_skins_directory().resolve()
|
|
||||||
target = (skins_dir / skin_id).resolve()
|
|
||||||
if target.parent != skins_dir:
|
|
||||||
self.logger.error(f"Skin id {skin_id!r} escapes the skins directory; rejecting")
|
|
||||||
return None
|
|
||||||
return target
|
|
||||||
|
|
||||||
def _install_skin_from_info(self, skin_id: str, skin_info: Dict,
|
|
||||||
branch: Optional[str] = None) -> bool:
|
|
||||||
"""Install a registry entry of type "skin" into skins/<id>/.
|
|
||||||
|
|
||||||
Reuses the plugin download machinery (git / monorepo zip / archive)
|
|
||||||
but validates skin.json instead of manifest.json and never installs
|
|
||||||
dependencies — skins are render-only (stdlib + PIL + the provided
|
|
||||||
SkinContext), which is also what keeps them safe to iterate on.
|
|
||||||
|
|
||||||
Downloads into a staging directory and validates there; the
|
|
||||||
existing installation is only replaced after the new one passes,
|
|
||||||
so a failed download or bad manifest can't destroy a working skin.
|
|
||||||
"""
|
|
||||||
from src.skin_system import skin_runtime
|
|
||||||
from src.skin_system.skin_base import SKIN_API_VERSION
|
|
||||||
|
|
||||||
repo_url = skin_info.get('repo')
|
|
||||||
if not repo_url:
|
|
||||||
self.logger.error(f"Skin {skin_id} missing repository URL")
|
|
||||||
return False
|
|
||||||
|
|
||||||
target = self._resolve_skin_target(skin_id)
|
|
||||||
if target is None:
|
|
||||||
return False
|
|
||||||
skins_dir = target.parent
|
|
||||||
skins_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
# Leading "_" keeps staging invisible to skin discovery
|
|
||||||
staging = skins_dir / f"_staging-{skin_id}"
|
|
||||||
if staging.exists() and not self._safe_remove_directory(staging):
|
|
||||||
return False
|
|
||||||
|
|
||||||
subpath = skin_info.get('plugin_path')
|
|
||||||
branch_candidates = self._distinct_sequence([
|
|
||||||
branch,
|
|
||||||
skin_info.get('branch'),
|
|
||||||
skin_info.get('default_branch'),
|
|
||||||
skin_info.get('last_commit_branch'),
|
|
||||||
'main',
|
|
||||||
'master'
|
|
||||||
])
|
|
||||||
|
|
||||||
try:
|
|
||||||
branch_used = None
|
|
||||||
if subpath:
|
|
||||||
for candidate in branch_candidates:
|
|
||||||
download_url = f"{repo_url}/archive/refs/heads/{candidate}.zip"
|
|
||||||
if self._install_from_monorepo(download_url, subpath, staging):
|
|
||||||
branch_used = candidate
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
branch_used = self._install_via_git(repo_url, staging, branch_candidates)
|
|
||||||
if branch_used is None and not staging.exists():
|
|
||||||
for candidate in branch_candidates:
|
|
||||||
download_url = f"{repo_url}/archive/refs/heads/{candidate}.zip"
|
|
||||||
if self._install_via_download(download_url, staging):
|
|
||||||
branch_used = candidate
|
|
||||||
break
|
|
||||||
|
|
||||||
if branch_used is None and not staging.exists():
|
|
||||||
self.logger.error(f"Failed to install skin {skin_id} via git or archive download")
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(staging / 'skin.json', 'r', encoding='utf-8') as f:
|
|
||||||
manifest = json.load(f)
|
|
||||||
except (OSError, json.JSONDecodeError) as e:
|
|
||||||
self.logger.error(f"Skin {skin_id} has no valid skin.json: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
missing = [k for k in ('id', 'name', 'version', 'skin_api_version', 'class_name')
|
|
||||||
if not manifest.get(k)]
|
|
||||||
if missing:
|
|
||||||
self.logger.error(f"Skin {skin_id} manifest missing fields: {missing}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Unlike plugins, a mismatched id is rejected rather than
|
|
||||||
# renamed: the manifest id is external input, and the registry
|
|
||||||
# id is what the user asked to install.
|
|
||||||
if manifest['id'] != skin_id:
|
|
||||||
self.logger.error(
|
|
||||||
f"Skin manifest id {manifest['id']!r} doesn't match registry id "
|
|
||||||
f"{skin_id!r}; not installing")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _api_major(v):
|
|
||||||
try:
|
|
||||||
return int(str(v).split('.')[0])
|
|
||||||
except (ValueError, IndexError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
if _api_major(manifest['skin_api_version']) != _api_major(SKIN_API_VERSION):
|
|
||||||
self.logger.error(
|
|
||||||
f"Skin {skin_id} targets skin API {manifest['skin_api_version']} but this "
|
|
||||||
f"LEDMatrix provides {SKIN_API_VERSION}; not installing")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Validated — swap into place
|
|
||||||
if target.exists() and not self._safe_remove_directory(target):
|
|
||||||
self.logger.error(f"Could not replace existing skin directory: {target}")
|
|
||||||
return False
|
|
||||||
shutil.move(str(staging), str(target))
|
|
||||||
skin_runtime.discover_skins(force_refresh=True)
|
|
||||||
self.logger.info(f"Successfully installed skin: {skin_id} (branch: {branch_used})")
|
|
||||||
return True
|
|
||||||
finally:
|
|
||||||
if staging.exists():
|
|
||||||
self._safe_remove_directory(staging)
|
|
||||||
|
|
||||||
def uninstall_skin(self, skin_id: str) -> bool:
|
|
||||||
"""Remove an installed skin. Plugin configs referencing it keep
|
|
||||||
validating; rendering falls back to the built-in layout."""
|
|
||||||
from src.skin_system import skin_runtime
|
|
||||||
|
|
||||||
target = self._resolve_skin_target(skin_id)
|
|
||||||
if target is None:
|
|
||||||
return False
|
|
||||||
if not target.exists():
|
|
||||||
self.logger.info(f"Skin {skin_id} not found (already uninstalled)")
|
|
||||||
return True
|
|
||||||
if self._safe_remove_directory(target):
|
|
||||||
skin_runtime.discover_skins(force_refresh=True)
|
|
||||||
self.logger.info(f"Successfully uninstalled skin: {skin_id}")
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def uninstall_plugin(self, plugin_id: str) -> bool:
|
def uninstall_plugin(self, plugin_id: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Uninstall a plugin by removing its directory.
|
Uninstall a plugin by removing its directory.
|
||||||
@@ -2638,12 +2495,6 @@ class PluginStoreManager:
|
|||||||
plugin_path = self._find_plugin_path(plugin_id)
|
plugin_path = self._find_plugin_path(plugin_id)
|
||||||
|
|
||||||
if plugin_path is None or not plugin_path.exists():
|
if plugin_path is None or not plugin_path.exists():
|
||||||
# A skin id passed to the plugin uninstall path (the store UI
|
|
||||||
# uses one uninstall flow) removes the skin instead
|
|
||||||
skin_target = self._resolve_skin_target(plugin_id) \
|
|
||||||
if self._SKIN_ID_PATTERN.match(str(plugin_id)) else None
|
|
||||||
if skin_target is not None and skin_target.exists():
|
|
||||||
return self.uninstall_skin(plugin_id)
|
|
||||||
self.logger.info(f"Plugin {plugin_id} not found (already uninstalled)")
|
self.logger.info(f"Plugin {plugin_id} not found (already uninstalled)")
|
||||||
return True # Already uninstalled, consider this success
|
return True # Already uninstalled, consider this success
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""Credential redaction for text that leaves the process that produced it.
|
||||||
|
|
||||||
|
Kept free of Flask so the display service can redact what it publishes (see
|
||||||
|
src/error_aggregator.py) as well as the web interface what it returns.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
# Credentials that turn up inside exception text. A requests error quotes the
|
||||||
|
# URL it failed on, and plugins that authenticate by query string put their key
|
||||||
|
# there, so echoing an exception verbatim can hand out an API key. Redact the
|
||||||
|
# value, keep the parameter name -- knowing *which* credential was involved is
|
||||||
|
# part of the diagnosis.
|
||||||
|
_REDACT_CREDENTIAL = re.compile(
|
||||||
|
r'((?:api[_-]?key|access[_-]?token|auth|apikey|key|passwd|password|pwd|'
|
||||||
|
r'secret|sig|signature|token)["\']?\s*[=:]\s*["\']?)([^\s&"\'<>,}]+)',
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# `Authorization: <scheme> <credential>`. The scheme name is kept because it
|
||||||
|
# says which kind of credential failed; the credential goes. Any scheme
|
||||||
|
# matches, not a fixed list: ApiKey, Negotiate, NTLM, AWS4-HMAC-SHA256 and
|
||||||
|
# whatever a plugin's API invents next are all credentials, and a list would
|
||||||
|
# silently leak the ones nobody thought of. Not covered by the generic pattern
|
||||||
|
# above, whose value part stops at whitespace and so would keep the credential
|
||||||
|
# once a space follows the scheme.
|
||||||
|
_REDACT_AUTH_HEADER = re.compile(
|
||||||
|
r'((?:proxy-)?authorization["\']?\s*[=:]\s*["\']?\s*'
|
||||||
|
r'(?:[A-Za-z][\w.+-]*[ \t]+)?)' # optional scheme name, kept
|
||||||
|
r'([^\s,"\'<>}]+)', # the credential, redacted
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Credentials embedded in a URL: https://user:password@host. requests quotes
|
||||||
|
# the full URL in its exceptions, so this is a realistic leak. The username is
|
||||||
|
# kept -- it identifies which account failed without being the secret.
|
||||||
|
_REDACT_URL_USERINFO = re.compile(r'([a-z][a-z0-9+.-]*://[^/\s:@]+:)([^/\s@]+)(@)',
|
||||||
|
re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def redact_credentials(text: str) -> str:
|
||||||
|
"""Replace credentials in ``text`` with ``<redacted>``; keep everything
|
||||||
|
else, including line breaks, so a stack trace stays readable."""
|
||||||
|
text = text or ''
|
||||||
|
# Order matters: the URL and header forms are more specific than the
|
||||||
|
# generic key=value pattern, which would otherwise chew the scheme.
|
||||||
|
text = _REDACT_URL_USERINFO.sub(r'\1<redacted>\3', text)
|
||||||
|
text = _REDACT_AUTH_HEADER.sub(r'\1<redacted>', text)
|
||||||
|
return _REDACT_CREDENTIAL.sub(r'\1<redacted>', text)
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
"""
|
|
||||||
Skin system: user-installable visual overlays for sports scoreboards.
|
|
||||||
|
|
||||||
A skin replaces only the rendering of a scoreboard (live / recent /
|
|
||||||
upcoming) while the host plugin keeps doing data fetching, scheduling,
|
|
||||||
caching, live priority, and vegas mode. See docs/SKIN_SYSTEM.md.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Skins are not offered to users yet. The only render hook is
|
|
||||||
# SportsCore._render_game in src/base_classes/sports/core.py, and none of the
|
|
||||||
# current scoreboard plugins (monorepo or third-party) build on
|
|
||||||
# src.base_classes, so a selected skin never draws. The web UI and store
|
|
||||||
# read these instead of offering install/selection; stored "skin" config
|
|
||||||
# values still load and save. See docs/SKIN_SYSTEM.md.
|
|
||||||
SKINS_RENDER_SUPPORTED = False
|
|
||||||
SKINS_UNSUPPORTED_MESSAGE = (
|
|
||||||
"Skins aren't supported yet: the current scoreboard plugins don't render "
|
|
||||||
"them. Installed skins and saved skin settings are kept but have no effect."
|
|
||||||
)
|
|
||||||
|
|
||||||
from src.skin_system.skin_base import ( # noqa: E402
|
|
||||||
SKIN_API_VERSION,
|
|
||||||
VIEW_MODEL_VERSION,
|
|
||||||
ScoreboardSkin,
|
|
||||||
SkinContext,
|
|
||||||
)
|
|
||||||
from src.skin_system.skin_runtime import (
|
|
||||||
build_context,
|
|
||||||
discover_skins,
|
|
||||||
get_skins_directory,
|
|
||||||
load_skin,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"SKIN_API_VERSION",
|
|
||||||
"VIEW_MODEL_VERSION",
|
|
||||||
"ScoreboardSkin",
|
|
||||||
"SkinContext",
|
|
||||||
"build_context",
|
|
||||||
"discover_skins",
|
|
||||||
"get_skins_directory",
|
|
||||||
"load_skin",
|
|
||||||
]
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "401570001",
|
|
||||||
"game_time": "7:05PM",
|
|
||||||
"game_date": "Jul 16th",
|
|
||||||
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
|
||||||
"status_text": "Bot 7th",
|
|
||||||
"is_live": true,
|
|
||||||
"is_final": false,
|
|
||||||
"is_upcoming": false,
|
|
||||||
"is_halftime": false,
|
|
||||||
"is_period_break": false,
|
|
||||||
"home_abbr": "LAD",
|
|
||||||
"home_id": "19",
|
|
||||||
"home_score": "5",
|
|
||||||
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
|
||||||
"home_logo_url": null,
|
|
||||||
"home_record": "58-33",
|
|
||||||
"away_abbr": "SF",
|
|
||||||
"away_id": "26",
|
|
||||||
"away_score": "3",
|
|
||||||
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
|
||||||
"away_logo_url": null,
|
|
||||||
"away_record": "49-42",
|
|
||||||
"is_within_window": true,
|
|
||||||
"status": "STATUS_IN_PROGRESS",
|
|
||||||
"status_state": "in",
|
|
||||||
"inning": 7,
|
|
||||||
"inning_half": "bottom",
|
|
||||||
"balls": 3,
|
|
||||||
"strikes": 2,
|
|
||||||
"outs": 2,
|
|
||||||
"bases_occupied": [
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
true
|
|
||||||
],
|
|
||||||
"start_time": "2026-07-16T23:05:00Z",
|
|
||||||
"series_summary": "LAD leads 2-1"
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "401570001",
|
|
||||||
"game_time": "7:05PM",
|
|
||||||
"game_date": "Jul 16th",
|
|
||||||
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
|
||||||
"status_text": "Final",
|
|
||||||
"is_live": false,
|
|
||||||
"is_final": true,
|
|
||||||
"is_upcoming": false,
|
|
||||||
"is_halftime": false,
|
|
||||||
"is_period_break": false,
|
|
||||||
"home_abbr": "LAD",
|
|
||||||
"home_id": "19",
|
|
||||||
"home_score": "5",
|
|
||||||
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
|
||||||
"home_logo_url": null,
|
|
||||||
"home_record": "58-33",
|
|
||||||
"away_abbr": "SF",
|
|
||||||
"away_id": "26",
|
|
||||||
"away_score": "3",
|
|
||||||
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
|
||||||
"away_logo_url": null,
|
|
||||||
"away_record": "49-42",
|
|
||||||
"is_within_window": true,
|
|
||||||
"status": "STATUS_FINAL",
|
|
||||||
"status_state": "post",
|
|
||||||
"inning": 9,
|
|
||||||
"inning_half": "top",
|
|
||||||
"balls": 0,
|
|
||||||
"strikes": 0,
|
|
||||||
"outs": 3,
|
|
||||||
"bases_occupied": [
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false
|
|
||||||
],
|
|
||||||
"start_time": "2026-07-16T23:05:00Z",
|
|
||||||
"series_summary": "Series tied 2-2"
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "401570001",
|
|
||||||
"game_time": "7:05PM",
|
|
||||||
"game_date": "Jul 16th",
|
|
||||||
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
|
||||||
"status_text": "7:05 PM",
|
|
||||||
"is_live": false,
|
|
||||||
"is_final": false,
|
|
||||||
"is_upcoming": true,
|
|
||||||
"is_halftime": false,
|
|
||||||
"is_period_break": false,
|
|
||||||
"home_abbr": "LAD",
|
|
||||||
"home_id": "19",
|
|
||||||
"home_score": "0",
|
|
||||||
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
|
||||||
"home_logo_url": null,
|
|
||||||
"home_record": "58-33",
|
|
||||||
"away_abbr": "SF",
|
|
||||||
"away_id": "26",
|
|
||||||
"away_score": "0",
|
|
||||||
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
|
||||||
"away_logo_url": null,
|
|
||||||
"away_record": "49-42",
|
|
||||||
"is_within_window": true,
|
|
||||||
"status": "STATUS_SCHEDULED",
|
|
||||||
"status_state": "pre",
|
|
||||||
"inning": 0,
|
|
||||||
"inning_half": "top",
|
|
||||||
"balls": 0,
|
|
||||||
"strikes": 0,
|
|
||||||
"outs": 0,
|
|
||||||
"bases_occupied": [
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false
|
|
||||||
],
|
|
||||||
"start_time": "2026-07-16T23:05:00Z",
|
|
||||||
"series_summary": ""
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "401570001",
|
|
||||||
"game_time": "7:05PM",
|
|
||||||
"game_date": "Jul 16th",
|
|
||||||
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
|
||||||
"status_text": "Q4 2:34",
|
|
||||||
"is_live": true,
|
|
||||||
"is_final": false,
|
|
||||||
"is_upcoming": false,
|
|
||||||
"is_halftime": false,
|
|
||||||
"is_period_break": false,
|
|
||||||
"home_abbr": "OKC",
|
|
||||||
"home_id": "19",
|
|
||||||
"home_score": "5",
|
|
||||||
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
|
||||||
"home_logo_url": null,
|
|
||||||
"home_record": "",
|
|
||||||
"away_abbr": "MIN",
|
|
||||||
"away_id": "26",
|
|
||||||
"away_score": "3",
|
|
||||||
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
|
||||||
"away_logo_url": null,
|
|
||||||
"away_record": "",
|
|
||||||
"is_within_window": true,
|
|
||||||
"period": 4,
|
|
||||||
"period_text": "Q4",
|
|
||||||
"clock": "2:34"
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "401570001",
|
|
||||||
"game_time": "7:05PM",
|
|
||||||
"game_date": "Jul 16th",
|
|
||||||
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
|
||||||
"status_text": "Final",
|
|
||||||
"is_live": false,
|
|
||||||
"is_final": true,
|
|
||||||
"is_upcoming": false,
|
|
||||||
"is_halftime": false,
|
|
||||||
"is_period_break": false,
|
|
||||||
"home_abbr": "OKC",
|
|
||||||
"home_id": "19",
|
|
||||||
"home_score": "5",
|
|
||||||
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
|
||||||
"home_logo_url": null,
|
|
||||||
"home_record": "",
|
|
||||||
"away_abbr": "MIN",
|
|
||||||
"away_id": "26",
|
|
||||||
"away_score": "3",
|
|
||||||
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
|
||||||
"away_logo_url": null,
|
|
||||||
"away_record": "",
|
|
||||||
"is_within_window": true,
|
|
||||||
"period": 4,
|
|
||||||
"period_text": "Final",
|
|
||||||
"clock": "0:00"
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "401570001",
|
|
||||||
"game_time": "7:05PM",
|
|
||||||
"game_date": "Jul 16th",
|
|
||||||
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
|
||||||
"status_text": "7:05 PM",
|
|
||||||
"is_live": false,
|
|
||||||
"is_final": false,
|
|
||||||
"is_upcoming": true,
|
|
||||||
"is_halftime": false,
|
|
||||||
"is_period_break": false,
|
|
||||||
"home_abbr": "OKC",
|
|
||||||
"home_id": "19",
|
|
||||||
"home_score": "0",
|
|
||||||
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
|
||||||
"home_logo_url": null,
|
|
||||||
"home_record": "",
|
|
||||||
"away_abbr": "MIN",
|
|
||||||
"away_id": "26",
|
|
||||||
"away_score": "0",
|
|
||||||
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
|
||||||
"away_logo_url": null,
|
|
||||||
"away_record": "",
|
|
||||||
"is_within_window": true,
|
|
||||||
"period": 0,
|
|
||||||
"period_text": "",
|
|
||||||
"clock": "0:00"
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "401570001",
|
|
||||||
"game_time": "7:05PM",
|
|
||||||
"game_date": "Jul 16th",
|
|
||||||
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
|
||||||
"status_text": "Q3 8:12",
|
|
||||||
"is_live": true,
|
|
||||||
"is_final": false,
|
|
||||||
"is_upcoming": false,
|
|
||||||
"is_halftime": false,
|
|
||||||
"is_period_break": false,
|
|
||||||
"home_abbr": "KC",
|
|
||||||
"home_id": "19",
|
|
||||||
"home_score": "21",
|
|
||||||
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
|
||||||
"home_logo_url": null,
|
|
||||||
"home_record": "58-33",
|
|
||||||
"away_abbr": "BUF",
|
|
||||||
"away_id": "26",
|
|
||||||
"away_score": "17",
|
|
||||||
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
|
||||||
"away_logo_url": null,
|
|
||||||
"away_record": "49-42",
|
|
||||||
"is_within_window": true,
|
|
||||||
"period": 3,
|
|
||||||
"period_text": "Q3",
|
|
||||||
"clock": "8:12",
|
|
||||||
"home_timeouts": 2,
|
|
||||||
"away_timeouts": 3,
|
|
||||||
"down_distance_text": "3rd & 4",
|
|
||||||
"down_distance_text_long": "3rd & 4 at KC 22",
|
|
||||||
"is_redzone": true,
|
|
||||||
"possession": "12",
|
|
||||||
"possession_indicator": "away",
|
|
||||||
"scoring_event": null
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "401570001",
|
|
||||||
"game_time": "7:05PM",
|
|
||||||
"game_date": "Jul 16th",
|
|
||||||
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
|
||||||
"status_text": "Final",
|
|
||||||
"is_live": false,
|
|
||||||
"is_final": true,
|
|
||||||
"is_upcoming": false,
|
|
||||||
"is_halftime": false,
|
|
||||||
"is_period_break": false,
|
|
||||||
"home_abbr": "KC",
|
|
||||||
"home_id": "19",
|
|
||||||
"home_score": "21",
|
|
||||||
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
|
||||||
"home_logo_url": null,
|
|
||||||
"home_record": "58-33",
|
|
||||||
"away_abbr": "BUF",
|
|
||||||
"away_id": "26",
|
|
||||||
"away_score": "17",
|
|
||||||
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
|
||||||
"away_logo_url": null,
|
|
||||||
"away_record": "49-42",
|
|
||||||
"is_within_window": true,
|
|
||||||
"period": 4,
|
|
||||||
"period_text": "Final",
|
|
||||||
"clock": "0:00",
|
|
||||||
"home_timeouts": 0,
|
|
||||||
"away_timeouts": 0,
|
|
||||||
"down_distance_text": "",
|
|
||||||
"down_distance_text_long": "",
|
|
||||||
"is_redzone": false,
|
|
||||||
"possession": null,
|
|
||||||
"possession_indicator": null,
|
|
||||||
"scoring_event": null
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "401570001",
|
|
||||||
"game_time": "7:05PM",
|
|
||||||
"game_date": "Jul 16th",
|
|
||||||
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
|
||||||
"status_text": "7:05 PM",
|
|
||||||
"is_live": false,
|
|
||||||
"is_final": false,
|
|
||||||
"is_upcoming": true,
|
|
||||||
"is_halftime": false,
|
|
||||||
"is_period_break": false,
|
|
||||||
"home_abbr": "KC",
|
|
||||||
"home_id": "19",
|
|
||||||
"home_score": "0",
|
|
||||||
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
|
||||||
"home_logo_url": null,
|
|
||||||
"home_record": "58-33",
|
|
||||||
"away_abbr": "BUF",
|
|
||||||
"away_id": "26",
|
|
||||||
"away_score": "0",
|
|
||||||
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
|
||||||
"away_logo_url": null,
|
|
||||||
"away_record": "49-42",
|
|
||||||
"is_within_window": true,
|
|
||||||
"period": 0,
|
|
||||||
"period_text": "",
|
|
||||||
"clock": "0:00",
|
|
||||||
"home_timeouts": 3,
|
|
||||||
"away_timeouts": 3,
|
|
||||||
"down_distance_text": "",
|
|
||||||
"down_distance_text_long": "",
|
|
||||||
"is_redzone": false,
|
|
||||||
"possession": null,
|
|
||||||
"possession_indicator": null,
|
|
||||||
"scoring_event": null
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "401570001",
|
|
||||||
"game_time": "7:05PM",
|
|
||||||
"game_date": "Jul 16th",
|
|
||||||
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
|
||||||
"status_text": "P3 14:55",
|
|
||||||
"is_live": true,
|
|
||||||
"is_final": false,
|
|
||||||
"is_upcoming": false,
|
|
||||||
"is_halftime": false,
|
|
||||||
"is_period_break": false,
|
|
||||||
"home_abbr": "COL",
|
|
||||||
"home_id": "19",
|
|
||||||
"home_score": "2",
|
|
||||||
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
|
||||||
"home_logo_url": null,
|
|
||||||
"home_record": "58-33",
|
|
||||||
"away_abbr": "VGK",
|
|
||||||
"away_id": "26",
|
|
||||||
"away_score": "2",
|
|
||||||
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
|
||||||
"away_logo_url": null,
|
|
||||||
"away_record": "49-42",
|
|
||||||
"is_within_window": true,
|
|
||||||
"period": 3,
|
|
||||||
"period_text": "P3",
|
|
||||||
"clock": "14:55",
|
|
||||||
"power_play": true,
|
|
||||||
"penalties": [],
|
|
||||||
"home_shots": 27,
|
|
||||||
"away_shots": 31
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "401570001",
|
|
||||||
"game_time": "7:05PM",
|
|
||||||
"game_date": "Jul 16th",
|
|
||||||
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
|
||||||
"status_text": "Final/OT",
|
|
||||||
"is_live": false,
|
|
||||||
"is_final": true,
|
|
||||||
"is_upcoming": false,
|
|
||||||
"is_halftime": false,
|
|
||||||
"is_period_break": false,
|
|
||||||
"home_abbr": "COL",
|
|
||||||
"home_id": "19",
|
|
||||||
"home_score": "3",
|
|
||||||
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
|
||||||
"home_logo_url": null,
|
|
||||||
"home_record": "58-33",
|
|
||||||
"away_abbr": "VGK",
|
|
||||||
"away_id": "26",
|
|
||||||
"away_score": "2",
|
|
||||||
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
|
||||||
"away_logo_url": null,
|
|
||||||
"away_record": "49-42",
|
|
||||||
"is_within_window": true,
|
|
||||||
"period": 5,
|
|
||||||
"period_text": "Final/OT",
|
|
||||||
"clock": "0:00",
|
|
||||||
"power_play": false,
|
|
||||||
"penalties": [],
|
|
||||||
"home_shots": 35,
|
|
||||||
"away_shots": 33
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "401570001",
|
|
||||||
"game_time": "7:05PM",
|
|
||||||
"game_date": "Jul 16th",
|
|
||||||
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
|
||||||
"status_text": "7:05 PM",
|
|
||||||
"is_live": false,
|
|
||||||
"is_final": false,
|
|
||||||
"is_upcoming": true,
|
|
||||||
"is_halftime": false,
|
|
||||||
"is_period_break": false,
|
|
||||||
"home_abbr": "COL",
|
|
||||||
"home_id": "19",
|
|
||||||
"home_score": "0",
|
|
||||||
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
|
||||||
"home_logo_url": null,
|
|
||||||
"home_record": "58-33",
|
|
||||||
"away_abbr": "VGK",
|
|
||||||
"away_id": "26",
|
|
||||||
"away_score": "0",
|
|
||||||
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
|
||||||
"away_logo_url": null,
|
|
||||||
"away_record": "49-42",
|
|
||||||
"is_within_window": true,
|
|
||||||
"period": 0,
|
|
||||||
"period_text": "",
|
|
||||||
"clock": "0:00",
|
|
||||||
"power_play": false,
|
|
||||||
"penalties": [],
|
|
||||||
"home_shots": 0,
|
|
||||||
"away_shots": 0
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 444 B |
Binary file not shown.
|
Before Width: | Height: | Size: 446 B |
@@ -1,171 +0,0 @@
|
|||||||
"""
|
|
||||||
Skin API: the classes a skin author works with.
|
|
||||||
|
|
||||||
A skin is a directory under skins/<skin-id>/ containing a skin.json
|
|
||||||
manifest and a Python module exposing a ScoreboardSkin subclass. The
|
|
||||||
host (a sports scoreboard's base classes) builds a SkinContext per
|
|
||||||
render and calls render_live / render_recent / render_upcoming with the
|
|
||||||
game view model. The skin draws onto ctx.canvas and returns True; the
|
|
||||||
host composites the canvas onto the display. A skin never talks to the
|
|
||||||
display, the network, or the plugin directly.
|
|
||||||
|
|
||||||
Skin API Version: 1.0.0
|
|
||||||
View Model Version: 1.0
|
|
||||||
"""
|
|
||||||
|
|
||||||
from abc import ABC
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Any, Callable, Dict, Optional, Tuple, Union
|
|
||||||
|
|
||||||
from PIL import Image, ImageDraw
|
|
||||||
|
|
||||||
try:
|
|
||||||
import freetype
|
|
||||||
except ImportError: # pragma: no cover - freetype ships with the project deps
|
|
||||||
freetype = None
|
|
||||||
|
|
||||||
from src.adaptive_layout import FitResult, LayoutContext, Region
|
|
||||||
|
|
||||||
# Major must match a skin manifest's skin_api_version major or the skin
|
|
||||||
# is refused at load time (renames/removals bump major; additions minor).
|
|
||||||
SKIN_API_VERSION = "1.0.0"
|
|
||||||
|
|
||||||
# Version of the guaranteed `game` dict keys (see docs/CREATING_SKINS.md).
|
|
||||||
VIEW_MODEL_VERSION = "1.0"
|
|
||||||
|
|
||||||
|
|
||||||
def _draw_bdf_text_on(draw: ImageDraw.ImageDraw, text: str, x: int, y: int,
|
|
||||||
color: Tuple[int, int, int], face: Any,
|
|
||||||
clip_w: int, clip_h: int) -> None:
|
|
||||||
"""Render a freetype BDF face glyph-by-glyph onto an arbitrary canvas.
|
|
||||||
|
|
||||||
DisplayManager._draw_bdf_text only draws onto the panel image; skins
|
|
||||||
draw onto their own canvas, so the fitted-font path (fit_text can
|
|
||||||
return freetype faces) needs this standalone equivalent.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
ascender_px = face.size.ascender >> 6
|
|
||||||
except Exception:
|
|
||||||
ascender_px = 0
|
|
||||||
baseline_y = y + ascender_px
|
|
||||||
for char in text:
|
|
||||||
face.load_char(char)
|
|
||||||
bitmap = face.glyph.bitmap
|
|
||||||
glyph_left = face.glyph.bitmap_left
|
|
||||||
glyph_top = face.glyph.bitmap_top
|
|
||||||
for i in range(bitmap.rows):
|
|
||||||
for j in range(bitmap.width):
|
|
||||||
byte_index = i * bitmap.pitch + (j // 8)
|
|
||||||
if byte_index < len(bitmap.buffer) and \
|
|
||||||
bitmap.buffer[byte_index] & (1 << (7 - (j % 8))):
|
|
||||||
px = x + glyph_left + j
|
|
||||||
py = baseline_y - glyph_top + i
|
|
||||||
if 0 <= px < clip_w and 0 <= py < clip_h:
|
|
||||||
draw.point((px, py), fill=color)
|
|
||||||
x += face.glyph.advance.x >> 6
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class SkinContext:
|
|
||||||
"""Everything a skin may touch during one render call.
|
|
||||||
|
|
||||||
The canvas is a fresh RGB image sized to the current display (or
|
|
||||||
vegas card). Draw onto it via the helpers below or raw ``draw``;
|
|
||||||
never call display/update methods — the host composites the canvas.
|
|
||||||
"""
|
|
||||||
|
|
||||||
canvas: Image.Image
|
|
||||||
draw: ImageDraw.ImageDraw
|
|
||||||
layout: LayoutContext
|
|
||||||
width: int
|
|
||||||
height: int
|
|
||||||
fonts: Dict[str, Any]
|
|
||||||
options: Dict[str, Any]
|
|
||||||
logger: Any
|
|
||||||
sport: Optional[str] = None
|
|
||||||
view_model_version: str = VIEW_MODEL_VERSION
|
|
||||||
# load_logo("home") / load_logo("away") -> RGBA PIL image or None.
|
|
||||||
# Bound to the current game; hits the host's logo cache (never loads
|
|
||||||
# from disk twice), downloads missing logos like the built-in layout.
|
|
||||||
load_logo: Callable[[str], Optional[Image.Image]] = field(default=lambda side: None)
|
|
||||||
# draw_text_outlined(text, (x, y), font, fill=..., outline_color=...)
|
|
||||||
# — the classic scorebug outlined text, drawn onto this canvas.
|
|
||||||
# TTF fonts only (ctx.fonts values are TTF); for ladder-fitted fonts
|
|
||||||
# use draw_fit / draw_text, which handle BDF faces too.
|
|
||||||
draw_text_outlined: Callable[..., None] = field(default=lambda *a, **k: None)
|
|
||||||
|
|
||||||
def draw_text(self, text: str, x: int, y: int,
|
|
||||||
color: Tuple[int, int, int] = (255, 255, 255),
|
|
||||||
font: Any = None) -> None:
|
|
||||||
"""Draw text at a top-left position, handling both PIL fonts and
|
|
||||||
the freetype BDF faces that layout.fit_text can return."""
|
|
||||||
if font is None:
|
|
||||||
font = self.fonts.get('time')
|
|
||||||
if freetype is not None and isinstance(font, freetype.Face):
|
|
||||||
_draw_bdf_text_on(self.draw, text, int(x), int(y), color, font,
|
|
||||||
self.width, self.height)
|
|
||||||
else:
|
|
||||||
self.draw.text((int(x), int(y)), text, font=font, fill=color)
|
|
||||||
|
|
||||||
def draw_fit(self, fit: FitResult, box: Union[Region, Tuple[int, int]],
|
|
||||||
color: Tuple[int, int, int] = (255, 255, 255),
|
|
||||||
align: str = "center", valign: str = "center") -> None:
|
|
||||||
"""Draw a layout.fit_text() result aligned within a Region — the
|
|
||||||
canvas-local equivalent of adaptive_layout.draw_fitted_text."""
|
|
||||||
region = box if isinstance(box, Region) else Region(0, 0, box[0], box[1])
|
|
||||||
x, y = region.align_xy(fit.width, fit.height, align, valign)
|
|
||||||
self.draw_text(fit.text, x, y - fit.y_offset, color=color, font=fit.font)
|
|
||||||
|
|
||||||
def draw_image(self, img: Optional[Image.Image],
|
|
||||||
box: Union[Region, Tuple[int, int]], *,
|
|
||||||
mode: str = "contain", align: str = "center",
|
|
||||||
valign: str = "center", cache_key: Any = None) -> None:
|
|
||||||
"""Fit an image (a logo, art) into a Region and paste it, honoring
|
|
||||||
alpha. Silently no-ops on None so `ctx.draw_image(ctx.load_logo(
|
|
||||||
'home'), ...)` stays safe when a logo is missing."""
|
|
||||||
if img is None:
|
|
||||||
return
|
|
||||||
region = box if isinstance(box, Region) else Region(0, 0, box[0], box[1])
|
|
||||||
fitted = self.layout.fit_image(img, region, mode=mode,
|
|
||||||
cache_key=cache_key)
|
|
||||||
result = fitted.image # fit_image returns an ImageFitResult (always RGBA)
|
|
||||||
if result is None:
|
|
||||||
return
|
|
||||||
x, y = region.align_xy(result.width, result.height, align, valign)
|
|
||||||
self.canvas.paste(result, (int(x), int(y)), result)
|
|
||||||
|
|
||||||
|
|
||||||
class ScoreboardSkin(ABC):
|
|
||||||
"""Base class for scoreboard skins.
|
|
||||||
|
|
||||||
Override only the modes you want to restyle; any mode you leave
|
|
||||||
unimplemented (or return False from) falls back to the plugin's
|
|
||||||
built-in renderer, so a live-only skin still gets recent/upcoming
|
|
||||||
screens for free.
|
|
||||||
|
|
||||||
Skins should be stateless: three host instances (live, recent,
|
|
||||||
upcoming) each hold their own skin instance, and a render must be
|
|
||||||
derivable from (ctx, game) alone.
|
|
||||||
"""
|
|
||||||
|
|
||||||
SKIN_API_VERSION = SKIN_API_VERSION
|
|
||||||
|
|
||||||
def __init__(self, manifest: Dict[str, Any], options: Dict[str, Any]):
|
|
||||||
self.manifest = manifest
|
|
||||||
self.options = options or {}
|
|
||||||
|
|
||||||
def render_live(self, ctx: SkinContext, game: Dict[str, Any]) -> bool:
|
|
||||||
return False
|
|
||||||
|
|
||||||
def render_recent(self, ctx: SkinContext, game: Dict[str, Any]) -> bool:
|
|
||||||
return False
|
|
||||||
|
|
||||||
def render_upcoming(self, ctx: SkinContext, game: Dict[str, Any]) -> bool:
|
|
||||||
return False
|
|
||||||
|
|
||||||
def render_vegas_card(self, ctx: SkinContext,
|
|
||||||
game: Dict[str, Any]) -> Optional[Image.Image]:
|
|
||||||
"""Render one vegas scroll card at ctx.width x ctx.height. Return
|
|
||||||
the finished image, or None to let the host use its default vegas
|
|
||||||
rendering (which captures the regular display output)."""
|
|
||||||
return None
|
|
||||||
@@ -1,352 +0,0 @@
|
|||||||
"""
|
|
||||||
Skin runtime: discovery, validation, loading, and context building.
|
|
||||||
|
|
||||||
Deliberately generic — this module knows nothing about sports beyond
|
|
||||||
passing a `sport` label through; the sports flavor lives in skin_base
|
|
||||||
(ScoreboardSkin) and in the hosts that call build_context.
|
|
||||||
|
|
||||||
Every failure path here logs and returns None: a broken or missing skin
|
|
||||||
must never take down the plugin that references it — the host falls
|
|
||||||
back to its built-in renderer.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import importlib.util
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
import threading
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Dict, Optional, Tuple
|
|
||||||
|
|
||||||
from PIL import Image, ImageDraw
|
|
||||||
|
|
||||||
from src.adaptive_layout import LayoutContext
|
|
||||||
from src.logging_config import get_logger
|
|
||||||
from src.skin_system.skin_base import (
|
|
||||||
SKIN_API_VERSION,
|
|
||||||
ScoreboardSkin,
|
|
||||||
SkinContext,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
|
||||||
|
|
||||||
_REQUIRED_MANIFEST_FIELDS = ("id", "name", "version", "skin_api_version", "class_name")
|
|
||||||
_DEFAULT_ENTRY_POINT = "skin.py"
|
|
||||||
|
|
||||||
_lock = threading.RLock()
|
|
||||||
# skins_dir -> (fingerprint, {skin_id: manifest+path})
|
|
||||||
_discovery_cache: Dict[str, Tuple[Tuple, Dict[str, Dict[str, Any]]]] = {}
|
|
||||||
|
|
||||||
_shared_layout_font_manager: Optional[Any] = None
|
|
||||||
|
|
||||||
|
|
||||||
def _get_font_manager() -> Any:
|
|
||||||
"""Shared FontManager for skin LayoutContexts. SportsCore hosts don't
|
|
||||||
carry a plugin_manager, so skins share one module-level FontManager —
|
|
||||||
the same shape as base_plugin._fallback_font_manager, constructed
|
|
||||||
directly so rendering never has to import the whole plugin system."""
|
|
||||||
global _shared_layout_font_manager
|
|
||||||
if _shared_layout_font_manager is None:
|
|
||||||
from src.font_manager import FontManager
|
|
||||||
_shared_layout_font_manager = FontManager({})
|
|
||||||
return _shared_layout_font_manager
|
|
||||||
|
|
||||||
|
|
||||||
def get_skins_directory() -> Path:
|
|
||||||
"""Central skins directory: <project_root>/skins. Lives outside the
|
|
||||||
plugin directories on purpose — plugin reinstall/update deletes the
|
|
||||||
whole plugin directory, and a skin must survive that."""
|
|
||||||
return Path(__file__).resolve().parents[2] / "skins"
|
|
||||||
|
|
||||||
|
|
||||||
def _major(version: str) -> Optional[int]:
|
|
||||||
try:
|
|
||||||
return int(str(version).split(".")[0])
|
|
||||||
except (ValueError, AttributeError, IndexError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _read_manifest(skin_dir: Path) -> Optional[Dict[str, Any]]:
|
|
||||||
manifest_path = skin_dir / "skin.json"
|
|
||||||
if not manifest_path.is_file():
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
with open(manifest_path, "r", encoding="utf-8") as f:
|
|
||||||
manifest = json.load(f)
|
|
||||||
except (OSError, json.JSONDecodeError) as e:
|
|
||||||
logger.error("Skin manifest %s is unreadable: %s", manifest_path, e)
|
|
||||||
return None
|
|
||||||
missing = [k for k in _REQUIRED_MANIFEST_FIELDS if not manifest.get(k)]
|
|
||||||
if missing:
|
|
||||||
logger.error("Skin manifest %s missing required fields: %s",
|
|
||||||
manifest_path, ", ".join(missing))
|
|
||||||
return None
|
|
||||||
if manifest["id"] != skin_dir.name:
|
|
||||||
logger.warning("Skin manifest id %r does not match directory name %r",
|
|
||||||
manifest["id"], skin_dir.name)
|
|
||||||
manifest["_skin_dir"] = str(skin_dir)
|
|
||||||
return manifest
|
|
||||||
|
|
||||||
|
|
||||||
def _discovery_fingerprint(skins_dir: Path) -> Optional[Tuple]:
|
|
||||||
"""Cache key for a skins directory: its mtime plus every skin.json's
|
|
||||||
(path, mtime). The directory mtime alone misses in-place manifest edits
|
|
||||||
(a skin updated without adding/removing entries)."""
|
|
||||||
try:
|
|
||||||
parts = [skins_dir.stat().st_mtime]
|
|
||||||
for manifest_path in sorted(skins_dir.glob("*/skin.json")):
|
|
||||||
parts.append((str(manifest_path), manifest_path.stat().st_mtime))
|
|
||||||
return tuple(parts)
|
|
||||||
except OSError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def discover_skins(skins_dir: Optional[Path] = None,
|
|
||||||
force_refresh: bool = False) -> Dict[str, Dict[str, Any]]:
|
|
||||||
"""Return {skin_id: manifest} for every valid skin package installed.
|
|
||||||
|
|
||||||
Cached per directory and invalidated when the directory or any
|
|
||||||
skin.json changes; pass force_refresh to bypass.
|
|
||||||
"""
|
|
||||||
skins_dir = Path(skins_dir) if skins_dir else get_skins_directory()
|
|
||||||
cache_key = str(skins_dir)
|
|
||||||
fingerprint = _discovery_fingerprint(skins_dir)
|
|
||||||
if fingerprint is None:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
with _lock:
|
|
||||||
cached = _discovery_cache.get(cache_key)
|
|
||||||
if cached and not force_refresh and cached[0] == fingerprint:
|
|
||||||
return dict(cached[1])
|
|
||||||
|
|
||||||
skins: Dict[str, Dict[str, Any]] = {}
|
|
||||||
for entry in sorted(skins_dir.iterdir()):
|
|
||||||
if not entry.is_dir() or entry.name.startswith((".", "_")):
|
|
||||||
continue
|
|
||||||
manifest = _read_manifest(entry)
|
|
||||||
if manifest:
|
|
||||||
skins[manifest["id"]] = manifest
|
|
||||||
_discovery_cache[cache_key] = (fingerprint, skins)
|
|
||||||
return dict(skins)
|
|
||||||
|
|
||||||
|
|
||||||
def skin_targets(manifest: Dict[str, Any]) -> Tuple[list, list]:
|
|
||||||
"""(sports, sport_keys) a skin declares it supports."""
|
|
||||||
targets = manifest.get("targets") or {}
|
|
||||||
return (list(targets.get("sports") or []),
|
|
||||||
list(targets.get("sport_keys") or []))
|
|
||||||
|
|
||||||
|
|
||||||
def skin_matches_target(manifest: Dict[str, Any], sport: Optional[str],
|
|
||||||
sport_key: Optional[str]) -> bool:
|
|
||||||
"""True when the skin declares support for this sport family or exact
|
|
||||||
sport key. A skin with no targets at all matches everything."""
|
|
||||||
sports, sport_keys = skin_targets(manifest)
|
|
||||||
if not sports and not sport_keys:
|
|
||||||
return True
|
|
||||||
if sport and sport in sports:
|
|
||||||
return True
|
|
||||||
if sport_key and sport_key in sport_keys:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def skins_for_plugin(plugin_id: str,
|
|
||||||
skins: Optional[Dict[str, Dict[str, Any]]] = None) -> Dict[str, Dict[str, Any]]:
|
|
||||||
"""Installed skins that plausibly apply to a plugin, for UI dropdowns.
|
|
||||||
|
|
||||||
A skin matches when the plugin id is listed in targets.plugins, or any
|
|
||||||
declared sport / sport_key appears as a token of the plugin id (so a
|
|
||||||
skin targeting sports=["baseball"] matches "baseball-scoreboard", and
|
|
||||||
sport_keys=["milb"] matches "milb-scoreboard")."""
|
|
||||||
if skins is None:
|
|
||||||
skins = discover_skins()
|
|
||||||
tokens = set(str(plugin_id).lower().replace("-", "_").split("_"))
|
|
||||||
matched = {}
|
|
||||||
for skin_id, manifest in skins.items():
|
|
||||||
targets = manifest.get("targets") or {}
|
|
||||||
if plugin_id in (targets.get("plugins") or []):
|
|
||||||
matched[skin_id] = manifest
|
|
||||||
continue
|
|
||||||
sports, sport_keys = skin_targets(manifest)
|
|
||||||
if any(str(t).lower() in tokens for t in sports + sport_keys):
|
|
||||||
matched[skin_id] = manifest
|
|
||||||
return matched
|
|
||||||
|
|
||||||
|
|
||||||
def _load_skin_module(skin_id: str, skin_dir: Path, entry_point: str) -> Optional[Any]:
|
|
||||||
"""Import the skin's entry module under a namespaced sys.modules key,
|
|
||||||
namespacing its sibling .py files the same way — the collision-
|
|
||||||
avoidance scheme plugins use (plugin_loader._namespace_plugin_modules),
|
|
||||||
so two skins can both ship a helpers.py.
|
|
||||||
|
|
||||||
The entry module is cached: the live/recent/upcoming hosts all load
|
|
||||||
the same skin, and only the first load executes any code. (A skin
|
|
||||||
whose *code* changed on disk needs a service restart to take effect —
|
|
||||||
Python modules can't be safely hot-swapped.)
|
|
||||||
"""
|
|
||||||
entry_path = skin_dir / entry_point
|
|
||||||
if not entry_path.is_file():
|
|
||||||
logger.error("Skin '%s' entry point not found: %s", skin_id, entry_path)
|
|
||||||
return None
|
|
||||||
|
|
||||||
module_name = f"_skin_{skin_id}_{Path(entry_point).stem}"
|
|
||||||
with _lock:
|
|
||||||
cached_entry = sys.modules.get(module_name)
|
|
||||||
if cached_entry is not None:
|
|
||||||
return cached_entry
|
|
||||||
|
|
||||||
# Import siblings under their namespaced alias, and *bind* the bare
|
|
||||||
# name (cached or fresh) so `import helpers` inside the entry module
|
|
||||||
# resolves to this skin's copy. The bare bindings are transient —
|
|
||||||
# restored below so another skin's identically-named sibling can't
|
|
||||||
# be shadowed by ours.
|
|
||||||
replaced_bare: Dict[str, Any] = {}
|
|
||||||
try:
|
|
||||||
for sibling in skin_dir.glob("*.py"):
|
|
||||||
if sibling.name == entry_point:
|
|
||||||
continue
|
|
||||||
alias = f"_skin_{skin_id}_{sibling.stem}"
|
|
||||||
module = sys.modules.get(alias)
|
|
||||||
if module is None:
|
|
||||||
spec = importlib.util.spec_from_file_location(alias, sibling)
|
|
||||||
if not spec or not spec.loader:
|
|
||||||
continue
|
|
||||||
module = importlib.util.module_from_spec(spec)
|
|
||||||
sys.modules[alias] = module
|
|
||||||
replaced_bare.setdefault(sibling.stem, sys.modules.get(sibling.stem))
|
|
||||||
sys.modules[sibling.stem] = module
|
|
||||||
try:
|
|
||||||
spec.loader.exec_module(module)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Skin '%s' sibling module %s failed to import: %s",
|
|
||||||
skin_id, sibling.name, e, exc_info=True)
|
|
||||||
sys.modules.pop(alias, None)
|
|
||||||
return None
|
|
||||||
else:
|
|
||||||
replaced_bare.setdefault(sibling.stem, sys.modules.get(sibling.stem))
|
|
||||||
sys.modules[sibling.stem] = module
|
|
||||||
|
|
||||||
try:
|
|
||||||
spec = importlib.util.spec_from_file_location(module_name, entry_path)
|
|
||||||
if not spec or not spec.loader:
|
|
||||||
logger.error("Skin '%s': could not create import spec for %s",
|
|
||||||
skin_id, entry_path)
|
|
||||||
return None
|
|
||||||
module = importlib.util.module_from_spec(spec)
|
|
||||||
sys.modules[module_name] = module
|
|
||||||
spec.loader.exec_module(module)
|
|
||||||
return module
|
|
||||||
except Exception as e:
|
|
||||||
sys.modules.pop(module_name, None)
|
|
||||||
logger.error("Skin '%s' failed to import: %s", skin_id, e, exc_info=True)
|
|
||||||
return None
|
|
||||||
finally:
|
|
||||||
for bare_name, previous in replaced_bare.items():
|
|
||||||
if previous is None:
|
|
||||||
sys.modules.pop(bare_name, None)
|
|
||||||
else:
|
|
||||||
sys.modules[bare_name] = previous
|
|
||||||
|
|
||||||
|
|
||||||
def load_skin(skin_id: str, sport: Optional[str] = None,
|
|
||||||
sport_key: Optional[str] = None,
|
|
||||||
options: Optional[Dict[str, Any]] = None,
|
|
||||||
skins_dir: Optional[Path] = None) -> Optional[ScoreboardSkin]:
|
|
||||||
"""Load and instantiate a skin. Returns None (after logging why) on
|
|
||||||
any failure — callers treat None as 'use the built-in renderer'."""
|
|
||||||
skins = discover_skins(skins_dir)
|
|
||||||
manifest = skins.get(skin_id)
|
|
||||||
if manifest is None:
|
|
||||||
logger.warning("Skin '%s' is configured but not installed under %s; "
|
|
||||||
"using built-in renderer",
|
|
||||||
skin_id, skins_dir or get_skins_directory())
|
|
||||||
return None
|
|
||||||
|
|
||||||
manifest_major = _major(manifest.get("skin_api_version"))
|
|
||||||
api_major = _major(SKIN_API_VERSION)
|
|
||||||
if manifest_major != api_major:
|
|
||||||
logger.error("Skin '%s' targets skin API %s but this LEDMatrix "
|
|
||||||
"provides %s — the skin needs an update; using "
|
|
||||||
"built-in renderer",
|
|
||||||
skin_id, manifest.get("skin_api_version"), SKIN_API_VERSION)
|
|
||||||
return None
|
|
||||||
|
|
||||||
if not skin_matches_target(manifest, sport, sport_key):
|
|
||||||
# Soft: the user explicitly configured it, so warn but load anyway
|
|
||||||
# (a baseball skin may render an acceptable generic scoreboard).
|
|
||||||
logger.warning("Skin '%s' does not declare support for sport=%r / "
|
|
||||||
"sport_key=%r; loading anyway", skin_id, sport, sport_key)
|
|
||||||
|
|
||||||
skin_dir = Path(manifest["_skin_dir"])
|
|
||||||
module = _load_skin_module(skin_id, skin_dir,
|
|
||||||
manifest.get("entry_point", _DEFAULT_ENTRY_POINT))
|
|
||||||
if module is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
class_name = manifest["class_name"]
|
|
||||||
skin_class = getattr(module, class_name, None)
|
|
||||||
if skin_class is None or not isinstance(skin_class, type) or \
|
|
||||||
not issubclass(skin_class, ScoreboardSkin):
|
|
||||||
logger.error("Skin '%s': %s is missing or not a ScoreboardSkin subclass",
|
|
||||||
skin_id, class_name)
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
return skin_class(manifest, options or {})
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Skin '%s' failed to instantiate: %s", skin_id, e, exc_info=True)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def build_context(host: Any, game: Dict[str, Any],
|
|
||||||
size: Optional[Tuple[int, int]] = None) -> SkinContext:
|
|
||||||
"""Build a SkinContext for one render call.
|
|
||||||
|
|
||||||
`host` is a SportsCore-style object: display_manager, fonts, logger,
|
|
||||||
sport, skin_options, _load_and_resize_logo, _draw_text_with_outline.
|
|
||||||
`size` overrides the canvas size (vegas cards); default is the
|
|
||||||
current display size read live from the display manager.
|
|
||||||
"""
|
|
||||||
if size is not None:
|
|
||||||
width, height = int(size[0]), int(size[1])
|
|
||||||
else:
|
|
||||||
dm = host.display_manager
|
|
||||||
width = getattr(dm, "width", None) or dm.matrix.width
|
|
||||||
height = getattr(dm, "height", None) or dm.matrix.height
|
|
||||||
|
|
||||||
canvas = Image.new("RGB", (width, height), (0, 0, 0))
|
|
||||||
draw = ImageDraw.Draw(canvas)
|
|
||||||
layout = LayoutContext(width, height, _get_font_manager())
|
|
||||||
|
|
||||||
def load_logo(side: str) -> Optional[Image.Image]:
|
|
||||||
if side not in ("home", "away"):
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
logo_path = game.get(f"{side}_logo_path")
|
|
||||||
if logo_path is not None and not isinstance(logo_path, Path):
|
|
||||||
logo_path = Path(logo_path)
|
|
||||||
return host._load_and_resize_logo(
|
|
||||||
game.get(f"{side}_id"), game.get(f"{side}_abbr"),
|
|
||||||
logo_path, game.get(f"{side}_logo_url"))
|
|
||||||
except Exception as e:
|
|
||||||
host.logger.warning("Skin logo load failed for %s: %s", side, e)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def draw_text_outlined(text, position, font, fill=(255, 255, 255),
|
|
||||||
outline_color=(0, 0, 0)):
|
|
||||||
host._draw_text_with_outline(draw, text, position, font,
|
|
||||||
fill=fill, outline_color=outline_color)
|
|
||||||
|
|
||||||
return SkinContext(
|
|
||||||
canvas=canvas,
|
|
||||||
draw=draw,
|
|
||||||
layout=layout,
|
|
||||||
width=width,
|
|
||||||
height=height,
|
|
||||||
fonts=dict(host.fonts),
|
|
||||||
options=dict(getattr(host, "skin_options", {}) or {}),
|
|
||||||
logger=host.logger,
|
|
||||||
sport=getattr(host, "sport", None),
|
|
||||||
load_logo=load_logo,
|
|
||||||
draw_text_outlined=draw_text_outlined,
|
|
||||||
)
|
|
||||||
@@ -4,7 +4,6 @@ Centralized error handling for web interface.
|
|||||||
Provides helpers for consistent error responses across API endpoints.
|
Provides helpers for consistent error responses across API endpoints.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import re
|
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
from flask import jsonify
|
from flask import jsonify
|
||||||
|
|
||||||
@@ -12,42 +11,12 @@ from src.web_interface.errors import (
|
|||||||
WebInterfaceError, ErrorCode, ErrorCategory
|
WebInterfaceError, ErrorCode, ErrorCategory
|
||||||
)
|
)
|
||||||
from src.logging_config import get_logger
|
from src.logging_config import get_logger
|
||||||
|
from src.redaction import redact_credentials
|
||||||
|
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# Credentials that turn up inside exception text. A requests error quotes the
|
|
||||||
# URL it failed on, and plugins that authenticate by query string put their key
|
|
||||||
# there, so echoing an exception verbatim can hand out an API key. Redact the
|
|
||||||
# value, keep the parameter name -- knowing *which* credential was involved is
|
|
||||||
# part of the diagnosis.
|
|
||||||
_REDACT_CREDENTIAL = re.compile(
|
|
||||||
r'((?:api[_-]?key|access[_-]?token|auth|apikey|key|passwd|password|pwd|'
|
|
||||||
r'secret|sig|signature|token)["\']?\s*[=:]\s*["\']?)([^\s&"\'<>,}]+)',
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
|
|
||||||
# `Authorization: <scheme> <credential>`. The scheme name is kept because it
|
|
||||||
# says which kind of credential failed; the credential goes. Any scheme
|
|
||||||
# matches, not a fixed list: ApiKey, Negotiate, NTLM, AWS4-HMAC-SHA256 and
|
|
||||||
# whatever a plugin's API invents next are all credentials, and a list would
|
|
||||||
# silently leak the ones nobody thought of. Not covered by the generic pattern
|
|
||||||
# above, whose value part stops at whitespace and so would keep the credential
|
|
||||||
# once a space follows the scheme.
|
|
||||||
_REDACT_AUTH_HEADER = re.compile(
|
|
||||||
r'((?:proxy-)?authorization["\']?\s*[=:]\s*["\']?\s*'
|
|
||||||
r'(?:[A-Za-z][\w.+-]*[ \t]+)?)' # optional scheme name, kept
|
|
||||||
r'([^\s,"\'<>}]+)', # the credential, redacted
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Credentials embedded in a URL: https://user:password@host. requests quotes
|
|
||||||
# the full URL in its exceptions, so this is a realistic leak. The username is
|
|
||||||
# kept -- it identifies which account failed without being the secret.
|
|
||||||
_REDACT_URL_USERINFO = re.compile(r'([a-z][a-z0-9+.-]*://[^/\s:@]+:)([^/\s@]+)(@)',
|
|
||||||
re.IGNORECASE)
|
|
||||||
|
|
||||||
# Long enough for an errno string with a path, short enough not to dump a
|
# Long enough for an errno string with a path, short enough not to dump a
|
||||||
# parser's worth of context into a JSON field.
|
# parser's worth of context into a JSON field.
|
||||||
_MAX_DETAIL_LENGTH = 400
|
_MAX_DETAIL_LENGTH = 400
|
||||||
@@ -95,12 +64,7 @@ def redact_text(text: str, max_length: int = _MAX_DETAIL_LENGTH) -> str:
|
|||||||
Returns:
|
Returns:
|
||||||
A single line, credentials replaced, length capped.
|
A single line, credentials replaced, length capped.
|
||||||
"""
|
"""
|
||||||
text = text or ''
|
text = redact_credentials(text)
|
||||||
# Order matters: the URL and header forms are more specific than the
|
|
||||||
# generic key=value pattern, which would otherwise chew the scheme.
|
|
||||||
text = _REDACT_URL_USERINFO.sub(r'\1<redacted>\3', text)
|
|
||||||
text = _REDACT_AUTH_HEADER.sub(r'\1<redacted>', text)
|
|
||||||
text = _REDACT_CREDENTIAL.sub(r'\1<redacted>', text)
|
|
||||||
# Collapse newlines/tabs so the detail stays one line in a JSON field.
|
# Collapse newlines/tabs so the detail stays one line in a JSON field.
|
||||||
text = ' '.join(text.split())
|
text = ' '.join(text.split())
|
||||||
if len(text) > max_length:
|
if len(text) > max_length:
|
||||||
|
|||||||
Vendored
-9
@@ -639,15 +639,6 @@
|
|||||||
"POST"
|
"POST"
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
[
|
|
||||||
"/api/v3/skins",
|
|
||||||
"api_v3.list_skins",
|
|
||||||
[
|
|
||||||
"GET",
|
|
||||||
"HEAD",
|
|
||||||
"OPTIONS"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
[
|
||||||
"/api/v3/starlark/apps",
|
"/api/v3/starlark/apps",
|
||||||
"api_v3.get_starlark_apps",
|
"api_v3.get_starlark_apps",
|
||||||
|
|||||||
@@ -1,342 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for src/base_classes/api_extractors.py
|
|
||||||
|
|
||||||
Covers ESPNFootballExtractor, ESPNBaseballExtractor, ESPNHockeyExtractor,
|
|
||||||
SoccerAPIExtractor, and the shared _extract_common_details logic.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import pytest
|
|
||||||
from src.base_classes.api_extractors import (
|
|
||||||
ESPNFootballExtractor,
|
|
||||||
ESPNBaseballExtractor,
|
|
||||||
ESPNHockeyExtractor,
|
|
||||||
SoccerAPIExtractor,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Shared test data factories
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _make_espn_event(state: str = "in", home_abbr: str = "KC", away_abbr: str = "BUF",
|
|
||||||
home_score: str = "14", away_score: str = "7",
|
|
||||||
date_str: str = "2024-01-15T20:00:00Z",
|
|
||||||
include_situation: bool = False,
|
|
||||||
situation: dict | None = None,
|
|
||||||
status_detail: str = "2nd Qtr 8:42",
|
|
||||||
period: int = 2) -> dict:
|
|
||||||
"""Build a minimal ESPN-style game event dict."""
|
|
||||||
comp_status = {
|
|
||||||
"type": {
|
|
||||||
"state": state,
|
|
||||||
"shortDetail": status_detail,
|
|
||||||
"detail": status_detail,
|
|
||||||
"name": "STATUS_IN_PROGRESS",
|
|
||||||
},
|
|
||||||
"period": period,
|
|
||||||
"displayClock": "8:42",
|
|
||||||
}
|
|
||||||
comp = {
|
|
||||||
"status": comp_status,
|
|
||||||
"competitors": [
|
|
||||||
{
|
|
||||||
"homeAway": "home",
|
|
||||||
"team": {"abbreviation": home_abbr, "displayName": f"{home_abbr} Team"},
|
|
||||||
"score": home_score,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"homeAway": "away",
|
|
||||||
"team": {"abbreviation": away_abbr, "displayName": f"{away_abbr} Team"},
|
|
||||||
"score": away_score,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
if include_situation:
|
|
||||||
comp["situation"] = situation or {}
|
|
||||||
return {
|
|
||||||
"id": "test-game-1",
|
|
||||||
"date": date_str,
|
|
||||||
"competitions": [comp],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _make_logger() -> logging.Logger:
|
|
||||||
return logging.getLogger("test_extractor")
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# ESPNFootballExtractor
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestESPNFootballExtractor:
|
|
||||||
def setup_method(self):
|
|
||||||
self.extractor = ESPNFootballExtractor(_make_logger())
|
|
||||||
|
|
||||||
def test_extract_live_game_basic_fields(self):
|
|
||||||
event = _make_espn_event(state="in", home_score="14", away_score="7")
|
|
||||||
result = self.extractor.extract_game_details(event)
|
|
||||||
assert result is not None
|
|
||||||
assert result["home_abbr"] == "KC"
|
|
||||||
assert result["away_abbr"] == "BUF"
|
|
||||||
assert result["home_score"] == "14"
|
|
||||||
assert result["away_score"] == "7"
|
|
||||||
assert result["is_live"] is True
|
|
||||||
assert result["is_final"] is False
|
|
||||||
assert result["is_upcoming"] is False
|
|
||||||
|
|
||||||
def test_extract_final_game(self):
|
|
||||||
event = _make_espn_event(state="post")
|
|
||||||
result = self.extractor.extract_game_details(event)
|
|
||||||
assert result is not None
|
|
||||||
assert result["is_final"] is True
|
|
||||||
assert result["is_live"] is False
|
|
||||||
|
|
||||||
def test_extract_upcoming_game(self):
|
|
||||||
event = _make_espn_event(state="pre")
|
|
||||||
result = self.extractor.extract_game_details(event)
|
|
||||||
assert result is not None
|
|
||||||
assert result["is_upcoming"] is True
|
|
||||||
|
|
||||||
def test_sport_specific_fields_default_when_pregame(self):
|
|
||||||
event = _make_espn_event(state="pre")
|
|
||||||
fields = self.extractor.get_sport_specific_fields(event)
|
|
||||||
assert "down" in fields
|
|
||||||
assert "distance" in fields
|
|
||||||
assert "possession" in fields
|
|
||||||
assert "is_redzone" in fields
|
|
||||||
assert fields["is_redzone"] is False
|
|
||||||
|
|
||||||
def test_sport_specific_fields_live_with_situation(self):
|
|
||||||
situation = {
|
|
||||||
"down": 3,
|
|
||||||
"distance": 7,
|
|
||||||
"possession": "KC",
|
|
||||||
"isRedZone": True,
|
|
||||||
"homeTimeouts": 2,
|
|
||||||
"awayTimeouts": 1,
|
|
||||||
}
|
|
||||||
event = _make_espn_event(state="in", include_situation=True, situation=situation)
|
|
||||||
fields = self.extractor.get_sport_specific_fields(event)
|
|
||||||
assert fields["down"] == 3
|
|
||||||
assert fields["distance"] == 7
|
|
||||||
assert fields["is_redzone"] is True
|
|
||||||
assert fields["home_timeouts"] == 2
|
|
||||||
assert fields["away_timeouts"] == 1
|
|
||||||
|
|
||||||
def test_scoring_event_detected(self):
|
|
||||||
# situation must be non-empty (truthy) for the live block to execute
|
|
||||||
situation = {"down": 1, "distance": 10}
|
|
||||||
event = _make_espn_event(
|
|
||||||
state="in",
|
|
||||||
include_situation=True,
|
|
||||||
situation=situation,
|
|
||||||
status_detail="touchdown scored",
|
|
||||||
)
|
|
||||||
fields = self.extractor.get_sport_specific_fields(event)
|
|
||||||
assert "touchdown" in fields.get("scoring_event", "").lower()
|
|
||||||
|
|
||||||
def test_returns_none_on_empty_event(self):
|
|
||||||
assert self.extractor.extract_game_details({}) is None
|
|
||||||
|
|
||||||
def test_returns_none_when_teams_missing(self):
|
|
||||||
event = {
|
|
||||||
"id": "x",
|
|
||||||
"date": "2024-01-15T20:00:00Z",
|
|
||||||
"competitions": [
|
|
||||||
{
|
|
||||||
"status": {"type": {"state": "in", "shortDetail": "", "detail": "", "name": ""}},
|
|
||||||
"competitors": [], # no competitors
|
|
||||||
}
|
|
||||||
],
|
|
||||||
}
|
|
||||||
assert self.extractor.extract_game_details(event) is None
|
|
||||||
|
|
||||||
def test_date_z_suffix_parsed(self):
|
|
||||||
event = _make_espn_event(date_str="2024-01-15T20:00:00Z")
|
|
||||||
result = self.extractor.extract_game_details(event)
|
|
||||||
# Should not raise and should return a result
|
|
||||||
assert result is not None
|
|
||||||
|
|
||||||
def test_id_propagated(self):
|
|
||||||
event = _make_espn_event()
|
|
||||||
result = self.extractor.extract_game_details(event)
|
|
||||||
assert result["id"] == "test-game-1"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# ESPNBaseballExtractor
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestESPNBaseballExtractor:
|
|
||||||
def setup_method(self):
|
|
||||||
self.extractor = ESPNBaseballExtractor(_make_logger())
|
|
||||||
|
|
||||||
def test_extract_live_game(self):
|
|
||||||
event = _make_espn_event(
|
|
||||||
state="in", home_abbr="NYY", away_abbr="BOS",
|
|
||||||
home_score="3", away_score="2"
|
|
||||||
)
|
|
||||||
result = self.extractor.extract_game_details(event)
|
|
||||||
assert result is not None
|
|
||||||
assert result["home_abbr"] == "NYY"
|
|
||||||
assert result["is_live"] is True
|
|
||||||
|
|
||||||
def test_baseball_sport_fields_defaults(self):
|
|
||||||
event = _make_espn_event(state="pre")
|
|
||||||
fields = self.extractor.get_sport_specific_fields(event)
|
|
||||||
assert "inning" in fields
|
|
||||||
assert "outs" in fields
|
|
||||||
assert "bases" in fields
|
|
||||||
assert "strikes" in fields
|
|
||||||
assert "balls" in fields
|
|
||||||
|
|
||||||
def test_baseball_sport_fields_live(self):
|
|
||||||
situation = {
|
|
||||||
"inning": 7,
|
|
||||||
"outs": 2,
|
|
||||||
"bases": "110",
|
|
||||||
"strikes": 2,
|
|
||||||
"balls": 3,
|
|
||||||
"pitcher": "Smith",
|
|
||||||
"batter": "Jones",
|
|
||||||
}
|
|
||||||
event = _make_espn_event(state="in", include_situation=True, situation=situation)
|
|
||||||
fields = self.extractor.get_sport_specific_fields(event)
|
|
||||||
assert fields["inning"] == 7
|
|
||||||
assert fields["outs"] == 2
|
|
||||||
assert fields["strikes"] == 2
|
|
||||||
assert fields["pitcher"] == "Smith"
|
|
||||||
|
|
||||||
def test_returns_none_on_empty(self):
|
|
||||||
assert self.extractor.extract_game_details({}) is None
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# ESPNHockeyExtractor
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestESPNHockeyExtractor:
|
|
||||||
def setup_method(self):
|
|
||||||
self.extractor = ESPNHockeyExtractor(_make_logger())
|
|
||||||
|
|
||||||
def test_extract_live_game(self):
|
|
||||||
event = _make_espn_event(
|
|
||||||
state="in", home_abbr="BOS", away_abbr="TOR",
|
|
||||||
home_score="2", away_score="1"
|
|
||||||
)
|
|
||||||
result = self.extractor.extract_game_details(event)
|
|
||||||
assert result is not None
|
|
||||||
assert result["is_live"] is True
|
|
||||||
|
|
||||||
def test_hockey_period_text_p1(self):
|
|
||||||
situation = {"isPowerPlay": False}
|
|
||||||
event = _make_espn_event(
|
|
||||||
state="in", include_situation=True, situation=situation, period=1
|
|
||||||
)
|
|
||||||
fields = self.extractor.get_sport_specific_fields(event)
|
|
||||||
assert fields["period_text"] == "P1"
|
|
||||||
|
|
||||||
def test_hockey_period_text_p2(self):
|
|
||||||
situation = {"isPowerPlay": False} # non-empty so the live block executes
|
|
||||||
event = _make_espn_event(
|
|
||||||
state="in", include_situation=True, situation=situation, period=2
|
|
||||||
)
|
|
||||||
fields = self.extractor.get_sport_specific_fields(event)
|
|
||||||
assert fields["period_text"] == "P2"
|
|
||||||
|
|
||||||
def test_hockey_period_text_p3(self):
|
|
||||||
situation = {"isPowerPlay": False}
|
|
||||||
event = _make_espn_event(
|
|
||||||
state="in", include_situation=True, situation=situation, period=3
|
|
||||||
)
|
|
||||||
fields = self.extractor.get_sport_specific_fields(event)
|
|
||||||
assert fields["period_text"] == "P3"
|
|
||||||
|
|
||||||
def test_hockey_period_text_ot(self):
|
|
||||||
situation = {"isPowerPlay": False}
|
|
||||||
event = _make_espn_event(
|
|
||||||
state="in", include_situation=True, situation=situation, period=4
|
|
||||||
)
|
|
||||||
fields = self.extractor.get_sport_specific_fields(event)
|
|
||||||
assert fields["period_text"] == "OT1"
|
|
||||||
|
|
||||||
def test_hockey_power_play(self):
|
|
||||||
situation = {"isPowerPlay": True, "homeShots": 12, "awayShots": 8}
|
|
||||||
event = _make_espn_event(state="in", include_situation=True, situation=situation, period=2)
|
|
||||||
fields = self.extractor.get_sport_specific_fields(event)
|
|
||||||
assert fields["power_play"] is True
|
|
||||||
assert fields["shots_on_goal"]["home"] == 12
|
|
||||||
assert fields["shots_on_goal"]["away"] == 8
|
|
||||||
|
|
||||||
def test_hockey_fields_defaults_pregame(self):
|
|
||||||
event = _make_espn_event(state="pre")
|
|
||||||
fields = self.extractor.get_sport_specific_fields(event)
|
|
||||||
assert "period" in fields
|
|
||||||
assert "power_play" in fields
|
|
||||||
assert fields["power_play"] is False
|
|
||||||
|
|
||||||
def test_returns_none_on_empty(self):
|
|
||||||
assert self.extractor.extract_game_details({}) is None
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# SoccerAPIExtractor
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestSoccerAPIExtractor:
|
|
||||||
def setup_method(self):
|
|
||||||
self.extractor = SoccerAPIExtractor(_make_logger())
|
|
||||||
|
|
||||||
def _make_soccer_event(self, is_live: bool = True) -> dict:
|
|
||||||
return {
|
|
||||||
"id": "soccer-1",
|
|
||||||
"home_team": {"abbreviation": "ARS", "name": "Arsenal"},
|
|
||||||
"away_team": {"abbreviation": "CHE", "name": "Chelsea"},
|
|
||||||
"home_score": "2",
|
|
||||||
"away_score": "1",
|
|
||||||
"status": "LIVE",
|
|
||||||
"is_live": is_live,
|
|
||||||
"is_final": not is_live,
|
|
||||||
"is_upcoming": False,
|
|
||||||
"half": "1",
|
|
||||||
"stoppage_time": "2",
|
|
||||||
"home_yellow_cards": 1,
|
|
||||||
"away_yellow_cards": 2,
|
|
||||||
"home_red_cards": 0,
|
|
||||||
"away_red_cards": 0,
|
|
||||||
"home_possession": 55,
|
|
||||||
"away_possession": 45,
|
|
||||||
}
|
|
||||||
|
|
||||||
def test_extract_live_game(self):
|
|
||||||
event = self._make_soccer_event(is_live=True)
|
|
||||||
result = self.extractor.extract_game_details(event)
|
|
||||||
assert result is not None
|
|
||||||
assert result["home_abbr"] == "ARS"
|
|
||||||
assert result["away_abbr"] == "CHE"
|
|
||||||
assert result["is_live"] is True
|
|
||||||
|
|
||||||
def test_sport_specific_cards(self):
|
|
||||||
event = self._make_soccer_event()
|
|
||||||
fields = self.extractor.get_sport_specific_fields(event)
|
|
||||||
assert fields["cards"]["home_yellow"] == 1
|
|
||||||
assert fields["cards"]["away_yellow"] == 2
|
|
||||||
assert fields["cards"]["home_red"] == 0
|
|
||||||
|
|
||||||
def test_sport_specific_possession(self):
|
|
||||||
event = self._make_soccer_event()
|
|
||||||
fields = self.extractor.get_sport_specific_fields(event)
|
|
||||||
assert fields["possession"]["home"] == 55
|
|
||||||
assert fields["possession"]["away"] == 45
|
|
||||||
|
|
||||||
def test_sport_specific_half(self):
|
|
||||||
event = self._make_soccer_event()
|
|
||||||
fields = self.extractor.get_sport_specific_fields(event)
|
|
||||||
assert fields["half"] == "1"
|
|
||||||
|
|
||||||
def test_scores_as_strings(self):
|
|
||||||
event = self._make_soccer_event()
|
|
||||||
result = self.extractor.extract_game_details(event)
|
|
||||||
assert result["home_score"] == "2"
|
|
||||||
assert result["away_score"] == "1"
|
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
"""src/common must stay importable without display hardware.
|
"""src/common must stay importable without display hardware.
|
||||||
|
|
||||||
Plugins import src.common.sports_* in place of their bundled copies. If any of
|
Plugins import src.common.sports_* in place of their bundled copies. If any of
|
||||||
those modules reaches src.display_manager (and through it rgbmatrix) or the
|
those modules reaches src.display_manager (and through it rgbmatrix), a
|
||||||
src.base_classes package (whose core.py imports DisplayManager), a scoreboard
|
scoreboard adopting it acquires a hardware dependency it never had, and the headless
|
||||||
adopting it acquires a hardware dependency it never had, and the headless
|
|
||||||
tooling -- the web preview, check_plugin.py, these tests on a laptop -- stops
|
tooling -- the web preview, check_plugin.py, these tests on a laptop -- stops
|
||||||
being able to load it.
|
being able to load it.
|
||||||
|
|
||||||
@@ -26,7 +25,7 @@ from pathlib import Path
|
|||||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
COMMON = REPO_ROOT / "src" / "common"
|
COMMON = REPO_ROOT / "src" / "common"
|
||||||
|
|
||||||
FORBIDDEN = ("src.base_classes", "src.display_manager", "src.plugin_system")
|
FORBIDDEN = ("src.display_manager", "src.plugin_system")
|
||||||
|
|
||||||
#: Existing module-level violations, by file name, each with the reason it is
|
#: Existing module-level violations, by file name, each with the reason it is
|
||||||
#: tolerated. Empty when this test was added (core 3.4.0 + sports_helpers):
|
#: tolerated. Empty when this test was added (core 3.4.0 + sports_helpers):
|
||||||
@@ -124,13 +123,13 @@ def test_the_scan_sees_a_direct_import(tmp_path):
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
pass
|
pass
|
||||||
if True:
|
if True:
|
||||||
import src.base_classes.sports
|
import src.plugin_system.plugin_manager
|
||||||
from src import plugin_system
|
from src import plugin_system
|
||||||
def later():
|
def later():
|
||||||
from src.display_manager import DisplayManager
|
from src.display_manager import DisplayManager
|
||||||
"""))
|
"""))
|
||||||
targets = {t for n in _module_level_imports(tree) for t in _targets(n)}
|
targets = {t for n in _module_level_imports(tree) for t in _targets(n)}
|
||||||
assert {"src.base_classes.sports", "src.display_manager",
|
assert {"src.plugin_system.plugin_manager", "src.display_manager",
|
||||||
"src.plugin_system"} <= targets
|
"src.plugin_system"} <= targets
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,307 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for src/base_classes/data_sources.py
|
|
||||||
|
|
||||||
Covers ESPNDataSource, MLBAPIDataSource, SoccerAPIDataSource.
|
|
||||||
All HTTP calls are mocked to avoid network access.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from datetime import datetime, date
|
|
||||||
from unittest.mock import MagicMock, patch, Mock
|
|
||||||
import pytest
|
|
||||||
import requests
|
|
||||||
|
|
||||||
from src.base_classes.data_sources import ESPNDataSource, MLBAPIDataSource, SoccerAPIDataSource
|
|
||||||
|
|
||||||
|
|
||||||
def _make_logger() -> logging.Logger:
|
|
||||||
return logging.getLogger("test_data_sources")
|
|
||||||
|
|
||||||
|
|
||||||
def _mock_response(json_data: dict, status_code: int = 200):
|
|
||||||
resp = Mock(spec=requests.Response)
|
|
||||||
resp.status_code = status_code
|
|
||||||
resp.json.return_value = json_data
|
|
||||||
resp.raise_for_status = Mock()
|
|
||||||
if status_code >= 400:
|
|
||||||
resp.raise_for_status.side_effect = requests.HTTPError(response=resp)
|
|
||||||
return resp
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# ESPNDataSource
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestESPNDataSource:
|
|
||||||
def setup_method(self):
|
|
||||||
self.source = ESPNDataSource(_make_logger())
|
|
||||||
|
|
||||||
def test_get_headers(self):
|
|
||||||
headers = self.source.get_headers()
|
|
||||||
assert headers["Accept"] == "application/json"
|
|
||||||
assert "LEDMatrix" in headers["User-Agent"]
|
|
||||||
|
|
||||||
def test_fetch_live_games_returns_live_events(self):
|
|
||||||
live_event = {
|
|
||||||
"competitions": [{"status": {"type": {"state": "in"}}}]
|
|
||||||
}
|
|
||||||
non_live_event = {
|
|
||||||
"competitions": [{"status": {"type": {"state": "pre"}}}]
|
|
||||||
}
|
|
||||||
payload = {"events": [live_event, non_live_event]}
|
|
||||||
|
|
||||||
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
|
|
||||||
result = self.source.fetch_live_games("football", "nfl")
|
|
||||||
|
|
||||||
assert len(result) == 1
|
|
||||||
assert result[0] is live_event
|
|
||||||
|
|
||||||
def test_fetch_live_games_empty_when_none_live(self):
|
|
||||||
payload = {"events": [
|
|
||||||
{"competitions": [{"status": {"type": {"state": "post"}}}]}
|
|
||||||
]}
|
|
||||||
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
|
|
||||||
result = self.source.fetch_live_games("football", "nfl")
|
|
||||||
assert result == []
|
|
||||||
|
|
||||||
def test_fetch_live_games_returns_empty_on_error(self):
|
|
||||||
with patch.object(self.source.session, "get", side_effect=Exception("network failure")):
|
|
||||||
result = self.source.fetch_live_games("football", "nfl")
|
|
||||||
assert result == []
|
|
||||||
|
|
||||||
def test_fetch_schedule_returns_all_events(self):
|
|
||||||
events = [{"id": "1"}, {"id": "2"}]
|
|
||||||
payload = {"events": events}
|
|
||||||
start = datetime(2024, 1, 1)
|
|
||||||
end = datetime(2024, 1, 7)
|
|
||||||
|
|
||||||
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
|
|
||||||
result = self.source.fetch_schedule("football", "nfl", (start, end))
|
|
||||||
|
|
||||||
assert len(result) == 2
|
|
||||||
|
|
||||||
def test_fetch_schedule_returns_empty_on_error(self):
|
|
||||||
with patch.object(self.source.session, "get", side_effect=Exception("timeout")):
|
|
||||||
result = self.source.fetch_schedule("football", "nfl", (datetime.now(), datetime.now()))
|
|
||||||
assert result == []
|
|
||||||
|
|
||||||
def test_fetch_standings_success(self):
|
|
||||||
payload = {"standings": []}
|
|
||||||
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
|
|
||||||
result = self.source.fetch_standings("football", "nfl")
|
|
||||||
assert result == payload
|
|
||||||
|
|
||||||
def test_fetch_standings_returns_empty_on_error(self):
|
|
||||||
# A transport failure is a RequestException, not a bare Exception.
|
|
||||||
# The old stand-in passed only because the handler caught everything,
|
|
||||||
# including bugs in the method under test.
|
|
||||||
with patch.object(self.source.session, "get",
|
|
||||||
side_effect=requests.ConnectionError("error")):
|
|
||||||
result = self.source.fetch_standings("football", "nfl")
|
|
||||||
assert result == {}
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# fetch_standings endpoint selection
|
|
||||||
#
|
|
||||||
# College leagues publish a poll at /rankings and a records table at
|
|
||||||
# /standings; professional leagues have only /standings. Probing them in
|
|
||||||
# the wrong order still returns 200 -- just without a poll in it -- so
|
|
||||||
# nothing failed and the rank badge simply never appeared. Order is the
|
|
||||||
# behaviour here, so these tests assert it directly.
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _requested_endpoints(mock_get):
|
|
||||||
"""The endpoint names probed, in the order they were requested."""
|
|
||||||
return [call.args[0].rsplit("/", 1)[-1] for call in mock_get.call_args_list]
|
|
||||||
|
|
||||||
def test_professional_league_asks_standings_first(self):
|
|
||||||
payload = {"standings": []}
|
|
||||||
with patch.object(self.source.session, "get",
|
|
||||||
return_value=_mock_response(payload)) as mock_get:
|
|
||||||
result = self.source.fetch_standings("football", "nfl")
|
|
||||||
assert result == payload
|
|
||||||
assert self._requested_endpoints(mock_get) == ["standings"]
|
|
||||||
|
|
||||||
def test_college_league_asks_rankings_first(self):
|
|
||||||
poll = {"rankings": [{"name": "AP Top 25"}]}
|
|
||||||
with patch.object(self.source.session, "get",
|
|
||||||
return_value=_mock_response(poll)) as mock_get:
|
|
||||||
result = self.source.fetch_standings("football", "college-football")
|
|
||||||
assert result == poll
|
|
||||||
assert self._requested_endpoints(mock_get) == ["rankings"]
|
|
||||||
|
|
||||||
def test_rankings_200_without_a_poll_falls_through_to_standings(self):
|
|
||||||
"""A 200 is not the same as an answer.
|
|
||||||
|
|
||||||
This is the case the old code could not see: the endpoint responded,
|
|
||||||
so nothing raised, but the body carried no poll.
|
|
||||||
"""
|
|
||||||
empty_poll = _mock_response({"rankings": []})
|
|
||||||
table = _mock_response({"standings": [{"entries": []}]})
|
|
||||||
with patch.object(self.source.session, "get",
|
|
||||||
side_effect=[empty_poll, table]) as mock_get:
|
|
||||||
result = self.source.fetch_standings(
|
|
||||||
"basketball", "mens-college-basketball")
|
|
||||||
assert result == {"standings": [{"entries": []}]}
|
|
||||||
assert self._requested_endpoints(mock_get) == ["rankings", "standings"]
|
|
||||||
|
|
||||||
def test_404_on_the_first_endpoint_falls_through_quietly(self):
|
|
||||||
missing = _mock_response({}, status_code=404)
|
|
||||||
table = _mock_response({"standings": []})
|
|
||||||
with patch.object(self.source.session, "get",
|
|
||||||
side_effect=[missing, table]) as mock_get:
|
|
||||||
result = self.source.fetch_standings("baseball", "college-baseball")
|
|
||||||
assert result == {"standings": []}
|
|
||||||
assert self._requested_endpoints(mock_get) == ["rankings", "standings"]
|
|
||||||
|
|
||||||
def test_recovers_from_a_non_404_failure_on_the_first_endpoint(self):
|
|
||||||
table = _mock_response({"standings": [{"entries": []}]})
|
|
||||||
with patch.object(self.source.session, "get",
|
|
||||||
side_effect=[requests.ConnectionError("reset"), table]) as mock_get:
|
|
||||||
result = self.source.fetch_standings("football", "college-football")
|
|
||||||
assert result == {"standings": [{"entries": []}]}
|
|
||||||
assert self._requested_endpoints(mock_get) == ["rankings", "standings"]
|
|
||||||
|
|
||||||
def test_both_endpoints_failing_returns_empty(self):
|
|
||||||
with patch.object(self.source.session, "get",
|
|
||||||
side_effect=requests.ConnectionError("down")) as mock_get:
|
|
||||||
result = self.source.fetch_standings("football", "nfl")
|
|
||||||
assert result == {}
|
|
||||||
assert self._requested_endpoints(mock_get) == ["standings", "rankings"]
|
|
||||||
|
|
||||||
def test_a_non_object_payload_is_treated_as_a_miss(self):
|
|
||||||
odd = _mock_response(["not", "an", "object"])
|
|
||||||
table = _mock_response({"standings": []})
|
|
||||||
with patch.object(self.source.session, "get",
|
|
||||||
side_effect=[odd, table]) as mock_get:
|
|
||||||
result = self.source.fetch_standings("football", "college-football")
|
|
||||||
assert result == {"standings": []}
|
|
||||||
assert self._requested_endpoints(mock_get) == ["rankings", "standings"]
|
|
||||||
|
|
||||||
def test_a_bug_in_this_method_is_not_swallowed_as_a_failed_endpoint(self):
|
|
||||||
"""The guard for the narrowed handler.
|
|
||||||
|
|
||||||
An error raised while reading the payload used to be caught by the
|
|
||||||
endpoint handler and reported as 'no poll here', which would silently
|
|
||||||
drop rankings for a league that has them. It must surface instead.
|
|
||||||
"""
|
|
||||||
boom = Mock(spec=requests.Response)
|
|
||||||
boom.status_code = 200
|
|
||||||
boom.raise_for_status = Mock()
|
|
||||||
boom.json.side_effect = TypeError("a bug, not a network failure")
|
|
||||||
with patch.object(self.source.session, "get", return_value=boom):
|
|
||||||
with pytest.raises(TypeError):
|
|
||||||
self.source.fetch_standings("football", "nfl")
|
|
||||||
|
|
||||||
def test_base_url_set_correctly(self):
|
|
||||||
assert "espn.com" in self.source.base_url
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# MLBAPIDataSource
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestMLBAPIDataSource:
|
|
||||||
def setup_method(self):
|
|
||||||
self.source = MLBAPIDataSource(_make_logger())
|
|
||||||
|
|
||||||
def test_fetch_live_games_filters_live(self):
|
|
||||||
live_game = {"status": {"abstractGameState": "Live"}}
|
|
||||||
final_game = {"status": {"abstractGameState": "Final"}}
|
|
||||||
payload = {"dates": [{"games": [live_game, final_game]}]}
|
|
||||||
|
|
||||||
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
|
|
||||||
result = self.source.fetch_live_games("baseball", "mlb")
|
|
||||||
|
|
||||||
assert len(result) == 1
|
|
||||||
assert result[0] is live_game
|
|
||||||
|
|
||||||
def test_fetch_live_games_empty_dates(self):
|
|
||||||
payload = {"dates": []}
|
|
||||||
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
|
|
||||||
result = self.source.fetch_live_games("baseball", "mlb")
|
|
||||||
assert result == []
|
|
||||||
|
|
||||||
def test_fetch_live_games_returns_empty_on_error(self):
|
|
||||||
with patch.object(self.source.session, "get", side_effect=Exception("err")):
|
|
||||||
result = self.source.fetch_live_games("baseball", "mlb")
|
|
||||||
assert result == []
|
|
||||||
|
|
||||||
def test_fetch_schedule_aggregates_all_dates(self):
|
|
||||||
payload = {
|
|
||||||
"dates": [
|
|
||||||
{"games": [{"id": "1"}, {"id": "2"}]},
|
|
||||||
{"games": [{"id": "3"}]},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
|
|
||||||
result = self.source.fetch_schedule("baseball", "mlb", (datetime.now(), datetime.now()))
|
|
||||||
assert len(result) == 3
|
|
||||||
|
|
||||||
def test_fetch_schedule_returns_empty_on_error(self):
|
|
||||||
with patch.object(self.source.session, "get", side_effect=Exception("err")):
|
|
||||||
result = self.source.fetch_schedule("baseball", "mlb", (datetime.now(), datetime.now()))
|
|
||||||
assert result == []
|
|
||||||
|
|
||||||
def test_fetch_standings_success(self):
|
|
||||||
payload = {"records": []}
|
|
||||||
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
|
|
||||||
result = self.source.fetch_standings("baseball", "mlb")
|
|
||||||
assert result == payload
|
|
||||||
|
|
||||||
def test_fetch_standings_returns_empty_on_error(self):
|
|
||||||
with patch.object(self.source.session, "get", side_effect=Exception("err")):
|
|
||||||
result = self.source.fetch_standings("baseball", "mlb")
|
|
||||||
assert result == {}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# SoccerAPIDataSource
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestSoccerAPIDataSource:
|
|
||||||
def setup_method(self):
|
|
||||||
self.source = SoccerAPIDataSource(_make_logger(), api_key="test-key-123")
|
|
||||||
|
|
||||||
def test_headers_include_api_key(self):
|
|
||||||
headers = self.source.get_headers()
|
|
||||||
assert headers["X-Auth-Token"] == "test-key-123"
|
|
||||||
|
|
||||||
def test_headers_without_api_key(self):
|
|
||||||
source = SoccerAPIDataSource(_make_logger())
|
|
||||||
headers = source.get_headers()
|
|
||||||
assert "X-Auth-Token" not in headers
|
|
||||||
|
|
||||||
def test_fetch_live_games_success(self):
|
|
||||||
payload = {"matches": [{"id": "m1"}, {"id": "m2"}]}
|
|
||||||
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
|
|
||||||
result = self.source.fetch_live_games("soccer", "eng.1")
|
|
||||||
assert len(result) == 2
|
|
||||||
|
|
||||||
def test_fetch_live_games_returns_empty_on_error(self):
|
|
||||||
with patch.object(self.source.session, "get", side_effect=Exception("err")):
|
|
||||||
result = self.source.fetch_live_games("soccer", "eng.1")
|
|
||||||
assert result == []
|
|
||||||
|
|
||||||
def test_fetch_schedule_success(self):
|
|
||||||
payload = {"matches": [{"id": "m1"}]}
|
|
||||||
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
|
|
||||||
result = self.source.fetch_schedule("soccer", "eng.1", (datetime.now(), datetime.now()))
|
|
||||||
assert len(result) == 1
|
|
||||||
|
|
||||||
def test_fetch_schedule_returns_empty_on_error(self):
|
|
||||||
with patch.object(self.source.session, "get", side_effect=Exception("err")):
|
|
||||||
result = self.source.fetch_schedule("soccer", "eng.1", (datetime.now(), datetime.now()))
|
|
||||||
assert result == []
|
|
||||||
|
|
||||||
def test_fetch_standings_success(self):
|
|
||||||
payload = {"standings": []}
|
|
||||||
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
|
|
||||||
result = self.source.fetch_standings("soccer", "PL")
|
|
||||||
assert result == payload
|
|
||||||
|
|
||||||
def test_fetch_standings_returns_empty_on_error(self):
|
|
||||||
with patch.object(self.source.session, "get", side_effect=Exception("err")):
|
|
||||||
result = self.source.fetch_standings("soccer", "PL")
|
|
||||||
assert result == {}
|
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
"""
|
||||||
|
Starlark app location fields default to the device's own location.
|
||||||
|
|
||||||
|
The bug this pins: a user set Charlotte, North Carolina under General settings
|
||||||
|
(and in ledmatrix-weather), but a Tidbyt weather/radar app kept showing San
|
||||||
|
Francisco -- the ``DEFAULT_LOCATION`` its author hard-coded -- because the
|
||||||
|
app's own Location field was blank and nothing filled it. There was no San
|
||||||
|
Francisco anywhere in config.json to explain it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.device_location import (
|
||||||
|
FAILURE_RETRY_SECONDS,
|
||||||
|
DeviceLocationResolver,
|
||||||
|
apply_device_location,
|
||||||
|
location_field_ids,
|
||||||
|
parse_location,
|
||||||
|
pick_geocode_result,
|
||||||
|
)
|
||||||
|
|
||||||
|
CHARLOTTE = {"lat": 35.22709, "lng": -80.84313, "timezone": "America/New_York"}
|
||||||
|
DEVICE_CONFIG = {
|
||||||
|
"timezone": "America/Chicago",
|
||||||
|
"location": {"city": "Charlotte", "state": "North Carolina", "country": "US"},
|
||||||
|
}
|
||||||
|
SCHEMA = {"version": "1", "schema": [
|
||||||
|
{"typeOf": "location", "id": "location", "name": "Location"},
|
||||||
|
{"typeOf": "text", "id": "api_key", "name": "API key"},
|
||||||
|
]}
|
||||||
|
SAVED_BROOKLYN = json.dumps({"lat": "40.6782", "lng": "-73.9442",
|
||||||
|
"timezone": "America/New_York"})
|
||||||
|
|
||||||
|
|
||||||
|
class FakeCache:
|
||||||
|
def __init__(self):
|
||||||
|
self.store = {}
|
||||||
|
|
||||||
|
def get(self, key, max_age=300, memory_ttl=None):
|
||||||
|
return self.store.get(key)
|
||||||
|
|
||||||
|
def set(self, key, data, ttl=None):
|
||||||
|
self.store[key] = data
|
||||||
|
|
||||||
|
|
||||||
|
class CountingGeocoder:
|
||||||
|
def __init__(self, result=CHARLOTTE, error=None):
|
||||||
|
self.result = result
|
||||||
|
self.error = error
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
def __call__(self, city, state, country):
|
||||||
|
self.calls.append((city, state, country))
|
||||||
|
if self.error:
|
||||||
|
raise self.error
|
||||||
|
return self.result
|
||||||
|
|
||||||
|
|
||||||
|
class Clock:
|
||||||
|
def __init__(self):
|
||||||
|
self.now = 1_000_000.0
|
||||||
|
|
||||||
|
def __call__(self):
|
||||||
|
return self.now
|
||||||
|
|
||||||
|
|
||||||
|
def resolver(geocoder=None, cache=None, clock=None):
|
||||||
|
return DeviceLocationResolver(cache if cache is not None else FakeCache(),
|
||||||
|
MagicMock(), geocoder or CountingGeocoder(),
|
||||||
|
clock or Clock())
|
||||||
|
|
||||||
|
|
||||||
|
class TestApplyDeviceLocation:
|
||||||
|
def test_an_unset_location_renders_at_the_device_location(self):
|
||||||
|
out = apply_device_location({"api_key": "k"}, SCHEMA, resolver(), DEVICE_CONFIG)
|
||||||
|
loc = json.loads(out["location"])
|
||||||
|
assert (loc["lat"], loc["lng"]) == ("35.2271", "-80.8431")
|
||||||
|
assert loc["locality"] == "Charlotte"
|
||||||
|
assert out["api_key"] == "k"
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("blank", ["", " ", None, "{}", "not json",
|
||||||
|
json.dumps({"timezone": "America/Denver"})])
|
||||||
|
def test_a_blank_or_lat_lng_less_value_counts_as_unset(self, blank):
|
||||||
|
out = apply_device_location({"location": blank}, SCHEMA, resolver(), DEVICE_CONFIG)
|
||||||
|
assert json.loads(out["location"])["lat"] == "35.2271"
|
||||||
|
|
||||||
|
def test_a_saved_location_wins(self):
|
||||||
|
geocoder = CountingGeocoder()
|
||||||
|
out = apply_device_location({"location": SAVED_BROOKLYN}, SCHEMA,
|
||||||
|
resolver(geocoder), DEVICE_CONFIG)
|
||||||
|
assert out["location"] == SAVED_BROOKLYN
|
||||||
|
assert geocoder.calls == [], "nothing to resolve, so no network"
|
||||||
|
|
||||||
|
def test_the_input_is_not_mutated(self):
|
||||||
|
config = {"location": ""}
|
||||||
|
apply_device_location(config, SCHEMA, resolver(), DEVICE_CONFIG)
|
||||||
|
assert config == {"location": ""}
|
||||||
|
|
||||||
|
def test_a_timezone_typed_without_coordinates_is_kept(self):
|
||||||
|
partial = json.dumps({"timezone": "America/Denver"})
|
||||||
|
out = apply_device_location({"location": partial}, SCHEMA, resolver(), DEVICE_CONFIG)
|
||||||
|
assert json.loads(out["location"])["timezone"] == "America/Denver"
|
||||||
|
|
||||||
|
def test_the_timezone_is_the_citys_own(self):
|
||||||
|
"""The device timezone (Chicago here) is only a fallback."""
|
||||||
|
out = apply_device_location({}, SCHEMA, resolver(), DEVICE_CONFIG)
|
||||||
|
assert json.loads(out["location"])["timezone"] == "America/New_York"
|
||||||
|
|
||||||
|
def test_the_device_timezone_fills_in_when_the_geocoder_has_none(self):
|
||||||
|
geocoder = CountingGeocoder({"lat": 35.2, "lng": -80.8, "timezone": None})
|
||||||
|
out = apply_device_location({}, SCHEMA, resolver(geocoder), DEVICE_CONFIG)
|
||||||
|
assert json.loads(out["location"])["timezone"] == "America/Chicago"
|
||||||
|
|
||||||
|
def test_geocode_failure_falls_back_to_the_apps_own_default(self):
|
||||||
|
"""Dropped rather than passed blank: an app decoding "" would crash."""
|
||||||
|
failing = resolver(CountingGeocoder(error=OSError("network down")))
|
||||||
|
out = apply_device_location({"location": "", "api_key": "k"}, SCHEMA,
|
||||||
|
failing, DEVICE_CONFIG)
|
||||||
|
assert "location" not in out
|
||||||
|
assert out["api_key"] == "k"
|
||||||
|
|
||||||
|
def test_no_match_falls_back_too(self):
|
||||||
|
out = apply_device_location({}, SCHEMA, resolver(CountingGeocoder(result=None)),
|
||||||
|
DEVICE_CONFIG)
|
||||||
|
assert "location" not in out
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("device", [{}, None, {"location": {}},
|
||||||
|
{"location": {"city": " "}},
|
||||||
|
{"location": "Charlotte"}])
|
||||||
|
def test_no_device_location_falls_back(self, device):
|
||||||
|
geocoder = CountingGeocoder()
|
||||||
|
out = apply_device_location({}, SCHEMA, resolver(geocoder), device)
|
||||||
|
assert "location" not in out
|
||||||
|
assert geocoder.calls == []
|
||||||
|
|
||||||
|
def test_an_app_without_a_location_field_is_untouched(self):
|
||||||
|
schema = {"schema": [{"typeOf": "text", "id": "location"}]}
|
||||||
|
out = apply_device_location({"location": ""}, schema, resolver(), DEVICE_CONFIG)
|
||||||
|
assert out == {"location": ""}
|
||||||
|
|
||||||
|
def test_an_app_without_a_schema_is_untouched(self):
|
||||||
|
assert apply_device_location({"a": 1}, None, resolver(), DEVICE_CONFIG) == {"a": 1}
|
||||||
|
|
||||||
|
|
||||||
|
class TestGeocodingIsCached:
|
||||||
|
def test_the_city_is_geocoded_once(self):
|
||||||
|
geocoder = CountingGeocoder()
|
||||||
|
r = resolver(geocoder)
|
||||||
|
for _ in range(3):
|
||||||
|
apply_device_location({}, SCHEMA, r, DEVICE_CONFIG)
|
||||||
|
assert len(geocoder.calls) == 1
|
||||||
|
|
||||||
|
def test_the_cache_survives_a_restart(self):
|
||||||
|
cache = FakeCache()
|
||||||
|
resolver(CountingGeocoder(), cache).coordinates(DEVICE_CONFIG["location"])
|
||||||
|
geocoder = CountingGeocoder()
|
||||||
|
coords = resolver(geocoder, cache).coordinates(DEVICE_CONFIG["location"])
|
||||||
|
assert coords["lat"] == CHARLOTTE["lat"]
|
||||||
|
assert geocoder.calls == []
|
||||||
|
|
||||||
|
def test_a_new_device_city_is_looked_up(self):
|
||||||
|
geocoder = CountingGeocoder()
|
||||||
|
r = resolver(geocoder)
|
||||||
|
r.coordinates({"city": "Charlotte", "state": "NC", "country": "US"})
|
||||||
|
r.coordinates({"city": "Tampa", "state": "Florida", "country": "US"})
|
||||||
|
assert [c[0] for c in geocoder.calls] == ["Charlotte", "Tampa"]
|
||||||
|
|
||||||
|
def test_state_spellings_share_one_cache_entry(self):
|
||||||
|
""""North_Carolina", "north carolina" and "NC" are the same place."""
|
||||||
|
geocoder = CountingGeocoder()
|
||||||
|
r = resolver(geocoder)
|
||||||
|
for state in ("North Carolina", "North_Carolina", "NC"):
|
||||||
|
r.coordinates({"city": "Charlotte", "state": state, "country": "US"})
|
||||||
|
assert len(geocoder.calls) == 1
|
||||||
|
|
||||||
|
def test_a_failure_is_not_retried_on_every_render(self):
|
||||||
|
clock = Clock()
|
||||||
|
geocoder = CountingGeocoder(error=OSError("down"))
|
||||||
|
r = resolver(geocoder, clock=clock)
|
||||||
|
for _ in range(5):
|
||||||
|
assert r.coordinates(DEVICE_CONFIG["location"]) is None
|
||||||
|
assert len(geocoder.calls) == 1
|
||||||
|
|
||||||
|
clock.now += FAILURE_RETRY_SECONDS + 1
|
||||||
|
geocoder.error = None
|
||||||
|
assert r.coordinates(DEVICE_CONFIG["location"])["lat"] == CHARLOTTE["lat"]
|
||||||
|
assert len(geocoder.calls) == 2
|
||||||
|
|
||||||
|
def test_a_broken_cache_does_not_stop_the_lookup(self):
|
||||||
|
cache = MagicMock()
|
||||||
|
cache.get.side_effect = OSError("disk")
|
||||||
|
cache.set.side_effect = OSError("disk")
|
||||||
|
assert resolver(cache=cache).coordinates(DEVICE_CONFIG["location"]) is not None
|
||||||
|
|
||||||
|
|
||||||
|
class TestPickGeocodeResult:
|
||||||
|
RESULTS = [
|
||||||
|
{"latitude": 42.56, "longitude": -84.84, "country_code": "US", "admin1": "Michigan"},
|
||||||
|
{"latitude": 35.23, "longitude": -80.84, "country_code": "US", "admin1": "North Carolina"},
|
||||||
|
{"latitude": 18.34, "longitude": -64.93, "country_code": "VI", "admin1": "St Thomas"},
|
||||||
|
]
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("state", ["North Carolina", "north_carolina", "NC", "nc"])
|
||||||
|
def test_the_configured_state_wins_over_the_first_hit(self, state):
|
||||||
|
assert pick_geocode_result(self.RESULTS, state, "US")["admin1"] == "North Carolina"
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("country", ["US", "us", "USA", "United States"])
|
||||||
|
def test_country_spellings_match(self, country):
|
||||||
|
best = pick_geocode_result(self.RESULTS, "", country)
|
||||||
|
assert best["country_code"] == "US"
|
||||||
|
|
||||||
|
def test_an_unknown_state_falls_back_to_the_country(self):
|
||||||
|
assert pick_geocode_result(self.RESULTS, "Ontario", "US")["admin1"] == "Michigan"
|
||||||
|
|
||||||
|
def test_with_nothing_to_match_the_first_hit_is_used(self):
|
||||||
|
assert pick_geocode_result(self.RESULTS, "", "")["admin1"] == "Michigan"
|
||||||
|
|
||||||
|
def test_no_results(self):
|
||||||
|
assert pick_geocode_result([], "NC", "US") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_location_field_ids_reads_both_schema_shapes():
|
||||||
|
assert location_field_ids({"fields": [{"typeOf": "location", "id": "a"}]}) == ["a"]
|
||||||
|
assert location_field_ids({"schema": [{"type": "Location", "id": "b"},
|
||||||
|
{"typeOf": "location_based", "id": "c"}]}) == ["b"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_location_accepts_a_dict():
|
||||||
|
assert parse_location({"lat": 1, "lng": 2}) == {"lat": 1, "lng": 2}
|
||||||
|
assert parse_location({"lat": "x", "lng": 2}) is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The display plugin's render path
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
PLUGIN_DIR = Path(__file__).resolve().parent.parent / "plugin-repos" / "starlark-apps"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def manager_module():
|
||||||
|
if not PLUGIN_DIR.exists():
|
||||||
|
pytest.skip("starlark-apps plugin is not checked out")
|
||||||
|
sys.path.insert(0, str(PLUGIN_DIR))
|
||||||
|
# See test_starlark_display_contract.py: fcntl is POSIX-only and unused here.
|
||||||
|
injected_fcntl = "fcntl" not in sys.modules
|
||||||
|
if injected_fcntl:
|
||||||
|
stub = types.ModuleType("fcntl")
|
||||||
|
stub.LOCK_EX, stub.LOCK_UN = 2, 8
|
||||||
|
stub.flock = lambda *a, **kw: None
|
||||||
|
sys.modules["fcntl"] = stub
|
||||||
|
try:
|
||||||
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
"starlark_manager_location_test", PLUGIN_DIR / "manager.py")
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
except Exception as e: # noqa: BLE001 - optional deps may be absent
|
||||||
|
pytest.skip(f"starlark-apps manager is not importable here: {e}")
|
||||||
|
finally:
|
||||||
|
sys.path.remove(str(PLUGIN_DIR))
|
||||||
|
if injected_fcntl:
|
||||||
|
sys.modules.pop("fcntl", None)
|
||||||
|
|
||||||
|
|
||||||
|
def _render(manager_module, tmp_path, app_config, geocoder):
|
||||||
|
"""Run _render_app and return the config Pixlet was handed."""
|
||||||
|
plugin_cls = manager_module.StarlarkAppsPlugin
|
||||||
|
plugin = plugin_cls.__new__(plugin_cls)
|
||||||
|
plugin.logger = MagicMock()
|
||||||
|
plugin.config = {}
|
||||||
|
plugin.calculated_magnify = 1
|
||||||
|
plugin.pixlet = MagicMock()
|
||||||
|
plugin.pixlet.render.return_value = (True, None)
|
||||||
|
plugin._load_frames_from_cache = MagicMock(return_value=True)
|
||||||
|
plugin.device_location = resolver(geocoder)
|
||||||
|
plugin.global_config = DEVICE_CONFIG
|
||||||
|
|
||||||
|
app_cls = manager_module.StarlarkApp
|
||||||
|
app = app_cls.__new__(app_cls)
|
||||||
|
app.app_id = "weather"
|
||||||
|
app.config = dict(app_config)
|
||||||
|
app.schema = SCHEMA
|
||||||
|
app.star_file = tmp_path / "weather.star"
|
||||||
|
app.cache_file = tmp_path / "cached_render.webp"
|
||||||
|
app.last_render_time = 0
|
||||||
|
|
||||||
|
assert plugin._render_app(app, force=True) is True
|
||||||
|
return plugin.pixlet.render.call_args.kwargs["config"], app
|
||||||
|
|
||||||
|
|
||||||
|
class TestTheDisplayPluginRender:
|
||||||
|
def test_an_unset_location_is_rendered_at_the_device_location(self, manager_module, tmp_path):
|
||||||
|
config, app = _render(manager_module, tmp_path, {"render_interval": 300},
|
||||||
|
CountingGeocoder())
|
||||||
|
assert json.loads(config["location"])["lat"] == "35.2271"
|
||||||
|
assert "render_interval" not in config
|
||||||
|
assert "location" not in app.config, "never written back to the app's config"
|
||||||
|
|
||||||
|
def test_a_saved_location_wins(self, manager_module, tmp_path):
|
||||||
|
config, _ = _render(manager_module, tmp_path, {"location": SAVED_BROOKLYN},
|
||||||
|
CountingGeocoder())
|
||||||
|
assert config["location"] == SAVED_BROOKLYN
|
||||||
|
|
||||||
|
def test_geocode_failure_still_renders_with_the_apps_default(self, manager_module, tmp_path):
|
||||||
|
config, _ = _render(manager_module, tmp_path, {"location": ""},
|
||||||
|
CountingGeocoder(error=OSError("down")))
|
||||||
|
assert "location" not in config
|
||||||
@@ -209,30 +209,3 @@ class TestStandaloneBackupContract:
|
|||||||
assert "'.standalone-backup-'" in pm_text.replace('"', "'")
|
assert "'.standalone-backup-'" in pm_text.replace('"', "'")
|
||||||
assert ".standalone-backup-" in sm_text
|
assert ".standalone-backup-" in sm_text
|
||||||
|
|
||||||
|
|
||||||
class TestSkinTargetResolution:
|
|
||||||
def _store(self, tmp_path):
|
|
||||||
return PluginStoreManager(
|
|
||||||
plugins_dir=str(tmp_path / "plugins"),
|
|
||||||
uninstalled_registry_path=str(tmp_path / "uninstalled.json"))
|
|
||||||
|
|
||||||
def test_valid_skin_id_resolves_inside_skins_dir(self, tmp_path):
|
|
||||||
from src.skin_system import skin_runtime
|
|
||||||
store = self._store(tmp_path)
|
|
||||||
target = store._resolve_skin_target("my-skin")
|
|
||||||
assert target is not None
|
|
||||||
assert target.parent == skin_runtime.get_skins_directory().resolve()
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("bad_id", [
|
|
||||||
"../evil",
|
|
||||||
"..",
|
|
||||||
"a/../../etc",
|
|
||||||
"/etc/passwd",
|
|
||||||
"skin/../../outside",
|
|
||||||
"",
|
|
||||||
None,
|
|
||||||
123,
|
|
||||||
])
|
|
||||||
def test_traversal_and_malformed_ids_rejected(self, tmp_path, bad_id):
|
|
||||||
store = self._store(tmp_path)
|
|
||||||
assert store._resolve_skin_target(bad_id) is None
|
|
||||||
|
|||||||
@@ -0,0 +1,443 @@
|
|||||||
|
"""/api/v3/errors/* report the display service's errors, not the web's.
|
||||||
|
|
||||||
|
The error aggregator is a per-process singleton, and only the display service
|
||||||
|
runs plugins, so only its aggregator ever records anything. The routes used to
|
||||||
|
read the web process's own aggregator and so always answered "no errors".
|
||||||
|
|
||||||
|
Here the two services are two ErrorAggregator instances and two CacheManagers
|
||||||
|
over one temporary directory -- the same arrangement as the real services,
|
||||||
|
which share /var/cache/ledmatrix. The display side publishes through
|
||||||
|
ErrorSnapshotPublisher.tick(); the web side is the real blueprint.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from src.cache_manager import CacheManager # noqa: E402
|
||||||
|
from src import error_aggregator as errors # noqa: E402
|
||||||
|
from src.error_aggregator import ( # noqa: E402
|
||||||
|
ERROR_CLEAR_REQUEST_KEY, ERROR_SNAPSHOT_KEY, ErrorAggregator,
|
||||||
|
ErrorSnapshotPublisher,
|
||||||
|
)
|
||||||
|
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
||||||
|
|
||||||
|
|
||||||
|
class FakeClock:
|
||||||
|
def __init__(self):
|
||||||
|
self.now = 1000.0
|
||||||
|
|
||||||
|
def __call__(self):
|
||||||
|
return self.now
|
||||||
|
|
||||||
|
|
||||||
|
def _fail(aggregator, plugin_id="p1", message="boom", exc=ValueError, **kwargs):
|
||||||
|
try:
|
||||||
|
raise exc(message)
|
||||||
|
except Exception as e: # noqa: BLE001 - recording is the point
|
||||||
|
return aggregator.record_error(e, plugin_id=plugin_id, operation="update", **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def shared_cache(tmp_path, monkeypatch):
|
||||||
|
"""Two cache managers over one directory: the display's and the web's."""
|
||||||
|
monkeypatch.setattr(CacheManager, "_get_writable_cache_dir", lambda self: str(tmp_path))
|
||||||
|
display_cache, web_cache = CacheManager(), CacheManager()
|
||||||
|
yield display_cache, web_cache, tmp_path
|
||||||
|
display_cache.stop_cleanup_thread()
|
||||||
|
web_cache.stop_cleanup_thread()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def display(shared_cache):
|
||||||
|
display_cache, _, _ = shared_cache
|
||||||
|
aggregator = ErrorAggregator()
|
||||||
|
clock = FakeClock()
|
||||||
|
publisher = ErrorSnapshotPublisher(display_cache, aggregator=aggregator, clock=clock)
|
||||||
|
return aggregator, publisher, clock
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def web(api_v3_module, api_v3_client, shared_cache): # noqa: F811
|
||||||
|
_, web_cache, _ = shared_cache
|
||||||
|
api_v3_module.api_v3.cache_manager = web_cache
|
||||||
|
return api_v3_client
|
||||||
|
|
||||||
|
|
||||||
|
def _summary(client):
|
||||||
|
response = client.get("/api/v3/errors/summary")
|
||||||
|
assert response.status_code == 200, response.get_data(as_text=True)
|
||||||
|
return response.get_json()["data"]
|
||||||
|
|
||||||
|
|
||||||
|
# --- Display side -----------------------------------------------------------
|
||||||
|
|
||||||
|
class TestPublishing:
|
||||||
|
def test_errors_reach_the_shared_cache(self, display, shared_cache):
|
||||||
|
aggregator, publisher, _ = display
|
||||||
|
_, web_cache, _ = shared_cache
|
||||||
|
_fail(aggregator)
|
||||||
|
assert publisher.tick() is True
|
||||||
|
snapshot = web_cache.get(ERROR_SNAPSHOT_KEY, max_age=None, memory_ttl=0)
|
||||||
|
assert snapshot["total_errors"] == 1
|
||||||
|
assert snapshot["plugin_error_counts"] == {"p1": {"ValueError": 1}}
|
||||||
|
assert datetime.fromisoformat(snapshot["generated_at"])
|
||||||
|
|
||||||
|
def test_first_tick_publishes_even_with_no_errors(self, display, shared_cache):
|
||||||
|
# Replaces whatever a previous run of the display service left behind.
|
||||||
|
_, web_cache, _ = shared_cache
|
||||||
|
web_cache.set(ERROR_SNAPSHOT_KEY, {"total_errors": 99})
|
||||||
|
_, publisher, _ = display
|
||||||
|
assert publisher.tick() is True
|
||||||
|
assert web_cache.get(ERROR_SNAPSHOT_KEY, max_age=None, memory_ttl=0)["total_errors"] == 0
|
||||||
|
|
||||||
|
def test_nothing_is_written_when_nothing_changed(self, display, shared_cache):
|
||||||
|
aggregator, publisher, clock = display
|
||||||
|
publisher.tick()
|
||||||
|
publisher.cache_manager = MagicMock(wraps=publisher.cache_manager)
|
||||||
|
clock.now += 3600
|
||||||
|
assert publisher.tick() is False
|
||||||
|
publisher.cache_manager.set.assert_not_called()
|
||||||
|
|
||||||
|
def test_a_tight_failure_loop_is_throttled(self, display):
|
||||||
|
aggregator, publisher, clock = display
|
||||||
|
_fail(aggregator)
|
||||||
|
assert publisher.tick() is True
|
||||||
|
publisher.cache_manager = MagicMock(wraps=publisher.cache_manager)
|
||||||
|
for _ in range(50):
|
||||||
|
_fail(aggregator)
|
||||||
|
clock.now += 0.1
|
||||||
|
publisher.tick()
|
||||||
|
publisher.cache_manager.set.assert_not_called()
|
||||||
|
# Once the interval has passed, the backlog is published without a
|
||||||
|
# new error having to arrive.
|
||||||
|
clock.now += errors.SNAPSHOT_MIN_INTERVAL
|
||||||
|
assert publisher.tick() is True
|
||||||
|
assert publisher.cache_manager.set.call_count == 1
|
||||||
|
assert publisher.cache_manager.set.call_args[0][1]["total_errors"] == 51
|
||||||
|
|
||||||
|
def test_a_failing_cache_never_raises(self, display):
|
||||||
|
aggregator, publisher, clock = display
|
||||||
|
broken = MagicMock()
|
||||||
|
broken.get.side_effect = OSError("read-only file system")
|
||||||
|
broken.set.side_effect = OSError("read-only file system")
|
||||||
|
publisher.cache_manager = broken
|
||||||
|
_fail(aggregator)
|
||||||
|
assert publisher.tick() is False
|
||||||
|
broken.get.side_effect = None
|
||||||
|
broken.get.return_value = None
|
||||||
|
assert publisher.tick() is False # set still fails
|
||||||
|
# ...and retries at the throttled rate, not every tick.
|
||||||
|
calls = broken.set.call_count
|
||||||
|
clock.now += 1
|
||||||
|
publisher.tick()
|
||||||
|
assert broken.set.call_count == calls
|
||||||
|
|
||||||
|
def test_an_unserialisable_context_does_not_break_the_snapshot(self, display, shared_cache):
|
||||||
|
aggregator, publisher, _ = display
|
||||||
|
_fail(aggregator, context={"obj": object(), "n": 3})
|
||||||
|
assert publisher.tick() is True
|
||||||
|
_, web_cache, _ = shared_cache
|
||||||
|
snap = web_cache.get(ERROR_SNAPSHOT_KEY, max_age=None, memory_ttl=0)
|
||||||
|
assert snap["recent_errors"][0]["context"]["n"] == 3
|
||||||
|
|
||||||
|
def test_snapshot_stays_small(self, display):
|
||||||
|
aggregator, _, _ = display
|
||||||
|
for i in range(300):
|
||||||
|
_fail(aggregator, plugin_id=f"plugin-{i % 5}", message="x" * 20000,
|
||||||
|
context={f"k{j}": "v" * 5000 for j in range(50)})
|
||||||
|
snapshot = aggregator.build_snapshot()
|
||||||
|
assert len(snapshot["recent_errors"]) == 20
|
||||||
|
assert all(len(r["message"]) <= 300 for r in snapshot["recent_errors"])
|
||||||
|
assert len(json.dumps(snapshot)) < 200_000
|
||||||
|
|
||||||
|
def test_start_publishes_from_a_background_thread(self, shared_cache):
|
||||||
|
display_cache, web_cache, _ = shared_cache
|
||||||
|
aggregator = ErrorAggregator()
|
||||||
|
_fail(aggregator)
|
||||||
|
publisher = ErrorSnapshotPublisher(display_cache, aggregator=aggregator)
|
||||||
|
try:
|
||||||
|
publisher.start(interval=0.05)
|
||||||
|
deadline = datetime.now() + timedelta(seconds=5)
|
||||||
|
snapshot = None
|
||||||
|
while snapshot is None and datetime.now() < deadline:
|
||||||
|
snapshot = web_cache.get(ERROR_SNAPSHOT_KEY, max_age=None, memory_ttl=0)
|
||||||
|
assert snapshot and snapshot["total_errors"] == 1
|
||||||
|
finally:
|
||||||
|
publisher.stop()
|
||||||
|
|
||||||
|
def test_start_helper_never_raises(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(errors, "_snapshot_publisher", None)
|
||||||
|
|
||||||
|
def explode(*a, **k):
|
||||||
|
raise RuntimeError("no threads for you")
|
||||||
|
|
||||||
|
monkeypatch.setattr(errors.ErrorSnapshotPublisher, "start", explode)
|
||||||
|
assert errors.start_error_snapshot_publisher(MagicMock()) is None
|
||||||
|
|
||||||
|
def test_display_controller_starts_the_publisher(self):
|
||||||
|
# The display service is the only process that runs plugins, so it is
|
||||||
|
# the one that must publish; the web process must not.
|
||||||
|
# Read, not imported: display_controller needs rgbmatrix.
|
||||||
|
root = Path(__file__).parent.parent
|
||||||
|
controller = (root / "src" / "display_controller.py").read_text(encoding="utf-8")
|
||||||
|
init = controller.split(" def __init__(self):", 1)[1].split("\n def ", 1)[0]
|
||||||
|
assert "start_error_snapshot_publisher(self.cache_manager)" in init
|
||||||
|
web_dir = root / "web_interface"
|
||||||
|
for source in web_dir.rglob("*.py"):
|
||||||
|
assert "start_error_snapshot_publisher" not in source.read_text(encoding="utf-8"), source
|
||||||
|
|
||||||
|
|
||||||
|
class TestClearBefore:
|
||||||
|
def test_keeps_later_errors_and_rebuilds_counts(self):
|
||||||
|
aggregator = ErrorAggregator(pattern_threshold=2)
|
||||||
|
for _ in range(3):
|
||||||
|
_fail(aggregator, plugin_id="old")
|
||||||
|
for record in aggregator._records:
|
||||||
|
record.timestamp -= timedelta(hours=2)
|
||||||
|
for pattern in aggregator._patterns.values():
|
||||||
|
pattern.first_seen -= timedelta(hours=2)
|
||||||
|
_fail(aggregator, plugin_id="new", exc=KeyError)
|
||||||
|
cleared = aggregator.clear_before(datetime.now() - timedelta(hours=1))
|
||||||
|
assert cleared == 3
|
||||||
|
summary = aggregator.get_error_summary()
|
||||||
|
assert summary["total_errors"] == 1
|
||||||
|
assert summary["error_counts_by_type"] == {"KeyError": 1}
|
||||||
|
assert summary["plugin_error_counts"] == {"new": {"KeyError": 1}}
|
||||||
|
assert summary["active_patterns"] == {}
|
||||||
|
|
||||||
|
def test_a_pattern_made_only_of_later_errors_survives(self):
|
||||||
|
# A clear applied a few seconds late must not drop a pattern that
|
||||||
|
# formed entirely after the cutoff.
|
||||||
|
aggregator = ErrorAggregator(pattern_threshold=2)
|
||||||
|
cutoff = datetime.now() - timedelta(seconds=1)
|
||||||
|
for _ in range(3):
|
||||||
|
_fail(aggregator)
|
||||||
|
aggregator.clear_before(cutoff)
|
||||||
|
assert list(aggregator.get_error_summary()["active_patterns"]) == ["ValueError"]
|
||||||
|
|
||||||
|
def test_changes_the_version(self):
|
||||||
|
aggregator = ErrorAggregator()
|
||||||
|
v = aggregator.version
|
||||||
|
aggregator.clear_before(datetime.now())
|
||||||
|
assert aggregator.version != v
|
||||||
|
|
||||||
|
|
||||||
|
# --- Web side ---------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestRoutes:
|
||||||
|
def test_no_snapshot_yet(self, web):
|
||||||
|
data = _summary(web)
|
||||||
|
assert data["snapshot_available"] is False
|
||||||
|
assert data["generated_at"] is None
|
||||||
|
assert data["total_errors"] == 0
|
||||||
|
assert data["recent_errors"] == [] and data["active_patterns"] == {}
|
||||||
|
response = web.get("/api/v3/errors/summary")
|
||||||
|
assert "not reported" in response.get_json()["message"]
|
||||||
|
|
||||||
|
def test_summary_is_the_display_services(self, web, display):
|
||||||
|
aggregator, publisher, _ = display
|
||||||
|
for _ in range(5):
|
||||||
|
_fail(aggregator, plugin_id="weather", message="HTTP 500")
|
||||||
|
publisher.tick()
|
||||||
|
data = _summary(web)
|
||||||
|
assert data["snapshot_available"] is True
|
||||||
|
assert data["clear_pending"] is False
|
||||||
|
assert data["total_errors"] == 5
|
||||||
|
assert data["plugin_error_counts"] == {"weather": {"ValueError": 5}}
|
||||||
|
pattern = data["active_patterns"]["ValueError"]
|
||||||
|
assert pattern["affected_plugins"] == ["weather"]
|
||||||
|
assert pattern["sample_messages"] == ["HTTP 500"]
|
||||||
|
# The documented shape, plus only the documented additions.
|
||||||
|
assert set(data) == set(ErrorAggregator().get_error_summary()) | {
|
||||||
|
"generated_at", "snapshot_available", "clear_pending"}
|
||||||
|
|
||||||
|
def test_web_process_aggregator_is_not_what_is_reported(self, web, display, monkeypatch):
|
||||||
|
# The original bug: the route read this process's own aggregator.
|
||||||
|
local = ErrorAggregator()
|
||||||
|
_fail(local, plugin_id="only-in-web")
|
||||||
|
monkeypatch.setattr(errors, "_error_aggregator", local)
|
||||||
|
aggregator, publisher, _ = display
|
||||||
|
_fail(aggregator, plugin_id="from-display")
|
||||||
|
publisher.tick()
|
||||||
|
assert list(_summary(web)["plugin_error_counts"]) == ["from-display"]
|
||||||
|
|
||||||
|
def test_plugin_slice(self, web, display):
|
||||||
|
aggregator, publisher, _ = display
|
||||||
|
for _ in range(6):
|
||||||
|
_fail(aggregator, plugin_id="weather")
|
||||||
|
_fail(aggregator, plugin_id="clock", exc=KeyError)
|
||||||
|
publisher.tick()
|
||||||
|
data = web.get("/api/v3/errors/plugin/weather").get_json()["data"]
|
||||||
|
assert data["plugin_id"] == "weather"
|
||||||
|
assert data["status"] == "unhealthy"
|
||||||
|
assert data["total_errors"] == 6
|
||||||
|
assert data["error_types"] == {"ValueError": 6}
|
||||||
|
assert data["last_error"]["plugin_id"] == "weather"
|
||||||
|
assert data["snapshot_available"] is True
|
||||||
|
healthy = web.get("/api/v3/errors/plugin/never-failed").get_json()["data"]
|
||||||
|
assert healthy["status"] == "healthy" and healthy["total_errors"] == 0
|
||||||
|
assert healthy["last_error"] is None
|
||||||
|
|
||||||
|
def test_credentials_in_exception_text_are_redacted(self, web, display):
|
||||||
|
aggregator, publisher, _ = display
|
||||||
|
for _ in range(5):
|
||||||
|
_fail(aggregator, message="GET https://api.example.com/?api_key=SEKRIT123 failed")
|
||||||
|
publisher.tick()
|
||||||
|
body = web.get("/api/v3/errors/summary").get_data(as_text=True)
|
||||||
|
body += web.get("/api/v3/errors/plugin/p1").get_data(as_text=True)
|
||||||
|
assert "SEKRIT123" not in body
|
||||||
|
assert "api_key=<redacted>" in body
|
||||||
|
|
||||||
|
def test_snapshot_is_redacted_before_it_is_clipped(self, display):
|
||||||
|
"""The display redacts what it publishes, before clipping: keeping
|
||||||
|
only a traceback's tail could cut ``api_key=`` off and leave the key
|
||||||
|
itself, which the web side's redaction would then not recognise."""
|
||||||
|
aggregator, _, _ = display
|
||||||
|
record = _fail(aggregator, message="GET /?token=MSGSECRET failed")
|
||||||
|
tail = errors._SNAPSHOT_TRACE_CHARS - 3
|
||||||
|
filler = "x" * (tail - len("TRACESECRET "))
|
||||||
|
record.stack_trace = "requests failed: api_key=TRACESECRET " + filler
|
||||||
|
assert record.stack_trace[-tail:].startswith("TRACESECRET") # marker falls outside
|
||||||
|
record.context = {"url": "https://h/?password=CTXSECRET"}
|
||||||
|
for _ in range(5):
|
||||||
|
_fail(aggregator, message="GET /?token=SAMPLESECRET failed")
|
||||||
|
published = json.dumps(aggregator.build_snapshot())
|
||||||
|
for secret in ("MSGSECRET", "TRACESECRET", "CTXSECRET", "SAMPLESECRET"):
|
||||||
|
assert secret not in published, secret
|
||||||
|
|
||||||
|
|
||||||
|
class TestClear:
|
||||||
|
def test_clear_is_applied_by_the_display_and_republished(self, web, display):
|
||||||
|
aggregator, publisher, _ = display
|
||||||
|
_fail(aggregator)
|
||||||
|
_fail(aggregator)
|
||||||
|
publisher.tick()
|
||||||
|
response = web.post("/api/v3/errors/clear", json={"all": True})
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.get_json()["data"]
|
||||||
|
assert body["clear_requested"] is True
|
||||||
|
assert body["cleared_count"] == 2
|
||||||
|
# The display applies it on its next tick, throttle or not...
|
||||||
|
assert publisher.tick() is True
|
||||||
|
assert aggregator.get_error_summary()["total_errors"] == 0
|
||||||
|
data = _summary(web)
|
||||||
|
assert data["total_errors"] == 0 and data["clear_pending"] is False
|
||||||
|
# ...and only once.
|
||||||
|
assert publisher.tick() is False
|
||||||
|
|
||||||
|
def test_summary_hides_cleared_errors_before_the_display_applies_it(self, web, display):
|
||||||
|
aggregator, publisher, _ = display
|
||||||
|
for _ in range(5):
|
||||||
|
_fail(aggregator)
|
||||||
|
publisher.tick()
|
||||||
|
web.post("/api/v3/errors/clear", json={"all": True})
|
||||||
|
# No display tick yet.
|
||||||
|
data = _summary(web)
|
||||||
|
assert data["clear_pending"] is True
|
||||||
|
assert data["total_errors"] == 0
|
||||||
|
assert data["recent_errors"] == [] and data["active_patterns"] == {}
|
||||||
|
assert data["plugin_error_counts"] == {}
|
||||||
|
plugin = web.get("/api/v3/errors/plugin/p1").get_json()["data"]
|
||||||
|
assert plugin["status"] == "healthy" and plugin["total_errors"] == 0
|
||||||
|
|
||||||
|
def test_a_snapshot_written_just_before_the_clear_cannot_bring_errors_back(
|
||||||
|
self, web, display, shared_cache):
|
||||||
|
# The race: the display builds a snapshot, the user clicks Clear, and
|
||||||
|
# the display's write lands after the request.
|
||||||
|
aggregator, publisher, _ = display
|
||||||
|
display_cache, _, _ = shared_cache
|
||||||
|
for _ in range(3):
|
||||||
|
_fail(aggregator)
|
||||||
|
stale = aggregator.build_snapshot()
|
||||||
|
web.post("/api/v3/errors/clear", json={"all": True})
|
||||||
|
stale["applied_clear_id"] = None
|
||||||
|
display_cache.set(ERROR_SNAPSHOT_KEY, stale)
|
||||||
|
assert _summary(web)["total_errors"] == 0
|
||||||
|
|
||||||
|
def test_errors_after_the_clear_are_kept(self, web, display):
|
||||||
|
aggregator, publisher, clock = display
|
||||||
|
_fail(aggregator, plugin_id="before")
|
||||||
|
publisher.tick()
|
||||||
|
web.post("/api/v3/errors/clear", json={"all": True})
|
||||||
|
# An error lands after the request but before the display applies it.
|
||||||
|
for record in aggregator._records:
|
||||||
|
record.timestamp -= timedelta(seconds=5)
|
||||||
|
_fail(aggregator, plugin_id="after")
|
||||||
|
publisher.tick()
|
||||||
|
data = _summary(web)
|
||||||
|
assert data["plugin_error_counts"] == {"after": {"ValueError": 1}}
|
||||||
|
assert data["clear_pending"] is False
|
||||||
|
|
||||||
|
def test_age_based_clear(self, web, display):
|
||||||
|
aggregator, publisher, _ = display
|
||||||
|
_fail(aggregator, plugin_id="old")
|
||||||
|
_fail(aggregator, plugin_id="old")
|
||||||
|
for record in aggregator._records:
|
||||||
|
record.timestamp -= timedelta(hours=3)
|
||||||
|
_fail(aggregator, plugin_id="new")
|
||||||
|
publisher.tick()
|
||||||
|
body = web.post("/api/v3/errors/clear", json={"max_age_hours": 1}).get_json()["data"]
|
||||||
|
assert body["cleared_count"] == 2
|
||||||
|
pending = _summary(web)
|
||||||
|
assert pending["clear_pending"] is True
|
||||||
|
assert [r["plugin_id"] for r in pending["recent_errors"]] == ["new"]
|
||||||
|
publisher.tick()
|
||||||
|
data = _summary(web)
|
||||||
|
assert data["plugin_error_counts"] == {"new": {"ValueError": 1}}
|
||||||
|
assert data["total_errors"] == 1
|
||||||
|
|
||||||
|
def test_a_narrower_clear_does_not_undo_a_pending_wider_one(self, web, display):
|
||||||
|
aggregator, publisher, _ = display
|
||||||
|
for _ in range(3):
|
||||||
|
_fail(aggregator)
|
||||||
|
publisher.tick()
|
||||||
|
web.post("/api/v3/errors/clear", json={"all": True})
|
||||||
|
web.post("/api/v3/errors/clear", json={"max_age_hours": 24})
|
||||||
|
assert _summary(web)["total_errors"] == 0
|
||||||
|
publisher.tick()
|
||||||
|
assert aggregator.get_error_summary()["total_errors"] == 0
|
||||||
|
|
||||||
|
def test_default_body_still_means_older_than_24_hours(self, web, display):
|
||||||
|
aggregator, publisher, _ = display
|
||||||
|
_fail(aggregator)
|
||||||
|
publisher.tick()
|
||||||
|
response = web.post("/api/v3/errors/clear")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.get_json()["data"]["cleared_count"] == 0
|
||||||
|
publisher.tick()
|
||||||
|
assert _summary(web)["total_errors"] == 1
|
||||||
|
|
||||||
|
def test_validation_is_unchanged_but_all_skips_it(self, web):
|
||||||
|
assert web.post("/api/v3/errors/clear", json={"max_age_hours": 0}).status_code == 400
|
||||||
|
assert web.post("/api/v3/errors/clear", json={"max_age_hours": 9000}).status_code == 400
|
||||||
|
assert web.post("/api/v3/errors/clear",
|
||||||
|
json={"all": True, "max_age_hours": "junk"}).status_code == 200
|
||||||
|
|
||||||
|
def test_a_request_that_did_not_reach_the_cache_is_an_error(self, web, api_v3_module): # noqa: F811
|
||||||
|
cache = MagicMock()
|
||||||
|
cache.get.return_value = None
|
||||||
|
api_v3_module.api_v3.cache_manager = cache
|
||||||
|
response = web.post("/api/v3/errors/clear", json={"all": True})
|
||||||
|
assert response.status_code == 500
|
||||||
|
assert "clear request" in response.get_json()["message"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not hasattr(os, "fchmod") or os.name == "nt",
|
||||||
|
reason="POSIX file modes")
|
||||||
|
def test_both_files_are_group_readable(web, display, shared_cache):
|
||||||
|
aggregator, publisher, _ = display
|
||||||
|
_, _, directory = shared_cache
|
||||||
|
_fail(aggregator)
|
||||||
|
publisher.tick()
|
||||||
|
web.post("/api/v3/errors/clear", json={"all": True})
|
||||||
|
for key in (ERROR_SNAPSHOT_KEY, ERROR_CLEAR_REQUEST_KEY):
|
||||||
|
mode = stat.S_IMODE(os.stat(directory / f"{key}.json").st_mode)
|
||||||
|
assert mode == 0o660, (key, oct(mode))
|
||||||
@@ -0,0 +1,484 @@
|
|||||||
|
"""The Fonts tab's "Used by" column, across the two services.
|
||||||
|
|
||||||
|
Plugins register fonts with FontManager in the display service; the web
|
||||||
|
interface lists font files from its own scan and has no FontManager. The
|
||||||
|
display side publishes {catalog key: [plugin ids]} to the shared cache
|
||||||
|
(src/font_usage.py) and GET /api/v3/fonts/catalog merges it in per request.
|
||||||
|
|
||||||
|
As in test_error_snapshot_cross_process.py, the two services are two
|
||||||
|
CacheManagers over one temporary directory.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from src.cache_manager import CacheManager # noqa: E402
|
||||||
|
from src.font_manager import FontManager # noqa: E402
|
||||||
|
from src import font_usage # noqa: E402
|
||||||
|
from src.font_usage import ( # noqa: E402
|
||||||
|
FONT_USAGE_KEY, REFRESH_INTERVAL, FontUsagePublisher, build_font_usage,
|
||||||
|
catalog_key_for, read_font_usage, start_font_usage_publisher,
|
||||||
|
)
|
||||||
|
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
||||||
|
|
||||||
|
REPO_FONTS = Path(__file__).resolve().parent.parent / "assets" / "fonts"
|
||||||
|
|
||||||
|
|
||||||
|
class FakeClock:
|
||||||
|
def __init__(self):
|
||||||
|
self.now = 1000.0
|
||||||
|
|
||||||
|
def __call__(self):
|
||||||
|
return self.now
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def fm():
|
||||||
|
return FontManager({})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def shared_cache(tmp_path, monkeypatch):
|
||||||
|
"""Two cache managers over one directory: the display's and the web's."""
|
||||||
|
cache_dir = tmp_path / "cache"
|
||||||
|
cache_dir.mkdir()
|
||||||
|
monkeypatch.setattr(CacheManager, "_get_writable_cache_dir", lambda self: str(cache_dir))
|
||||||
|
display_cache, web_cache = CacheManager(), CacheManager()
|
||||||
|
yield display_cache, web_cache
|
||||||
|
display_cache.stop_cleanup_thread()
|
||||||
|
web_cache.stop_cleanup_thread()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def display(shared_cache):
|
||||||
|
"""A FontManager, the loaded-plugin table and a publisher over them."""
|
||||||
|
display_cache, _ = shared_cache
|
||||||
|
font_manager = FontManager({})
|
||||||
|
plugin_manager = SimpleNamespace(plugins={})
|
||||||
|
clock = FakeClock()
|
||||||
|
publisher = FontUsagePublisher(display_cache, font_manager, plugin_manager, clock=clock)
|
||||||
|
return font_manager, plugin_manager, publisher, clock
|
||||||
|
|
||||||
|
|
||||||
|
def _load(font_manager, plugin_manager, plugin_id, *families):
|
||||||
|
"""What a plugin's constructor does, then the plugin manager."""
|
||||||
|
for i, family in enumerate(families):
|
||||||
|
font_manager.register_manager_font(plugin_id, f"{plugin_id}.e{i}", family, 8)
|
||||||
|
plugin_manager.plugins[plugin_id] = object()
|
||||||
|
|
||||||
|
|
||||||
|
def _published(web_cache):
|
||||||
|
return web_cache.get(FONT_USAGE_KEY, max_age=None, memory_ttl=0)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Normalisation to the catalog's keys ------------------------------------
|
||||||
|
|
||||||
|
class TestCatalogKeys:
|
||||||
|
"""The web catalog keys a font by its file name without the extension,
|
||||||
|
as on disk. Plugins name fonts by FontManager family, alias or path."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("family, key", [
|
||||||
|
("press_start", "PressStart2P-Regular"),
|
||||||
|
("four_by_six", "4x6-font"),
|
||||||
|
("five_by_seven", "5x7"),
|
||||||
|
("tom_thumb", "tom-thumb"),
|
||||||
|
])
|
||||||
|
def test_aliases(self, fm, family, key):
|
||||||
|
assert catalog_key_for(family, fm.font_catalog) == key
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("family, key", [
|
||||||
|
("5x7", "5x7"),
|
||||||
|
("4x6", "4x6"), # the BDF, not 4x6-font.ttf
|
||||||
|
("6x13b", "6x13B"), # FontManager lower-cases stems
|
||||||
|
("6x13B", "6x13B"),
|
||||||
|
("PressStart2P-Regular", "PressStart2P-Regular"),
|
||||||
|
("5by7.regular", "5by7.regular"), # a dot inside the stem
|
||||||
|
])
|
||||||
|
def test_scanned_families_keep_the_file_names_case(self, fm, family, key):
|
||||||
|
assert catalog_key_for(family, fm.font_catalog) == key
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("family, key", [
|
||||||
|
("assets/fonts/4x6-font.ttf", "4x6-font"),
|
||||||
|
("5x7.bdf", "5x7"),
|
||||||
|
(str(REPO_FONTS / "MatrixChunky8.bdf"), "MatrixChunky8"),
|
||||||
|
])
|
||||||
|
def test_paths(self, fm, family, key):
|
||||||
|
assert catalog_key_for(family, fm.font_catalog) == key
|
||||||
|
|
||||||
|
def test_absolute_path_through_the_catalog(self, fm, tmp_path):
|
||||||
|
catalog = {"custom": str(tmp_path / "Custom-Face.ttf")}
|
||||||
|
assert catalog_key_for("custom", catalog, fonts_dir=str(tmp_path)) == "Custom-Face"
|
||||||
|
|
||||||
|
def test_a_font_outside_assets_fonts_has_no_row(self, fm, tmp_path):
|
||||||
|
# A plugin's own font ("plugin::family") lives in its directory; a
|
||||||
|
# file of the same name there is not the catalog's 5x7.
|
||||||
|
catalog = {"weather::5x7": str(tmp_path / "5x7.bdf")}
|
||||||
|
assert catalog_key_for("weather::5x7", catalog) is None
|
||||||
|
assert catalog_key_for(str(tmp_path / "5x7.bdf"), fm.font_catalog) is None
|
||||||
|
assert catalog_key_for("plugins/x/fonts/5x7.bdf", fm.font_catalog) is None
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("family", ["no_such_font", "", " ", None, 8, "notafont.txt"])
|
||||||
|
def test_unresolvable(self, fm, family):
|
||||||
|
assert catalog_key_for(family, fm.font_catalog) is None
|
||||||
|
|
||||||
|
def test_build_merges_aliases_and_names_of_one_file(self, fm):
|
||||||
|
font_manager = FontManager({})
|
||||||
|
font_manager.register_manager_font("calendar", "calendar.title", "press_start", 8)
|
||||||
|
font_manager.register_manager_font("weather", "weather.t", "PressStart2P-Regular", 16)
|
||||||
|
font_manager.register_manager_font("weather", "weather.c", "four_by_six", 8)
|
||||||
|
font_manager.register_manager_font("weather", "weather.x", "no_such_font", 8)
|
||||||
|
assert build_font_usage(font_manager) == {
|
||||||
|
"4x6-font": ["weather"],
|
||||||
|
"PressStart2P-Regular": ["calendar", "weather"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Display side -----------------------------------------------------------
|
||||||
|
|
||||||
|
class TestPublishing:
|
||||||
|
def test_publishes_after_plugins_load(self, display, shared_cache):
|
||||||
|
font_manager, plugin_manager, publisher, _ = display
|
||||||
|
_, web_cache = shared_cache
|
||||||
|
_load(font_manager, plugin_manager, "calendar", "four_by_six", "press_start")
|
||||||
|
_load(font_manager, plugin_manager, "of-the-day", "press_start")
|
||||||
|
assert publisher.tick() is True
|
||||||
|
snapshot = _published(web_cache)
|
||||||
|
assert snapshot["fonts"] == {
|
||||||
|
"4x6-font": ["calendar"],
|
||||||
|
"PressStart2P-Regular": ["calendar", "of-the-day"],
|
||||||
|
}
|
||||||
|
assert snapshot["generated_at"]
|
||||||
|
|
||||||
|
def test_a_plugin_that_failed_to_load_is_not_listed(self, display, shared_cache):
|
||||||
|
# Its constructor registered fonts, then validation failed, so it
|
||||||
|
# never reached plugin_manager.plugins.
|
||||||
|
font_manager, plugin_manager, publisher, _ = display
|
||||||
|
_, web_cache = shared_cache
|
||||||
|
font_manager.register_manager_font("broken", "broken.x", "press_start", 8)
|
||||||
|
_load(font_manager, plugin_manager, "calendar", "four_by_six")
|
||||||
|
publisher.tick()
|
||||||
|
assert _published(web_cache)["fonts"] == {"4x6-font": ["calendar"]}
|
||||||
|
|
||||||
|
def test_load_and_unload_later_republish(self, display, shared_cache):
|
||||||
|
font_manager, plugin_manager, publisher, _ = display
|
||||||
|
_, web_cache = shared_cache
|
||||||
|
_load(font_manager, plugin_manager, "calendar", "four_by_six")
|
||||||
|
publisher.tick()
|
||||||
|
|
||||||
|
_load(font_manager, plugin_manager, "weather", "four_by_six", "five_by_seven")
|
||||||
|
assert publisher.tick() is True
|
||||||
|
assert _published(web_cache)["fonts"] == {
|
||||||
|
"4x6-font": ["calendar", "weather"], "5x7": ["weather"]}
|
||||||
|
|
||||||
|
# What PluginManager.unload_plugin does.
|
||||||
|
del plugin_manager.plugins["weather"]
|
||||||
|
font_manager.forget_manager_fonts("weather")
|
||||||
|
assert publisher.tick() is True
|
||||||
|
assert _published(web_cache)["fonts"] == {"4x6-font": ["calendar"]}
|
||||||
|
|
||||||
|
def test_first_tick_replaces_a_previous_runs_snapshot(self, display, shared_cache):
|
||||||
|
_, web_cache = shared_cache
|
||||||
|
web_cache.set(FONT_USAGE_KEY, {"generated_at": "old", "fonts": {"5x7": ["gone"]}})
|
||||||
|
_, _, publisher, _ = display
|
||||||
|
assert publisher.tick() is True
|
||||||
|
assert _published(web_cache)["fonts"] == {}
|
||||||
|
|
||||||
|
def test_nothing_is_written_when_nothing_changed(self, display):
|
||||||
|
font_manager, plugin_manager, publisher, clock = display
|
||||||
|
_load(font_manager, plugin_manager, "countdown", "press_start")
|
||||||
|
publisher.tick()
|
||||||
|
publisher.cache_manager = MagicMock(wraps=publisher.cache_manager)
|
||||||
|
with patch.object(font_usage, "build_font_usage",
|
||||||
|
wraps=font_usage.build_font_usage) as build:
|
||||||
|
for _ in range(20):
|
||||||
|
# The countdown plugin registers per countdown at render time.
|
||||||
|
font_manager.register_manager_font("countdown", "countdown.e0", "press_start", 8)
|
||||||
|
clock.now += 5
|
||||||
|
assert publisher.tick() is False
|
||||||
|
build.assert_not_called()
|
||||||
|
publisher.cache_manager.set.assert_not_called()
|
||||||
|
|
||||||
|
def test_a_registration_that_leaves_usage_unchanged_is_not_written(self, display):
|
||||||
|
font_manager, plugin_manager, publisher, _ = display
|
||||||
|
_load(font_manager, plugin_manager, "countdown", "press_start")
|
||||||
|
publisher.tick()
|
||||||
|
publisher.cache_manager = MagicMock(wraps=publisher.cache_manager)
|
||||||
|
# A new element, same font: the version moves, the usage does not.
|
||||||
|
font_manager.register_manager_font("countdown", "countdown.c2.value", "press_start", 12)
|
||||||
|
assert publisher.tick() is False
|
||||||
|
publisher.cache_manager.set.assert_not_called()
|
||||||
|
|
||||||
|
def test_an_unchanged_snapshot_is_refreshed_daily(self, display):
|
||||||
|
# The cache's disk cleanup deletes entries older than 30 days.
|
||||||
|
_, _, publisher, clock = display
|
||||||
|
publisher.tick()
|
||||||
|
publisher.cache_manager = MagicMock(wraps=publisher.cache_manager)
|
||||||
|
clock.now += REFRESH_INTERVAL - 1
|
||||||
|
assert publisher.tick() is False
|
||||||
|
clock.now += 1
|
||||||
|
assert publisher.tick() is True
|
||||||
|
publisher.cache_manager.set.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
class TestFailureIsolation:
|
||||||
|
def test_a_failing_cache_write_does_not_raise_and_is_retried(self, display, shared_cache):
|
||||||
|
font_manager, plugin_manager, publisher, _ = display
|
||||||
|
_, web_cache = shared_cache
|
||||||
|
_load(font_manager, plugin_manager, "calendar", "four_by_six")
|
||||||
|
real = publisher.cache_manager
|
||||||
|
publisher.cache_manager = MagicMock()
|
||||||
|
publisher.cache_manager.set.side_effect = PermissionError("read-only cache")
|
||||||
|
assert publisher.tick() is False
|
||||||
|
publisher.cache_manager = real
|
||||||
|
assert publisher.tick() is True
|
||||||
|
assert _published(web_cache)["fonts"] == {"4x6-font": ["calendar"]}
|
||||||
|
|
||||||
|
def test_a_broken_font_manager_does_not_raise(self, shared_cache):
|
||||||
|
display_cache, _ = shared_cache
|
||||||
|
broken = SimpleNamespace(manager_fonts=None, font_catalog={})
|
||||||
|
publisher = FontUsagePublisher(display_cache, broken, SimpleNamespace(plugins={}))
|
||||||
|
assert publisher.tick() is False
|
||||||
|
|
||||||
|
def test_start_never_raises(self):
|
||||||
|
assert start_font_usage_publisher(MagicMock(), MagicMock(), None) is None
|
||||||
|
with patch.object(font_usage.FontUsagePublisher, "start",
|
||||||
|
side_effect=RuntimeError("no threads")):
|
||||||
|
assert start_font_usage_publisher(MagicMock(), MagicMock(), MagicMock()) is None
|
||||||
|
|
||||||
|
def test_start_publishes_from_a_thread(self, shared_cache, monkeypatch):
|
||||||
|
display_cache, web_cache = shared_cache
|
||||||
|
font_manager = FontManager({})
|
||||||
|
plugin_manager = SimpleNamespace(plugins={})
|
||||||
|
_load(font_manager, plugin_manager, "calendar", "press_start")
|
||||||
|
publisher = start_font_usage_publisher(display_cache, font_manager, plugin_manager)
|
||||||
|
try:
|
||||||
|
import time
|
||||||
|
deadline = time.monotonic() + 5
|
||||||
|
while _published(web_cache) is None and time.monotonic() < deadline:
|
||||||
|
time.sleep(0.02)
|
||||||
|
assert _published(web_cache)["fonts"] == {"PressStart2P-Regular": ["calendar"]}
|
||||||
|
finally:
|
||||||
|
publisher.stop()
|
||||||
|
|
||||||
|
|
||||||
|
class TestFontManagerRegistrations:
|
||||||
|
def test_version_moves_only_when_a_family_changes(self):
|
||||||
|
font_manager = FontManager({})
|
||||||
|
v0 = font_manager.manager_fonts_version
|
||||||
|
font_manager.register_manager_font("p", "p.a", "press_start", 8)
|
||||||
|
v1 = font_manager.manager_fonts_version
|
||||||
|
font_manager.register_manager_font("p", "p.a", "press_start", 10) # size only
|
||||||
|
assert font_manager.manager_fonts_version == v1 > v0
|
||||||
|
font_manager.register_manager_font("p", "p.a", "four_by_six", 8)
|
||||||
|
assert font_manager.manager_fonts_version > v1
|
||||||
|
|
||||||
|
def test_forget_drops_every_registration_of_that_manager(self):
|
||||||
|
font_manager = FontManager({})
|
||||||
|
font_manager.register_manager_font("p", "p.a", "press_start", 8)
|
||||||
|
font_manager.register_manager_font("q", "q.a", "press_start", 8)
|
||||||
|
version = font_manager.manager_fonts_version
|
||||||
|
font_manager.forget_manager_fonts("p")
|
||||||
|
assert "p" not in font_manager.manager_fonts
|
||||||
|
assert "p.a" not in font_manager.detected_fonts
|
||||||
|
assert "q" in font_manager.manager_fonts and "q.a" in font_manager.detected_fonts
|
||||||
|
assert font_manager.manager_fonts_version > version
|
||||||
|
font_manager.forget_manager_fonts("never-registered") # no error
|
||||||
|
|
||||||
|
def test_unload_plugin_forgets_its_fonts(self, tmp_path):
|
||||||
|
from src.plugin_system.plugin_manager import PluginManager
|
||||||
|
font_manager = FontManager({})
|
||||||
|
pm = PluginManager(plugins_dir=str(tmp_path), config_manager=None,
|
||||||
|
display_manager=None, cache_manager=None,
|
||||||
|
font_manager=font_manager)
|
||||||
|
try:
|
||||||
|
font_manager.register_manager_font("calendar", "calendar.t", "press_start", 8)
|
||||||
|
pm.plugins["calendar"] = SimpleNamespace()
|
||||||
|
assert pm.unload_plugin("calendar") is True
|
||||||
|
assert "calendar" not in font_manager.manager_fonts
|
||||||
|
finally:
|
||||||
|
pm.stop_update_worker()
|
||||||
|
|
||||||
|
def test_unload_survives_a_font_manager_that_raises(self, tmp_path):
|
||||||
|
from src.plugin_system.plugin_manager import PluginManager
|
||||||
|
font_manager = MagicMock()
|
||||||
|
font_manager.forget_manager_fonts.side_effect = RuntimeError("boom")
|
||||||
|
pm = PluginManager(plugins_dir=str(tmp_path), config_manager=None,
|
||||||
|
display_manager=None, cache_manager=None,
|
||||||
|
font_manager=font_manager)
|
||||||
|
try:
|
||||||
|
pm.plugins["calendar"] = SimpleNamespace()
|
||||||
|
assert pm.unload_plugin("calendar") is True
|
||||||
|
finally:
|
||||||
|
pm.stop_update_worker()
|
||||||
|
|
||||||
|
|
||||||
|
def test_display_controller_starts_the_publisher(test_display_controller, mock_cache_manager):
|
||||||
|
publisher = test_display_controller._font_usage_publisher
|
||||||
|
assert isinstance(publisher, FontUsagePublisher)
|
||||||
|
assert publisher.plugin_manager is test_display_controller.plugin_manager
|
||||||
|
publisher.tick()
|
||||||
|
assert "fonts" in mock_cache_manager._memory_cache[FONT_USAGE_KEY]
|
||||||
|
|
||||||
|
|
||||||
|
# --- Reading side -----------------------------------------------------------
|
||||||
|
|
||||||
|
class TestReading:
|
||||||
|
def test_nothing_published(self, shared_cache):
|
||||||
|
_, web_cache = shared_cache
|
||||||
|
assert read_font_usage(web_cache) is None
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("junk", [
|
||||||
|
"a string", {"fonts": "nope"}, {"generated_at": "x"}, [1, 2],
|
||||||
|
])
|
||||||
|
def test_a_malformed_snapshot_reads_as_unknown(self, shared_cache, junk):
|
||||||
|
_, web_cache = shared_cache
|
||||||
|
web_cache.set(FONT_USAGE_KEY, junk)
|
||||||
|
assert read_font_usage(web_cache) is None
|
||||||
|
|
||||||
|
def test_bad_entries_are_dropped(self, shared_cache):
|
||||||
|
_, web_cache = shared_cache
|
||||||
|
web_cache.set(FONT_USAGE_KEY, {"generated_at": 5, "fonts": {
|
||||||
|
"5x7": ["b", "a", 3, None, "", "a"], "4x6": "calendar", "tom-thumb": [],
|
||||||
|
}})
|
||||||
|
assert read_font_usage(web_cache) == {"generated_at": None, "fonts": {"5x7": ["a", "b"]}}
|
||||||
|
|
||||||
|
def test_a_cache_that_raises_reads_as_unknown(self):
|
||||||
|
cache = MagicMock()
|
||||||
|
cache.get.side_effect = OSError("gone")
|
||||||
|
assert read_font_usage(cache) is None
|
||||||
|
|
||||||
|
|
||||||
|
# --- Web side ---------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def web(api_v3_module, api_v3_client, shared_cache, tmp_path): # noqa: F811
|
||||||
|
"""The real blueprint over a scratch assets/fonts and the shared cache."""
|
||||||
|
_, web_cache = shared_cache
|
||||||
|
api_v3_module.api_v3.cache_manager = web_cache
|
||||||
|
fonts_dir = tmp_path / "root" / "assets" / "fonts"
|
||||||
|
fonts_dir.mkdir(parents=True)
|
||||||
|
for name in ("PressStart2P-Regular.ttf", "5x7.bdf", "my-custom.ttf"):
|
||||||
|
(fonts_dir / name).write_bytes(b"not really a font")
|
||||||
|
from web_interface.cache import delete_cached
|
||||||
|
delete_cached("fonts_catalog")
|
||||||
|
with patch("web_interface.blueprints.api_v3.fonts.PROJECT_ROOT", tmp_path / "root"):
|
||||||
|
yield api_v3_client, web_cache, fonts_dir
|
||||||
|
delete_cached("fonts_catalog")
|
||||||
|
|
||||||
|
|
||||||
|
def _catalog(client):
|
||||||
|
response = client.get("/api/v3/fonts/catalog")
|
||||||
|
assert response.status_code == 200, response.get_data(as_text=True)
|
||||||
|
return response.get_json()["data"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestCatalogEndpoint:
|
||||||
|
def test_unknown_until_the_display_service_reports(self, web):
|
||||||
|
client, _, _ = web
|
||||||
|
data = _catalog(client)
|
||||||
|
assert data["font_usage"] == {"available": False, "generated_at": None}
|
||||||
|
assert {key: info["used_by"] for key, info in data["catalog"].items()} == {
|
||||||
|
"PressStart2P-Regular": None, "5x7": None, "my-custom": None}
|
||||||
|
|
||||||
|
def test_usage_is_merged_into_the_rows(self, web):
|
||||||
|
client, web_cache, _ = web
|
||||||
|
web_cache.set(FONT_USAGE_KEY, {"generated_at": "2026-09-23T10:00:00", "fonts": {
|
||||||
|
"PressStart2P-Regular": ["calendar", "clock-simple"],
|
||||||
|
"my-custom": ["football-scoreboard"],
|
||||||
|
"not-in-the-catalog": ["weather"],
|
||||||
|
}})
|
||||||
|
data = _catalog(client)
|
||||||
|
assert data["font_usage"] == {"available": True, "generated_at": "2026-09-23T10:00:00"}
|
||||||
|
rows = data["catalog"]
|
||||||
|
assert rows["PressStart2P-Regular"]["used_by"] == ["calendar", "clock-simple"]
|
||||||
|
assert rows["my-custom"]["used_by"] == ["football-scoreboard"]
|
||||||
|
assert rows["5x7"]["used_by"] == []
|
||||||
|
assert "not-in-the-catalog" not in rows
|
||||||
|
# The existing fields are still there.
|
||||||
|
assert rows["5x7"]["type"] == "bdf" and rows["5x7"]["is_system"] is True
|
||||||
|
|
||||||
|
def test_keys_match_case_insensitively(self, web):
|
||||||
|
client, web_cache, _ = web
|
||||||
|
web_cache.set(FONT_USAGE_KEY, {"fonts": {"pressstart2p-regular": ["calendar"],
|
||||||
|
"PressStart2P-Regular": ["weather"]}})
|
||||||
|
rows = _catalog(client)["catalog"]
|
||||||
|
assert rows["PressStart2P-Regular"]["used_by"] == ["calendar", "weather"]
|
||||||
|
|
||||||
|
def test_the_scan_stays_cached_while_usage_is_read_per_request(self, web):
|
||||||
|
client, web_cache, _ = web
|
||||||
|
_catalog(client) # fills the 5-minute cache
|
||||||
|
from web_interface.blueprints.api_v3 import fonts as fonts_module
|
||||||
|
with patch.object(fonts_module.os, "listdir",
|
||||||
|
side_effect=AssertionError("rescanned assets/fonts")):
|
||||||
|
web_cache.set(FONT_USAGE_KEY, {"fonts": {"5x7": ["weather"]}})
|
||||||
|
assert _catalog(client)["catalog"]["5x7"]["used_by"] == ["weather"]
|
||||||
|
web_cache.set(FONT_USAGE_KEY, {"fonts": {"5x7": ["calendar"]}})
|
||||||
|
assert _catalog(client)["catalog"]["5x7"]["used_by"] == ["calendar"]
|
||||||
|
# ...and usage never leaks into the cached scan.
|
||||||
|
from web_interface.cache import get_cached
|
||||||
|
cached = get_cached("fonts_catalog", ttl_seconds=300)
|
||||||
|
assert cached and all("used_by" not in info for info in cached.values())
|
||||||
|
|
||||||
|
def test_end_to_end_from_the_display_publisher(self, web, display):
|
||||||
|
client, _, fonts_dir = web
|
||||||
|
font_manager, plugin_manager, publisher, _ = display
|
||||||
|
_load(font_manager, plugin_manager, "calendar", "press_start", "five_by_seven")
|
||||||
|
publisher.tick()
|
||||||
|
rows = _catalog(client)["catalog"]
|
||||||
|
assert rows["PressStart2P-Regular"]["used_by"] == ["calendar"]
|
||||||
|
assert rows["5x7"]["used_by"] == ["calendar"]
|
||||||
|
# A later change reaches the web process too: it reads the file, not
|
||||||
|
# the copy its own memory tier kept from the first read.
|
||||||
|
_load(font_manager, plugin_manager, "weather", "five_by_seven")
|
||||||
|
publisher.tick()
|
||||||
|
assert _catalog(client)["catalog"]["5x7"]["used_by"] == ["calendar", "weather"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeleteInUseFont:
|
||||||
|
"""The UI warns with the catalog's used_by; the server does not block."""
|
||||||
|
|
||||||
|
def test_the_catalog_names_the_plugins_and_delete_still_works(self, web):
|
||||||
|
client, web_cache, fonts_dir = web
|
||||||
|
web_cache.set(FONT_USAGE_KEY, {"fonts": {"my-custom": ["football-scoreboard", "clock-simple"]}})
|
||||||
|
assert _catalog(client)["catalog"]["my-custom"]["used_by"] == [
|
||||||
|
"clock-simple", "football-scoreboard"]
|
||||||
|
response = client.delete("/api/v3/fonts/my-custom")
|
||||||
|
assert response.status_code == 200, response.get_json()
|
||||||
|
assert not (fonts_dir / "my-custom.ttf").exists()
|
||||||
|
assert "my-custom" not in _catalog(client)["catalog"]
|
||||||
|
|
||||||
|
def test_system_fonts_are_still_refused(self, web):
|
||||||
|
client, web_cache, fonts_dir = web
|
||||||
|
web_cache.set(FONT_USAGE_KEY, {"fonts": {}})
|
||||||
|
assert client.delete("/api/v3/fonts/5x7").status_code == 403
|
||||||
|
assert (fonts_dir / "5x7.bdf").exists()
|
||||||
|
|
||||||
|
|
||||||
|
class TestFontsTemplate:
|
||||||
|
"""fonts.html renders usage with DOM text APIs and warns before delete."""
|
||||||
|
|
||||||
|
TEMPLATE = (Path(__file__).resolve().parent.parent / "web_interface" / "templates"
|
||||||
|
/ "v3" / "partials" / "fonts.html").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
def test_used_by_is_never_written_as_html(self):
|
||||||
|
assert "usedSpan.textContent = font.usedBy.join(', ')" in self.TEMPLATE
|
||||||
|
assert "usedSpan.innerHTML" not in self.TEMPLATE
|
||||||
|
|
||||||
|
def test_delete_confirms_with_the_plugins(self):
|
||||||
|
assert "Used by: ${usedBy.join(', ')}" in self.TEMPLATE
|
||||||
|
assert "confirm(deleteFontConfirmMessage(fontFamily, usedBy))" in self.TEMPLATE
|
||||||
|
|
||||||
|
def test_catalog_fetches_bypass_the_browser_cache(self):
|
||||||
|
# /api/v3 GETs carry max-age=5. The usage check just before a delete
|
||||||
|
# would otherwise be the copy the post-delete reload is served, and
|
||||||
|
# the deleted font would stay listed.
|
||||||
|
fetches = [line for line in self.TEMPLATE.splitlines()
|
||||||
|
if "fetch(" in line and "/api/v3/fonts/catalog" in line]
|
||||||
|
assert len(fetches) == 2
|
||||||
|
assert all("cache: 'no-store'" in line for line in fetches)
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
"""``skin`` and ``skin_options`` outlive the skin system in stored configs.
|
||||||
|
|
||||||
|
The skin system was removed, but a config.json written while it existed can
|
||||||
|
carry ``skin`` / ``skin_options`` in any plugin section. They used to be core
|
||||||
|
plugin properties; now they are RETIRED_PLUGIN_KEYS, dropped where a section
|
||||||
|
is prepared or validated. Most plugin schemas set
|
||||||
|
``"additionalProperties": false``, so without that a device upgraded with such
|
||||||
|
a config would flag the plugin degraded on every start.
|
||||||
|
|
||||||
|
The web save paths are covered in
|
||||||
|
test/web_interface/test_plugin_config_json_saves.py (TestRetiredSkinKeys).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.config_manager import ConfigManager
|
||||||
|
from src.plugin_system.plugin_manager import PluginManager
|
||||||
|
from src.plugin_system.schema_manager import (
|
||||||
|
CORE_PLUGIN_PROPERTIES,
|
||||||
|
RETIRED_PLUGIN_KEYS,
|
||||||
|
SchemaManager,
|
||||||
|
drop_retired_plugin_keys,
|
||||||
|
)
|
||||||
|
|
||||||
|
PLUGIN_ID = "scoreboard-shaped"
|
||||||
|
|
||||||
|
STRICT_SCHEMA = {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": False,
|
||||||
|
"properties": {
|
||||||
|
"enabled": {"type": "boolean", "default": True},
|
||||||
|
"favorite_teams": {"type": "array", "items": {"type": "string"},
|
||||||
|
"default": []},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
OLD_SECTION = {
|
||||||
|
"enabled": True,
|
||||||
|
"favorite_teams": ["TB"],
|
||||||
|
"skin": {"live": "neon"},
|
||||||
|
"skin_options": {"accent": "#ff0000"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _copy(value):
|
||||||
|
return json.loads(json.dumps(value))
|
||||||
|
|
||||||
|
|
||||||
|
class TestDropRetiredPluginKeys:
|
||||||
|
def test_they_are_no_longer_core_properties(self):
|
||||||
|
assert RETIRED_PLUGIN_KEYS == {"skin", "skin_options"}
|
||||||
|
assert RETIRED_PLUGIN_KEYS.isdisjoint(CORE_PLUGIN_PROPERTIES)
|
||||||
|
|
||||||
|
def test_drops_both(self):
|
||||||
|
assert drop_retired_plugin_keys(OLD_SECTION, STRICT_SCHEMA) == {
|
||||||
|
"enabled": True, "favorite_teams": ["TB"]}
|
||||||
|
|
||||||
|
def test_does_not_mutate_the_section(self):
|
||||||
|
section = _copy(OLD_SECTION)
|
||||||
|
drop_retired_plugin_keys(section, STRICT_SCHEMA)
|
||||||
|
assert section == OLD_SECTION
|
||||||
|
|
||||||
|
def test_returns_the_same_object_when_there_is_nothing_to_drop(self):
|
||||||
|
section = {"enabled": True}
|
||||||
|
assert drop_retired_plugin_keys(section, STRICT_SCHEMA) is section
|
||||||
|
|
||||||
|
def test_a_plugin_that_declares_skin_keeps_it(self):
|
||||||
|
schema = _copy(STRICT_SCHEMA)
|
||||||
|
schema["properties"]["skin"] = {"type": "string"}
|
||||||
|
assert drop_retired_plugin_keys(OLD_SECTION, schema) == {
|
||||||
|
"enabled": True, "favorite_teams": ["TB"], "skin": {"live": "neon"}}
|
||||||
|
|
||||||
|
def test_without_a_schema_nothing_is_dropped(self):
|
||||||
|
assert drop_retired_plugin_keys(OLD_SECTION, None) is OLD_SECTION
|
||||||
|
|
||||||
|
def test_tolerates_a_non_dict(self):
|
||||||
|
assert drop_retired_plugin_keys(None, STRICT_SCHEMA) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def schema_manager(tmp_path):
|
||||||
|
plugin_dir = tmp_path / "plugins" / PLUGIN_ID
|
||||||
|
plugin_dir.mkdir(parents=True)
|
||||||
|
(plugin_dir / "config_schema.json").write_text(json.dumps(STRICT_SCHEMA),
|
||||||
|
encoding="utf-8")
|
||||||
|
(plugin_dir / "manifest.json").write_text(json.dumps({"id": PLUGIN_ID}),
|
||||||
|
encoding="utf-8")
|
||||||
|
return SchemaManager(plugins_dir=tmp_path / "plugins", project_root=tmp_path)
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidation:
|
||||||
|
def test_the_prepared_section_has_neither_and_validates(self, schema_manager):
|
||||||
|
prepared = schema_manager.prepare_plugin_config(PLUGIN_ID, _copy(OLD_SECTION))
|
||||||
|
assert "skin" not in prepared and "skin_options" not in prepared
|
||||||
|
assert prepared["favorite_teams"] == ["TB"]
|
||||||
|
assert schema_manager.validate_config_against_schema(
|
||||||
|
prepared, STRICT_SCHEMA, PLUGIN_ID) == (True, [])
|
||||||
|
|
||||||
|
def test_the_raw_section_validates_too(self, schema_manager):
|
||||||
|
assert schema_manager.validate_config_against_schema(
|
||||||
|
_copy(OLD_SECTION), STRICT_SCHEMA, PLUGIN_ID) == (True, [])
|
||||||
|
|
||||||
|
def test_validate_all_plugin_configs_passes(self, schema_manager, tmp_path):
|
||||||
|
config_file = tmp_path / "config.json"
|
||||||
|
config_file.write_text(json.dumps({"timezone": "UTC", PLUGIN_ID: OLD_SECTION}),
|
||||||
|
encoding="utf-8")
|
||||||
|
config_manager = ConfigManager(config_path=str(config_file),
|
||||||
|
secrets_path=str(tmp_path / "secrets.json"))
|
||||||
|
results = config_manager.validate_all_plugin_configs(schema_manager)
|
||||||
|
assert results[PLUGIN_ID] == {"valid": True, "errors": []}
|
||||||
|
|
||||||
|
def test_a_real_violation_is_still_reported(self, schema_manager):
|
||||||
|
section = dict(OLD_SECTION, not_declared=1)
|
||||||
|
valid, errors = schema_manager.validate_config_against_schema(
|
||||||
|
section, STRICT_SCHEMA, PLUGIN_ID)
|
||||||
|
assert valid is False and len(errors) == 1
|
||||||
|
assert "not_declared" in errors[0]
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoadPath:
|
||||||
|
"""The real PluginManager.load_plugin, with the plugin class stubbed."""
|
||||||
|
|
||||||
|
def test_an_old_section_loads_without_warning_or_degraded(self, tmp_path):
|
||||||
|
plugins_dir = tmp_path / "plugins"
|
||||||
|
plugin_dir = plugins_dir / PLUGIN_ID
|
||||||
|
plugin_dir.mkdir(parents=True)
|
||||||
|
manifest = {"id": PLUGIN_ID, "name": "Scoreboard-shaped", "version": "1.0.0",
|
||||||
|
"entry_point": "manager.py", "class_name": "Plugin"}
|
||||||
|
(plugin_dir / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
|
||||||
|
(plugin_dir / "config_schema.json").write_text(json.dumps(STRICT_SCHEMA),
|
||||||
|
encoding="utf-8")
|
||||||
|
|
||||||
|
config_manager = MagicMock()
|
||||||
|
config_manager.load_config.return_value = {PLUGIN_ID: _copy(OLD_SECTION)}
|
||||||
|
with patch('src.common.permission_utils.ensure_directory_permissions'):
|
||||||
|
manager = PluginManager(plugins_dir=str(plugins_dir),
|
||||||
|
config_manager=config_manager,
|
||||||
|
display_manager=MagicMock(),
|
||||||
|
cache_manager=MagicMock())
|
||||||
|
manager.logger = MagicMock()
|
||||||
|
manager.health_tracker = MagicMock()
|
||||||
|
manager.plugin_manifests[PLUGIN_ID] = manifest
|
||||||
|
manager.plugin_loader.find_plugin_directory = MagicMock(return_value=plugin_dir)
|
||||||
|
|
||||||
|
received = {}
|
||||||
|
|
||||||
|
def fake_load_plugin(**kwargs):
|
||||||
|
received.update(kwargs["config"])
|
||||||
|
instance = MagicMock()
|
||||||
|
instance.validate_config.return_value = True
|
||||||
|
return instance, MagicMock()
|
||||||
|
|
||||||
|
manager.plugin_loader.load_plugin = MagicMock(side_effect=fake_load_plugin)
|
||||||
|
|
||||||
|
assert manager.load_plugin(PLUGIN_ID) is True
|
||||||
|
assert "skin" not in received and "skin_options" not in received
|
||||||
|
assert received["favorite_teams"] == ["TB"]
|
||||||
|
warnings = [c for c in manager.logger.warning.call_args_list
|
||||||
|
if "does not match its schema" in str(c.args[0])]
|
||||||
|
assert warnings == []
|
||||||
|
degraded = [c.args for c in manager.health_tracker.set_degraded.call_args_list
|
||||||
|
if c.args[0] == PLUGIN_ID]
|
||||||
|
assert degraded, "schema validation never ran"
|
||||||
|
assert degraded[-1][1] is None
|
||||||
@@ -1,377 +0,0 @@
|
|||||||
"""Gap tests for src/skin_system/skin_runtime.py: the discovery cache,
|
|
||||||
module namespacing internals, API gating edge cases, and targeting.
|
|
||||||
|
|
||||||
test/test_skin_system.py already covers discovery validation, load_skin
|
|
||||||
basics, and build_context — nothing here duplicates those.
|
|
||||||
|
|
||||||
NOTE: every test uses a UNIQUE skin id. load_skin caches the entry
|
|
||||||
module in sys.modules per skin id and never re-executes it, so reusing
|
|
||||||
an id across tests would silently serve another test's module.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import builtins
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
# skin_runtime -> skin_base can transitively reach hardware modules via
|
|
||||||
# sports imports in sibling tests' processes; stub the matrix driver
|
|
||||||
# before importing, matching test_skin_system.py.
|
|
||||||
sys.modules.setdefault("rgbmatrix", MagicMock())
|
|
||||||
|
|
||||||
from src.skin_system import skin_runtime
|
|
||||||
from src.skin_system.skin_base import SKIN_API_VERSION, ScoreboardSkin
|
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_BODY = (
|
|
||||||
"from src.skin_system.skin_base import ScoreboardSkin\n"
|
|
||||||
"class {cls}(ScoreboardSkin):\n"
|
|
||||||
" def render_live(self, ctx, game):\n"
|
|
||||||
" return True\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _clean_runtime_state():
|
|
||||||
"""Clear the discovery cache and any skin modules this test creates."""
|
|
||||||
skin_runtime._discovery_cache.clear()
|
|
||||||
before = {k for k in sys.modules if k.startswith("_skin_")}
|
|
||||||
yield
|
|
||||||
skin_runtime._discovery_cache.clear()
|
|
||||||
created = [k for k in sys.modules
|
|
||||||
if k.startswith("_skin_") and k not in before]
|
|
||||||
for k in created:
|
|
||||||
sys.modules.pop(k, None)
|
|
||||||
|
|
||||||
|
|
||||||
def make_skin(skins_dir: Path, skin_id: str, *,
|
|
||||||
api_version: str = SKIN_API_VERSION,
|
|
||||||
class_name: str = "TestSkin",
|
|
||||||
body: str = None,
|
|
||||||
extra_files: dict = None,
|
|
||||||
entry_point: str = None,
|
|
||||||
manifest_id: str = None,
|
|
||||||
manifest_extra: dict = None,
|
|
||||||
write_entry: bool = True) -> Path:
|
|
||||||
"""Write a skin package directory and return its path."""
|
|
||||||
skin_dir = skins_dir / skin_id
|
|
||||||
skin_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
manifest = {
|
|
||||||
"id": manifest_id or skin_id,
|
|
||||||
"name": skin_id,
|
|
||||||
"version": "1.0.0",
|
|
||||||
"skin_api_version": api_version,
|
|
||||||
"class_name": class_name,
|
|
||||||
}
|
|
||||||
if entry_point:
|
|
||||||
manifest["entry_point"] = entry_point
|
|
||||||
manifest.update(manifest_extra or {})
|
|
||||||
(skin_dir / "skin.json").write_text(json.dumps(manifest))
|
|
||||||
if write_entry:
|
|
||||||
entry_name = entry_point or "skin.py"
|
|
||||||
(skin_dir / entry_name).write_text(
|
|
||||||
body if body is not None else DEFAULT_BODY.format(cls=class_name))
|
|
||||||
for name, content in (extra_files or {}).items():
|
|
||||||
(skin_dir / name).write_text(content)
|
|
||||||
return skin_dir
|
|
||||||
|
|
||||||
|
|
||||||
def counting_read_manifest(monkeypatch):
|
|
||||||
"""Wrap skin_runtime._read_manifest with a call counter."""
|
|
||||||
original = skin_runtime._read_manifest
|
|
||||||
counter = {"count": 0}
|
|
||||||
|
|
||||||
def wrapper(skin_dir):
|
|
||||||
counter["count"] += 1
|
|
||||||
return original(skin_dir)
|
|
||||||
|
|
||||||
monkeypatch.setattr(skin_runtime, "_read_manifest", wrapper)
|
|
||||||
return counter
|
|
||||||
|
|
||||||
|
|
||||||
def bump_mtime(path: Path, offset: float = 100.0):
|
|
||||||
"""Set a distinct, strictly later mtime so the fingerprint changes."""
|
|
||||||
t = time.time() + offset
|
|
||||||
os.utime(path, (t, t))
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# A. Discovery cache
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestDiscoveryCache:
|
|
||||||
def test_second_call_serves_cache(self, tmp_path, monkeypatch):
|
|
||||||
make_skin(tmp_path, "t01-cache-hit")
|
|
||||||
counter = counting_read_manifest(monkeypatch)
|
|
||||||
first = skin_runtime.discover_skins(tmp_path)
|
|
||||||
count_after_first = counter["count"]
|
|
||||||
assert count_after_first >= 1
|
|
||||||
second = skin_runtime.discover_skins(tmp_path)
|
|
||||||
assert counter["count"] == count_after_first # no re-read
|
|
||||||
assert second == first
|
|
||||||
assert "t01-cache-hit" in second
|
|
||||||
|
|
||||||
def test_manifest_edit_invalidates_without_force_refresh(self, tmp_path):
|
|
||||||
skin_dir = make_skin(tmp_path, "t02-edit")
|
|
||||||
skins = skin_runtime.discover_skins(tmp_path)
|
|
||||||
assert skins["t02-edit"]["name"] == "t02-edit"
|
|
||||||
|
|
||||||
manifest_path = skin_dir / "skin.json"
|
|
||||||
manifest = json.loads(manifest_path.read_text())
|
|
||||||
manifest["name"] = "renamed"
|
|
||||||
manifest_path.write_text(json.dumps(manifest))
|
|
||||||
bump_mtime(manifest_path)
|
|
||||||
|
|
||||||
skins = skin_runtime.discover_skins(tmp_path) # no force_refresh
|
|
||||||
assert skins["t02-edit"]["name"] == "renamed"
|
|
||||||
|
|
||||||
def test_new_skin_dir_invalidates(self, tmp_path):
|
|
||||||
make_skin(tmp_path, "t03-first")
|
|
||||||
assert set(skin_runtime.discover_skins(tmp_path)) == {"t03-first"}
|
|
||||||
|
|
||||||
new_dir = make_skin(tmp_path, "t03-second")
|
|
||||||
bump_mtime(new_dir / "skin.json")
|
|
||||||
bump_mtime(tmp_path)
|
|
||||||
|
|
||||||
skins = skin_runtime.discover_skins(tmp_path) # no force_refresh
|
|
||||||
assert set(skins) == {"t03-first", "t03-second"}
|
|
||||||
|
|
||||||
def test_py_file_change_does_not_invalidate(self, tmp_path, monkeypatch):
|
|
||||||
# PIN: the fingerprint only globs */skin.json — editing a skin's
|
|
||||||
# .py file alone does NOT invalidate the cache; the cached
|
|
||||||
# manifests are still served (a code change needs a restart).
|
|
||||||
skin_dir = make_skin(tmp_path, "t04-pyedit")
|
|
||||||
counter = counting_read_manifest(monkeypatch)
|
|
||||||
skin_runtime.discover_skins(tmp_path)
|
|
||||||
count_after_first = counter["count"]
|
|
||||||
|
|
||||||
(skin_dir / "skin.py").write_text("# rewritten\n" +
|
|
||||||
DEFAULT_BODY.format(cls="TestSkin"))
|
|
||||||
bump_mtime(skin_dir / "skin.py")
|
|
||||||
|
|
||||||
skins = skin_runtime.discover_skins(tmp_path)
|
|
||||||
assert counter["count"] == count_after_first # cache still served
|
|
||||||
assert "t04-pyedit" in skins
|
|
||||||
|
|
||||||
def test_force_refresh_rereads_with_unchanged_fingerprint(self, tmp_path,
|
|
||||||
monkeypatch):
|
|
||||||
make_skin(tmp_path, "t05-force")
|
|
||||||
counter = counting_read_manifest(monkeypatch)
|
|
||||||
skin_runtime.discover_skins(tmp_path)
|
|
||||||
count_after_first = counter["count"]
|
|
||||||
skin_runtime.discover_skins(tmp_path, force_refresh=True)
|
|
||||||
assert counter["count"] > count_after_first
|
|
||||||
|
|
||||||
def test_result_mapping_is_copy_but_manifests_shared(self, tmp_path):
|
|
||||||
make_skin(tmp_path, "t06-copy")
|
|
||||||
result = skin_runtime.discover_skins(tmp_path)
|
|
||||||
|
|
||||||
# Mutating the returned mapping does not poison the cache...
|
|
||||||
del result["t06-copy"]
|
|
||||||
again = skin_runtime.discover_skins(tmp_path) # cache hit
|
|
||||||
assert "t06-copy" in again
|
|
||||||
|
|
||||||
# ...but the inner manifest dicts ARE shared with the cache (pin).
|
|
||||||
again["t06-copy"]["name"] = "mutated-inner"
|
|
||||||
third = skin_runtime.discover_skins(tmp_path) # cache hit
|
|
||||||
assert third["t06-copy"]["name"] == "mutated-inner"
|
|
||||||
|
|
||||||
def test_missing_directory_returns_empty_and_caches_nothing(self, tmp_path):
|
|
||||||
missing = tmp_path / "not-yet"
|
|
||||||
assert skin_runtime.discover_skins(missing) == {}
|
|
||||||
assert str(missing) not in skin_runtime._discovery_cache
|
|
||||||
|
|
||||||
# Creating the directory later is picked up without force_refresh.
|
|
||||||
make_skin(missing, "t07-late")
|
|
||||||
skins = skin_runtime.discover_skins(missing)
|
|
||||||
assert "t07-late" in skins
|
|
||||||
|
|
||||||
def test_hidden_underscore_and_plain_file_entries_skipped(self, tmp_path):
|
|
||||||
make_skin(tmp_path, ".hidden-skin")
|
|
||||||
make_skin(tmp_path, "_private-skin")
|
|
||||||
(tmp_path / "stray-file").write_text("not a directory")
|
|
||||||
make_skin(tmp_path, "t08-good")
|
|
||||||
skins = skin_runtime.discover_skins(tmp_path, force_refresh=True)
|
|
||||||
assert set(skins) == {"t08-good"}
|
|
||||||
|
|
||||||
def test_manifest_id_mismatch_keys_by_manifest_id(self, tmp_path):
|
|
||||||
make_skin(tmp_path, "t09-dirname", manifest_id="t09-manifest-id")
|
|
||||||
skins = skin_runtime.discover_skins(tmp_path, force_refresh=True)
|
|
||||||
assert "t09-manifest-id" in skins
|
|
||||||
assert "t09-dirname" not in skins
|
|
||||||
assert skins["t09-manifest-id"]["_skin_dir"].endswith("t09-dirname")
|
|
||||||
|
|
||||||
def test_falsy_required_field_drops_skin(self, tmp_path):
|
|
||||||
make_skin(tmp_path, "t10-empty-class", class_name="")
|
|
||||||
skins = skin_runtime.discover_skins(tmp_path, force_refresh=True)
|
|
||||||
assert skins == {}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# B. Module namespacing (_load_skin_module via load_skin)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
BODY_WITH_HELPERS = (
|
|
||||||
"import helpers\n"
|
|
||||||
"from src.skin_system.skin_base import ScoreboardSkin\n"
|
|
||||||
"class TestSkin(ScoreboardSkin):\n"
|
|
||||||
" pass\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestModuleNamespacing:
|
|
||||||
def test_namespaced_sys_modules_keys(self, tmp_path):
|
|
||||||
make_skin(tmp_path, "t11-ns", body=BODY_WITH_HELPERS,
|
|
||||||
extra_files={"helpers.py": "VALUE = 11\n"})
|
|
||||||
skin = skin_runtime.load_skin("t11-ns", skins_dir=tmp_path)
|
|
||||||
assert skin is not None
|
|
||||||
assert "_skin_t11-ns_skin" in sys.modules
|
|
||||||
assert "_skin_t11-ns_helpers" in sys.modules
|
|
||||||
|
|
||||||
def test_preseeded_bare_name_restored(self, tmp_path, monkeypatch):
|
|
||||||
sentinel = object()
|
|
||||||
monkeypatch.setitem(sys.modules, "helpers", sentinel)
|
|
||||||
make_skin(tmp_path, "t12a-restore", body=BODY_WITH_HELPERS,
|
|
||||||
extra_files={"helpers.py": "VALUE = 'a'\n"})
|
|
||||||
skin = skin_runtime.load_skin("t12a-restore", skins_dir=tmp_path)
|
|
||||||
assert skin is not None
|
|
||||||
assert sys.modules["helpers"] is sentinel
|
|
||||||
|
|
||||||
def test_absent_bare_name_stays_absent(self, tmp_path):
|
|
||||||
saved = sys.modules.pop("helpers", None)
|
|
||||||
try:
|
|
||||||
assert "helpers" not in sys.modules
|
|
||||||
make_skin(tmp_path, "t12b-absent", body=BODY_WITH_HELPERS,
|
|
||||||
extra_files={"helpers.py": "VALUE = 'b'\n"})
|
|
||||||
skin = skin_runtime.load_skin("t12b-absent", skins_dir=tmp_path)
|
|
||||||
assert skin is not None
|
|
||||||
assert "helpers" not in sys.modules
|
|
||||||
finally:
|
|
||||||
if saved is not None:
|
|
||||||
sys.modules["helpers"] = saved
|
|
||||||
|
|
||||||
def test_stdlib_shadowing_sibling_leaves_real_module_intact(self, tmp_path):
|
|
||||||
real_json = sys.modules["json"]
|
|
||||||
make_skin(tmp_path, "t12c-json",
|
|
||||||
extra_files={"json.py": "SKIN_LOCAL = True\n"})
|
|
||||||
skin = skin_runtime.load_skin("t12c-json", skins_dir=tmp_path)
|
|
||||||
assert skin is not None
|
|
||||||
assert sys.modules["json"] is real_json
|
|
||||||
assert not hasattr(sys.modules["json"], "SKIN_LOCAL")
|
|
||||||
assert json.loads('{"ok": 1}') == {"ok": 1} # stdlib still works
|
|
||||||
# The skin's copy lives only under its namespaced alias.
|
|
||||||
assert getattr(sys.modules["_skin_t12c-json_json"], "SKIN_LOCAL") is True
|
|
||||||
|
|
||||||
def test_entry_module_executed_once_across_loads(self, tmp_path,
|
|
||||||
monkeypatch):
|
|
||||||
executions = []
|
|
||||||
monkeypatch.setattr(builtins, "_t13_skin_executions", executions,
|
|
||||||
raising=False)
|
|
||||||
body = (
|
|
||||||
"import builtins\n"
|
|
||||||
"builtins._t13_skin_executions.append(1)\n"
|
|
||||||
"from src.skin_system.skin_base import ScoreboardSkin\n"
|
|
||||||
"class TestSkin(ScoreboardSkin):\n"
|
|
||||||
" pass\n"
|
|
||||||
)
|
|
||||||
make_skin(tmp_path, "t13-cached", body=body)
|
|
||||||
for _ in range(3):
|
|
||||||
skin = skin_runtime.load_skin("t13-cached", skins_dir=tmp_path)
|
|
||||||
assert skin is not None
|
|
||||||
assert len(executions) == 1 # module executed exactly once
|
|
||||||
|
|
||||||
def test_sibling_import_failure_returns_none_and_restores_bare(
|
|
||||||
self, tmp_path, monkeypatch):
|
|
||||||
sentinel = object()
|
|
||||||
monkeypatch.setitem(sys.modules, "helpers", sentinel)
|
|
||||||
make_skin(tmp_path, "t14-sibfail", body=BODY_WITH_HELPERS,
|
|
||||||
extra_files={"helpers.py": "raise RuntimeError('sibling boom')\n"})
|
|
||||||
assert skin_runtime.load_skin("t14-sibfail", skins_dir=tmp_path) is None
|
|
||||||
assert sys.modules["helpers"] is sentinel
|
|
||||||
|
|
||||||
def test_missing_entry_point_file(self, tmp_path):
|
|
||||||
make_skin(tmp_path, "t15-noentry", write_entry=False)
|
|
||||||
assert skin_runtime.load_skin("t15-noentry", skins_dir=tmp_path) is None
|
|
||||||
|
|
||||||
def test_custom_entry_point(self, tmp_path):
|
|
||||||
make_skin(tmp_path, "t16-custom", entry_point="render.py")
|
|
||||||
skin = skin_runtime.load_skin("t16-custom", skins_dir=tmp_path)
|
|
||||||
assert isinstance(skin, ScoreboardSkin)
|
|
||||||
assert "_skin_t16-custom_render" in sys.modules
|
|
||||||
assert "_skin_t16-custom_skin" not in sys.modules
|
|
||||||
|
|
||||||
def test_class_name_pointing_at_unrelated_class(self, tmp_path):
|
|
||||||
body = "class NotASkin:\n pass\n"
|
|
||||||
make_skin(tmp_path, "t17a-wrongclass", body=body,
|
|
||||||
class_name="NotASkin")
|
|
||||||
assert skin_runtime.load_skin("t17a-wrongclass",
|
|
||||||
skins_dir=tmp_path) is None
|
|
||||||
|
|
||||||
def test_class_name_pointing_at_instance(self, tmp_path):
|
|
||||||
body = (
|
|
||||||
"from src.skin_system.skin_base import ScoreboardSkin\n"
|
|
||||||
"class MySkin(ScoreboardSkin):\n"
|
|
||||||
" pass\n"
|
|
||||||
"obj = MySkin({}, {})\n"
|
|
||||||
)
|
|
||||||
make_skin(tmp_path, "t17b-instance", body=body, class_name="obj")
|
|
||||||
assert skin_runtime.load_skin("t17b-instance",
|
|
||||||
skins_dir=tmp_path) is None
|
|
||||||
|
|
||||||
def test_constructor_raising_returns_none(self, tmp_path):
|
|
||||||
body = (
|
|
||||||
"from src.skin_system.skin_base import ScoreboardSkin\n"
|
|
||||||
"class TestSkin(ScoreboardSkin):\n"
|
|
||||||
" def __init__(self, manifest, options):\n"
|
|
||||||
" raise ValueError('ctor boom')\n"
|
|
||||||
)
|
|
||||||
make_skin(tmp_path, "t18-ctor", body=body)
|
|
||||||
assert skin_runtime.load_skin("t18-ctor", skins_dir=tmp_path) is None
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# C. API gate + targeting
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestApiGateAndTargeting:
|
|
||||||
def test_same_major_higher_minor_loads(self, tmp_path):
|
|
||||||
make_skin(tmp_path, "t19-minor", api_version="1.9.0")
|
|
||||||
skin = skin_runtime.load_skin("t19-minor", skins_dir=tmp_path)
|
|
||||||
assert isinstance(skin, ScoreboardSkin)
|
|
||||||
|
|
||||||
def test_malformed_api_version_refused(self, tmp_path):
|
|
||||||
make_skin(tmp_path, "t20-malformed", api_version="abc")
|
|
||||||
assert skin_runtime.load_skin("t20-malformed",
|
|
||||||
skins_dir=tmp_path) is None
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("manifest,sport,sport_key,expected", [
|
|
||||||
# No targets key at all -> matches everything
|
|
||||||
({"id": "x"}, "baseball", "mlb", True),
|
|
||||||
({"id": "x"}, None, None, True),
|
|
||||||
# Empty targets dict -> matches everything
|
|
||||||
({"id": "x", "targets": {}}, "hockey", None, True),
|
|
||||||
# sports family match
|
|
||||||
({"id": "x", "targets": {"sports": ["baseball"]}},
|
|
||||||
"baseball", None, True),
|
|
||||||
# sport_keys exact match
|
|
||||||
({"id": "x", "targets": {"sport_keys": ["milb"]}},
|
|
||||||
None, "milb", True),
|
|
||||||
# OR semantics: sport_keys matches even though sports excludes it
|
|
||||||
({"id": "x", "targets": {"sports": ["hockey"],
|
|
||||||
"sport_keys": ["milb"]}},
|
|
||||||
"baseball", "milb", True),
|
|
||||||
# Neither matches
|
|
||||||
({"id": "x", "targets": {"sports": ["hockey"]}},
|
|
||||||
"baseball", None, False),
|
|
||||||
({"id": "x", "targets": {"sports": ["hockey"],
|
|
||||||
"sport_keys": ["nhl"]}},
|
|
||||||
"baseball", "milb", False),
|
|
||||||
])
|
|
||||||
def test_skin_matches_target(self, manifest, sport, sport_key, expected):
|
|
||||||
assert skin_runtime.skin_matches_target(
|
|
||||||
manifest, sport, sport_key) is expected
|
|
||||||
@@ -1,571 +0,0 @@
|
|||||||
"""Tests for the skin system: discovery, version gating, fallback
|
|
||||||
semantics, module isolation, and the view-model contract."""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from PIL import Image, ImageFont
|
|
||||||
|
|
||||||
# src.base_classes.sports transitively imports the hardware matrix driver;
|
|
||||||
# stub it so the fallback-semantics tests can import SportsCore off-device.
|
|
||||||
sys.modules.setdefault("rgbmatrix", MagicMock())
|
|
||||||
|
|
||||||
from src.skin_system import skin_runtime
|
|
||||||
from src.skin_system.skin_base import (
|
|
||||||
SKIN_API_VERSION,
|
|
||||||
ScoreboardSkin,
|
|
||||||
SkinContext,
|
|
||||||
)
|
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
FIXTURES_DIR = PROJECT_ROOT / "src" / "skin_system" / "fixtures"
|
|
||||||
|
|
||||||
# The v1.0 guaranteed view-model keys (docs/CREATING_SKINS.md). Renaming
|
|
||||||
# or removing any of these is a breaking change to every published skin:
|
|
||||||
# it requires a VIEW_MODEL_VERSION major bump and a compat shim.
|
|
||||||
GUARANTEED_KEYS = [
|
|
||||||
"id", "game_time", "game_date", "start_time_utc", "status_text",
|
|
||||||
"is_live", "is_final", "is_upcoming", "is_halftime",
|
|
||||||
"home_abbr", "home_id", "home_score", "home_logo_path", "home_record",
|
|
||||||
"away_abbr", "away_id", "away_score", "away_logo_path", "away_record",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def write_skin(skins_dir: Path, skin_id: str, *, api_version: str = SKIN_API_VERSION,
|
|
||||||
body: str = None, extra_files: dict = None,
|
|
||||||
class_name: str = "TestSkin") -> Path:
|
|
||||||
skin_dir = skins_dir / skin_id
|
|
||||||
skin_dir.mkdir(parents=True)
|
|
||||||
manifest = {
|
|
||||||
"id": skin_id, "name": skin_id, "version": "1.0.0",
|
|
||||||
"skin_api_version": api_version, "class_name": class_name,
|
|
||||||
"targets": {"sports": ["baseball"]},
|
|
||||||
}
|
|
||||||
(skin_dir / "skin.json").write_text(json.dumps(manifest))
|
|
||||||
if body is None:
|
|
||||||
body = (
|
|
||||||
"from src.skin_system.skin_base import ScoreboardSkin\n"
|
|
||||||
f"class {class_name}(ScoreboardSkin):\n"
|
|
||||||
" def render_live(self, ctx, game):\n"
|
|
||||||
" ctx.draw.rectangle([0, 0, 4, 4], fill=(255, 0, 0))\n"
|
|
||||||
" return True\n"
|
|
||||||
)
|
|
||||||
(skin_dir / "skin.py").write_text(body)
|
|
||||||
for name, content in (extra_files or {}).items():
|
|
||||||
(skin_dir / name).write_text(content)
|
|
||||||
return skin_dir
|
|
||||||
|
|
||||||
|
|
||||||
class TestDiscovery:
|
|
||||||
def test_discovers_valid_skin(self, tmp_path):
|
|
||||||
write_skin(tmp_path, "my-skin")
|
|
||||||
skins = skin_runtime.discover_skins(tmp_path, force_refresh=True)
|
|
||||||
assert "my-skin" in skins
|
|
||||||
assert skins["my-skin"]["_skin_dir"].endswith("my-skin")
|
|
||||||
|
|
||||||
def test_skips_manifest_missing_required_fields(self, tmp_path):
|
|
||||||
skin_dir = tmp_path / "broken"
|
|
||||||
skin_dir.mkdir()
|
|
||||||
(skin_dir / "skin.json").write_text(json.dumps({"id": "broken"}))
|
|
||||||
assert skin_runtime.discover_skins(tmp_path, force_refresh=True) == {}
|
|
||||||
|
|
||||||
def test_skips_unreadable_manifest_and_non_skin_dirs(self, tmp_path):
|
|
||||||
(tmp_path / "not-a-skin").mkdir()
|
|
||||||
bad = tmp_path / "bad-json"
|
|
||||||
bad.mkdir()
|
|
||||||
(bad / "skin.json").write_text("{nope")
|
|
||||||
write_skin(tmp_path, "good-skin")
|
|
||||||
skins = skin_runtime.discover_skins(tmp_path, force_refresh=True)
|
|
||||||
assert list(skins) == ["good-skin"]
|
|
||||||
|
|
||||||
def test_missing_directory_is_empty(self, tmp_path):
|
|
||||||
assert skin_runtime.discover_skins(tmp_path / "nope") == {}
|
|
||||||
|
|
||||||
def test_example_skin_in_repo_is_discoverable(self):
|
|
||||||
skins = skin_runtime.discover_skins(force_refresh=True)
|
|
||||||
assert "example-classic-baseball" in skins
|
|
||||||
|
|
||||||
|
|
||||||
class TestLoadSkin:
|
|
||||||
def test_loads_and_instantiates(self, tmp_path):
|
|
||||||
write_skin(tmp_path, "my-skin")
|
|
||||||
skin = skin_runtime.load_skin("my-skin", sport="baseball",
|
|
||||||
skins_dir=tmp_path)
|
|
||||||
assert isinstance(skin, ScoreboardSkin)
|
|
||||||
|
|
||||||
def test_unknown_skin_returns_none(self, tmp_path):
|
|
||||||
assert skin_runtime.load_skin("ghost", skins_dir=tmp_path) is None
|
|
||||||
|
|
||||||
def test_api_major_mismatch_is_refused(self, tmp_path):
|
|
||||||
write_skin(tmp_path, "old-skin", api_version="99.0.0")
|
|
||||||
assert skin_runtime.load_skin("old-skin", skins_dir=tmp_path) is None
|
|
||||||
|
|
||||||
def test_target_mismatch_still_loads(self, tmp_path):
|
|
||||||
write_skin(tmp_path, "my-skin") # targets baseball
|
|
||||||
skin = skin_runtime.load_skin("my-skin", sport="hockey",
|
|
||||||
skins_dir=tmp_path)
|
|
||||||
assert skin is not None # soft warning, not a hard block
|
|
||||||
|
|
||||||
def test_import_error_returns_none(self, tmp_path):
|
|
||||||
write_skin(tmp_path, "crashy", body="raise RuntimeError('boom')\n")
|
|
||||||
assert skin_runtime.load_skin("crashy", skins_dir=tmp_path) is None
|
|
||||||
|
|
||||||
def test_wrong_class_returns_none(self, tmp_path):
|
|
||||||
write_skin(tmp_path, "classless", body="x = 1\n")
|
|
||||||
assert skin_runtime.load_skin("classless", skins_dir=tmp_path) is None
|
|
||||||
|
|
||||||
def test_options_are_passed_through(self, tmp_path):
|
|
||||||
write_skin(tmp_path, "my-skin")
|
|
||||||
skin = skin_runtime.load_skin("my-skin", skins_dir=tmp_path,
|
|
||||||
options={"accent": [1, 2, 3]})
|
|
||||||
assert skin.options == {"accent": [1, 2, 3]}
|
|
||||||
|
|
||||||
def test_sibling_modules_are_isolated_between_skins(self, tmp_path):
|
|
||||||
helper = "VALUE = {!r}\n"
|
|
||||||
body = (
|
|
||||||
"import helpers\n"
|
|
||||||
"from src.skin_system.skin_base import ScoreboardSkin\n"
|
|
||||||
"class TestSkin(ScoreboardSkin):\n"
|
|
||||||
" def render_live(self, ctx, game):\n"
|
|
||||||
" ctx.logger.info(helpers.VALUE)\n"
|
|
||||||
" self.helper_value = helpers.VALUE\n"
|
|
||||||
" return False\n"
|
|
||||||
)
|
|
||||||
write_skin(tmp_path, "skin-a", body=body,
|
|
||||||
extra_files={"helpers.py": helper.format("A")})
|
|
||||||
write_skin(tmp_path, "skin-b", body=body,
|
|
||||||
extra_files={"helpers.py": helper.format("B")})
|
|
||||||
skin_a = skin_runtime.load_skin("skin-a", skins_dir=tmp_path)
|
|
||||||
skin_b = skin_runtime.load_skin("skin-b", skins_dir=tmp_path)
|
|
||||||
ctx = _make_context()
|
|
||||||
skin_a.render_live(ctx, {})
|
|
||||||
skin_b.render_live(ctx, {})
|
|
||||||
assert skin_a.helper_value == "A"
|
|
||||||
assert skin_b.helper_value == "B"
|
|
||||||
|
|
||||||
def test_same_skin_loads_repeatedly_with_siblings(self, tmp_path):
|
|
||||||
"""The live/recent/upcoming hosts each load the same skin — the
|
|
||||||
2nd and 3rd loads must still resolve sibling modules (regression:
|
|
||||||
cached siblings used to be skipped without rebinding)."""
|
|
||||||
body = (
|
|
||||||
"import reload_helpers\n"
|
|
||||||
"from src.skin_system.skin_base import ScoreboardSkin\n"
|
|
||||||
"class TestSkin(ScoreboardSkin):\n"
|
|
||||||
" def render_live(self, ctx, game):\n"
|
|
||||||
" self.helper_value = reload_helpers.VALUE\n"
|
|
||||||
" return False\n"
|
|
||||||
)
|
|
||||||
write_skin(tmp_path, "reload-skin", body=body,
|
|
||||||
extra_files={"reload_helpers.py": "VALUE = 'R'\n"})
|
|
||||||
ctx = _make_context()
|
|
||||||
for _ in range(3):
|
|
||||||
skin = skin_runtime.load_skin("reload-skin", skins_dir=tmp_path)
|
|
||||||
assert skin is not None
|
|
||||||
skin.render_live(ctx, {})
|
|
||||||
assert skin.helper_value == "R"
|
|
||||||
|
|
||||||
|
|
||||||
def _make_host(fonts=None):
|
|
||||||
host = MagicMock()
|
|
||||||
host.sport = "baseball"
|
|
||||||
host.sport_key = "mlb"
|
|
||||||
host.skin_options = {"accent": True}
|
|
||||||
host.fonts = fonts or {"time": ImageFont.load_default()}
|
|
||||||
host.logger = logging.getLogger("test_skin_system")
|
|
||||||
host.display_manager.width = 128
|
|
||||||
host.display_manager.height = 32
|
|
||||||
return host
|
|
||||||
|
|
||||||
|
|
||||||
def _make_context(width=128, height=32):
|
|
||||||
host = _make_host()
|
|
||||||
return skin_runtime.build_context(host, {}, size=(width, height))
|
|
||||||
|
|
||||||
|
|
||||||
class TestBuildContext:
|
|
||||||
def test_context_shape(self):
|
|
||||||
host = _make_host()
|
|
||||||
game = {"home_abbr": "LAD", "away_abbr": "SF"}
|
|
||||||
ctx = skin_runtime.build_context(host, game)
|
|
||||||
assert (ctx.width, ctx.height) == (128, 32)
|
|
||||||
assert ctx.canvas.size == (128, 32)
|
|
||||||
assert ctx.sport == "baseball"
|
|
||||||
assert ctx.options == {"accent": True}
|
|
||||||
assert ctx.layout.bounds.w == 128
|
|
||||||
|
|
||||||
def test_explicit_size_overrides_display(self):
|
|
||||||
ctx = skin_runtime.build_context(_make_host(), {}, size=(64, 64))
|
|
||||||
assert ctx.canvas.size == (64, 64)
|
|
||||||
|
|
||||||
def test_load_logo_binds_game_and_survives_failure(self):
|
|
||||||
host = _make_host()
|
|
||||||
host._load_and_resize_logo.side_effect = RuntimeError("disk gone")
|
|
||||||
ctx = skin_runtime.build_context(
|
|
||||||
host, {"home_id": "1", "home_abbr": "LAD",
|
|
||||||
"home_logo_path": "x.png", "home_logo_url": None})
|
|
||||||
assert ctx.load_logo("home") is None # exception swallowed
|
|
||||||
assert ctx.load_logo("elsewhere") is None # bad side rejected
|
|
||||||
|
|
||||||
def test_draw_helpers_draw_on_canvas(self):
|
|
||||||
ctx = _make_context()
|
|
||||||
ctx.draw_text("HI", 2, 2, font=ImageFont.load_default())
|
|
||||||
fit = ctx.layout.fit_text("42", ctx.layout.bounds)
|
|
||||||
ctx.draw_fit(fit, ctx.layout.bounds)
|
|
||||||
logo = Image.new("RGBA", (16, 16), (255, 0, 0, 255))
|
|
||||||
ctx.draw_image(logo, ctx.layout.bounds.left_col(20))
|
|
||||||
ctx.draw_image(None, ctx.layout.bounds) # None must no-op
|
|
||||||
assert ctx.canvas.convert("L").getbbox() is not None
|
|
||||||
|
|
||||||
|
|
||||||
class _FallbackProbe:
|
|
||||||
"""Bare-bones SportsCore stand-in that exercises the real _render_game."""
|
|
||||||
|
|
||||||
def __init__(self, skin):
|
|
||||||
from src.base_classes.sports import SportsCore
|
|
||||||
self._cls = SportsCore
|
|
||||||
self.SKIN_MODE = "live"
|
|
||||||
self.logger = logging.getLogger("test_skin_system")
|
|
||||||
self.sport = "baseball"
|
|
||||||
self.sport_key = "mlb"
|
|
||||||
self.skin_options = {}
|
|
||||||
self.fonts = {"time": ImageFont.load_default()}
|
|
||||||
self._skin = skin
|
|
||||||
self._skin_load_attempted = True
|
|
||||||
self._skin_failures = 0
|
|
||||||
self._skin_slow_renders = 0
|
|
||||||
self._skin_config = "test-skin"
|
|
||||||
self.display_manager = MagicMock()
|
|
||||||
self.display_manager.width = 128
|
|
||||||
self.display_manager.height = 32
|
|
||||||
self.display_manager.image = Image.new("RGB", (128, 32))
|
|
||||||
self.builtin_calls = 0
|
|
||||||
|
|
||||||
def _resolve_skin_id(self):
|
|
||||||
return "test-skin"
|
|
||||||
|
|
||||||
def _draw_scorebug_layout(self, game, force_clear=False):
|
|
||||||
self.builtin_calls += 1
|
|
||||||
|
|
||||||
def _render_game(self, game, force_clear=False):
|
|
||||||
from src.base_classes.sports import SportsCore
|
|
||||||
SportsCore._render_game(self, game, force_clear)
|
|
||||||
|
|
||||||
def _get_skin(self):
|
|
||||||
return self._skin
|
|
||||||
|
|
||||||
|
|
||||||
class TestRenderGameFallback:
|
|
||||||
def test_skin_handles_render(self):
|
|
||||||
class GoodSkin(ScoreboardSkin):
|
|
||||||
def render_live(self, ctx, game):
|
|
||||||
ctx.draw.rectangle([0, 0, 10, 10], fill=(0, 255, 0))
|
|
||||||
return True
|
|
||||||
|
|
||||||
probe = _FallbackProbe(GoodSkin({}, {}))
|
|
||||||
probe._render_game({"status_text": "Q1"})
|
|
||||||
assert probe.builtin_calls == 0
|
|
||||||
probe.display_manager.update_display.assert_called_once()
|
|
||||||
assert probe.display_manager.image.convert("L").getbbox() is not None
|
|
||||||
|
|
||||||
def test_skin_declining_falls_back(self):
|
|
||||||
probe = _FallbackProbe(ScoreboardSkin({}, {})) # all renders -> False
|
|
||||||
probe._render_game({"status_text": "Q1"})
|
|
||||||
assert probe.builtin_calls == 1
|
|
||||||
|
|
||||||
def test_no_skin_falls_back(self):
|
|
||||||
probe = _FallbackProbe(None)
|
|
||||||
probe._render_game({"status_text": "Q1"})
|
|
||||||
assert probe.builtin_calls == 1
|
|
||||||
|
|
||||||
def test_three_strikes_disables_skin(self):
|
|
||||||
class BrokenSkin(ScoreboardSkin):
|
|
||||||
calls = 0
|
|
||||||
|
|
||||||
def render_live(self, ctx, game):
|
|
||||||
BrokenSkin.calls += 1
|
|
||||||
raise ValueError("kaboom")
|
|
||||||
|
|
||||||
probe = _FallbackProbe(BrokenSkin({}, {}))
|
|
||||||
for i in range(5):
|
|
||||||
probe._render_game({"status_text": "Q1"})
|
|
||||||
# every render fell back to the built-in layout...
|
|
||||||
assert probe.builtin_calls == 5
|
|
||||||
# ...and the skin stopped being called after the 3rd failure
|
|
||||||
assert BrokenSkin.calls == 3
|
|
||||||
assert probe._skin_failures == 3
|
|
||||||
|
|
||||||
def test_skin_cannot_mutate_callers_game_dict(self):
|
|
||||||
class MutatingSkin(ScoreboardSkin):
|
|
||||||
def render_live(self, ctx, game):
|
|
||||||
game.clear()
|
|
||||||
game["hacked"] = True
|
|
||||||
return True
|
|
||||||
|
|
||||||
probe = _FallbackProbe(MutatingSkin({}, {}))
|
|
||||||
game = {"status_text": "Q1", "home_score": "3"}
|
|
||||||
probe._render_game(game)
|
|
||||||
assert game == {"status_text": "Q1", "home_score": "3"}
|
|
||||||
|
|
||||||
|
|
||||||
class TestSkinModeResolution:
|
|
||||||
def _core(self, skin_config, mode="live"):
|
|
||||||
from src.base_classes.sports import SportsCore
|
|
||||||
probe = _FallbackProbe(None)
|
|
||||||
probe.SKIN_MODE = mode
|
|
||||||
probe._skin_config = skin_config
|
|
||||||
return SportsCore._resolve_skin_id(probe)
|
|
||||||
|
|
||||||
def test_plain_id_applies_to_all_modes(self):
|
|
||||||
assert self._core("retro", "live") == "retro"
|
|
||||||
assert self._core("retro", "recent") == "retro"
|
|
||||||
|
|
||||||
def test_per_mode_mapping(self):
|
|
||||||
cfg = {"live": "retro", "recent": "built-in"}
|
|
||||||
assert self._core(cfg, "live") == "retro"
|
|
||||||
assert self._core(cfg, "recent") is None
|
|
||||||
assert self._core(cfg, "upcoming") is None
|
|
||||||
|
|
||||||
def test_builtin_and_empty_mean_none(self):
|
|
||||||
assert self._core("built-in") is None
|
|
||||||
assert self._core("") is None
|
|
||||||
assert self._core(None) is None
|
|
||||||
|
|
||||||
|
|
||||||
class TestViewModelContract:
|
|
||||||
@pytest.mark.parametrize("sport", ["baseball", "basketball", "football", "hockey"])
|
|
||||||
@pytest.mark.parametrize("mode", ["live", "recent", "upcoming"])
|
|
||||||
def test_fixtures_carry_all_guaranteed_keys(self, sport, mode):
|
|
||||||
with open(FIXTURES_DIR / f"{sport}_{mode}.json") as f:
|
|
||||||
game = json.load(f)
|
|
||||||
missing = [k for k in GUARANTEED_KEYS if k not in game]
|
|
||||||
assert not missing, f"{sport}_{mode} fixture missing {missing}"
|
|
||||||
|
|
||||||
def test_extractor_produces_guaranteed_keys(self):
|
|
||||||
"""The real extractor's output must be a superset of the documented
|
|
||||||
contract — this is the test that catches accidental renames."""
|
|
||||||
import pytz
|
|
||||||
from src.base_classes.sports import SportsCore
|
|
||||||
|
|
||||||
event = {
|
|
||||||
"id": "401570001",
|
|
||||||
"date": "2026-07-16T23:05:00Z",
|
|
||||||
"competitions": [{
|
|
||||||
"status": {"type": {"name": "STATUS_IN_PROGRESS", "state": "in",
|
|
||||||
"shortDetail": "Bot 7th"}},
|
|
||||||
"competitors": [
|
|
||||||
{"homeAway": "home", "id": "19",
|
|
||||||
"team": {"abbreviation": "LAD"}, "score": "5",
|
|
||||||
"records": [{"summary": "58-33"}]},
|
|
||||||
{"homeAway": "away", "id": "26",
|
|
||||||
"team": {"abbreviation": "SF"}, "score": "3",
|
|
||||||
"records": [{"summary": "49-42"}]},
|
|
||||||
],
|
|
||||||
}],
|
|
||||||
}
|
|
||||||
probe = MagicMock()
|
|
||||||
probe.logger = logging.getLogger("test_skin_system")
|
|
||||||
probe.favorite_teams = []
|
|
||||||
probe.config = {}
|
|
||||||
probe.logo_dir = Path("assets/logos")
|
|
||||||
probe._get_timezone.return_value = pytz.utc
|
|
||||||
probe.display_manager.format_date_with_ordinal.return_value = "Jul 16th"
|
|
||||||
|
|
||||||
details, _, _, _, _ = SportsCore._extract_game_details_common(probe, event)
|
|
||||||
assert details is not None
|
|
||||||
missing = [k for k in GUARANTEED_KEYS if k not in details]
|
|
||||||
assert not missing, (
|
|
||||||
f"_extract_game_details_common no longer emits {missing}. "
|
|
||||||
"These keys are part of the frozen skin view-model contract "
|
|
||||||
"(VIEW_MODEL_VERSION) — renaming or removing them breaks every "
|
|
||||||
"published skin. Add a compat shim or bump the major version.")
|
|
||||||
|
|
||||||
|
|
||||||
class TestPluginMatching:
|
|
||||||
def test_matches_by_sport_token_and_sport_key(self, tmp_path):
|
|
||||||
write_skin(tmp_path, "bb-skin") # targets sports=["baseball"]
|
|
||||||
skins = skin_runtime.discover_skins(tmp_path, force_refresh=True)
|
|
||||||
assert "bb-skin" in skin_runtime.skins_for_plugin("baseball-scoreboard", skins)
|
|
||||||
assert "bb-skin" not in skin_runtime.skins_for_plugin("football-scoreboard", skins)
|
|
||||||
|
|
||||||
def test_matches_by_explicit_plugin_list(self, tmp_path):
|
|
||||||
skin_dir = write_skin(tmp_path, "exact-skin")
|
|
||||||
manifest = json.loads((skin_dir / "skin.json").read_text())
|
|
||||||
manifest["targets"] = {"plugins": ["my-custom-plugin"]}
|
|
||||||
(skin_dir / "skin.json").write_text(json.dumps(manifest))
|
|
||||||
skins = skin_runtime.discover_skins(tmp_path, force_refresh=True)
|
|
||||||
assert "exact-skin" in skin_runtime.skins_for_plugin("my-custom-plugin", skins)
|
|
||||||
assert "exact-skin" not in skin_runtime.skins_for_plugin("baseball-scoreboard", skins)
|
|
||||||
|
|
||||||
|
|
||||||
class TestSchemaInjection:
|
|
||||||
def _manager(self):
|
|
||||||
from src.plugin_system.schema_manager import SchemaManager
|
|
||||||
return SchemaManager()
|
|
||||||
|
|
||||||
def test_injects_enum_with_installed_and_configured_skins(self):
|
|
||||||
sm = self._manager()
|
|
||||||
schema = {"type": "object", "properties": {}}
|
|
||||||
out = sm.inject_skin_selector(schema, "baseball-scoreboard",
|
|
||||||
current_value="gone-skin")
|
|
||||||
enum = out["properties"]["skin"]["enum"]
|
|
||||||
assert enum[0] == "built-in"
|
|
||||||
assert "example-classic-baseball" in enum
|
|
||||||
# an uninstalled-but-configured skin must stay selectable so the
|
|
||||||
# saved config never becomes invalid in the UI
|
|
||||||
assert "gone-skin" in enum
|
|
||||||
assert "skin" not in schema["properties"] # source schema untouched
|
|
||||||
|
|
||||||
def test_no_matching_skins_leaves_schema_alone(self):
|
|
||||||
sm = self._manager()
|
|
||||||
schema = {"type": "object", "properties": {}}
|
|
||||||
out = sm.inject_skin_selector(schema, "totally-unrelated-plugin")
|
|
||||||
assert "skin" not in out.get("properties", {})
|
|
||||||
|
|
||||||
def test_validation_accepts_skin_keys_without_enum(self):
|
|
||||||
sm = self._manager()
|
|
||||||
schema = {"type": "object", "properties": {"foo": {"type": "string"}}}
|
|
||||||
ok, errors = sm.validate_config_against_schema(
|
|
||||||
{"skin": "any-id-even-uninstalled", "skin_options": {"x": 1}},
|
|
||||||
schema, "baseball-scoreboard")
|
|
||||||
assert ok, errors
|
|
||||||
ok, errors = sm.validate_config_against_schema(
|
|
||||||
{"skin": {"live": "a", "recent": "built-in"}}, schema, "p")
|
|
||||||
assert ok, errors
|
|
||||||
|
|
||||||
|
|
||||||
class TestExampleSkin:
|
|
||||||
@pytest.mark.parametrize("mode", ["live", "recent", "upcoming"])
|
|
||||||
@pytest.mark.parametrize("size", [(128, 32), (64, 32), (128, 64)])
|
|
||||||
def test_renders_all_modes_and_sizes(self, mode, size):
|
|
||||||
skin = skin_runtime.load_skin("example-classic-baseball", sport="baseball")
|
|
||||||
assert skin is not None
|
|
||||||
host = _make_host()
|
|
||||||
host._load_and_resize_logo.return_value = Image.new("RGBA", (32, 32), (200, 0, 0, 255))
|
|
||||||
with open(FIXTURES_DIR / f"baseball_{mode}.json") as f:
|
|
||||||
game = json.load(f)
|
|
||||||
ctx = skin_runtime.build_context(host, game, size=size)
|
|
||||||
assert getattr(skin, f"render_{mode}")(ctx, game) is True
|
|
||||||
assert ctx.canvas.convert("L").getbbox() is not None
|
|
||||||
|
|
||||||
|
|
||||||
class TestRenderSkinCard:
|
|
||||||
"""render_skin_card (vegas cards) shares _render_game's 3-strike counter.
|
|
||||||
|
|
||||||
Both paths reset the counter on success — transient failures must not
|
|
||||||
accumulate across a session and disable a working skin.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def _probe(self, skin):
|
|
||||||
from src.base_classes.sports import SportsCore
|
|
||||||
probe = _FallbackProbe(skin)
|
|
||||||
probe.render_skin_card = (
|
|
||||||
lambda game, size: SportsCore.render_skin_card(probe, game, size))
|
|
||||||
return probe
|
|
||||||
|
|
||||||
def test_vegas_card_returned_when_skin_provides_one(self):
|
|
||||||
card_img = Image.new("RGB", (96, 32), (0, 0, 255))
|
|
||||||
|
|
||||||
class CardSkin(ScoreboardSkin):
|
|
||||||
def render_vegas_card(self, ctx, game):
|
|
||||||
return card_img
|
|
||||||
|
|
||||||
probe = self._probe(CardSkin({}, {}))
|
|
||||||
assert probe.render_skin_card({}, (96, 32)) is card_img
|
|
||||||
|
|
||||||
def test_vegas_card_none_falls_through_to_mode_renderer(self):
|
|
||||||
class ModeOnlySkin(ScoreboardSkin):
|
|
||||||
def render_live(self, ctx, game):
|
|
||||||
ctx.draw.rectangle([0, 0, 5, 5], fill=(255, 0, 0))
|
|
||||||
return True
|
|
||||||
|
|
||||||
probe = self._probe(ModeOnlySkin({}, {}))
|
|
||||||
card = probe.render_skin_card({}, (96, 32))
|
|
||||||
assert card is not None
|
|
||||||
assert card.size == (96, 32)
|
|
||||||
assert card.convert("L").getbbox() is not None
|
|
||||||
|
|
||||||
def test_skin_declining_returns_none(self):
|
|
||||||
probe = self._probe(ScoreboardSkin({}, {})) # all renders -> False
|
|
||||||
assert probe.render_skin_card({}, (96, 32)) is None
|
|
||||||
assert probe._skin_failures == 0 # declining is not a failure
|
|
||||||
|
|
||||||
def test_no_skin_returns_none(self):
|
|
||||||
probe = self._probe(None)
|
|
||||||
assert probe.render_skin_card({}, (96, 32)) is None
|
|
||||||
|
|
||||||
def test_card_failures_count_toward_shared_disable(self):
|
|
||||||
class BrokenCardSkin(ScoreboardSkin):
|
|
||||||
calls = 0
|
|
||||||
|
|
||||||
def render_vegas_card(self, ctx, game):
|
|
||||||
BrokenCardSkin.calls += 1
|
|
||||||
raise ValueError("kaboom")
|
|
||||||
|
|
||||||
probe = self._probe(BrokenCardSkin({}, {}))
|
|
||||||
for _ in range(5):
|
|
||||||
assert probe.render_skin_card({}, (96, 32)) is None
|
|
||||||
# Skin stopped being consulted after the 3rd failure...
|
|
||||||
assert BrokenCardSkin.calls == 3
|
|
||||||
assert probe._skin_failures == 3
|
|
||||||
# ...and the shared counter also disables _render_game's skin path.
|
|
||||||
probe._render_game({"status_text": "Q1"})
|
|
||||||
assert probe.builtin_calls == 1
|
|
||||||
assert BrokenCardSkin.calls == 3 # not consulted again
|
|
||||||
|
|
||||||
def test_card_success_resets_strikes(self):
|
|
||||||
"""A successful card render clears accumulated strikes (mirroring
|
|
||||||
_render_game) — 2 failures + a success + 1 failure leaves the skin
|
|
||||||
enabled with a single strike, instead of disabling it."""
|
|
||||||
card_img = Image.new("RGB", (96, 32), (0, 0, 255))
|
|
||||||
|
|
||||||
class FlakyCardSkin(ScoreboardSkin):
|
|
||||||
fail = True
|
|
||||||
|
|
||||||
def render_vegas_card(self, ctx, game):
|
|
||||||
if FlakyCardSkin.fail:
|
|
||||||
raise ValueError("kaboom")
|
|
||||||
return card_img
|
|
||||||
|
|
||||||
probe = self._probe(FlakyCardSkin({}, {}))
|
|
||||||
FlakyCardSkin.fail = True
|
|
||||||
probe.render_skin_card({}, (96, 32))
|
|
||||||
probe.render_skin_card({}, (96, 32))
|
|
||||||
assert probe._skin_failures == 2
|
|
||||||
|
|
||||||
FlakyCardSkin.fail = False
|
|
||||||
assert probe.render_skin_card({}, (96, 32)) is card_img
|
|
||||||
assert probe._skin_failures == 0 # success cleared the strikes
|
|
||||||
|
|
||||||
FlakyCardSkin.fail = True
|
|
||||||
probe.render_skin_card({}, (96, 32))
|
|
||||||
assert probe._skin_failures == 1 # counting from the reset state
|
|
||||||
FlakyCardSkin.fail = False
|
|
||||||
assert probe.render_skin_card({}, (96, 32)) is card_img # still enabled
|
|
||||||
|
|
||||||
def test_card_success_via_mode_renderer_also_resets_strikes(self):
|
|
||||||
"""The fallthrough path (render_vegas_card None -> mode renderer
|
|
||||||
True) resets the counter as well."""
|
|
||||||
class ModeOnlySkin(ScoreboardSkin):
|
|
||||||
def render_live(self, ctx, game):
|
|
||||||
ctx.draw.rectangle([0, 0, 5, 5], fill=(255, 0, 0))
|
|
||||||
return True
|
|
||||||
|
|
||||||
probe = self._probe(ModeOnlySkin({}, {}))
|
|
||||||
probe._skin_failures = 2
|
|
||||||
assert probe.render_skin_card({}, (96, 32)) is not None
|
|
||||||
assert probe._skin_failures == 0
|
|
||||||
|
|
||||||
def test_render_game_success_also_resets_strikes(self):
|
|
||||||
"""Same reset contract on the display path, for symmetry."""
|
|
||||||
class GoodSkin(ScoreboardSkin):
|
|
||||||
def render_live(self, ctx, game):
|
|
||||||
return True
|
|
||||||
|
|
||||||
probe = _FallbackProbe(GoodSkin({}, {}))
|
|
||||||
probe._skin_failures = 2
|
|
||||||
probe._render_game({"status_text": "Q1"})
|
|
||||||
assert probe._skin_failures == 0
|
|
||||||
@@ -1,660 +0,0 @@
|
|||||||
"""Characterization tests for src/base_classes/sports.py.
|
|
||||||
|
|
||||||
These tests PIN the current behavior of SportsCore / SportsUpcoming /
|
|
||||||
SportsRecent / SportsLive ahead of the sports-unification merge (features
|
|
||||||
from nine drifted plugin copies are about to be folded in). They assert
|
|
||||||
what the code DOES today, not what it should do — a few pinned behaviors
|
|
||||||
look like bugs and are flagged inline with "PINNED AS-IS".
|
|
||||||
|
|
||||||
Coverage:
|
|
||||||
- `_extract_game_details_common` + the four sport extractors
|
|
||||||
(football/hockey/baseball/basketball) against realistic ESPN scoreboard
|
|
||||||
events (adapted from the ledmatrix-plugins monorepo test fixtures).
|
|
||||||
The output must remain a superset of the frozen skin view-model
|
|
||||||
contract (GUARANTEED_KEYS, imported from test_skin_system).
|
|
||||||
- update() flow for concrete SportsUpcoming/SportsRecent/SportsLive
|
|
||||||
subclasses: population, favorite-team filtering, empty/failed-fetch
|
|
||||||
tolerance. All offline: `_fetch_data` reads a pre-seeded mocked cache
|
|
||||||
and every instance's requests session raises ConnectionError.
|
|
||||||
- Rendering smoke: one `display()` per mode class at 128x32 draws
|
|
||||||
non-zero ink onto a real PIL image.
|
|
||||||
- Guard rails: the skin-system seam methods on SportsCore must survive
|
|
||||||
the merge.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import sys
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import pytz
|
|
||||||
import requests
|
|
||||||
from freezegun import freeze_time
|
|
||||||
from PIL import Image
|
|
||||||
|
|
||||||
# src.base_classes.sports transitively imports the hardware matrix driver;
|
|
||||||
# stub it so these tests can import the sports base classes off-device.
|
|
||||||
sys.modules.setdefault("rgbmatrix", MagicMock())
|
|
||||||
|
|
||||||
from src.base_classes.baseball import Baseball
|
|
||||||
from src.base_classes.basketball import Basketball
|
|
||||||
from src.base_classes.football import Football
|
|
||||||
from src.base_classes.hockey import Hockey, HockeyLive
|
|
||||||
from src.base_classes.sports import (
|
|
||||||
SportsCore,
|
|
||||||
SportsLive,
|
|
||||||
SportsRecent,
|
|
||||||
SportsUpcoming,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Reuse the frozen v1.0 skin view-model contract rather than redeclaring it.
|
|
||||||
from test.test_skin_system import GUARANTEED_KEYS
|
|
||||||
|
|
||||||
SPORT_CLASSES = [Football, Hockey, Baseball, Basketball]
|
|
||||||
SPORT_IDS = ["football", "hockey", "baseball", "basketball"]
|
|
||||||
|
|
||||||
# All update()-flow tests run at this frozen instant so the 21-day
|
|
||||||
# SportsRecent window and time.time() interval gates are deterministic.
|
|
||||||
FROZEN_NOW = "2026-01-20 12:00:00"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# ESPN scoreboard event builders (shape adapted from the monorepo fixtures,
|
|
||||||
# e.g. ledmatrix-plugins/plugins/hockey-scoreboard/test/fixtures/mock.json:
|
|
||||||
# team-shaped competitors with status/score/records).
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _competitor(abbr, team_id, score, home_away, record="30-10-5"):
|
|
||||||
return {
|
|
||||||
"homeAway": home_away,
|
|
||||||
"id": team_id,
|
|
||||||
"score": score,
|
|
||||||
"team": {
|
|
||||||
"id": team_id,
|
|
||||||
"abbreviation": abbr,
|
|
||||||
"name": abbr.title(),
|
|
||||||
"displayName": abbr.title(),
|
|
||||||
"logo": None,
|
|
||||||
},
|
|
||||||
"records": [{"summary": record}],
|
|
||||||
# The hockey extractor reads competitor["statistics"] for shot counts;
|
|
||||||
# it now defaults to an empty list when the key is absent rather than
|
|
||||||
# dropping the whole event (see
|
|
||||||
# test_hockey_event_without_statistics_still_extracts).
|
|
||||||
"statistics": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def make_event(event_id, state, date, home=("TB", "20", "3"),
|
|
||||||
away=("DAL", "9", "2"), period=2, clock="12:45",
|
|
||||||
name=None, short_detail=None, situation=None,
|
|
||||||
home_record="30-10-5", away_record="25-14-6"):
|
|
||||||
"""Build a realistic ESPN scoreboard event in the given state
|
|
||||||
('in' / 'post' / 'pre')."""
|
|
||||||
defaults = {
|
|
||||||
"in": ("STATUS_IN_PROGRESS", f"P{period} {clock}"),
|
|
||||||
"post": ("STATUS_FINAL", "Final"),
|
|
||||||
"pre": ("STATUS_SCHEDULED", "1/15 - 6:30 PM"),
|
|
||||||
}
|
|
||||||
default_name, default_detail = defaults[state]
|
|
||||||
status = {
|
|
||||||
"clock": 0.0,
|
|
||||||
"displayClock": clock,
|
|
||||||
"period": period,
|
|
||||||
"type": {
|
|
||||||
"id": "2",
|
|
||||||
"name": name or default_name,
|
|
||||||
"state": state,
|
|
||||||
"completed": state == "post",
|
|
||||||
"description": short_detail or default_detail,
|
|
||||||
"detail": short_detail or default_detail,
|
|
||||||
"shortDetail": short_detail or default_detail,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
competition = {
|
|
||||||
"id": event_id,
|
|
||||||
"date": date,
|
|
||||||
"status": status,
|
|
||||||
"competitors": [
|
|
||||||
_competitor(home[0], home[1], home[2], "home", home_record),
|
|
||||||
_competitor(away[0], away[1], away[2], "away", away_record),
|
|
||||||
],
|
|
||||||
}
|
|
||||||
if situation is not None:
|
|
||||||
competition["situation"] = situation
|
|
||||||
return {
|
|
||||||
"id": event_id,
|
|
||||||
"date": date,
|
|
||||||
"name": f"{away[0]} at {home[0]}",
|
|
||||||
"shortName": f"{away[0]} @ {home[0]}",
|
|
||||||
"competitions": [competition],
|
|
||||||
# Real ESPN payloads duplicate status at the event top level; the
|
|
||||||
# baseball extractor reads it there for live innings.
|
|
||||||
"status": status,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def make_probe(favorites=None):
|
|
||||||
"""Bare-bones SportsCore stand-in for exercising the real extractors
|
|
||||||
unbound (same pattern as TestViewModelContract in test_skin_system)."""
|
|
||||||
probe = MagicMock()
|
|
||||||
probe.logger = logging.getLogger("test_sports_base_characterization")
|
|
||||||
probe.favorite_teams = list(favorites or [])
|
|
||||||
probe.config = {}
|
|
||||||
probe.logo_dir = Path("assets/logos")
|
|
||||||
probe._get_timezone.return_value = pytz.utc
|
|
||||||
probe.display_manager.format_date_with_ordinal.return_value = "Jan 15th"
|
|
||||||
# The sport extractors call self._extract_game_details_common — route
|
|
||||||
# it to the real implementation instead of a MagicMock.
|
|
||||||
probe._extract_game_details_common = (
|
|
||||||
lambda event: SportsCore._extract_game_details_common(probe, event))
|
|
||||||
return probe
|
|
||||||
|
|
||||||
|
|
||||||
def extract(sport_cls, event, favorites=None):
|
|
||||||
return sport_cls._extract_game_details(make_probe(favorites), event)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 1. _extract_game_details_common contract, per wired sport
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestExtractGameDetailsContract:
|
|
||||||
@pytest.mark.parametrize("sport_cls", SPORT_CLASSES, ids=SPORT_IDS)
|
|
||||||
def test_live_event_guaranteed_keys_and_values(self, sport_cls):
|
|
||||||
event = make_event("401", "in", "2026-01-15T18:30:00Z")
|
|
||||||
details = extract(sport_cls, event)
|
|
||||||
assert details is not None
|
|
||||||
missing = [k for k in GUARANTEED_KEYS if k not in details]
|
|
||||||
assert not missing, (
|
|
||||||
f"{sport_cls.__name__} extractor no longer emits {missing} — "
|
|
||||||
"these keys are the frozen skin view-model contract.")
|
|
||||||
assert details["id"] == "401"
|
|
||||||
assert details["home_abbr"] == "TB"
|
|
||||||
assert details["away_abbr"] == "DAL"
|
|
||||||
assert details["home_id"] == "20"
|
|
||||||
assert details["away_id"] == "9"
|
|
||||||
assert details["home_score"] == "3"
|
|
||||||
assert details["away_score"] == "2"
|
|
||||||
assert details["home_record"] == "30-10-5"
|
|
||||||
assert details["away_record"] == "25-14-6"
|
|
||||||
assert details["is_live"] is True
|
|
||||||
assert details["is_final"] is False
|
|
||||||
assert details["is_upcoming"] is False
|
|
||||||
assert details["status_text"] == "P2 12:45"
|
|
||||||
assert details["start_time_utc"] == datetime(
|
|
||||||
2026, 1, 15, 18, 30, tzinfo=timezone.utc)
|
|
||||||
# Sport-specific formatting of the same event:
|
|
||||||
if sport_cls in (Football, Basketball):
|
|
||||||
assert details["period_text"] == "Q2"
|
|
||||||
assert details["clock"] == "12:45"
|
|
||||||
elif sport_cls is Hockey:
|
|
||||||
assert details["period_text"] == "P2"
|
|
||||||
assert details["clock"] == "12:45"
|
|
||||||
else: # Baseball keys inning/status instead of period_text
|
|
||||||
assert details["inning"] == 2
|
|
||||||
assert details["status_state"] == "in"
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("sport_cls", SPORT_CLASSES, ids=SPORT_IDS)
|
|
||||||
def test_final_event_classification(self, sport_cls):
|
|
||||||
event = make_event("402", "post", "2026-01-14T00:00:00Z",
|
|
||||||
home=("BOS", "1", "4"), away=("TOR", "21", "2"),
|
|
||||||
period=3, clock="0:00")
|
|
||||||
details = extract(sport_cls, event)
|
|
||||||
assert details is not None
|
|
||||||
assert details["is_final"] is True
|
|
||||||
assert details["is_live"] is False
|
|
||||||
assert details["is_upcoming"] is False
|
|
||||||
assert details["home_score"] == "4"
|
|
||||||
assert details["away_score"] == "2"
|
|
||||||
if sport_cls in (Football, Hockey, Basketball):
|
|
||||||
assert details["period_text"] == "Final"
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("sport_cls", SPORT_CLASSES, ids=SPORT_IDS)
|
|
||||||
def test_upcoming_event_classification(self, sport_cls):
|
|
||||||
event = make_event("403", "pre", "2026-01-15T18:30:00Z",
|
|
||||||
home=("NYR", "13", "0"), away=("PIT", "16", "0"),
|
|
||||||
period=0, clock="0:00")
|
|
||||||
details = extract(sport_cls, event)
|
|
||||||
assert details is not None
|
|
||||||
assert details["is_upcoming"] is True
|
|
||||||
assert details["is_live"] is False
|
|
||||||
assert details["is_final"] is False
|
|
||||||
# Local time formatting (probe timezone is UTC): 18:30Z -> 6:30PM,
|
|
||||||
# date rendered through display_manager.format_date_with_ordinal.
|
|
||||||
assert details["game_time"] == "6:30PM"
|
|
||||||
assert details["game_date"] == "Jan 15th"
|
|
||||||
|
|
||||||
def test_halftime_state_flags(self):
|
|
||||||
# is_halftime keys off name STATUS_HALFTIME (or state "halftime")
|
|
||||||
# while state "in" still counts as live.
|
|
||||||
event = make_event("404", "in", "2026-01-15T18:30:00Z",
|
|
||||||
name="STATUS_HALFTIME", short_detail="Halftime")
|
|
||||||
details, *_ = SportsCore._extract_game_details_common(
|
|
||||||
make_probe(), event)
|
|
||||||
assert details["is_live"] is True
|
|
||||||
assert details["is_halftime"] is True
|
|
||||||
|
|
||||||
def test_state_name_conflict_is_both_final_and_upcoming(self):
|
|
||||||
# PINNED AS-IS (looks like a bug): is_upcoming also matches on
|
|
||||||
# status.type.name ('scheduled'/'pre-game'/'status_scheduled'), so
|
|
||||||
# an event with state="post" but name="Scheduled" reports BOTH
|
|
||||||
# is_final and is_upcoming True.
|
|
||||||
event = make_event("405", "post", "2026-01-14T00:00:00Z",
|
|
||||||
name="Scheduled")
|
|
||||||
details, *_ = SportsCore._extract_game_details_common(
|
|
||||||
make_probe(), event)
|
|
||||||
assert details["is_final"] is True
|
|
||||||
assert details["is_upcoming"] is True
|
|
||||||
|
|
||||||
def test_zero_zero_record_blanked(self):
|
|
||||||
event = make_event("406", "pre", "2026-01-15T18:30:00Z",
|
|
||||||
home_record="0-0", away_record="0-0-0")
|
|
||||||
details, *_ = SportsCore._extract_game_details_common(
|
|
||||||
make_probe(), event)
|
|
||||||
assert details["home_record"] == ""
|
|
||||||
assert details["away_record"] == ""
|
|
||||||
|
|
||||||
def test_missing_abbreviation_uses_name_prefix(self):
|
|
||||||
event = make_event("407", "pre", "2026-01-15T18:30:00Z")
|
|
||||||
for comp in event["competitions"][0]["competitors"]:
|
|
||||||
del comp["team"]["abbreviation"]
|
|
||||||
comp["team"]["name"] = "Sharks" if comp["homeAway"] == "home" \
|
|
||||||
else "Penguins"
|
|
||||||
details, *_ = SportsCore._extract_game_details_common(
|
|
||||||
make_probe(), event)
|
|
||||||
assert details["home_abbr"] == "Sha"
|
|
||||||
assert details["away_abbr"] == "Pen"
|
|
||||||
|
|
||||||
def test_empty_or_malformed_event_returns_none_tuple(self):
|
|
||||||
probe = make_probe()
|
|
||||||
assert SportsCore._extract_game_details_common(probe, {}) == \
|
|
||||||
(None, None, None, None, None)
|
|
||||||
assert SportsCore._extract_game_details_common(probe, None) == \
|
|
||||||
(None, None, None, None, None)
|
|
||||||
# Malformed event (no competitions) is swallowed, not raised.
|
|
||||||
assert SportsCore._extract_game_details_common(
|
|
||||||
probe, {"id": "999"}) == (None, None, None, None, None)
|
|
||||||
|
|
||||||
def test_football_live_situation_fields(self):
|
|
||||||
event = make_event(
|
|
||||||
"408", "in", "2026-01-15T18:30:00Z",
|
|
||||||
situation={
|
|
||||||
"shortDownDistanceText": "3rd & 4",
|
|
||||||
"downDistanceText": "3rd & 4 at TB 30",
|
|
||||||
"isRedZone": False,
|
|
||||||
"possession": "20",
|
|
||||||
"homeTimeouts": 2,
|
|
||||||
"awayTimeouts": 3,
|
|
||||||
})
|
|
||||||
details = extract(Football, event)
|
|
||||||
assert details["down_distance_text"] == "3rd & 4"
|
|
||||||
assert details["down_distance_text_long"] == "3rd & 4 at TB 30"
|
|
||||||
assert details["possession"] == "20"
|
|
||||||
assert details["possession_indicator"] == "home" # matches home id
|
|
||||||
assert details["home_timeouts"] == 2
|
|
||||||
assert details["away_timeouts"] == 3
|
|
||||||
|
|
||||||
def test_hockey_live_power_play_and_default_shots(self):
|
|
||||||
event = make_event("409", "in", "2026-01-15T18:30:00Z",
|
|
||||||
situation={"isPowerPlay": True, "penalties": ""})
|
|
||||||
details = extract(Hockey, event)
|
|
||||||
assert details["power_play"] is True
|
|
||||||
# Empty statistics arrays -> save-percentage math yields 0 shots.
|
|
||||||
assert details["home_shots"] == 0
|
|
||||||
assert details["away_shots"] == 0
|
|
||||||
|
|
||||||
def test_hockey_event_without_statistics_still_extracts(self):
|
|
||||||
# FIXED (was pinned as returning None): the hockey extractor used to
|
|
||||||
# iterate competitor["statistics"] unguarded, so a competitor without
|
|
||||||
# the key raised KeyError internally and the WHOLE event was dropped
|
|
||||||
# despite valid scores and status. It now defaults to an empty list,
|
|
||||||
# matching the behaviour already shipped in the hockey plugin, so the
|
|
||||||
# event survives with zeroed shot counts -- the same values
|
|
||||||
# test_hockey_live_power_play_and_default_shots already expects for an
|
|
||||||
# EMPTY statistics array.
|
|
||||||
event = make_event("410", "in", "2026-01-15T18:30:00Z")
|
|
||||||
for comp in event["competitions"][0]["competitors"]:
|
|
||||||
del comp["statistics"]
|
|
||||||
details = extract(Hockey, event)
|
|
||||||
assert details is not None
|
|
||||||
assert details["home_abbr"] == "TB"
|
|
||||||
assert details["away_abbr"] == "DAL"
|
|
||||||
assert details["home_score"] == "3"
|
|
||||||
assert details["home_shots"] == 0
|
|
||||||
assert details["away_shots"] == 0
|
|
||||||
|
|
||||||
def test_baseball_live_inning_and_count(self):
|
|
||||||
event = make_event(
|
|
||||||
"411", "in", "2026-07-16T23:05:00Z",
|
|
||||||
home=("LAD", "19", "5"), away=("SF", "26", "3"),
|
|
||||||
period=7, short_detail="Bot 7th",
|
|
||||||
situation={
|
|
||||||
"count": {"balls": 2, "strikes": 1},
|
|
||||||
"outs": 2,
|
|
||||||
"onFirst": True,
|
|
||||||
"onSecond": False,
|
|
||||||
"onThird": True,
|
|
||||||
})
|
|
||||||
details = extract(Baseball, event)
|
|
||||||
assert details["inning"] == 7 # from top-level status period
|
|
||||||
assert details["inning_half"] == "bottom"
|
|
||||||
assert details["balls"] == 2
|
|
||||||
assert details["strikes"] == 1
|
|
||||||
assert details["outs"] == 2
|
|
||||||
assert details["bases_occupied"] == [True, False, True]
|
|
||||||
assert details["status"] == "status_in_progress"
|
|
||||||
assert details["series_summary"] == ""
|
|
||||||
|
|
||||||
def test_baseball_live_without_top_level_status_still_extracts(self):
|
|
||||||
# FIXED (was pinned as returning None): the baseball extractor read
|
|
||||||
# game_event["status"] -- the event TOP-LEVEL status -- for the
|
|
||||||
# inning, so an otherwise-valid live event lacking that duplicate key
|
|
||||||
# was dropped entirely. Real ESPN events carry status in both places,
|
|
||||||
# but MiLB events (synthesized from the MLB Stats API into an
|
|
||||||
# ESPN-like shape) populate only the competition-level one. It now
|
|
||||||
# reads the competition-level `status` that
|
|
||||||
# _extract_game_details_common has already validated, so it can never
|
|
||||||
# be missing at that point.
|
|
||||||
event = make_event("412", "in", "2026-07-16T23:05:00Z", period=7)
|
|
||||||
del event["status"]
|
|
||||||
details = extract(Baseball, event)
|
|
||||||
assert details is not None
|
|
||||||
assert details["inning"] == 7
|
|
||||||
|
|
||||||
def test_baseball_live_without_top_level_status_extracts_for_favorites(self):
|
|
||||||
# The favourite-team branch logs the status payload for diagnostics and
|
|
||||||
# read the same event top-level key the test above proves can be
|
|
||||||
# absent. So the identical MiLB event that extracts fine for a
|
|
||||||
# non-favourite raised KeyError and was dropped once the team WAS a
|
|
||||||
# favourite -- the worst shape for the bug, since it only hit the games
|
|
||||||
# the user cared most about, and only on the diagnostic path that was
|
|
||||||
# supposed to help debug them.
|
|
||||||
event = make_event("413", "in", "2026-07-16T23:05:00Z", period=7)
|
|
||||||
del event["status"]
|
|
||||||
details = extract(Baseball, event, favorites=["TB"])
|
|
||||||
assert details is not None
|
|
||||||
assert details["inning"] == 7
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 2. update() flow on concrete subclasses (offline, cache-fed)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class _UpcomingHarness(Hockey, SportsUpcoming):
|
|
||||||
"""Cheapest concrete SportsUpcoming: hockey extractor + cache-fed data."""
|
|
||||||
|
|
||||||
def _fetch_data(self):
|
|
||||||
return self.cache_manager.get(f"{self.sport_key}_schedule")
|
|
||||||
|
|
||||||
|
|
||||||
class _RecentHarness(Hockey, SportsRecent):
|
|
||||||
def _fetch_data(self):
|
|
||||||
return self.cache_manager.get(f"{self.sport_key}_schedule")
|
|
||||||
|
|
||||||
|
|
||||||
class _LiveHarness(HockeyLive):
|
|
||||||
def _fetch_data(self):
|
|
||||||
return self.cache_manager.get(f"{self.sport_key}_schedule")
|
|
||||||
|
|
||||||
|
|
||||||
def make_schedule():
|
|
||||||
"""A mixed schedule around the frozen 'now' of 2026-01-20."""
|
|
||||||
return {"events": [
|
|
||||||
# Final 6 days ago — inside the recent 21-day window.
|
|
||||||
make_event("9001", "post", "2026-01-14T00:00:00Z",
|
|
||||||
home=("BOS", "1", "4"), away=("TOR", "21", "2"),
|
|
||||||
period=3, clock="0:00"),
|
|
||||||
# Live game.
|
|
||||||
make_event("9002", "in", "2026-01-15T00:30:00Z",
|
|
||||||
home=("TB", "20", "3"), away=("DAL", "9", "2")),
|
|
||||||
# Two scheduled games.
|
|
||||||
make_event("9003", "pre", "2026-01-16T00:00:00Z",
|
|
||||||
home=("NYR", "13", "0"), away=("PIT", "16", "0"),
|
|
||||||
period=0),
|
|
||||||
make_event("9004", "pre", "2026-01-17T00:00:00Z",
|
|
||||||
home=("BOS", "1", "0"), away=("MTL", "10", "0"),
|
|
||||||
period=0),
|
|
||||||
# Final from November — outside the recent 21-day window.
|
|
||||||
make_event("9005", "post", "2025-11-01T00:00:00Z",
|
|
||||||
home=("SEA", "124292", "1"), away=("VAN", "22", "5"),
|
|
||||||
period=3, clock="0:00"),
|
|
||||||
]}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def build_manager(monkeypatch, tmp_path):
|
|
||||||
"""Factory for concrete sports managers: mocked display/cache managers,
|
|
||||||
logo dir redirected to tmp, background service stubbed, and the
|
|
||||||
requests session rigged to prove nothing hits the network."""
|
|
||||||
monkeypatch.setattr(
|
|
||||||
SportsCore, "_initialize_logo_dir", lambda self, configured: tmp_path)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"src.base_classes.sports.core.get_background_service",
|
|
||||||
lambda *args, **kwargs: MagicMock())
|
|
||||||
|
|
||||||
# Rig requests.Session BEFORE any manager is built. Construction creates
|
|
||||||
# both SportsCore.session and the ESPNDataSource.session; replacing only
|
|
||||||
# manager.session after the fact (below) leaves data_source.session real,
|
|
||||||
# so an accidental fetch during or right after construction could reach
|
|
||||||
# the network. Patching the class makes every session created here raise.
|
|
||||||
def _offline_get(*args, **kwargs):
|
|
||||||
raise requests.exceptions.ConnectionError(
|
|
||||||
"characterization tests are offline")
|
|
||||||
|
|
||||||
monkeypatch.setattr(requests.Session, "get", _offline_get)
|
|
||||||
|
|
||||||
def build(cls, schedule, **mode_cfg):
|
|
||||||
config = {
|
|
||||||
"timezone": "UTC",
|
|
||||||
"display": {},
|
|
||||||
"nhl_scoreboard": {"enabled": True, **mode_cfg},
|
|
||||||
}
|
|
||||||
display_manager = MagicMock()
|
|
||||||
display_manager.matrix.width = 128
|
|
||||||
display_manager.matrix.height = 32
|
|
||||||
display_manager.width = 128
|
|
||||||
display_manager.height = 32
|
|
||||||
display_manager.image = Image.new("RGB", (128, 32))
|
|
||||||
display_manager.format_date_with_ordinal.side_effect = (
|
|
||||||
lambda dt: dt.strftime("%b %d"))
|
|
||||||
cache_manager = MagicMock()
|
|
||||||
cache_manager.get.return_value = schedule
|
|
||||||
cache_manager.cache_dir = str(tmp_path)
|
|
||||||
manager = cls(config, display_manager, cache_manager,
|
|
||||||
logging.getLogger("test_sports_base_characterization"),
|
|
||||||
"nhl")
|
|
||||||
# Safety net: any accidental network fetch must fail loudly.
|
|
||||||
manager.session = MagicMock()
|
|
||||||
manager.session.get.side_effect = requests.exceptions.ConnectionError(
|
|
||||||
"characterization tests are offline")
|
|
||||||
return manager
|
|
||||||
|
|
||||||
return build
|
|
||||||
|
|
||||||
|
|
||||||
def _ids(games):
|
|
||||||
return [g["id"] for g in games]
|
|
||||||
|
|
||||||
|
|
||||||
@freeze_time(FROZEN_NOW)
|
|
||||||
class TestUpcomingUpdateFlow:
|
|
||||||
def test_populates_games_list_sorted_by_start_time(self, build_manager):
|
|
||||||
manager = build_manager(_UpcomingHarness, make_schedule())
|
|
||||||
manager.update()
|
|
||||||
# PINNED AS-IS: SportsUpcoming filters purely on is_upcoming
|
|
||||||
# (state 'pre') — there is NO date filter, so 'pre' games whose
|
|
||||||
# start time is already in the past (9003/9004 vs frozen 1/20)
|
|
||||||
# are still shown.
|
|
||||||
assert _ids(manager.games_list) == ["9003", "9004"]
|
|
||||||
assert manager.current_game["id"] == "9003"
|
|
||||||
|
|
||||||
def test_filters_by_favorite_teams(self, build_manager):
|
|
||||||
manager = build_manager(_UpcomingHarness, make_schedule(),
|
|
||||||
show_favorite_teams_only=True,
|
|
||||||
favorite_teams=["BOS"])
|
|
||||||
manager.update()
|
|
||||||
assert _ids(manager.games_list) == ["9004"]
|
|
||||||
assert manager.current_game["id"] == "9004"
|
|
||||||
|
|
||||||
def test_favorites_only_with_no_favorites_shows_nothing(
|
|
||||||
self, build_manager):
|
|
||||||
# PINNED AS-IS: show_favorite_teams_only=True with an empty
|
|
||||||
# favorite_teams list drops every game rather than falling back
|
|
||||||
# to showing all games.
|
|
||||||
manager = build_manager(_UpcomingHarness, make_schedule(),
|
|
||||||
show_favorite_teams_only=True,
|
|
||||||
favorite_teams=[])
|
|
||||||
manager.update()
|
|
||||||
assert manager.games_list == []
|
|
||||||
assert manager.current_game is None
|
|
||||||
|
|
||||||
def test_caps_at_upcoming_games_to_show(self, build_manager):
|
|
||||||
manager = build_manager(_UpcomingHarness, make_schedule(),
|
|
||||||
upcoming_games_to_show=1)
|
|
||||||
manager.update()
|
|
||||||
assert _ids(manager.games_list) == ["9003"]
|
|
||||||
|
|
||||||
def test_tolerates_empty_events_list(self, build_manager):
|
|
||||||
manager = build_manager(_UpcomingHarness, {"events": []})
|
|
||||||
manager.update() # must not raise
|
|
||||||
assert manager.games_list == []
|
|
||||||
assert manager.current_game is None
|
|
||||||
|
|
||||||
def test_tolerates_fetch_returning_none(self, build_manager):
|
|
||||||
manager = build_manager(_UpcomingHarness, None)
|
|
||||||
manager.update() # must not raise
|
|
||||||
assert manager.games_list == []
|
|
||||||
assert manager.current_game is None
|
|
||||||
|
|
||||||
def test_disabled_manager_update_is_noop(self, build_manager):
|
|
||||||
manager = build_manager(_UpcomingHarness, make_schedule(),
|
|
||||||
enabled=False)
|
|
||||||
manager.update()
|
|
||||||
assert manager.games_list == []
|
|
||||||
manager.cache_manager.get.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
@freeze_time(FROZEN_NOW)
|
|
||||||
class TestRecentUpdateFlow:
|
|
||||||
def test_populates_only_finals_within_21_day_window(self, build_manager):
|
|
||||||
manager = build_manager(_RecentHarness, make_schedule())
|
|
||||||
manager.update()
|
|
||||||
# 9001 (final, 6 days old) kept; 9005 (final, ~80 days old)
|
|
||||||
# excluded by the 21-day cutoff; live/pre games excluded.
|
|
||||||
assert _ids(manager.games_list) == ["9001"]
|
|
||||||
assert manager.current_game["id"] == "9001"
|
|
||||||
assert manager.current_game["is_final"] is True
|
|
||||||
|
|
||||||
def test_filters_by_favorite_teams(self, build_manager):
|
|
||||||
manager = build_manager(_RecentHarness, make_schedule(),
|
|
||||||
show_favorite_teams_only=True,
|
|
||||||
favorite_teams=["TOR"])
|
|
||||||
manager.update()
|
|
||||||
assert _ids(manager.games_list) == ["9001"]
|
|
||||||
|
|
||||||
stranger = build_manager(_RecentHarness, make_schedule(),
|
|
||||||
show_favorite_teams_only=True,
|
|
||||||
favorite_teams=["XXX"])
|
|
||||||
stranger.update()
|
|
||||||
assert stranger.games_list == []
|
|
||||||
assert stranger.current_game is None
|
|
||||||
|
|
||||||
def test_tolerates_empty_events_list(self, build_manager):
|
|
||||||
manager = build_manager(_RecentHarness, {"events": []})
|
|
||||||
manager.update() # must not raise
|
|
||||||
assert manager.games_list == []
|
|
||||||
assert manager.current_game is None
|
|
||||||
|
|
||||||
|
|
||||||
@freeze_time(FROZEN_NOW)
|
|
||||||
class TestLiveUpdateFlow:
|
|
||||||
def test_selects_only_live_games(self, build_manager):
|
|
||||||
manager = build_manager(_LiveHarness, make_schedule())
|
|
||||||
manager.update()
|
|
||||||
assert _ids(manager.live_games) == ["9002"]
|
|
||||||
assert manager.current_game["id"] == "9002"
|
|
||||||
assert manager.current_game["is_live"] is True
|
|
||||||
|
|
||||||
def test_no_live_games_clears_current_game(self, build_manager):
|
|
||||||
schedule = {"events": [
|
|
||||||
make_event("9001", "post", "2026-01-14T00:00:00Z"),
|
|
||||||
make_event("9003", "pre", "2026-01-16T00:00:00Z", period=0),
|
|
||||||
]}
|
|
||||||
manager = build_manager(_LiveHarness, schedule)
|
|
||||||
manager.update()
|
|
||||||
assert manager.live_games == []
|
|
||||||
assert manager.current_game is None
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 3. Rendering smoke — one display() per mode class at 128x32
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _fake_logo(*args, **kwargs):
|
|
||||||
return Image.new("RGBA", (24, 24), (180, 30, 30, 255))
|
|
||||||
|
|
||||||
|
|
||||||
@freeze_time(FROZEN_NOW)
|
|
||||||
class TestRenderingSmoke:
|
|
||||||
def _assert_rendered(self, manager):
|
|
||||||
manager.display_manager.update_display.assert_called()
|
|
||||||
assert manager.display_manager.image.convert("L").getbbox() is not None
|
|
||||||
|
|
||||||
def test_upcoming_display_draws_ink(self, build_manager):
|
|
||||||
manager = build_manager(_UpcomingHarness, make_schedule())
|
|
||||||
manager.update()
|
|
||||||
manager._load_and_resize_logo = _fake_logo
|
|
||||||
assert manager.display(force_clear=True) is True
|
|
||||||
self._assert_rendered(manager)
|
|
||||||
|
|
||||||
def test_recent_display_draws_ink(self, build_manager):
|
|
||||||
manager = build_manager(_RecentHarness, make_schedule())
|
|
||||||
manager.update()
|
|
||||||
manager._load_and_resize_logo = _fake_logo
|
|
||||||
assert manager.display(force_clear=True) is True
|
|
||||||
self._assert_rendered(manager)
|
|
||||||
|
|
||||||
def test_live_display_draws_ink(self, build_manager):
|
|
||||||
manager = build_manager(_LiveHarness, make_schedule())
|
|
||||||
manager.update()
|
|
||||||
manager._load_and_resize_logo = _fake_logo
|
|
||||||
assert manager.display(force_clear=True) is True
|
|
||||||
self._assert_rendered(manager)
|
|
||||||
|
|
||||||
def test_draw_scorebug_layout_direct_call_does_not_raise(
|
|
||||||
self, build_manager):
|
|
||||||
# The base-class placeholder renderer must also stay callable.
|
|
||||||
manager = build_manager(_UpcomingHarness, make_schedule())
|
|
||||||
game = manager._extract_game_details(make_schedule()["events"][2])
|
|
||||||
manager._load_and_resize_logo = _fake_logo
|
|
||||||
SportsCore._draw_scorebug_layout(manager, game)
|
|
||||||
assert manager.display_manager.image.convert("L").getbbox() is not None
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 4. Guard rails — seams the merge must not silently drop
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestGuardRails:
|
|
||||||
def test_skin_seam_methods_survive(self):
|
|
||||||
for name in ("_resolve_skin_id", "_get_skin", "_render_game",
|
|
||||||
"render_skin_card"):
|
|
||||||
assert callable(getattr(SportsCore, name, None)), (
|
|
||||||
f"SportsCore.{name} is part of the skin-system seam "
|
|
||||||
"(src/skin_system) — the sports-unification merge must "
|
|
||||||
"keep it.")
|
|
||||||
|
|
||||||
def test_skin_mode_per_class(self):
|
|
||||||
assert SportsCore.SKIN_MODE == "live"
|
|
||||||
assert SportsUpcoming.SKIN_MODE == "upcoming"
|
|
||||||
assert SportsRecent.SKIN_MODE == "recent"
|
|
||||||
assert SportsLive.SKIN_MODE == "live" # inherits the default
|
|
||||||
|
|
||||||
def test_core_display_and_extractor_seams_survive(self):
|
|
||||||
for name in ("display", "_draw_scorebug_layout",
|
|
||||||
"_extract_game_details_common", "update"):
|
|
||||||
owner = SportsCore if name != "update" else SportsUpcoming
|
|
||||||
assert callable(getattr(owner, name, None)), name
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,613 +0,0 @@
|
|||||||
"""Tests for the methods promoted onto SportsCore from the nine bundled
|
|
||||||
plugin copies of ``sports.py`` (phase B1 of docs/SPORTS_UNIFICATION.md).
|
|
||||||
|
|
||||||
Three methods and their seams land here:
|
|
||||||
|
|
||||||
- ``cleanup()`` — byte-identical in all nine copies. The tests pin the
|
|
||||||
ordering (session close, then caches, then the completion log) and the
|
|
||||||
deliberate *omission*: the process-wide background service must never be
|
|
||||||
shut down by one unloading plugin.
|
|
||||||
- ``_get_layout_offset()`` — football's resolver-backed variant, with the
|
|
||||||
classic inline config read as the fallback used by every plugin that
|
|
||||||
doesn't hand core a ``_config_schema_path()``.
|
|
||||||
- ``_load_custom_font_from_element_config()`` — baseball's body (the only
|
|
||||||
copy that handles BDF strikes correctly) under hockey's wider signature,
|
|
||||||
resolving font files through the ``_font_root()`` seam instead of the
|
|
||||||
process cwd.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import ast
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from PIL import Image, ImageFont
|
|
||||||
|
|
||||||
# src.base_classes.sports transitively imports the hardware matrix driver;
|
|
||||||
# stub it so these tests can import the sports base classes off-device.
|
|
||||||
sys.modules.setdefault("rgbmatrix", MagicMock())
|
|
||||||
|
|
||||||
from src.base_classes.sports import SportsCore
|
|
||||||
|
|
||||||
LOGGER = logging.getLogger("test_sports_core_promotions")
|
|
||||||
|
|
||||||
CORE_ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
FONTS_DIR = CORE_ROOT / "assets" / "fonts"
|
|
||||||
TTF_NAME = "PressStart2P-Regular.ttf"
|
|
||||||
BDF_NAME = "5x7.bdf" # a BDF whose only valid strike is 7px
|
|
||||||
BDF_NATIVE_SIZE = 7
|
|
||||||
|
|
||||||
|
|
||||||
class _StubSports(SportsCore):
|
|
||||||
"""Minimal concrete SportsCore — the abstract methods are never called
|
|
||||||
by anything under test here."""
|
|
||||||
|
|
||||||
def _fetch_data(self):
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _extract_game_details(self, game_event):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def build(monkeypatch, tmp_path):
|
|
||||||
"""Factory for real SportsCore instances: logo dir redirected to tmp and
|
|
||||||
the process-wide background service replaced with a MagicMock so the
|
|
||||||
tests can assert nothing ever calls it."""
|
|
||||||
monkeypatch.setattr(
|
|
||||||
SportsCore, "_initialize_logo_dir", lambda self, configured: tmp_path)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"src.base_classes.sports.core.get_background_service",
|
|
||||||
lambda *args, **kwargs: MagicMock())
|
|
||||||
|
|
||||||
def _build(config=None, cls=_StubSports):
|
|
||||||
display_manager = MagicMock()
|
|
||||||
display_manager.matrix.width = 128
|
|
||||||
display_manager.matrix.height = 32
|
|
||||||
display_manager.width = 128
|
|
||||||
display_manager.height = 32
|
|
||||||
display_manager.image = Image.new("RGB", (128, 32))
|
|
||||||
cache_manager = MagicMock()
|
|
||||||
cache_manager.cache_dir = str(tmp_path)
|
|
||||||
return cls(config if config is not None else {"timezone": "UTC"},
|
|
||||||
display_manager, cache_manager, LOGGER, "nhl")
|
|
||||||
|
|
||||||
return _build
|
|
||||||
|
|
||||||
|
|
||||||
def probe(config=None):
|
|
||||||
"""Unbound-call stand-in for hosts we don't need a full instance for
|
|
||||||
(same pattern as make_probe in test_sports_base_characterization)."""
|
|
||||||
host = MagicMock()
|
|
||||||
host.logger = LOGGER
|
|
||||||
host.config = config if config is not None else {}
|
|
||||||
host._font_cache = {}
|
|
||||||
host._bdf_native_size_cache = {}
|
|
||||||
host._config_schema_path.return_value = None
|
|
||||||
host._font_root.side_effect = lambda: SportsCore._font_root(host)
|
|
||||||
host._resolve_font_path.side_effect = (
|
|
||||||
lambda name: SportsCore._resolve_font_path(host, name))
|
|
||||||
return host
|
|
||||||
|
|
||||||
|
|
||||||
def offset(host, element, axis, default=0):
|
|
||||||
return SportsCore._get_layout_offset(host, element, axis, default)
|
|
||||||
|
|
||||||
|
|
||||||
def load_font(host, *args, **kwargs):
|
|
||||||
return SportsCore._load_custom_font_from_element_config(host, *args, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 1. cleanup()
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestCleanup:
|
|
||||||
def test_closes_session_and_clears_all_caches(self, build):
|
|
||||||
manager = build()
|
|
||||||
session = MagicMock()
|
|
||||||
manager.session = session
|
|
||||||
manager._logo_cache["TB"] = object()
|
|
||||||
manager._font_cache[("PressStart2P-Regular.ttf", 8)] = object()
|
|
||||||
manager._bdf_native_size_cache["assets/fonts/5x7.bdf"] = 7
|
|
||||||
|
|
||||||
manager.cleanup()
|
|
||||||
|
|
||||||
session.close.assert_called_once_with()
|
|
||||||
assert manager._logo_cache == {}
|
|
||||||
# Promoted alongside the font loader: these hold PIL faces and are
|
|
||||||
# an unbounded leak across enable/disable cycles if never released.
|
|
||||||
assert manager._font_cache == {}
|
|
||||||
assert manager._bdf_native_size_cache == {}
|
|
||||||
|
|
||||||
def test_second_cleanup_is_a_noop(self, build):
|
|
||||||
manager = build()
|
|
||||||
manager.session = MagicMock()
|
|
||||||
manager._logo_cache["TB"] = object()
|
|
||||||
manager._font_cache[("x", 8)] = object()
|
|
||||||
|
|
||||||
manager.cleanup()
|
|
||||||
manager.cleanup() # must not raise on already-released state
|
|
||||||
|
|
||||||
assert manager._logo_cache == {}
|
|
||||||
assert manager._font_cache == {}
|
|
||||||
assert manager._bdf_native_size_cache == {}
|
|
||||||
assert manager.session.close.call_count == 2
|
|
||||||
|
|
||||||
def test_does_not_shut_down_the_shared_background_service(self, build):
|
|
||||||
# get_background_service() hands out a PROCESS-WIDE singleton shared
|
|
||||||
# by every scoreboard. One plugin unloading must not stop background
|
|
||||||
# fetching for the other eight — cleanup() touches it not at all.
|
|
||||||
manager = build()
|
|
||||||
service = manager.background_service
|
|
||||||
manager.session = MagicMock()
|
|
||||||
|
|
||||||
manager.cleanup()
|
|
||||||
|
|
||||||
assert service.shutdown.called is False
|
|
||||||
assert service.stop.called is False
|
|
||||||
assert service.method_calls == [], (
|
|
||||||
"cleanup() called into the shared background service: "
|
|
||||||
f"{service.method_calls}")
|
|
||||||
|
|
||||||
def test_completion_is_logged_even_when_session_close_raises(self, build, caplog):
|
|
||||||
manager = build()
|
|
||||||
manager.session = MagicMock()
|
|
||||||
manager.session.close.side_effect = RuntimeError("socket already gone")
|
|
||||||
manager._logo_cache["TB"] = object()
|
|
||||||
|
|
||||||
with caplog.at_level(logging.DEBUG, logger=LOGGER.name):
|
|
||||||
manager.cleanup()
|
|
||||||
|
|
||||||
messages = [r.message for r in caplog.records]
|
|
||||||
assert any("Error closing session" in m for m in messages)
|
|
||||||
# Ordering is load-bearing: the caches still get cleared and the
|
|
||||||
# completion log still fires after a failed close.
|
|
||||||
assert manager._logo_cache == {}
|
|
||||||
assert any("cleanup completed" in m for m in messages)
|
|
||||||
|
|
||||||
def test_tolerates_missing_attributes(self):
|
|
||||||
# The hasattr guards exist so a partially constructed instance (an
|
|
||||||
# __init__ that raised) can still be cleaned up.
|
|
||||||
host = MagicMock(spec=["logger"])
|
|
||||||
host.logger = LOGGER
|
|
||||||
SportsCore.cleanup(host)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 2. _get_layout_offset() + the _config_schema_path() seam
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def layout_config(element, axis, value):
|
|
||||||
return {"customization": {"layout": {element: {axis: value}}}}
|
|
||||||
|
|
||||||
|
|
||||||
class TestLayoutOffsetClassicPath:
|
|
||||||
"""The default path: _config_schema_path() returns None, so offsets come
|
|
||||||
from the inline customization.layout read every plugin ships today."""
|
|
||||||
|
|
||||||
def test_config_schema_path_defaults_to_none(self, build):
|
|
||||||
manager = build()
|
|
||||||
assert manager._config_schema_path() is None
|
|
||||||
|
|
||||||
def test_reads_configured_int(self):
|
|
||||||
host = probe(layout_config("home_logo", "x_offset", 5))
|
|
||||||
assert offset(host, "home_logo", "x_offset") == 5
|
|
||||||
|
|
||||||
def test_float_is_truncated_to_int(self):
|
|
||||||
host = probe(layout_config("score", "y_offset", 2.9))
|
|
||||||
result = offset(host, "score", "y_offset")
|
|
||||||
assert result == 2 and isinstance(result, int)
|
|
||||||
|
|
||||||
def test_numeric_string_is_coerced(self):
|
|
||||||
host = probe(layout_config("score", "x_offset", "-3.5"))
|
|
||||||
assert offset(host, "score", "x_offset") == -3
|
|
||||||
|
|
||||||
def test_unconfigured_element_and_axis_use_default(self):
|
|
||||||
host = probe(layout_config("score", "x_offset", 5))
|
|
||||||
assert offset(host, "status_text", "x_offset", 7) == 7
|
|
||||||
assert offset(host, "score", "y_offset", -1) == -1
|
|
||||||
assert offset(probe(), "score", "x_offset", 4) == 4
|
|
||||||
|
|
||||||
def test_non_numeric_string_degrades_to_default(self):
|
|
||||||
host = probe(layout_config("score", "x_offset", "left"))
|
|
||||||
assert offset(host, "score", "x_offset", 3) == 3
|
|
||||||
|
|
||||||
def test_unsupported_type_degrades_to_default(self):
|
|
||||||
host = probe(layout_config("score", "x_offset", {"nested": 1}))
|
|
||||||
assert offset(host, "score", "x_offset", 2) == 2
|
|
||||||
host = probe(layout_config("score", "x_offset", None))
|
|
||||||
assert offset(host, "score", "x_offset", 2) == 2
|
|
||||||
|
|
||||||
def test_broken_config_object_degrades_to_default(self):
|
|
||||||
host = probe()
|
|
||||||
host.config = "not a dict"
|
|
||||||
assert offset(host, "score", "x_offset", 6) == 6
|
|
||||||
|
|
||||||
def test_boolean_counts_as_one(self):
|
|
||||||
# PINNED AS-IS: the classic read predates the resolver and treats a
|
|
||||||
# bool as its int value (True -> 1). See the resolver test below for
|
|
||||||
# the stricter, more correct handling.
|
|
||||||
host = probe(layout_config("score", "x_offset", True))
|
|
||||||
assert offset(host, "score", "x_offset", 4) == 1
|
|
||||||
|
|
||||||
|
|
||||||
class TestLayoutOffsetResolverPath:
|
|
||||||
"""When a plugin supplies its config_schema.json, offsets resolve through
|
|
||||||
src.element_style instead."""
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def schema_path(self, tmp_path):
|
|
||||||
path = tmp_path / "config_schema.json"
|
|
||||||
path.write_text(json.dumps({
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"customization": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"layout": {"type": "object", "properties": {}},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
return str(path)
|
|
||||||
|
|
||||||
def host(self, schema_path, config):
|
|
||||||
host = probe(config)
|
|
||||||
host._config_schema_path.return_value = schema_path
|
|
||||||
del host._style_resolver_cached # MagicMock auto-attrs otherwise
|
|
||||||
host._style_resolver_cached = None
|
|
||||||
return host
|
|
||||||
|
|
||||||
def test_reads_configured_offsets(self, schema_path):
|
|
||||||
host = self.host(schema_path, layout_config("home_logo", "x_offset", 5))
|
|
||||||
assert offset(host, "home_logo", "x_offset") == 5
|
|
||||||
|
|
||||||
def test_numeric_string_is_coerced(self, schema_path):
|
|
||||||
host = self.host(schema_path, layout_config("score", "x_offset", "-3.5"))
|
|
||||||
assert offset(host, "score", "x_offset") == -3
|
|
||||||
|
|
||||||
def test_missing_value_uses_default(self, schema_path):
|
|
||||||
host = self.host(schema_path, layout_config("score", "x_offset", 5))
|
|
||||||
assert offset(host, "score", "y_offset", 9) == 9
|
|
||||||
|
|
||||||
def test_bad_input_degrades_to_default(self, schema_path):
|
|
||||||
host = self.host(schema_path, layout_config("score", "x_offset", "left"))
|
|
||||||
assert offset(host, "score", "x_offset", 3) == 3
|
|
||||||
host = self.host(schema_path, {"customization": {"layout": "nope"}})
|
|
||||||
assert offset(host, "score", "x_offset", 3) == 3
|
|
||||||
|
|
||||||
def test_boolean_is_rejected_unlike_the_classic_path(self, schema_path):
|
|
||||||
# The intended behavior difference: a bool is not a pixel offset, so
|
|
||||||
# the resolver returns the default where the classic read returns 1.
|
|
||||||
host = self.host(schema_path, layout_config("score", "x_offset", True))
|
|
||||||
assert offset(host, "score", "x_offset", 4) == 4
|
|
||||||
|
|
||||||
def test_resolver_is_cached_and_rebuilt_when_config_is_swapped(self, schema_path):
|
|
||||||
host = self.host(schema_path, layout_config("score", "x_offset", 5))
|
|
||||||
assert offset(host, "score", "x_offset") == 5
|
|
||||||
first = host._style_resolver_cached
|
|
||||||
assert offset(host, "score", "x_offset") == 5
|
|
||||||
assert host._style_resolver_cached is first
|
|
||||||
|
|
||||||
# on_config_change swaps the dict object; the resolver must follow.
|
|
||||||
host.config = layout_config("score", "x_offset", 11)
|
|
||||||
assert offset(host, "score", "x_offset") == 11
|
|
||||||
assert host._style_resolver_cached is not first
|
|
||||||
|
|
||||||
def test_missing_schema_file_still_resolves_offsets(self, tmp_path):
|
|
||||||
# Offsets don't depend on schema defaults, so an unreadable schema
|
|
||||||
# must not cost the plugin its layout customization.
|
|
||||||
host = self.host(str(tmp_path / "absent.json"),
|
|
||||||
layout_config("score", "x_offset", 5))
|
|
||||||
assert offset(host, "score", "x_offset") == 5
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 3. _load_custom_font_from_element_config() + the _font_root() seam
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestFontRootSeam:
|
|
||||||
def test_default_font_root_is_the_core_install_root(self, build):
|
|
||||||
manager = build()
|
|
||||||
assert Path(manager._font_root()) == CORE_ROOT
|
|
||||||
assert (Path(manager._font_root()) / "assets" / "fonts").is_dir()
|
|
||||||
|
|
||||||
def test_resolve_font_path_honors_an_overridden_root(self, tmp_path):
|
|
||||||
fonts = tmp_path / "assets" / "fonts"
|
|
||||||
fonts.mkdir(parents=True)
|
|
||||||
(fonts / "Bundled.ttf").write_bytes(b"not really a font")
|
|
||||||
host = probe()
|
|
||||||
host._font_root.side_effect = lambda: str(tmp_path)
|
|
||||||
assert SportsCore._resolve_font_path(host, "Bundled.ttf") == str(
|
|
||||||
fonts / "Bundled.ttf")
|
|
||||||
|
|
||||||
def test_unknown_font_returns_the_familiar_relative_path(self):
|
|
||||||
host = probe()
|
|
||||||
assert SportsCore._resolve_font_path(host, "Nope.ttf") == os.path.join(
|
|
||||||
"assets", "fonts", "Nope.ttf")
|
|
||||||
|
|
||||||
|
|
||||||
class TestFontLoaderSignature:
|
|
||||||
"""Hockey's signature is the only safe superset: basketball's positional
|
|
||||||
``default_font: str`` blows up on an explicit None."""
|
|
||||||
|
|
||||||
def test_two_arg_call(self):
|
|
||||||
font = load_font(probe(), {"font": TTF_NAME, "font_size": 10})
|
|
||||||
assert isinstance(font, ImageFont.FreeTypeFont)
|
|
||||||
assert font.size == 10
|
|
||||||
|
|
||||||
def test_default_size_is_used_when_config_omits_it(self):
|
|
||||||
assert load_font(probe(), {}, 12).size == 12
|
|
||||||
|
|
||||||
def test_three_positional_args(self):
|
|
||||||
font = load_font(probe(), {}, 6, "4x6-font.ttf")
|
|
||||||
assert isinstance(font, ImageFont.FreeTypeFont)
|
|
||||||
assert font.size == 6
|
|
||||||
assert font.path.endswith("4x6-font.ttf")
|
|
||||||
|
|
||||||
def test_explicit_default_font_none(self):
|
|
||||||
# The regression this signature guards: os.path.join(..., None).
|
|
||||||
font = load_font(probe(), {"font_size": 9}, default_font=None)
|
|
||||||
assert isinstance(font, ImageFont.FreeTypeFont)
|
|
||||||
assert font.path.endswith(TTF_NAME)
|
|
||||||
|
|
||||||
def test_config_font_wins_over_default_font(self):
|
|
||||||
font = load_font(probe(), {"font": TTF_NAME}, 8, "4x6-font.ttf")
|
|
||||||
assert font.path.endswith(TTF_NAME)
|
|
||||||
|
|
||||||
def test_string_font_size_is_coerced(self):
|
|
||||||
assert load_font(probe(), {"font": TTF_NAME, "font_size": "11"}).size == 11
|
|
||||||
|
|
||||||
|
|
||||||
class TestFontLoaderBehavior:
|
|
||||||
def test_family_alias_resolves_through_the_font_manager_catalog(self):
|
|
||||||
# "press_start" is a FontManager catalog family, not a filename; the
|
|
||||||
# promoted loader must not carry its own duplicate alias table.
|
|
||||||
host = probe()
|
|
||||||
font = load_font(host, {"font": "press_start", "font_size": 8})
|
|
||||||
assert font.path.endswith(TTF_NAME)
|
|
||||||
assert ("PressStart2P-Regular.ttf", 8) in host._font_cache
|
|
||||||
|
|
||||||
def test_memo_cache_returns_the_same_face(self):
|
|
||||||
host = probe()
|
|
||||||
first = load_font(host, {"font": TTF_NAME, "font_size": 8})
|
|
||||||
second = load_font(host, {"font": TTF_NAME, "font_size": 8})
|
|
||||||
assert first is second
|
|
||||||
assert len(host._font_cache) == 1
|
|
||||||
# A different size is a different face.
|
|
||||||
assert load_font(host, {"font": TTF_NAME, "font_size": 9}) is not first
|
|
||||||
assert len(host._font_cache) == 2
|
|
||||||
|
|
||||||
def test_bdf_loads_at_its_native_strike_when_the_request_misses(self):
|
|
||||||
# BDF is a fixed-size bitmap format: FreeType raises "invalid pixel
|
|
||||||
# size" for anything but the file's own strike. Baseball's retry is
|
|
||||||
# the only copy that gets this right.
|
|
||||||
host = probe()
|
|
||||||
font = load_font(host, {"font": BDF_NAME, "font_size": 8})
|
|
||||||
assert isinstance(font, ImageFont.FreeTypeFont)
|
|
||||||
assert font.size == BDF_NATIVE_SIZE
|
|
||||||
assert font.path.endswith(BDF_NAME)
|
|
||||||
assert set(host._bdf_native_size_cache.values()) == {BDF_NATIVE_SIZE}
|
|
||||||
# The retried face is memoized under the REQUESTED size.
|
|
||||||
assert host._font_cache[(BDF_NAME, 8)] is font
|
|
||||||
|
|
||||||
def test_bdf_at_its_native_size_needs_no_retry(self):
|
|
||||||
host = probe()
|
|
||||||
font = load_font(host, {"font": BDF_NAME, "font_size": BDF_NATIVE_SIZE})
|
|
||||||
assert font.size == BDF_NATIVE_SIZE
|
|
||||||
assert host._bdf_native_size_cache == {}
|
|
||||||
|
|
||||||
def test_bdf_strike_lookup_is_memoized(self, monkeypatch):
|
|
||||||
calls = []
|
|
||||||
real = SportsCore.__module__
|
|
||||||
|
|
||||||
def counting(path):
|
|
||||||
calls.append(path)
|
|
||||||
from src.font_manager import FontManager
|
|
||||||
return FontManager._read_bdf_native_size(path)
|
|
||||||
|
|
||||||
monkeypatch.setattr(f"{real}._read_bdf_native_size", counting)
|
|
||||||
host = probe()
|
|
||||||
load_font(host, {"font": BDF_NAME, "font_size": 8})
|
|
||||||
host._font_cache.clear() # force the load path again
|
|
||||||
load_font(host, {"font": BDF_NAME, "font_size": 8})
|
|
||||||
assert len(calls) == 1
|
|
||||||
|
|
||||||
def test_missing_font_falls_back_and_caches_the_fallback(self, caplog):
|
|
||||||
host = probe()
|
|
||||||
with caplog.at_level(logging.WARNING, logger=LOGGER.name):
|
|
||||||
font = load_font(host, {"font": "DoesNotExist.ttf", "font_size": 8})
|
|
||||||
assert isinstance(font, ImageFont.FreeTypeFont)
|
|
||||||
assert font.path.endswith(TTF_NAME)
|
|
||||||
assert any("Font file not found" in r.message for r in caplog.records)
|
|
||||||
# Cached under the requested name so a misconfiguration costs one
|
|
||||||
# disk probe, not one per frame.
|
|
||||||
assert host._font_cache[("DoesNotExist.ttf", 8)] is font
|
|
||||||
|
|
||||||
def test_unknown_extension_falls_back(self):
|
|
||||||
host = probe()
|
|
||||||
font = load_font(host, {"font": "AUTHORS", "font_size": 8})
|
|
||||||
assert font.path.endswith(TTF_NAME)
|
|
||||||
|
|
||||||
def test_fallback_honors_the_supplied_default_font(self):
|
|
||||||
host = probe()
|
|
||||||
font = load_font(host, {"font": "DoesNotExist.ttf"}, 6, "4x6-font.ttf")
|
|
||||||
assert font.path.endswith("4x6-font.ttf")
|
|
||||||
|
|
||||||
|
|
||||||
class TestFontLoaderCwdIndependence:
|
|
||||||
"""The bug the _font_root() seam exists to prevent: every plugin copy
|
|
||||||
joins 'assets/fonts' onto the process cwd, so a process started anywhere
|
|
||||||
else silently degrades to PIL's default bitmap face (the same defect
|
|
||||||
already fixed in FontManager — see CHANGELOG Unreleased/Fixed)."""
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("font_name,expected_size",
|
|
||||||
[(TTF_NAME, 8), (BDF_NAME, BDF_NATIVE_SIZE)])
|
|
||||||
def test_fonts_load_from_an_unrelated_cwd(self, monkeypatch, font_name,
|
|
||||||
expected_size):
|
|
||||||
monkeypatch.chdir("/")
|
|
||||||
host = probe()
|
|
||||||
font = load_font(host, {"font": font_name, "font_size": 8})
|
|
||||||
assert isinstance(font, ImageFont.FreeTypeFont), (
|
|
||||||
f"{font_name} degraded to PIL's default face when the process "
|
|
||||||
"runs outside the install root")
|
|
||||||
assert font.size == expected_size
|
|
||||||
assert Path(font.path) == FONTS_DIR / font_name
|
|
||||||
|
|
||||||
def test_fallback_font_also_survives_an_unrelated_cwd(self, monkeypatch):
|
|
||||||
monkeypatch.chdir("/")
|
|
||||||
font = load_font(probe(), {"font": "DoesNotExist.ttf", "font_size": 8})
|
|
||||||
assert isinstance(font, ImageFont.FreeTypeFont)
|
|
||||||
assert Path(font.path) == FONTS_DIR / TTF_NAME
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("key", ["score", "time", "team", "status",
|
|
||||||
"detail", "rank"])
|
|
||||||
def test_load_fonts_survives_an_unrelated_cwd(self, monkeypatch, key):
|
|
||||||
"""`_load_fonts` had the same cwd-relative literals the seam exists to
|
|
||||||
remove; every scoreboard font silently became PIL's default bitmap face
|
|
||||||
when the process started outside the install root."""
|
|
||||||
monkeypatch.chdir("/")
|
|
||||||
fonts = SportsCore._load_fonts(probe())
|
|
||||||
assert isinstance(fonts[key], ImageFont.FreeTypeFont), (
|
|
||||||
f"fonts['{key}'] degraded to PIL's default face outside the "
|
|
||||||
"install root")
|
|
||||||
|
|
||||||
|
|
||||||
class TestShouldLogCooldown:
|
|
||||||
"""`_should_log` reads `self._last_warning_time` unguarded, so it must be
|
|
||||||
initialized in __init__ — otherwise the first warning of a run raises
|
|
||||||
AttributeError instead of logging."""
|
|
||||||
|
|
||||||
def test_cooldown_clock_is_initialized(self, build):
|
|
||||||
assert build()._last_warning_time == 0
|
|
||||||
|
|
||||||
def test_first_call_logs_then_cools_down(self, build):
|
|
||||||
manager = build()
|
|
||||||
assert manager._should_log("api", cooldown=60) is True
|
|
||||||
assert manager._should_log("api", cooldown=60) is False
|
|
||||||
|
|
||||||
def test_cooldown_expires(self, build):
|
|
||||||
manager = build()
|
|
||||||
assert manager._should_log("api", cooldown=60) is True
|
|
||||||
manager._warning_cooldowns["api"] -= 61
|
|
||||||
assert manager._should_log("api", cooldown=60) is True
|
|
||||||
|
|
||||||
def test_cooldowns_are_tracked_per_warning_type(self, build):
|
|
||||||
"""The parameter was accepted and ignored: one shared timestamp meant an
|
|
||||||
API warning silenced an unrelated cache warning for the next minute."""
|
|
||||||
manager = build()
|
|
||||||
assert manager._should_log("api", cooldown=60) is True
|
|
||||||
assert manager._should_log("cache", cooldown=60) is True
|
|
||||||
assert manager._should_log("api", cooldown=60) is False
|
|
||||||
assert manager._should_log("cache", cooldown=60) is False
|
|
||||||
|
|
||||||
def test_one_type_expiring_does_not_free_another(self, build):
|
|
||||||
manager = build()
|
|
||||||
manager._should_log("api")
|
|
||||||
manager._should_log("cache")
|
|
||||||
manager._warning_cooldowns["api"] -= 61
|
|
||||||
assert manager._should_log("api") is True
|
|
||||||
assert manager._should_log("cache") is False
|
|
||||||
|
|
||||||
def test_legacy_single_clock_field_is_kept_in_step(self, build):
|
|
||||||
"""Subclasses in the plugin copies read _last_warning_time directly."""
|
|
||||||
manager = build()
|
|
||||||
manager._should_log("api")
|
|
||||||
assert manager._last_warning_time == manager._warning_cooldowns["api"]
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 4. Seam guard rails
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestPromotedSeamsExist:
|
|
||||||
@pytest.mark.parametrize("name", [
|
|
||||||
"cleanup", "_get_layout_offset", "_load_custom_font_from_element_config",
|
|
||||||
"_config_schema_path", "_font_root", "_resolve_font_path",
|
|
||||||
])
|
|
||||||
def test_method_is_callable_on_the_base_class(self, name):
|
|
||||||
assert callable(getattr(SportsCore, name, None)), (
|
|
||||||
f"SportsCore.{name} is part of the promoted plugin-facing seam "
|
|
||||||
"(docs/SPORTS_UNIFICATION.md) — plugins probe for it with "
|
|
||||||
"hasattr before delegating.")
|
|
||||||
|
|
||||||
def test_no_sport_names_leaked_into_core(self):
|
|
||||||
"""core.py must never branch on which sport it is (prose and skin-id
|
|
||||||
examples in docstrings are fine — executable code is not)."""
|
|
||||||
tree = ast.parse((CORE_ROOT / "src" / "base_classes" / "sports"
|
|
||||||
/ "core.py").read_text())
|
|
||||||
docstrings = set()
|
|
||||||
for node in ast.walk(tree):
|
|
||||||
if isinstance(node, (ast.Module, ast.ClassDef, ast.FunctionDef,
|
|
||||||
ast.AsyncFunctionDef)):
|
|
||||||
first = node.body[0] if node.body else None
|
|
||||||
if (isinstance(first, ast.Expr)
|
|
||||||
and isinstance(first.value, ast.Constant)
|
|
||||||
and isinstance(first.value.value, str)):
|
|
||||||
docstrings.add(id(first.value))
|
|
||||||
|
|
||||||
tokens = []
|
|
||||||
for node in ast.walk(tree):
|
|
||||||
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
||||||
if id(node) not in docstrings:
|
|
||||||
tokens.append(node.value)
|
|
||||||
elif isinstance(node, ast.Name):
|
|
||||||
tokens.append(node.id)
|
|
||||||
elif isinstance(node, ast.Attribute):
|
|
||||||
tokens.append(node.attr)
|
|
||||||
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef,
|
|
||||||
ast.ClassDef)):
|
|
||||||
tokens.append(node.name)
|
|
||||||
|
|
||||||
haystack = " ".join(tokens).lower()
|
|
||||||
for sport in ("afl", "nrl", "hockey", "baseball", "basketball",
|
|
||||||
"football", "lacrosse", "soccer", "ufc"):
|
|
||||||
assert sport not in haystack, (
|
|
||||||
f"core.py code mentions '{sport}' — core must never learn "
|
|
||||||
"sport names; add an override point instead.")
|
|
||||||
|
|
||||||
|
|
||||||
class TestInstallRootResolution:
|
|
||||||
"""Guards the depth bug the sports.py -> package move introduced.
|
|
||||||
|
|
||||||
The move was byte-identical in every class body, but `__file__` gained a
|
|
||||||
directory, so `Path(__file__).resolve().parents[2]` silently changed from
|
|
||||||
the repo root to `<root>/src`. Textual identity is not semantic identity
|
|
||||||
when code measures its own location: these tests assert the resolved
|
|
||||||
values, not the index.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def test_install_root_is_the_repo_root(self):
|
|
||||||
from src.base_classes.sports.core import _INSTALL_ROOT
|
|
||||||
|
|
||||||
# The repo root is the directory that actually holds src/ and assets/.
|
|
||||||
assert (_INSTALL_ROOT / "src").is_dir()
|
|
||||||
assert (_INSTALL_ROOT / "src" / "base_classes" / "sports").is_dir()
|
|
||||||
assert _INSTALL_ROOT.name != "src", (
|
|
||||||
"_INSTALL_ROOT resolved to src/ — the parents[] depth is off by "
|
|
||||||
"one, which is exactly the regression the package move caused.")
|
|
||||||
|
|
||||||
def test_resolve_project_path_roots_at_repo_not_src(self):
|
|
||||||
from src.base_classes.sports.core import SportsCore, _INSTALL_ROOT
|
|
||||||
|
|
||||||
resolved = SportsCore._resolve_project_path(None, Path("assets/fonts"))
|
|
||||||
assert resolved == _INSTALL_ROOT / "assets" / "fonts"
|
|
||||||
assert "src" not in resolved.relative_to(_INSTALL_ROOT).parts
|
|
||||||
|
|
||||||
def test_absolute_paths_pass_through_unchanged(self):
|
|
||||||
from src.base_classes.sports.core import SportsCore
|
|
||||||
|
|
||||||
absolute = Path("/tmp/some/logo/dir")
|
|
||||||
assert SportsCore._resolve_project_path(None, absolute) == absolute
|
|
||||||
|
|
||||||
def test_font_root_and_project_path_share_one_anchor(self):
|
|
||||||
"""Both consumers must derive from the same constant, so a future
|
|
||||||
move needs exactly one line changed rather than two."""
|
|
||||||
from src.base_classes.sports.core import SportsCore, _INSTALL_ROOT
|
|
||||||
|
|
||||||
assert SportsCore._font_root(None) == str(_INSTALL_ROOT)
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
"""Tests that SportsCore's decoded-logo cache is LRU-bounded.
|
|
||||||
|
|
||||||
``self._logo_cache`` was a plain dict keyed by team abbreviation, with no
|
|
||||||
eviction. The entries are decoded RGBA thumbnails sized to display*1.5 -- about
|
|
||||||
36KB on a 256x64 panel, more for wide wordmarks -- and
|
|
||||||
assets/sports/ncaa_logos ships 307 of them. A plugin that walked a full league
|
|
||||||
therefore held the whole league in memory: roughly 11-18MB per manager
|
|
||||||
instance, and a league runs three of them (live/recent/upcoming) each with its
|
|
||||||
own cache. On a 1GB Pi 3B+ with ~290MB available that is worth recovering.
|
|
||||||
|
|
||||||
The bound has to be LRU rather than "clear when full": the logos on screen
|
|
||||||
right now are exactly the ones that must not be thrown away.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from PIL import Image
|
|
||||||
|
|
||||||
# src.base_classes.sports transitively imports the hardware matrix driver.
|
|
||||||
sys.modules.setdefault("rgbmatrix", MagicMock())
|
|
||||||
|
|
||||||
from src.base_classes.sports import SportsCore # noqa: E402
|
|
||||||
|
|
||||||
LOGGER = logging.getLogger("test_sports_logo_cache_bounded")
|
|
||||||
|
|
||||||
|
|
||||||
class _StubSports(SportsCore):
|
|
||||||
def _fetch_data(self):
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _extract_game_details(self, game_event):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def host(monkeypatch, tmp_path):
|
|
||||||
monkeypatch.setattr(
|
|
||||||
SportsCore, "_initialize_logo_dir", lambda self, configured: tmp_path)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"src.base_classes.sports.core.get_background_service",
|
|
||||||
lambda *args, **kwargs: MagicMock())
|
|
||||||
# Never reach for the network: every logo these tests ask for exists.
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"src.base_classes.sports.core.download_missing_logo",
|
|
||||||
lambda *a, **k: None)
|
|
||||||
|
|
||||||
display_manager = MagicMock()
|
|
||||||
display_manager.matrix.width = 128
|
|
||||||
display_manager.matrix.height = 32
|
|
||||||
display_manager.width = 128
|
|
||||||
display_manager.height = 32
|
|
||||||
display_manager.image = Image.new("RGB", (128, 32))
|
|
||||||
cache_manager = MagicMock()
|
|
||||||
cache_manager.cache_dir = str(tmp_path)
|
|
||||||
instance = _StubSports({"timezone": "UTC"}, display_manager,
|
|
||||||
cache_manager, LOGGER, "nhl")
|
|
||||||
instance._logo_dir = tmp_path
|
|
||||||
return instance, tmp_path
|
|
||||||
|
|
||||||
|
|
||||||
def _load(host, abbrev):
|
|
||||||
"""Create a real logo file for `abbrev` and load it through the cache."""
|
|
||||||
instance, tmp_path = host
|
|
||||||
path = tmp_path / f"{abbrev}.png"
|
|
||||||
if not path.exists():
|
|
||||||
Image.new("RGBA", (64, 64), (1, 2, 3, 255)).save(path)
|
|
||||||
return instance._load_and_resize_logo(abbrev, abbrev, path, None)
|
|
||||||
|
|
||||||
|
|
||||||
class TestTheCacheIsBounded:
|
|
||||||
def test_it_never_exceeds_the_limit(self, host):
|
|
||||||
instance, _ = host
|
|
||||||
limit = instance._LOGO_CACHE_MAX
|
|
||||||
for i in range(limit + 40):
|
|
||||||
assert _load(host, f"T{i}") is not None
|
|
||||||
# The regression: this grew to limit + 40, and for NCAA to 307.
|
|
||||||
assert len(instance._logo_cache) == limit
|
|
||||||
|
|
||||||
def test_the_limit_is_smaller_than_a_real_league(self, host):
|
|
||||||
"""ncaa_logos ships 307 files; the cap has to be well under that."""
|
|
||||||
instance, _ = host
|
|
||||||
assert instance._LOGO_CACHE_MAX < 307
|
|
||||||
|
|
||||||
|
|
||||||
class TestEvictionIsLRUNotArbitrary:
|
|
||||||
def test_the_least_recently_used_goes_first(self, host):
|
|
||||||
instance, _ = host
|
|
||||||
limit = instance._LOGO_CACHE_MAX
|
|
||||||
for i in range(limit):
|
|
||||||
_load(host, f"T{i}")
|
|
||||||
_load(host, "NEW")
|
|
||||||
assert "T0" not in instance._logo_cache, "oldest should have been evicted"
|
|
||||||
assert "NEW" in instance._logo_cache
|
|
||||||
|
|
||||||
def test_touching_an_entry_saves_it(self, host):
|
|
||||||
instance, _ = host
|
|
||||||
limit = instance._LOGO_CACHE_MAX
|
|
||||||
for i in range(limit):
|
|
||||||
_load(host, f"T{i}")
|
|
||||||
_load(host, "T0") # a cache hit -- T0 is on screen again
|
|
||||||
_load(host, "NEW") # forces one eviction
|
|
||||||
|
|
||||||
assert "T0" in instance._logo_cache, "a logo in use was thrown away"
|
|
||||||
assert "T1" not in instance._logo_cache, "T1 was the true LRU entry"
|
|
||||||
|
|
||||||
|
|
||||||
class TestHitsStillAvoidDiskWork:
|
|
||||||
def test_a_second_request_returns_the_cached_object(self, host, monkeypatch):
|
|
||||||
instance, _ = host
|
|
||||||
first = _load(host, "TB")
|
|
||||||
|
|
||||||
def _boom(*a, **k):
|
|
||||||
raise AssertionError("cache hit re-opened the file from disk")
|
|
||||||
|
|
||||||
monkeypatch.setattr(Image, "open", _boom)
|
|
||||||
assert _load(host, "TB") is first
|
|
||||||
@@ -1,585 +0,0 @@
|
|||||||
"""Tests for the methods promoted onto SportsUpcoming / SportsRecent /
|
|
||||||
SportsLive from the nine plugin copies (phase B1 of the sports unification;
|
|
||||||
see docs/SPORTS_UNIFICATION.md).
|
|
||||||
|
|
||||||
Covered promotions:
|
|
||||||
- SportsRecent: `_get_zero_clock_duration` / `_clear_zero_clock_tracking`
|
|
||||||
(+ the `_zero_clock_timestamps` initializer).
|
|
||||||
- SportsLive: `_is_game_really_over` / `_detect_stale_games`
|
|
||||||
(+ `game_update_timestamps` / `stale_game_timeout`, and the
|
|
||||||
`FINAL_PERIOD` / `CLOCK_COUNTS_DOWN` class attributes).
|
|
||||||
- SportsUpcoming: `_select_games_for_display`.
|
|
||||||
- SportsRecent: `_select_recent_games_for_display`.
|
|
||||||
|
|
||||||
The live pair is the risk centre: `_detect_stale_games` is the only caller
|
|
||||||
that *removes* games, so `_is_game_really_over` returning a false positive
|
|
||||||
silently drops a live game from the display. The canonical form deliberately
|
|
||||||
declines to treat a missing clock as 0:00 — the plugin variant that did so
|
|
||||||
dropped clockless sports (baseball) from the FINAL_PERIOD-th period onward.
|
|
||||||
That regression is pinned by
|
|
||||||
`test_missing_clock_at_late_period_is_not_over`.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import requests
|
|
||||||
from freezegun import freeze_time
|
|
||||||
from PIL import Image
|
|
||||||
|
|
||||||
# src.base_classes.sports transitively imports the hardware matrix driver;
|
|
||||||
# stub it so these tests can import the sports base classes off-device.
|
|
||||||
sys.modules.setdefault("rgbmatrix", MagicMock())
|
|
||||||
|
|
||||||
from src.base_classes.hockey import Hockey, HockeyLive
|
|
||||||
from src.base_classes.sports import (
|
|
||||||
SportsCore,
|
|
||||||
SportsLive,
|
|
||||||
SportsRecent,
|
|
||||||
SportsUpcoming,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Harnesses
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class _UpcomingHarness(Hockey, SportsUpcoming):
|
|
||||||
"""Cheapest concrete SportsUpcoming: hockey extractor + cache-fed data.
|
|
||||||
|
|
||||||
`_favorite_key` is inherited from SportsCore — these harnesses
|
|
||||||
deliberately do NOT define it, so the selection tests exercise the real
|
|
||||||
seam rather than a local stand-in.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def _fetch_data(self):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class _RecentHarness(Hockey, SportsRecent):
|
|
||||||
def _fetch_data(self):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class _LiveHarness(HockeyLive):
|
|
||||||
def _fetch_data(self):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class _ThreePeriodLiveHarness(_LiveHarness):
|
|
||||||
"""Hockey-shaped: regulation ends after period 3."""
|
|
||||||
|
|
||||||
FINAL_PERIOD = 3
|
|
||||||
|
|
||||||
|
|
||||||
class _CountUpLiveHarness(_LiveHarness):
|
|
||||||
"""Soccer/AFL/NRL-shaped: the clock counts up, so 0:00 is kickoff."""
|
|
||||||
|
|
||||||
CLOCK_COUNTS_DOWN = False
|
|
||||||
|
|
||||||
|
|
||||||
class _IdFavoriteUpcomingHarness(_UpcomingHarness):
|
|
||||||
"""NRL-shaped: abbreviations are ambiguous, so favorites match on team id."""
|
|
||||||
|
|
||||||
def _favorite_key(self, game, side):
|
|
||||||
team_id = game.get(f"{side}_id")
|
|
||||||
return str(team_id) if team_id is not None else None
|
|
||||||
|
|
||||||
|
|
||||||
class _IdFavoriteRecentHarness(_RecentHarness):
|
|
||||||
def _favorite_key(self, game, side):
|
|
||||||
team_id = game.get(f"{side}_id")
|
|
||||||
return str(team_id) if team_id is not None else None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def build_manager(monkeypatch, tmp_path):
|
|
||||||
"""Factory for concrete sports managers: mocked display/cache managers,
|
|
||||||
logo dir redirected to tmp, background service stubbed, and the requests
|
|
||||||
session rigged to prove nothing hits the network."""
|
|
||||||
monkeypatch.setattr(
|
|
||||||
SportsCore, "_initialize_logo_dir", lambda self, configured: tmp_path)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"src.base_classes.sports.core.get_background_service",
|
|
||||||
lambda *args, **kwargs: MagicMock())
|
|
||||||
|
|
||||||
def build(cls, **mode_cfg):
|
|
||||||
config = {
|
|
||||||
"timezone": "UTC",
|
|
||||||
"display": {},
|
|
||||||
"nhl_scoreboard": {"enabled": True, **mode_cfg},
|
|
||||||
}
|
|
||||||
display_manager = MagicMock()
|
|
||||||
display_manager.matrix.width = 128
|
|
||||||
display_manager.matrix.height = 32
|
|
||||||
display_manager.width = 128
|
|
||||||
display_manager.height = 32
|
|
||||||
display_manager.image = Image.new("RGB", (128, 32))
|
|
||||||
cache_manager = MagicMock()
|
|
||||||
cache_manager.get.return_value = None
|
|
||||||
cache_manager.cache_dir = str(tmp_path)
|
|
||||||
manager = cls(config, display_manager, cache_manager,
|
|
||||||
logging.getLogger("test_sports_modes_promotions"),
|
|
||||||
"nhl")
|
|
||||||
manager.session = MagicMock()
|
|
||||||
manager.session.get.side_effect = requests.exceptions.ConnectionError(
|
|
||||||
"promotion tests are offline")
|
|
||||||
return manager
|
|
||||||
|
|
||||||
return build
|
|
||||||
|
|
||||||
|
|
||||||
def game(game_id="1", home="BOS", away="TOR", start=None, home_id=None,
|
|
||||||
away_id=None, **extra):
|
|
||||||
g = {
|
|
||||||
"id": game_id,
|
|
||||||
"home_abbr": home,
|
|
||||||
"away_abbr": away,
|
|
||||||
"home_id": home_id,
|
|
||||||
"away_id": away_id,
|
|
||||||
"start_time_utc": start,
|
|
||||||
}
|
|
||||||
g.update(extra)
|
|
||||||
return g
|
|
||||||
|
|
||||||
|
|
||||||
def at(day, hour=12):
|
|
||||||
return datetime(2026, 1, day, hour, tzinfo=timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
def _ids(games):
|
|
||||||
return [g["id"] for g in games]
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Tier 1 — zero-clock tracking (SportsRecent)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestZeroClockTracking:
|
|
||||||
def test_initializer_present_and_empty(self, build_manager):
|
|
||||||
manager = build_manager(_RecentHarness)
|
|
||||||
assert manager._zero_clock_timestamps == {}
|
|
||||||
|
|
||||||
def test_first_call_returns_zero_and_starts_tracking(self, build_manager):
|
|
||||||
manager = build_manager(_RecentHarness)
|
|
||||||
assert manager._get_zero_clock_duration("g1") == 0.0
|
|
||||||
assert "g1" in manager._zero_clock_timestamps
|
|
||||||
|
|
||||||
def test_subsequent_call_returns_elapsed_seconds(self, build_manager):
|
|
||||||
manager = build_manager(_RecentHarness)
|
|
||||||
with freeze_time("2026-01-20 12:00:00") as frozen:
|
|
||||||
assert manager._get_zero_clock_duration("g1") == 0.0
|
|
||||||
frozen.tick(45)
|
|
||||||
assert manager._get_zero_clock_duration("g1") == pytest.approx(45.0)
|
|
||||||
frozen.tick(15)
|
|
||||||
assert manager._get_zero_clock_duration("g1") == pytest.approx(60.0)
|
|
||||||
|
|
||||||
def test_tracking_is_per_game(self, build_manager):
|
|
||||||
manager = build_manager(_RecentHarness)
|
|
||||||
with freeze_time("2026-01-20 12:00:00") as frozen:
|
|
||||||
manager._get_zero_clock_duration("g1")
|
|
||||||
frozen.tick(30)
|
|
||||||
assert manager._get_zero_clock_duration("g2") == 0.0
|
|
||||||
assert manager._get_zero_clock_duration("g1") == pytest.approx(30.0)
|
|
||||||
|
|
||||||
def test_clear_resets_tracking(self, build_manager):
|
|
||||||
manager = build_manager(_RecentHarness)
|
|
||||||
with freeze_time("2026-01-20 12:00:00") as frozen:
|
|
||||||
manager._get_zero_clock_duration("g1")
|
|
||||||
frozen.tick(30)
|
|
||||||
manager._clear_zero_clock_tracking("g1")
|
|
||||||
assert "g1" not in manager._zero_clock_timestamps
|
|
||||||
# Restarts from zero after clearing.
|
|
||||||
assert manager._get_zero_clock_duration("g1") == 0.0
|
|
||||||
|
|
||||||
def test_clear_unknown_game_is_a_noop(self, build_manager):
|
|
||||||
manager = build_manager(_RecentHarness)
|
|
||||||
manager._clear_zero_clock_tracking("never-seen") # must not raise
|
|
||||||
assert manager._zero_clock_timestamps == {}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Tier 2a — _is_game_really_over (SportsLive)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestIsGameReallyOver:
|
|
||||||
def test_missing_clock_at_late_period_is_not_over(self, build_manager):
|
|
||||||
"""THE baseball regression: no `clock` key at all, period 7.
|
|
||||||
|
|
||||||
The rejected variant coerced a missing clock to the literal "0:00" and
|
|
||||||
declared the game over — dropping every MLB game from the 5th inning
|
|
||||||
onward, since baseball has no game clock and `period` is the inning.
|
|
||||||
A missing clock must fail safe.
|
|
||||||
"""
|
|
||||||
manager = build_manager(_LiveHarness)
|
|
||||||
g = game(period=7, period_text="Top 7th")
|
|
||||||
assert "clock" not in g
|
|
||||||
assert manager._is_game_really_over(g) is False
|
|
||||||
|
|
||||||
def test_none_clock_at_late_period_is_not_over(self, build_manager):
|
|
||||||
manager = build_manager(_LiveHarness)
|
|
||||||
assert manager._is_game_really_over(
|
|
||||||
game(clock=None, period=7, period_text="Top 7th")) is False
|
|
||||||
|
|
||||||
def test_non_string_clock_at_late_period_is_not_over(self, build_manager):
|
|
||||||
manager = build_manager(_LiveHarness)
|
|
||||||
assert manager._is_game_really_over(
|
|
||||||
game(clock=0, period=9, period_text="Bot 9th")) is False
|
|
||||||
|
|
||||||
def test_blank_clock_at_late_period_is_not_over(self, build_manager):
|
|
||||||
manager = build_manager(_LiveHarness)
|
|
||||||
assert manager._is_game_really_over(
|
|
||||||
game(clock=" ", period=5, period_text="5th")) is False
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("period_text", ["Final", "final", "Final/OT",
|
|
||||||
"FINAL", "Final - SO"])
|
|
||||||
def test_final_period_text_is_over(self, build_manager, period_text):
|
|
||||||
manager = build_manager(_LiveHarness)
|
|
||||||
assert manager._is_game_really_over(
|
|
||||||
game(clock="12:00", period=2, period_text=period_text)) is True
|
|
||||||
|
|
||||||
def test_none_period_text_does_not_raise(self, build_manager):
|
|
||||||
"""All nine plugin copies called `.lower()` on `game.get("period_text", "")`,
|
|
||||||
which is None when the key is present-but-None; `_detect_stale_games`
|
|
||||||
has no try/except around the call."""
|
|
||||||
manager = build_manager(_LiveHarness)
|
|
||||||
assert manager._is_game_really_over(
|
|
||||||
game(clock="12:00", period=2, period_text=None)) is False
|
|
||||||
|
|
||||||
def test_missing_period_text_does_not_raise(self, build_manager):
|
|
||||||
manager = build_manager(_LiveHarness)
|
|
||||||
assert manager._is_game_really_over(game(clock="12:00", period=2)) is False
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"clock", ["0:00", ":00", "00", "000", " 0:00 ", "00:00", "0", "0000"])
|
|
||||||
def test_expired_clock_at_final_period_is_over(self, build_manager, clock):
|
|
||||||
"""Every spelling of a zeroed clock counts, not a hand-listed few.
|
|
||||||
|
|
||||||
"00:00" is the one that motivated comparing numerically: it normalizes
|
|
||||||
to "0000", which matched none of the literals the plugin copies listed,
|
|
||||||
so a two-digit-minute expired clock kept the game on screen forever.
|
|
||||||
"""
|
|
||||||
manager = build_manager(_LiveHarness)
|
|
||||||
assert manager._is_game_really_over(
|
|
||||||
game(clock=clock, period=4, period_text="Q4")) is True
|
|
||||||
|
|
||||||
def test_none_period_at_expired_clock_does_not_raise(self, build_manager):
|
|
||||||
"""`period` present-but-None: `None >= FINAL_PERIOD` is a TypeError, and
|
|
||||||
`_detect_stale_games` has no try/except — the same failure shape as the
|
|
||||||
`period_text` case above."""
|
|
||||||
manager = build_manager(_LiveHarness)
|
|
||||||
assert manager._is_game_really_over(
|
|
||||||
game(clock="0:00", period=None, period_text="Q4")) is False
|
|
||||||
|
|
||||||
def test_none_period_with_running_clock_does_not_raise(self, build_manager):
|
|
||||||
manager = build_manager(_LiveHarness)
|
|
||||||
assert manager._is_game_really_over(
|
|
||||||
game(clock="8:12", period=None, period_text="Q2")) is False
|
|
||||||
|
|
||||||
def test_expired_clock_after_final_period_is_over(self, build_manager):
|
|
||||||
manager = build_manager(_LiveHarness)
|
|
||||||
assert manager._is_game_really_over(
|
|
||||||
game(clock="0:00", period=5, period_text="OT")) is True
|
|
||||||
|
|
||||||
def test_expired_clock_before_final_period_is_not_over(self, build_manager):
|
|
||||||
manager = build_manager(_LiveHarness)
|
|
||||||
assert manager._is_game_really_over(
|
|
||||||
game(clock="0:00", period=3, period_text="Q3")) is False
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("clock", [":40", "0:40", "1:00"])
|
|
||||||
def test_running_clock_is_not_over(self, build_manager, clock):
|
|
||||||
"""Sub-minute clocks like ':40' are legitimate, not expired."""
|
|
||||||
manager = build_manager(_LiveHarness)
|
|
||||||
assert manager._is_game_really_over(
|
|
||||||
game(clock=clock, period=4, period_text="Q4")) is False
|
|
||||||
|
|
||||||
def test_defaults_are_four_period_countdown(self):
|
|
||||||
assert SportsLive.FINAL_PERIOD == 4
|
|
||||||
assert SportsLive.CLOCK_COUNTS_DOWN is True
|
|
||||||
|
|
||||||
def test_final_period_override_three(self, build_manager):
|
|
||||||
"""Hockey-shaped subclass: regulation ends after period 3."""
|
|
||||||
manager = build_manager(_ThreePeriodLiveHarness)
|
|
||||||
assert manager.FINAL_PERIOD == 3
|
|
||||||
assert manager._is_game_really_over(
|
|
||||||
game(clock="0:00", period=3, period_text="P3")) is True
|
|
||||||
assert manager._is_game_really_over(
|
|
||||||
game(clock="0:00", period=2, period_text="P2")) is False
|
|
||||||
# And the unmodified default still requires period 4.
|
|
||||||
assert build_manager(_LiveHarness)._is_game_really_over(
|
|
||||||
game(clock="0:00", period=3, period_text="P3")) is False
|
|
||||||
|
|
||||||
def test_count_up_clock_never_expires(self, build_manager):
|
|
||||||
"""Soccer/AFL/NRL: 0:00 means kickoff, so the clock branch must not run."""
|
|
||||||
manager = build_manager(_CountUpLiveHarness)
|
|
||||||
assert manager.CLOCK_COUNTS_DOWN is False
|
|
||||||
for period in (1, 2, 4, 9):
|
|
||||||
assert manager._is_game_really_over(
|
|
||||||
game(clock="0:00", period=period, period_text="1st Half")) is False
|
|
||||||
|
|
||||||
def test_count_up_clock_still_honors_final_text(self, build_manager):
|
|
||||||
manager = build_manager(_CountUpLiveHarness)
|
|
||||||
assert manager._is_game_really_over(
|
|
||||||
game(clock="0:00", period=2, period_text="Final")) is True
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Tier 2b — _detect_stale_games (SportsLive)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestDetectStaleGames:
|
|
||||||
def test_initializer_defaults(self, build_manager):
|
|
||||||
manager = build_manager(_LiveHarness)
|
|
||||||
assert manager.game_update_timestamps == {}
|
|
||||||
assert manager.stale_game_timeout == 300
|
|
||||||
|
|
||||||
def test_stale_timeout_is_configurable(self, build_manager):
|
|
||||||
manager = build_manager(_LiveHarness, stale_game_timeout=42)
|
|
||||||
assert manager.stale_game_timeout == 42
|
|
||||||
|
|
||||||
def test_mutates_caller_list_in_place_and_returns_none(self, build_manager):
|
|
||||||
manager = build_manager(_LiveHarness)
|
|
||||||
fresh = game("1", period_text="P2", clock="10:00", period=2)
|
|
||||||
over = game("2", period_text="Final", clock="0:00", period=3)
|
|
||||||
games = [fresh, over]
|
|
||||||
original = games
|
|
||||||
|
|
||||||
result = manager._detect_stale_games(games)
|
|
||||||
|
|
||||||
assert result is None
|
|
||||||
assert games is original # same object, mutated in place
|
|
||||||
assert _ids(games) == ["1"]
|
|
||||||
|
|
||||||
def test_evicts_only_past_timeout_games(self, build_manager):
|
|
||||||
manager = build_manager(_LiveHarness)
|
|
||||||
with freeze_time("2026-01-20 12:00:00"):
|
|
||||||
now = time.time()
|
|
||||||
manager.game_update_timestamps = {
|
|
||||||
"1": {"last_seen": now - 10}, # fresh
|
|
||||||
"2": {"last_seen": now - 299}, # just inside the timeout
|
|
||||||
"3": {"last_seen": now - 301}, # past the timeout
|
|
||||||
}
|
|
||||||
games = [game("1", period_text="P1", clock="10:00", period=1),
|
|
||||||
game("2", period_text="P1", clock="10:00", period=1),
|
|
||||||
game("3", period_text="P1", clock="10:00", period=1)]
|
|
||||||
manager._detect_stale_games(games)
|
|
||||||
|
|
||||||
assert _ids(games) == ["1", "2"]
|
|
||||||
assert "3" not in manager.game_update_timestamps
|
|
||||||
assert set(manager.game_update_timestamps) == {"1", "2"}
|
|
||||||
|
|
||||||
def test_unknown_last_seen_is_never_stale(self, build_manager):
|
|
||||||
"""last_seen == 0 (or no entry) means 'never recorded', not 'ancient'."""
|
|
||||||
manager = build_manager(_LiveHarness)
|
|
||||||
games = [game("1", period_text="P1", clock="10:00", period=1),
|
|
||||||
game("2", period_text="P1", clock="10:00", period=1)]
|
|
||||||
manager.game_update_timestamps = {"1": {"last_seen": 0}}
|
|
||||||
manager._detect_stale_games(games)
|
|
||||||
assert _ids(games) == ["1", "2"]
|
|
||||||
|
|
||||||
def test_removes_games_that_are_really_over(self, build_manager):
|
|
||||||
manager = build_manager(_LiveHarness)
|
|
||||||
manager.game_update_timestamps = {"2": {"last_seen": 0}}
|
|
||||||
games = [game("1", period_text="Q2", clock="5:00", period=2),
|
|
||||||
game("2", period_text="Final", clock="0:00", period=4)]
|
|
||||||
manager._detect_stale_games(games)
|
|
||||||
assert _ids(games) == ["1"]
|
|
||||||
assert "2" not in manager.game_update_timestamps
|
|
||||||
|
|
||||||
def test_keeps_clockless_late_game(self, build_manager):
|
|
||||||
"""The end-to-end form of the baseball regression: a clockless game in
|
|
||||||
the 7th must survive the removal path."""
|
|
||||||
manager = build_manager(_LiveHarness)
|
|
||||||
games = [game("mlb-1", period=7, period_text="Top 7th")]
|
|
||||||
manager._detect_stale_games(games)
|
|
||||||
assert _ids(games) == ["mlb-1"]
|
|
||||||
|
|
||||||
def test_games_without_id_are_skipped(self, build_manager):
|
|
||||||
manager = build_manager(_LiveHarness)
|
|
||||||
no_id = {"home_abbr": "BOS", "away_abbr": "TOR",
|
|
||||||
"period_text": "Final", "clock": "0:00", "period": 4}
|
|
||||||
games = [no_id]
|
|
||||||
manager._detect_stale_games(games)
|
|
||||||
# `continue` fires before the "really over" check, so it stays.
|
|
||||||
assert games == [no_id]
|
|
||||||
|
|
||||||
def test_empty_list_is_tolerated(self, build_manager):
|
|
||||||
manager = build_manager(_LiveHarness)
|
|
||||||
games = []
|
|
||||||
assert manager._detect_stale_games(games) is None
|
|
||||||
assert games == []
|
|
||||||
|
|
||||||
def test_removal_is_by_value_not_identity(self, build_manager):
|
|
||||||
"""Sharp edge worth pinning: `list.remove` compares with `dict.__eq__`,
|
|
||||||
so two structurally-equal dicts drop the FIRST occurrence."""
|
|
||||||
manager = build_manager(_LiveHarness)
|
|
||||||
first = game("1", period_text="Final", clock="0:00", period=4)
|
|
||||||
twin = dict(first)
|
|
||||||
games = [first, twin]
|
|
||||||
manager._detect_stale_games(games)
|
|
||||||
# Both entries are removed here (two iterations, two removals), but the
|
|
||||||
# first removal deletes `first`, not the dict being iterated.
|
|
||||||
assert games == []
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Tier 3a — _select_games_for_display (SportsUpcoming)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestSelectGamesForDisplay:
|
|
||||||
def test_no_favorites_returns_all_sorted_ascending(self, build_manager):
|
|
||||||
manager = build_manager(_UpcomingHarness)
|
|
||||||
games = [game("late", start=at(20)), game("early", start=at(10)),
|
|
||||||
game("mid", start=at(15))]
|
|
||||||
assert _ids(manager._select_games_for_display(games, [])) == [
|
|
||||||
"early", "mid", "late"]
|
|
||||||
|
|
||||||
def test_missing_start_time_sorts_last(self, build_manager):
|
|
||||||
manager = build_manager(_UpcomingHarness)
|
|
||||||
games = [game("none", start=None), game("early", start=at(10))]
|
|
||||||
assert _ids(manager._select_games_for_display(games, [])) == [
|
|
||||||
"early", "none"]
|
|
||||||
|
|
||||||
def test_filters_to_favorite_teams(self, build_manager):
|
|
||||||
manager = build_manager(_UpcomingHarness)
|
|
||||||
games = [game("1", home="BOS", away="TOR", start=at(10)),
|
|
||||||
game("2", home="NYR", away="PIT", start=at(11)),
|
|
||||||
game("3", home="MTL", away="BOS", start=at(12))]
|
|
||||||
assert _ids(manager._select_games_for_display(games, ["BOS"])) == ["1", "3"]
|
|
||||||
|
|
||||||
def test_respects_upcoming_games_to_show_per_team(self, build_manager):
|
|
||||||
manager = build_manager(_UpcomingHarness, upcoming_games_to_show=2)
|
|
||||||
games = [game(str(i), home="BOS", away="TOR", start=at(10 + i))
|
|
||||||
for i in range(5)]
|
|
||||||
assert _ids(manager._select_games_for_display(games, ["BOS"])) == ["0", "1"]
|
|
||||||
|
|
||||||
def test_game_between_two_favorites_counts_for_both(self, build_manager):
|
|
||||||
manager = build_manager(_UpcomingHarness, upcoming_games_to_show=1)
|
|
||||||
games = [game("shared", home="BOS", away="TOR", start=at(10)),
|
|
||||||
game("bos2", home="BOS", away="NYR", start=at(11)),
|
|
||||||
game("tor2", home="TOR", away="PIT", start=at(12))]
|
|
||||||
# "shared" fills both BOS's and TOR's single slot, so nothing else fits.
|
|
||||||
assert _ids(manager._select_games_for_display(
|
|
||||||
games, ["BOS", "TOR"])) == ["shared"]
|
|
||||||
|
|
||||||
def test_deduplicates_by_game_id(self, build_manager):
|
|
||||||
manager = build_manager(_UpcomingHarness, upcoming_games_to_show=5)
|
|
||||||
g = game("dupe", home="BOS", away="TOR", start=at(10))
|
|
||||||
assert _ids(manager._select_games_for_display(
|
|
||||||
[g, dict(g)], ["BOS"])) == ["dupe"]
|
|
||||||
|
|
||||||
def test_non_favorite_games_excluded(self, build_manager):
|
|
||||||
manager = build_manager(_UpcomingHarness)
|
|
||||||
games = [game("1", home="NYR", away="PIT", start=at(10))]
|
|
||||||
assert manager._select_games_for_display(games, ["BOS"]) == []
|
|
||||||
|
|
||||||
def test_favorite_key_seam_supports_id_matching(self, build_manager):
|
|
||||||
"""The NRL case: two clubs share the abbreviation 'NEW', so favorites
|
|
||||||
must be matched on team id. Only the seam changes — the promoted
|
|
||||||
method is identical."""
|
|
||||||
games = [
|
|
||||||
game("knights", home="NEW", away="SYD", home_id=1, away_id=2,
|
|
||||||
start=at(10)),
|
|
||||||
game("warriors", home="NEW", away="SYD", home_id=99, away_id=2,
|
|
||||||
start=at(11)),
|
|
||||||
]
|
|
||||||
abbr_manager = build_manager(_UpcomingHarness)
|
|
||||||
# Abbreviation matching cannot tell the two "NEW" clubs apart.
|
|
||||||
assert _ids(abbr_manager._select_games_for_display(games, ["NEW"])) == [
|
|
||||||
"knights", "warriors"]
|
|
||||||
|
|
||||||
id_manager = build_manager(_IdFavoriteUpcomingHarness)
|
|
||||||
assert _ids(id_manager._select_games_for_display(games, ["99"])) == [
|
|
||||||
"warriors"]
|
|
||||||
|
|
||||||
def test_favorite_key_none_never_matches(self, build_manager):
|
|
||||||
"""`_favorite_key` returning None (missing id) must not match, even
|
|
||||||
against a favorites list holding the string 'None'."""
|
|
||||||
manager = build_manager(_IdFavoriteUpcomingHarness)
|
|
||||||
games = [game("1", home="BOS", away="TOR", start=at(10))] # no ids
|
|
||||||
assert manager._select_games_for_display(games, ["None"]) == []
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Tier 3b — _select_recent_games_for_display (SportsRecent)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestSelectRecentGamesForDisplay:
|
|
||||||
def test_no_favorites_returns_all_sorted_descending(self, build_manager):
|
|
||||||
manager = build_manager(_RecentHarness)
|
|
||||||
games = [game("early", start=at(10)), game("late", start=at(20)),
|
|
||||||
game("mid", start=at(15))]
|
|
||||||
assert _ids(manager._select_recent_games_for_display(games, [])) == [
|
|
||||||
"late", "mid", "early"]
|
|
||||||
|
|
||||||
def test_missing_start_time_sorts_last(self, build_manager):
|
|
||||||
manager = build_manager(_RecentHarness)
|
|
||||||
games = [game("none", start=None), game("late", start=at(20))]
|
|
||||||
assert _ids(manager._select_recent_games_for_display(games, [])) == [
|
|
||||||
"late", "none"]
|
|
||||||
|
|
||||||
def test_respects_recent_games_to_show_per_team(self, build_manager):
|
|
||||||
manager = build_manager(_RecentHarness, recent_games_to_show=2)
|
|
||||||
games = [game(str(i), home="BOS", away="TOR", start=at(10 + i))
|
|
||||||
for i in range(5)]
|
|
||||||
# Most recent first.
|
|
||||||
assert _ids(manager._select_recent_games_for_display(
|
|
||||||
games, ["BOS"])) == ["4", "3"]
|
|
||||||
|
|
||||||
def test_game_between_two_favorites_counts_for_both(self, build_manager):
|
|
||||||
manager = build_manager(_RecentHarness, recent_games_to_show=1)
|
|
||||||
games = [game("shared", home="BOS", away="TOR", start=at(20)),
|
|
||||||
game("bos2", home="BOS", away="NYR", start=at(19)),
|
|
||||||
game("tor2", home="TOR", away="PIT", start=at(18))]
|
|
||||||
assert _ids(manager._select_recent_games_for_display(
|
|
||||||
games, ["BOS", "TOR"])) == ["shared"]
|
|
||||||
|
|
||||||
def test_deduplicates_by_game_id(self, build_manager):
|
|
||||||
manager = build_manager(_RecentHarness, recent_games_to_show=5)
|
|
||||||
g = game("dupe", home="BOS", away="TOR", start=at(10))
|
|
||||||
assert _ids(manager._select_recent_games_for_display(
|
|
||||||
[g, dict(g)], ["BOS"])) == ["dupe"]
|
|
||||||
|
|
||||||
def test_non_favorite_games_excluded(self, build_manager):
|
|
||||||
manager = build_manager(_RecentHarness)
|
|
||||||
games = [game("1", home="NYR", away="PIT", start=at(10))]
|
|
||||||
assert manager._select_recent_games_for_display(games, ["BOS"]) == []
|
|
||||||
|
|
||||||
def test_favorite_key_seam_supports_id_matching(self, build_manager):
|
|
||||||
games = [
|
|
||||||
game("knights", home="NEW", away="SYD", home_id=1, away_id=2,
|
|
||||||
start=at(10)),
|
|
||||||
game("warriors", home="NEW", away="SYD", home_id=99, away_id=2,
|
|
||||||
start=at(11)),
|
|
||||||
]
|
|
||||||
id_manager = build_manager(_IdFavoriteRecentHarness)
|
|
||||||
assert _ids(id_manager._select_recent_games_for_display(
|
|
||||||
games, ["99"])) == ["warriors"]
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Seam / inertness guards
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestPromotionShape:
|
|
||||||
def test_promoted_methods_live_on_the_right_classes(self):
|
|
||||||
assert hasattr(SportsRecent, "_get_zero_clock_duration")
|
|
||||||
assert hasattr(SportsRecent, "_clear_zero_clock_tracking")
|
|
||||||
assert hasattr(SportsRecent, "_select_recent_games_for_display")
|
|
||||||
assert hasattr(SportsUpcoming, "_select_games_for_display")
|
|
||||||
assert hasattr(SportsLive, "_is_game_really_over")
|
|
||||||
assert hasattr(SportsLive, "_detect_stale_games")
|
|
||||||
|
|
||||||
def test_modes_does_not_define_the_favorite_key_seam(self):
|
|
||||||
"""`_favorite_key` is a SportsCore override point. modes.py must call
|
|
||||||
it, never define it — this test fails if the seam is added in the
|
|
||||||
wrong file."""
|
|
||||||
import src.base_classes.sports.modes as modes
|
|
||||||
|
|
||||||
for cls in (SportsUpcoming, SportsRecent, SportsLive):
|
|
||||||
assert "_favorite_key" not in vars(cls)
|
|
||||||
assert "_favorite_key" not in modes.__dict__
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
"""Odds must be fetched for the games shown, not every game in the window.
|
|
||||||
|
|
||||||
SportsUpcoming.update() collected every upcoming game in the schedule window
|
|
||||||
and called _fetch_odds() on each one *inside* that collection loop, narrowing
|
|
||||||
to upcoming_games_to_show only afterwards. The comment there said odds were
|
|
||||||
fetched "only for games that will be displayed", but the sole narrowing it
|
|
||||||
applied was show_favorite_teams_only, which is not the default -- so in the
|
|
||||||
usual configuration nothing narrowed it at all.
|
|
||||||
|
|
||||||
Measured on a live rig: a college league produced 946 upcoming games in one
|
|
||||||
cycle and displayed 1 of them. The same shape on the football plugin produced
|
|
||||||
a burst of 467 sequential ESPN requests that ran for 35s and blew that
|
|
||||||
plugin's 30s update budget, and it repeats every time the 1h odds TTL expires.
|
|
||||||
|
|
||||||
SportsLive is deliberately different: it walks the raw event list because it
|
|
||||||
has to find which games are live, but only fetches odds for a game that has
|
|
||||||
already passed the is_live/is_halftime test, so the fan-out is bounded by how
|
|
||||||
many games are actually in progress.
|
|
||||||
"""
|
|
||||||
import ast
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
MODES = (Path(__file__).resolve().parent.parent
|
|
||||||
/ "src" / "base_classes" / "sports" / "modes.py")
|
|
||||||
TREE = ast.parse(MODES.read_text(encoding="utf-8"))
|
|
||||||
|
|
||||||
|
|
||||||
def _fetch_sites():
|
|
||||||
"""(class name, method name, lineno) for every self._fetch_odds(...) call."""
|
|
||||||
calls = [n.lineno for n in ast.walk(TREE)
|
|
||||||
if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute)
|
|
||||||
and n.func.attr == "_fetch_odds"]
|
|
||||||
sites = []
|
|
||||||
for cls in [n for n in ast.walk(TREE) if isinstance(n, ast.ClassDef)]:
|
|
||||||
for fn in [n for n in cls.body if isinstance(n, ast.FunctionDef)]:
|
|
||||||
for lineno in calls:
|
|
||||||
if fn.lineno <= lineno <= (fn.end_lineno or fn.lineno):
|
|
||||||
sites.append((cls.name, fn.name, lineno))
|
|
||||||
assert len(sites) == len(calls), "a _fetch_odds call sits outside any method"
|
|
||||||
return sites
|
|
||||||
|
|
||||||
|
|
||||||
def _innermost_loop_iterable(lineno):
|
|
||||||
best = None
|
|
||||||
for node in ast.walk(TREE):
|
|
||||||
if isinstance(node, ast.For) and \
|
|
||||||
node.lineno <= lineno <= (node.end_lineno or node.lineno):
|
|
||||||
if best is None or node.lineno > best.lineno:
|
|
||||||
best = node
|
|
||||||
return None if best is None else ast.unparse(best.iter)
|
|
||||||
|
|
||||||
|
|
||||||
def _spans(body, lineno):
|
|
||||||
"""True when `lineno` falls inside this list of statements."""
|
|
||||||
return any(n.lineno <= lineno <= (n.end_lineno or n.lineno) for n in body)
|
|
||||||
|
|
||||||
|
|
||||||
def _parents(tree):
|
|
||||||
table = {}
|
|
||||||
for node in ast.walk(tree):
|
|
||||||
for child in ast.iter_child_nodes(node):
|
|
||||||
table[child] = node
|
|
||||||
return table
|
|
||||||
|
|
||||||
|
|
||||||
PARENTS = _parents(TREE)
|
|
||||||
|
|
||||||
|
|
||||||
def _mentions_positively(test, names):
|
|
||||||
"""True when `test` references every name, none of them under a `not`.
|
|
||||||
|
|
||||||
Structural, not textual. Matching the unparsed source would accept
|
|
||||||
`not (details["is_live"] or details["is_halftime"])` -- which selects
|
|
||||||
exactly the non-live games this guard exists to exclude -- because the
|
|
||||||
names still appear in the text.
|
|
||||||
"""
|
|
||||||
found = set()
|
|
||||||
for node in ast.walk(test):
|
|
||||||
if not (isinstance(node, ast.Constant) and node.value in names):
|
|
||||||
continue
|
|
||||||
negated = False
|
|
||||||
cursor = node
|
|
||||||
while cursor is not test and cursor in PARENTS:
|
|
||||||
cursor = PARENTS[cursor]
|
|
||||||
if isinstance(cursor, ast.UnaryOp) and isinstance(cursor.op, ast.Not):
|
|
||||||
negated = True
|
|
||||||
break
|
|
||||||
if not negated:
|
|
||||||
found.add(node.value)
|
|
||||||
return found >= set(names)
|
|
||||||
|
|
||||||
|
|
||||||
def _guarded_by_positive(lineno, names):
|
|
||||||
"""True when some enclosing `if` runs this line only if `names` hold.
|
|
||||||
|
|
||||||
Only the TRUE branch counts: an `if` whose `else` contains the call would
|
|
||||||
otherwise look like a guard while doing the opposite.
|
|
||||||
"""
|
|
||||||
for node in ast.walk(TREE):
|
|
||||||
if isinstance(node, ast.If) and _spans(node.body, lineno) \
|
|
||||||
and _mentions_positively(node.test, names):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def test_every_fetch_site_is_accounted_for():
|
|
||||||
"""A new call site must be classified deliberately, not inherited silently."""
|
|
||||||
found = {(cls, fn) for cls, fn, _ in _fetch_sites()}
|
|
||||||
assert found == {("SportsUpcoming", "update"), ("SportsLive", "update")}, (
|
|
||||||
f"unexpected _fetch_odds call sites: {sorted(found)}. Each one is a "
|
|
||||||
"sequential ESPN request per game -- classify it here on purpose.")
|
|
||||||
|
|
||||||
|
|
||||||
def test_upcoming_fetches_only_the_selected_games():
|
|
||||||
for cls, _fn, lineno in _fetch_sites():
|
|
||||||
if cls != "SportsUpcoming":
|
|
||||||
continue
|
|
||||||
iterable = _innermost_loop_iterable(lineno)
|
|
||||||
assert iterable == "team_games", (
|
|
||||||
f"SportsUpcoming._fetch_odds at line {lineno} iterates over "
|
|
||||||
f"{iterable!r}. It must run over team_games -- already narrowed to "
|
|
||||||
"upcoming_games_to_show -- not over every event in the schedule "
|
|
||||||
"window. Each item costs one sequential ESPN request.")
|
|
||||||
|
|
||||||
|
|
||||||
def test_live_only_fetches_for_games_actually_in_progress():
|
|
||||||
for cls, _fn, lineno in _fetch_sites():
|
|
||||||
if cls != "SportsLive":
|
|
||||||
continue
|
|
||||||
assert _guarded_by_positive(lineno, {"is_live", "is_halftime"}), (
|
|
||||||
f"SportsLive._fetch_odds at line {lineno} does not sit in the true "
|
|
||||||
"branch of a test requiring the game to be in progress. Without "
|
|
||||||
"that, it fans out across the whole event list -- one sequential "
|
|
||||||
"ESPN request per game.")
|
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
"""starlark-apps: the root display service must not lock the web UI out.
|
||||||
|
|
||||||
|
Reported by a user after a fresh install: installing an app from the Starlark
|
||||||
|
tab failed with "install failed: Failed to install from repository", and so did
|
||||||
|
uploading a .star file and installing from a GitHub directory. The cause was
|
||||||
|
ownership, which the error named nowhere -- they found it only by reading the
|
||||||
|
service logs, and fixed it with
|
||||||
|
|
||||||
|
sudo chown -R ledpi:ledpi /home/ledpi/LEDMatrix/starlark-apps
|
||||||
|
|
||||||
|
The starlark-apps directory is not in the repository, so it is created lazily
|
||||||
|
by whichever process reaches it first. Those processes run as different users:
|
||||||
|
systemd/ledmatrix.service is `User=root` and constructs this plugin at startup
|
||||||
|
(which is what calls _get_apps_directory), while systemd/ledmatrix-web.service
|
||||||
|
runs as the login user and is what actually installs apps. The documented
|
||||||
|
first step is to install pixlet and reboot, so on a fresh machine the display
|
||||||
|
service usually wins the race and the directory lands root-owned.
|
||||||
|
|
||||||
|
The web user cannot repair that -- chown needs root. So root hands the
|
||||||
|
directory over itself, every startup, which also heals machines already broken
|
||||||
|
by this.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
PLUGIN_DIR = Path(__file__).resolve().parent.parent / "plugin-repos" / "starlark-apps"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def manager_module():
|
||||||
|
if not PLUGIN_DIR.exists():
|
||||||
|
pytest.skip("starlark-apps plugin is not checked out")
|
||||||
|
sys.path.insert(0, str(PLUGIN_DIR))
|
||||||
|
injected_fcntl = "fcntl" not in sys.modules
|
||||||
|
if injected_fcntl:
|
||||||
|
stub = types.ModuleType("fcntl")
|
||||||
|
stub.LOCK_EX, stub.LOCK_UN = 2, 8
|
||||||
|
stub.flock = lambda *a, **kw: None
|
||||||
|
sys.modules["fcntl"] = stub
|
||||||
|
try:
|
||||||
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
"starlark_manager_ownership", PLUGIN_DIR / "manager.py")
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
except ImportError as e:
|
||||||
|
# Only a genuinely absent dependency is a skip. A syntax error or a
|
||||||
|
# NameError in the plugin is a regression these tests exist to catch,
|
||||||
|
# and swallowing it here would turn a red suite green.
|
||||||
|
if any(dep in str(e) for dep in ("PIL", "Pillow", "pixlet", "frame_extractor")):
|
||||||
|
pytest.skip(f"starlark-apps optional dependency missing: {e}")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
sys.path.remove(str(PLUGIN_DIR))
|
||||||
|
if injected_fcntl:
|
||||||
|
sys.modules.pop("fcntl", None)
|
||||||
|
|
||||||
|
|
||||||
|
def _plugin(manager_module):
|
||||||
|
"""A manager with __init__ bypassed -- only ownership paths are tested."""
|
||||||
|
cls = manager_module.StarlarkAppsPlugin
|
||||||
|
inst = cls.__new__(cls)
|
||||||
|
inst.logger = MagicMock()
|
||||||
|
return inst
|
||||||
|
|
||||||
|
|
||||||
|
class _Stat:
|
||||||
|
"""A real stat_result with only the ownership fields overridden.
|
||||||
|
|
||||||
|
Everything else is delegated to the genuine result. A stub carrying just
|
||||||
|
st_uid/st_gid passed locally but broke in CI, because pathlib itself reads
|
||||||
|
st_mode while walking the tree on some Python versions -- and the fields
|
||||||
|
it needs are an implementation detail, not something this test should be
|
||||||
|
asserting about.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, real, uid, gid):
|
||||||
|
self._real = real
|
||||||
|
self.st_uid = uid
|
||||||
|
self.st_gid = gid
|
||||||
|
|
||||||
|
def __getattr__(self, name):
|
||||||
|
return getattr(self._real, name)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def owned(monkeypatch):
|
||||||
|
"""Let a test declare a fake uid/gid for specific paths.
|
||||||
|
|
||||||
|
Real ownership cannot be faked without root, and these tests must run as
|
||||||
|
an ordinary user in CI.
|
||||||
|
"""
|
||||||
|
fake = {}
|
||||||
|
real_stat = Path.stat
|
||||||
|
real_lstat = os.lstat
|
||||||
|
|
||||||
|
def patched_stat(self, *args, **kwargs):
|
||||||
|
st = real_stat(self, *args, **kwargs)
|
||||||
|
key = str(self)
|
||||||
|
return _Stat(st, *fake[key]) if key in fake else st
|
||||||
|
|
||||||
|
def patched_lstat(path, *args, **kwargs):
|
||||||
|
st = real_lstat(path, *args, **kwargs)
|
||||||
|
key = str(path)
|
||||||
|
return _Stat(st, *fake[key]) if key in fake else st
|
||||||
|
|
||||||
|
# Both, because the code reads the checkout owner through Path.stat and
|
||||||
|
# each entry it repairs through os.lstat -- lstat so a symlink reports
|
||||||
|
# itself rather than its target.
|
||||||
|
monkeypatch.setattr(Path, "stat", patched_stat)
|
||||||
|
monkeypatch.setattr(os, "lstat", patched_lstat)
|
||||||
|
return fake
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def as_root(monkeypatch):
|
||||||
|
"""Run the handover as root, recording chowns instead of performing them."""
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr("os.geteuid", lambda: 0)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"os.chown",
|
||||||
|
lambda p, uid, gid, **kw: calls.append((str(p), uid, gid)))
|
||||||
|
return calls
|
||||||
|
|
||||||
|
|
||||||
|
def _tree(tmp_path):
|
||||||
|
"""A project root with an apps directory holding one installed app."""
|
||||||
|
apps = tmp_path / "starlark-apps"
|
||||||
|
(apps / "analogclock").mkdir(parents=True)
|
||||||
|
(apps / "analogclock" / "analog_clock.star").write_text("# app")
|
||||||
|
(apps / "manifest.json").write_text("{}")
|
||||||
|
return apps
|
||||||
|
|
||||||
|
|
||||||
|
class TestRootHandsTheDirectoryOver:
|
||||||
|
def test_root_created_directory_is_given_to_the_checkout_owner(
|
||||||
|
self, manager_module, tmp_path, owned, as_root):
|
||||||
|
apps = _tree(tmp_path)
|
||||||
|
owned[str(tmp_path)] = (1000, 1000) # checkout belongs to the login user
|
||||||
|
owned[str(apps)] = (0, 0) # but root got there first
|
||||||
|
|
||||||
|
_plugin(manager_module)._hand_apps_dir_to_checkout_owner(apps, tmp_path)
|
||||||
|
|
||||||
|
assert (str(apps), 1000, 1000) in as_root
|
||||||
|
|
||||||
|
def test_contents_are_repaired_not_just_the_directory(
|
||||||
|
self, manager_module, tmp_path, owned, as_root):
|
||||||
|
"""An install broken by this leaves root-owned files inside it too."""
|
||||||
|
apps = _tree(tmp_path)
|
||||||
|
owned[str(tmp_path)] = (1000, 1000)
|
||||||
|
for p in (apps, apps / "analogclock",
|
||||||
|
apps / "analogclock" / "analog_clock.star",
|
||||||
|
apps / "manifest.json"):
|
||||||
|
owned[str(p)] = (0, 0)
|
||||||
|
|
||||||
|
_plugin(manager_module)._hand_apps_dir_to_checkout_owner(apps, tmp_path)
|
||||||
|
|
||||||
|
chowned = {c[0] for c in as_root}
|
||||||
|
assert str(apps / "analogclock" / "analog_clock.star") in chowned
|
||||||
|
assert str(apps / "manifest.json") in chowned
|
||||||
|
|
||||||
|
def test_nothing_is_touched_when_ownership_is_already_right(
|
||||||
|
self, manager_module, tmp_path, owned, as_root):
|
||||||
|
apps = _tree(tmp_path)
|
||||||
|
owned[str(tmp_path)] = (1000, 1000)
|
||||||
|
for p in (apps, apps / "analogclock",
|
||||||
|
apps / "analogclock" / "analog_clock.star",
|
||||||
|
apps / "manifest.json"):
|
||||||
|
owned[str(p)] = (1000, 1000)
|
||||||
|
|
||||||
|
_plugin(manager_module)._hand_apps_dir_to_checkout_owner(apps, tmp_path)
|
||||||
|
|
||||||
|
assert as_root == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestItDoesNotOverreach:
|
||||||
|
def test_a_non_root_process_changes_nothing(
|
||||||
|
self, manager_module, tmp_path, owned, monkeypatch):
|
||||||
|
"""The web service also calls this. It has no right to chown anything."""
|
||||||
|
apps = _tree(tmp_path)
|
||||||
|
owned[str(tmp_path)] = (1000, 1000)
|
||||||
|
owned[str(apps)] = (0, 0)
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr("os.geteuid", lambda: 1000)
|
||||||
|
monkeypatch.setattr("os.chown", lambda p, u, g, **kw: calls.append(p))
|
||||||
|
|
||||||
|
_plugin(manager_module)._hand_apps_dir_to_checkout_owner(apps, tmp_path)
|
||||||
|
|
||||||
|
assert calls == []
|
||||||
|
|
||||||
|
def test_a_genuinely_root_owned_checkout_is_left_alone(
|
||||||
|
self, manager_module, tmp_path, owned, as_root):
|
||||||
|
"""Installed as root on purpose: there is nobody to hand it to."""
|
||||||
|
apps = _tree(tmp_path)
|
||||||
|
owned[str(tmp_path)] = (0, 0)
|
||||||
|
owned[str(apps)] = (0, 0)
|
||||||
|
|
||||||
|
_plugin(manager_module)._hand_apps_dir_to_checkout_owner(apps, tmp_path)
|
||||||
|
|
||||||
|
assert as_root == []
|
||||||
|
|
||||||
|
def test_a_failed_chown_warns_and_does_not_raise(
|
||||||
|
self, manager_module, tmp_path, owned, monkeypatch):
|
||||||
|
"""Startup must not die because one file could not be handed over."""
|
||||||
|
apps = _tree(tmp_path)
|
||||||
|
owned[str(tmp_path)] = (1000, 1000)
|
||||||
|
owned[str(apps)] = (0, 0)
|
||||||
|
monkeypatch.setattr("os.geteuid", lambda: 0)
|
||||||
|
|
||||||
|
def boom(*_a, **_kw):
|
||||||
|
raise OSError("read-only file system")
|
||||||
|
|
||||||
|
monkeypatch.setattr("os.chown", boom)
|
||||||
|
plugin = _plugin(manager_module)
|
||||||
|
|
||||||
|
plugin._hand_apps_dir_to_checkout_owner(apps, tmp_path) # must not raise
|
||||||
|
|
||||||
|
assert plugin.logger.warning.called
|
||||||
|
|
||||||
|
|
||||||
|
class TestTheDirectoryGetterUsesIt:
|
||||||
|
def test_get_apps_directory_performs_the_handover(
|
||||||
|
self, manager_module, monkeypatch):
|
||||||
|
"""Pins the wiring: creating the directory without handing it over is
|
||||||
|
exactly the bug.
|
||||||
|
|
||||||
|
This calls the real getter, which resolves to the checkout's own
|
||||||
|
starlark-apps directory, so it removes the directory again when the
|
||||||
|
test was what created it.
|
||||||
|
"""
|
||||||
|
plugin = _plugin(manager_module)
|
||||||
|
seen = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
type(plugin), "_hand_apps_dir_to_checkout_owner",
|
||||||
|
lambda self, apps, root: seen.append((apps, root)))
|
||||||
|
expected = PLUGIN_DIR.parent.parent / "starlark-apps"
|
||||||
|
pre_existing = expected.exists()
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = plugin._get_apps_directory()
|
||||||
|
|
||||||
|
assert result == expected and result.exists()
|
||||||
|
assert seen and seen[0] == (expected, expected.parent)
|
||||||
|
finally:
|
||||||
|
if not pre_existing and expected.exists():
|
||||||
|
try:
|
||||||
|
expected.rmdir()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TestTheErrorNamesTheCause:
|
||||||
|
"""The reporter saw only "Failed to install from repository".
|
||||||
|
|
||||||
|
That message names no path and no cause, so the only way to the answer was
|
||||||
|
reading the service logs. The handover above should stop the failure
|
||||||
|
happening at all; this makes the failure legible if it ever does.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.fixture(scope="class")
|
||||||
|
def hint(self):
|
||||||
|
try:
|
||||||
|
from web_interface.blueprints.api_v3.starlark import _ownership_hint
|
||||||
|
except ImportError as e:
|
||||||
|
# Same rule as above: absent Flask is a skip, a broken module is not.
|
||||||
|
if "flask" in str(e).lower():
|
||||||
|
pytest.skip(f"Flask is not installed here: {e}")
|
||||||
|
raise
|
||||||
|
return _ownership_hint
|
||||||
|
|
||||||
|
def test_a_permission_error_explains_itself(self, hint):
|
||||||
|
message = hint(PermissionError(13, "Permission denied"))
|
||||||
|
assert message
|
||||||
|
assert "chown" in message
|
||||||
|
assert "starlark-apps" in message
|
||||||
|
|
||||||
|
def test_it_points_at_the_automatic_repair_first(self, hint):
|
||||||
|
"""Restarting the display service is the fix that needs no root user
|
||||||
|
to understand it -- the manual chown is the fallback."""
|
||||||
|
message = hint(PermissionError(13, "Permission denied"))
|
||||||
|
assert "systemctl restart ledmatrix" in message
|
||||||
|
assert message.index("systemctl") < message.index("chown")
|
||||||
|
|
||||||
|
def test_other_failures_are_not_mislabelled(self, hint):
|
||||||
|
"""A network error must not be reported as an ownership problem."""
|
||||||
|
for err in (ValueError("bad json"), OSError(28, "No space left on device"),
|
||||||
|
TimeoutError("github timed out")):
|
||||||
|
assert hint(err) is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestItWillNotBeTrickedIntoGivingAwayAFile:
|
||||||
|
"""Root chowning a tree is a privilege-escalation primitive if it follows
|
||||||
|
links: anyone who can write in the directory could point one at a
|
||||||
|
root-owned file and have this hand it over."""
|
||||||
|
|
||||||
|
def test_a_symlink_is_never_followed(self, manager_module, tmp_path, owned, as_root):
|
||||||
|
apps = _tree(tmp_path)
|
||||||
|
target = tmp_path / "precious"
|
||||||
|
target.write_text("root-owned secret")
|
||||||
|
(apps / "evil").symlink_to(target)
|
||||||
|
owned[str(tmp_path)] = (1000, 1000)
|
||||||
|
owned[str(apps)] = (0, 0)
|
||||||
|
# The link must look like it NEEDS handing over, or it would be
|
||||||
|
# skipped for already having the right owner and this test would pass
|
||||||
|
# without ever exercising the symlink check.
|
||||||
|
owned[str(apps / "evil")] = (0, 0)
|
||||||
|
owned[str(target)] = (0, 0)
|
||||||
|
|
||||||
|
_plugin(manager_module)._hand_apps_dir_to_checkout_owner(apps, tmp_path)
|
||||||
|
|
||||||
|
chowned = {c[0] for c in as_root}
|
||||||
|
assert str(target) not in chowned
|
||||||
|
assert str(apps / "evil") not in chowned
|
||||||
|
|
||||||
|
def test_the_directory_is_handed_over_last(self, manager_module, tmp_path, owned, as_root):
|
||||||
|
"""Its contents must be settled before the container changes hands."""
|
||||||
|
apps = _tree(tmp_path)
|
||||||
|
owned[str(tmp_path)] = (1000, 1000)
|
||||||
|
for p in (apps, apps / "analogclock",
|
||||||
|
apps / "analogclock" / "analog_clock.star",
|
||||||
|
apps / "manifest.json"):
|
||||||
|
owned[str(p)] = (0, 0)
|
||||||
|
|
||||||
|
_plugin(manager_module)._hand_apps_dir_to_checkout_owner(apps, tmp_path)
|
||||||
|
|
||||||
|
order = [c[0] for c in as_root]
|
||||||
|
assert order[-1] == str(apps)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAPermissionFailureReachesTheCaller:
|
||||||
|
def test_install_app_does_not_swallow_permission_errors(
|
||||||
|
self, manager_module, tmp_path, monkeypatch):
|
||||||
|
"""A False here reads as "this app is broken" and routes to a generic
|
||||||
|
message -- which is how the ownership bug stayed invisible."""
|
||||||
|
plugin = _plugin(manager_module)
|
||||||
|
plugin.apps_dir = tmp_path
|
||||||
|
plugin.apps = {}
|
||||||
|
|
||||||
|
def denied(self, *a, **kw):
|
||||||
|
raise PermissionError(13, "Permission denied")
|
||||||
|
|
||||||
|
monkeypatch.setattr(Path, "mkdir", denied)
|
||||||
|
|
||||||
|
with pytest.raises(PermissionError):
|
||||||
|
plugin.install_app("analogclock", str(tmp_path / "x.star"), {})
|
||||||
|
|
||||||
|
def test_other_install_failures_still_return_false(
|
||||||
|
self, manager_module, tmp_path, monkeypatch):
|
||||||
|
"""Only permission errors are promoted; the bool contract is intact."""
|
||||||
|
plugin = _plugin(manager_module)
|
||||||
|
plugin.apps_dir = tmp_path
|
||||||
|
plugin.apps = {}
|
||||||
|
|
||||||
|
def broken(self, *a, **kw):
|
||||||
|
raise OSError(28, "No space left on device")
|
||||||
|
|
||||||
|
monkeypatch.setattr(Path, "mkdir", broken)
|
||||||
|
|
||||||
|
assert plugin.install_app("analogclock", str(tmp_path / "x.star"), {}) is False
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""Registry entries that aren't plugins are hidden and refused.
|
||||||
|
|
||||||
|
The official registry no longer lists skins, but a custom registry (added
|
||||||
|
from the Plugin Store) can still carry ``"type": "skin"`` entries. With the
|
||||||
|
skin system removed, installing one as a plugin would unpack it into the
|
||||||
|
plugins directory, so the store hides such entries and refuses them at install.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.plugin_system.store_manager import PluginStoreManager
|
||||||
|
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401
|
||||||
|
|
||||||
|
PLUGIN = {"id": "clock", "name": "Clock", "repo": "https://github.com/x/clock"}
|
||||||
|
UNTYPED = {"id": "news", "name": "News", "repo": "https://github.com/x/news"}
|
||||||
|
SKIN = {"id": "retro", "name": "Retro", "type": "skin", "repo": "https://github.com/x/retro"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("entry,expected", [
|
||||||
|
(PLUGIN, True),
|
||||||
|
({**PLUGIN, "type": "plugin"}, True),
|
||||||
|
({**PLUGIN, "type": None}, True),
|
||||||
|
(SKIN, False),
|
||||||
|
({**PLUGIN, "type": "theme"}, False),
|
||||||
|
(None, False),
|
||||||
|
("clock", False),
|
||||||
|
])
|
||||||
|
def test_is_plugin_entry(entry, expected):
|
||||||
|
assert PluginStoreManager.is_plugin_entry(entry) is expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_refuses_a_non_plugin_entry(tmp_path):
|
||||||
|
store = PluginStoreManager(plugins_dir=str(tmp_path / "plugins"))
|
||||||
|
store.get_plugin_info = MagicMock(return_value=dict(SKIN))
|
||||||
|
store._install_from_monorepo_zip = MagicMock()
|
||||||
|
store._install_via_git = MagicMock()
|
||||||
|
assert store._install_plugin_impl("retro") is False
|
||||||
|
assert not (tmp_path / "plugins" / "retro").exists()
|
||||||
|
store._install_from_monorepo_zip.assert_not_called()
|
||||||
|
store._install_via_git.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def store(api_v3_module):
|
||||||
|
mock = MagicMock()
|
||||||
|
mock.is_plugin_entry = PluginStoreManager.is_plugin_entry
|
||||||
|
api_v3_module.api_v3.plugin_store_manager = mock
|
||||||
|
return mock
|
||||||
|
|
||||||
|
|
||||||
|
def test_custom_registry_listing_hides_non_plugins(api_v3_client, store):
|
||||||
|
store.fetch_registry_from_url.return_value = {"plugins": [PLUGIN, SKIN, UNTYPED]}
|
||||||
|
resp = api_v3_client.post("/api/v3/plugins/registry-from-url",
|
||||||
|
data=json.dumps({"repo_url": "https://github.com/x/registry"}),
|
||||||
|
content_type="application/json")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert [p["id"] for p in resp.get_json()["plugins"]] == ["clock", "news"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_store_listing_hides_non_plugins(api_v3_client, store):
|
||||||
|
store.search_plugins.return_value = [PLUGIN, SKIN, UNTYPED]
|
||||||
|
resp = api_v3_client.get("/api/v3/plugins/store/list")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.get_json()
|
||||||
|
listed = body.get("data", body).get("plugins", [])
|
||||||
|
assert [p["id"] for p in listed] == ["clock", "news"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_route_refuses_a_non_plugin_entry(api_v3_client, store):
|
||||||
|
store.get_registry_info.return_value = dict(SKIN)
|
||||||
|
resp = api_v3_client.post("/api/v3/plugins/install",
|
||||||
|
data=json.dumps({"plugin_id": "retro"}),
|
||||||
|
content_type="application/json")
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "not a plugin" in resp.get_json()["message"]
|
||||||
|
store.install_plugin.assert_not_called()
|
||||||
@@ -9,9 +9,9 @@ can never disagree. (Historically the store used raw string equality, which
|
|||||||
reinstalled over cosmetic differences like "v1.2.0" vs "1.2.0" and even
|
reinstalled over cosmetic differences like "v1.2.0" vs "1.2.0" and even
|
||||||
DOWNGRADED locally-ahead plugins; this file's tests killed that.)
|
DOWNGRADED locally-ahead plugins; this file's tests killed that.)
|
||||||
|
|
||||||
Two other version parsers legitimately remain and are pinned here so they
|
One other version parser legitimately remains and is pinned here so it
|
||||||
don't drift: `compatibility.parse_semver` (the install-compatibility gate,
|
doesn't drift: `compatibility.parse_semver` (the install-compatibility gate,
|
||||||
range-spec oriented) and `skin_runtime._major` (skin API major gate).
|
range-spec oriented).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -21,7 +21,6 @@ import pytest
|
|||||||
from packaging.version import parse as pkg_parse
|
from packaging.version import parse as pkg_parse
|
||||||
|
|
||||||
from src.plugin_system.compatibility import is_update_available, parse_semver
|
from src.plugin_system.compatibility import is_update_available, parse_semver
|
||||||
from src.skin_system.skin_runtime import _major
|
|
||||||
from src.plugin_system.store_manager import PluginStoreManager
|
from src.plugin_system.store_manager import PluginStoreManager
|
||||||
from web_interface.blueprints.api_v3 import _is_plugin_update_available
|
from web_interface.blueprints.api_v3 import _is_plugin_update_available
|
||||||
|
|
||||||
@@ -142,25 +141,6 @@ class TestStoreManagerUsesSharedComparator:
|
|||||||
reinstall.assert_not_called()
|
reinstall.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
class TestSkinRuntimeMajor:
|
|
||||||
def test_plain_versions(self):
|
|
||||||
assert _major("1.0.0") == 1
|
|
||||||
assert _major("2.1") == 2
|
|
||||||
|
|
||||||
def test_int_input_tolerated(self):
|
|
||||||
assert _major(2) == 2
|
|
||||||
|
|
||||||
def test_garbage_returns_none(self):
|
|
||||||
assert _major("garbage") is None
|
|
||||||
assert _major(None) is None
|
|
||||||
|
|
||||||
def test_v_prefix_not_tolerated(self):
|
|
||||||
# Unlike parse_semver, _major does NOT strip a leading 'v' —
|
|
||||||
# a skin.json declaring "v1.0.0" fails the API gate. Characterized
|
|
||||||
# so a manifest-format loosening elsewhere doesn't silently diverge.
|
|
||||||
assert _major("v1.0.0") is None
|
|
||||||
|
|
||||||
|
|
||||||
class TestParseSemverAgreesWithPackaging:
|
class TestParseSemverAgreesWithPackaging:
|
||||||
"""parse_semver and packaging must agree on ordering for plain X.Y.Z —
|
"""parse_semver and packaging must agree on ordering for plain X.Y.Z —
|
||||||
the region where the two ecosystems overlap and must never diverge."""
|
the region where the two ecosystems overlap and must never diverge."""
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ test_api_v3_secret_roundtrip.py, so assertions are on config.json itself.
|
|||||||
- Legacy booleans (#588) were normalized only at load, so posting back what
|
- Legacy booleans (#588) were normalized only at load, so posting back what
|
||||||
GET /plugins/config returned failed validation.
|
GET /plugins/config returned failed validation.
|
||||||
- The JSON save's filter kept only enabled/display_duration/live_priority, so
|
- The JSON save's filter kept only enabled/display_duration/live_priority, so
|
||||||
a submitted skin, skin_options or vegas_* tuning key was silently dropped.
|
a submitted vegas_* tuning key was silently dropped.
|
||||||
|
- ``skin`` and ``skin_options`` outlived the skin system in stored configs:
|
||||||
|
every save path must accept a section carrying them, and drop them.
|
||||||
- Plugin sections posted to /config/main skipped all of that and were stored
|
- Plugin sections posted to /config/main skipped all of that and were stored
|
||||||
verbatim, including values /plugins/config rejects.
|
verbatim, including values /plugins/config rejects.
|
||||||
"""
|
"""
|
||||||
@@ -65,7 +67,9 @@ STORED = {
|
|||||||
"display": {"brightness": 80, "show_icons": False},
|
"display": {"brightness": 80, "show_icons": False},
|
||||||
"global": {"dynamic_duration": {"enabled": False, "min_duration_seconds": 45}},
|
"global": {"dynamic_duration": {"enabled": False, "min_duration_seconds": 45}},
|
||||||
"vegas_width_pct": 60,
|
"vegas_width_pct": 60,
|
||||||
|
# Written by a release that still had the skin system
|
||||||
"skin": "neon",
|
"skin": "neon",
|
||||||
|
"skin_options": {"accent": "#00ff00"},
|
||||||
}
|
}
|
||||||
|
|
||||||
_ATTRS = ('config_manager', 'plugin_manager', 'plugin_store_manager',
|
_ATTRS = ('config_manager', 'plugin_manager', 'plugin_store_manager',
|
||||||
@@ -174,28 +178,64 @@ class TestCoreOwnedKeysSurviveTheFilter:
|
|||||||
env.store({"enabled": True, "city": "Paris"})
|
env.store({"enabled": True, "city": "Paris"})
|
||||||
resp = env.save({
|
resp = env.save({
|
||||||
"vegas_width_pct": 50, "vegas_overflow": "truncate",
|
"vegas_width_pct": 50, "vegas_overflow": "truncate",
|
||||||
"vegas_max_width_screens": 2, "skin": "retro",
|
"vegas_max_width_screens": 2, "live_priority": True,
|
||||||
"skin_options": {"accent": "#ff0000"}, "live_priority": True,
|
|
||||||
})
|
})
|
||||||
assert resp.status_code == 200, resp.get_json()
|
assert resp.status_code == 200, resp.get_json()
|
||||||
stored = env.stored()
|
stored = env.stored()
|
||||||
assert stored["vegas_width_pct"] == 50
|
assert stored["vegas_width_pct"] == 50
|
||||||
assert stored["vegas_overflow"] == "truncate"
|
assert stored["vegas_overflow"] == "truncate"
|
||||||
assert stored["vegas_max_width_screens"] == 2
|
assert stored["vegas_max_width_screens"] == 2
|
||||||
assert stored["skin"] == "retro"
|
|
||||||
assert stored["skin_options"] == {"accent": "#ff0000"}
|
|
||||||
assert stored["live_priority"] is True
|
assert stored["live_priority"] is True
|
||||||
|
|
||||||
def test_stored_core_keys_survive_an_unrelated_save(self, env):
|
def test_stored_core_keys_survive_an_unrelated_save(self, env):
|
||||||
assert env.save({"city": "Nice"}).status_code == 200
|
assert env.save({"city": "Nice"}).status_code == 200
|
||||||
stored = env.stored()
|
stored = env.stored()
|
||||||
assert stored["vegas_width_pct"] == 60 and stored["skin"] == "neon"
|
assert stored["vegas_width_pct"] == 60
|
||||||
|
|
||||||
def test_a_non_core_unknown_key_is_still_filtered(self, env):
|
def test_a_non_core_unknown_key_is_still_filtered(self, env):
|
||||||
assert env.save({"not_in_schema": 1}).status_code == 200
|
assert env.save({"not_in_schema": 1}).status_code == 200
|
||||||
assert "not_in_schema" not in env.stored()
|
assert "not_in_schema" not in env.stored()
|
||||||
|
|
||||||
|
|
||||||
|
class TestRetiredSkinKeys:
|
||||||
|
"""STORED carries skin/skin_options, and SCHEMA sets
|
||||||
|
additionalProperties: false without declaring them."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _assert_dropped(stored):
|
||||||
|
assert "skin" not in stored and "skin_options" not in stored
|
||||||
|
assert stored["vegas_width_pct"] == 60 # still a core-owned key
|
||||||
|
|
||||||
|
def test_json_save_accepts_and_drops_them(self, env):
|
||||||
|
resp = env.save({"city": "Nice"})
|
||||||
|
assert resp.status_code == 200, resp.get_json()
|
||||||
|
assert env.stored()["city"] == "Nice"
|
||||||
|
self._assert_dropped(env.stored())
|
||||||
|
|
||||||
|
def test_form_save_accepts_and_drops_them(self, env):
|
||||||
|
resp = env.client.post(f"/api/v3/plugins/config?plugin_id={PLUGIN_ID}",
|
||||||
|
data={"city": "Nice"})
|
||||||
|
assert resp.status_code == 200, resp.get_json()
|
||||||
|
assert env.stored()["city"] == "Nice"
|
||||||
|
self._assert_dropped(env.stored())
|
||||||
|
|
||||||
|
def test_config_main_accepts_and_drops_them(self, env):
|
||||||
|
resp = env.client.post("/api/v3/config/main", json={PLUGIN_ID: {"city": "Nice"}})
|
||||||
|
assert resp.status_code == 200, resp.get_json()
|
||||||
|
self._assert_dropped(env.stored())
|
||||||
|
|
||||||
|
def test_submitted_ones_are_not_stored(self, env):
|
||||||
|
env.store({"enabled": True, "city": "Paris", "vegas_width_pct": 60})
|
||||||
|
resp = env.save({"skin": "retro", "skin_options": {"accent": "#ff0000"}})
|
||||||
|
assert resp.status_code == 200, resp.get_json()
|
||||||
|
self._assert_dropped(env.stored())
|
||||||
|
|
||||||
|
def test_get_config_leaves_them_out(self, env):
|
||||||
|
data = env.client.get(f"/api/v3/plugins/config?plugin_id={PLUGIN_ID}").get_json()["data"]
|
||||||
|
assert data["city"] == "Paris"
|
||||||
|
assert "skin" not in data and "skin_options" not in data
|
||||||
|
|
||||||
|
|
||||||
class TestLegacyBooleans:
|
class TestLegacyBooleans:
|
||||||
LEGACY = {"enabled": True, "city": "Paris", "global": {"dynamic_duration": True}}
|
LEGACY = {"enabled": True, "city": "Paris", "global": {"dynamic_duration": True}}
|
||||||
|
|
||||||
@@ -246,7 +286,7 @@ class TestPluginSectionsInConfigMain:
|
|||||||
assert stored["city"] == "Lyon"
|
assert stored["city"] == "Lyon"
|
||||||
assert stored["display"] == {"brightness": 80, "show_icons": False}
|
assert stored["display"] == {"brightness": 80, "show_icons": False}
|
||||||
assert stored["vegas_overflow"] == "rotate"
|
assert stored["vegas_overflow"] == "rotate"
|
||||||
assert stored["vegas_width_pct"] == 60 and stored["skin"] == "neon"
|
assert stored["vegas_width_pct"] == 60
|
||||||
|
|
||||||
def test_secrets_still_go_to_the_secrets_file(self, env):
|
def test_secrets_still_go_to_the_secrets_file(self, env):
|
||||||
resp = self._post(env, {PLUGIN_ID: {"api_key": "s3cret"}})
|
resp = self._post(env, {PLUGIN_ID: {"api_key": "s3cret"}})
|
||||||
|
|||||||
@@ -969,3 +969,59 @@ class TestPixletEditorHostDefaultsButDoesNotOverride:
|
|||||||
def test_keeps_an_operator_configured_loopback_host(self, client, app_dir, tmp_path):
|
def test_keeps_an_operator_configured_loopback_host(self, client, app_dir, tmp_path):
|
||||||
env = self._start(client, app_dir, tmp_path, operator_host='127.0.0.1')
|
env = self._start(client, app_dir, tmp_path, operator_host='127.0.0.1')
|
||||||
assert env['PIXLET_EDITOR_HOST'] == '127.0.0.1'
|
assert env['PIXLET_EDITOR_HOST'] == '127.0.0.1'
|
||||||
|
|
||||||
|
|
||||||
|
class TestStandaloneRenderUsesTheDeviceLocation:
|
||||||
|
"""The web-service render (plugin not loaded) fills a blank Location field
|
||||||
|
the same way the display plugin does -- see test/test_device_location.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
SCHEMA = {"schema": [{"typeOf": "location", "id": "location"}]}
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app_dir(self, tmp_path, monkeypatch):
|
||||||
|
from web_interface.blueprints import api_v3 as module
|
||||||
|
apps_dir = tmp_path / "starlark-apps"
|
||||||
|
app_dir = apps_dir / "weather"
|
||||||
|
app_dir.mkdir(parents=True)
|
||||||
|
(app_dir / "weather.star").write_text("# app")
|
||||||
|
(app_dir / "schema.json").write_text(json.dumps(self.SCHEMA))
|
||||||
|
monkeypatch.setattr(module, '_STARLARK_APPS_DIR', apps_dir)
|
||||||
|
monkeypatch.setattr(module, '_STARLARK_MANIFEST_FILE', apps_dir / 'manifest.json')
|
||||||
|
(apps_dir / 'manifest.json').write_text(json.dumps(
|
||||||
|
{'apps': {'weather': {'star_file': 'weather.star'}}}))
|
||||||
|
config_manager = MagicMock()
|
||||||
|
config_manager.load_config.return_value = {
|
||||||
|
'timezone': 'America/New_York',
|
||||||
|
'location': {'city': 'Charlotte', 'state': 'North Carolina', 'country': 'US'},
|
||||||
|
}
|
||||||
|
monkeypatch.setattr(module.api_v3, 'config_manager', config_manager, raising=False)
|
||||||
|
monkeypatch.setattr(module, '_find_pixlet_binary', lambda _p=None: '/usr/bin/pixlet')
|
||||||
|
from src.device_location import DeviceLocationResolver
|
||||||
|
geocoder = MagicMock(return_value={'lat': 35.22709, 'lng': -80.84313,
|
||||||
|
'timezone': 'America/New_York'})
|
||||||
|
monkeypatch.setattr(module, '_starlark_device_location',
|
||||||
|
DeviceLocationResolver(None, MagicMock(), geocoder))
|
||||||
|
return app_dir
|
||||||
|
|
||||||
|
def _render_args(self, app_dir):
|
||||||
|
from web_interface.blueprints import api_v3 as module
|
||||||
|
|
||||||
|
def fake_run(cmd, **kwargs):
|
||||||
|
(app_dir / 'cached_render.webp').write_bytes(b'webp')
|
||||||
|
return MagicMock(returncode=0, stderr='')
|
||||||
|
|
||||||
|
with patch.object(module.subprocess, 'run', side_effect=fake_run) as run:
|
||||||
|
ok, status, err = module._standalone_render_starlark_app('weather')
|
||||||
|
assert ok, err
|
||||||
|
return [a for a in run.call_args.args[0] if a.startswith('location=')]
|
||||||
|
|
||||||
|
def test_a_blank_location_renders_at_the_device_city(self, app_dir):
|
||||||
|
(app_dir / 'config.json').write_text(json.dumps({'location': ''}))
|
||||||
|
[arg] = self._render_args(app_dir)
|
||||||
|
assert json.loads(arg[len('location='):])['lat'] == '35.2271'
|
||||||
|
|
||||||
|
def test_a_saved_location_wins(self, app_dir):
|
||||||
|
saved = json.dumps({'lat': '40.6782', 'lng': '-73.9442'})
|
||||||
|
(app_dir / 'config.json').write_text(json.dumps({'location': saved}))
|
||||||
|
assert self._render_args(app_dir) == [f'location={saved}']
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ from src.web_interface.validators import (
|
|||||||
from src.error_aggregator import get_error_aggregator
|
from src.error_aggregator import get_error_aggregator
|
||||||
from src.common.permission_utils import install_requirements_file
|
from src.common.permission_utils import install_requirements_file
|
||||||
from src.common.path_safety import resolve_under
|
from src.common.path_safety import resolve_under
|
||||||
|
from src.device_location import DeviceLocationResolver, apply_device_location
|
||||||
_SUDO = shutil.which('sudo')
|
_SUDO = shutil.which('sudo')
|
||||||
_JOURNALCTL = shutil.which('journalctl')
|
_JOURNALCTL = shutil.which('journalctl')
|
||||||
_GIT = shutil.which('git')
|
_GIT = shutil.which('git')
|
||||||
@@ -1261,9 +1262,8 @@ def _enhance_schema_with_core_properties(schema):
|
|||||||
"""
|
"""
|
||||||
Enhance schema with the core-owned per-plugin properties.
|
Enhance schema with the core-owned per-plugin properties.
|
||||||
|
|
||||||
``enabled``, ``display_duration``, ``live_priority``, ``skin``,
|
``enabled``, ``display_duration``, ``live_priority`` and the ``vegas_*``
|
||||||
``skin_options`` and the ``vegas_*`` tuning keys are system-managed and
|
tuning keys are system-managed and always allowed, even when the plugin's schema doesn't declare them. The
|
||||||
always allowed, even when the plugin's schema doesn't declare them. The
|
|
||||||
list is ``schema_manager.CORE_PLUGIN_PROPERTIES``, the one validation uses,
|
list is ``schema_manager.CORE_PLUGIN_PROPERTIES``, the one validation uses,
|
||||||
so the save filter keeps exactly what validation accepts.
|
so the save filter keeps exactly what validation accepts.
|
||||||
|
|
||||||
@@ -1704,6 +1704,32 @@ def _validate_starlark_app_path(app_id: str) -> Tuple[Optional[Path], Optional[s
|
|||||||
except OSError as e:
|
except OSError as e:
|
||||||
logger.warning("Path validation error for app_id %r: %s", app_id, e)
|
logger.warning("Path validation error for app_id %r: %s", app_id, e)
|
||||||
return None, "Invalid app_id"
|
return None, "Invalid app_id"
|
||||||
|
_starlark_device_location: Optional[DeviceLocationResolver] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_starlark_device_location() -> DeviceLocationResolver:
|
||||||
|
"""One resolver per process, so a geocode failure's backoff is shared."""
|
||||||
|
global _starlark_device_location
|
||||||
|
if _starlark_device_location is None:
|
||||||
|
_starlark_device_location = DeviceLocationResolver(
|
||||||
|
getattr(api_v3, 'cache_manager', None) or _ensure_cache_manager(), logger)
|
||||||
|
return _starlark_device_location
|
||||||
|
|
||||||
|
|
||||||
|
def _read_starlark_schema(app_dir: Path) -> Optional[Dict[str, Any]]:
|
||||||
|
"""An installed app's schema.json, or None if it has none or it's unreadable."""
|
||||||
|
schema_file = app_dir / 'schema.json'
|
||||||
|
if not schema_file.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
with open(schema_file) as f:
|
||||||
|
schema = json.load(f)
|
||||||
|
except (OSError, json.JSONDecodeError) as e:
|
||||||
|
logger.warning("Could not read schema.json at %s: %s", schema_file, e)
|
||||||
|
return None
|
||||||
|
return schema if isinstance(schema, dict) else None
|
||||||
|
|
||||||
|
|
||||||
def _standalone_render_starlark_app(app_id: str) -> Tuple[bool, int, Optional[str]]:
|
def _standalone_render_starlark_app(app_id: str) -> Tuple[bool, int, Optional[str]]:
|
||||||
"""Render a Starlark app via pixlet directly (no plugin required).
|
"""Render a Starlark app via pixlet directly (no plugin required).
|
||||||
|
|
||||||
@@ -1775,6 +1801,9 @@ def _standalone_render_starlark_app(app_id: str) -> Tuple[bool, int, Optional[st
|
|||||||
|
|
||||||
INTERNAL_KEYS = {'render_interval', 'display_duration'}
|
INTERNAL_KEYS = {'render_interval', 'display_duration'}
|
||||||
pixlet_config = {k: v for k, v in app_config.items() if k not in INTERNAL_KEYS}
|
pixlet_config = {k: v for k, v in app_config.items() if k not in INTERNAL_KEYS}
|
||||||
|
pixlet_config = apply_device_location(
|
||||||
|
pixlet_config, _read_starlark_schema(app_dir),
|
||||||
|
_get_starlark_device_location(), full_config)
|
||||||
|
|
||||||
output_path = str(app_dir / 'cached_render.webp')
|
output_path = str(app_dir / 'cached_render.webp')
|
||||||
cmd = [pixlet_path, 'render', str(star_file)]
|
cmd = [pixlet_path, 'render', str(star_file)]
|
||||||
|
|||||||
@@ -1056,10 +1056,14 @@ def save_main_config():
|
|||||||
if error:
|
if error:
|
||||||
return error
|
return error
|
||||||
|
|
||||||
# Deep merge regular config into main config
|
# Deep merge regular config into main config, dropping
|
||||||
|
# retired core keys (skin, skin_options) from the stored section
|
||||||
|
from src.plugin_system.schema_manager import drop_retired_plugin_keys
|
||||||
stored_section = current_config.get(plugin_id)
|
stored_section = current_config.get(plugin_id)
|
||||||
current_config[plugin_id] = deep_merge(
|
current_config[plugin_id] = deep_merge(
|
||||||
stored_section if isinstance(stored_section, dict) else {}, regular_config)
|
drop_retired_plugin_keys(
|
||||||
|
stored_section if isinstance(stored_section, dict) else {}, schema),
|
||||||
|
regular_config)
|
||||||
if secrets_config:
|
if secrets_config:
|
||||||
plugin_secrets_updates[plugin_id] = secrets_config
|
plugin_secrets_updates[plugin_id] = secrets_config
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,46 @@ from web_interface.blueprints.api_v3 import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _catalog_response(catalog):
|
||||||
|
"""The catalog response, with each font's ``used_by`` merged in now.
|
||||||
|
|
||||||
|
Usage comes from the display service (src/font_usage.py) and changes
|
||||||
|
independently of the files, so it is read per request and never stored
|
||||||
|
in the 5-minute ``fonts_catalog`` cache: entries are copied, not edited.
|
||||||
|
``used_by`` is a list of plugin ids, empty when no loaded plugin
|
||||||
|
registered the font, and None when the display service has not reported.
|
||||||
|
"""
|
||||||
|
from src.font_usage import read_font_usage
|
||||||
|
from web_interface.blueprints.api_v3.display import _cache_manager
|
||||||
|
try:
|
||||||
|
usage = read_font_usage(_cache_manager())
|
||||||
|
except Exception:
|
||||||
|
logger.debug("[FontCatalog] Could not read font usage", exc_info=True)
|
||||||
|
usage = None
|
||||||
|
|
||||||
|
used_by = {}
|
||||||
|
if usage is not None:
|
||||||
|
# The snapshot keys fonts by file stem, as the catalog does; match
|
||||||
|
# case-insensitively too, since FontManager lower-cases families.
|
||||||
|
by_lower = {key.lower(): key for key in catalog}
|
||||||
|
for key, plugin_ids in usage['fonts'].items():
|
||||||
|
row = key if key in catalog else by_lower.get(key.lower())
|
||||||
|
if row is not None:
|
||||||
|
used_by.setdefault(row, set()).update(plugin_ids)
|
||||||
|
|
||||||
|
merged = {
|
||||||
|
key: dict(info, used_by=(sorted(used_by.get(key, ())) if usage is not None else None))
|
||||||
|
for key, info in catalog.items()
|
||||||
|
}
|
||||||
|
return jsonify({'status': 'success', 'data': {
|
||||||
|
'catalog': merged,
|
||||||
|
'font_usage': {
|
||||||
|
'available': usage is not None,
|
||||||
|
'generated_at': usage['generated_at'] if usage is not None else None,
|
||||||
|
},
|
||||||
|
}})
|
||||||
|
|
||||||
|
|
||||||
@api_v3.route('/fonts/catalog', methods=['GET'])
|
@api_v3.route('/fonts/catalog', methods=['GET'])
|
||||||
def get_fonts_catalog():
|
def get_fonts_catalog():
|
||||||
"""Get fonts catalog"""
|
"""Get fonts catalog"""
|
||||||
@@ -18,7 +58,7 @@ def get_fonts_catalog():
|
|||||||
from web_interface.cache import get_cached, set_cached
|
from web_interface.cache import get_cached, set_cached
|
||||||
cached_result = get_cached('fonts_catalog', ttl_seconds=300)
|
cached_result = get_cached('fonts_catalog', ttl_seconds=300)
|
||||||
if cached_result is not None:
|
if cached_result is not None:
|
||||||
return jsonify({'status': 'success', 'data': {'catalog': cached_result}})
|
return _catalog_response(cached_result)
|
||||||
except ImportError:
|
except ImportError:
|
||||||
# Cache not available, continue without caching
|
# Cache not available, continue without caching
|
||||||
get_cached = None
|
get_cached = None
|
||||||
@@ -116,7 +156,7 @@ def get_fonts_catalog():
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.error("[FontCatalog] Failed to cache fonts_catalog", exc_info=True)
|
logger.error("[FontCatalog] Failed to cache fonts_catalog", exc_info=True)
|
||||||
|
|
||||||
return jsonify({'status': 'success', 'data': {'catalog': catalog}})
|
return _catalog_response(catalog)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("%s failed", request.path, exc_info=True)
|
logger.error("%s failed", request.path, exc_info=True)
|
||||||
return jsonify({'status': 'error',
|
return jsonify({'status': 'error',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Routes with no larger group of their own: errors, integrations,
|
"""Routes with no larger group of their own: errors, integrations,
|
||||||
cache, sync, skins, logs, health and hardware.
|
cache, sync, logs, health and hardware.
|
||||||
|
|
||||||
Routes decorate the shared `api_v3` Blueprint from ._common, so their
|
Routes decorate the shared `api_v3` Blueprint from ._common, so their
|
||||||
endpoint names are unchanged by living here.
|
endpoint names are unchanged by living here.
|
||||||
@@ -10,10 +10,11 @@ from web_interface.blueprints.api_v3 import (
|
|||||||
_MQTT_BRIDGE_DIR, _SUDO, _coerce_mqtt_bridge_value,
|
_MQTT_BRIDGE_DIR, _SUDO, _coerce_mqtt_bridge_value,
|
||||||
_get_display_service_status, _mqtt_bridge_service_state,
|
_get_display_service_status, _mqtt_bridge_service_state,
|
||||||
_read_mqtt_bridge_config, api_v3, contextlib, describe_exception,
|
_read_mqtt_bridge_config, api_v3, contextlib, describe_exception,
|
||||||
error_response, get_error_aggregator, json, jsonify, logger, os, request,
|
error_response, json, jsonify, logger, os, redact_text, request,
|
||||||
subprocess, success_response, tempfile,
|
subprocess, success_response, tempfile,
|
||||||
)
|
)
|
||||||
from src.common.path_safety import safe_path_component
|
from src.common.path_safety import safe_path_component
|
||||||
|
from src import error_aggregator as _errors
|
||||||
import web_interface.blueprints.api_v3 as _pkg
|
import web_interface.blueprints.api_v3 as _pkg
|
||||||
# Read through the module rather than bound by value: tests patch these
|
# Read through the module rather than bound by value: tests patch these
|
||||||
# as module attributes, and a value binding would not see the patch.
|
# as module attributes, and a value binding would not see the patch.
|
||||||
@@ -149,51 +150,6 @@ def get_hardware_status():
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.error("Unexpected error reading hardware status", exc_info=True)
|
logger.error("Unexpected error reading hardware status", exc_info=True)
|
||||||
return jsonify({"status": "error", "message": "Unable to read hardware status"}), 500
|
return jsonify({"status": "error", "message": "Unable to read hardware status"}), 500
|
||||||
@api_v3.route('/skins', methods=['GET'])
|
|
||||||
def list_skins():
|
|
||||||
"""List installed visual skins (docs/SKIN_SYSTEM.md).
|
|
||||||
|
|
||||||
Optional ?plugin_id=... filters to skins matching that plugin.
|
|
||||||
|
|
||||||
The response carries ``supported: false`` and a ``message``: the current
|
|
||||||
scoreboard plugins don't render skins, so a client must not present
|
|
||||||
these as selectable.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
from src.skin_system import (
|
|
||||||
SKINS_RENDER_SUPPORTED, SKINS_UNSUPPORTED_MESSAGE, skin_runtime,
|
|
||||||
)
|
|
||||||
|
|
||||||
plugin_id = request.args.get('plugin_id')
|
|
||||||
if plugin_id:
|
|
||||||
skins = skin_runtime.skins_for_plugin(plugin_id)
|
|
||||||
else:
|
|
||||||
# The discovery cache self-invalidates on directory/manifest
|
|
||||||
# mtime changes, so no force_refresh — keeps Pi disk I/O down.
|
|
||||||
skins = skin_runtime.discover_skins()
|
|
||||||
|
|
||||||
payload = []
|
|
||||||
for skin_id, manifest in sorted(skins.items()):
|
|
||||||
skin_dir = Path(manifest['_skin_dir'])
|
|
||||||
preview = manifest.get('preview')
|
|
||||||
payload.append({
|
|
||||||
'id': skin_id,
|
|
||||||
'name': manifest.get('name', skin_id),
|
|
||||||
'version': manifest.get('version'),
|
|
||||||
'author': manifest.get('author'),
|
|
||||||
'description': manifest.get('description', ''),
|
|
||||||
'skin_api_version': manifest.get('skin_api_version'),
|
|
||||||
'targets': manifest.get('targets', {}),
|
|
||||||
'modes': manifest.get('modes', []),
|
|
||||||
'has_preview': bool(preview and (skin_dir / preview).is_file()),
|
|
||||||
})
|
|
||||||
data = {'skins': payload, 'supported': SKINS_RENDER_SUPPORTED}
|
|
||||||
if not SKINS_RENDER_SUPPORTED:
|
|
||||||
data['message'] = SKINS_UNSUPPORTED_MESSAGE
|
|
||||||
return jsonify({'status': 'success', 'data': data})
|
|
||||||
except Exception as e:
|
|
||||||
logger.error('Error in list_skins', exc_info=True)
|
|
||||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
|
||||||
@api_v3.route('/logs', methods=['GET'])
|
@api_v3.route('/logs', methods=['GET'])
|
||||||
def get_logs():
|
def get_logs():
|
||||||
"""Get system logs from journalctl"""
|
"""Get system logs from journalctl"""
|
||||||
@@ -326,17 +282,62 @@ def delete_cache_file():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error('Error in delete_cache_file', exc_info=True)
|
logger.error('Error in delete_cache_file', exc_info=True)
|
||||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||||
|
def _errors_cache():
|
||||||
|
"""The shared cache the display service publishes its errors to."""
|
||||||
|
if not api_v3.cache_manager:
|
||||||
|
from src.cache_manager import CacheManager
|
||||||
|
api_v3.cache_manager = CacheManager()
|
||||||
|
return api_v3.cache_manager
|
||||||
|
|
||||||
|
|
||||||
|
def _redact_error_text(text, keep_lines=False):
|
||||||
|
"""Credentials out of plugin exception text, which can quote a URL with
|
||||||
|
an API key in it. Stack traces keep their line breaks and indentation."""
|
||||||
|
if not isinstance(text, str):
|
||||||
|
return text
|
||||||
|
if not keep_lines:
|
||||||
|
return redact_text(text, max_length=len(text) + 1)
|
||||||
|
return '\n'.join(
|
||||||
|
line[:len(line) - len(line.lstrip())] + redact_text(line, max_length=len(line) + 1)
|
||||||
|
for line in text.splitlines()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _redact_error_record(record):
|
||||||
|
if not isinstance(record, dict):
|
||||||
|
return record
|
||||||
|
record = dict(record)
|
||||||
|
record['message'] = _redact_error_text(record.get('message'))
|
||||||
|
record['stack_trace'] = _redact_error_text(record.get('stack_trace'), keep_lines=True)
|
||||||
|
if isinstance(record.get('context'), dict):
|
||||||
|
record['context'] = {k: _redact_error_text(v) for k, v in record['context'].items()}
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
def _read_errors():
|
||||||
|
snapshot, clear_request = _errors.read_error_report(_errors_cache())
|
||||||
|
return snapshot, clear_request
|
||||||
|
|
||||||
|
|
||||||
@api_v3.route('/errors/summary', methods=['GET'])
|
@api_v3.route('/errors/summary', methods=['GET'])
|
||||||
def get_error_summary():
|
def get_error_summary():
|
||||||
"""
|
"""
|
||||||
Get summary of all errors for monitoring and debugging.
|
Get summary of all errors for monitoring and debugging.
|
||||||
|
|
||||||
Returns error counts, detected patterns, and recent errors.
|
Returns error counts, detected patterns, and recent errors, as last
|
||||||
|
reported by the display service (which runs the plugins, so it is the
|
||||||
|
only process that records their errors). ``snapshot_available`` is false
|
||||||
|
until it has reported; ``generated_at`` says when it did.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
aggregator = get_error_aggregator()
|
summary = _errors.error_summary_from_report(*_read_errors())
|
||||||
summary = aggregator.get_error_summary()
|
summary['recent_errors'] = [_redact_error_record(r) for r in summary['recent_errors']]
|
||||||
return success_response(data=summary, message="Error summary retrieved")
|
for pattern in summary['active_patterns'].values():
|
||||||
|
if isinstance(pattern, dict) and isinstance(pattern.get('sample_messages'), list):
|
||||||
|
pattern['sample_messages'] = [_redact_error_text(m) for m in pattern['sample_messages']]
|
||||||
|
message = ("Error summary retrieved" if summary['snapshot_available']
|
||||||
|
else "The display service has not reported any errors yet")
|
||||||
|
return success_response(data=summary, message=message)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting error summary: {e}", exc_info=True)
|
logger.error(f"Error getting error summary: {e}", exc_info=True)
|
||||||
return error_response(
|
return error_response(
|
||||||
@@ -352,11 +353,13 @@ def get_plugin_errors(plugin_id):
|
|||||||
Args:
|
Args:
|
||||||
plugin_id: Plugin identifier
|
plugin_id: Plugin identifier
|
||||||
|
|
||||||
Returns health status and error statistics for the plugin.
|
Returns health status and error statistics for the plugin, from the
|
||||||
|
display service's last report (see get_error_summary). A plugin with no
|
||||||
|
recorded errors is "healthy".
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
aggregator = get_error_aggregator()
|
health = _errors.plugin_health_from_report(*_read_errors(), plugin_id)
|
||||||
health = aggregator.get_plugin_health(plugin_id)
|
health['last_error'] = _redact_error_record(health['last_error'])
|
||||||
return success_response(data=health, message="Plugin health retrieved")
|
return success_response(data=health, message="Plugin health retrieved")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting plugin health for {plugin_id}: {e}", exc_info=True)
|
logger.error(f"Error getting plugin health for {plugin_id}: {e}", exc_info=True)
|
||||||
@@ -372,42 +375,61 @@ def clear_old_errors():
|
|||||||
|
|
||||||
Request body (optional):
|
Request body (optional):
|
||||||
max_age_hours: Maximum age in hours (default: 24, max: 8760 = 1 year)
|
max_age_hours: Maximum age in hours (default: 24, max: 8760 = 1 year)
|
||||||
|
all: true clears every error recorded so far (max_age_hours ignored)
|
||||||
|
|
||||||
|
The errors live in the display service, so this records a clear request
|
||||||
|
that it applies within a few seconds. Reads hide the cleared errors from
|
||||||
|
the moment the request is recorded.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
|
clear_all = _coerce_to_bool(data.get('all'))
|
||||||
raw_max_age = data.get('max_age_hours', 24)
|
raw_max_age = data.get('max_age_hours', 24)
|
||||||
|
|
||||||
# Validate and coerce max_age_hours
|
# Validate and coerce max_age_hours
|
||||||
|
max_age_hours = None
|
||||||
|
if not clear_all:
|
||||||
|
try:
|
||||||
|
max_age_hours = int(raw_max_age)
|
||||||
|
if max_age_hours < 1:
|
||||||
|
return error_response(
|
||||||
|
error_code=ErrorCode.INVALID_INPUT,
|
||||||
|
message="max_age_hours must be at least 1",
|
||||||
|
context={'provided_value': raw_max_age},
|
||||||
|
status_code=400
|
||||||
|
)
|
||||||
|
if max_age_hours > 8760: # 1 year max
|
||||||
|
return error_response(
|
||||||
|
error_code=ErrorCode.INVALID_INPUT,
|
||||||
|
message="max_age_hours cannot exceed 8760 (1 year)",
|
||||||
|
context={'provided_value': raw_max_age},
|
||||||
|
status_code=400
|
||||||
|
)
|
||||||
|
except (ValueError, TypeError, OverflowError):
|
||||||
|
return error_response(
|
||||||
|
error_code=ErrorCode.INVALID_INPUT,
|
||||||
|
message="max_age_hours must be a valid integer",
|
||||||
|
context={'provided_value': str(raw_max_age)},
|
||||||
|
status_code=400
|
||||||
|
)
|
||||||
|
|
||||||
|
now = _pkg.time.time()
|
||||||
|
cutoff = now if clear_all else now - max_age_hours * 3600
|
||||||
try:
|
try:
|
||||||
max_age_hours = int(raw_max_age)
|
result = _errors.request_error_clear(_errors_cache(), cutoff)
|
||||||
if max_age_hours < 1:
|
except OSError as e:
|
||||||
return error_response(
|
logger.error("Could not record an error clear request: %s", e)
|
||||||
error_code=ErrorCode.INVALID_INPUT,
|
|
||||||
message="max_age_hours must be at least 1",
|
|
||||||
context={'provided_value': raw_max_age},
|
|
||||||
status_code=400
|
|
||||||
)
|
|
||||||
if max_age_hours > 8760: # 1 year max
|
|
||||||
return error_response(
|
|
||||||
error_code=ErrorCode.INVALID_INPUT,
|
|
||||||
message="max_age_hours cannot exceed 8760 (1 year)",
|
|
||||||
context={'provided_value': raw_max_age},
|
|
||||||
status_code=400
|
|
||||||
)
|
|
||||||
except (ValueError, TypeError, OverflowError):
|
|
||||||
return error_response(
|
return error_response(
|
||||||
error_code=ErrorCode.INVALID_INPUT,
|
error_code=ErrorCode.SYSTEM_ERROR,
|
||||||
message="max_age_hours must be a valid integer",
|
message="Could not record the clear request in the shared cache",
|
||||||
context={'provided_value': str(raw_max_age)},
|
status_code=500
|
||||||
status_code=400
|
|
||||||
)
|
)
|
||||||
|
|
||||||
aggregator = get_error_aggregator()
|
scope = "all errors" if clear_all else f"errors older than {max_age_hours} hours"
|
||||||
cleared_count = aggregator.clear_old_records(max_age_hours=max_age_hours)
|
|
||||||
|
|
||||||
return success_response(
|
return success_response(
|
||||||
data={'cleared_count': cleared_count},
|
data=result,
|
||||||
message=f"Cleared {cleared_count} error records older than {max_age_hours} hours"
|
message=(f"Clear of {scope} requested; the display service applies it "
|
||||||
|
f"within about {int(_errors.SNAPSHOT_TICK_INTERVAL)} seconds")
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error clearing old errors: {e}", exc_info=True)
|
logger.error(f"Error clearing old errors: {e}", exc_info=True)
|
||||||
|
|||||||
@@ -1296,15 +1296,16 @@ def install_plugin():
|
|||||||
plugin_id = data['plugin_id']
|
plugin_id = data['plugin_id']
|
||||||
branch = data.get('branch') # Optional branch parameter
|
branch = data.get('branch') # Optional branch parameter
|
||||||
|
|
||||||
# A registry skin would install but never render with the current
|
# A registry entry that isn't a plugin (a custom registry can still
|
||||||
# scoreboards; refuse it with the reason rather than a generic failure
|
# list old "type": "skin" entries) gets a clear refusal, not a failed
|
||||||
|
# install.
|
||||||
try:
|
try:
|
||||||
registry_entry = api_v3.plugin_store_manager.get_registry_info(plugin_id)
|
registry_entry = api_v3.plugin_store_manager.get_registry_info(plugin_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
registry_entry = None
|
registry_entry = None
|
||||||
if isinstance(registry_entry, dict) and (registry_entry.get('type') or 'plugin') == 'skin':
|
if isinstance(registry_entry, dict) and not api_v3.plugin_store_manager.is_plugin_entry(registry_entry):
|
||||||
from src.skin_system import SKINS_UNSUPPORTED_MESSAGE
|
return jsonify({'status': 'error',
|
||||||
return jsonify({'status': 'error', 'message': SKINS_UNSUPPORTED_MESSAGE}), 400
|
'message': f"{plugin_id} is a {registry_entry.get('type')!r} entry, not a plugin"}), 400
|
||||||
|
|
||||||
# Install the plugin
|
# Install the plugin
|
||||||
# Log the plugins directory being used for debugging
|
# Log the plugins directory being used for debugging
|
||||||
@@ -1506,13 +1507,10 @@ def get_registry_from_url():
|
|||||||
registry = api_v3.plugin_store_manager.fetch_registry_from_url(repo_url)
|
registry = api_v3.plugin_store_manager.fetch_registry_from_url(repo_url)
|
||||||
|
|
||||||
if registry:
|
if registry:
|
||||||
# Skins aren't offered: current scoreboards don't render them
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
'plugins': [
|
'plugins': [p for p in registry.get('plugins', [])
|
||||||
p for p in registry.get('plugins', [])
|
if api_v3.plugin_store_manager.is_plugin_entry(p)],
|
||||||
if not (isinstance(p, dict) and (p.get('type') or 'plugin') == 'skin')
|
|
||||||
],
|
|
||||||
'registry_url': repo_url
|
'registry_url': repo_url
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
@@ -1627,9 +1625,7 @@ def list_plugin_store():
|
|||||||
# Format plugins for the web interface
|
# Format plugins for the web interface
|
||||||
formatted_plugins = []
|
formatted_plugins = []
|
||||||
for plugin in plugins:
|
for plugin in plugins:
|
||||||
# Registry skins install but never render with the current
|
if not api_v3.plugin_store_manager.is_plugin_entry(plugin):
|
||||||
# scoreboards, so the store doesn't offer them
|
|
||||||
if (plugin.get('type') or 'plugin') == 'skin':
|
|
||||||
continue
|
continue
|
||||||
formatted_plugins.append({
|
formatted_plugins.append({
|
||||||
'id': plugin.get('id'),
|
'id': plugin.get('id'),
|
||||||
@@ -2210,7 +2206,10 @@ def save_plugin_config():
|
|||||||
if plugin_id not in current_config:
|
if plugin_id not in current_config:
|
||||||
current_config[plugin_id] = {}
|
current_config[plugin_id] = {}
|
||||||
|
|
||||||
current_config[plugin_id] = deep_merge(current_config[plugin_id], regular_config)
|
# Retired core keys (skin, skin_options) leave the stored section here
|
||||||
|
from src.plugin_system.schema_manager import drop_retired_plugin_keys
|
||||||
|
current_config[plugin_id] = deep_merge(
|
||||||
|
drop_retired_plugin_keys(current_config[plugin_id], schema), regular_config)
|
||||||
|
|
||||||
# Deep merge plugin secrets in secrets config
|
# Deep merge plugin secrets in secrets config
|
||||||
if secrets_config:
|
if secrets_config:
|
||||||
@@ -2755,11 +2754,6 @@ def get_plugin_schema():
|
|||||||
schema = schema_mgr.load_schema(plugin_id, use_cache=True)
|
schema = schema_mgr.load_schema(plugin_id, use_cache=True)
|
||||||
|
|
||||||
if schema:
|
if schema:
|
||||||
# No "Visual Skin" dropdown: the current scoreboard plugins don't
|
|
||||||
# render skins (src.skin_system.SKINS_UNSUPPORTED_MESSAGE), so
|
|
||||||
# schema_mgr.inject_skin_selector is deliberately not called. A
|
|
||||||
# stored "skin" value is unaffected: validation still allows it
|
|
||||||
# and a form save deep-merges over the stored section, keeping it.
|
|
||||||
return jsonify({'status': 'success', 'data': {'schema': schema}})
|
return jsonify({'status': 'success', 'data': {'schema': schema}})
|
||||||
|
|
||||||
# Return a simple default schema if file not found
|
# Return a simple default schema if file not found
|
||||||
|
|||||||
@@ -26,6 +26,36 @@ import web_interface.blueprints.api_v3 as _pkg
|
|||||||
# package is the only patch point that covers every caller.
|
# package is the only patch point that covers every caller.
|
||||||
|
|
||||||
|
|
||||||
|
def _ownership_hint(err: BaseException):
|
||||||
|
"""An actionable message when the apps directory is not writable.
|
||||||
|
|
||||||
|
The display service runs as root and the web interface as the login user
|
||||||
|
(see systemd/ledmatrix.service and systemd/ledmatrix-web.service). The
|
||||||
|
starlark-apps directory is not in the repository, so whichever service
|
||||||
|
reaches it first creates it -- and when that is the display service, the
|
||||||
|
web user cannot write into it and every install fails.
|
||||||
|
|
||||||
|
The plugin now hands the directory back on startup, so this should not be
|
||||||
|
reachable. It is kept because the failure is otherwise invisible: the
|
||||||
|
generic message names no path and no cause, and the one user who hit it
|
||||||
|
had to read the service logs to find it. If the handover is ever prevented
|
||||||
|
-- an exotic mount, a directory root-owned for another reason -- this says
|
||||||
|
what to do instead of costing somebody an evening.
|
||||||
|
|
||||||
|
Returns None when `err` is not a permission problem.
|
||||||
|
"""
|
||||||
|
if not isinstance(err, PermissionError):
|
||||||
|
return None
|
||||||
|
return (
|
||||||
|
f"Cannot write to {_STARLARK_APPS_DIR}. It is owned by another user "
|
||||||
|
f"-- usually because the display service, which runs as root, created "
|
||||||
|
f"it before the web interface did. Restarting the display service "
|
||||||
|
f"(sudo systemctl restart ledmatrix) repairs the ownership "
|
||||||
|
f"automatically. To fix it by hand: "
|
||||||
|
f"sudo chown -R $USER:$USER {_STARLARK_APPS_DIR}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@api_v3.route('/starlark/status', methods=['GET'])
|
@api_v3.route('/starlark/status', methods=['GET'])
|
||||||
def get_starlark_status():
|
def get_starlark_status():
|
||||||
"""Get Starlark plugin status and Pixlet availability."""
|
"""Get Starlark plugin status and Pixlet availability."""
|
||||||
@@ -278,7 +308,8 @@ def upload_starlark_app():
|
|||||||
# without it, though, and describe_exception redacts credentials and
|
# without it, though, and describe_exception redacts credentials and
|
||||||
# truncates -- the same trade-off every other handler here makes.
|
# truncates -- the same trade-off every other handler here makes.
|
||||||
logger.exception("[Starlark] File error uploading starlark app: %s", err)
|
logger.exception("[Starlark] File error uploading starlark app: %s", err)
|
||||||
return jsonify({'status': 'error', 'message': 'File error during upload',
|
return jsonify({'status': 'error',
|
||||||
|
'message': _ownership_hint(err) or 'File error during upload',
|
||||||
'details': describe_exception(err)}), 500
|
'details': describe_exception(err)}), 500
|
||||||
except ImportError as err:
|
except ImportError as err:
|
||||||
logger.exception("[Starlark] Module load error uploading starlark app: %s", err)
|
logger.exception("[Starlark] Module load error uploading starlark app: %s", err)
|
||||||
@@ -702,6 +733,16 @@ def install_from_tronbyte_repository():
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("[Starlark] install_from_tronbyte_repository failed")
|
logger.exception("[Starlark] install_from_tronbyte_repository failed")
|
||||||
|
hint = _ownership_hint(e)
|
||||||
|
if hint:
|
||||||
|
# `details` is kept deliberately. CodeQL flags it as information
|
||||||
|
# exposure, but this package's rule is "if it returns 5xx, it says
|
||||||
|
# why" -- enforced by test_no_api_v3_handler_discards_its_exception,
|
||||||
|
# whose PRE_EXISTING allowance may shrink and never grow. The
|
||||||
|
# Starlark routes are exactly the ones that policy was written for:
|
||||||
|
# they answered 500 with no detail for three releases. It stays.
|
||||||
|
return jsonify({'status': 'error', 'message': hint,
|
||||||
|
'details': describe_exception(e)}), 500
|
||||||
return jsonify({'status': 'error', 'message': 'Failed to install from repository', 'details': describe_exception(e)}), 500
|
return jsonify({'status': 'error', 'message': 'Failed to install from repository', 'details': describe_exception(e)}), 500
|
||||||
@api_v3.route('/starlark/repository/categories', methods=['GET'])
|
@api_v3.route('/starlark/repository/categories', methods=['GET'])
|
||||||
def get_tronbyte_categories():
|
def get_tronbyte_categories():
|
||||||
|
|||||||
@@ -9,10 +9,11 @@
|
|||||||
<!-- Available Fonts -->
|
<!-- Available Fonts -->
|
||||||
<div class="bg-gray-50 rounded-lg p-4">
|
<div class="bg-gray-50 rounded-lg p-4">
|
||||||
<h3 class="text-md font-medium text-gray-900 mb-3">Available Font Families</h3>
|
<h3 class="text-md font-medium text-gray-900 mb-3">Available Font Families</h3>
|
||||||
<div id="available-fonts" class="bg-gray-800 text-gray-100 font-mono text-sm p-3 rounded h-40 overflow-y-auto">
|
<div id="available-fonts" class="bg-gray-800 text-gray-100 font-mono text-sm p-3 rounded h-64 overflow-y-auto">
|
||||||
<div class="text-gray-400">Loading...</div>
|
<div class="text-gray-400">Loading...</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-gray-600 mt-2">All available font families in the system</p>
|
<p class="text-sm text-gray-600 mt-2">All available font families in the system.
|
||||||
|
<span id="font-usage-note"></span></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -299,8 +300,11 @@ async function loadFontData() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Use absolute URLs to ensure they work when loaded via HTMX
|
// Use absolute URLs to ensure they work when loaded via HTMX
|
||||||
|
// no-store: /api/v3 GETs are browser-cacheable for 5 s, and this
|
||||||
|
// reload follows an upload or delete (or the usage check before one),
|
||||||
|
// so a cached copy would show the list as it was before the change.
|
||||||
const [catalogRes, tokensRes] = await Promise.all([
|
const [catalogRes, tokensRes] = await Promise.all([
|
||||||
fetch(`/api/v3/fonts/catalog`),
|
fetch(`/api/v3/fonts/catalog`, { cache: 'no-store' }),
|
||||||
fetch(`/api/v3/fonts/tokens`)
|
fetch(`/api/v3/fonts/tokens`)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -325,6 +329,7 @@ async function loadFontData() {
|
|||||||
|
|
||||||
// Update both window properties and local references
|
// Update both window properties and local references
|
||||||
window.fontCatalog = catalogData.data.catalog || {};
|
window.fontCatalog = catalogData.data.catalog || {};
|
||||||
|
window.fontUsage = catalogData.data.font_usage || { available: false, generated_at: null };
|
||||||
window.fontTokens = tokensData.data.tokens || {};
|
window.fontTokens = tokensData.data.tokens || {};
|
||||||
|
|
||||||
// Update local variable references
|
// Update local variable references
|
||||||
@@ -355,10 +360,29 @@ async function loadFontData() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The plugin ids that use a font, as the catalog reports them: an array
|
||||||
|
// (possibly empty) when the display service has reported, null when not.
|
||||||
|
function fontUsedBy(fontInfo) {
|
||||||
|
if (!fontInfo || typeof fontInfo !== 'object') return null;
|
||||||
|
return Array.isArray(fontInfo.used_by)
|
||||||
|
? fontInfo.used_by.filter(id => typeof id === 'string')
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
function updateAvailableFontsDisplay() {
|
function updateAvailableFontsDisplay() {
|
||||||
const container = document.getElementById('available-fonts');
|
const container = document.getElementById('available-fonts');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
|
const usage = window.fontUsage || {};
|
||||||
|
const note = document.getElementById('font-usage-note');
|
||||||
|
if (note) {
|
||||||
|
const reported = usage.generated_at
|
||||||
|
? ` (reported ${String(usage.generated_at).replace('T', ' ')})` : '';
|
||||||
|
note.textContent = usage.available
|
||||||
|
? `"Used by" lists the running plugins that registered the font with the display service${reported}. A plugin that opens a font file directly is not listed.`
|
||||||
|
: '"Used by" is unknown: the display service has not reported which fonts its plugins use. Start it, or reload this tab a few seconds after it starts.';
|
||||||
|
}
|
||||||
|
|
||||||
if (Object.keys(fontCatalog).length === 0) {
|
if (Object.keys(fontCatalog).length === 0) {
|
||||||
container.innerHTML = '<div class="text-gray-400">No fonts available</div>';
|
container.innerHTML = '<div class="text-gray-400">No fonts available</div>';
|
||||||
return;
|
return;
|
||||||
@@ -371,18 +395,34 @@ function updateAvailableFontsDisplay() {
|
|||||||
const fontType = typeof fontInfo === 'object' ? (fontInfo.type || '').toUpperCase() : '';
|
const fontType = typeof fontInfo === 'object' ? (fontInfo.type || '').toUpperCase() : '';
|
||||||
// Use is_system flag from API (single source of truth)
|
// Use is_system flag from API (single source of truth)
|
||||||
const isSystem = typeof fontInfo === 'object' ? (fontInfo.is_system === true) : false;
|
const isSystem = typeof fontInfo === 'object' ? (fontInfo.is_system === true) : false;
|
||||||
return { name, filename, displayName, fontType, fontPath, isSystem };
|
const usedBy = fontUsedBy(fontInfo);
|
||||||
|
return { name, filename, displayName, fontType, fontPath, isSystem, usedBy };
|
||||||
}).sort((a, b) => a.displayName.localeCompare(b.displayName));
|
}).sort((a, b) => a.displayName.localeCompare(b.displayName));
|
||||||
|
|
||||||
// Build list using DOM APIs to prevent XSS
|
// Build list using DOM APIs to prevent XSS (plugin ids are untrusted too)
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'flex items-center gap-2 pb-1 mb-1 border-b border-gray-600 text-xs uppercase text-gray-400';
|
||||||
|
const fontHead = document.createElement('span');
|
||||||
|
fontHead.className = 'flex-1 min-w-0';
|
||||||
|
fontHead.textContent = 'Font';
|
||||||
|
const usedHead = document.createElement('span');
|
||||||
|
usedHead.className = 'flex-1 min-w-0';
|
||||||
|
usedHead.textContent = 'Used by';
|
||||||
|
const actionHead = document.createElement('span');
|
||||||
|
actionHead.className = 'w-24 flex-shrink-0';
|
||||||
|
header.append(fontHead, usedHead, actionHead);
|
||||||
|
container.appendChild(header);
|
||||||
|
|
||||||
fontEntries.forEach(font => {
|
fontEntries.forEach(font => {
|
||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'flex items-center justify-between py-1 border-b border-gray-700 last:border-0';
|
row.className = 'flex items-start gap-2 py-1 border-b border-gray-700 last:border-0';
|
||||||
|
|
||||||
const nameSpan = document.createElement('span');
|
const nameSpan = document.createElement('span');
|
||||||
nameSpan.className = 'truncate flex-1';
|
nameSpan.className = 'truncate flex-1 min-w-0';
|
||||||
nameSpan.textContent = font.displayName;
|
nameSpan.textContent = font.displayName;
|
||||||
|
nameSpan.title = font.filename;
|
||||||
|
|
||||||
if (font.fontType) {
|
if (font.fontType) {
|
||||||
const typeSpan = document.createElement('span');
|
const typeSpan = document.createElement('span');
|
||||||
@@ -393,29 +433,83 @@ function updateAvailableFontsDisplay() {
|
|||||||
|
|
||||||
row.appendChild(nameSpan);
|
row.appendChild(nameSpan);
|
||||||
|
|
||||||
|
// Wraps rather than truncates: the list is what a delete is weighed
|
||||||
|
// against, and a title tooltip is out of reach on a phone.
|
||||||
|
const usedSpan = document.createElement('span');
|
||||||
|
usedSpan.className = 'flex-1 min-w-0 break-words';
|
||||||
|
if (font.usedBy === null) {
|
||||||
|
usedSpan.classList.add('text-gray-500', 'italic');
|
||||||
|
usedSpan.textContent = 'unknown';
|
||||||
|
usedSpan.title = 'The display service has not reported which fonts its plugins use';
|
||||||
|
} else if (font.usedBy.length === 0) {
|
||||||
|
usedSpan.classList.add('text-gray-500');
|
||||||
|
usedSpan.textContent = '—';
|
||||||
|
usedSpan.title = 'No running plugin registered this font';
|
||||||
|
} else {
|
||||||
|
usedSpan.classList.add('text-gray-300');
|
||||||
|
usedSpan.textContent = font.usedBy.join(', ');
|
||||||
|
}
|
||||||
|
row.appendChild(usedSpan);
|
||||||
|
|
||||||
|
const actionCell = document.createElement('span');
|
||||||
|
actionCell.className = 'w-24 flex-shrink-0 text-right';
|
||||||
if (font.isSystem) {
|
if (font.isSystem) {
|
||||||
const systemBadge = document.createElement('span');
|
const systemBadge = document.createElement('span');
|
||||||
systemBadge.className = 'text-gray-600 text-xs ml-2';
|
systemBadge.className = 'text-gray-600 text-xs';
|
||||||
systemBadge.textContent = '[system]';
|
systemBadge.textContent = '[system]';
|
||||||
row.appendChild(systemBadge);
|
actionCell.appendChild(systemBadge);
|
||||||
} else {
|
} else {
|
||||||
const deleteBtn = document.createElement('button');
|
const deleteBtn = document.createElement('button');
|
||||||
deleteBtn.className = 'text-red-400 hover:text-red-300 text-xs ml-2';
|
deleteBtn.className = 'text-red-400 hover:text-red-300 text-xs';
|
||||||
deleteBtn.title = 'Delete font';
|
deleteBtn.title = 'Delete font';
|
||||||
deleteBtn.textContent = '[delete]';
|
deleteBtn.textContent = '[delete]';
|
||||||
deleteBtn.dataset.fontName = font.name;
|
deleteBtn.dataset.fontName = font.name;
|
||||||
deleteBtn.addEventListener('click', function() {
|
deleteBtn.addEventListener('click', function() {
|
||||||
deleteFont(this.dataset.fontName);
|
deleteFont(this.dataset.fontName);
|
||||||
});
|
});
|
||||||
row.appendChild(deleteBtn);
|
actionCell.appendChild(deleteBtn);
|
||||||
}
|
}
|
||||||
|
row.appendChild(actionCell);
|
||||||
|
|
||||||
container.appendChild(row);
|
container.appendChild(row);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Which plugins use a font now. Re-reads the catalog so the warning reflects
|
||||||
|
// the display service's latest report rather than the one the tab opened
|
||||||
|
// with; falls back to that one if the request fails.
|
||||||
|
async function currentFontUsedBy(fontName) {
|
||||||
|
const own = (obj) => obj && typeof obj === 'object'
|
||||||
|
&& Object.prototype.hasOwnProperty.call(obj, fontName);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v3/fonts/catalog', { cache: 'no-store' });
|
||||||
|
if (response.ok) {
|
||||||
|
const catalog = (await response.json())?.data?.catalog;
|
||||||
|
if (own(catalog)) return fontUsedBy(catalog[fontName]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Could not refresh font usage before delete:', error);
|
||||||
|
}
|
||||||
|
return own(fontCatalog) ? fontUsedBy(fontCatalog[fontName]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The confirm() text for deleting a font. Plain text: confirm() renders no
|
||||||
|
// HTML, so a plugin id cannot inject markup here.
|
||||||
|
function deleteFontConfirmMessage(fontFamily, usedBy) {
|
||||||
|
if (Array.isArray(usedBy) && usedBy.length > 0) {
|
||||||
|
return `Font "${fontFamily}" is in use.\n\nUsed by: ${usedBy.join(', ')}.\n\n`
|
||||||
|
+ 'Delete anyway? This action cannot be undone.';
|
||||||
|
}
|
||||||
|
const base = `Are you sure you want to delete the font "${fontFamily}"? This action cannot be undone.`;
|
||||||
|
if (usedBy === null) {
|
||||||
|
return `${base}\n\nWhich plugins use it is unknown: the display service has not reported.`;
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteFont(fontFamily) {
|
async function deleteFont(fontFamily) {
|
||||||
if (!confirm(`Are you sure you want to delete the font "${fontFamily}"? This action cannot be undone.`)) {
|
const usedBy = await currentFontUsedBy(fontFamily);
|
||||||
|
if (!confirm(deleteFontConfirmMessage(fontFamily, usedBy))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -104,6 +104,25 @@
|
|||||||
<div class="w-2 h-2 bg-green-500 rounded-full"></div>
|
<div class="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||||
<span>Connected to log stream</span>
|
<span>Connected to log stream</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Plugin errors, as recorded by the display service -->
|
||||||
|
<div id="plugin-errors-panel" class="mt-6 border-t border-gray-200 pt-4">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2 mb-3">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-base font-semibold text-gray-900">Plugin errors</h3>
|
||||||
|
<p id="plugin-errors-meta" class="text-xs text-gray-600">Recorded by the display service</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button id="plugin-errors-refresh-btn" type="button" class="btn bg-gray-600 hover:bg-gray-700 text-white px-3 py-1 rounded text-sm">
|
||||||
|
<i class="fas fa-sync-alt mr-1"></i>Refresh
|
||||||
|
</button>
|
||||||
|
<button id="plugin-errors-clear-btn" type="button" class="btn bg-red-600 hover:bg-red-700 text-white px-3 py-1 rounded text-sm">
|
||||||
|
<i class="fas fa-eraser mr-1"></i>Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="plugin-errors-body" class="text-sm text-gray-600" aria-live="polite">Loading...</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -218,10 +237,18 @@ window._filteredLogs = [];
|
|||||||
// Current-plugin poll and the realtime log stream run only while the Logs
|
// Current-plugin poll and the realtime log stream run only while the Logs
|
||||||
// tab is active and the page is visible (both restart with an immediate
|
// tab is active and the page is visible (both restart with an immediate
|
||||||
// refresh). Re-registering on partial reload replaces the old one.
|
// refresh). Re-registering on partial reload replaces the old one.
|
||||||
|
const pluginErrorsRefreshBtn = document.getElementById('plugin-errors-refresh-btn');
|
||||||
|
if (pluginErrorsRefreshBtn) pluginErrorsRefreshBtn.onclick = refreshPluginErrors;
|
||||||
|
const pluginErrorsClearBtn = document.getElementById('plugin-errors-clear-btn');
|
||||||
|
if (pluginErrorsClearBtn) pluginErrorsClearBtn.onclick = clearPluginErrors;
|
||||||
|
|
||||||
function startLogsActivity() {
|
function startLogsActivity() {
|
||||||
refreshCurrentPluginStatus();
|
refreshCurrentPluginStatus();
|
||||||
if (window._currentPluginPollTimer) clearInterval(window._currentPluginPollTimer);
|
if (window._currentPluginPollTimer) clearInterval(window._currentPluginPollTimer);
|
||||||
window._currentPluginPollTimer = setInterval(refreshCurrentPluginStatus, 5000);
|
window._currentPluginPollTimer = setInterval(refreshCurrentPluginStatus, 5000);
|
||||||
|
refreshPluginErrors();
|
||||||
|
if (window._pluginErrorsPollTimer) clearInterval(window._pluginErrorsPollTimer);
|
||||||
|
window._pluginErrorsPollTimer = setInterval(refreshPluginErrors, 15000);
|
||||||
if (window._isRealtime && !window._logsEventSource) setupRealtimeLogs();
|
if (window._isRealtime && !window._logsEventSource) setupRealtimeLogs();
|
||||||
}
|
}
|
||||||
function stopLogsActivity() {
|
function stopLogsActivity() {
|
||||||
@@ -229,6 +256,10 @@ window._filteredLogs = [];
|
|||||||
clearInterval(window._currentPluginPollTimer);
|
clearInterval(window._currentPluginPollTimer);
|
||||||
window._currentPluginPollTimer = null;
|
window._currentPluginPollTimer = null;
|
||||||
}
|
}
|
||||||
|
if (window._pluginErrorsPollTimer) {
|
||||||
|
clearInterval(window._pluginErrorsPollTimer);
|
||||||
|
window._pluginErrorsPollTimer = null;
|
||||||
|
}
|
||||||
stopRealtimeLogs();
|
stopRealtimeLogs();
|
||||||
}
|
}
|
||||||
if (window.LEDVisibility) {
|
if (window.LEDVisibility) {
|
||||||
@@ -787,6 +818,120 @@ function refreshCurrentPluginStatus() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Plugin errors panel. The display service records them and publishes a
|
||||||
|
// snapshot every few seconds; /api/v3/errors/summary serves that snapshot.
|
||||||
|
// Plugin ids and messages come from plugins: everything goes through escapeHtml.
|
||||||
|
function formatErrorTime(iso) {
|
||||||
|
return iso ? String(iso).replace('T', ' ').slice(0, 19) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPluginErrors(summary) {
|
||||||
|
const body = document.getElementById('plugin-errors-body');
|
||||||
|
const meta = document.getElementById('plugin-errors-meta');
|
||||||
|
if (!body) return;
|
||||||
|
|
||||||
|
if (meta) {
|
||||||
|
let text = 'Recorded by the display service';
|
||||||
|
if (summary.generated_at) text += ' · updated ' + formatErrorTime(summary.generated_at);
|
||||||
|
if (summary.clear_pending) text += ' · clearing…';
|
||||||
|
meta.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!summary.snapshot_available) {
|
||||||
|
body.innerHTML = '<p class="text-gray-600"><i class="fas fa-hourglass-half mr-1"></i>' +
|
||||||
|
'Display service hasn’t reported yet. Errors appear here once it is running.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const plugins = Object.entries(summary.plugin_error_counts || {}).map(([id, types]) => {
|
||||||
|
const total = Object.values(types || {}).reduce((sum, n) => sum + (Number(n) || 0), 0);
|
||||||
|
return { id, types: types || {}, total };
|
||||||
|
}).sort((a, b) => b.total - a.total);
|
||||||
|
const patterns = Object.values(summary.active_patterns || {});
|
||||||
|
|
||||||
|
if (!Number(summary.total_errors) && plugins.length === 0 && patterns.length === 0) {
|
||||||
|
body.innerHTML = '<p class="text-gray-600"><i class="fas fa-check-circle text-green-600 mr-1"></i>' +
|
||||||
|
'No plugin errors recorded</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
if (plugins.length) {
|
||||||
|
html += '<div class="overflow-x-auto"><table class="min-w-full text-sm">' +
|
||||||
|
'<thead><tr class="text-left text-xs uppercase text-gray-500 border-b border-gray-200">' +
|
||||||
|
'<th class="py-1 pr-4 font-medium">Plugin</th><th class="py-1 pr-4 font-medium">Errors</th>' +
|
||||||
|
'<th class="py-1 font-medium">Types</th></tr></thead><tbody>';
|
||||||
|
plugins.forEach(p => {
|
||||||
|
const types = Object.entries(p.types)
|
||||||
|
.map(([type, n]) => `${escapeHtml(type)} ×${escapeHtml(String(Number(n) || 0))}`)
|
||||||
|
.join(', ');
|
||||||
|
html += '<tr class="border-b border-gray-100">' +
|
||||||
|
`<td class="py-1 pr-4"><code class="bg-gray-100 px-1 rounded">${escapeHtml(p.id)}</code></td>` +
|
||||||
|
`<td class="py-1 pr-4 font-semibold text-gray-900">${escapeHtml(String(p.total))}</td>` +
|
||||||
|
`<td class="py-1 text-gray-700 break-words">${types}</td></tr>`;
|
||||||
|
});
|
||||||
|
html += '</tbody></table></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patterns.length) {
|
||||||
|
const badge = {
|
||||||
|
critical: 'bg-red-100 text-red-800',
|
||||||
|
error: 'bg-amber-100 text-amber-800',
|
||||||
|
warning: 'bg-yellow-100 text-yellow-800'
|
||||||
|
};
|
||||||
|
html += '<h4 class="mt-4 mb-2 text-sm font-semibold text-gray-900">Repeating errors</h4><ul class="list-none pl-0 space-y-2">';
|
||||||
|
patterns.forEach(p => {
|
||||||
|
const severity = String(p.severity || 'warning');
|
||||||
|
const affected = (p.affected_plugins || []).map(id => escapeHtml(id)).join(', ') || 'unknown';
|
||||||
|
const sample = (p.sample_messages || [])[0];
|
||||||
|
html += '<li class="border border-gray-200 rounded-lg px-3 py-2">' +
|
||||||
|
'<div class="flex flex-wrap items-center gap-2">' +
|
||||||
|
`<span class="px-2 py-0.5 rounded text-xs font-semibold ${badge[severity] || badge.warning}">${escapeHtml(severity)}</span>` +
|
||||||
|
`<code class="font-semibold text-gray-900">${escapeHtml(p.error_type || '')}</code>` +
|
||||||
|
`<span class="text-gray-700">×${escapeHtml(String(Number(p.count) || 0))}</span>` +
|
||||||
|
`<span class="text-xs text-gray-500 ml-auto">last seen ${escapeHtml(formatErrorTime(p.last_seen))}</span></div>` +
|
||||||
|
`<div class="mt-1 text-xs text-gray-600">Plugins: ${affected}</div>` +
|
||||||
|
(sample ? `<div class="mt-1 text-xs font-mono text-gray-700 break-words">${escapeHtml(sample)}</div>` : '') +
|
||||||
|
'</li>';
|
||||||
|
});
|
||||||
|
html += '</ul>';
|
||||||
|
}
|
||||||
|
body.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshPluginErrors() {
|
||||||
|
fetch('/api/v3/errors/summary')
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status !== 'success' || !data.data) throw new Error(data.message || 'Request failed');
|
||||||
|
renderPluginErrors(data.data);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
const body = document.getElementById('plugin-errors-body');
|
||||||
|
if (body) body.textContent = 'Could not load plugin errors.';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPluginErrors() {
|
||||||
|
if (!confirm('Clear all recorded plugin errors?')) return;
|
||||||
|
fetch('/api/v3/errors/clear', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ all: true })
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (typeof showNotification !== 'undefined') {
|
||||||
|
showNotification(data.status === 'success' ? 'Plugin errors cleared'
|
||||||
|
: (data.message || 'Could not clear plugin errors'),
|
||||||
|
data.status === 'success' ? 'success' : 'error');
|
||||||
|
}
|
||||||
|
refreshPluginErrors();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (typeof showNotification !== 'undefined') showNotification('Could not clear plugin errors', 'error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Cleanup on page unload
|
// Cleanup on page unload
|
||||||
window.addEventListener('beforeunload', function() {
|
window.addEventListener('beforeunload', function() {
|
||||||
if (window._logsEventSource) {
|
if (window._logsEventSource) {
|
||||||
@@ -796,5 +941,9 @@ window.addEventListener('beforeunload', function() {
|
|||||||
clearInterval(window._currentPluginPollTimer);
|
clearInterval(window._currentPluginPollTimer);
|
||||||
window._currentPluginPollTimer = null;
|
window._currentPluginPollTimer = null;
|
||||||
}
|
}
|
||||||
|
if (window._pluginErrorsPollTimer) {
|
||||||
|
clearInterval(window._pluginErrorsPollTimer);
|
||||||
|
window._pluginErrorsPollTimer = null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -221,6 +221,7 @@
|
|||||||
data-starlark-location-key="timezone">
|
data-starlark-location-key="timezone">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Leave latitude and longitude blank to use this device's location (City / State / Country in General settings). If no city is set there, or it can't be looked up, the app uses its own default.</p>
|
||||||
{% if field_desc %}
|
{% if field_desc %}
|
||||||
<p class="text-xs text-gray-400 mt-1">{{ field_desc }}</p>
|
<p class="text-xs text-gray-400 mt-1">{{ field_desc }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -421,9 +422,10 @@ function saveStarlarkConfig(appId) {
|
|||||||
var locKey = sub.getAttribute('data-starlark-location-key');
|
var locKey = sub.getAttribute('data-starlark-location-key');
|
||||||
if (sub.value) loc[locKey] = sub.value;
|
if (sub.value) loc[locKey] = sub.value;
|
||||||
});
|
});
|
||||||
if (Object.keys(loc).length > 0) {
|
// Blank still has to be sent: the save merges into the stored config,
|
||||||
config[fieldId] = JSON.stringify(loc);
|
// so leaving the key out kept the old location with no way to clear
|
||||||
}
|
// it back to the device's.
|
||||||
|
config[fieldId] = Object.keys(loc).length > 0 ? JSON.stringify(loc) : '';
|
||||||
});
|
});
|
||||||
|
|
||||||
fetch('/api/v3/starlark/apps/' + encodeURIComponent(appId) + '/config', {
|
fetch('/api/v3/starlark/apps/' + encodeURIComponent(appId) + '/config', {
|
||||||
|
|||||||
Reference in New Issue
Block a user