Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b67f9e4a2a | ||
|
|
b7f5f8483a | ||
|
|
3872a68ff7 | ||
|
|
989162d28f | ||
|
|
cdf03fb107 | ||
|
|
6a9d8014e5 | ||
|
|
c90129285c | ||
|
|
66f9950a30 | ||
|
|
4abcd0e4f9 | ||
|
|
2a1c47fa76 | ||
|
|
9db1d2391a | ||
|
|
14a59c863c | ||
|
|
bff13129c4 | ||
|
|
6499794c12 | ||
|
|
3d347a368a | ||
|
|
0aca40cf3a | ||
|
|
9837315308 | ||
|
|
c1fa5094be | ||
|
|
4d49b0f892 | ||
|
|
efe76d3add | ||
|
|
273d9962d1 | ||
|
|
9e3b5f366e | ||
|
|
6edd80d9f3 | ||
|
|
1c7a0cef66 | ||
|
|
6052a60d22 | ||
|
|
7f7f0d6464 |
@@ -48,3 +48,4 @@ config/backups/
|
||||
|
||||
# Starlark apps runtime storage (installed .star files and cached renders)
|
||||
/starlark-apps/
|
||||
skin_renders/
|
||||
|
||||
@@ -31,6 +31,14 @@
|
||||
- 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`
|
||||
|
||||
## Skin System (visual overlays for sports scoreboards)
|
||||
- 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); hook: `SportsCore._render_game()` in `src/base_classes/sports.py`
|
||||
- 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
|
||||
- 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()`
|
||||
|
||||
@@ -440,6 +440,16 @@ See the [Plugin Store documentation](https://github.com/ChuckBuilds/ledmatrix-pl
|
||||
|
||||
For plugin development, check out the [Hello World Plugin](https://github.com/ChuckBuilds/ledmatrix-hello-world) repository as a starter template.
|
||||
|
||||
### Visual Skins for Scoreboards
|
||||
|
||||
Want a different look for a sports scoreboard without forking the plugin?
|
||||
**Skins** restyle the live/recent/upcoming screens while the plugin keeps
|
||||
handling data, scheduling, caching, and vegas mode. Install one with
|
||||
`git clone <skin repo> skins/<skin-id>`, select it in the plugin's config,
|
||||
and you're done — see [docs/SKIN_SYSTEM.md](docs/SKIN_SYSTEM.md) (how it
|
||||
works) and [docs/CREATING_SKINS.md](docs/CREATING_SKINS.md) (build your own,
|
||||
including a ready-made Claude Code prompt).
|
||||
|
||||
2. **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>
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,29 @@
|
||||
# bandit.yaml — LEDMatrix bandit configuration
|
||||
# https://bandit.readthedocs.io/en/latest/config.html
|
||||
#
|
||||
# Skips are justified by the specific codebase context documented below.
|
||||
# Do not remove skips without updating the justification comment.
|
||||
|
||||
skips:
|
||||
# B104: Binding to all interfaces (0.0.0.0)
|
||||
# Intentional — the Flask server binds 0.0.0.0 for LAN access on a Raspberry Pi.
|
||||
# This is not internet-facing and is documented in web_interface/app.py.
|
||||
- B104
|
||||
|
||||
# B603: subprocess call without shell=True
|
||||
# All subprocess.run() calls in this codebase use list arguments (confirmed by
|
||||
# grep — zero uses of shell=True in src/ or web_interface/). List args prevent
|
||||
# shell injection. See src/common/permission_utils.py for the primary usage.
|
||||
- B603
|
||||
|
||||
# B607: Starting a process with a partial executable path
|
||||
# The subprocess calls invoke system utilities (systemctl, sudo, git) by name.
|
||||
# These are fixed-list invocations, not user-controlled, and rely on PATH.
|
||||
- B607
|
||||
|
||||
exclude_dirs:
|
||||
- tests
|
||||
- test
|
||||
- venv
|
||||
- .venv
|
||||
- rpi-rgb-led-matrix-master
|
||||
@@ -121,6 +121,7 @@
|
||||
"axis": "horizontal"
|
||||
},
|
||||
"display_durations": {},
|
||||
"plugin_rotation_order": [],
|
||||
"use_short_date_format": true,
|
||||
"vegas_scroll": {
|
||||
"enabled": false,
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
# Adaptive Layout & Font Scaling
|
||||
|
||||
`src/adaptive_layout.py` lets a plugin render legibly on **any** panel size
|
||||
(64x32, 128x32, 96x48, 128x64, 256x64, ...) without hand-tuned per-display
|
||||
layouts. It is **opt-in**: nothing changes for plugins that don't use it.
|
||||
|
||||
It generalizes three patterns proven in the plugin ecosystem:
|
||||
|
||||
| Pattern | Origin | Core API |
|
||||
|---|---|---|
|
||||
| Geometry scale factor vs. a design size | f1-scoreboard | `ctx.px(base)` / `ctx.scale` |
|
||||
| Breakpoint tiers | masters-tournament | `ctx.tier` / `ctx.by_tier({...})` |
|
||||
| "Largest crisp font that fits" ladder | baseball-scoreboard | `ctx.fit_text(...)` and friends |
|
||||
|
||||
## Quick start
|
||||
|
||||
Every `BasePlugin` has a lazy `self.layout` (a `LayoutContext` for the
|
||||
current logical display size, rebuilt automatically if the size changes)
|
||||
and a one-liner `self.draw_fit(...)`:
|
||||
|
||||
```python
|
||||
def display(self, force_clear=False):
|
||||
from src.adaptive_layout import LADDER_ARCADE
|
||||
|
||||
b = self.layout.bounds.inset(1) # Region(0,0,W,H) minus 1px margin
|
||||
rows = b.split_v(3, 1, 1, gap=1) # 3/5 for time, 1/5 each for the rest
|
||||
|
||||
self.draw_fit(self.time_str, rows[0], ladder=LADDER_ARCADE)
|
||||
self.draw_fit(self.weekday, rows[1]) # default LADDER_GRID
|
||||
self.draw_fit(self.date_str, rows[2])
|
||||
self.display_manager.update_display()
|
||||
```
|
||||
|
||||
On 128x64 the time renders at press_start 24px; on 64x32 it steps down to
|
||||
8px. The rows partition the height, so bands can never overlap — no more
|
||||
`y = height - 7` magic numbers.
|
||||
|
||||
## Region — rect algebra
|
||||
|
||||
`Region(x, y, w, h)` is a frozen dataclass. All carving clamps to
|
||||
non-negative dimensions, so degenerate panels behave.
|
||||
|
||||
- Carving: `inset(dx, dy)`, `top_band(h)`, `bottom_band(h)`,
|
||||
`middle(top_h, bottom_h)`, `left_col(w)`, `right_col(w)`,
|
||||
`split_h(*weights, gap=0)`, `split_v(*weights, gap=0)`
|
||||
- Placement: `align_xy(w, h, align, valign)`, `center_xy(w, h)`,
|
||||
`contains(w, h)`, `.center`, `.right`, `.bottom`
|
||||
|
||||
Scoreboard-style layout:
|
||||
|
||||
```python
|
||||
b = self.layout.bounds
|
||||
status = b.top_band(self.layout.px(7))
|
||||
detail = b.bottom_band(self.layout.px(7))
|
||||
score_area = b.middle(status.h, detail.h)
|
||||
away_slot, home_slot = b.left_col(b.h), b.right_col(b.h)
|
||||
```
|
||||
|
||||
## Font ladders — discrete, never fractional
|
||||
|
||||
Pixel fonts (BDF, PressStart2P) only look right at native/integer sizes, so
|
||||
fonts are never scaled continuously. A `FontLadder` is an ordered tuple of
|
||||
`FontStep(family, size_px)` rungs, largest first; fitting walks down until
|
||||
the measured text fits.
|
||||
|
||||
- `LADDER_GRID` (default): X11 BDFs at native sizes — 10x20 → 9x18 → 9x15 →
|
||||
8x13 → 7x13 → 6x13 → 6x12 → 6x10 → 6x9 → 5x8 → 5x7 → 4x6 → tom-thumb.
|
||||
Body text, labels, multi-row content.
|
||||
- `LADDER_ARCADE`: PressStart2P at 32/24/16/8 (integer multiples of its 8px
|
||||
grid). Headline text: clocks, scores.
|
||||
|
||||
Custom ladders are just tuples — e.g. to add your plugin's registered font
|
||||
on top: `(FontStep("myplugin::digits", 16),) + LADDER_GRID`.
|
||||
|
||||
## LayoutContext
|
||||
|
||||
Built per (width, height); exposes facts and fit queries:
|
||||
|
||||
- `bounds`, `width`, `height`, `aspect`
|
||||
- `tier` by height (`xs`≤16, `sm`≤32, `md`≤48, `lg`≤64, `xl`) and
|
||||
`width_tier` (`narrow`≤64, `normal`≤128, `wide`≤256, `ultrawide`)
|
||||
- `is_wide_short` — aspect ≥ 2.5 and height ≤ 32 (the classic 128x32 shape)
|
||||
- `scale` — `min(w/design_w, h/design_h)` vs. your manifest's
|
||||
`display.design_size` (default 128x32). **Geometry only** — gaps, icon
|
||||
and logo sizes via `px(base, minimum, maximum)`; fonts use ladders.
|
||||
- `by_tier({"sm": 10, "lg": 18})` — value for the nearest defined tier
|
||||
at-or-below the panel's tier.
|
||||
- `fit_text(text, box, ladder, ellipsis=True)` → `FitResult` — largest rung
|
||||
that fits; ellipsizes as a last resort. Cached per (text, box, ladder).
|
||||
- `fit_text_proportional(text, box, base_size_px, ladder, ellipsis=True, scale=None)` —
|
||||
rung closest to (not exceeding) `base_size_px * scale`, still capped to
|
||||
what fits the box. Use this instead of `fit_text` when several
|
||||
independently-fitted elements need to stay visually harmonious as the
|
||||
panel grows — `fit_text` maximizes *each one* within its own region,
|
||||
which can make one element (e.g. a score with a generous box) balloon
|
||||
out of proportion to a neighbor that scales by geometry (e.g. logos
|
||||
sized via `px()`), even though each individual pick is "correct" in
|
||||
isolation. `base_size_px` is normally the element's existing classic/
|
||||
fixed font size. `scale` defaults to `self.scale` (the conservative
|
||||
min-of-both-axes factor `px()` uses); pass an axis-specific value when
|
||||
the surrounding composition already scales that way — e.g. a scoreboard
|
||||
whose logo slots track height alone (`min(height, width // 2)`) should
|
||||
size its text by `height / design_height` too, or the text reads as
|
||||
under-scaled next to bigger logos on a panel that only grew taller.
|
||||
- `fit_lines(lines, box, ladder, spacing)` — every line fits the width and
|
||||
the stack fits the height (measures the actual strings).
|
||||
- `font_for_rows(rows, box_h, ladder)` — largest rung whose line height
|
||||
fits `rows` rows.
|
||||
|
||||
`FitResult` carries the ready-to-use `font` (drops straight into
|
||||
`display_manager.draw_text(font=...)`), the possibly-ellipsized `text`,
|
||||
ink `width`/`height`, `baseline`, `y_offset`, `line_height`, and `fits`.
|
||||
|
||||
## Adaptive images
|
||||
|
||||
`src/adaptive_images.py` is the image counterpart to `fit_text`, exposed as
|
||||
`self.layout.fit_image(...)` (cached per panel size) and the one-liner
|
||||
`self.draw_image(...)`:
|
||||
|
||||
```python
|
||||
# Team logo: trim its transparent padding, fill the slot height (the
|
||||
# football/hockey pattern), cached across frames by a stable key
|
||||
self.draw_image(logo, regs.away_slot, mode="fill_height",
|
||||
crop_to_ink=True, cache_key=f"logo:{abbr}")
|
||||
|
||||
# Album art: cover-crop a square, faces kept by the top anchor
|
||||
self.draw_image(art, row.art, mode="cover", anchor="top")
|
||||
|
||||
# Pixel flags / sprite icons: NEAREST keeps hard edges
|
||||
from src.adaptive_images import RESAMPLE_NEAREST
|
||||
self.draw_image(flag, box, resample=RESAMPLE_NEAREST)
|
||||
```
|
||||
|
||||
Modes: `contain` (letterbox, default), `cover` (crop-to-fill),
|
||||
`fill_height` (logo-style), `stretch`. Unlike PIL's `thumbnail()`
|
||||
(downscale-only — why imagery stays tiny on big panels) fitting **upscales
|
||||
by default**; pass `upscale=False` for the legacy behavior. Results are
|
||||
cached per (image, box size, options) with a bounded LRU — always pass a
|
||||
stable `cache_key` (e.g. `"logo:KC"`) for images you reload. The module
|
||||
also exports the Pillow-compat `RESAMPLE_LANCZOS`/`RESAMPLE_NEAREST`
|
||||
constants so plugins can drop their local shims.
|
||||
|
||||
## Composite layouts
|
||||
|
||||
Pre-carved Region arrangements for the layouts plugins keep rebuilding:
|
||||
|
||||
```python
|
||||
from src.adaptive_layout import scoreboard_regions, media_row
|
||||
|
||||
regs = scoreboard_regions(self.layout.bounds, ctx=self.layout)
|
||||
# regs.away_slot / home_slot — logo slots (logo_slot = min(H, W // 2),
|
||||
# capped so a center reserve always exists —
|
||||
# see below)
|
||||
# regs.status_band — top band (replaces the magic y = 1)
|
||||
# regs.score_area — center gap, plus a controlled bleed into
|
||||
# each logo slot (replaces y = H//2 - 3)
|
||||
# regs.detail_band — bottom band (replaces y = H - 7)
|
||||
# regs.bottom_left / bottom_right — record/timeout corners
|
||||
|
||||
row = media_row(self.layout.bounds, ctx=self.layout) # art left, text right
|
||||
```
|
||||
|
||||
Both work on the full panel or on a scroll-mode card Region. They return
|
||||
Regions and never draw — compose them with `draw_fit`/`draw_image`.
|
||||
|
||||
**`scoreboard_regions`'s center reserve.** The raw `logo_slot = min(H, W//2)`
|
||||
formula has a blind spot: at exactly 2:1 aspect ratio (width = 2×height —
|
||||
two, four, or more square modules stacked into a taller panel, e.g.
|
||||
96x48, 128x64, 256x128) the two logo slots mathematically claim the
|
||||
*entire* width, leaving zero pixels for a center column no matter how
|
||||
big the panel gets. Wide panels (the 128x32 design baseline, 192x48,
|
||||
256x32) never hit this, since height is already the tighter constraint
|
||||
there. Two parameters fix it without any plugin-side code:
|
||||
`min_center_fraction`/`min_center_design_px` guarantee a real minimum
|
||||
center reserve at any aspect ratio, and `score_bleed_fraction` lets the
|
||||
score's *fit box* extend a controlled amount into each logo slot — the
|
||||
same way a real broadcast scoreboard's numbers cross slightly into the
|
||||
team marks flanking them — so a short score string never has to truncate
|
||||
even on the tightest aspect ratios. All three have sane defaults; override
|
||||
them per call if a plugin's card proportions genuinely differ.
|
||||
|
||||
## Preserving user customization
|
||||
|
||||
Adaptive layout supplies *defaults*; explicit user configuration wins:
|
||||
|
||||
- **User-set fonts win.** If the plugin's config has an explicit
|
||||
`font`/`font_size` for an element, load it as before and skip the ladder —
|
||||
fit only when the user hasn't overridden (see the football-scoreboard
|
||||
`_resolve_element_fit` pattern).
|
||||
- **Offsets apply on top.** `customization.layout.<element>.{x_offset,y_offset}`
|
||||
style knobs translate the *computed* region as a final step:
|
||||
`region.offset(user_dx, user_dy)`. `draw_image(..., offset=(dx, dy))`
|
||||
does the same for images.
|
||||
- **Colors pass through.** `draw_fit`/`draw_fitted_text` take explicit
|
||||
`color=` params; adaptive mode never repaints semantic or user-chosen
|
||||
colors.
|
||||
|
||||
## Manifest declaration
|
||||
|
||||
Declare the size your layout was authored against so `ctx.scale` means
|
||||
something:
|
||||
|
||||
```json
|
||||
"display": { "design_size": { "width": 128, "height": 32 } }
|
||||
```
|
||||
|
||||
Also available under `requires.display_size`: `min_width`, `min_height`,
|
||||
`max_width`, `max_height`.
|
||||
|
||||
## Performance notes (Pi)
|
||||
|
||||
Fit queries are cached, so cost is O(unique strings). For per-second text
|
||||
(clocks, live scores), fit on a **shape placeholder** and reuse the font:
|
||||
|
||||
```python
|
||||
fit = self.layout.fit_text("00:00", box, ladder=LADDER_ARCADE) # cached once
|
||||
self.display_manager.draw_text(current_time, font=fit.font, ...)
|
||||
```
|
||||
|
||||
## Testing across sizes
|
||||
|
||||
The harness already renders every plugin at a spread of sizes (now
|
||||
including 96x48):
|
||||
|
||||
```bash
|
||||
python scripts/check_plugin.py <plugin-dir> --sizes 64x32,128x32,96x48,128x64,256x64
|
||||
python scripts/render_plugin.py <plugin-dir> --width 96 --height 48
|
||||
```
|
||||
|
||||
`BoundsCheckingDisplayManager` flags right/bottom overflow and now records
|
||||
mediated draw calls with negative coordinates in
|
||||
`negative_coordinate_calls` (raw-PIL draws remain uncovered).
|
||||
|
||||
Reference migration: the **text-display** plugin's `font_mode: "auto"`.
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
Advanced patterns, examples, and best practices for developing LEDMatrix plugins.
|
||||
|
||||
> **Adaptive layout:** for plugins that should render legibly on any panel
|
||||
> size (fonts that grow on big panels, layouts that degrade gracefully on
|
||||
> small ones), use the adaptive layout system — `self.layout`, `draw_fit`,
|
||||
> `draw_image`, `scoreboard_regions` — documented in
|
||||
> [ADAPTIVE_LAYOUT.md](ADAPTIVE_LAYOUT.md).
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Using Weather Icons](#using-weather-icons)
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
# Creating Skins
|
||||
|
||||
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 see it on your matrix, add to your plugin's section in `config/config.json`:
|
||||
|
||||
```json
|
||||
"baseball-scoreboard": {
|
||||
"skin": "my-skin",
|
||||
"skin_options": { }
|
||||
}
|
||||
```
|
||||
|
||||
or pick it from the **Visual Skin** dropdown in the web UI (it appears once a
|
||||
matching skin is installed). `"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>`), or submit it to the plugin registry as an
|
||||
entry with `"type": "skin"` (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.
|
||||
@@ -48,6 +48,12 @@ display_manager.draw_text("Centered", centered=True) # Auto-center
|
||||
width = display_manager.get_text_width("Text", font)
|
||||
height = display_manager.get_font_height(font)
|
||||
|
||||
# Adaptive layout (recommended for multi-size support — text and images
|
||||
# that scale to any panel; see docs/ADAPTIVE_LAYOUT.md)
|
||||
rows = self.layout.bounds.inset(1).split_v(3, 1, gap=1)
|
||||
self.draw_fit("12:34", rows[0]) # largest crisp font that fits
|
||||
self.draw_image(logo, rows[1], mode="fill_height", crop_to_ink=True)
|
||||
|
||||
# Weather icons
|
||||
display_manager.draw_weather_icon("rain", x=10, y=10, size=16)
|
||||
|
||||
|
||||
@@ -6,6 +6,12 @@ Tools for rapid plugin development without deploying to the RPi.
|
||||
|
||||
Interactive web UI for tweaking plugin configs and seeing the rendered display in real time.
|
||||
|
||||
The size inputs have a preset dropdown with the harness's standard panel
|
||||
sizes, and the **All Sizes** button renders the current config at every
|
||||
harness size in a side-by-side gallery (`POST /api/render-matrix`) — the
|
||||
quickest way to eyeball adaptive-layout behavior across panels
|
||||
(see [ADAPTIVE_LAYOUT.md](ADAPTIVE_LAYOUT.md)).
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# FontManager Usage Guide
|
||||
|
||||
> **Picking a size automatically:** if you want the *largest font that fits
|
||||
> a given area* rather than a fixed size, use the adaptive layout system's
|
||||
> font ladders, which resolve through this FontManager. `BasePlugin`
|
||||
> subclasses get this as `self.layout.fit_text(...)`; other code can build
|
||||
> a `LayoutContext(width, height, font_manager)` directly — see
|
||||
> [ADAPTIVE_LAYOUT.md](ADAPTIVE_LAYOUT.md).
|
||||
|
||||
## Overview
|
||||
|
||||
The enhanced FontManager provides comprehensive font management for the LEDMatrix application with support for:
|
||||
|
||||
@@ -248,7 +248,6 @@ test/
|
||||
├── test_config_service.py # Config service tests
|
||||
├── test_config_validation_edge_cases.py # Config edge cases
|
||||
├── test_font_manager.py # Font manager tests
|
||||
├── test_layout_manager.py # Layout manager tests
|
||||
├── test_text_helper.py # Text helper tests
|
||||
├── test_error_handling.py # Error handling tests
|
||||
├── test_error_aggregator.py # Error aggregation tests
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
Complete API reference for plugin developers. This document describes all methods and properties available to plugins through the Display Manager, Cache Manager, and Plugin Manager.
|
||||
|
||||
> **Adaptive layout:** every `BasePlugin` also exposes `self.layout`,
|
||||
> `self.draw_fit(text, region)` and `self.draw_image(img, region, ...)` —
|
||||
> the recommended way to render text and images that scale to any panel
|
||||
> size. See [ADAPTIVE_LAYOUT.md](ADAPTIVE_LAYOUT.md).
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [BasePlugin](#baseplugin)
|
||||
|
||||
@@ -2,6 +2,20 @@
|
||||
|
||||
This guide explains how to set up a development workflow for plugins that are maintained in separate Git repositories while still being able to test them within the LEDMatrix project.
|
||||
|
||||
> **Rendering guidance:** plugins should read the display size dynamically
|
||||
> (`self.display_manager.matrix.width/height`) rather than hardcoding one
|
||||
> panel. For plugins that want to *scale* their layout to any panel, the
|
||||
> opt-in adaptive layout system ([ADAPTIVE_LAYOUT.md](ADAPTIVE_LAYOUT.md))
|
||||
> provides the shared helpers — fonts, images, and composite layouts that
|
||||
> scale. Existing plugins keep their classic rendering unless they adopt
|
||||
> those APIs; nothing migrates automatically.
|
||||
|
||||
> **Just want a different look for an existing sports scoreboard?** You may
|
||||
> not need a plugin at all — a **skin** restyles the live/recent/upcoming
|
||||
> rendering while the plugin keeps handling data, scheduling, caching, and
|
||||
> vegas mode, in ~100 lines of drawing code. See
|
||||
> [CREATING_SKINS.md](CREATING_SKINS.md).
|
||||
|
||||
## Overview
|
||||
|
||||
When developing plugins in separate repositories, you need a way to:
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
# Skin System Architecture
|
||||
|
||||
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
|
||||
|
||||
Every sports scoreboard (baseball, football, basketball, hockey — anything
|
||||
built on `src/base_classes/sports.py`) renders through exactly one seam:
|
||||
`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.
|
||||
|
||||
The web UI shows a **Visual Skin** dropdown for plugins that have matching
|
||||
skins installed: `SchemaManager.inject_skin_selector` adds an enum to the
|
||||
*served* schema only. Validation never sees the enum — so a config that
|
||||
references an uninstalled skin stays valid (rendering just falls back), and
|
||||
the currently-configured value is always kept selectable. `GET /api/v3/skins`
|
||||
lists installed skins (optionally filtered by `?plugin_id=`).
|
||||
|
||||
## 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:** registry entries with `"type": "skin"` install through the same
|
||||
`plugins.json` pipeline; `PluginStoreManager` routes them to `skins/`,
|
||||
validates `skin.json` (including the API major version) instead of
|
||||
`manifest.json`, and never installs 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).
|
||||
@@ -206,6 +206,40 @@ To use an existing widget in your plugin's `config_schema.json`, simply add the
|
||||
|
||||
The widget will be automatically rendered when the plugin configuration form is loaded.
|
||||
|
||||
## Marking Fields as Advanced (`x-advanced`)
|
||||
|
||||
Add `"x-advanced": true` to any top-level, non-object property to move it out
|
||||
of the main form and into a single collapsed **Advanced Settings** section at
|
||||
the bottom of the plugin's configuration page:
|
||||
|
||||
```json
|
||||
{
|
||||
"properties": {
|
||||
"city": {
|
||||
"type": "string",
|
||||
"title": "City"
|
||||
},
|
||||
"request_timeout": {
|
||||
"type": "integer",
|
||||
"default": 10,
|
||||
"description": "HTTP timeout in seconds",
|
||||
"x-advanced": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Guidelines:
|
||||
|
||||
- Use it for fine-tuning knobs most users never touch (timeouts, retry
|
||||
behavior, cache TTLs, styling overrides). Anything a first-time user must
|
||||
set to get the plugin working should stay basic.
|
||||
- Nothing is hidden permanently — the section expands on click, and the
|
||||
settings search finds and auto-expands advanced fields like any others.
|
||||
- The flag is ignored on `object`-type properties (they already render as
|
||||
their own collapsible sections) and is safely ignored by older cores, so
|
||||
adding it never breaks compatibility.
|
||||
|
||||
## Creating Custom Widgets
|
||||
|
||||
### Step 1: Create Widget File
|
||||
|
||||
@@ -8,16 +8,11 @@ numpy>=1.24.0 # For fast array operations in ScrollHelper (compatible with 2.x)
|
||||
|
||||
# Timezone handling
|
||||
pytz>=2024.2,<2025.0 # Updated for latest timezone data
|
||||
timezonefinder>=6.5.0,<7.0.0 # Updated for better performance and accuracy
|
||||
geopy>=2.4.1,<3.0.0
|
||||
|
||||
# HTTP requests
|
||||
requests>=2.33.0,<3.0.0
|
||||
|
||||
# Google API integration
|
||||
google-auth-oauthlib>=1.2.0,<2.0.0
|
||||
google-auth-httplib2>=0.2.0,<1.0.0
|
||||
google-api-python-client>=2.147.0,<3.0.0
|
||||
|
||||
# Font rendering
|
||||
freetype-py>=2.5.1,<3.0.0
|
||||
@@ -29,10 +24,8 @@ spotipy>=2.25.2,<3.0.0
|
||||
Flask>=3.1.3,<4.0.0
|
||||
|
||||
# Text processing
|
||||
unidecode>=1.3.8,<2.0.0
|
||||
|
||||
# Calendar integration
|
||||
icalevents>=0.1.27,<1.0.0
|
||||
|
||||
# WebSocket support
|
||||
python-socketio>=5.14.0,<6.0.0
|
||||
|
||||
@@ -90,11 +90,40 @@
|
||||
"min_height": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"max_width": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"max_height": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"display": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"design_size": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"width": {
|
||||
"type": "integer",
|
||||
"minimum": 8
|
||||
},
|
||||
"height": {
|
||||
"type": "integer",
|
||||
"minimum": 8
|
||||
}
|
||||
},
|
||||
"required": ["width", "height"],
|
||||
"description": "Panel size the plugin's layout was authored against; core derives the adaptive-layout scale factor from it. Defaults to 128x32 when omitted."
|
||||
}
|
||||
},
|
||||
"description": "Display/layout hints for the adaptive layout system"
|
||||
},
|
||||
"config_schema": {
|
||||
"type": "string",
|
||||
"description": "Path to configuration schema file"
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
LEDMatrix Plugin Security Auditor
|
||||
|
||||
Performs AST-based security analysis of all Python files in plugin directories.
|
||||
Designed to run in CI — exits non-zero on CRITICAL findings only.
|
||||
|
||||
Usage:
|
||||
python scripts/audit_plugins.py
|
||||
python scripts/audit_plugins.py --verbose
|
||||
python scripts/audit_plugins.py --plugin hello-world
|
||||
python scripts/audit_plugins.py --output results.json
|
||||
"""
|
||||
|
||||
import ast
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from dataclasses import dataclass, asdict
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
PLUGIN_BASE_DIRS = [
|
||||
PROJECT_ROOT / "plugins",
|
||||
PROJECT_ROOT / "plugin-repos",
|
||||
]
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Finding dataclass
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class Finding:
|
||||
plugin_id: str
|
||||
file: str
|
||||
line: int
|
||||
severity: str # CRITICAL | WARNING | INFO
|
||||
rule: str
|
||||
message: str
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# AST visitor
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class _PluginVisitor(ast.NodeVisitor):
|
||||
"""Collect security findings from a single plugin Python file."""
|
||||
|
||||
def __init__(self, filepath: Path, plugin_id: str):
|
||||
self.filepath = filepath
|
||||
self.plugin_id = plugin_id
|
||||
self.findings: list[Finding] = []
|
||||
# Local name -> real dotted path, so aliased imports and from-imports
|
||||
# of dangerous APIs (import subprocess as sp; from builtins import
|
||||
# eval as e) are still recognized in visit_Call below.
|
||||
self._aliases: dict[str, str] = {}
|
||||
|
||||
def _add(self, node: ast.AST, severity: str, rule: str, message: str) -> None:
|
||||
self.findings.append(Finding(
|
||||
plugin_id=self.plugin_id,
|
||||
file=str(self.filepath.relative_to(PROJECT_ROOT)),
|
||||
line=getattr(node, "lineno", 0),
|
||||
severity=severity,
|
||||
rule=rule,
|
||||
message=message,
|
||||
))
|
||||
|
||||
def _resolve(self, local_name: str) -> str:
|
||||
"""Resolve a local name through recorded import aliases to its real
|
||||
dotted path (e.g. "sp" -> "subprocess"); unresolved names pass through
|
||||
unchanged."""
|
||||
return self._aliases.get(local_name, local_name)
|
||||
|
||||
def _resolve_call_target(self, func: ast.expr) -> str | None:
|
||||
"""Resolve a Call's func node to a fully-qualified dotted target,
|
||||
covering a direct name (bare builtin, aliased import, or
|
||||
from-import: from builtins import eval as e; from subprocess
|
||||
import run; from os import system as s) and module-attribute
|
||||
access (subprocess.run, sp.run, os.system, o.system) uniformly.
|
||||
Returns None for call shapes this doesn't attempt to resolve."""
|
||||
if isinstance(func, ast.Name):
|
||||
return self._resolve(func.id)
|
||||
if isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name):
|
||||
base = self._resolve(func.value.id)
|
||||
return f"{base}.{func.attr}"
|
||||
return None
|
||||
|
||||
def visit_Call(self, node: ast.Call) -> None:
|
||||
target = self._resolve_call_target(node.func)
|
||||
if target is None:
|
||||
self.generic_visit(node)
|
||||
return
|
||||
|
||||
leaf = target.rsplit(".", 1)[-1]
|
||||
|
||||
# eval() / exec() / compile() — arbitrary code execution, whether a
|
||||
# bare call, an aliased import, or a from-import
|
||||
# (from builtins import eval as e; e(...))
|
||||
if leaf == "eval":
|
||||
self._add(node, "CRITICAL", "PLUGIN-001",
|
||||
"eval() call — arbitrary code execution risk")
|
||||
elif leaf == "exec":
|
||||
self._add(node, "CRITICAL", "PLUGIN-002",
|
||||
"exec() call — arbitrary code execution risk")
|
||||
elif leaf == "compile":
|
||||
self._add(node, "WARNING", "PLUGIN-003",
|
||||
"compile() call — dynamic code compilation")
|
||||
|
||||
# subprocess.*(shell=True), whether subprocess.run(...), sp.run(...),
|
||||
# or a from-import (from subprocess import run; run(..., shell=True))
|
||||
if target in {
|
||||
"subprocess.run", "subprocess.call", "subprocess.Popen",
|
||||
"subprocess.check_call", "subprocess.check_output",
|
||||
}:
|
||||
for kw in node.keywords:
|
||||
if (kw.arg == "shell" and
|
||||
isinstance(kw.value, ast.Constant) and
|
||||
kw.value.value is True):
|
||||
self._add(node, "WARNING", "PLUGIN-004",
|
||||
f"subprocess.{leaf}(shell=True) — "
|
||||
f"shell injection risk if args include user input")
|
||||
|
||||
# os.system(), whether os.system(...), o.system(...), or a
|
||||
# from-import (from os import system as s; s(...))
|
||||
if target == "os.system":
|
||||
self._add(node, "WARNING", "PLUGIN-005",
|
||||
"os.system() call — prefer subprocess with list args")
|
||||
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_Import(self, node: ast.Import) -> None:
|
||||
for alias in node.names:
|
||||
if alias.asname:
|
||||
local, real = alias.asname, alias.name
|
||||
else:
|
||||
# `import os.path` binds the top-level name `os`, not `os.path`
|
||||
local = real = alias.name.split(".")[0]
|
||||
self._aliases[local] = real
|
||||
self._check_import(node, alias.name)
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
||||
if node.module:
|
||||
for alias in node.names:
|
||||
local = alias.asname or alias.name
|
||||
self._aliases[local] = f"{node.module}.{alias.name}"
|
||||
self._check_import(node, node.module)
|
||||
self.generic_visit(node)
|
||||
|
||||
def _check_import(self, node: ast.AST, module_name: str) -> None:
|
||||
dangerous = {
|
||||
"ctypes": ("WARNING", "PLUGIN-010", "ctypes import — native code execution"),
|
||||
"cffi": ("WARNING", "PLUGIN-011", "cffi import — native code execution"),
|
||||
"pickle": ("WARNING", "PLUGIN-012",
|
||||
"pickle import — deserialization can execute arbitrary code"),
|
||||
"marshal": ("WARNING", "PLUGIN-013",
|
||||
"marshal import — deserialization risk"),
|
||||
}
|
||||
for mod, (severity, rule, msg) in dangerous.items():
|
||||
if module_name == mod or module_name.startswith(mod + "."):
|
||||
self._add(node, severity, rule, msg)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Per-plugin audit
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def audit_plugin(plugin_dir: Path) -> list[Finding]:
|
||||
"""Audit a single plugin directory. Returns all findings."""
|
||||
findings: list[Finding] = []
|
||||
plugin_id = plugin_dir.name
|
||||
|
||||
# Check for required files
|
||||
for required_file, rule, msg in [
|
||||
("manifest.json", "PLUGIN-020",
|
||||
"manifest.json missing — plugin may be incomplete"),
|
||||
("config_schema.json", "PLUGIN-021",
|
||||
"config_schema.json missing — no input validation schema declared"),
|
||||
]:
|
||||
if not (plugin_dir / required_file).exists():
|
||||
findings.append(Finding(
|
||||
plugin_id=plugin_id,
|
||||
file=str((plugin_dir / required_file).relative_to(PROJECT_ROOT)),
|
||||
line=0,
|
||||
severity="WARNING",
|
||||
rule=rule,
|
||||
message=msg,
|
||||
))
|
||||
|
||||
# AST analysis of all Python files
|
||||
for py_file in sorted(plugin_dir.rglob("*.py")):
|
||||
try:
|
||||
source = py_file.read_text(encoding="utf-8")
|
||||
tree = ast.parse(source, filename=str(py_file))
|
||||
visitor = _PluginVisitor(py_file, plugin_id)
|
||||
visitor.visit(tree)
|
||||
findings.extend(visitor.findings)
|
||||
except SyntaxError as exc:
|
||||
# A file the visitor can't even parse is a file we can't verify
|
||||
# is safe -- this must block the audit, not just warn.
|
||||
findings.append(Finding(
|
||||
plugin_id=plugin_id,
|
||||
file=str(py_file.relative_to(PROJECT_ROOT)),
|
||||
line=getattr(exc, "lineno", 0) or 0,
|
||||
severity="CRITICAL",
|
||||
rule="PLUGIN-030",
|
||||
message=f"Python syntax error — cannot be parsed: {exc}",
|
||||
))
|
||||
except OSError as exc:
|
||||
# Same reasoning as SyntaxError: an unreadable file was never
|
||||
# actually scanned, so it must block rather than pass silently.
|
||||
findings.append(Finding(
|
||||
plugin_id=plugin_id,
|
||||
file=str(py_file.relative_to(PROJECT_ROOT)),
|
||||
line=0,
|
||||
severity="CRITICAL",
|
||||
rule="PLUGIN-031",
|
||||
message=f"Could not read file: {exc}",
|
||||
))
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Main
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="LEDMatrix plugin security auditor",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument("--plugin", "-p", default=None,
|
||||
help="Audit a specific plugin ID only")
|
||||
parser.add_argument("--output", "-o", default=None,
|
||||
help="Write JSON results to this file")
|
||||
parser.add_argument("--verbose", "-v", action="store_true",
|
||||
help="Show all findings, not just summary")
|
||||
args = parser.parse_args()
|
||||
|
||||
print("=" * 60)
|
||||
print("LEDMatrix Plugin Security Audit")
|
||||
print(f"Project root: {PROJECT_ROOT}")
|
||||
print("=" * 60)
|
||||
|
||||
all_findings: list[Finding] = []
|
||||
plugins_scanned = 0
|
||||
plugin_found = args.plugin is None
|
||||
|
||||
for base_dir in PLUGIN_BASE_DIRS:
|
||||
if not base_dir.exists():
|
||||
if args.verbose:
|
||||
print(f" ⏭️ Skipping {base_dir.name}/ (directory not found)")
|
||||
continue
|
||||
|
||||
base_label = base_dir.relative_to(PROJECT_ROOT)
|
||||
print(f"\n Scanning {base_label}/")
|
||||
|
||||
for plugin_dir in sorted(base_dir.iterdir()):
|
||||
if not plugin_dir.is_dir():
|
||||
continue
|
||||
if plugin_dir.name.startswith((".", "_")):
|
||||
continue
|
||||
if args.plugin and plugin_dir.name != args.plugin:
|
||||
continue
|
||||
if args.plugin:
|
||||
plugin_found = True
|
||||
|
||||
findings = audit_plugin(plugin_dir)
|
||||
all_findings.extend(findings)
|
||||
plugins_scanned += 1
|
||||
|
||||
critical = [f for f in findings if f.severity == "CRITICAL"]
|
||||
warnings = [f for f in findings if f.severity == "WARNING"]
|
||||
|
||||
if critical:
|
||||
icon, label = "🚨", "CRITICAL"
|
||||
elif warnings:
|
||||
icon, label = "⚠️ ", "WARN "
|
||||
else:
|
||||
icon, label = "✅", "PASS "
|
||||
|
||||
print(f" {icon} [{label}] {plugin_dir.name}"
|
||||
f" — {len(critical)} critical, {len(warnings)} warnings")
|
||||
|
||||
if args.verbose:
|
||||
for f in findings:
|
||||
severity_icon = {"CRITICAL": "🚨", "WARNING": "⚠️ ", "INFO": "ℹ️ "}.get(
|
||||
f.severity, " "
|
||||
)
|
||||
print(f" {severity_icon} {f.rule} {f.file}:{f.line} — {f.message}")
|
||||
|
||||
if args.plugin and not plugin_found:
|
||||
print(f"\n 🚨 Plugin '{args.plugin}' not found in any of "
|
||||
f"{[str(d.relative_to(PROJECT_ROOT)) for d in PLUGIN_BASE_DIRS]} — "
|
||||
f"nothing was audited")
|
||||
return 1
|
||||
|
||||
# Summary
|
||||
critical_findings = [f for f in all_findings if f.severity == "CRITICAL"]
|
||||
warning_findings = [f for f in all_findings if f.severity == "WARNING"]
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f" Plugins scanned : {plugins_scanned}")
|
||||
print(f" CRITICAL : {len(critical_findings)}")
|
||||
print(f" WARNING : {len(warning_findings)}")
|
||||
|
||||
if critical_findings:
|
||||
print("\n 🚨 CRITICAL findings:")
|
||||
for f in critical_findings:
|
||||
print(f" {f.plugin_id} | {Path(f.file).name}:{f.line} | {f.message}")
|
||||
|
||||
# Write JSON output
|
||||
if args.output:
|
||||
output_data = {
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"plugins_scanned": plugins_scanned,
|
||||
"summary": {
|
||||
"critical": len(critical_findings),
|
||||
"warnings": len(warning_findings),
|
||||
},
|
||||
"findings": [f.to_dict() for f in all_findings],
|
||||
}
|
||||
Path(args.output).write_text(
|
||||
json.dumps(output_data, indent=2), encoding="utf-8"
|
||||
)
|
||||
print(f"\n Results written to: {args.output}")
|
||||
|
||||
if critical_findings:
|
||||
print("\n 🚨 Blocking — CRITICAL issues must be resolved")
|
||||
return 1
|
||||
|
||||
print("\n ✅ No critical issues found")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -37,10 +37,11 @@ os.environ['EMULATOR'] = 'true'
|
||||
|
||||
from src.logging_config import get_logger # noqa: E402
|
||||
from src.plugin_system.testing.loading import ( # noqa: E402
|
||||
find_plugin_dir, load_config_defaults, load_harness_spec,
|
||||
build_full_config, find_plugin_dir, load_harness_spec, load_manifest,
|
||||
)
|
||||
from src.plugin_system.testing.harness import ( # noqa: E402
|
||||
RenderResult, render_plugin_matrix, compare_to_goldens, write_goldens,
|
||||
check_scale_up,
|
||||
)
|
||||
from src.plugin_system.testing.sizes import ( # noqa: E402
|
||||
parse_size_token, resolve_test_sizes, safe_mode_filename, size_label,
|
||||
@@ -96,12 +97,11 @@ def check_one(plugin_id: str, search_dirs: List[str], sizes, mock_data: Dict,
|
||||
# matrix path does; explicit CLI flags still override the file.
|
||||
spec = load_harness_spec(plugin_dir)
|
||||
|
||||
# config_schema defaults (real-install behavior), then harness.json config,
|
||||
# then CLI --config — most specific wins.
|
||||
full_config = {"enabled": True}
|
||||
full_config.update(load_config_defaults(plugin_dir))
|
||||
full_config.update(spec.get("config", {}))
|
||||
full_config.update(config)
|
||||
# config_schema defaults (real-install behavior, with enabled forced True
|
||||
# so a plugin's own enabled:false default can't accidentally disable
|
||||
# testing), then harness.json config, then CLI --config — most specific
|
||||
# wins.
|
||||
full_config = build_full_config(plugin_dir, spec, config)
|
||||
|
||||
# Precedence: CLI flag > LEDMATRIX_TEST_SIZES env > harness.json > default.
|
||||
effective_sizes = sizes if sizes else resolve_test_sizes(spec.get("sizes"))
|
||||
@@ -110,28 +110,55 @@ def check_one(plugin_id: str, search_dirs: List[str], sizes, mock_data: Dict,
|
||||
effective_freeze = freeze_time or spec.get("freeze_time")
|
||||
effective_run_update = run_update and not spec.get("skip_update", False)
|
||||
|
||||
results = render_plugin_matrix(
|
||||
plugin_id=plugin_id, plugin_dir=plugin_dir, config=full_config,
|
||||
mock_data=effective_mock_data, sizes=effective_sizes,
|
||||
run_update=effective_run_update, freeze_time=effective_freeze,
|
||||
)
|
||||
# The plugin's declared design size drives the scale-up fill check
|
||||
# (panels >= 2x the design size must not be left mostly empty).
|
||||
declared = load_manifest(plugin_dir).get("display", {}).get("design_size", {})
|
||||
design_size = (int(declared.get("width", 128)), int(declared.get("height", 32)))
|
||||
fill_strict = spec.get("fill_check") == "strict"
|
||||
|
||||
golden_dir = golden_dir_override or (plugin_dir / 'test' / 'golden')
|
||||
if update_golden:
|
||||
written = write_goldens(results, golden_dir)
|
||||
logger.info("Wrote %d golden image(s) for %s to %s", written, plugin_id, golden_dir)
|
||||
else:
|
||||
compare_to_goldens(results, golden_dir)
|
||||
# Every run: the base config, plus one per harness.json "variant" —
|
||||
# a config overlay with its own golden dir (e.g. adaptive layout mode
|
||||
# tested alongside the classic default).
|
||||
runs = [(None, {}, golden_dir_override or (plugin_dir / 'test' / 'golden'))]
|
||||
for variant in spec.get("variants", []):
|
||||
name = variant.get("name") or "variant"
|
||||
vdir = plugin_dir / variant.get("golden_dir", f"test/golden-{name}")
|
||||
runs.append((name, variant.get("config", {}), vdir))
|
||||
|
||||
if out_dir:
|
||||
for r in results:
|
||||
if r.image is None:
|
||||
continue
|
||||
dest = out_dir / plugin_id / size_label(r.width, r.height)
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
r.image.save(dest / f"{safe_mode_filename(r.mode)}.png", format="PNG")
|
||||
all_run_results: List[RenderResult] = []
|
||||
for variant_name, overlay, golden_dir in runs:
|
||||
run_config = {**full_config, **overlay}
|
||||
results = render_plugin_matrix(
|
||||
plugin_id=plugin_id, plugin_dir=plugin_dir, config=run_config,
|
||||
mock_data=effective_mock_data, sizes=effective_sizes,
|
||||
run_update=effective_run_update, freeze_time=effective_freeze,
|
||||
)
|
||||
|
||||
return results
|
||||
if update_golden:
|
||||
written = write_goldens(results, golden_dir)
|
||||
logger.info("Wrote %d golden image(s) for %s%s to %s", written, plugin_id,
|
||||
f" [{variant_name}]" if variant_name else "", golden_dir)
|
||||
else:
|
||||
compare_to_goldens(results, golden_dir)
|
||||
|
||||
check_scale_up(results, design_size=design_size, strict=fill_strict)
|
||||
|
||||
# Tag variant runs so the report and PNG dumps stay distinguishable.
|
||||
if variant_name:
|
||||
for r in results:
|
||||
r.mode = f"{r.mode}@{variant_name}"
|
||||
|
||||
if out_dir:
|
||||
for r in results:
|
||||
if r.image is None:
|
||||
continue
|
||||
dest = out_dir / plugin_id / size_label(r.width, r.height)
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
r.image.save(dest / f"{safe_mode_filename(r.mode)}.png", format="PNG")
|
||||
|
||||
all_run_results.extend(results)
|
||||
|
||||
return all_run_results
|
||||
|
||||
|
||||
def print_report(all_results: Dict[str, List[RenderResult]]) -> bool:
|
||||
@@ -147,6 +174,10 @@ def print_report(all_results: Dict[str, List[RenderResult]]) -> bool:
|
||||
detail = " (golden ✓)"
|
||||
if r.update_error is not None:
|
||||
detail += f" (update warn: {r.update_error})"
|
||||
if r.fill_checked and r.fill_ok is None and r.fill_extent:
|
||||
# warn-only underfill: big panel left mostly empty
|
||||
ex, ey = r.fill_extent
|
||||
detail += f" (fill warn: extent {ex:.0%}x{ey:.0%})"
|
||||
else:
|
||||
everything_ok = False
|
||||
if r.error is not None:
|
||||
@@ -156,6 +187,10 @@ def print_report(all_results: Dict[str, List[RenderResult]]) -> bool:
|
||||
elif r.golden_ok is False:
|
||||
status = "FAIL"
|
||||
detail = f" golden drift: {r.golden_diff_pixels}px (max Δ={r.golden_max_delta})"
|
||||
elif r.fill_ok is False:
|
||||
ex, ey = r.fill_extent or (0.0, 0.0)
|
||||
status = "FAIL"
|
||||
detail = f" fill: extent {ex:.0%}x{ey:.0%} below required coverage"
|
||||
else:
|
||||
status, detail = "FAIL", ""
|
||||
print(f" [{status}] {r.size_label:>7} {r.mode}{detail}")
|
||||
|
||||
@@ -55,7 +55,7 @@ def main():
|
||||
failures += not check("draw.textbbox",
|
||||
lambda: draw.textbbox((0, 0), "Test", font=font))
|
||||
|
||||
print("\nResampling (used in logo_helper, image_utils, sports base):")
|
||||
print("\nResampling (used in logo_helper, sports base):")
|
||||
logo = Image.new('RGBA', (200, 200), (255, 128, 0, 200))
|
||||
failures += not check("Image.Resampling.LANCZOS exists",
|
||||
lambda: str(Image.Resampling.LANCZOS))
|
||||
|
||||
@@ -16,6 +16,7 @@ Opens at http://localhost:5001
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import argparse
|
||||
import logging
|
||||
@@ -44,6 +45,10 @@ MAX_HEIGHT = 512
|
||||
MIN_WIDTH = 1
|
||||
MIN_HEIGHT = 1
|
||||
|
||||
# plugin_id arrives in request input and is used to build filesystem paths —
|
||||
# allowlist it (same pattern the web UI's pages_v3 uses)
|
||||
_SAFE_PLUGIN_ID_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}$')
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Plugin discovery
|
||||
@@ -106,15 +111,30 @@ def discover_plugins() -> List[Dict[str, Any]]:
|
||||
|
||||
|
||||
def find_plugin_dir(plugin_id: str) -> Optional[Path]:
|
||||
"""Find a plugin directory by ID."""
|
||||
"""Find a plugin directory by ID.
|
||||
|
||||
plugin_id comes from request input: it must pass an allowlist match,
|
||||
and the resulting directory is normalized and required to live inside
|
||||
one of the plugin search dirs, so a crafted id can never name a path
|
||||
outside them.
|
||||
"""
|
||||
if not isinstance(plugin_id, str) or not _SAFE_PLUGIN_ID_RE.match(plugin_id):
|
||||
return None
|
||||
from src.plugin_system.plugin_loader import PluginLoader
|
||||
loader = PluginLoader()
|
||||
for search_dir in get_search_dirs():
|
||||
if not search_dir.exists():
|
||||
continue
|
||||
result = loader.find_plugin_directory(plugin_id, search_dir)
|
||||
if result:
|
||||
return Path(result)
|
||||
if not result:
|
||||
continue
|
||||
# Normalize WITHOUT following symlinks (dev plugins are often
|
||||
# symlinked into plugins/) and require lexical containment in the
|
||||
# search dir, so no id can ever name a path outside it.
|
||||
result_abs = os.path.abspath(str(result))
|
||||
root_abs = os.path.abspath(str(search_dir))
|
||||
if os.path.commonpath([result_abs, root_abs]) == root_abs:
|
||||
return Path(result_abs)
|
||||
return None
|
||||
|
||||
|
||||
@@ -176,6 +196,118 @@ def api_plugin_defaults(plugin_id):
|
||||
return jsonify({'defaults': defaults})
|
||||
|
||||
|
||||
def _render_once(plugin_id, plugin_dir, manifest, config, mock_data, width, height,
|
||||
skip_update):
|
||||
"""Render one plugin at one size. Returns the /api/render response dict.
|
||||
|
||||
A fresh plugin instance per call, mirroring the safety harness, so sizes
|
||||
never share state.
|
||||
"""
|
||||
from src.plugin_system.testing import VisualTestDisplayManager, MockCacheManager, MockPluginManager
|
||||
from src.plugin_system.plugin_loader import PluginLoader
|
||||
|
||||
display_manager = VisualTestDisplayManager(width=width, height=height)
|
||||
cache_manager = MockCacheManager()
|
||||
plugin_manager = MockPluginManager()
|
||||
|
||||
# Pre-populate cache with mock data
|
||||
for key, value in mock_data.items():
|
||||
cache_manager.set(key, value)
|
||||
|
||||
loader = PluginLoader()
|
||||
errors = []
|
||||
warnings = []
|
||||
|
||||
plugin_instance, _module = loader.load_plugin(
|
||||
plugin_id=plugin_id,
|
||||
manifest=manifest,
|
||||
plugin_dir=plugin_dir,
|
||||
config=config,
|
||||
display_manager=display_manager,
|
||||
cache_manager=cache_manager,
|
||||
plugin_manager=plugin_manager,
|
||||
install_deps=False,
|
||||
)
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
# Run update()
|
||||
if not skip_update:
|
||||
try:
|
||||
plugin_instance.update()
|
||||
except Exception as e:
|
||||
logger.warning("update() raised for plugin %s", plugin_id, exc_info=True)
|
||||
warnings.append(f"update() raised: {type(e).__name__} — see server log")
|
||||
|
||||
# Run display()
|
||||
try:
|
||||
plugin_instance.display(force_clear=True)
|
||||
except Exception as e:
|
||||
logger.warning("display() raised for plugin %s", plugin_id, exc_info=True)
|
||||
errors.append(f"display() raised: {type(e).__name__} — see server log")
|
||||
|
||||
render_time_ms = round((time.time() - start_time) * 1000, 1)
|
||||
|
||||
return {
|
||||
'image': f'data:image/png;base64,{display_manager.get_image_base64()}',
|
||||
'width': width,
|
||||
'height': height,
|
||||
'render_time_ms': render_time_ms,
|
||||
'errors': errors,
|
||||
'warnings': warnings,
|
||||
}
|
||||
|
||||
|
||||
def _trusted_plugin_dir(plugin_dir: Path) -> Optional[Path]:
|
||||
"""Re-derive a plugin directory from the search dirs' own listings.
|
||||
|
||||
Path-injection barrier: unlike ``Path.iterdir()`` (which CodeQL doesn't
|
||||
recognize as a taint-clearing enumeration), ``os.scandir()`` is. The
|
||||
returned Path is built from a trusted root plus a name the filesystem
|
||||
itself produced under that root via scandir — request-derived strings
|
||||
never enter its construction — so a crafted plugin id can never make
|
||||
downstream file access leave the plugin search dirs. Comparison is by
|
||||
name, deliberately without symlink resolution (dev plugins are
|
||||
commonly symlinked into plugins/).
|
||||
"""
|
||||
wanted_name = Path(os.path.normpath(str(plugin_dir))).name
|
||||
for search_dir in get_search_dirs():
|
||||
search_dir_str = str(search_dir)
|
||||
try:
|
||||
with os.scandir(search_dir_str) as entries:
|
||||
for entry in entries:
|
||||
if entry.name == wanted_name and entry.is_dir():
|
||||
return Path(search_dir_str) / entry.name
|
||||
except OSError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _parse_render_request(data):
|
||||
"""Shared /api/render* request prep. Returns (plugin_dir, manifest, config,
|
||||
mock_data, skip_update) or raises ValueError with a client message."""
|
||||
plugin_id = data['plugin_id']
|
||||
candidate_dir = find_plugin_dir(plugin_id)
|
||||
# Never reuse `candidate_dir` past this point: it's built from
|
||||
# request-derived input, and a variable reassigned only on some paths
|
||||
# isn't a barrier CodeQL's flow analysis honors. `trusted_dir` is the
|
||||
# sole name used below, always the scandir-sourced result.
|
||||
trusted_dir = _trusted_plugin_dir(candidate_dir) if candidate_dir else None
|
||||
if not trusted_dir:
|
||||
raise LookupError(f'Plugin not found: {plugin_id}')
|
||||
|
||||
manifest_path = trusted_dir / 'manifest.json'
|
||||
with open(manifest_path, 'r') as f:
|
||||
manifest = json.load(f)
|
||||
|
||||
# Build config: schema defaults + user overrides
|
||||
config = {'enabled': True}
|
||||
config.update(load_config_defaults(trusted_dir))
|
||||
config.update(data.get('config', {}))
|
||||
|
||||
return trusted_dir, manifest, config, data.get('mock_data', {}), data.get('skip_update', False)
|
||||
|
||||
|
||||
@app.route('/api/render', methods=['POST'])
|
||||
def api_render():
|
||||
"""Render a plugin and return the display as base64 PNG."""
|
||||
@@ -183,11 +315,6 @@ def api_render():
|
||||
if not data or 'plugin_id' not in data:
|
||||
return jsonify({'error': 'plugin_id is required'}), 400
|
||||
|
||||
plugin_id = data['plugin_id']
|
||||
user_config = data.get('config', {})
|
||||
mock_data = data.get('mock_data', {})
|
||||
skip_update = data.get('skip_update', False)
|
||||
|
||||
try:
|
||||
width = int(data.get('width', 128))
|
||||
height = int(data.get('height', 32))
|
||||
@@ -199,78 +326,77 @@ def api_render():
|
||||
if not (MIN_HEIGHT <= height <= MAX_HEIGHT):
|
||||
return jsonify({'error': f'height must be between {MIN_HEIGHT} and {MAX_HEIGHT}'}), 400
|
||||
|
||||
# Find plugin
|
||||
plugin_dir = find_plugin_dir(plugin_id)
|
||||
if not plugin_dir:
|
||||
return jsonify({'error': f'Plugin not found: {plugin_id}'}), 404
|
||||
|
||||
# Load manifest
|
||||
manifest_path = plugin_dir / 'manifest.json'
|
||||
with open(manifest_path, 'r') as f:
|
||||
manifest = json.load(f)
|
||||
|
||||
# Build config: schema defaults + user overrides
|
||||
config_defaults = load_config_defaults(plugin_dir)
|
||||
config = {'enabled': True}
|
||||
config.update(config_defaults)
|
||||
config.update(user_config)
|
||||
|
||||
# Create display manager and mocks
|
||||
from src.plugin_system.testing import VisualTestDisplayManager, MockCacheManager, MockPluginManager
|
||||
from src.plugin_system.plugin_loader import PluginLoader
|
||||
|
||||
display_manager = VisualTestDisplayManager(width=width, height=height)
|
||||
cache_manager = MockCacheManager()
|
||||
plugin_manager = MockPluginManager()
|
||||
|
||||
# Pre-populate cache with mock data
|
||||
for key, value in mock_data.items():
|
||||
cache_manager.set(key, value)
|
||||
|
||||
# Load plugin
|
||||
loader = PluginLoader()
|
||||
errors = []
|
||||
warnings = []
|
||||
try:
|
||||
plugin_dir, manifest, config, mock_data, skip_update = _parse_render_request(data)
|
||||
except LookupError:
|
||||
return jsonify({'error': f"Plugin not found: {data['plugin_id']}"}), 404
|
||||
except Exception:
|
||||
# Bad manifest.json / schema / fixture — details go to the dev's
|
||||
# console, not the HTTP response
|
||||
app.logger.exception('render request preparation failed')
|
||||
return jsonify({'error': 'Could not prepare render request; see server log'}), 400
|
||||
|
||||
try:
|
||||
plugin_instance, module = loader.load_plugin(
|
||||
plugin_id=plugin_id,
|
||||
manifest=manifest,
|
||||
plugin_dir=plugin_dir,
|
||||
config=config,
|
||||
display_manager=display_manager,
|
||||
cache_manager=cache_manager,
|
||||
plugin_manager=plugin_manager,
|
||||
install_deps=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({'error': f'Failed to load plugin: {e}'}), 500
|
||||
result = _render_once(data['plugin_id'], plugin_dir, manifest, config,
|
||||
mock_data, width, height, skip_update)
|
||||
except Exception:
|
||||
app.logger.exception('plugin load failed during render')
|
||||
return jsonify({'error': 'Failed to load plugin; see server log'}), 500
|
||||
return jsonify(result)
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
# Run update()
|
||||
if not skip_update:
|
||||
@app.route('/api/sizes')
|
||||
def api_sizes():
|
||||
"""The representative panel-size sample the safety harness renders at."""
|
||||
from src.plugin_system.testing.sizes import DEFAULT_TEST_SIZES
|
||||
return jsonify({'sizes': [list(s) for s in DEFAULT_TEST_SIZES]})
|
||||
|
||||
|
||||
MAX_MATRIX_SIZES = 12
|
||||
|
||||
|
||||
@app.route('/api/render-matrix', methods=['POST'])
|
||||
def api_render_matrix():
|
||||
"""Render a plugin at a list of sizes (default: the harness sample) so the
|
||||
UI can show a side-by-side multi-resolution gallery."""
|
||||
data = request.get_json()
|
||||
if not data or 'plugin_id' not in data:
|
||||
return jsonify({'error': 'plugin_id is required'}), 400
|
||||
|
||||
from src.plugin_system.testing.sizes import DEFAULT_TEST_SIZES
|
||||
sizes = data.get('sizes') or [list(s) for s in DEFAULT_TEST_SIZES]
|
||||
if len(sizes) > MAX_MATRIX_SIZES:
|
||||
return jsonify({'error': f'at most {MAX_MATRIX_SIZES} sizes per request'}), 400
|
||||
parsed_sizes = []
|
||||
for pair in sizes:
|
||||
try:
|
||||
plugin_instance.update()
|
||||
except Exception as e:
|
||||
warnings.append(f"update() raised: {e}")
|
||||
w, h = int(pair[0]), int(pair[1])
|
||||
except (TypeError, ValueError, IndexError):
|
||||
return jsonify({'error': f'invalid size entry {pair!r} (expected [w, h])'}), 400
|
||||
if not (MIN_WIDTH <= w <= MAX_WIDTH and MIN_HEIGHT <= h <= MAX_HEIGHT):
|
||||
return jsonify({'error': f'size {w}x{h} out of bounds'}), 400
|
||||
parsed_sizes.append((w, h))
|
||||
|
||||
# Run display()
|
||||
try:
|
||||
plugin_instance.display(force_clear=True)
|
||||
except Exception as e:
|
||||
errors.append(f"display() raised: {e}")
|
||||
plugin_dir, manifest, config, mock_data, skip_update = _parse_render_request(data)
|
||||
except LookupError:
|
||||
return jsonify({'error': f"Plugin not found: {data['plugin_id']}"}), 404
|
||||
except Exception:
|
||||
app.logger.exception('render request preparation failed')
|
||||
return jsonify({'error': 'Could not prepare render request; see server log'}), 400
|
||||
|
||||
render_time_ms = round((time.time() - start_time) * 1000, 1)
|
||||
|
||||
return jsonify({
|
||||
'image': f'data:image/png;base64,{display_manager.get_image_base64()}',
|
||||
'width': width,
|
||||
'height': height,
|
||||
'render_time_ms': render_time_ms,
|
||||
'errors': errors,
|
||||
'warnings': warnings,
|
||||
})
|
||||
results = []
|
||||
for w, h in parsed_sizes:
|
||||
try:
|
||||
results.append(_render_once(data['plugin_id'], plugin_dir, manifest,
|
||||
config, mock_data, w, h, skip_update))
|
||||
except Exception:
|
||||
app.logger.exception('plugin load failed during %dx%d render', w, h)
|
||||
results.append({'image': None, 'width': w, 'height': h,
|
||||
'render_time_ms': 0,
|
||||
'errors': ['Failed to load plugin; see server log'],
|
||||
'warnings': []})
|
||||
return jsonify({'results': results})
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Security Report Generator
|
||||
|
||||
Aggregates JSON output from all CI security audit jobs into a single
|
||||
Markdown report suitable for PR comments and artifact storage.
|
||||
|
||||
Expected artifact layout (from actions/download-artifact@v4):
|
||||
<artifact-dir>/
|
||||
sast-results/
|
||||
bandit-results.json
|
||||
semgrep-results.json
|
||||
dependency-audit-results/
|
||||
pip-audit-results.json
|
||||
safety-results.json
|
||||
secrets-scan-results/
|
||||
gitleaks-results.json
|
||||
security-proofs-results/
|
||||
security-proofs-results.json
|
||||
plugin-audit-results/
|
||||
plugin-audit-results.json
|
||||
|
||||
Usage:
|
||||
python scripts/generate_report.py --artifact-dir audit-artifacts/ --output report.md
|
||||
python scripts/generate_report.py --artifact-dir audit-artifacts/ --output report.md --verbose
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
# Gitleaks matches exactly equal to one of these (not a substring match -- a
|
||||
# real secret that merely contains one of these words as part of its actual
|
||||
# value must still be reported) are known template placeholders.
|
||||
_GITLEAKS_SUPPRESS_EXACT_VALUES = {
|
||||
"YOUR_YOUTUBE_API_KEY",
|
||||
"YOUR_YOUTUBE_CHANNEL_ID",
|
||||
"YOUR_GITHUB_PERSONAL_ACCESS_TOKEN",
|
||||
}
|
||||
|
||||
# Findings in these files are suppressed regardless of value -- they are
|
||||
# template/example files that are expected to only ever contain placeholders.
|
||||
_GITLEAKS_SUPPRESS_PATHS = [
|
||||
"config_secrets.template.json",
|
||||
"config.template.json",
|
||||
]
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Helpers
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _load(path: Path) -> tuple[dict | list | None, str | None]:
|
||||
"""Load a JSON artifact file.
|
||||
|
||||
Returns (data, error): error is None on success (data is whatever was
|
||||
parsed, which may legitimately be an empty list/dict for a clean scan);
|
||||
otherwise error is a human-readable reason the artifact is unavailable,
|
||||
distinguishing "missing/malformed artifact" from "valid empty result" so
|
||||
callers don't silently treat a broken CI job as a clean pass.
|
||||
"""
|
||||
if not path.exists():
|
||||
return None, f"artifact not found: {path}"
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8")), None
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
return None, f"could not read/parse {path}: {exc}"
|
||||
|
||||
|
||||
def _md_sanitize_cell(value: object) -> str:
|
||||
"""Escape/normalize a value so scanner-controlled content (a matched
|
||||
secret, a bandit issue_text, a file path) can't alter the Markdown
|
||||
table's structure: pipes would add bogus columns, newlines would break
|
||||
out of the row (or forge a fake header/separator line)."""
|
||||
text = str(value)
|
||||
text = text.replace("\\", "\\\\").replace("|", "\\|")
|
||||
text = text.replace("\r\n", " ").replace("\n", " ").replace("\r", " ")
|
||||
return text
|
||||
|
||||
|
||||
def _md_table_row(*cells: str) -> str:
|
||||
return "| " + " | ".join(_md_sanitize_cell(c) for c in cells) + " |"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Per-tool summarizers
|
||||
# Returns: (markdown_lines: list[str], critical_count: int, available: bool)
|
||||
# `available=False` means the artifact was missing or malformed -- distinct
|
||||
# from a valid scan that simply found nothing -- so the caller can report
|
||||
# INCOMPLETE instead of silently counting it as a clean pass.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _summarize_bandit(artifact_dir: Path) -> tuple[list[str], int, bool]:
|
||||
data, error = _load(artifact_dir / "sast-results" / "bandit-results.json")
|
||||
if error:
|
||||
return [f"_bandit results unavailable: {error}_"], 0, False
|
||||
|
||||
results = data.get("results", [])
|
||||
high = [r for r in results if r.get("issue_severity") == "HIGH"]
|
||||
medium = [r for r in results if r.get("issue_severity") == "MEDIUM"]
|
||||
low = [r for r in results if r.get("issue_severity") == "LOW"]
|
||||
|
||||
lines = [
|
||||
f"**Bandit**: {len(high)} HIGH · {len(medium)} MEDIUM · {len(low)} LOW"
|
||||
]
|
||||
|
||||
if high:
|
||||
lines += [
|
||||
"",
|
||||
"| Severity | File | Line | Issue |",
|
||||
"| --- | --- | --- | --- |",
|
||||
]
|
||||
for r in high[:10]:
|
||||
fname = Path(r.get("filename", "")).name
|
||||
lines.append(_md_table_row(
|
||||
"HIGH", f"`{fname}`",
|
||||
str(r.get("line_number", "?")),
|
||||
r.get("issue_text", "")
|
||||
))
|
||||
if len(high) > 10:
|
||||
lines.append(f"_… and {len(high) - 10} more HIGH findings_")
|
||||
|
||||
return lines, len(high), True
|
||||
|
||||
|
||||
def _summarize_pip_audit(artifact_dir: Path) -> tuple[list[str], int, bool]:
|
||||
data, error = _load(artifact_dir / "dependency-audit-results" / "pip-audit-results.json")
|
||||
if error:
|
||||
return [f"_pip-audit results unavailable: {error}_"], 0, False
|
||||
|
||||
# pip-audit JSON format: {"dependencies": [{"name": ..., "vulns": [...]}]}
|
||||
vulns: list[dict] = []
|
||||
for dep in data.get("dependencies", []):
|
||||
for v in dep.get("vulns", []):
|
||||
vulns.append({"package": dep.get("name", "?"), **v})
|
||||
|
||||
lines = [f"**pip-audit**: {len(vulns)} vulnerabilities found"]
|
||||
|
||||
if vulns:
|
||||
lines += ["", "| Package | ID | Fix |", "| --- | --- | --- |"]
|
||||
for v in vulns[:10]:
|
||||
fix = v.get("fix_versions", ["none"])
|
||||
fix_str = ", ".join(fix) if fix else "none"
|
||||
lines.append(_md_table_row(
|
||||
v.get("package", "?"),
|
||||
v.get("id", "?"),
|
||||
fix_str,
|
||||
))
|
||||
|
||||
# Treat known vulnerabilities as warnings, not critical (they may be unavoidable)
|
||||
return lines, 0, True
|
||||
|
||||
|
||||
def _summarize_gitleaks(artifact_dir: Path) -> tuple[list[str], int, bool]:
|
||||
data, error = _load(artifact_dir / "secrets-scan-results" / "gitleaks-results.json")
|
||||
if error:
|
||||
return [f"_gitleaks results unavailable: {error}_"], 0, False
|
||||
|
||||
if not isinstance(data, list):
|
||||
data = []
|
||||
|
||||
real_findings = []
|
||||
suppressed = 0
|
||||
for finding in data:
|
||||
secret_val = str(finding.get("Secret", "") or finding.get("Match", ""))
|
||||
file_name = Path(finding.get("File", "")).name
|
||||
if (secret_val in _GITLEAKS_SUPPRESS_EXACT_VALUES
|
||||
or file_name in _GITLEAKS_SUPPRESS_PATHS):
|
||||
suppressed += 1
|
||||
else:
|
||||
real_findings.append(finding)
|
||||
|
||||
lines = [
|
||||
f"**Gitleaks**: {len(real_findings)} finding(s) "
|
||||
f"({suppressed} suppressed as template placeholders)"
|
||||
]
|
||||
|
||||
if real_findings:
|
||||
lines += ["", "| Rule | File | Line | Description |", "| --- | --- | --- | --- |"]
|
||||
for f in real_findings[:10]:
|
||||
fname = Path(f.get("File", "")).name
|
||||
lines.append(_md_table_row(
|
||||
f.get("RuleID", "?"),
|
||||
f"`{fname}`",
|
||||
str(f.get("StartLine", "?")),
|
||||
f.get("Description", ""),
|
||||
))
|
||||
|
||||
critical = len(real_findings) # any real secret is critical
|
||||
return lines, critical, True
|
||||
|
||||
|
||||
def _summarize_security_proofs(artifact_dir: Path) -> tuple[list[str], int, bool]:
|
||||
data, error = _load(artifact_dir / "security-proofs-results" / "security-proofs-results.json")
|
||||
if error:
|
||||
return [f"_security proofs results unavailable: {error}_"], 0, False
|
||||
|
||||
if not isinstance(data, list):
|
||||
data = []
|
||||
|
||||
critical = [r for r in data if r.get("severity") == "CRITICAL"]
|
||||
warnings = [r for r in data if r.get("severity") == "WARNING"]
|
||||
passed = [r for r in data if r.get("severity") == "PASS"]
|
||||
skipped = [r for r in data if r.get("severity") == "SKIP"]
|
||||
|
||||
lines = [
|
||||
f"**Security Proofs**: "
|
||||
f"{len(passed)} PASS · {len(warnings)} WARN · "
|
||||
f"{len(critical)} CRITICAL · {len(skipped)} SKIP",
|
||||
"",
|
||||
]
|
||||
|
||||
_icon = {"PASS": "✅", "INFO": "ℹ️", "WARNING": "⚠️", # nosec B105 - severity labels, not credentials
|
||||
"CRITICAL": "🚨", "SKIP": "⏭️"}
|
||||
for r in data:
|
||||
icon = _icon.get(r.get("severity", ""), "❓")
|
||||
lines.append(
|
||||
f"- {icon} **{r.get('test_id', '?')}**: {r.get('message', '')}"
|
||||
)
|
||||
if r.get("details") and r.get("severity") in ("CRITICAL", "WARNING"):
|
||||
lines.append(f" - _{r['details']}_")
|
||||
|
||||
return lines, len(critical), True
|
||||
|
||||
|
||||
def _summarize_plugin_audit(artifact_dir: Path) -> tuple[list[str], int, bool]:
|
||||
data, error = _load(artifact_dir / "plugin-audit-results" / "plugin-audit-results.json")
|
||||
if error:
|
||||
return [f"_plugin audit results unavailable: {error}_"], 0, False
|
||||
|
||||
summary = data.get("summary", {})
|
||||
findings = data.get("findings", [])
|
||||
critical_findings = [f for f in findings if f.get("severity") == "CRITICAL"]
|
||||
warning_findings = [f for f in findings if f.get("severity") == "WARNING"]
|
||||
|
||||
lines = [
|
||||
f"**Plugin Audit**: {data.get('plugins_scanned', '?')} plugins scanned — "
|
||||
f"{summary.get('critical', 0)} CRITICAL · {summary.get('warnings', 0)} WARNINGS"
|
||||
]
|
||||
|
||||
if critical_findings:
|
||||
lines += ["", "| Plugin | File | Line | Rule | Message |",
|
||||
"| --- | --- | --- | --- | --- |"]
|
||||
for f in critical_findings[:10]:
|
||||
fname = Path(f.get("file", "")).name
|
||||
lines.append(_md_table_row(
|
||||
f.get("plugin_id", "?"),
|
||||
f"`{fname}`",
|
||||
str(f.get("line", "?")),
|
||||
f.get("rule", "?"),
|
||||
f.get("message", ""),
|
||||
))
|
||||
|
||||
if warning_findings and not critical_findings:
|
||||
lines.append(f"\n_{len(warning_findings)} warning(s) found — see artifact for details_")
|
||||
|
||||
return lines, summary.get("critical", 0), True
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Main
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate consolidated security audit report",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument("--artifact-dir", required=True,
|
||||
help="Directory containing downloaded CI artifacts")
|
||||
parser.add_argument("--output", "-o", required=True,
|
||||
help="Output Markdown file path")
|
||||
parser.add_argument("--verbose", "-v", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
artifact_dir = Path(args.artifact_dir)
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||
|
||||
bandit_lines, bandit_crit, bandit_ok = _summarize_bandit(artifact_dir)
|
||||
pip_audit_lines, pip_audit_crit, pip_audit_ok = _summarize_pip_audit(artifact_dir)
|
||||
gitleaks_lines, gitleaks_crit, gitleaks_ok = _summarize_gitleaks(artifact_dir)
|
||||
proofs_lines, proofs_crit, proofs_ok = _summarize_security_proofs(artifact_dir)
|
||||
plugins_lines, plugins_crit, plugins_ok = _summarize_plugin_audit(artifact_dir)
|
||||
|
||||
unavailable_tools = [
|
||||
name for name, ok in [
|
||||
("bandit", bandit_ok), ("pip-audit", pip_audit_ok),
|
||||
("gitleaks", gitleaks_ok), ("security-proofs", proofs_ok),
|
||||
("plugin-audit", plugins_ok),
|
||||
] if not ok
|
||||
]
|
||||
|
||||
total_critical = bandit_crit + pip_audit_crit + gitleaks_crit + proofs_crit + plugins_crit
|
||||
if unavailable_tools:
|
||||
# A missing/malformed artifact means that tool's checks never
|
||||
# actually ran -- this must not be reported as a clean PASS just
|
||||
# because the *artifacts that did load* found nothing.
|
||||
overall = "INCOMPLETE ⚠️"
|
||||
elif total_critical > 0:
|
||||
overall = "ACTION REQUIRED 🚨"
|
||||
else:
|
||||
overall = "PASSED ✅"
|
||||
|
||||
def section(title: str, lines: list[str]) -> str:
|
||||
return f"### {title}\n\n" + "\n".join(lines) + "\n"
|
||||
|
||||
incomplete_note = (
|
||||
f"\n_⚠️ Incomplete: results unavailable for {', '.join(unavailable_tools)} "
|
||||
f"— see the corresponding section(s) below for details_\n"
|
||||
if unavailable_tools else ""
|
||||
)
|
||||
|
||||
report = f"""## 🔒 Security Audit — {overall}
|
||||
|
||||
_Generated: {timestamp}_
|
||||
{incomplete_note}
|
||||
| Critical | High/Warn | Overall |
|
||||
| :---: | :---: | :---: |
|
||||
| {'🚨 ' + str(total_critical) if total_critical else '✅ 0'} | ⚠️ see below | {overall} |
|
||||
|
||||
---
|
||||
|
||||
{section('SAST — Bandit', bandit_lines)}
|
||||
{section('Dependencies — pip-audit', pip_audit_lines)}
|
||||
{section('Secrets — Gitleaks', gitleaks_lines)}
|
||||
{section('LEDMatrix Security Proofs', proofs_lines)}
|
||||
{section('Plugin Security Audit', plugins_lines)}
|
||||
---
|
||||
|
||||
_Total critical findings: **{total_critical}**_
|
||||
"""
|
||||
|
||||
output_path = Path(args.output)
|
||||
output_path.write_text(report, encoding="utf-8")
|
||||
|
||||
if args.verbose:
|
||||
print(f" Report written to: {output_path}")
|
||||
print(f" Status: {overall}")
|
||||
print(f" Critical findings: {total_critical}")
|
||||
print(f" bandit={bandit_crit} pip-audit={pip_audit_crit} "
|
||||
f"gitleaks={gitleaks_crit} proofs={proofs_crit} plugins={plugins_crit}")
|
||||
if unavailable_tools:
|
||||
print(f" Unavailable: {', '.join(unavailable_tools)}")
|
||||
|
||||
if unavailable_tools:
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,593 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
LEDMatrix Security Proof Tests
|
||||
|
||||
Automated proofs that run in CI to verify security properties hold on every
|
||||
commit. Inspired by the Huntarr security review approach of using standard
|
||||
tooling to confirm specific vulnerability classes are absent.
|
||||
|
||||
Usage:
|
||||
python scripts/prove_security.py
|
||||
python scripts/prove_security.py --verbose
|
||||
python scripts/prove_security.py --output results.json
|
||||
|
||||
Exit code: 1 only if CRITICAL findings are detected. Warnings are reported
|
||||
but do not block CI.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass, asdict
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Result dataclass
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class TestResult:
|
||||
test_id: str
|
||||
severity: str # PASS | INFO | WARNING | CRITICAL | SKIP
|
||||
message: str
|
||||
details: str = ""
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
|
||||
@property
|
||||
def icon(self) -> str:
|
||||
return {
|
||||
"PASS": "✅", # nosec B105 - severity label, not a credential
|
||||
"INFO": "ℹ️ ",
|
||||
"WARNING": "⚠️ ",
|
||||
"CRITICAL": "🚨",
|
||||
"SKIP": "⏭️ ",
|
||||
}.get(self.severity, "❓")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# T1: Plugin Loading / Zip Slip
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_t1a_zip_slip_protection() -> TestResult:
|
||||
"""
|
||||
Verify that zip-slip protection actually guards zip extraction in
|
||||
store_manager.py.
|
||||
|
||||
A whole-file substring check for "is_relative_to"/"Zip-slip detected"
|
||||
would pass even if the guard existed somewhere unrelated, or covered
|
||||
only one of several extract()/extractall() call sites. Instead, this
|
||||
walks the AST: for every extract()/extractall() call, it confirms an
|
||||
is_relative_to() check (and the "Zip-slip detected" log) appears
|
||||
earlier in that same enclosing function -- validate-then-bulk-extract
|
||||
(validate every member, then call extractall() only after all passed)
|
||||
counts as protecting the call, since it covers the same member list.
|
||||
"""
|
||||
store_manager = PROJECT_ROOT / "src" / "plugin_system" / "store_manager.py"
|
||||
if not store_manager.exists():
|
||||
return TestResult("T1a", "CRITICAL",
|
||||
"store_manager.py not found",
|
||||
f"Expected at {store_manager}")
|
||||
|
||||
content = store_manager.read_text(encoding="utf-8")
|
||||
try:
|
||||
tree = ast.parse(content, filename=str(store_manager))
|
||||
except SyntaxError as exc:
|
||||
return TestResult("T1a", "CRITICAL",
|
||||
"store_manager.py could not be parsed",
|
||||
str(exc))
|
||||
|
||||
extraction_sites = 0
|
||||
unprotected: list[str] = []
|
||||
|
||||
for func in ast.walk(tree):
|
||||
if not isinstance(func, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
continue
|
||||
|
||||
extract_calls = [
|
||||
node for node in ast.walk(func)
|
||||
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
|
||||
and node.func.attr in ("extract", "extractall")
|
||||
]
|
||||
if not extract_calls:
|
||||
continue
|
||||
extraction_sites += len(extract_calls)
|
||||
|
||||
guard_lines = [
|
||||
n.lineno for n in ast.walk(func)
|
||||
if isinstance(n, ast.Attribute) and n.attr == "is_relative_to"
|
||||
]
|
||||
has_zip_slip_log = any(
|
||||
isinstance(n, ast.Constant) and isinstance(n.value, str)
|
||||
and "Zip-slip detected" in n.value
|
||||
for n in ast.walk(func)
|
||||
)
|
||||
|
||||
for call in extract_calls:
|
||||
guarded = has_zip_slip_log and any(g < call.lineno for g in guard_lines)
|
||||
if not guarded:
|
||||
unprotected.append(
|
||||
f"{func.name}() line {call.lineno}: {call.func.attr}() call not "
|
||||
f"clearly preceded by an is_relative_to() guard + Zip-slip log "
|
||||
f"in the same function"
|
||||
)
|
||||
|
||||
if extraction_sites == 0:
|
||||
return TestResult("T1a", "WARNING",
|
||||
"No zipfile extract()/extractall() calls found in store_manager.py",
|
||||
"Verify plugin installation no longer extracts zip archives, "
|
||||
"or that this check still targets the right file")
|
||||
|
||||
if unprotected:
|
||||
return TestResult("T1a", "CRITICAL",
|
||||
f"{len(unprotected)} of {extraction_sites} zip extraction "
|
||||
f"call(s) not clearly guarded",
|
||||
"; ".join(unprotected))
|
||||
|
||||
return TestResult("T1a", "PASS",
|
||||
"Zip-slip protection verified",
|
||||
f"All {extraction_sites} extract()/extractall() call(s) in "
|
||||
f"store_manager.py are preceded by an is_relative_to() guard "
|
||||
f"with a Zip-slip log in the same function")
|
||||
|
||||
|
||||
def test_t1b_dangerous_plugin_calls() -> list[TestResult]:
|
||||
"""
|
||||
Scan plugin directories for dangerous function calls (eval, exec).
|
||||
These represent arbitrary code execution risks in plugin code.
|
||||
"""
|
||||
results = []
|
||||
plugin_dirs = [
|
||||
PROJECT_ROOT / "plugins",
|
||||
PROJECT_ROOT / "plugin-repos",
|
||||
]
|
||||
|
||||
violations: list[str] = []
|
||||
files_scanned = 0
|
||||
|
||||
scan_errors: list[str] = []
|
||||
|
||||
for base in plugin_dirs:
|
||||
if not base.exists():
|
||||
continue
|
||||
for plugin_dir in sorted(base.iterdir()):
|
||||
if not plugin_dir.is_dir() or plugin_dir.name.startswith(('.', '_')):
|
||||
continue
|
||||
for py_file in plugin_dir.rglob("*.py"):
|
||||
files_scanned += 1
|
||||
try:
|
||||
source = py_file.read_text(encoding="utf-8")
|
||||
tree = ast.parse(source, filename=str(py_file))
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
|
||||
if node.func.id in ("eval", "exec"):
|
||||
rel = py_file.relative_to(PROJECT_ROOT)
|
||||
violations.append(
|
||||
f"{rel}:{node.lineno} — {node.func.id}() call")
|
||||
except (SyntaxError, OSError) as exc:
|
||||
# A file we couldn't parse/read was never actually
|
||||
# scanned for eval()/exec() -- that must block this
|
||||
# test, not silently pass as if it were clean.
|
||||
rel = py_file.relative_to(PROJECT_ROOT)
|
||||
scan_errors.append(f"{rel} — {type(exc).__name__}: {exc}")
|
||||
|
||||
if scan_errors:
|
||||
results.append(TestResult(
|
||||
"T1b", "CRITICAL",
|
||||
f"{len(scan_errors)} plugin file(s) could not be scanned for eval()/exec()",
|
||||
"; ".join(scan_errors[:10])
|
||||
))
|
||||
|
||||
if violations:
|
||||
results.append(TestResult(
|
||||
"T1b", "CRITICAL",
|
||||
f"Dangerous function calls found in plugins ({len(violations)} instance(s))",
|
||||
"; ".join(violations[:10])
|
||||
))
|
||||
elif not scan_errors:
|
||||
results.append(TestResult(
|
||||
"T1b", "PASS",
|
||||
"No eval()/exec() calls found in plugins",
|
||||
f"{files_scanned} plugin Python files scanned"
|
||||
))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# T2: API Surface Inventory
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_t2a_api_surface_inventory() -> TestResult:
|
||||
"""
|
||||
Document the API surface area.
|
||||
|
||||
This app intentionally has no authentication (local-only Raspberry Pi
|
||||
design, documented in web_interface/app.py). This test produces an
|
||||
inventory for audit purposes and warns only if the design-intent comment
|
||||
is removed from app.py (which would indicate someone deleted the rationale
|
||||
without adding auth, rather than a deliberate undocumented change).
|
||||
"""
|
||||
api_file = PROJECT_ROOT / "web_interface" / "blueprints" / "api_v3.py"
|
||||
app_file = PROJECT_ROOT / "web_interface" / "app.py"
|
||||
|
||||
if not api_file.exists():
|
||||
return TestResult("T2a", "WARNING", "api_v3.py not found", str(api_file))
|
||||
|
||||
api_content = api_file.read_text(encoding="utf-8")
|
||||
routes = re.findall(r"@api_v3\.route\('([^']+)'", api_content)
|
||||
|
||||
csrf_documented = False
|
||||
if app_file.exists():
|
||||
app_content = app_file.read_text(encoding="utf-8")
|
||||
csrf_documented = "CSRF protection disabled for local-only" in app_content
|
||||
|
||||
summary = (
|
||||
f"{len(routes)} API routes in api_v3.py. "
|
||||
f"No auth decorators (intentional local-only design). "
|
||||
f"CSRF disabled: {'YES — design intent documented in app.py' if csrf_documented else 'YES — but design intent comment NOT found in app.py'}. "
|
||||
f"Rate limiting: 1000/min."
|
||||
)
|
||||
|
||||
if not csrf_documented:
|
||||
return TestResult(
|
||||
"T2a", "WARNING",
|
||||
"CSRF is disabled but the design-intent comment is missing from app.py",
|
||||
"Add the rationale comment back, or add proper CSRF protection if "
|
||||
"the app is now internet-facing"
|
||||
)
|
||||
|
||||
# There is currently no config mechanism that actually enforces the
|
||||
# local-only boundary the design-intent comment describes -- app.py
|
||||
# hardcodes host='0.0.0.0' unconditionally, so nothing here can confirm
|
||||
# this deployment is in fact LAN-only. Reporting this as mere INFO
|
||||
# understates that: an unauthenticated, CSRF-disabled API surface is a
|
||||
# real risk the moment this ever runs somewhere other than a home LAN,
|
||||
# documented rationale or not.
|
||||
return TestResult(
|
||||
"T2a", "WARNING",
|
||||
"API surface has no auth and CSRF disabled; enforcement of the "
|
||||
"documented local-only boundary cannot be confirmed",
|
||||
summary
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# T3: Secrets & Credential Handling
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Patterns that suggest real credentials (must be >8 chars, not placeholders)
|
||||
_SECRET_PATTERNS = [
|
||||
(r'(?i)password\s*=\s*["\'](?!none|empty|placeholder|example|test|default|""|'')[^"\']{8,}["\']', "WARNING", "password"),
|
||||
(r'(?i)api[_-]?key\s*=\s*["\'](?!none|empty|placeholder|YOUR_|example|test)[^"\']{16,}["\']', "WARNING", "api_key"),
|
||||
(r'(?i)secret\s*=\s*["\'](?!none|empty|placeholder|YOUR_|example|test)[^"\']{16,}["\']', "WARNING", "secret"),
|
||||
# Real GitHub token pattern
|
||||
(r'ghp_[a-zA-Z0-9]{36}', "CRITICAL", "github_token"),
|
||||
# Generic long bearer tokens
|
||||
(r'Bearer\s+[a-zA-Z0-9\-_\.]{32,}', "WARNING", "bearer_token"),
|
||||
]
|
||||
|
||||
_TEMPLATE_SKIP_STRINGS = [
|
||||
"YOUR_", "PLACEHOLDER", "_HERE", "example.com", "config_secrets.template",
|
||||
"prove_security", # this file itself
|
||||
]
|
||||
|
||||
_SCAN_DIRS = ["src", "web_interface", "scripts"]
|
||||
|
||||
|
||||
def test_t3a_hardcoded_secrets() -> TestResult:
|
||||
"""Scan source code for hardcoded credentials."""
|
||||
violations: list[str] = []
|
||||
|
||||
for dir_name in _SCAN_DIRS:
|
||||
scan_dir = PROJECT_ROOT / dir_name
|
||||
if not scan_dir.exists():
|
||||
continue
|
||||
for py_file in scan_dir.rglob("*.py"):
|
||||
# Skip test files and this script
|
||||
if "test" in str(py_file).lower() or "prove_security" in str(py_file):
|
||||
continue
|
||||
try:
|
||||
content = py_file.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
for pattern, severity, pattern_type in _SECRET_PATTERNS:
|
||||
for match in re.finditer(pattern, content):
|
||||
line_content = match.group(0)
|
||||
# Skip lines containing template placeholder strings.
|
||||
# line_content is only used for this in-memory check --
|
||||
# it must never be stored or included in output below.
|
||||
if any(skip in line_content for skip in _TEMPLATE_SKIP_STRINGS):
|
||||
continue
|
||||
rel = py_file.relative_to(PROJECT_ROOT)
|
||||
line_no = content[: match.start()].count("\n") + 1
|
||||
# Redacted fingerprint lets the same finding be recognized
|
||||
# across scans without ever reporting the matched
|
||||
# credential itself (which would otherwise get published
|
||||
# into CI logs, JSON artifacts, and PR comments -- wider
|
||||
# exposure than the original leak).
|
||||
fingerprint = hashlib.sha256(line_content.encode()).hexdigest()[:12]
|
||||
violations.append(
|
||||
f"[{severity}] {rel}:{line_no} — {pattern_type} "
|
||||
f"(fingerprint {fingerprint})"
|
||||
)
|
||||
|
||||
critical_violations = [v for v in violations if "[CRITICAL]" in v]
|
||||
if critical_violations:
|
||||
return TestResult(
|
||||
"T3a", "CRITICAL",
|
||||
f"Hardcoded secrets found ({len(critical_violations)} critical)",
|
||||
"; ".join(critical_violations[:5])
|
||||
)
|
||||
if violations:
|
||||
return TestResult(
|
||||
"T3a", "WARNING",
|
||||
f"Potential hardcoded secrets found ({len(violations)} instance(s))",
|
||||
"; ".join(violations[:5])
|
||||
)
|
||||
|
||||
return TestResult("T3a", "PASS", "No hardcoded secrets detected",
|
||||
f"Scanned {', '.join(_SCAN_DIRS)}")
|
||||
|
||||
|
||||
def test_t3b_plaintext_password_storage() -> TestResult:
|
||||
"""
|
||||
Check for user account password storage without hashing.
|
||||
|
||||
The LEDMatrix app has no user account system, so this should produce INFO.
|
||||
It would only CRITICAL if someone added user auth and stored passwords without hashing.
|
||||
|
||||
We require all three of: a password *variable assignment or DB operation*,
|
||||
a clear storage call (INSERT / db commit / ORM save), and no hashing lib present
|
||||
— to avoid false positives from files that contain 'password' for WiFi handling
|
||||
and '.save()' for image/file saving in unrelated functions.
|
||||
"""
|
||||
hashing_libs = ["bcrypt", "argon2", "pbkdf2", "scrypt",
|
||||
"generate_password_hash", "hashpw", "make_password"]
|
||||
# Patterns that indicate password being stored in a database / ORM context.
|
||||
# Must be specific enough to avoid matching set.add(), file.save(), etc.
|
||||
db_storage_patterns = ["INSERT INTO", "db.session", "session.add(", "session.commit(", "orm.save"]
|
||||
|
||||
password_storage_found = False
|
||||
|
||||
for dir_name in _SCAN_DIRS:
|
||||
scan_dir = PROJECT_ROOT / dir_name
|
||||
if not scan_dir.exists():
|
||||
continue
|
||||
for py_file in scan_dir.rglob("*.py"):
|
||||
try:
|
||||
content = py_file.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
continue
|
||||
# Require DB/ORM context specifically — not just any .save() call
|
||||
if ("password" in content.lower() and
|
||||
any(store in content for store in db_storage_patterns) and
|
||||
not any(h in content for h in hashing_libs)):
|
||||
password_storage_found = True
|
||||
|
||||
if password_storage_found:
|
||||
return TestResult(
|
||||
"T3b", "CRITICAL",
|
||||
"Potential plaintext password storage in database/ORM detected",
|
||||
"Found password + database storage operations without a recognized hashing library"
|
||||
)
|
||||
|
||||
return TestResult("T3b", "INFO",
|
||||
"No plaintext password storage detected",
|
||||
"App has no user account system — expected result")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# T4: Path Traversal
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_t4a_path_traversal() -> TestResult:
|
||||
"""
|
||||
Verify static file serving uses send_from_directory (safe) rather than
|
||||
open() with user-supplied paths. Also checks for extractall() calls that
|
||||
lack the is_relative_to() guard.
|
||||
"""
|
||||
issues: list[str] = []
|
||||
|
||||
app_file = PROJECT_ROOT / "web_interface" / "app.py"
|
||||
if app_file.exists():
|
||||
content = app_file.read_text(encoding="utf-8")
|
||||
# The file-serve route should use send_from_directory or commonpath
|
||||
if "send_from_directory" not in content and "commonpath" not in content:
|
||||
issues.append("app.py: file-serve routes may not use send_from_directory/commonpath")
|
||||
|
||||
# Check all extractall() calls have a preceding is_relative_to guard
|
||||
for py_file in (PROJECT_ROOT / "src").rglob("*.py"):
|
||||
try:
|
||||
content = py_file.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
continue
|
||||
if "extractall(" in content and "is_relative_to" not in content:
|
||||
rel = py_file.relative_to(PROJECT_ROOT)
|
||||
issues.append(f"{rel}: extractall() without is_relative_to() guard")
|
||||
|
||||
if issues:
|
||||
return TestResult(
|
||||
"T4a", "WARNING",
|
||||
f"Potential path traversal patterns found ({len(issues)})",
|
||||
"; ".join(issues)
|
||||
)
|
||||
|
||||
return TestResult("T4a", "PASS",
|
||||
"Path traversal mitigations verified",
|
||||
"send_from_directory/commonpath used for file serving; "
|
||||
"extractall() calls have is_relative_to() guards")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# T5: Auth Bypass Patterns
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_t5a_auth_bypass_patterns() -> TestResult:
|
||||
"""
|
||||
Look for broken auth bypass patterns — not the intentional no-auth design
|
||||
(T2a covers that), but patterns that suggest auth was INTENDED to exist
|
||||
but has an exploitable bypass: broad substring matching, debug-mode skips,
|
||||
or if-True conditions.
|
||||
"""
|
||||
bypass_signals = [
|
||||
(r'if\s+True\s*:', "if True: bypass"),
|
||||
(r'if\s+debug\s*:', "debug-mode auth skip"),
|
||||
(r'request\.path\s+in\s+', "substring path matching in auth (Huntarr pattern)"),
|
||||
(r'EXEMPT_ROUTES\s*=', "exempt routes list"),
|
||||
]
|
||||
|
||||
findings: list[str] = []
|
||||
|
||||
for dir_name in ["src", "web_interface"]:
|
||||
scan_dir = PROJECT_ROOT / dir_name
|
||||
if not scan_dir.exists():
|
||||
continue
|
||||
for py_file in scan_dir.rglob("*.py"):
|
||||
try:
|
||||
content = py_file.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
continue
|
||||
for pattern, label in bypass_signals:
|
||||
if re.search(pattern, content):
|
||||
# Only flag if the file also contains auth-related terms
|
||||
if any(auth in content.lower() for auth in
|
||||
["auth", "login", "authenticate", "token", "permission"]):
|
||||
rel = py_file.relative_to(PROJECT_ROOT)
|
||||
findings.append(f"{rel}: {label}")
|
||||
|
||||
if findings:
|
||||
return TestResult(
|
||||
"T5a", "WARNING",
|
||||
f"Potential auth bypass patterns found ({len(findings)})",
|
||||
"; ".join(findings[:5])
|
||||
)
|
||||
|
||||
return TestResult("T5a", "PASS",
|
||||
"No auth bypass patterns detected",
|
||||
"Checked src/ and web_interface/ for bypass signals")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# T6: Docker / Container Hardening
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_t6_docker_hardening() -> TestResult:
|
||||
"""Container security — skipped if no Dockerfile exists."""
|
||||
dockerfile = PROJECT_ROOT / "Dockerfile"
|
||||
if not dockerfile.exists():
|
||||
return TestResult("T6", "SKIP",
|
||||
"No Dockerfile found — container security scan not applicable",
|
||||
"If Docker support is added in future, enable hadolint/trivy scanning "
|
||||
"in .github/workflows/security-audit.yml")
|
||||
|
||||
content = dockerfile.read_text(encoding="utf-8")
|
||||
issues: list[str] = []
|
||||
|
||||
# Check for non-root USER directive
|
||||
user_lines = [l for l in content.splitlines() if l.strip().startswith("USER")]
|
||||
if not user_lines or user_lines[-1].strip() == "USER root":
|
||||
issues.append("Container runs as root — use USER directive to drop privileges")
|
||||
|
||||
# Check for pinned base image tags. A tag (even a specific version, not
|
||||
# just :latest) is mutable -- the same tag can point to a different
|
||||
# image later. Only a @sha256 digest is truly immutable/reproducible.
|
||||
from_lines = [line for line in content.splitlines() if line.strip().startswith("FROM")]
|
||||
for from_line in from_lines:
|
||||
parts = from_line.split()
|
||||
# FROM [--platform=<platform>] <image> [AS <name>] -- skip an
|
||||
# optional --platform= flag so it's never mistaken for the image
|
||||
# token itself (which would falsely report it as unpinned).
|
||||
image_parts = [p for p in parts[1:] if not p.startswith("--platform=")]
|
||||
if image_parts:
|
||||
image = image_parts[0]
|
||||
if "@sha256:" not in image:
|
||||
issues.append(f"Base image not pinned to a digest: {image}")
|
||||
|
||||
if issues:
|
||||
return TestResult("T6", "WARNING",
|
||||
f"Dockerfile hardening issues ({len(issues)})",
|
||||
"; ".join(issues))
|
||||
|
||||
return TestResult("T6", "PASS", "Dockerfile hardening checks passed", "")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Runner
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="LEDMatrix security proof tests",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument("--output", "-o", default=None,
|
||||
help="Write JSON results to this file")
|
||||
parser.add_argument("--verbose", "-v", action="store_true",
|
||||
help="Show details for each check")
|
||||
args = parser.parse_args()
|
||||
|
||||
print("=" * 60)
|
||||
print("LEDMatrix Security Proof Tests")
|
||||
print(f"Project root: {PROJECT_ROOT}")
|
||||
print("=" * 60)
|
||||
|
||||
all_results: list[TestResult] = []
|
||||
|
||||
# Run all test groups
|
||||
all_results.append(test_t1a_zip_slip_protection())
|
||||
all_results.extend(test_t1b_dangerous_plugin_calls())
|
||||
all_results.append(test_t2a_api_surface_inventory())
|
||||
all_results.append(test_t3a_hardcoded_secrets())
|
||||
all_results.append(test_t3b_plaintext_password_storage())
|
||||
all_results.append(test_t4a_path_traversal())
|
||||
all_results.append(test_t5a_auth_bypass_patterns())
|
||||
all_results.append(test_t6_docker_hardening())
|
||||
|
||||
# Print results
|
||||
print()
|
||||
for r in all_results:
|
||||
line = f" {r.icon} [{r.severity:<8}] {r.test_id}: {r.message}"
|
||||
print(line)
|
||||
if args.verbose and r.details:
|
||||
print(f" {r.details}")
|
||||
|
||||
# Tally
|
||||
critical = [r for r in all_results if r.severity == "CRITICAL"]
|
||||
warnings = [r for r in all_results if r.severity == "WARNING"]
|
||||
passed = [r for r in all_results if r.severity == "PASS"]
|
||||
skipped = [r for r in all_results if r.severity == "SKIP"]
|
||||
|
||||
print()
|
||||
print(f" Results: {len(passed)} PASS {len(warnings)} WARN "
|
||||
f"{len(critical)} CRITICAL {len(skipped)} SKIP")
|
||||
|
||||
# Write JSON output
|
||||
if args.output:
|
||||
output_data = [r.to_dict() for r in all_results]
|
||||
Path(args.output).write_text(
|
||||
json.dumps(output_data, indent=2), encoding="utf-8"
|
||||
)
|
||||
print(f" Results written to: {args.output}")
|
||||
|
||||
if critical:
|
||||
print(f"\n 🚨 {len(critical)} CRITICAL issue(s) found — blocking")
|
||||
return 1
|
||||
|
||||
if warnings:
|
||||
print(f"\n ⚠️ {len(warnings)} warning(s) found — non-blocking")
|
||||
|
||||
print("\n ✅ All checks passed (warnings are non-blocking)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -28,7 +28,7 @@ os.environ['EMULATOR'] = 'true'
|
||||
# Import logger after path setup so src.logging_config is importable
|
||||
from src.logging_config import get_logger # noqa: E402
|
||||
from src.plugin_system.testing.loading import ( # noqa: E402
|
||||
find_plugin_dir, load_manifest, load_config_defaults,
|
||||
build_full_config, find_plugin_dir, load_manifest,
|
||||
)
|
||||
logger = get_logger("[Render Plugin]")
|
||||
|
||||
@@ -83,16 +83,13 @@ def main() -> int:
|
||||
manifest = load_manifest(Path(plugin_dir))
|
||||
|
||||
# Parse config: start with schema defaults, then apply overrides
|
||||
config_defaults = load_config_defaults(Path(plugin_dir))
|
||||
try:
|
||||
user_config = json.loads(args.config)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error("Invalid JSON config: %s", e)
|
||||
return 1
|
||||
|
||||
config = {'enabled': True}
|
||||
config.update(config_defaults)
|
||||
config.update(user_config)
|
||||
config = build_full_config(Path(plugin_dir), cli_config=user_config)
|
||||
|
||||
# Load mock data if provided
|
||||
mock_data = {}
|
||||
|
||||
@@ -209,6 +209,11 @@
|
||||
onchange="onConfigChange()">
|
||||
<span class="text-xs ml-2" style="color: var(--text-secondary);">px</span>
|
||||
</div>
|
||||
<select id="sizePreset" onchange="applySizePreset()"
|
||||
class="w-full mt-2 px-2 py-1.5 rounded text-xs"
|
||||
style="background: var(--bg-primary); color: var(--text-secondary); border: 1px solid var(--border-color);">
|
||||
<option value="">Preset sizes…</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Config form -->
|
||||
@@ -242,13 +247,18 @@
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Render button -->
|
||||
<!-- Render buttons -->
|
||||
<div class="flex gap-2">
|
||||
<button onclick="renderPlugin()" id="renderBtn"
|
||||
class="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium text-white"
|
||||
style="background: var(--accent);">
|
||||
Render
|
||||
</button>
|
||||
<button onclick="renderAllSizes()" id="renderAllBtn" title="Render at every harness test size"
|
||||
class="px-4 py-2.5 rounded-lg text-sm font-medium"
|
||||
style="background: var(--bg-tertiary); color: var(--text-primary); border: 1px solid var(--border-color);">
|
||||
All Sizes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -311,6 +321,15 @@
|
||||
<div id="messagesPanel" class="panel p-3 hidden">
|
||||
<div id="messagesList" class="text-xs font-mono space-y-1"></div>
|
||||
</div>
|
||||
|
||||
<!-- Multi-size gallery -->
|
||||
<div id="galleryPanel" class="panel p-4 hidden">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<span class="text-xs font-medium" style="color: var(--text-secondary);">All Sizes</span>
|
||||
<span class="text-xs" style="color: var(--text-secondary);" id="galleryStatus"></span>
|
||||
</div>
|
||||
<div id="galleryGrid" class="flex flex-wrap gap-4 items-start"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -340,8 +359,30 @@
|
||||
opt.textContent = `${p.name} (${p.id})`;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
|
||||
// Load harness size presets
|
||||
try {
|
||||
const sizesRes = await fetch('/api/sizes');
|
||||
const sizesData = await sizesRes.json();
|
||||
const preset = document.getElementById('sizePreset');
|
||||
(sizesData.sizes || []).forEach(([w, h]) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = `${w}x${h}`;
|
||||
opt.textContent = `${w} x ${h}`;
|
||||
preset.appendChild(opt);
|
||||
});
|
||||
} catch (e) { /* presets are a convenience; ignore */ }
|
||||
});
|
||||
|
||||
function applySizePreset() {
|
||||
const value = document.getElementById('sizePreset').value;
|
||||
if (!value) return;
|
||||
const [w, h] = value.split('x');
|
||||
document.getElementById('displayWidth').value = w;
|
||||
document.getElementById('displayHeight').value = h;
|
||||
onConfigChange();
|
||||
}
|
||||
|
||||
// ---------- Plugin selection ----------
|
||||
async function onPluginChange() {
|
||||
const pluginId = document.getElementById('pluginSelect').value;
|
||||
@@ -485,6 +526,89 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Multi-size gallery ----------
|
||||
async function renderAllSizes() {
|
||||
if (!currentPluginId) return;
|
||||
|
||||
const btn = document.getElementById('renderAllBtn');
|
||||
const panel = document.getElementById('galleryPanel');
|
||||
const grid = document.getElementById('galleryGrid');
|
||||
const status = document.getElementById('galleryStatus');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Rendering…';
|
||||
panel.classList.remove('hidden');
|
||||
grid.innerHTML = '';
|
||||
status.textContent = 'Rendering at all harness sizes…';
|
||||
|
||||
const config = jsonEditor ? jsonEditor.getValue() : {};
|
||||
config.enabled = true;
|
||||
let mockData = {};
|
||||
const mockInput = document.getElementById('mockDataInput').value.trim();
|
||||
if (mockInput) {
|
||||
try { mockData = JSON.parse(mockInput); }
|
||||
catch (e) { showMessages([], [`Mock data JSON error: ${e.message}`]); }
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/render-matrix', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
plugin_id: currentPluginId,
|
||||
config: config,
|
||||
mock_data: mockData,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.error) {
|
||||
status.textContent = data.error;
|
||||
return;
|
||||
}
|
||||
|
||||
let failures = 0;
|
||||
(data.results || []).forEach(r => {
|
||||
const cell = document.createElement('div');
|
||||
cell.style.cssText = 'display:flex;flex-direction:column;gap:4px;';
|
||||
const failed = (r.errors || []).length > 0 || !r.image;
|
||||
if (failed) failures++;
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.className = 'text-xs font-mono';
|
||||
label.style.color = failed ? '#f87171' : 'var(--text-secondary)';
|
||||
label.textContent = `${r.width}x${r.height} · ${r.render_time_ms}ms`;
|
||||
cell.appendChild(label);
|
||||
|
||||
if (r.image) {
|
||||
const img = document.createElement('img');
|
||||
img.src = r.image;
|
||||
// Small panels get 2x zoom so they stay legible in the grid
|
||||
const zoom = r.height >= 128 ? 1 : 2;
|
||||
img.style.cssText =
|
||||
`image-rendering: pixelated; width:${r.width * zoom}px; ` +
|
||||
`height:${r.height * zoom}px; ` +
|
||||
`border:1px solid ${failed ? '#f87171' : 'var(--border-color)'};`;
|
||||
cell.appendChild(img);
|
||||
}
|
||||
if (failed) {
|
||||
const err = document.createElement('span');
|
||||
err.className = 'text-xs font-mono';
|
||||
err.style.color = '#f87171';
|
||||
err.textContent = (r.errors || ['render failed']).join('; ');
|
||||
cell.appendChild(err);
|
||||
}
|
||||
grid.appendChild(cell);
|
||||
});
|
||||
status.textContent = failures
|
||||
? `${failures} size(s) failed`
|
||||
: `${(data.results || []).length} sizes rendered`;
|
||||
} catch (e) {
|
||||
status.textContent = `Network error: ${e.message}`;
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'All Sizes';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Zoom ----------
|
||||
function updateZoom() {
|
||||
const zoom = parseInt(document.getElementById('zoomSlider').value);
|
||||
|
||||
@@ -78,21 +78,17 @@ class WiFiMonitorDaemon:
|
||||
|
||||
while self.running:
|
||||
try:
|
||||
# Get current status before checking
|
||||
status = self.wifi_manager.get_wifi_status()
|
||||
ethernet_connected = self.wifi_manager._is_ethernet_connected()
|
||||
|
||||
# Check WiFi status and manage AP mode
|
||||
state_changed = self.wifi_manager.check_and_manage_ap_mode()
|
||||
|
||||
# Get updated status after check
|
||||
updated_status = self.wifi_manager.get_wifi_status()
|
||||
updated_ethernet = self.wifi_manager._is_ethernet_connected()
|
||||
|
||||
# One combined check that also returns the state it observed —
|
||||
# the previous flow fetched status before AND after the check
|
||||
# on top of the check's own internal fetch, each one several
|
||||
# nmcli subprocess forks, every 30s, forever.
|
||||
(state_changed, updated_status, updated_ethernet,
|
||||
ap_active) = self.wifi_manager.check_and_manage_ap_mode_with_state()
|
||||
|
||||
current_state = {
|
||||
'connected': updated_status.connected,
|
||||
'ethernet_connected': updated_ethernet,
|
||||
'ap_active': updated_status.ap_mode_active,
|
||||
'ap_active': ap_active,
|
||||
'ssid': updated_status.ssid
|
||||
}
|
||||
|
||||
@@ -109,7 +105,7 @@ class WiFiMonitorDaemon:
|
||||
else:
|
||||
logger.debug("Ethernet not connected")
|
||||
|
||||
if updated_status.ap_mode_active:
|
||||
if ap_active:
|
||||
logger.info(f"AP mode ACTIVE - SSID: {ap_ssid} (IP: 192.168.4.1)")
|
||||
else:
|
||||
logger.debug("AP mode inactive")
|
||||
@@ -123,16 +119,16 @@ class WiFiMonitorDaemon:
|
||||
# Log periodic status (less verbose)
|
||||
if updated_status.connected:
|
||||
logger.debug(f"Status check: WiFi={updated_status.ssid} ({updated_status.signal}%), "
|
||||
f"Ethernet={updated_ethernet}, AP={updated_status.ap_mode_active}")
|
||||
f"Ethernet={updated_ethernet}, AP={ap_active}")
|
||||
else:
|
||||
logger.debug(f"Status check: WiFi=disconnected, Ethernet={updated_ethernet}, AP={updated_status.ap_mode_active}")
|
||||
logger.debug(f"Status check: WiFi=disconnected, Ethernet={updated_ethernet}, AP={ap_active}")
|
||||
|
||||
# Escalating recovery: if nmcli reports connected but actual internet
|
||||
# is unreachable for several consecutive checks, restart NetworkManager.
|
||||
# This is done HERE (not inside check_and_manage_ap_mode) to keep the
|
||||
# AP-enable trigger clean and avoid false-positive AP enables from
|
||||
# transient packet loss on otherwise working WiFi.
|
||||
if updated_status.connected and not updated_status.ap_mode_active:
|
||||
if updated_status.connected and not ap_active:
|
||||
if not self.wifi_manager.check_internet_connectivity():
|
||||
self._consecutive_internet_failures += 1
|
||||
logger.warning(
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
#!/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
|
||||
|
||||
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'] = ImageFont.truetype(press, 10)
|
||||
fonts['time'] = ImageFont.truetype(press, 8)
|
||||
fonts['team'] = ImageFont.truetype(press, 8)
|
||||
fonts['status'] = ImageFont.truetype(small, 6)
|
||||
fonts['detail'] = ImageFont.truetype(small, 6)
|
||||
fonts['rank'] = ImageFont.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())
|
||||
@@ -0,0 +1,23 @@
|
||||
# skins/
|
||||
|
||||
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>` (or via the Plugin
|
||||
Store for registry entries with `"type": "skin"`).
|
||||
- Select it: set `"skin": "<skin-id>"` in the plugin's section of
|
||||
`config/config.json`, or use the web UI's 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.
|
||||
|
After Width: | Height: | Size: 5.3 KiB |
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
"""
|
||||
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
|
||||
@@ -4,5 +4,5 @@ LEDMatrix Display System
|
||||
Core source package for the LED Matrix Display project.
|
||||
"""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
__version__ = "3.1.0"
|
||||
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
"""
|
||||
Adaptive image fitting for plugins — the image counterpart to
|
||||
src/adaptive_layout.py's text fitting.
|
||||
|
||||
Promotes the proven in-field image patterns into one shared helper so
|
||||
plugins stop hand-copying resize/cache code:
|
||||
|
||||
- "crop transparent padding, then fill the row height" (football/hockey
|
||||
logo pattern) -> ``crop_to_ink=True, mode="fill_height"``
|
||||
- "crop-to-fill with a top anchor for faces" (masters-tournament headshot
|
||||
pattern) -> ``mode="cover", anchor="top"``
|
||||
- "letterbox to fit, centered on a background" (static-image pattern)
|
||||
-> ``mode="contain"``
|
||||
- NEAREST for pixel art/flags vs LANCZOS for photos (masters flag pattern)
|
||||
-> ``resample=RESAMPLE_NEAREST``
|
||||
|
||||
Unlike PIL's ``thumbnail()`` (downscale-only — the reason plugin imagery
|
||||
stays tiny on big panels), ``fit_image`` upscales by default so content
|
||||
genuinely adapts to larger displays; pass ``upscale=False`` for the old
|
||||
behavior.
|
||||
|
||||
Use via ``LayoutContext.fit_image(...)`` (cached per panel size) or
|
||||
``BasePlugin.draw_image(...)``; the module-level functions are the
|
||||
uncached primitives.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional, Tuple
|
||||
|
||||
from PIL import Image
|
||||
|
||||
# The one Pillow >= 9.1 compat shim (replaces the per-plugin copies).
|
||||
try:
|
||||
RESAMPLE_LANCZOS = Image.Resampling.LANCZOS
|
||||
RESAMPLE_NEAREST = Image.Resampling.NEAREST
|
||||
except AttributeError: # Pillow < 9.1
|
||||
RESAMPLE_LANCZOS = Image.LANCZOS
|
||||
RESAMPLE_NEAREST = Image.NEAREST
|
||||
|
||||
FIT_MODES = ("contain", "cover", "fill_height", "stretch")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ImageFitResult:
|
||||
"""A processed RGBA copy of a source image, sized for a target box."""
|
||||
image: Image.Image
|
||||
width: int
|
||||
height: int
|
||||
scale: float # scale applied vs the (possibly ink-cropped) source
|
||||
mode: str
|
||||
source_size: Tuple[int, int]
|
||||
|
||||
@property
|
||||
def is_empty(self) -> bool:
|
||||
return self.width <= 0 or self.height <= 0
|
||||
|
||||
|
||||
_EMPTY_IMAGE = Image.new("RGBA", (1, 1), (0, 0, 0, 0))
|
||||
|
||||
|
||||
def _empty_result(mode: str, source_size: Tuple[int, int]) -> ImageFitResult:
|
||||
return ImageFitResult(_EMPTY_IMAGE, 0, 0, 0.0, mode, source_size)
|
||||
|
||||
|
||||
def _box_dims(box: Any) -> Tuple[int, int]:
|
||||
"""Accept a Region (duck-typed .w/.h) or a (w, h) tuple."""
|
||||
if hasattr(box, "w") and hasattr(box, "h"):
|
||||
return (int(box.w), int(box.h))
|
||||
w, h = box
|
||||
return (int(w), int(h))
|
||||
|
||||
|
||||
def fit_image(img: Image.Image, box: Any, *, mode: str = "contain",
|
||||
crop_to_ink: bool = False, anchor: str = "center",
|
||||
resample: Any = None, upscale: bool = True) -> ImageFitResult:
|
||||
"""Fit an image into a box, preserving crispness policy per content type.
|
||||
|
||||
Args:
|
||||
img: Source PIL image (any mode; output is always RGBA).
|
||||
box: Region or (w, h) target box.
|
||||
mode: "contain" (letterbox), "cover" (crop-to-fill),
|
||||
"fill_height" (height == box height, contain-capped by width),
|
||||
"stretch" (exact resize).
|
||||
crop_to_ink: Trim fully-transparent padding (getbbox) before fitting —
|
||||
logos shipped with generous padding otherwise render small.
|
||||
anchor: For "cover" crops: "center" or "top" (keeps faces/tops).
|
||||
resample: PIL resampling filter; defaults to RESAMPLE_LANCZOS.
|
||||
Use RESAMPLE_NEAREST for pixel art, flags, and sprite icons.
|
||||
upscale: Allow scaling above source size (default True — the adaptive
|
||||
point). False mimics the legacy thumbnail() behavior.
|
||||
"""
|
||||
if mode not in FIT_MODES:
|
||||
raise ValueError(f"Unknown fit mode '{mode}' (expected one of {FIT_MODES})")
|
||||
box_w, box_h = _box_dims(box)
|
||||
if box_w <= 0 or box_h <= 0 or img.width <= 0 or img.height <= 0:
|
||||
return _empty_result(mode, img.size)
|
||||
|
||||
resample = RESAMPLE_LANCZOS if resample is None else resample
|
||||
|
||||
work = img if img.mode == "RGBA" else img.convert("RGBA")
|
||||
if crop_to_ink:
|
||||
bbox = work.getbbox()
|
||||
if bbox is None: # fully transparent
|
||||
return _empty_result(mode, img.size)
|
||||
work = work.crop(bbox)
|
||||
|
||||
src_w, src_h = work.size
|
||||
|
||||
if mode == "stretch":
|
||||
out = work.resize((box_w, box_h), resample)
|
||||
return ImageFitResult(out, box_w, box_h, box_w / src_w, mode, (src_w, src_h))
|
||||
|
||||
if mode == "cover":
|
||||
scale = max(box_w / src_w, box_h / src_h)
|
||||
if not upscale:
|
||||
scale = min(scale, 1.0)
|
||||
scaled_w = max(1, round(src_w * scale))
|
||||
scaled_h = max(1, round(src_h * scale))
|
||||
out = work.resize((scaled_w, scaled_h), resample)
|
||||
# Crop the overhang down to the box (only when the scaled image is
|
||||
# larger; with upscale=False it may be smaller and is left as-is).
|
||||
crop_w, crop_h = min(box_w, scaled_w), min(box_h, scaled_h)
|
||||
left = (scaled_w - crop_w) // 2
|
||||
top = 0 if anchor == "top" else (scaled_h - crop_h) // 2
|
||||
out = out.crop((left, top, left + crop_w, top + crop_h))
|
||||
return ImageFitResult(out, out.width, out.height, scale, mode, (src_w, src_h))
|
||||
|
||||
# contain / fill_height share the "preserve aspect, no crop" path
|
||||
if mode == "fill_height":
|
||||
scale = box_h / src_h
|
||||
# contain-cap: never exceed the box width (football's logo_slot rule)
|
||||
scale = min(scale, box_w / src_w)
|
||||
else: # contain
|
||||
scale = min(box_w / src_w, box_h / src_h)
|
||||
if not upscale:
|
||||
scale = min(scale, 1.0)
|
||||
out_w = max(1, round(src_w * scale))
|
||||
out_h = max(1, round(src_h * scale))
|
||||
if (out_w, out_h) == (src_w, src_h):
|
||||
# No resize needed — but `work` may still BE the caller's original
|
||||
# image (RGBA source, no ink crop). The result must always be an
|
||||
# independent copy: LayoutContext caches ImageFitResults, and an
|
||||
# aliased image would let later mutations of the source corrupt
|
||||
# cached fits (or vice versa).
|
||||
out = work.copy() if work is img else work
|
||||
else:
|
||||
out = work.resize((out_w, out_h), resample)
|
||||
return ImageFitResult(out, out_w, out_h, scale, mode, (src_w, src_h))
|
||||
|
||||
|
||||
def draw_fitted_image(display_manager: Any, ifit: ImageFitResult, box: Any, *,
|
||||
align: str = "center", valign: str = "center",
|
||||
offset: Tuple[int, int] = (0, 0)) -> Optional[Tuple[int, int]]:
|
||||
"""Paste a fitted image aligned within a Region onto the display canvas.
|
||||
|
||||
Pastes with the image's own alpha mask. Returns the (x, y) actually used
|
||||
so callers can position adjacent decorations, or None when nothing was
|
||||
drawn (empty fit / no canvas).
|
||||
"""
|
||||
if ifit is None or ifit.is_empty:
|
||||
return None
|
||||
image = getattr(display_manager, "image", None)
|
||||
if image is None:
|
||||
return None
|
||||
if hasattr(box, "align_xy"):
|
||||
x, y = box.align_xy(ifit.width, ifit.height, align, valign)
|
||||
else:
|
||||
box_w, box_h = _box_dims(box)
|
||||
x = (box_w - ifit.width) // 2
|
||||
y = (box_h - ifit.height) // 2
|
||||
x += int(offset[0])
|
||||
y += int(offset[1])
|
||||
image.paste(ifit.image, (x, y), ifit.image)
|
||||
return (x, y)
|
||||
@@ -0,0 +1,746 @@
|
||||
"""
|
||||
Adaptive layout and font scaling helpers for plugins.
|
||||
|
||||
Generalizes the three size-adaptation patterns proven in the plugin
|
||||
ecosystem into small composable core helpers, so plugins render legibly on
|
||||
any panel size (64x32, 128x32, 96x48, 128x64, 256x64, ...) without
|
||||
hand-tuned per-display layouts:
|
||||
|
||||
- Region: integer rect algebra (bands, columns, weighted splits, centering).
|
||||
Regions partition space, so text bands can't overlap by construction —
|
||||
replacing the magic ``y = 1`` / ``y = height - 7`` offsets tuned for 128x32.
|
||||
- Font ladders: ordered (family, size) steps known to render crisply.
|
||||
Pixel fonts (BDF, PressStart2P) only look right at native/integer sizes,
|
||||
so fonts are never scaled continuously — fitting walks a ladder from the
|
||||
largest rung down until the measured text fits the target box. This is
|
||||
baseball-scoreboard's fallback-ladder pattern promoted to core.
|
||||
- LayoutContext: per-(width, height) facts — breakpoint tiers
|
||||
(masters-tournament's pattern), a geometry scale factor vs. a declared
|
||||
design size (f1-scoreboard's pattern), and cached fit-text queries.
|
||||
|
||||
Everything is opt-in: plugins get a context via ``self.layout`` on
|
||||
BasePlugin (or construct one directly) and existing plugins are unaffected.
|
||||
|
||||
Fonts are resolved through FontManager's catalog (family names are
|
||||
lowercased file stems from assets/fonts, e.g. "9x15", "tom-thumb", plus
|
||||
aliases like "press_start"). FitResult.font is a plain PIL font or
|
||||
freetype.Face, so it drops straight into DisplayManager.draw_text().
|
||||
"""
|
||||
|
||||
import logging
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
|
||||
|
||||
import freetype
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Height-based breakpoint tiers, smallest to largest. A 32px-tall panel is
|
||||
# the ecosystem baseline ("sm"); 96x48 lands in "md"; 128x64 in "lg".
|
||||
_HEIGHT_TIERS: Tuple[Tuple[str, int], ...] = (
|
||||
("xs", 16), ("sm", 32), ("md", 48), ("lg", 64), ("xl", 10 ** 9),
|
||||
)
|
||||
TIER_ORDER: Tuple[str, ...] = tuple(name for name, _ in _HEIGHT_TIERS)
|
||||
|
||||
_WIDTH_TIERS: Tuple[Tuple[str, int], ...] = (
|
||||
("narrow", 64), ("normal", 128), ("wide", 256), ("ultrawide", 10 ** 9),
|
||||
)
|
||||
WIDTH_TIER_ORDER: Tuple[str, ...] = tuple(name for name, _ in _WIDTH_TIERS)
|
||||
|
||||
# The panel size most existing plugins were authored against.
|
||||
DEFAULT_DESIGN_SIZE: Tuple[int, int] = (128, 32)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Region:
|
||||
"""An integer rectangle. Carving methods return sub-Regions clamped to
|
||||
non-negative dimensions, so degenerate panels never produce negative
|
||||
boxes — a band request larger than the region simply consumes it all."""
|
||||
|
||||
x: int
|
||||
y: int
|
||||
w: int
|
||||
h: int
|
||||
|
||||
def __post_init__(self):
|
||||
object.__setattr__(self, "w", max(0, int(self.w)))
|
||||
object.__setattr__(self, "h", max(0, int(self.h)))
|
||||
object.__setattr__(self, "x", int(self.x))
|
||||
object.__setattr__(self, "y", int(self.y))
|
||||
|
||||
@property
|
||||
def right(self) -> int:
|
||||
return self.x + self.w
|
||||
|
||||
@property
|
||||
def bottom(self) -> int:
|
||||
return self.y + self.h
|
||||
|
||||
@property
|
||||
def center(self) -> Tuple[int, int]:
|
||||
return (self.x + self.w // 2, self.y + self.h // 2)
|
||||
|
||||
# ---- carving -----------------------------------------------------
|
||||
|
||||
def inset(self, dx: int, dy: Optional[int] = None) -> "Region":
|
||||
"""Shrink by dx horizontally and dy (default dx) vertically, each side."""
|
||||
if dy is None:
|
||||
dy = dx
|
||||
return Region(self.x + dx, self.y + dy, self.w - 2 * dx, self.h - 2 * dy)
|
||||
|
||||
def offset(self, dx: int, dy: int) -> "Region":
|
||||
"""Translate without resizing — the hook for user x/y-offset
|
||||
customization: compute regions first, then apply the user's
|
||||
configured offsets as a final translation."""
|
||||
return Region(self.x + dx, self.y + dy, self.w, self.h)
|
||||
|
||||
def top_band(self, h: int) -> "Region":
|
||||
return Region(self.x, self.y, self.w, min(h, self.h))
|
||||
|
||||
def bottom_band(self, h: int) -> "Region":
|
||||
h = min(h, self.h)
|
||||
return Region(self.x, self.bottom - h, self.w, h)
|
||||
|
||||
def middle(self, top_h: int = 0, bottom_h: int = 0) -> "Region":
|
||||
"""What remains between a top band and a bottom band."""
|
||||
return Region(self.x, self.y + top_h, self.w, self.h - top_h - bottom_h)
|
||||
|
||||
def left_col(self, w: int) -> "Region":
|
||||
return Region(self.x, self.y, min(w, self.w), self.h)
|
||||
|
||||
def right_col(self, w: int) -> "Region":
|
||||
w = min(w, self.w)
|
||||
return Region(self.right - w, self.y, w, self.h)
|
||||
|
||||
def split_h(self, *weights: float, gap: int = 0) -> List["Region"]:
|
||||
"""Side-by-side columns sized by weight; gaps between them."""
|
||||
sizes = _weighted_sizes(self.w, weights, gap)
|
||||
cols, cursor = [], self.x
|
||||
for size in sizes:
|
||||
cols.append(Region(cursor, self.y, size, self.h))
|
||||
cursor += size + gap
|
||||
return cols
|
||||
|
||||
def split_v(self, *weights: float, gap: int = 0) -> List["Region"]:
|
||||
"""Stacked rows sized by weight; gaps between them."""
|
||||
sizes = _weighted_sizes(self.h, weights, gap)
|
||||
rows, cursor = [], self.y
|
||||
for size in sizes:
|
||||
rows.append(Region(self.x, cursor, self.w, size))
|
||||
cursor += size + gap
|
||||
return rows
|
||||
|
||||
# ---- placement ---------------------------------------------------
|
||||
|
||||
def align_xy(self, w: int, h: int, align: str = "center",
|
||||
valign: str = "center") -> Tuple[int, int]:
|
||||
"""Top-left position for a w x h box aligned within this region.
|
||||
align: left|center|right; valign: top|center|bottom."""
|
||||
if align == "left":
|
||||
x = self.x
|
||||
elif align == "right":
|
||||
x = self.right - w
|
||||
else:
|
||||
x = self.x + (self.w - w) // 2
|
||||
if valign == "top":
|
||||
y = self.y
|
||||
elif valign == "bottom":
|
||||
y = self.bottom - h
|
||||
else:
|
||||
y = self.y + (self.h - h) // 2
|
||||
return (x, y)
|
||||
|
||||
def center_xy(self, w: int, h: int) -> Tuple[int, int]:
|
||||
return self.align_xy(w, h)
|
||||
|
||||
def contains(self, w: int, h: int) -> bool:
|
||||
return w <= self.w and h <= self.h
|
||||
|
||||
|
||||
def _weighted_sizes(total: int, weights: Sequence[float], gap: int) -> List[int]:
|
||||
"""Integer sizes proportional to weights, remainder spread left-to-right."""
|
||||
if not weights:
|
||||
return []
|
||||
usable = max(0, total - gap * (len(weights) - 1))
|
||||
weight_sum = sum(weights) or 1
|
||||
sizes = [int(usable * w / weight_sum) for w in weights]
|
||||
remainder = usable - sum(sizes)
|
||||
for i in range(remainder):
|
||||
sizes[i % len(sizes)] += 1
|
||||
return sizes
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Font ladders
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FontStep:
|
||||
"""One rung: a FontManager catalog family at a size it renders crisply."""
|
||||
family: str
|
||||
size_px: int
|
||||
|
||||
|
||||
FontLadder = Tuple[FontStep, ...]
|
||||
|
||||
# X11 BDF bitmap fonts at their native pixel sizes, largest to smallest —
|
||||
# baseball-scoreboard's fallback ladder extended upward. Same-height rungs
|
||||
# are ordered widest first so width-constrained text steps to a narrower
|
||||
# face before dropping a size.
|
||||
LADDER_GRID: FontLadder = (
|
||||
FontStep("10x20", 20),
|
||||
FontStep("9x18", 18),
|
||||
FontStep("9x15", 15),
|
||||
FontStep("8x13", 13),
|
||||
FontStep("7x13", 13),
|
||||
FontStep("6x13", 13),
|
||||
FontStep("6x12", 12),
|
||||
FontStep("6x10", 10),
|
||||
FontStep("6x9", 9),
|
||||
FontStep("5x8", 8),
|
||||
FontStep("5x7", 7),
|
||||
FontStep("4x6", 6),
|
||||
FontStep("tom-thumb", 6),
|
||||
)
|
||||
|
||||
# PressStart2P at integer multiples of its 8px pixel grid only — fractional
|
||||
# sizes blur a pixel font. For headline text (clocks, scores).
|
||||
LADDER_ARCADE: FontLadder = (
|
||||
FontStep("press_start", 32),
|
||||
FontStep("press_start", 24),
|
||||
FontStep("press_start", 16),
|
||||
FontStep("press_start", 8),
|
||||
)
|
||||
|
||||
LADDER_DEFAULT: FontLadder = LADDER_GRID
|
||||
|
||||
ELLIPSIS = "…"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FitResult:
|
||||
"""A fitted font plus the ink metrics of the (possibly ellipsized) text.
|
||||
|
||||
``y_offset`` is the gap between the y passed to draw_text() and where
|
||||
ink actually starts; subtract it from the desired ink-top position when
|
||||
drawing (draw_fitted_text does this for you).
|
||||
"""
|
||||
font: Any
|
||||
family: str
|
||||
size_px: int
|
||||
text: str
|
||||
width: int
|
||||
height: int
|
||||
baseline: int
|
||||
y_offset: int
|
||||
fits: bool
|
||||
line_height: int = 0
|
||||
|
||||
|
||||
def measure_ink(text: str, font: Any) -> Tuple[int, int, int, int]:
|
||||
"""Measure the ink box of text: (width, height, baseline, y_offset).
|
||||
|
||||
y_offset is the distance from the y coordinate DisplayManager.draw_text()
|
||||
is given to the top of the actual ink — PIL draws TTF from the em-box
|
||||
top and _draw_bdf_text derives the baseline from y + ascender, so both
|
||||
leave a font-dependent gap that matters when centering in short bands.
|
||||
"""
|
||||
if isinstance(font, freetype.Face):
|
||||
width = 0
|
||||
ascender = font.size.ascender >> 6
|
||||
ink_top, ink_bottom = None, None
|
||||
for char in text:
|
||||
font.load_char(char)
|
||||
width += font.glyph.advance.x >> 6
|
||||
rows = font.glyph.bitmap.rows
|
||||
if rows:
|
||||
top = ascender - font.glyph.bitmap_top
|
||||
ink_top = top if ink_top is None else min(ink_top, top)
|
||||
ink_bottom = top + rows if ink_bottom is None else max(ink_bottom, top + rows)
|
||||
if ink_top is None:
|
||||
ink_top, ink_bottom = 0, 0
|
||||
return (width, ink_bottom - ink_top, ascender, ink_top)
|
||||
bbox = font.getbbox(text)
|
||||
return (bbox[2] - bbox[0], bbox[3] - bbox[1], -bbox[1], bbox[1])
|
||||
|
||||
|
||||
def font_line_height(font: Any) -> int:
|
||||
"""Recommended line spacing for a font (matches DisplayManager.get_font_height)."""
|
||||
if isinstance(font, freetype.Face):
|
||||
return font.size.height >> 6
|
||||
ascent, descent = font.getmetrics()
|
||||
return ascent + descent
|
||||
|
||||
|
||||
def measure_font_crispness(font: Any, sample_text: str = "Ay0",
|
||||
canvas_size: Tuple[int, int] = (250, 60)) -> float:
|
||||
"""Fraction of the rendered sample's ink-bbox pixels that are neither
|
||||
pure black nor pure white — i.e. antialiased.
|
||||
|
||||
BDF (freetype.Face) glyphs are true bitmaps and always render at 0.0.
|
||||
"Pixel-style" TTFs (PressStart2P, and similar fonts bundled for
|
||||
plugins that draw through ImageDraw.text() and so can't take a BDF
|
||||
face) are NOT automatically crisp at arbitrary sizes — PIL antialiases
|
||||
TTF outlines by default, and a pixel-grid font only lands on whole
|
||||
pixels at specific sizes (for PressStart2P: exact multiples of 8).
|
||||
Requesting an unverified size silently produces soft/blurry glyphs on
|
||||
an LED panel, which reads as fuzzy compared to a true BDF rung.
|
||||
|
||||
Use this to vet any custom FontLadder rung that mixes TTF fonts before
|
||||
shipping it — see test_adaptive_layout.py::test_ladder_is_crisp for the
|
||||
pattern. A rung should score 0.0 (or very close, to allow for the odd
|
||||
diagonal stroke) before it belongs in a "crisp" ladder.
|
||||
"""
|
||||
if isinstance(font, freetype.Face):
|
||||
return 0.0
|
||||
from PIL import Image, ImageDraw
|
||||
img = Image.new("L", canvas_size, 0)
|
||||
ImageDraw.Draw(img).text((2, 2), sample_text, font=font, fill=255)
|
||||
bbox = img.getbbox()
|
||||
if bbox is None:
|
||||
return 0.0
|
||||
pixels = img.crop(bbox).tobytes()
|
||||
pure = sum(1 for p in pixels if p == 0 or p == 255)
|
||||
return (len(pixels) - pure) / len(pixels)
|
||||
|
||||
|
||||
class LayoutContext:
|
||||
"""Per-render-size layout facts and fit-text queries for one panel size.
|
||||
|
||||
Construct once per (width, height); BasePlugin.layout does this and
|
||||
rebuilds automatically when the logical display size changes.
|
||||
"""
|
||||
|
||||
def __init__(self, width: int, height: int, font_manager: Any,
|
||||
design_size: Tuple[int, int] = DEFAULT_DESIGN_SIZE):
|
||||
self.width = int(width)
|
||||
self.height = int(height)
|
||||
self.font_manager = font_manager
|
||||
self.design_size = design_size
|
||||
self.bounds = Region(0, 0, self.width, self.height)
|
||||
self.aspect = self.width / max(1, self.height)
|
||||
self.tier = _pick_tier(_HEIGHT_TIERS, self.height)
|
||||
self.width_tier = _pick_tier(_WIDTH_TIERS, self.width)
|
||||
self.is_wide_short = self.aspect >= 2.5 and self.height <= 32
|
||||
design_w, design_h = design_size
|
||||
# Geometry scale only (gaps, icon/logo sizes) — never applied to
|
||||
# fonts, which step between crisp ladder rungs instead.
|
||||
self.scale = min(self.width / max(1, design_w),
|
||||
self.height / max(1, design_h))
|
||||
# LRU-bounded: entries are small, but keys embed the fitted TEXT —
|
||||
# a plugin fitting changing text (a live game clock, a ticker) on a
|
||||
# 24/7 service would otherwise grow this without bound.
|
||||
self._fit_cache: "OrderedDict[Any, FitResult]" = OrderedDict()
|
||||
# LRU-bounded (images are big). Entries hold a strong reference to
|
||||
# the source image when keyed by id() so the id can't be recycled
|
||||
# out from under the cache.
|
||||
self._image_cache: "OrderedDict[Any, Tuple[Any, Any]]" = OrderedDict()
|
||||
|
||||
_IMAGE_CACHE_MAX = 64
|
||||
_FIT_CACHE_MAX = 512
|
||||
|
||||
def _fit_cache_get(self, key: Any) -> Optional["FitResult"]:
|
||||
cached = self._fit_cache.get(key)
|
||||
if cached is not None:
|
||||
self._fit_cache.move_to_end(key)
|
||||
return cached
|
||||
|
||||
def _fit_cache_put(self, key: Any, result: "FitResult") -> None:
|
||||
self._fit_cache[key] = result
|
||||
while len(self._fit_cache) > self._FIT_CACHE_MAX:
|
||||
self._fit_cache.popitem(last=False)
|
||||
|
||||
# ---- the three adaptation patterns --------------------------------
|
||||
|
||||
def px(self, base: int, minimum: int = 1, maximum: Optional[int] = None) -> int:
|
||||
"""Scale a design-size pixel measurement (f1's pattern): gaps,
|
||||
icon sizes, logo slots. Clamped to [minimum, maximum]."""
|
||||
value = max(minimum, round(base * self.scale))
|
||||
if maximum is not None:
|
||||
value = min(value, maximum)
|
||||
return value
|
||||
|
||||
def by_tier(self, mapping: Dict[str, Any], default: Any = None) -> Any:
|
||||
"""Pick the value for the nearest defined tier at-or-below the
|
||||
panel's height tier (masters' pattern). Falls forward to the
|
||||
smallest defined tier above, then to default.
|
||||
|
||||
by_tier({"sm": 10, "lg": 18}) -> 10 on 128x32, 18 on 128x64.
|
||||
Keys may also use width tiers ("narrow", "wide", ...)."""
|
||||
order = TIER_ORDER if any(k in TIER_ORDER for k in mapping) else WIDTH_TIER_ORDER
|
||||
current = self.tier if order is TIER_ORDER else self.width_tier
|
||||
idx = order.index(current)
|
||||
for name in reversed(order[: idx + 1]):
|
||||
if name in mapping:
|
||||
return mapping[name]
|
||||
for name in order[idx + 1:]:
|
||||
if name in mapping:
|
||||
return mapping[name]
|
||||
return default
|
||||
|
||||
def fit_text(self, text: str, box: Union[Region, Tuple[int, int]],
|
||||
ladder: FontLadder = LADDER_DEFAULT,
|
||||
ellipsis: bool = True) -> FitResult:
|
||||
"""Largest ladder rung whose rendered text fits the box (baseball's
|
||||
pattern). If even the smallest rung is too wide, the text is
|
||||
ellipsized to fit (unless ellipsis=False); fits=False only when no
|
||||
acceptable rendering exists."""
|
||||
box_w, box_h = _box_dims(box)
|
||||
key = ("text", text, box_w, box_h, ladder, ellipsis)
|
||||
cached = self._fit_cache_get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
result = self._walk_ladder(text, ladder, box_w, box_h, ellipsis)
|
||||
self._fit_cache_put(key, result)
|
||||
return result
|
||||
|
||||
def fit_text_proportional(self, text: str, box: Union[Region, Tuple[int, int]],
|
||||
base_size_px: int, ladder: FontLadder = LADDER_DEFAULT,
|
||||
ellipsis: bool = True,
|
||||
scale: Optional[float] = None) -> FitResult:
|
||||
"""Ladder rung closest to (but not exceeding) ``base_size_px * scale``
|
||||
that still fits the box — proportional sizing instead of ``fit_text``'s
|
||||
"always maximize" behavior.
|
||||
|
||||
Use this when several independently-fitted elements need to stay
|
||||
visually harmonious as the panel grows (e.g. a scoreboard's score,
|
||||
status, and detail text) — ``fit_text`` maximizes each one within
|
||||
its own region, which can make one element balloon out of
|
||||
proportion to its neighbors (a huge score overlapping logos it fit
|
||||
fine at the design size) even though every individual pick is
|
||||
independently "correct". ``base_size_px`` is the size that element
|
||||
renders at on the design size (``design_size``, typically 128x32)
|
||||
— commonly a plugin's existing classic/fixed font size for that
|
||||
element.
|
||||
|
||||
``scale`` defaults to ``self.scale`` (the same conservative
|
||||
min(width_ratio, height_ratio) factor ``px()`` uses — safe for
|
||||
content whose aspect ratio matters). Pass an explicit axis-specific
|
||||
value when the surrounding composition already scales that way —
|
||||
e.g. a scoreboard whose logos scale with height alone
|
||||
(``logo_slot = min(height, width // 2)``) should size its score
|
||||
text by ``height / design_height`` too, or its text will look
|
||||
under-scaled next to bigger logos on a panel that only grew taller.
|
||||
|
||||
Falls back to the smallest rung when even that exceeds the target
|
||||
(a tiny scale factor), and to fit_text's ordinary smaller-rung
|
||||
fallback when the closest-to-target rung doesn't actually fit the
|
||||
box.
|
||||
"""
|
||||
box_w, box_h = _box_dims(box)
|
||||
effective_scale = self.scale if scale is None else scale
|
||||
key = ("text_prop", text, box_w, box_h, ladder, base_size_px, ellipsis, effective_scale)
|
||||
cached = self._fit_cache_get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
target = base_size_px * effective_scale
|
||||
eligible = [step for step in ladder if step.size_px <= target]
|
||||
candidates = eligible if eligible else (min(ladder, key=lambda s: s.size_px),)
|
||||
result = self._walk_ladder(text, candidates, box_w, box_h, ellipsis)
|
||||
self._fit_cache_put(key, result)
|
||||
return result
|
||||
|
||||
def _walk_ladder(self, text: str, ladder: Sequence[FontStep],
|
||||
box_w: int, box_h: int, ellipsis: bool) -> FitResult:
|
||||
"""Shared by fit_text/fit_text_proportional: first ladder entry (in
|
||||
the order given) whose rendered text fits, ellipsizing the last one
|
||||
tried if none do."""
|
||||
result = None
|
||||
for step in ladder:
|
||||
font = self.font_manager.get_font(step.family, step.size_px)
|
||||
width, height, baseline, y_offset = measure_ink(text, font)
|
||||
result = FitResult(font, step.family, step.size_px, text,
|
||||
width, height, baseline, y_offset,
|
||||
fits=(width <= box_w and height <= box_h),
|
||||
line_height=font_line_height(font))
|
||||
if result.fits:
|
||||
break
|
||||
|
||||
if result is not None and not result.fits and ellipsis:
|
||||
short = self.ellipsize(text, result.font, box_w)
|
||||
width, height, baseline, y_offset = measure_ink(short, result.font)
|
||||
result = FitResult(result.font, result.family, result.size_px,
|
||||
short, width, height, baseline, y_offset,
|
||||
fits=(width <= box_w and height <= box_h),
|
||||
line_height=result.line_height)
|
||||
return result
|
||||
|
||||
def fit_lines(self, lines: Sequence[str], box: Union[Region, Tuple[int, int]],
|
||||
ladder: FontLadder = LADDER_DEFAULT,
|
||||
spacing: int = 1) -> FitResult:
|
||||
"""Largest rung where every line fits the box width and the stacked
|
||||
lines (line_height + spacing apart) fit the box height. Measures the
|
||||
actual strings, so a long line pushes the ladder down a rung a short
|
||||
one wouldn't (baseball's multiline pattern). Text is the widest line."""
|
||||
box_w, box_h = _box_dims(box)
|
||||
key = ("lines", tuple(lines), box_w, box_h, ladder, spacing)
|
||||
cached = self._fit_cache_get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
rows = max(1, len(lines))
|
||||
result = None
|
||||
for step in ladder:
|
||||
font = self.font_manager.get_font(step.family, step.size_px)
|
||||
line_h = font_line_height(font)
|
||||
widest, metrics = "", (0, 0, 0, 0)
|
||||
for line in lines:
|
||||
m = measure_ink(line, font)
|
||||
if m[0] >= metrics[0]:
|
||||
widest, metrics = line, m
|
||||
total_h = rows * line_h + (rows - 1) * spacing
|
||||
result = FitResult(font, step.family, step.size_px, widest,
|
||||
metrics[0], metrics[1], metrics[2], metrics[3],
|
||||
fits=(metrics[0] <= box_w and total_h <= box_h),
|
||||
line_height=line_h)
|
||||
if result.fits:
|
||||
break
|
||||
|
||||
self._fit_cache_put(key, result)
|
||||
return result
|
||||
|
||||
def font_for_rows(self, rows: int, box_h: int,
|
||||
ladder: FontLadder = LADDER_GRID) -> FitResult:
|
||||
"""Largest rung whose line height lets `rows` rows fit in box_h
|
||||
(baseball's traditional-scoreboard pattern). Measures a digit/cap
|
||||
sample rather than specific strings."""
|
||||
key = ("rows", rows, box_h, ladder)
|
||||
cached = self._fit_cache_get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
sample = "0Ay"
|
||||
result = None
|
||||
for step in ladder:
|
||||
font = self.font_manager.get_font(step.family, step.size_px)
|
||||
line_h = font_line_height(font)
|
||||
width, height, baseline, y_offset = measure_ink(sample, font)
|
||||
result = FitResult(font, step.family, step.size_px, sample,
|
||||
width, height, baseline, y_offset,
|
||||
fits=(max(1, rows) * line_h <= box_h),
|
||||
line_height=line_h)
|
||||
if result.fits:
|
||||
break
|
||||
|
||||
self._fit_cache_put(key, result)
|
||||
return result
|
||||
|
||||
# ---- images ---------------------------------------------------------
|
||||
|
||||
def fit_image(self, img: Any, box: Union[Region, Tuple[int, int]], *,
|
||||
mode: str = "contain", crop_to_ink: bool = False,
|
||||
anchor: str = "center", resample: Any = None,
|
||||
upscale: bool = True, cache_key: Any = None) -> Any:
|
||||
"""Fit an image into a box (see src/adaptive_images.py for modes),
|
||||
cached per (image, box size, options) for this panel size.
|
||||
|
||||
Prefer a stable ``cache_key`` (e.g. "logo:KC") for images that get
|
||||
reloaded — the default id()-based key is safe (the entry pins the
|
||||
source image) but misses across reloads of the same content.
|
||||
"""
|
||||
from src.adaptive_images import fit_image as _fit_image
|
||||
|
||||
box_w, box_h = _box_dims(box)
|
||||
resample_name = getattr(resample, "name", repr(resample)) if resample is not None else "default"
|
||||
identity = cache_key if cache_key is not None else ("id", id(img))
|
||||
key = ("image", identity, img.size, box_w, box_h, mode,
|
||||
crop_to_ink, anchor, resample_name, upscale)
|
||||
|
||||
cached = self._image_cache.get(key)
|
||||
if cached is not None:
|
||||
self._image_cache.move_to_end(key)
|
||||
return cached[0]
|
||||
|
||||
result = _fit_image(img, (box_w, box_h), mode=mode,
|
||||
crop_to_ink=crop_to_ink, anchor=anchor,
|
||||
resample=resample, upscale=upscale)
|
||||
# Pin the source only for id()-keyed entries (see docstring).
|
||||
self._image_cache[key] = (result, img if cache_key is None else None)
|
||||
while len(self._image_cache) > self._IMAGE_CACHE_MAX:
|
||||
self._image_cache.popitem(last=False)
|
||||
return result
|
||||
|
||||
# ---- text utilities ------------------------------------------------
|
||||
|
||||
def ellipsize(self, text: str, font: Any, max_w: int) -> str:
|
||||
"""Trim text to fit max_w, appending an ellipsis. Returns '' when
|
||||
not even the ellipsis fits."""
|
||||
if measure_ink(text, font)[0] <= max_w:
|
||||
return text
|
||||
for end in range(len(text) - 1, 0, -1):
|
||||
candidate = text[:end].rstrip() + ELLIPSIS
|
||||
if measure_ink(candidate, font)[0] <= max_w:
|
||||
return candidate
|
||||
return ELLIPSIS if measure_ink(ELLIPSIS, font)[0] <= max_w else ""
|
||||
|
||||
def measure(self, text: str, font: Any) -> Tuple[int, int, int]:
|
||||
"""Ink (width, height, baseline) of text — see measure_ink."""
|
||||
width, height, baseline, _ = measure_ink(text, font)
|
||||
return (width, height, baseline)
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""Drop cached fit results (call after fonts are reloaded)."""
|
||||
self._fit_cache.clear()
|
||||
self._image_cache.clear()
|
||||
|
||||
|
||||
def _pick_tier(tiers: Tuple[Tuple[str, int], ...], value: int) -> str:
|
||||
for name, limit in tiers:
|
||||
if value <= limit:
|
||||
return name
|
||||
return tiers[-1][0]
|
||||
|
||||
|
||||
def _box_dims(box: Union[Region, Tuple[int, int]]) -> Tuple[int, int]:
|
||||
if isinstance(box, Region):
|
||||
return (box.w, box.h)
|
||||
w, h = box
|
||||
return (int(w), int(h))
|
||||
|
||||
|
||||
def draw_fitted_text(display_manager: Any, 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 FitResult's text aligned within a Region via
|
||||
DisplayManager.draw_text(), compensating for the font's ink offset so
|
||||
the ink (not the em box) is what gets aligned."""
|
||||
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)
|
||||
display_manager.draw_text(fit.text, x=x, y=y - fit.y_offset,
|
||||
color=color, font=fit.font)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Composite layouts — the region arrangements repeated across plugins,
|
||||
# expressed as Region math so migrated plugins stop hand-copying coordinate
|
||||
# formulas. Deliberately tiny: these return Regions, they don't draw.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScoreboardRegions:
|
||||
"""The two-logos-plus-center-score card shared by the sports plugins."""
|
||||
bounds: Region
|
||||
logo_slot: int # width of each logo slot: min(H, W // 2), center-reserved
|
||||
away_slot: Region # left logo slot
|
||||
home_slot: Region # right logo slot
|
||||
center_col: Region # column between the slots (>= min_center_fraction of width)
|
||||
status_band: Region # top band (replaces the magic y = 1)
|
||||
score_area: Region # center_col's true width, between the bands (replaces y = H//2 - 3)
|
||||
detail_band: Region # bottom band (replaces the magic y = H - 7)
|
||||
bottom_left: Region # bottom corner: away records / timeouts
|
||||
bottom_right: Region # bottom corner: home records / timeouts
|
||||
|
||||
|
||||
def scoreboard_regions(bounds: Region, *, ctx: Optional["LayoutContext"] = None,
|
||||
status_h: Optional[int] = None,
|
||||
detail_h: Optional[int] = None,
|
||||
min_center_fraction: float = 0.15,
|
||||
min_center_design_px: int = 40,
|
||||
score_bleed_fraction: float = 0.5) -> ScoreboardRegions:
|
||||
"""Carve a game-card Region into the standard scoreboard arrangement.
|
||||
|
||||
Encodes the invariant duplicated across the sports plugins:
|
||||
``logo_slot = min(height, width // 2)`` (capped at half the card so the
|
||||
home slot never collapses), away logo centered in the left slot, home in
|
||||
the right.
|
||||
|
||||
That formula alone has a blind spot: at exactly 2:1 aspect ratio
|
||||
(width == 2 * height — a very common shape, e.g. two, four, or more
|
||||
square modules stacked into a taller panel) ``width // 2`` and
|
||||
``height`` are equal, so the two logo slots claim the *entire* width
|
||||
and leave zero pixels for a center column, no matter how large the
|
||||
panel gets. It isn't a "small panel" problem: 96x48, 128x64, and
|
||||
256x128 (all exactly 2:1) hit it identically, while wide panels like
|
||||
the 128x32 design baseline or a 192x48/256x32 panel never do, because
|
||||
height is already the tighter constraint there.
|
||||
|
||||
Two knobs fix it, both defaulted to values verified against the full
|
||||
harness size spread (see test_adaptive_layout.py::TestScoreboardRegions):
|
||||
|
||||
- ``min_center_fraction`` / ``min_center_design_px`` reserve at least
|
||||
``max(width * min_center_fraction, min_center_design_px * ctx.scale)``
|
||||
for the center column, capping ``logo_slot`` further when needed. The
|
||||
design-px term (scaled by the context's geometry factor, so it grows
|
||||
on bigger panels like everything else in ``px()``) matters most on
|
||||
small panels where a flat fraction alone reserves too little absolute
|
||||
space for even a short score string. On wide panels the height
|
||||
constraint already leaves more room than either reserves, so both are
|
||||
a no-op there — 128x32/192x48-style layouts are unaffected.
|
||||
- ``score_bleed_fraction`` extends the score's own *fit box* (not the
|
||||
logo slots themselves) an extra ``logo_slot * score_bleed_fraction``
|
||||
into each side — controlled, intentional overlap with the logo art,
|
||||
the same way real broadcast scoreboards let a big score number's
|
||||
edges cross into the team marks flanking it. Without this, on a
|
||||
square-ish panel the center reserve alone can be too narrow for even
|
||||
a modest score to render without truncating (`"17-21"` -> `"17-2…"`),
|
||||
which is worse than a little overlap.
|
||||
|
||||
status_band and detail_band span the FULL card width and overlay the
|
||||
logo slots — matching the classic layouts, where short outlined status/
|
||||
date text is drawn over the logos without issue; only score_area (the
|
||||
one element whose size actively grows with the panel) uses the
|
||||
narrower, bleed-adjusted box. Band heights default to the classic
|
||||
128x32 values, scaled by the context's geometry factor when one is
|
||||
provided. Works on a full panel or on a scroll-mode card Region.
|
||||
"""
|
||||
if status_h is None:
|
||||
status_h = ctx.px(9, minimum=7) if ctx else 9
|
||||
if detail_h is None:
|
||||
detail_h = ctx.px(8, minimum=7) if ctx else 8
|
||||
|
||||
logo_slot = min(bounds.h, bounds.w // 2)
|
||||
design_reserve = int(min_center_design_px * (ctx.scale if ctx else 1.0))
|
||||
min_center_w = max(1, int(bounds.w * min_center_fraction), design_reserve)
|
||||
max_logo_slot_by_center = max(1, (bounds.w - min_center_w) // 2)
|
||||
logo_slot = min(logo_slot, max_logo_slot_by_center)
|
||||
away_slot = bounds.left_col(logo_slot)
|
||||
home_slot = bounds.right_col(logo_slot)
|
||||
center_col = Region(bounds.x + logo_slot, bounds.y,
|
||||
bounds.w - 2 * logo_slot, bounds.h)
|
||||
status_band = bounds.top_band(status_h)
|
||||
detail_band = bounds.bottom_band(detail_h)
|
||||
middle = bounds.middle(status_band.h, detail_band.h)
|
||||
# score_area is the true center gap's width plus a controlled bleed
|
||||
# into each logo slot (see score_bleed_fraction above) -- narrower than
|
||||
# the full card width status/detail get, since it's the one element
|
||||
# whose size actively grows with the panel and needs its *fit box* to
|
||||
# reflect real available space, but generous enough that a short score
|
||||
# string never has to truncate on a square-ish panel.
|
||||
bleed = int(logo_slot * score_bleed_fraction)
|
||||
score_area = Region(center_col.x - bleed, middle.y,
|
||||
center_col.w + 2 * bleed, middle.h)
|
||||
bottom = bounds.bottom_band(detail_h)
|
||||
return ScoreboardRegions(
|
||||
bounds=bounds, logo_slot=logo_slot,
|
||||
away_slot=away_slot, home_slot=home_slot, center_col=center_col,
|
||||
status_band=status_band, score_area=score_area, detail_band=detail_band,
|
||||
bottom_left=bottom.left_col(logo_slot),
|
||||
bottom_right=bottom.right_col(logo_slot),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MediaRow:
|
||||
"""Art/icon on the left, text column on the right (music's idiom)."""
|
||||
art: Region
|
||||
body: Region
|
||||
|
||||
|
||||
def media_row(bounds: Region, *, ctx: Optional["LayoutContext"] = None,
|
||||
square: bool = True, gap: Optional[int] = None) -> MediaRow:
|
||||
"""Split a Region into an art slot and a body column.
|
||||
|
||||
With ``square=True`` the art slot is bounds.h wide (album-art style);
|
||||
otherwise it takes the left half. The gap defaults to 2px scaled by the
|
||||
context's geometry factor.
|
||||
"""
|
||||
if gap is None:
|
||||
gap = ctx.px(2, minimum=1) if ctx else 2
|
||||
art_w = bounds.h if square else bounds.w // 2
|
||||
art_w = min(art_w, bounds.w)
|
||||
art = bounds.left_col(art_w)
|
||||
body = Region(bounds.x + art_w + gap, bounds.y,
|
||||
bounds.w - art_w - gap, bounds.h)
|
||||
return MediaRow(art=art, body=body)
|
||||
@@ -1,134 +0,0 @@
|
||||
"""
|
||||
Background Cache Mixin for Sports Managers
|
||||
|
||||
This mixin provides common caching functionality to eliminate code duplication
|
||||
across all sports managers. It implements the background service cache pattern
|
||||
where Recent/Upcoming managers consume data from the background service cache.
|
||||
"""
|
||||
|
||||
import time
|
||||
from typing import Dict, Optional, Any, Callable
|
||||
|
||||
|
||||
class BackgroundCacheMixin:
|
||||
"""
|
||||
Mixin class that provides background service cache functionality to sports managers.
|
||||
|
||||
This mixin eliminates code duplication by providing a common implementation
|
||||
for the background service cache pattern used across all sports managers.
|
||||
|
||||
Note: For non-sports managers (weather, stocks, news, etc.), use
|
||||
GenericCacheMixin instead. See src/generic_cache_mixin.py for details.
|
||||
"""
|
||||
|
||||
def _fetch_data_with_background_cache(self,
|
||||
sport_key: str,
|
||||
api_fetch_method: Callable,
|
||||
live_manager_class: type = None) -> Optional[Dict]:
|
||||
"""
|
||||
Common logic for fetching data with background service cache support.
|
||||
|
||||
This method implements the background service cache pattern:
|
||||
1. Live managers always fetch fresh data
|
||||
2. Recent/Upcoming managers try background cache first
|
||||
3. Fallback to direct API call if background data unavailable
|
||||
|
||||
Args:
|
||||
sport_key: Sport identifier (e.g., 'nba', 'nfl', 'ncaa_fb')
|
||||
api_fetch_method: Method to call for direct API fetch
|
||||
live_manager_class: Class to check if this is a live manager
|
||||
|
||||
Returns:
|
||||
Cached or fresh data from API
|
||||
"""
|
||||
start_time = time.time()
|
||||
cache_hit = False
|
||||
cache_source = None
|
||||
|
||||
try:
|
||||
# For Live managers, always fetch fresh data
|
||||
if live_manager_class and isinstance(self, live_manager_class):
|
||||
self.logger.info(f"[{sport_key.upper()}] Live manager - fetching fresh data")
|
||||
result = api_fetch_method(use_cache=False)
|
||||
cache_source = "live_fresh"
|
||||
else:
|
||||
# For Recent/Upcoming managers, try background service cache first
|
||||
cache_key = self.cache_manager.generate_sport_cache_key(sport_key)
|
||||
|
||||
# Check if background service has fresh data
|
||||
if self.cache_manager.is_background_data_available(cache_key, sport_key):
|
||||
cached_data = self.cache_manager.get_background_cached_data(cache_key, sport_key)
|
||||
if cached_data:
|
||||
self.logger.info(f"[{sport_key.upper()}] Using background service cache for {cache_key}")
|
||||
result = cached_data
|
||||
cache_hit = True
|
||||
cache_source = "background_cache"
|
||||
else:
|
||||
self.logger.warning(f"[{sport_key.upper()}] Background cache check passed but no data returned for {cache_key}")
|
||||
result = None
|
||||
cache_source = "background_miss"
|
||||
else:
|
||||
self.logger.info(f"[{sport_key.upper()}] Background data not available for {cache_key}")
|
||||
result = None
|
||||
cache_source = "background_unavailable"
|
||||
|
||||
# Fallback to direct API call if background data not available
|
||||
if result is None:
|
||||
self.logger.info(f"[{sport_key.upper()}] Fetching directly from API for {cache_key}")
|
||||
result = api_fetch_method(use_cache=True)
|
||||
cache_source = "api_fallback"
|
||||
|
||||
# Record performance metrics
|
||||
duration = time.time() - start_time
|
||||
self.cache_manager.record_fetch_time(duration)
|
||||
|
||||
# Log performance metrics
|
||||
self._log_fetch_performance(sport_key, duration, cache_hit, cache_source)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
duration = time.time() - start_time
|
||||
self.logger.error(f"[{sport_key.upper()}] Error in background cache fetch after {duration:.2f}s: {e}")
|
||||
self.cache_manager.record_fetch_time(duration)
|
||||
raise
|
||||
|
||||
def _log_fetch_performance(self, sport_key: str, duration: float, cache_hit: bool, cache_source: str):
|
||||
"""
|
||||
Log detailed performance metrics for fetch operations.
|
||||
|
||||
Args:
|
||||
sport_key: Sport identifier
|
||||
duration: Fetch operation duration in seconds
|
||||
cache_hit: Whether this was a cache hit
|
||||
cache_source: Source of the data (background_cache, api_fallback, etc.)
|
||||
"""
|
||||
# Log basic performance info
|
||||
self.logger.info(f"[{sport_key.upper()}] Fetch completed in {duration:.2f}s "
|
||||
f"(cache_hit={cache_hit}, source={cache_source})")
|
||||
|
||||
# Log detailed metrics every 10 operations
|
||||
if hasattr(self, '_fetch_count'):
|
||||
self._fetch_count += 1
|
||||
else:
|
||||
self._fetch_count = 1
|
||||
|
||||
if self._fetch_count % 10 == 0:
|
||||
metrics = self.cache_manager.get_cache_metrics()
|
||||
self.logger.info(f"[{sport_key.upper()}] Cache Performance Summary - "
|
||||
f"Hit Rate: {metrics['cache_hit_rate']:.2%}, "
|
||||
f"Background Hit Rate: {metrics['background_hit_rate']:.2%}, "
|
||||
f"API Calls Saved: {metrics['api_calls_saved']}")
|
||||
|
||||
def get_cache_performance_summary(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get cache performance summary for this manager.
|
||||
|
||||
Returns:
|
||||
Dictionary containing cache performance metrics
|
||||
"""
|
||||
return self.cache_manager.get_cache_metrics()
|
||||
|
||||
def log_cache_performance(self):
|
||||
"""Log current cache performance metrics."""
|
||||
self.cache_manager.log_cache_metrics()
|
||||
@@ -29,6 +29,10 @@ except ImportError:
|
||||
|
||||
|
||||
class SportsCore(ABC):
|
||||
# Which ScoreboardSkin render method this class's display path maps to.
|
||||
# SportsLive inherits the default; SportsUpcoming/SportsRecent override.
|
||||
SKIN_MODE = "live"
|
||||
|
||||
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str):
|
||||
self.logger = logger
|
||||
self.config = config
|
||||
@@ -99,6 +103,17 @@ class SportsCore(ABC):
|
||||
self.last_update = 0
|
||||
self.current_game = None
|
||||
self.fonts = self._load_fonts()
|
||||
|
||||
# Optional visual skin (see docs/SKIN_SYSTEM.md). "skin" is either a
|
||||
# skin id applied to all modes, or a per-mode mapping like
|
||||
# {"live": "retro", "recent": "built-in"}. Loaded lazily on first
|
||||
# render so a broken skin can never block startup.
|
||||
self._skin_config = self.mode_config.get("skin")
|
||||
self.skin_options = self.mode_config.get("skin_options", {}) or {}
|
||||
self._skin = None
|
||||
self._skin_load_attempted = False
|
||||
self._skin_failures = 0
|
||||
self._skin_slow_renders = 0
|
||||
|
||||
# Initialize dynamic team resolver and resolve favorite teams
|
||||
self.dynamic_resolver = DynamicTeamResolver()
|
||||
@@ -205,6 +220,95 @@ class SportsCore(ABC):
|
||||
self.logger.error(f"Error in base _draw_scorebug_layout: {e}", exc_info=True)
|
||||
|
||||
|
||||
def _resolve_skin_id(self) -> Optional[str]:
|
||||
"""The skin id configured for this instance's mode, or None for the
|
||||
built-in renderer. Accepts a plain id (all modes) or a per-mode
|
||||
mapping ({"live": "retro-baseball", "recent": "built-in"})."""
|
||||
skin_id = self._skin_config
|
||||
if isinstance(skin_id, dict):
|
||||
skin_id = skin_id.get(self.SKIN_MODE)
|
||||
if not skin_id or not isinstance(skin_id, str) or skin_id == "built-in":
|
||||
return None
|
||||
return skin_id
|
||||
|
||||
def _get_skin(self):
|
||||
"""Lazily load the configured skin once. Returns None (built-in
|
||||
renderer) when no skin is configured or loading failed."""
|
||||
if not self._skin_load_attempted:
|
||||
self._skin_load_attempted = True
|
||||
skin_id = self._resolve_skin_id()
|
||||
if skin_id:
|
||||
try:
|
||||
from src.skin_system import skin_runtime
|
||||
self._skin = skin_runtime.load_skin(
|
||||
skin_id, sport=self.sport, sport_key=self.sport_key,
|
||||
options=self.skin_options)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to load skin '{skin_id}': {e}", exc_info=True)
|
||||
self._skin = None
|
||||
return self._skin
|
||||
|
||||
def _render_game(self, game: Dict, force_clear: bool = False) -> None:
|
||||
"""Render one game: try the configured skin first, fall back to the
|
||||
built-in _draw_scorebug_layout. A skin that raises 3 times in a row
|
||||
is disabled for the rest of the session."""
|
||||
skin = self._get_skin()
|
||||
if skin is not None and self._skin_failures < 3:
|
||||
try:
|
||||
from src.skin_system import skin_runtime
|
||||
ctx = skin_runtime.build_context(self, game)
|
||||
render = getattr(skin, f"render_{self.SKIN_MODE}")
|
||||
started = time.monotonic()
|
||||
handled = render(ctx, dict(game))
|
||||
elapsed = time.monotonic() - started
|
||||
if elapsed > 0.15 and self._skin_slow_renders < 5:
|
||||
self._skin_slow_renders += 1
|
||||
self.logger.warning(
|
||||
f"Skin '{self._resolve_skin_id()}' took {elapsed * 1000:.0f}ms to "
|
||||
f"render {self.SKIN_MODE} — slow renders stall the whole display loop")
|
||||
if handled:
|
||||
self._skin_failures = 0
|
||||
self.display_manager.image.paste(ctx.canvas, (0, 0))
|
||||
self.display_manager.update_display()
|
||||
return
|
||||
except Exception:
|
||||
self._skin_failures += 1
|
||||
outcome = ("disabling skin for this session" if self._skin_failures >= 3
|
||||
else "falling back to built-in renderer")
|
||||
self.logger.error(
|
||||
f"Skin '{self._resolve_skin_id()}' failed rendering {self.SKIN_MODE} "
|
||||
f"({self._skin_failures}/3); {outcome}", exc_info=True)
|
||||
self._draw_scorebug_layout(game, force_clear)
|
||||
|
||||
def render_skin_card(self, game: Dict, size: tuple) -> Optional[Image.Image]:
|
||||
"""Render one game as a standalone card via the configured skin —
|
||||
for vegas mode and previews. Tries render_vegas_card at the given
|
||||
size, then the mode renderer on a card-sized canvas. Returns None
|
||||
when no skin is active or the skin declined, so callers can use
|
||||
their default rendering."""
|
||||
skin = self._get_skin()
|
||||
if skin is None or self._skin_failures >= 3:
|
||||
return None
|
||||
try:
|
||||
from src.skin_system import skin_runtime
|
||||
ctx = skin_runtime.build_context(self, game, size=size)
|
||||
card = skin.render_vegas_card(ctx, dict(game))
|
||||
if card is not None:
|
||||
return card
|
||||
ctx = skin_runtime.build_context(self, game, size=size)
|
||||
render = getattr(skin, f"render_{self.SKIN_MODE}")
|
||||
if render(ctx, dict(game)):
|
||||
return ctx.canvas
|
||||
except Exception:
|
||||
# Card failures count toward the same 3-strike session disable
|
||||
# as display failures — a skin broken for vegas shouldn't get
|
||||
# to throw on every scroll tick forever.
|
||||
self._skin_failures += 1
|
||||
self.logger.error(
|
||||
f"Skin '{self._resolve_skin_id()}' card render failed "
|
||||
f"({self._skin_failures}/3)", exc_info=True)
|
||||
return None
|
||||
|
||||
def display(self, force_clear: bool = False) -> bool:
|
||||
"""Common display method for all NCAA FB managers""" # Updated docstring
|
||||
if not self.is_enabled: # Check if module is enabled
|
||||
@@ -229,7 +333,7 @@ class SportsCore(ABC):
|
||||
return False
|
||||
|
||||
try:
|
||||
self._draw_scorebug_layout(self.current_game, force_clear)
|
||||
self._render_game(self.current_game, force_clear)
|
||||
# display_manager.update_display() should be called within subclass draw methods
|
||||
# or after calling display() in the main loop. Let's keep it out of the base display.
|
||||
return True
|
||||
@@ -646,6 +750,8 @@ class SportsCore(ABC):
|
||||
pass
|
||||
|
||||
class SportsUpcoming(SportsCore):
|
||||
SKIN_MODE = "upcoming"
|
||||
|
||||
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.upcoming_games = [] # Store all fetched upcoming games initially
|
||||
@@ -973,7 +1079,7 @@ class SportsUpcoming(SportsCore):
|
||||
self.logger.debug(f"Switched to game index {self.current_game_index}")
|
||||
|
||||
if self.current_game:
|
||||
self._draw_scorebug_layout(self.current_game, force_clear)
|
||||
self._render_game(self.current_game, force_clear)
|
||||
return True
|
||||
# update_display() is called within _draw_scorebug_layout for upcoming
|
||||
return False
|
||||
@@ -984,6 +1090,7 @@ class SportsUpcoming(SportsCore):
|
||||
|
||||
|
||||
class SportsRecent(SportsCore):
|
||||
SKIN_MODE = "recent"
|
||||
|
||||
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)
|
||||
@@ -1274,7 +1381,7 @@ class SportsRecent(SportsCore):
|
||||
self.logger.debug(f"Switched to game index {self.current_game_index}")
|
||||
|
||||
if self.current_game:
|
||||
self._draw_scorebug_layout(self.current_game, force_clear)
|
||||
self._render_game(self.current_game, force_clear)
|
||||
return True
|
||||
# update_display() is called within _draw_scorebug_layout for recent
|
||||
return False
|
||||
|
||||
@@ -10,6 +10,7 @@ import time
|
||||
import tempfile
|
||||
import logging
|
||||
import threading
|
||||
import zlib
|
||||
from typing import Dict, Any, Optional, Protocol
|
||||
from datetime import datetime
|
||||
|
||||
@@ -53,6 +54,11 @@ class DiskCache:
|
||||
self.cache_dir = cache_dir
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
self._lock = threading.Lock()
|
||||
# key -> adler32 of the last payload successfully written to the
|
||||
# primary cache path; lets set() skip rewriting identical data
|
||||
# (per-process only — worst case another process rewrites, never
|
||||
# a missed write). Guarded by _lock.
|
||||
self._write_digests: Dict[str, int] = {}
|
||||
|
||||
def get_cache_path(self, key: str) -> Optional[str]:
|
||||
"""
|
||||
@@ -155,10 +161,35 @@ class DiskCache:
|
||||
cache_path = self.get_cache_path(key)
|
||||
if not cache_path:
|
||||
return
|
||||
|
||||
|
||||
# Serialize once, compact (no indent): the payload is reused by every
|
||||
# write path below, and cache files are machine-read only — indenting
|
||||
# them just multiplied the bytes written to the SD card.
|
||||
try:
|
||||
payload = json.dumps(data, cls=DateTimeEncoder)
|
||||
except (TypeError, ValueError) as e:
|
||||
self.logger.warning("Cache data for key '%s' not serializable: %s", key, e)
|
||||
return
|
||||
|
||||
digest = zlib.adler32(payload.encode('utf-8'))
|
||||
|
||||
try:
|
||||
# Atomic write to avoid partial/corrupt files
|
||||
with self._lock:
|
||||
# Skip the disk entirely when this exact payload was already
|
||||
# written for this key (plugins re-save unchanged API data
|
||||
# every update cycle — each write is real SD-card wear).
|
||||
# Refresh the file mtime so records that rely on it for TTL
|
||||
# (no embedded 'timestamp') don't expire early; a metadata
|
||||
# touch is journal-cheap compared to rewriting the data.
|
||||
if self._write_digests.get(key) == digest:
|
||||
try:
|
||||
os.utime(cache_path, None)
|
||||
return
|
||||
except OSError:
|
||||
# File vanished or perms changed — fall through and write
|
||||
self._write_digests.pop(key, None)
|
||||
|
||||
tmp_dir = os.path.dirname(cache_path)
|
||||
# Try to create temp file in cache directory first
|
||||
# If that fails due to permissions, fall back to direct write
|
||||
@@ -181,13 +212,17 @@ class DiskCache:
|
||||
fd = None
|
||||
|
||||
if tmp_path and fd is not None:
|
||||
# Use atomic write with temp file
|
||||
# Atomic write with temp file. No fsync: os.replace
|
||||
# already guarantees readers never see a torn file,
|
||||
# and cache data is re-fetchable — forcing a disk
|
||||
# flush per write was the single biggest SD-card
|
||||
# wear source (dozens of fsyncs/min on API-heavy
|
||||
# installs) for data that can be re-downloaded.
|
||||
try:
|
||||
with os.fdopen(fd, 'w', encoding='utf-8') as tmp_file:
|
||||
json.dump(data, tmp_file, indent=4, cls=DateTimeEncoder)
|
||||
tmp_file.flush()
|
||||
os.fsync(tmp_file.fileno())
|
||||
tmp_file.write(payload)
|
||||
os.replace(tmp_path, cache_path)
|
||||
self._write_digests[key] = digest
|
||||
# Set proper permissions: 660 (rw-rw----) for group-readable cache files
|
||||
try:
|
||||
os.chmod(cache_path, 0o660) # nosec B103 - intentional; web UI and service share a group
|
||||
@@ -203,9 +238,8 @@ class DiskCache:
|
||||
# Fallback: direct write (not atomic, but better than failing)
|
||||
try:
|
||||
with open(cache_path, 'w', encoding='utf-8') as cache_file:
|
||||
json.dump(data, cache_file, indent=4, cls=DateTimeEncoder)
|
||||
cache_file.flush()
|
||||
os.fsync(cache_file.fileno())
|
||||
cache_file.write(payload)
|
||||
self._write_digests[key] = digest
|
||||
# Set proper permissions: 660 (rw-rw----) for group-readable cache files
|
||||
try:
|
||||
os.chmod(cache_path, 0o660) # nosec B103 - intentional; web UI and service share a group
|
||||
@@ -229,9 +263,12 @@ class DiskCache:
|
||||
pass
|
||||
|
||||
if os.path.isdir(fallback_dir) and os.access(fallback_dir, os.W_OK):
|
||||
# NOTE: no digest record here — the fallback file
|
||||
# is a different path, so future sets must keep
|
||||
# retrying the primary location.
|
||||
fallback_path = os.path.join(fallback_dir, os.path.basename(cache_path))
|
||||
with open(fallback_path, 'w', encoding='utf-8') as tmp_file:
|
||||
json.dump(data, tmp_file, indent=4, cls=DateTimeEncoder)
|
||||
tmp_file.write(payload)
|
||||
# Set proper permissions: 660 (rw-rw----) for group-readable cache files
|
||||
try:
|
||||
os.chmod(fallback_path, 0o660) # nosec B103 - intentional; web UI and service share a group
|
||||
@@ -272,6 +309,7 @@ class DiskCache:
|
||||
|
||||
with self._lock:
|
||||
if key:
|
||||
self._write_digests.pop(key, None)
|
||||
cache_path = self.get_cache_path(key)
|
||||
if cache_path and os.path.exists(cache_path):
|
||||
try:
|
||||
@@ -280,6 +318,7 @@ class DiskCache:
|
||||
self.logger.warning("Could not remove cache file %s: %s", cache_path, e)
|
||||
else:
|
||||
# Clear all cache files
|
||||
self._write_digests.clear()
|
||||
if os.path.exists(self.cache_dir):
|
||||
for filename in os.listdir(self.cache_dir):
|
||||
if filename.endswith('.json'):
|
||||
|
||||
@@ -2,6 +2,28 @@
|
||||
|
||||
This directory contains reusable utilities and helpers for LEDMatrix plugins and core modules.
|
||||
|
||||
## Adaptive Layout & Images (`src/adaptive_layout.py`, `src/adaptive_images.py`)
|
||||
|
||||
The recommended way to lay out plugins that render legibly on **any** panel
|
||||
size (64x32 through 256x128+) without hand-tuned coordinates. Re-exported
|
||||
from `src.common` for convenience; canonical import paths are
|
||||
`src.adaptive_layout` / `src.adaptive_images`.
|
||||
|
||||
```python
|
||||
# Every BasePlugin already has self.layout and the draw helpers:
|
||||
regs = scoreboard_regions(self.layout.bounds, ctx=self.layout)
|
||||
self.draw_image(away_logo, regs.away_slot, mode="fill_height",
|
||||
crop_to_ink=True, cache_key=f"logo:{abbr}")
|
||||
self.draw_fit(score_text, regs.score_area) # largest crisp font that fits
|
||||
self.draw_fit(status, regs.status_band)
|
||||
```
|
||||
|
||||
Key pieces: `Region` (rect algebra: bands/columns/splits/offset),
|
||||
font ladders (`LADDER_GRID`, `LADDER_ARCADE` — discrete crisp sizes, never
|
||||
fractional scaling), `LayoutContext` (`fit_text`, `fit_image`, `by_tier`,
|
||||
`px`), and composite carvers `scoreboard_regions()` / `media_row()`.
|
||||
Full guide: [docs/ADAPTIVE_LAYOUT.md](../../docs/ADAPTIVE_LAYOUT.md).
|
||||
|
||||
## Error Handling (`error_handler.py`)
|
||||
|
||||
Common error handling patterns and utilities:
|
||||
|
||||
@@ -26,6 +26,31 @@ from src.common.scroll_helper import ScrollHelper
|
||||
from src.common.logo_helper import LogoHelper
|
||||
from src.common.text_helper import TextHelper
|
||||
|
||||
# Adaptive layout & images (canonical homes: src.adaptive_layout /
|
||||
# src.adaptive_images — re-exported here so plugin authors find them in the
|
||||
# blessed-helpers package). See docs/ADAPTIVE_LAYOUT.md.
|
||||
from src.adaptive_layout import (
|
||||
Region,
|
||||
LayoutContext,
|
||||
FontStep,
|
||||
FontLadder,
|
||||
LADDER_GRID,
|
||||
LADDER_ARCADE,
|
||||
FitResult,
|
||||
draw_fitted_text,
|
||||
ScoreboardRegions,
|
||||
scoreboard_regions,
|
||||
MediaRow,
|
||||
media_row,
|
||||
)
|
||||
from src.adaptive_images import (
|
||||
ImageFitResult,
|
||||
fit_image,
|
||||
draw_fitted_image,
|
||||
RESAMPLE_LANCZOS,
|
||||
RESAMPLE_NEAREST,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'handle_file_operation',
|
||||
'handle_json_operation',
|
||||
@@ -37,4 +62,22 @@ __all__ = [
|
||||
'ScrollHelper',
|
||||
'LogoHelper',
|
||||
'TextHelper',
|
||||
# adaptive layout & images
|
||||
'Region',
|
||||
'LayoutContext',
|
||||
'FontStep',
|
||||
'FontLadder',
|
||||
'LADDER_GRID',
|
||||
'LADDER_ARCADE',
|
||||
'FitResult',
|
||||
'draw_fitted_text',
|
||||
'ScoreboardRegions',
|
||||
'scoreboard_regions',
|
||||
'MediaRow',
|
||||
'media_row',
|
||||
'ImageFitResult',
|
||||
'fit_image',
|
||||
'draw_fitted_image',
|
||||
'RESAMPLE_LANCZOS',
|
||||
'RESAMPLE_NEAREST',
|
||||
]
|
||||
|
||||
@@ -1,328 +0,0 @@
|
||||
"""
|
||||
Example: Basketball Plugin using LEDMatrix Common Helpers
|
||||
|
||||
This example shows how to refactor the basketball plugin to use the
|
||||
ledmatrix-common package for cleaner, more maintainable code.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
# Import common helpers
|
||||
from src.common import (
|
||||
LogoHelper, TextHelper, APIHelper, DisplayHelper,
|
||||
GameHelper, ConfigHelper
|
||||
)
|
||||
from src.plugin_system.base_plugin import BasePlugin
|
||||
|
||||
|
||||
class BasketballPluginManager(BasePlugin):
|
||||
"""
|
||||
Basketball scoreboard plugin using LEDMatrix Common helpers.
|
||||
|
||||
This version is much cleaner and more maintainable than the original
|
||||
because it delegates common functionality to the shared helpers.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
plugin_id: str,
|
||||
config: Dict[str, Any],
|
||||
display_manager,
|
||||
cache_manager,
|
||||
plugin_manager
|
||||
):
|
||||
"""Initialize the basketball plugin with common helpers."""
|
||||
super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager)
|
||||
|
||||
# Get display dimensions
|
||||
self.display_width = display_manager.matrix.width
|
||||
self.display_height = display_manager.matrix.height
|
||||
|
||||
# Initialize common helpers
|
||||
self._init_helpers()
|
||||
|
||||
# Load configuration
|
||||
self._load_config()
|
||||
|
||||
# State tracking
|
||||
self.current_games = []
|
||||
self.current_game = None
|
||||
|
||||
# Log initialization
|
||||
enabled_leagues = [k for k, v in self.league_configs.items() if v['enabled']]
|
||||
self.logger.info(f"Basketball plugin initialized with leagues: {enabled_leagues}")
|
||||
|
||||
def _init_helpers(self):
|
||||
"""Initialize all common helpers."""
|
||||
# Logo helper for team logos
|
||||
self.logo_helper = LogoHelper(
|
||||
display_width=self.display_width,
|
||||
display_height=self.display_height,
|
||||
logger=self.logger
|
||||
)
|
||||
|
||||
# Text helper for rendering
|
||||
self.text_helper = TextHelper(logger=self.logger)
|
||||
self.fonts = self.text_helper.load_fonts()
|
||||
|
||||
# API helper for ESPN data
|
||||
self.api_helper = APIHelper(
|
||||
cache_manager=self.cache_manager,
|
||||
logger=self.logger
|
||||
)
|
||||
|
||||
# Display helper for layouts
|
||||
self.display_helper = DisplayHelper(
|
||||
display_width=self.display_width,
|
||||
display_height=self.display_height,
|
||||
logger=self.logger
|
||||
)
|
||||
|
||||
# Game helper for data processing
|
||||
self.game_helper = GameHelper(
|
||||
timezone_str=self.config.get('timezone', 'UTC'),
|
||||
logger=self.logger
|
||||
)
|
||||
|
||||
# Config helper for configuration management
|
||||
self.config_helper = ConfigHelper(logger=self.logger)
|
||||
|
||||
def _load_config(self):
|
||||
"""Load and validate configuration."""
|
||||
# Get basketball-specific config
|
||||
basketball_config = self.config_helper.get_sports_config(self.config, 'basketball')
|
||||
|
||||
# Build league configurations
|
||||
self.league_configs = {
|
||||
'nba': {
|
||||
'enabled': basketball_config.get('nba_enabled', True),
|
||||
'url': 'https://site.api.espn.com/apis/site/v2/sports/basketball/nba/scoreboard',
|
||||
'logo_dir': Path('assets/sports/nba_logos'),
|
||||
'favorite_teams': basketball_config.get('nba_favorite_teams', []),
|
||||
'display_modes': {
|
||||
'nba_live': basketball_config.get('nba_display_modes_live', True),
|
||||
'nba_recent': basketball_config.get('nba_display_modes_recent', True),
|
||||
'nba_upcoming': basketball_config.get('nba_display_modes_upcoming', True),
|
||||
},
|
||||
},
|
||||
'wnba': {
|
||||
'enabled': basketball_config.get('wnba_enabled', False),
|
||||
'url': 'https://site.api.espn.com/apis/site/v2/sports/basketball/wnba/scoreboard',
|
||||
'logo_dir': Path('assets/sports/wnba_logos'),
|
||||
'favorite_teams': basketball_config.get('wnba_favorite_teams', []),
|
||||
'display_modes': {
|
||||
'wnba_live': basketball_config.get('wnba_display_modes_live', True),
|
||||
'wnba_recent': basketball_config.get('wnba_display_modes_recent', True),
|
||||
'wnba_upcoming': basketball_config.get('wnba_display_modes_upcoming', True),
|
||||
},
|
||||
},
|
||||
'ncaam': {
|
||||
'enabled': basketball_config.get('ncaam_basketball_enabled', False),
|
||||
'url': 'https://site.api.espn.com/apis/site/v2/sports/basketball/mens-college-basketball/scoreboard',
|
||||
'logo_dir': Path('assets/sports/ncaa_logos'),
|
||||
'favorite_teams': basketball_config.get('ncaam_basketball_favorite_teams', []),
|
||||
'display_modes': {
|
||||
'ncaam_basketball_live': basketball_config.get('ncaam_basketball_display_modes_live', True),
|
||||
'ncaam_basketball_recent': basketball_config.get('ncaam_basketball_display_modes_recent', True),
|
||||
'ncaam_basketball_upcoming': basketball_config.get('ncaam_basketball_display_modes_upcoming', True),
|
||||
},
|
||||
},
|
||||
'ncaaw': {
|
||||
'enabled': basketball_config.get('ncaaw_basketball_enabled', False),
|
||||
'url': 'https://site.api.espn.com/apis/site/v2/sports/basketball/womens-college-basketball/scoreboard',
|
||||
'logo_dir': Path('assets/sports/ncaa_logos'),
|
||||
'favorite_teams': basketball_config.get('ncaaw_basketball_favorite_teams', []),
|
||||
'display_modes': {
|
||||
'ncaaw_basketball_live': basketball_config.get('ncaaw_basketball_display_modes_live', True),
|
||||
'ncaaw_basketball_recent': basketball_config.get('ncaaw_basketball_display_modes_recent', True),
|
||||
'ncaaw_basketball_upcoming': basketball_config.get('ncaaw_basketball_display_modes_upcoming', True),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def update(self) -> None:
|
||||
"""Update game data for all enabled leagues."""
|
||||
try:
|
||||
all_games = []
|
||||
|
||||
for league_key, league_config in self.league_configs.items():
|
||||
if not league_config['enabled']:
|
||||
continue
|
||||
|
||||
games = self._fetch_league_games(league_key, league_config)
|
||||
for game in games:
|
||||
game['league_key'] = league_key
|
||||
game['league_config'] = league_config
|
||||
all_games.extend(games)
|
||||
|
||||
self.current_games = all_games
|
||||
self.logger.debug(f"Updated basketball data: {len(all_games)} total games")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error updating basketball data: {e}", exc_info=True)
|
||||
|
||||
def _fetch_league_games(self, league_key: str, league_config: Dict) -> List[Dict]:
|
||||
"""Fetch games for a specific league using API helper."""
|
||||
try:
|
||||
# Use API helper to fetch ESPN data with caching
|
||||
data = self.api_helper.fetch_espn_scoreboard(
|
||||
sport='basketball',
|
||||
league=league_key,
|
||||
cache_key=f"basketball_{league_key}",
|
||||
cache_ttl=300 # 5 minutes cache
|
||||
)
|
||||
|
||||
if not data:
|
||||
return []
|
||||
|
||||
# Use game helper to process events
|
||||
events = data.get('events', [])
|
||||
games = self.game_helper.process_games(events, sport='basketball')
|
||||
|
||||
# Add logo paths to games
|
||||
for game in games:
|
||||
logo_dir = league_config['logo_dir']
|
||||
game['home_logo_path'] = logo_dir / f"{game['home_abbr']}.png"
|
||||
game['away_logo_path'] = logo_dir / f"{game['away_abbr']}.png"
|
||||
|
||||
return games
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error fetching {league_key} games: {e}", exc_info=True)
|
||||
return []
|
||||
|
||||
def display(self, force_clear: bool = False, display_mode: str = None) -> None:
|
||||
"""Display basketball games using display helper."""
|
||||
try:
|
||||
mode = display_mode or self._determine_display_mode()
|
||||
|
||||
if not mode:
|
||||
self._display_no_games()
|
||||
return
|
||||
|
||||
# Filter games for mode
|
||||
filtered_games = self._filter_games_for_mode(mode)
|
||||
|
||||
if not filtered_games:
|
||||
self._display_no_games()
|
||||
return
|
||||
|
||||
# Display first game
|
||||
self.current_game = filtered_games[0]
|
||||
self._draw_scorebug_layout(self.current_game, force_clear)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error displaying game: {e}", exc_info=True)
|
||||
|
||||
def _determine_display_mode(self) -> Optional[str]:
|
||||
"""Determine display mode based on available games."""
|
||||
# Priority: live > recent > upcoming
|
||||
for game in self.current_games:
|
||||
if game.get('is_live'):
|
||||
return f"{game['league_key']}_live"
|
||||
for game in self.current_games:
|
||||
if game.get('is_final'):
|
||||
return f"{game['league_key']}_recent"
|
||||
for game in self.current_games:
|
||||
if game.get('is_upcoming'):
|
||||
return f"{game['league_key']}_upcoming"
|
||||
return None
|
||||
|
||||
def _filter_games_for_mode(self, mode: str) -> List[Dict]:
|
||||
"""Filter games based on display mode."""
|
||||
filtered = []
|
||||
|
||||
for game in self.current_games:
|
||||
league_config = game.get('league_config', {})
|
||||
display_modes = league_config.get('display_modes', {})
|
||||
|
||||
if mode in display_modes and display_modes[mode]:
|
||||
if 'live' in mode and game.get('is_live'):
|
||||
filtered.append(game)
|
||||
elif 'recent' in mode and game.get('is_final'):
|
||||
filtered.append(game)
|
||||
elif 'upcoming' in mode and game.get('is_upcoming'):
|
||||
filtered.append(game)
|
||||
|
||||
return filtered[:5]
|
||||
|
||||
def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None:
|
||||
"""Draw the basketball scorebug layout using display helper."""
|
||||
try:
|
||||
# Load logos using logo helper
|
||||
home_logo = self.logo_helper.load_logo(
|
||||
game['home_abbr'],
|
||||
game['home_logo_path']
|
||||
)
|
||||
away_logo = self.logo_helper.load_logo(
|
||||
game['away_abbr'],
|
||||
game['away_logo_path']
|
||||
)
|
||||
|
||||
if not home_logo or not away_logo:
|
||||
self.logger.error("Failed to load logos")
|
||||
self._display_error("Logo Error")
|
||||
return
|
||||
|
||||
# Use display helper to create scorebug layout
|
||||
final_img = self.display_helper.draw_scorebug_layout(
|
||||
game_data=game,
|
||||
fonts=self.fonts,
|
||||
home_logo=home_logo,
|
||||
away_logo=away_logo
|
||||
)
|
||||
|
||||
# Display the image
|
||||
self.display_manager.image.paste(final_img, (0, 0))
|
||||
self.display_manager.update_display()
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error drawing scorebug: {e}", exc_info=True)
|
||||
|
||||
def _display_no_games(self) -> None:
|
||||
"""Display 'no games' message using display helper."""
|
||||
try:
|
||||
img = self.display_helper.draw_no_data_message("No Games")
|
||||
self.display_manager.image = img.copy()
|
||||
self.display_manager.update_display()
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error displaying no games: {e}", exc_info=True)
|
||||
|
||||
def _display_error(self, message: str) -> None:
|
||||
"""Display error message using display helper."""
|
||||
try:
|
||||
img = self.display_helper.draw_error_message(message)
|
||||
self.display_manager.image = img.copy()
|
||||
self.display_manager.update_display()
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error displaying error message: {e}", exc_info=True)
|
||||
|
||||
def get_display_duration(self) -> float:
|
||||
"""Get display duration."""
|
||||
return self.config.get('display_duration', 15)
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Cleanup resources."""
|
||||
self.current_games = []
|
||||
self.logger.info("Basketball plugin cleaned up")
|
||||
|
||||
|
||||
# Example usage and benefits:
|
||||
"""
|
||||
Benefits of using LEDMatrix Common helpers:
|
||||
|
||||
1. **Cleaner Code**: The plugin is much shorter and more readable
|
||||
2. **Reusable Components**: Common functionality is shared across plugins
|
||||
3. **Better Testing**: Each helper can be tested independently
|
||||
4. **Easier Maintenance**: Bug fixes in helpers benefit all plugins
|
||||
5. **Consistent Behavior**: All plugins use the same underlying logic
|
||||
6. **Reduced Dependencies**: Plugins don't need to import LEDMatrix core
|
||||
7. **Better Error Handling**: Centralized error handling in helpers
|
||||
8. **Configuration Management**: Consistent config handling across plugins
|
||||
|
||||
The original basketball plugin was 326 lines. This version is much cleaner
|
||||
and delegates most functionality to the common helpers, making it easier to
|
||||
maintain and extend.
|
||||
"""
|
||||
@@ -72,9 +72,20 @@ class LogoHelper:
|
||||
|
||||
Returns:
|
||||
PIL Image object or None if loading fails
|
||||
|
||||
Note: for new adaptive-layout code prefer ``BasePlugin.draw_image``
|
||||
/ ``LayoutContext.fit_image`` (src/adaptive_images.py) for the
|
||||
fitting step — LogoHelper remains useful for its download and
|
||||
placeholder logic.
|
||||
"""
|
||||
# Check cache first
|
||||
cache_key = f"{team_abbr}_{logo_path}"
|
||||
# Resolve the effective target size BEFORE the cache lookup so the
|
||||
# key is size-qualified — a panel-size change must not return a
|
||||
# logo resized for the old dimensions.
|
||||
if max_width is None:
|
||||
max_width = int(self.display_width * 1.5)
|
||||
if max_height is None:
|
||||
max_height = int(self.display_height * 1.5)
|
||||
cache_key = f"{team_abbr}_{logo_path}_{max_width}x{max_height}"
|
||||
if cache_key in self._logo_cache:
|
||||
self.logger.debug(f"Using cached logo for {team_abbr}")
|
||||
# Update LRU order (move to end)
|
||||
|
||||
@@ -146,6 +146,60 @@ def ensure_file_permissions(path: Path, mode: int = 0o644) -> None:
|
||||
raise
|
||||
|
||||
|
||||
_shared_group_gid_cache: Optional[int] = None
|
||||
|
||||
|
||||
def get_shared_group_gid() -> Optional[int]:
|
||||
"""
|
||||
Return the gid that should own config/secrets files shared between the
|
||||
root-run ``ledmatrix.service`` (main display) and the non-root user that
|
||||
``ledmatrix-web.service`` runs as (see install_web_service.sh, which sets
|
||||
``User=$SUDO_USER``).
|
||||
|
||||
Resolved once from the project root directory's current group (normally
|
||||
the login user's group from the initial ``git clone``), since that user
|
||||
is stable across reinstalls unlike any single file's ownership.
|
||||
|
||||
Returns:
|
||||
The gid, or None if it cannot be determined.
|
||||
"""
|
||||
global _shared_group_gid_cache
|
||||
if _shared_group_gid_cache is not None:
|
||||
return _shared_group_gid_cache
|
||||
try:
|
||||
project_root = Path(__file__).resolve().parent.parent.parent
|
||||
_shared_group_gid_cache = project_root.stat().st_gid
|
||||
return _shared_group_gid_cache
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def ensure_shared_group_ownership(path: Path) -> None:
|
||||
"""
|
||||
Best-effort chgrp of ``path`` to the shared group (see
|
||||
:func:`get_shared_group_gid`) when running as root.
|
||||
|
||||
Only root can change a file's group to one the calling process isn't a
|
||||
member of, which is exactly the case that causes the web interface
|
||||
(running as a non-root user) to get ``PermissionError`` reading files
|
||||
the root-run display service just wrote with a 0o640/2775 mode: the mode
|
||||
is group-readable, but without this the group is root's, not the web
|
||||
user's. Silently does nothing if not running as root or on any error —
|
||||
this is a hardening step, not a required one.
|
||||
"""
|
||||
if os.geteuid() != 0:
|
||||
return
|
||||
gid = get_shared_group_gid()
|
||||
if gid is None:
|
||||
return
|
||||
try:
|
||||
if path.exists() and path.stat().st_gid != gid:
|
||||
os.chown(path, -1, gid)
|
||||
logger.debug(f"Set shared group ownership (gid {gid}) on {path}")
|
||||
except OSError as e:
|
||||
logger.debug(f"Could not set shared group ownership on {path}: {e}")
|
||||
|
||||
|
||||
def get_config_file_mode(file_path: Path) -> int:
|
||||
"""
|
||||
Return appropriate permission mode for config files.
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Snapshot write policy for the display preview mirror.
|
||||
|
||||
The display service mirrors frames to /tmp/led_matrix_preview.png, which
|
||||
serves two consumers with different needs:
|
||||
|
||||
- The web UI's live preview (SSE reader in web_interface/app.py) wants
|
||||
fresh frames — but only while a browser is actually watching.
|
||||
- The health check (web_interface/blueprints/api_v3.py, hardware status)
|
||||
uses the file's AGE as a liveness proxy: age >= 60s reads as degraded.
|
||||
|
||||
PNG-encoding every frame at 5 fps forever — identical frames, no viewers —
|
||||
was one of the biggest fixed CPU costs on the Pi. This module is the pure
|
||||
decision logic (extracted so it's unit-testable off-Pi; display_manager
|
||||
imports rgbmatrix unconditionally and can't be):
|
||||
|
||||
WRITE — encode + atomically replace the snapshot file
|
||||
TOUCH — os.utime only: keeps the health-check mtime fresh and lets
|
||||
the SSE reader (mtime-gated) resend at a low rate, without
|
||||
paying for a PNG encode of an unchanged frame
|
||||
SKIP — do nothing
|
||||
|
||||
Policy:
|
||||
- With a fresh viewer marker: changed frames write at up to 1/VIEWER_INTERVAL.
|
||||
- Without viewers: changed frames still write at 1/IDLE_INTERVAL so the
|
||||
preview page shows something recent on open.
|
||||
- Unchanged frames are never re-encoded; the mtime is touched every
|
||||
TOUCH_INTERVAL so the health check (60s threshold) never degrades.
|
||||
|
||||
If any constant here changes, re-check the health threshold in
|
||||
api_v3.py (get_hardware_status) — TOUCH_INTERVAL must stay well under it.
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
|
||||
# Snapshot cadence with a browser preview open (seconds).
|
||||
VIEWER_INTERVAL = 0.2
|
||||
# Snapshot cadence with no viewers — cheap freshness for page-open (seconds).
|
||||
IDLE_INTERVAL = 30.0
|
||||
# Max age of the last write/touch before bumping mtime for the health
|
||||
# check. MUST stay well under api_v3's 60s degraded threshold.
|
||||
TOUCH_INTERVAL = 20.0
|
||||
# A viewer marker older than this no longer counts as a live viewer.
|
||||
VIEWER_MARKER_FRESH_SEC = 5.0
|
||||
|
||||
|
||||
class SnapshotAction(Enum):
|
||||
WRITE = "write"
|
||||
TOUCH = "touch"
|
||||
SKIP = "skip"
|
||||
|
||||
|
||||
def decide(now: float, last_write_ts: float, last_touch_ts: float,
|
||||
viewer_fresh: bool, frame_changed: bool) -> SnapshotAction:
|
||||
"""Decide what to do with the current frame.
|
||||
|
||||
Args:
|
||||
now: current monotonic-ish timestamp (same clock as the ts args)
|
||||
last_write_ts: when a frame was last actually encoded+written
|
||||
last_touch_ts: when the file mtime was last bumped (write or touch)
|
||||
viewer_fresh: a browser preview is currently watching
|
||||
frame_changed: the frame differs from the last WRITTEN frame
|
||||
"""
|
||||
interval = VIEWER_INTERVAL if viewer_fresh else IDLE_INTERVAL
|
||||
if frame_changed and (now - last_write_ts) >= interval:
|
||||
return SnapshotAction.WRITE
|
||||
if (now - max(last_write_ts, last_touch_ts)) >= TOUCH_INTERVAL:
|
||||
return SnapshotAction.TOUCH
|
||||
return SnapshotAction.SKIP
|
||||
@@ -11,6 +11,9 @@ from typing import Dict, List, Optional, Tuple, Union
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
# Shared throwaway draw surface for measuring text without a target canvas.
|
||||
_measure_draw = ImageDraw.Draw(Image.new("RGB", (1, 1)))
|
||||
|
||||
|
||||
class TextHelper:
|
||||
"""
|
||||
@@ -112,10 +115,10 @@ class TextHelper:
|
||||
Width in pixels
|
||||
"""
|
||||
try:
|
||||
return draw.textlength(text, font=font)
|
||||
return int(_measure_draw.textlength(text, font=font))
|
||||
except AttributeError:
|
||||
# Fallback for older PIL versions
|
||||
bbox = draw.textbbox((0, 0), text, font=font)
|
||||
bbox = _measure_draw.textbbox((0, 0), text, font=font)
|
||||
return bbox[2] - bbox[0]
|
||||
|
||||
def get_text_height(self, text: str, font: ImageFont.ImageFont) -> int:
|
||||
@@ -129,13 +132,8 @@ class TextHelper:
|
||||
Returns:
|
||||
Height in pixels
|
||||
"""
|
||||
try:
|
||||
bbox = draw.textbbox((0, 0), text, font=font)
|
||||
return bbox[3] - bbox[1]
|
||||
except AttributeError:
|
||||
# Fallback for older PIL versions
|
||||
bbox = draw.textbbox((0, 0), text, font=font)
|
||||
return bbox[3] - bbox[1]
|
||||
bbox = _measure_draw.textbbox((0, 0), text, font=font)
|
||||
return bbox[3] - bbox[1]
|
||||
|
||||
def get_text_dimensions(self, text: str, font: ImageFont.ImageFont) -> Tuple[int, int]:
|
||||
"""
|
||||
|
||||
@@ -38,6 +38,7 @@ from src.config_manager_atomic import (
|
||||
from src.common.permission_utils import (
|
||||
ensure_directory_permissions,
|
||||
ensure_file_permissions,
|
||||
ensure_shared_group_ownership,
|
||||
get_config_file_mode,
|
||||
get_config_dir_mode
|
||||
)
|
||||
@@ -56,6 +57,13 @@ class ConfigManager:
|
||||
self.secrets_path: str = secrets_path or "config/config_secrets.json"
|
||||
self.template_path: str = "config/config.template.json"
|
||||
self.config: Dict[str, Any] = {}
|
||||
# (mtime_ns, size) signature of (config, secrets, template) at the
|
||||
# last successful load. load_config() skips the full re-read (3 file
|
||||
# parses + recursive template migration) when nothing changed —
|
||||
# ~30 web request handlers call it, some 2-3x per request. Cross-
|
||||
# process freshness is preserved: another process's save bumps the
|
||||
# mtime, so the next load here re-reads.
|
||||
self._loaded_sig: Optional[tuple] = None
|
||||
self.logger: logging.Logger = get_logger(__name__)
|
||||
|
||||
# Initialize atomic config manager
|
||||
@@ -122,6 +130,14 @@ class ConfigManager:
|
||||
# Update in-memory config if save was successful
|
||||
if result.status == SaveResultStatus.SUCCESS:
|
||||
self.config = new_config_data
|
||||
# In-memory config now matches what was just written; refresh
|
||||
# the load signature so the fast path stays valid. NOTE: the
|
||||
# in-memory copy includes merged secrets; the on-disk file has
|
||||
# them stripped — the fast path returning self.config preserves
|
||||
# exactly the pre-cache behavior (load-after-save also returned
|
||||
# the secret-merged self.config only after re-reading secrets;
|
||||
# here secrets file is unchanged, so contents are equivalent).
|
||||
self._loaded_sig = self._files_signature()
|
||||
self.logger.info(f"Configuration successfully saved atomically to {os.path.abspath(self.config_path)}")
|
||||
elif result.status == SaveResultStatus.ROLLED_BACK:
|
||||
# Reload config from file after rollback
|
||||
@@ -179,13 +195,36 @@ class ConfigManager:
|
||||
atomic_mgr = self._get_atomic_manager()
|
||||
return atomic_mgr.validate_config_file(config_path)
|
||||
|
||||
def _files_signature(self) -> tuple:
|
||||
"""(mtime_ns, size) of config/secrets/template, None for missing —
|
||||
cheap staleness probe (3 stats) for the load_config fast path."""
|
||||
sig = []
|
||||
for path in (self.config_path, self.secrets_path, self.template_path):
|
||||
try:
|
||||
st = os.stat(path)
|
||||
sig.append((st.st_mtime_ns, st.st_size))
|
||||
except OSError:
|
||||
sig.append(None)
|
||||
return tuple(sig)
|
||||
|
||||
def load_config(self) -> Dict[str, Any]:
|
||||
"""Load configuration from JSON files."""
|
||||
"""Load configuration from JSON files.
|
||||
|
||||
Fast path: when config.json, config_secrets.json and the template
|
||||
are all unchanged since the last successful load (mtime_ns + size),
|
||||
the already-parsed self.config is returned without touching the
|
||||
files — same aliasing semantics as the full path, which also
|
||||
returns self.config.
|
||||
"""
|
||||
try:
|
||||
current_sig = self._files_signature()
|
||||
if self.config and self._loaded_sig == current_sig:
|
||||
return self.config
|
||||
|
||||
# Check if config file exists, if not create from template
|
||||
if not os.path.exists(self.config_path):
|
||||
self._create_config_from_template()
|
||||
|
||||
|
||||
# Load main config
|
||||
self.logger.info(f"Attempting to load config from: {os.path.abspath(self.config_path)}")
|
||||
with open(self.config_path, 'r') as f:
|
||||
@@ -196,6 +235,11 @@ class ConfigManager:
|
||||
|
||||
# Load and merge secrets if they exist (be permissive on errors)
|
||||
if os.path.exists(self.secrets_path):
|
||||
# Self-heal stale group ownership (e.g. the root-run display
|
||||
# service wrote this file before the web user was granted
|
||||
# group access) before every load attempt; no-op unless
|
||||
# running as root and the group is already wrong.
|
||||
ensure_shared_group_ownership(Path(self.secrets_path))
|
||||
try:
|
||||
with open(self.secrets_path, 'r') as f:
|
||||
secrets = json.load(f)
|
||||
@@ -205,7 +249,10 @@ class ConfigManager:
|
||||
self.logger.warning(f"Secrets file not readable ({self.secrets_path}): {e}. Continuing without secrets.")
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
self.logger.warning(f"Error reading secrets file ({self.secrets_path}): {e}. Continuing without secrets.")
|
||||
|
||||
|
||||
# Signature taken AFTER load + migration (migration may write the
|
||||
# config back), so it reflects exactly what was read/written.
|
||||
self._loaded_sig = self._files_signature()
|
||||
return self.config
|
||||
|
||||
except FileNotFoundError as e:
|
||||
@@ -264,7 +311,8 @@ class ConfigManager:
|
||||
json.dump(config_to_write, f, indent=4)
|
||||
|
||||
# Update the in-memory config to the new state (which includes secrets for runtime)
|
||||
self.config = new_config_data
|
||||
self.config = new_config_data
|
||||
self._loaded_sig = self._files_signature()
|
||||
self.logger.info(f"Configuration successfully saved to {os.path.abspath(self.config_path)}")
|
||||
if secrets_content:
|
||||
self.logger.info("Secret values were preserved in memory and not written to the main config file.")
|
||||
@@ -321,6 +369,7 @@ class ConfigManager:
|
||||
# Set proper file permissions after creation
|
||||
config_path_obj = Path(self.config_path)
|
||||
ensure_file_permissions(config_path_obj, get_config_file_mode(config_path_obj))
|
||||
ensure_shared_group_ownership(config_path_obj)
|
||||
|
||||
self.logger.info(f"Created config.json from template at {os.path.abspath(self.config_path)}")
|
||||
|
||||
@@ -433,6 +482,11 @@ class ConfigManager:
|
||||
self.logger.error(error_msg)
|
||||
raise ConfigError(error_msg, config_path=path_to_load)
|
||||
|
||||
if file_type == "secrets":
|
||||
# Best-effort self-heal: no-op unless running as root and the
|
||||
# group is stale (see load_config for why this can happen).
|
||||
ensure_shared_group_ownership(Path(path_to_load))
|
||||
|
||||
try:
|
||||
with open(path_to_load, 'r') as f:
|
||||
return json.load(f)
|
||||
@@ -440,7 +494,18 @@ class ConfigManager:
|
||||
error_msg = f"Error parsing {file_type} configuration file: {path_to_load}"
|
||||
self.logger.error(error_msg, exc_info=True)
|
||||
raise ConfigError(error_msg, config_path=path_to_load) from e
|
||||
except (IOError, OSError, PermissionError) as e:
|
||||
except PermissionError as e:
|
||||
if file_type == "secrets":
|
||||
# Match load_config()'s tolerance: a secrets file the web
|
||||
# process can't read (e.g. written 0640 by the root-run
|
||||
# display service before the group was fixed up) shouldn't
|
||||
# 500 the settings page — degrade to "no secrets" instead.
|
||||
self.logger.warning(f"Secrets file not readable ({path_to_load}): {e}. Returning empty secrets.")
|
||||
return {}
|
||||
error_msg = f"Error loading {file_type} configuration file {path_to_load}: {str(e)}"
|
||||
self.logger.error(error_msg, exc_info=True)
|
||||
raise ConfigError(error_msg, config_path=path_to_load) from e
|
||||
except (IOError, OSError) as e:
|
||||
error_msg = f"Error loading {file_type} configuration file {path_to_load}: {str(e)}"
|
||||
self.logger.error(error_msg, exc_info=True)
|
||||
raise ConfigError(error_msg, config_path=path_to_load) from e
|
||||
@@ -497,6 +562,7 @@ class ConfigManager:
|
||||
# Ensure final file has correct permissions
|
||||
try:
|
||||
ensure_file_permissions(path_obj, file_mode)
|
||||
ensure_shared_group_ownership(path_obj)
|
||||
except OSError as perm_error:
|
||||
# If we can't set permissions but file was written, log warning but don't fail
|
||||
self.logger.warning(
|
||||
|
||||
@@ -17,6 +17,7 @@ from enum import Enum
|
||||
|
||||
from src.exceptions import ConfigError
|
||||
from src.logging_config import get_logger
|
||||
from src.common.permission_utils import ensure_shared_group_ownership
|
||||
|
||||
|
||||
class SaveResultStatus(Enum):
|
||||
@@ -410,6 +411,13 @@ class AtomicConfigManager:
|
||||
# This is important because temp files may have different permissions
|
||||
# and we need root service to be able to read config.json
|
||||
os.chmod(destination, target_mode)
|
||||
|
||||
# Also fix group ownership when this save is running as root
|
||||
# (the display service): 0o640 alone only helps the non-root web
|
||||
# user read a root-written secrets file if its group already
|
||||
# matches the web user's group, which isn't guaranteed. See
|
||||
# permission_utils.ensure_shared_group_ownership for why.
|
||||
ensure_shared_group_ownership(destination)
|
||||
|
||||
except Exception as e:
|
||||
raise ConfigError(f"Error during atomic move: {e}") from e
|
||||
|
||||
@@ -23,6 +23,9 @@ Entry point: :func:`main` — instantiates :class:`DisplayController` and calls
|
||||
import time
|
||||
import os
|
||||
import json
|
||||
import threading
|
||||
import types
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List, Optional, Callable
|
||||
from datetime import datetime
|
||||
@@ -199,6 +202,10 @@ class DisplayController:
|
||||
self.wifi_status_file = WIFI_STATUS_FILE
|
||||
self.wifi_status_active = False
|
||||
self.wifi_status_expires_at: Optional[float] = None
|
||||
# _check_wifi_status_message throttle state (checked at frame rate,
|
||||
# stat'd at most once per second)
|
||||
self._wifi_status_check_ts = 0.0
|
||||
self._wifi_status_last_result: Optional[Dict[str, Any]] = None
|
||||
|
||||
# Plugin display() signature cache — must be initialised before the plugin
|
||||
# loading loop below so the .pop() invalidation at load time is always safe.
|
||||
@@ -374,6 +381,10 @@ class DisplayController:
|
||||
logger.debug("%d plugin(s) disabled in config", disabled_count)
|
||||
|
||||
logger.info("Plugin system initialized in %.3f seconds", time.time() - plugin_time)
|
||||
# Parallel loading appends modes in load-completion order, which
|
||||
# varies between restarts; apply the user's configured rotation
|
||||
# order (no-op when not configured).
|
||||
self._apply_plugin_rotation_order()
|
||||
logger.info("Total available modes: %d", len(self.available_modes))
|
||||
logger.info("Available modes: %s", self.available_modes)
|
||||
|
||||
@@ -502,7 +513,10 @@ class DisplayController:
|
||||
|
||||
# Run plugin updates inside the Vegas loop so the inter-iteration
|
||||
# gap is <1 ms (nothing left for _tick_plugin_updates() to do).
|
||||
self.vegas_coordinator.set_update_callback(self._tick_plugin_updates)
|
||||
# Use the Vegas-aware variant so plugins that got fresh data are
|
||||
# hot-swapped into the scroll promptly instead of waiting for the
|
||||
# next full cycle.
|
||||
self.vegas_coordinator.set_update_callback(self._tick_plugin_updates_for_vegas)
|
||||
|
||||
# Wire multi-display sync into Vegas render pipeline
|
||||
follower_pos = self.config.get("sync", {}).get("follower_position", "left")
|
||||
@@ -621,18 +635,28 @@ class DisplayController:
|
||||
|
||||
current_day = current_time.strftime('%A').lower() # e.g. 'monday'
|
||||
current_time_only = current_time.time()
|
||||
|
||||
|
||||
# Check if per-day schedule is configured
|
||||
days_config = schedule_config.get('days')
|
||||
|
||||
# Determine which schedule to use
|
||||
|
||||
# Determine which schedule to use. Respect an explicit 'mode' field
|
||||
# (like the dim schedule does) so a stray/legacy 'days' dict left over
|
||||
# from config migration or a prior per-day setup can't silently
|
||||
# override a user's Global schedule selection.
|
||||
mode = schedule_config.get('mode')
|
||||
mode_normalized = mode.replace('_', '-') if mode else None
|
||||
|
||||
use_per_day = False
|
||||
if days_config:
|
||||
# Check if days dict is not empty and contains current day
|
||||
if days_config and current_day in days_config:
|
||||
if mode_normalized == 'global':
|
||||
use_per_day = False
|
||||
elif mode_normalized == 'per-day':
|
||||
use_per_day = bool(days_config and current_day in days_config)
|
||||
elif days_config:
|
||||
# No explicit mode recorded (legacy config) - fall back to
|
||||
# inferring from presence of a 'days' dict for the current day.
|
||||
if current_day in days_config:
|
||||
use_per_day = True
|
||||
elif days_config:
|
||||
# Days dict exists but doesn't have current day - fall back to global
|
||||
else:
|
||||
logger.debug("Per-day schedule exists but %s not configured, using global schedule", current_day)
|
||||
|
||||
if use_per_day:
|
||||
@@ -828,6 +852,42 @@ class DisplayController:
|
||||
if hasattr(self.plugin_manager, 'health_tracker') and self.plugin_manager.health_tracker:
|
||||
self.plugin_manager.health_tracker.record_failure(plugin_id, exc)
|
||||
|
||||
def _tick_plugin_updates_for_vegas(self) -> None:
|
||||
"""Run scheduled plugin updates and tell Vegas mode which plugins
|
||||
actually got fresh data, so it can hot-swap them into the scroll
|
||||
without waiting for a full cycle to complete.
|
||||
|
||||
Used as the Vegas coordinator's update callback instead of the plain
|
||||
_tick_plugin_updates() so that a live score change is reflected in
|
||||
the ticker within a few seconds rather than at the next cycle
|
||||
boundary (which, depending on min/max_cycle_duration, can be
|
||||
minutes away). Restores wiring that PR #299 added and PR #330's
|
||||
sync-mode refactor inadvertently dropped: coordinator.mark_plugin_updated()
|
||||
has been unreachable dead code since.
|
||||
|
||||
Delegates the before/after plugin_last_update snapshot to
|
||||
PluginManager.run_scheduled_updates_with_changes() so the snapshot,
|
||||
update pass, and diff are lock-protected against this callback's own
|
||||
background update-tick thread racing the main render loop.
|
||||
"""
|
||||
if not self.plugin_manager or not hasattr(self.plugin_manager, "run_scheduled_updates_with_changes"):
|
||||
self._tick_plugin_updates()
|
||||
return
|
||||
|
||||
updated = self.plugin_manager.run_scheduled_updates_with_changes()
|
||||
|
||||
vc = getattr(self, "vegas_coordinator", None)
|
||||
if vc is None:
|
||||
return
|
||||
|
||||
if updated:
|
||||
logger.info("Vegas update tick: %d plugin(s) updated: %s", len(updated), updated)
|
||||
for plugin_id in updated:
|
||||
try:
|
||||
vc.mark_plugin_updated(plugin_id)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
logger.exception("Error marking plugin %s updated for Vegas", plugin_id)
|
||||
|
||||
def _tick_plugin_updates(self):
|
||||
"""Run scheduled plugin updates if the plugin manager supports them."""
|
||||
if not self.plugin_manager:
|
||||
@@ -839,6 +899,30 @@ class DisplayController:
|
||||
except Exception: # pylint: disable=broad-except
|
||||
logger.exception("Error running scheduled plugin updates")
|
||||
|
||||
@contextmanager
|
||||
def _display_lock_or_skip(self, plugin_id):
|
||||
"""Try-lock guard keeping a plugin's display() off its in-flight update().
|
||||
|
||||
Yields True when display may run (lock held, released on exit) or
|
||||
when no lock support exists (older plugin manager). Yields False when
|
||||
the plugin's update() is currently executing on the background
|
||||
worker — the caller should treat the frame as displayed (the panel
|
||||
holds the last pushed frame) rather than as a plugin failure, so a
|
||||
mid-update skip never advances the rotation.
|
||||
"""
|
||||
pm = self.plugin_manager
|
||||
if not pm or not hasattr(pm, 'get_plugin_lock') or not plugin_id:
|
||||
yield True
|
||||
return
|
||||
lock = pm.get_plugin_lock(plugin_id)
|
||||
if not lock.acquire(blocking=False):
|
||||
yield False
|
||||
return
|
||||
try:
|
||||
yield True
|
||||
finally:
|
||||
lock.release()
|
||||
|
||||
_FOLLOWER_SEND_INTERVAL = 1.0 / 90 # raw bytes are cheap; 90fps > follower render rate
|
||||
|
||||
def _follower_rebuild_scroll_image(self) -> None:
|
||||
@@ -1053,6 +1137,29 @@ class DisplayController:
|
||||
remaining = self.on_demand_expires_at - time.time()
|
||||
return max(0.0, remaining)
|
||||
|
||||
def _publish_current_mode_state(self) -> None:
|
||||
"""Publish the currently active display mode/plugin to cache for the web UI."""
|
||||
try:
|
||||
state = {
|
||||
'mode': self.current_display_mode,
|
||||
'plugin_id': self.mode_to_plugin_id.get(self.current_display_mode),
|
||||
'mode_index': self.current_mode_index,
|
||||
'total_modes': len(self.available_modes),
|
||||
'on_demand_active': self.on_demand_active,
|
||||
'is_display_active': self.is_display_active,
|
||||
'last_updated': time.time(),
|
||||
}
|
||||
self.cache_manager.set('display_current_state', state)
|
||||
self._last_published_mode = self.current_display_mode
|
||||
except (OSError, RuntimeError, ValueError, TypeError) as err:
|
||||
logger.error("Failed to publish current display state: %s", err, exc_info=True)
|
||||
|
||||
def _publish_current_mode_state_if_changed(self) -> None:
|
||||
"""Publish current mode state only when it actually changed, to avoid
|
||||
writing to the shared cache on every render tick."""
|
||||
if self.current_display_mode != getattr(self, '_last_published_mode', None):
|
||||
self._publish_current_mode_state()
|
||||
|
||||
def _publish_on_demand_state(self) -> None:
|
||||
"""Publish current on-demand state to cache for external consumers."""
|
||||
try:
|
||||
@@ -1572,6 +1679,7 @@ class DisplayController:
|
||||
logger.info("Starting display with cached data (fast startup mode)")
|
||||
self.current_display_mode = self.available_modes[self.current_mode_index] if self.available_modes else 'none'
|
||||
logger.info(f"Initial mode set to: {self.current_display_mode} (index: {self.current_mode_index}, total modes: {len(self.available_modes)})")
|
||||
self._publish_current_mode_state()
|
||||
|
||||
while True:
|
||||
# Apply plugin enable/disable edits saved via the web UI. The
|
||||
@@ -1632,10 +1740,12 @@ class DisplayController:
|
||||
logger.debug(f"Error clearing display when inactive: {e}")
|
||||
|
||||
logger.info(f"Display not active (is_display_active={self.is_display_active}), sleeping...")
|
||||
self._publish_current_mode_state()
|
||||
self._sleep_with_plugin_updates(60)
|
||||
continue
|
||||
|
||||
logger.info(f"Display active, processing mode: {self.current_display_mode}")
|
||||
self._publish_current_mode_state_if_changed()
|
||||
logger.debug("Display active, processing mode: %s", self.current_display_mode)
|
||||
|
||||
# Plugins update on their own schedules - no forced sync updates needed
|
||||
# Each plugin has its own update_interval and background services
|
||||
@@ -1803,7 +1913,7 @@ class DisplayController:
|
||||
if self.plugin_manager and hasattr(self.plugin_manager, 'health_tracker') and self.plugin_manager.health_tracker:
|
||||
should_skip = self.plugin_manager.health_tracker.should_skip_plugin(plugin_id)
|
||||
if should_skip:
|
||||
logger.info(f"Skipping plugin {plugin_id} due to circuit breaker (mode: {active_mode})")
|
||||
logger.info("Skipping plugin %s due to circuit breaker (mode: %s)", plugin_id, active_mode)
|
||||
display_result = False
|
||||
# Skip to next mode - let existing logic handle it
|
||||
manager_to_display = None
|
||||
@@ -1826,6 +1936,7 @@ class DisplayController:
|
||||
plugin_id = getattr(manager_to_display, 'plugin_id', active_mode)
|
||||
try:
|
||||
logger.debug(f"Calling display() for {active_mode} with force_clear={self.force_change}")
|
||||
can_display = False
|
||||
if hasattr(manager_to_display, 'display'):
|
||||
# Opt #1: look up (or compute once) whether display() accepts display_mode
|
||||
_cache_key = plugin_id
|
||||
@@ -1836,14 +1947,64 @@ class DisplayController:
|
||||
)
|
||||
_accepts_display_mode = self._plugin_accepts_display_mode[_cache_key]
|
||||
|
||||
# Use PluginExecutor for safe execution with timeout
|
||||
if self.plugin_manager and hasattr(self.plugin_manager, 'plugin_executor'):
|
||||
result = self.plugin_manager.plugin_executor.execute_display(
|
||||
manager_to_display,
|
||||
plugin_id,
|
||||
force_clear=self.force_change,
|
||||
display_mode=active_mode if _accepts_display_mode else None
|
||||
)
|
||||
pm = self.plugin_manager
|
||||
display_lock = None
|
||||
can_display = True
|
||||
if pm and hasattr(pm, 'get_plugin_lock'):
|
||||
display_lock = pm.get_plugin_lock(plugin_id)
|
||||
can_display = display_lock.acquire(blocking=False)
|
||||
|
||||
if not can_display:
|
||||
# update() in flight on the worker — hold
|
||||
# the last frame; not a plugin failure
|
||||
result = True
|
||||
elif pm and hasattr(pm, 'plugin_executor'):
|
||||
# PluginExecutor's own thread.join(timeout) can
|
||||
# return before the real display() call
|
||||
# finishes (a lingering daemon thread keeps
|
||||
# running it) -- so the lock is released from
|
||||
# inside the wrapped call itself, whichever
|
||||
# thread actually finishes it, rather than
|
||||
# here when this dispatch merely returns.
|
||||
release_guard = threading.Lock()
|
||||
released = {'done': False}
|
||||
|
||||
def _release_display_lock():
|
||||
with release_guard:
|
||||
if released['done']:
|
||||
return
|
||||
released['done'] = True
|
||||
if display_lock is not None:
|
||||
display_lock.release()
|
||||
|
||||
if _accepts_display_mode:
|
||||
def _display_target(display_mode=None, force_clear=False):
|
||||
try:
|
||||
return manager_to_display.display(
|
||||
display_mode=display_mode, force_clear=force_clear)
|
||||
finally:
|
||||
_release_display_lock()
|
||||
else:
|
||||
def _display_target(force_clear=False):
|
||||
try:
|
||||
return manager_to_display.display(force_clear=force_clear)
|
||||
finally:
|
||||
_release_display_lock()
|
||||
|
||||
try:
|
||||
result = self.plugin_manager.plugin_executor.execute_display(
|
||||
types.SimpleNamespace(display=_display_target),
|
||||
plugin_id,
|
||||
force_clear=self.force_change,
|
||||
display_mode=active_mode if _accepts_display_mode else None
|
||||
)
|
||||
except Exception: # pragma: no cover - defensive;
|
||||
# execute_display catches everything
|
||||
# internally, but guarantee the lock is
|
||||
# never leaked if something unexpected
|
||||
# slips through.
|
||||
_release_display_lock()
|
||||
raise
|
||||
# execute_display returns bool, convert to expected format
|
||||
if result:
|
||||
result = True # Success
|
||||
@@ -1851,23 +2012,31 @@ class DisplayController:
|
||||
result = False # Failed
|
||||
else:
|
||||
# Fallback to direct call if executor not available
|
||||
if _accepts_display_mode:
|
||||
result = manager_to_display.display(display_mode=active_mode, force_clear=self.force_change)
|
||||
else:
|
||||
result = manager_to_display.display(force_clear=self.force_change)
|
||||
try:
|
||||
if _accepts_display_mode:
|
||||
result = manager_to_display.display(display_mode=active_mode, force_clear=self.force_change)
|
||||
else:
|
||||
result = manager_to_display.display(force_clear=self.force_change)
|
||||
finally:
|
||||
if display_lock is not None:
|
||||
display_lock.release()
|
||||
|
||||
logger.debug(f"display() returned: {result} (type: {type(result)})")
|
||||
# Check if display() returned a boolean (new behavior)
|
||||
if isinstance(result, bool):
|
||||
display_result = result
|
||||
if not display_result:
|
||||
logger.info(f"Plugin {plugin_id} display() returned False for mode {active_mode}")
|
||||
logger.info("Plugin %s display() returned False for mode %s", plugin_id, active_mode)
|
||||
|
||||
# Record success if display completed without exception
|
||||
if self.plugin_manager and hasattr(self.plugin_manager, 'health_tracker') and self.plugin_manager.health_tracker:
|
||||
self.plugin_manager.health_tracker.record_success(plugin_id)
|
||||
|
||||
self.force_change = False
|
||||
# Record success only when display() actually ran this
|
||||
# frame -- a skipped frame (lock busy) held the last
|
||||
# frame, not a real success, and must not clear
|
||||
# force_change or the pending mode-switch clear will
|
||||
# be lost when display() finally does run.
|
||||
if can_display:
|
||||
if self.plugin_manager and hasattr(self.plugin_manager, 'health_tracker') and self.plugin_manager.health_tracker:
|
||||
self.plugin_manager.health_tracker.record_success(plugin_id)
|
||||
self.force_change = False
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
logger.exception("Error displaying %s", self.current_display_mode)
|
||||
# Record failure
|
||||
@@ -2072,10 +2241,23 @@ class DisplayController:
|
||||
|
||||
# For plugins, call display multiple times to allow game rotation
|
||||
if manager_to_display and hasattr(manager_to_display, 'display'):
|
||||
# Check if plugin needs high FPS (like stock ticker)
|
||||
# Always enable high-FPS for static-image plugin (for GIF animation support)
|
||||
# High-FPS decision, in precedence order:
|
||||
# 1. A plugin that declares needs_high_fps knows best
|
||||
# (e.g. static-image sets it False for still PNGs,
|
||||
# True for animated GIFs).
|
||||
# 2. Back-compat: older static-image versions without
|
||||
# the attribute keep the historical forced high-FPS
|
||||
# (GIF support).
|
||||
# 3. Otherwise scrolling plugins get high FPS.
|
||||
plugin_id = getattr(manager_to_display, 'plugin_id', None)
|
||||
if plugin_id == 'static-image':
|
||||
declared = getattr(manager_to_display, 'needs_high_fps', None)
|
||||
if declared is not None:
|
||||
needs_high_fps = bool(declared)
|
||||
logger.debug(
|
||||
"[DisplayController] FPS check for %s (plugin=%s) - "
|
||||
"plugin declares needs_high_fps=%s",
|
||||
active_mode, plugin_id, needs_high_fps)
|
||||
elif plugin_id == 'static-image':
|
||||
needs_high_fps = True
|
||||
logger.debug("FPS check - static-image plugin: forcing high-FPS mode for GIF support")
|
||||
else:
|
||||
@@ -2139,11 +2321,16 @@ class DisplayController:
|
||||
|
||||
while True:
|
||||
try:
|
||||
# Pass display_mode to maintain sticky manager state
|
||||
if _accepts_display_mode:
|
||||
result = manager_to_display.display(display_mode=active_mode, force_clear=False)
|
||||
else:
|
||||
result = manager_to_display.display(force_clear=False)
|
||||
with self._display_lock_or_skip(plugin_id) as can_display:
|
||||
if can_display:
|
||||
# Pass display_mode to maintain sticky manager state
|
||||
if _accepts_display_mode:
|
||||
result = manager_to_display.display(display_mode=active_mode, force_clear=False)
|
||||
else:
|
||||
result = manager_to_display.display(force_clear=False)
|
||||
else:
|
||||
# update() in flight — hold the last frame
|
||||
result = True
|
||||
if isinstance(result, bool) and not result:
|
||||
logger.debug("Display returned False, breaking early")
|
||||
break
|
||||
@@ -2203,11 +2390,16 @@ class DisplayController:
|
||||
break
|
||||
|
||||
try:
|
||||
# Pass display_mode to maintain sticky manager state
|
||||
if _accepts_display_mode:
|
||||
result = manager_to_display.display(display_mode=active_mode, force_clear=False)
|
||||
else:
|
||||
result = manager_to_display.display(force_clear=False)
|
||||
with self._display_lock_or_skip(plugin_id) as can_display:
|
||||
if can_display:
|
||||
# Pass display_mode to maintain sticky manager state
|
||||
if _accepts_display_mode:
|
||||
result = manager_to_display.display(display_mode=active_mode, force_clear=False)
|
||||
else:
|
||||
result = manager_to_display.display(force_clear=False)
|
||||
else:
|
||||
# update() in flight — hold the last frame
|
||||
result = True
|
||||
if isinstance(result, bool) and not result:
|
||||
# For dynamic duration plugins, don't exit on False - keep looping
|
||||
# until cycle is complete or max duration is reached
|
||||
@@ -2354,6 +2546,16 @@ class DisplayController:
|
||||
Returns None on any error or if message is expired/invalid.
|
||||
"""
|
||||
try:
|
||||
# Throttle the existence stat to ~1 Hz: this runs on every render
|
||||
# iteration (60+ fps), and the file usually doesn't exist — the
|
||||
# status message's lifetime is measured in seconds anyway.
|
||||
# Both attributes are initialised in __init__.
|
||||
now = time.time()
|
||||
if (now - self._wifi_status_check_ts) < 1.0:
|
||||
return self._wifi_status_last_result
|
||||
self._wifi_status_check_ts = now
|
||||
self._wifi_status_last_result = None
|
||||
|
||||
# Check if file exists
|
||||
if not self.wifi_status_file or not self.wifi_status_file.exists():
|
||||
return None
|
||||
@@ -2404,13 +2606,14 @@ class DisplayController:
|
||||
pass
|
||||
return None
|
||||
|
||||
# Message is valid and not expired
|
||||
return {
|
||||
# Message is valid and not expired — cache for the throttle window
|
||||
self._wifi_status_last_result = {
|
||||
'message': message,
|
||||
'timestamp': timestamp,
|
||||
'duration': duration,
|
||||
'expires_at': expires_at
|
||||
}
|
||||
return self._wifi_status_last_result
|
||||
|
||||
except Exception as e:
|
||||
# Catch-all for any unexpected errors - log but don't break the display
|
||||
@@ -2670,11 +2873,52 @@ class DisplayController:
|
||||
except Exception as e:
|
||||
logger.error("Plugin reconcile: error enabling %s: %s", plugin_id, e, exc_info=True)
|
||||
|
||||
# Newly enabled plugins were appended at the end; put them in the
|
||||
# configured rotation slot before resyncing the index.
|
||||
self._apply_plugin_rotation_order()
|
||||
self._resync_mode_index_after_change(previous_mode)
|
||||
logger.info("Plugin reconcile complete: +%s -%s (%d modes)",
|
||||
logger.info("[DisplayController] Plugin reconcile complete: +%s -%s (%d modes)",
|
||||
sorted(to_add), sorted(to_remove), len(self.available_modes))
|
||||
return True
|
||||
|
||||
def _apply_plugin_rotation_order(self) -> None:
|
||||
"""Reorder available_modes to follow display.plugin_rotation_order.
|
||||
|
||||
The configured value is a list of plugin ids; their modes rotate in
|
||||
that order (each plugin's own modes keep their declared order), with
|
||||
any enabled-but-unlisted plugins appended afterwards in their current
|
||||
relative order. An empty/missing list leaves available_modes exactly
|
||||
as built (today's behavior). Mirrors vegas_mode/config.py's
|
||||
get_ordered_plugins() semantics for the primary rotation.
|
||||
"""
|
||||
configured = (self.config.get("display", {}) or {}).get("plugin_rotation_order", []) or []
|
||||
# Defensive: hand-edited or migrated configs may hold a non-list or
|
||||
# non-string entries; keep the existing rotation rather than applying
|
||||
# a garbage order.
|
||||
if not isinstance(configured, list):
|
||||
logger.warning("[DisplayController] Ignoring invalid plugin_rotation_order (not a list): %r",
|
||||
type(configured).__name__)
|
||||
return
|
||||
configured = [p for p in configured if isinstance(p, str)]
|
||||
if not configured or not self.available_modes:
|
||||
return
|
||||
|
||||
ordered_ids = [p for p in configured if p in self.plugin_display_modes]
|
||||
new_modes: List[str] = []
|
||||
for plugin_id in ordered_ids:
|
||||
for mode in self.plugin_display_modes[plugin_id]:
|
||||
if mode in self.available_modes and mode not in new_modes:
|
||||
new_modes.append(mode)
|
||||
# Unlisted plugins' modes (and any mode not attributable to a plugin)
|
||||
# follow in their existing relative order.
|
||||
for mode in self.available_modes:
|
||||
if mode not in new_modes:
|
||||
new_modes.append(mode)
|
||||
if new_modes != self.available_modes:
|
||||
self.available_modes = new_modes
|
||||
logger.info("[DisplayController] Applied plugin rotation order %s -> modes: %s",
|
||||
configured, self.available_modes)
|
||||
|
||||
def _resync_mode_index_after_change(self, previous_mode: Optional[str]) -> None:
|
||||
"""Clamp rotation state after available_modes changed. Stays on the
|
||||
previous mode if it survived, otherwise restarts cleanly within range."""
|
||||
@@ -2714,6 +2958,14 @@ class DisplayController:
|
||||
|
||||
def cleanup(self):
|
||||
"""Clean up resources."""
|
||||
# Stop the async update worker first so no in-flight update() call
|
||||
# is still touching display/cache-backed resources while they're
|
||||
# torn down below.
|
||||
if self.plugin_manager and hasattr(self.plugin_manager, 'stop_update_worker'):
|
||||
try:
|
||||
self.plugin_manager.stop_update_worker()
|
||||
except Exception as e:
|
||||
logger.warning("Error stopping plugin update worker: %s", e)
|
||||
# Shutdown config service if it exists
|
||||
if hasattr(self, 'config_service'):
|
||||
try:
|
||||
|
||||
@@ -31,13 +31,25 @@ if os.getenv("EMULATOR", "false") == "true":
|
||||
else:
|
||||
from rgbmatrix import RGBMatrix, RGBMatrixOptions
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import threading
|
||||
import time
|
||||
from typing import Dict, Any, List, Optional
|
||||
from collections import OrderedDict
|
||||
from typing import Dict, Any, List, Optional, Tuple
|
||||
import logging
|
||||
import math
|
||||
import zlib
|
||||
import freetype
|
||||
|
||||
from src.common import snapshot_policy
|
||||
from src.common.permission_utils import (
|
||||
ensure_directory_permissions,
|
||||
ensure_file_permissions,
|
||||
get_assets_dir_mode,
|
||||
get_assets_file_mode,
|
||||
)
|
||||
|
||||
# Get logger without configuring
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(logging.INFO) # Set to INFO level
|
||||
@@ -180,14 +192,45 @@ class DisplayManager:
|
||||
# the logical image is blitted to the matrix unchanged.
|
||||
self._double_sided = None # dict {copies, axis, logical_width, logical_height} or None
|
||||
self._physical_image = None # full-chain buffer reused each frame when tiling
|
||||
# Text-width measurement cache: (text, id(font)) -> pixel_width
|
||||
# Text-width measurement cache: (text, id(font)) -> (width, font_ref)
|
||||
# Avoids re-measuring the same string+font on every display() call.
|
||||
# LRU-bounded: keys embed the TEXT, so changing strings (a clock, a
|
||||
# live score) would otherwise grow it forever on a 24/7 service.
|
||||
# Entries hold a strong reference to the font so its id() can't be
|
||||
# recycled by a different font object — an id-keyed cache without
|
||||
# the reference can return the WRONG width after garbage collection.
|
||||
# Cleared on _load_fonts() so stale entries don't survive a font reload.
|
||||
self._text_width_cache: Dict[tuple, int] = {}
|
||||
# Snapshot settings for web preview integration (service writes, web reads)
|
||||
self._text_width_cache: "OrderedDict[tuple, Tuple[int, Any]]" = OrderedDict()
|
||||
self._TEXT_WIDTH_CACHE_MAX = 1024
|
||||
# Snapshot mirror for web preview + health check (service writes, web
|
||||
# reads). Cadence/skip decisions live in src/common/snapshot_policy.py:
|
||||
# full rate only while the web SSE broadcaster keeps the viewer marker
|
||||
# fresh; unchanged frames are never re-encoded, only mtime-touched.
|
||||
self._snapshot_path = "/tmp/led_matrix_preview.png" # nosec B108 - fixed path intentional; web UI reads same path
|
||||
self._snapshot_min_interval_sec = 0.2 # max ~5 fps
|
||||
self._viewer_marker_path = "/tmp/led_matrix_preview_viewer" # nosec B108 - touched by web SSE broadcaster
|
||||
self._last_snapshot_ts = 0.0
|
||||
self._last_snapshot_touch_ts = 0.0
|
||||
self._last_snapshot_digest: Optional[int] = None
|
||||
self._snapshot_dir_prepared = False
|
||||
self._viewer_check_ts = 0.0
|
||||
self._viewer_fresh = False
|
||||
self._viewer_was_fresh = False
|
||||
# Snapshot failures are logged as warnings, rate-limited so a
|
||||
# persistent failure (e.g. an unwritable file) can't spam the log —
|
||||
# but is never silent: the snapshot's mtime doubles as the web UI's
|
||||
# hardware-liveness signal, so a quiet failure makes health checks lie.
|
||||
self._snapshot_fail_log_ts = 0.0
|
||||
# Dirty tracking: (image digest, brightness) of the last frame pushed
|
||||
# to the panel; update_display() skips identical pushes. Kill switch:
|
||||
# display.dirty_tracking: false.
|
||||
self._dirty_tracking_enabled = bool(
|
||||
self.config.get('display', {}).get('dirty_tracking', True))
|
||||
self._last_pushed_digest = None
|
||||
# Serializes update_display(): plugins can call it directly from
|
||||
# background threads (see docstring on update_display), not just the
|
||||
# render loop. RLock in case a caller within the critical section
|
||||
# ever re-enters (e.g. via a nested draw callback).
|
||||
self._update_lock = threading.RLock()
|
||||
|
||||
# Scrolling state tracking for graceful updates
|
||||
self._scrolling_state = {
|
||||
@@ -418,6 +461,10 @@ class DisplayManager:
|
||||
try:
|
||||
# RGBMatrix accepts brightness as a property
|
||||
self.matrix.brightness = brightness
|
||||
# Brightness applies on the next swap — force a re-push even if
|
||||
# the image itself is unchanged (belt-and-braces: brightness is
|
||||
# also part of the dirty-tracking digest when readable).
|
||||
self._last_pushed_digest = None
|
||||
logger.info(f"[BRIGHTNESS] Display brightness set to {brightness}%")
|
||||
return True
|
||||
except AttributeError as e:
|
||||
@@ -509,33 +556,70 @@ class DisplayManager:
|
||||
return phys
|
||||
|
||||
def update_display(self):
|
||||
"""Update the display using double buffering with proper sync."""
|
||||
"""Update the display using double buffering with proper sync.
|
||||
|
||||
Skips the panel push entirely when the frame is byte-identical to
|
||||
the last pushed one (same image digest AND same brightness) — static
|
||||
content re-rendered every second, and 125 fps loops between actual
|
||||
scroll steps, otherwise re-walk the full framebuffer for nothing.
|
||||
The panel keeps refreshing the current frame from its own thread,
|
||||
so skipping a swap never blanks or freezes the hardware.
|
||||
|
||||
Correctness hinges on invalidation: clear() resets the digest (it
|
||||
writes to the matrix directly), and brightness is PART of the digest
|
||||
so a dim-schedule change is never skipped. Disable via config
|
||||
``display.dirty_tracking: false`` if a redraw issue is ever suspected.
|
||||
|
||||
Serialized via ``_update_lock``: plugins can call this directly from
|
||||
background threads (e.g. sports base classes push an immediate
|
||||
"live" refresh from inside update()), so without a lock two callers
|
||||
could both pass the digest check before either writes it back,
|
||||
double-pushing a frame, or interleave the offscreen/current canvas
|
||||
swap below. The lock is scoped to this method, so callers never
|
||||
need to know about it.
|
||||
"""
|
||||
try:
|
||||
if self.matrix is None:
|
||||
# Fallback mode - no actual hardware to update
|
||||
logger.debug("Update display called in fallback mode (no hardware)")
|
||||
# Still write a snapshot so the web UI can preview
|
||||
with self._update_lock:
|
||||
if self.matrix is None:
|
||||
# Fallback mode - no actual hardware to update
|
||||
logger.debug("Update display called in fallback mode (no hardware)")
|
||||
# Still write a snapshot so the web UI can preview
|
||||
self._write_snapshot_if_due()
|
||||
return
|
||||
|
||||
if self._capture_mode_active:
|
||||
return # Skip hardware write — content is being captured off-screen
|
||||
|
||||
digest = None
|
||||
if self._dirty_tracking_enabled:
|
||||
try:
|
||||
brightness = getattr(self.matrix, 'brightness', None)
|
||||
except AttributeError:
|
||||
brightness = None
|
||||
digest = (zlib.adler32(self.image.tobytes()), brightness)
|
||||
if digest == self._last_pushed_digest:
|
||||
# Nothing changed since the last push — the panel is
|
||||
# already showing exactly this frame.
|
||||
self._write_snapshot_if_due()
|
||||
return
|
||||
|
||||
# Copy the current image to the offscreen canvas. In double-sided
|
||||
# mode the logical screen is first tiled across the full chain.
|
||||
if self._double_sided is not None:
|
||||
self.offscreen_canvas.SetImage(self._composite_double_sided())
|
||||
else:
|
||||
self.offscreen_canvas.SetImage(self.image)
|
||||
|
||||
# Swap buffers immediately
|
||||
self.matrix.SwapOnVSync(self.offscreen_canvas)
|
||||
|
||||
# Swap our canvas references
|
||||
self.offscreen_canvas, self.current_canvas = self.current_canvas, self.offscreen_canvas
|
||||
|
||||
self._last_pushed_digest = digest
|
||||
|
||||
# Write a snapshot for the web preview (throttled)
|
||||
self._write_snapshot_if_due()
|
||||
return
|
||||
|
||||
if self._capture_mode_active:
|
||||
return # Skip hardware write — content is being captured off-screen
|
||||
|
||||
# Copy the current image to the offscreen canvas. In double-sided
|
||||
# mode the logical screen is first tiled across the full chain.
|
||||
if self._double_sided is not None:
|
||||
self.offscreen_canvas.SetImage(self._composite_double_sided())
|
||||
else:
|
||||
self.offscreen_canvas.SetImage(self.image)
|
||||
|
||||
# Swap buffers immediately
|
||||
self.matrix.SwapOnVSync(self.offscreen_canvas)
|
||||
|
||||
# Swap our canvas references
|
||||
self.offscreen_canvas, self.current_canvas = self.current_canvas, self.offscreen_canvas
|
||||
|
||||
# Write a snapshot for the web preview (throttled)
|
||||
self._write_snapshot_if_due()
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating display: {e}")
|
||||
|
||||
@@ -569,6 +653,9 @@ class DisplayManager:
|
||||
# Clear both canvases and the underlying matrix to ensure no artifacts.
|
||||
# Failures are non-fatal — the image buffer is already black above, so
|
||||
# the next update_display() call will push clean content regardless.
|
||||
# The matrix content no longer matches the last pushed digest,
|
||||
# so dirty tracking must not skip the next push.
|
||||
self._last_pushed_digest = None
|
||||
try:
|
||||
self.offscreen_canvas.Clear()
|
||||
except (RuntimeError, OSError) as e:
|
||||
@@ -699,12 +786,15 @@ class DisplayManager:
|
||||
|
||||
Results are cached by (text, font identity) so plugins that measure
|
||||
the same string every frame (e.g. to centre a score) pay only one
|
||||
measurement per unique (text, font) pair.
|
||||
measurement per unique (text, font) pair. The entry keeps the font
|
||||
alive so its id() can't be recycled, and the cache is LRU-bounded so
|
||||
ever-changing text (clocks, tickers) can't grow it without limit.
|
||||
"""
|
||||
cache_key = (text, id(font))
|
||||
cached = self._text_width_cache.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
self._text_width_cache.move_to_end(cache_key)
|
||||
return cached[0]
|
||||
|
||||
try:
|
||||
if isinstance(font, freetype.Face):
|
||||
@@ -719,7 +809,9 @@ class DisplayManager:
|
||||
logger.error("Error getting text width: %s", e)
|
||||
return 0
|
||||
|
||||
self._text_width_cache[cache_key] = width
|
||||
self._text_width_cache[cache_key] = (width, font)
|
||||
while len(self._text_width_cache) > self._TEXT_WIDTH_CACHE_MAX:
|
||||
self._text_width_cache.popitem(last=False)
|
||||
return width
|
||||
|
||||
def get_font_height(self, font):
|
||||
@@ -1128,27 +1220,56 @@ class DisplayManager:
|
||||
'deferred_update_ttl': self._scrolling_state['deferred_update_ttl']
|
||||
}
|
||||
|
||||
def _viewer_is_fresh(self, now: float) -> bool:
|
||||
"""True when a browser preview is watching (marker file touched by
|
||||
the web SSE broadcaster). The marker is stat'd at most once per
|
||||
second — at 125 fps loops a per-call stat would be pure overhead."""
|
||||
if (now - self._viewer_check_ts) >= 1.0:
|
||||
self._viewer_check_ts = now
|
||||
try:
|
||||
marker_age = now - os.stat(self._viewer_marker_path).st_mtime
|
||||
self._viewer_fresh = marker_age < snapshot_policy.VIEWER_MARKER_FRESH_SEC
|
||||
except OSError:
|
||||
self._viewer_fresh = False
|
||||
return self._viewer_fresh
|
||||
|
||||
def _write_snapshot_if_due(self) -> None:
|
||||
"""Write the current image to a PNG snapshot file at a limited frequency."""
|
||||
"""Mirror the current frame to the preview snapshot when the policy
|
||||
says it's worth it — see src/common/snapshot_policy.py. Unchanged
|
||||
frames are never re-encoded; without viewers the cadence drops to
|
||||
the idle keepalive."""
|
||||
try:
|
||||
now = time.time()
|
||||
if (now - self._last_snapshot_ts) < self._snapshot_min_interval_sec:
|
||||
viewer_fresh = self._viewer_is_fresh(now)
|
||||
if viewer_fresh and not self._viewer_was_fresh:
|
||||
# A preview just opened: let the next changed frame through
|
||||
# immediately instead of waiting out the idle interval.
|
||||
self._last_snapshot_ts = 0.0
|
||||
self._viewer_was_fresh = viewer_fresh
|
||||
|
||||
digest = zlib.adler32(self.image.tobytes())
|
||||
action = snapshot_policy.decide(
|
||||
now, self._last_snapshot_ts, self._last_snapshot_touch_ts,
|
||||
viewer_fresh, digest != self._last_snapshot_digest)
|
||||
if action is snapshot_policy.SnapshotAction.SKIP:
|
||||
return
|
||||
# Ensure directory exists with proper permissions
|
||||
from pathlib import Path
|
||||
from src.common.permission_utils import (
|
||||
ensure_directory_permissions,
|
||||
ensure_file_permissions,
|
||||
get_assets_dir_mode,
|
||||
get_assets_file_mode
|
||||
)
|
||||
if action is snapshot_policy.SnapshotAction.TOUCH:
|
||||
# mtime bump only: keeps the health check (snapshot age)
|
||||
# green without paying for a PNG encode of an unchanged frame
|
||||
os.utime(self._snapshot_path, None)
|
||||
self._last_snapshot_touch_ts = now
|
||||
return
|
||||
|
||||
# WRITE: ensure directory permissions once, not per frame
|
||||
snapshot_path_obj = Path(self._snapshot_path)
|
||||
# Only ensure permissions on non-system directories
|
||||
# Never modify /tmp permissions - it has special system permissions (1777)
|
||||
# that must not be changed or it breaks apt and other system tools
|
||||
parent_dir = snapshot_path_obj.parent
|
||||
if parent_dir and str(parent_dir) != '/tmp': # nosec B108 - guard to skip /tmp for permission ops
|
||||
ensure_directory_permissions(parent_dir, get_assets_dir_mode())
|
||||
if not self._snapshot_dir_prepared:
|
||||
# Never modify /tmp permissions - it has special system
|
||||
# permissions (1777) that must not be changed or it breaks
|
||||
# apt and other system tools
|
||||
parent_dir = snapshot_path_obj.parent
|
||||
if parent_dir and str(parent_dir) != '/tmp': # nosec B108 - guard to skip /tmp for permission ops
|
||||
ensure_directory_permissions(parent_dir, get_assets_dir_mode())
|
||||
self._snapshot_dir_prepared = True
|
||||
# Write atomically: temp then replace
|
||||
tmp_path = f"{self._snapshot_path}.tmp"
|
||||
self.image.save(tmp_path, format='PNG')
|
||||
@@ -1163,6 +1284,18 @@ class DisplayManager:
|
||||
except Exception:
|
||||
pass
|
||||
self._last_snapshot_ts = now
|
||||
self._last_snapshot_touch_ts = now
|
||||
self._last_snapshot_digest = digest
|
||||
except Exception as e:
|
||||
# Snapshot failures should never break display; log at debug to avoid noise
|
||||
logger.debug(f"Snapshot write skipped: {e}")
|
||||
# Snapshot failures must never break display — but they must not
|
||||
# be silent either: the snapshot's mtime is the web UI's display
|
||||
# mirror AND its hardware-liveness proxy, so a quietly failing
|
||||
# write freezes the mirror and makes health checks lie (seen in
|
||||
# the field: a stale root-owned /tmp file froze it for a day).
|
||||
# Warn at most once per 5 minutes to avoid log spam.
|
||||
if (now - self._snapshot_fail_log_ts) > 300:
|
||||
self._snapshot_fail_log_ts = now
|
||||
logger.warning("Snapshot write failing (web preview/health "
|
||||
"mirror is stale): %s", e)
|
||||
else:
|
||||
logger.debug(f"Snapshot write skipped: {e}")
|
||||
@@ -35,6 +35,7 @@ import urllib.request
|
||||
import zipfile
|
||||
import tempfile
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from pathlib import Path
|
||||
from PIL import ImageFont
|
||||
from typing import Dict, Tuple, Optional, Union, Any, List
|
||||
@@ -58,7 +59,13 @@ class FontManager:
|
||||
# Font discovery and catalog
|
||||
self.font_catalog: Dict[str, str] = {} # family_name -> file_path
|
||||
self.font_cache: Dict[str, Union[ImageFont.FreeTypeFont, freetype.Face]] = {} # (family, size) -> font
|
||||
self.metrics_cache: Dict[str, Tuple[int, int, int]] = {} # (text, font_id) -> (width, height, baseline)
|
||||
# (text, id(font)) -> ((width, height, baseline), font_ref).
|
||||
# LRU-bounded — keys embed the measured TEXT, so changing strings
|
||||
# (clocks, live scores) would otherwise grow it forever. Entries
|
||||
# keep the font alive so its id() can't be recycled by a different
|
||||
# font object (which would silently return wrong metrics).
|
||||
self.metrics_cache: "OrderedDict[Any, Tuple[Tuple[int, int, int], Any]]" = OrderedDict()
|
||||
self._METRICS_CACHE_MAX = 1024
|
||||
|
||||
# Plugin font management
|
||||
self.plugin_fonts: Dict[str, Dict[str, Any]] = {} # plugin_id -> font_manifest
|
||||
@@ -103,6 +110,10 @@ class FontManager:
|
||||
# Font overrides storage (for manual overrides)
|
||||
self.font_overrides_file = "config/font_overrides.json"
|
||||
self.font_overrides: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
# Bumped whenever cached font objects are invalidated, so holders of
|
||||
# derived caches (e.g. adaptive-layout fit results) know to rebuild.
|
||||
self.cache_generation = 0
|
||||
|
||||
self._initialize_fonts()
|
||||
|
||||
@@ -112,6 +123,7 @@ class FontManager:
|
||||
self.fonts_config = new_config.get("fonts", {})
|
||||
self.font_cache.clear() # Clear cache to force reload
|
||||
self.metrics_cache.clear() # Clear metrics cache
|
||||
self.cache_generation += 1
|
||||
self._initialize_fonts()
|
||||
logger.info("FontManager configuration reloaded successfully")
|
||||
|
||||
@@ -482,6 +494,14 @@ class FontManager:
|
||||
def _load_bdf_font(self, font_path: str, size_px: int) -> freetype.Face:
|
||||
"""Load a BDF font using FreeType."""
|
||||
try:
|
||||
native_size = self._read_bdf_native_size(font_path)
|
||||
if native_size is not None and native_size != size_px:
|
||||
# BDF is a fixed-strike bitmap format: FreeType renders the
|
||||
# native size no matter what set_char_size asks for.
|
||||
logger.debug(
|
||||
"BDF font %s requested at %spx but renders at its native "
|
||||
"%spx", font_path, size_px, native_size
|
||||
)
|
||||
face = freetype.Face(font_path)
|
||||
# Set character size (width, height) in 1/64th of points
|
||||
face.set_char_size(size_px * 64, size_px * 64, 72, 72)
|
||||
@@ -490,6 +510,41 @@ class FontManager:
|
||||
logger.error(f"Error loading BDF font {font_path}: {e}")
|
||||
raise
|
||||
|
||||
def get_native_bdf_size(self, family: str) -> Optional[int]:
|
||||
"""The one true pixel size of a BDF family in the catalog, or None
|
||||
for scalable (TTF) families / unknown families."""
|
||||
font_path = self.font_catalog.get(family)
|
||||
if not font_path or not font_path.endswith('.bdf'):
|
||||
return None
|
||||
return self._read_bdf_native_size(font_path)
|
||||
|
||||
@staticmethod
|
||||
def _read_bdf_native_size(bdf_path: str) -> Optional[int]:
|
||||
"""Read a BDF file's own header to find its one true pixel size.
|
||||
Prefers the PIXEL_SIZE property, which states the real pixel height
|
||||
directly; falls back to the SIZE line's point-size only if PIXEL_SIZE
|
||||
is absent, since point-size only equals pixel height at exactly
|
||||
100dpi — several bundled fonts (e.g. 6x13.bdf, 5x8.bdf) are defined
|
||||
at 75dpi, where the two values genuinely differ."""
|
||||
size_line_value = None
|
||||
try:
|
||||
with open(bdf_path, "r", encoding="ascii", errors="ignore") as f:
|
||||
for line in f:
|
||||
if line.startswith("PIXEL_SIZE"):
|
||||
parts = line.split()
|
||||
if len(parts) >= 2:
|
||||
return int(float(parts[1]))
|
||||
elif line.startswith("SIZE") and size_line_value is None:
|
||||
# Format: "SIZE <point_size> <xres> <yres>"
|
||||
parts = line.split()
|
||||
if len(parts) >= 2:
|
||||
size_line_value = int(float(parts[1]))
|
||||
elif line.startswith("STARTCHAR"):
|
||||
break
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
return size_line_value
|
||||
|
||||
def _get_fallback_font(self) -> ImageFont.ImageFont:
|
||||
"""Get a fallback font when loading fails."""
|
||||
return ImageFont.load_default()
|
||||
@@ -507,10 +562,14 @@ class FontManager:
|
||||
Returns:
|
||||
Tuple of (width, height, baseline_offset)
|
||||
"""
|
||||
cache_key = f"{hash(text)}_{id(font)}"
|
||||
# Key on the text itself (hash(text) could collide) + font identity;
|
||||
# the entry below keeps the font referenced so the id stays valid.
|
||||
cache_key = (text, id(font))
|
||||
|
||||
if cache_key in self.metrics_cache:
|
||||
return self.metrics_cache[cache_key]
|
||||
cached = self.metrics_cache.get(cache_key)
|
||||
if cached is not None:
|
||||
self.metrics_cache.move_to_end(cache_key)
|
||||
return cached[0]
|
||||
|
||||
try:
|
||||
if isinstance(font, freetype.Face):
|
||||
@@ -547,7 +606,9 @@ class FontManager:
|
||||
baseline = 10
|
||||
|
||||
result = (width, height, baseline)
|
||||
self.metrics_cache[cache_key] = result
|
||||
self.metrics_cache[cache_key] = (result, font)
|
||||
while len(self.metrics_cache) > self._METRICS_CACHE_MAX:
|
||||
self.metrics_cache.popitem(last=False)
|
||||
return result
|
||||
|
||||
def get_font_height(self, font: Union[ImageFont.FreeTypeFont, freetype.Face]) -> int:
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
import os
|
||||
import freetype
|
||||
from PIL import ImageDraw, ImageFont
|
||||
import logging
|
||||
from typing import Dict, Any
|
||||
from src.display_manager import DisplayManager
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class FontTestManager:
|
||||
"""Manager for testing fonts with easy BDF/TTF switching."""
|
||||
|
||||
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager):
|
||||
self.display_manager = display_manager
|
||||
self.config = config
|
||||
self.logger = logging.getLogger('FontTest')
|
||||
|
||||
# FONT CONFIGURATION - EASY SWITCHING
|
||||
# Set to 'bdf' or 'ttf' to switch font types
|
||||
self.font_type = 'bdf' # Change this to 'ttf' to use TTF font
|
||||
|
||||
# Font configurations
|
||||
self.font_configs = {
|
||||
'bdf': {
|
||||
'path': "assets/fonts/cozette.bdf",
|
||||
'display_name': "Cozette BTF",
|
||||
'description': "BTF font Test"
|
||||
},
|
||||
'ttf': {
|
||||
'path': "assets/fonts/5by7.regular.ttf",
|
||||
'display_name': "5by7 TTF",
|
||||
'description': "TTF font test"
|
||||
}
|
||||
}
|
||||
|
||||
# Get current font configuration
|
||||
self.current_config = self.font_configs[self.font_type]
|
||||
self.font_path = self.current_config['path']
|
||||
|
||||
# Verify font exists
|
||||
if not os.path.exists(self.font_path):
|
||||
self.logger.error(f"Font file not found: {self.font_path}")
|
||||
raise FileNotFoundError(f"Font file not found: {self.font_path}")
|
||||
|
||||
# Load the font based on type
|
||||
if self.font_type == 'bdf':
|
||||
self._load_bdf_font()
|
||||
else:
|
||||
self._load_ttf_font()
|
||||
|
||||
self.logger.info(f"Initialized FontTestManager with {self.current_config['description']}")
|
||||
|
||||
def _load_bdf_font(self):
|
||||
"""Load BDF font using freetype."""
|
||||
try:
|
||||
self.face = freetype.Face(self.font_path)
|
||||
self.logger.info(f"Successfully loaded BDF font from {self.font_path}")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to load BDF font: {e}")
|
||||
raise
|
||||
|
||||
def _load_ttf_font(self):
|
||||
"""Load TTF font using PIL."""
|
||||
try:
|
||||
self.font = ImageFont.truetype(self.font_path, 8) # Size 8 for 5x7 font
|
||||
self.logger.info(f"Successfully loaded TTF font from {self.font_path}")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to load TTF font: {e}")
|
||||
raise
|
||||
|
||||
def update(self):
|
||||
"""No update needed for static display."""
|
||||
|
||||
def display(self, force_clear: bool = False):
|
||||
"""Display the font with sample text."""
|
||||
try:
|
||||
# Clear the display
|
||||
self.display_manager.clear()
|
||||
|
||||
# Draw font name at the top
|
||||
self.display_manager.draw_text(self.current_config['display_name'], y=2, color=(255, 255, 255))
|
||||
|
||||
# Draw sample text
|
||||
draw = ImageDraw.Draw(self.display_manager.image)
|
||||
sample_text = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
|
||||
# Calculate starting position
|
||||
x = 10 # Start 10 pixels from the left
|
||||
y = 10 # Start 10 pixels from the top
|
||||
|
||||
# Draw text based on font type
|
||||
if self.font_type == 'bdf':
|
||||
self._draw_bdf_text(draw, sample_text, x, y)
|
||||
else:
|
||||
self._draw_ttf_text(draw, sample_text, x, y)
|
||||
|
||||
# Update the display once
|
||||
self.display_manager.update_display()
|
||||
|
||||
# Log that display is complete
|
||||
self.logger.info("Font test display complete.")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error displaying font test: {e}", exc_info=True)
|
||||
|
||||
def _draw_bdf_text(self, draw, text, x, y):
|
||||
"""Draw text using BDF font."""
|
||||
for char in text:
|
||||
# Load the glyph
|
||||
self.face.load_char(char)
|
||||
bitmap = self.face.glyph.bitmap
|
||||
|
||||
# Draw the glyph
|
||||
for i in range(bitmap.rows):
|
||||
for j in range(bitmap.width):
|
||||
try:
|
||||
# Get the byte containing the pixel
|
||||
byte_index = i * bitmap.pitch + (j // 8)
|
||||
if byte_index < len(bitmap.buffer):
|
||||
byte = bitmap.buffer[byte_index]
|
||||
# Check if the specific bit is set
|
||||
if byte & (1 << (7 - (j % 8))):
|
||||
draw.point((x + j, y + i), fill=(255, 255, 255))
|
||||
except IndexError:
|
||||
self.logger.warning(f"Index out of range for char '{char}' at position ({i}, {j})")
|
||||
continue
|
||||
|
||||
# Move to next character position
|
||||
x += self.face.glyph.advance.x >> 6
|
||||
|
||||
def _draw_ttf_text(self, draw, text, x, y):
|
||||
"""Draw text using TTF font."""
|
||||
draw.text((x, y), text, font=self.font, fill=(255, 255, 255))
|
||||
@@ -1,150 +0,0 @@
|
||||
"""
|
||||
Generic Cache Mixin for Any Manager
|
||||
|
||||
This mixin provides caching functionality that can be used by any manager
|
||||
that needs to cache data, not just sports managers. It's a more general
|
||||
version of BackgroundCacheMixin that works for weather, stocks, news, etc.
|
||||
"""
|
||||
|
||||
import time
|
||||
from typing import Dict, Optional, Any, Callable
|
||||
|
||||
|
||||
class GenericCacheMixin:
|
||||
"""
|
||||
Generic mixin class that provides caching functionality to any manager.
|
||||
|
||||
This mixin can be used by weather, stock, news, or any other manager
|
||||
that needs to cache data with performance monitoring.
|
||||
|
||||
Note: For sports managers that need background service cache integration,
|
||||
use BackgroundCacheMixin instead. See src/background_cache_mixin.py for details.
|
||||
"""
|
||||
|
||||
def _fetch_data_with_cache(self,
|
||||
cache_key: str,
|
||||
api_fetch_method: Callable,
|
||||
cache_ttl: int = 300,
|
||||
force_refresh: bool = False) -> Optional[Dict]:
|
||||
"""
|
||||
Generic caching pattern for any manager.
|
||||
|
||||
Args:
|
||||
cache_key: Unique cache key for this data
|
||||
api_fetch_method: Method to call for fresh data
|
||||
cache_ttl: Time-to-live in seconds (default: 5 minutes)
|
||||
force_refresh: Skip cache and fetch fresh data
|
||||
|
||||
Returns:
|
||||
Cached or fresh data from API
|
||||
"""
|
||||
start_time = time.time()
|
||||
cache_hit = False
|
||||
cache_source = None
|
||||
|
||||
try:
|
||||
# Check cache first (unless forcing refresh)
|
||||
if not force_refresh:
|
||||
cached_data = self.cache_manager.get_cached_data(cache_key, cache_ttl)
|
||||
if cached_data:
|
||||
self.logger.info(f"Using cached data for {cache_key}")
|
||||
cache_hit = True
|
||||
cache_source = "cache"
|
||||
self.cache_manager.record_cache_hit('regular')
|
||||
|
||||
# Record performance metrics
|
||||
duration = time.time() - start_time
|
||||
self.cache_manager.record_fetch_time(duration)
|
||||
self._log_fetch_performance(cache_key, duration, cache_hit, cache_source)
|
||||
|
||||
return cached_data
|
||||
|
||||
# Fetch fresh data
|
||||
self.logger.info(f"Fetching fresh data for {cache_key}")
|
||||
result = api_fetch_method()
|
||||
cache_source = "api_fresh"
|
||||
|
||||
# Store in cache if we got data
|
||||
if result:
|
||||
self.cache_manager.save_cache(cache_key, result)
|
||||
self.cache_manager.record_cache_miss('regular')
|
||||
else:
|
||||
self.logger.warning(f"No data returned for {cache_key}")
|
||||
|
||||
# Record performance metrics
|
||||
duration = time.time() - start_time
|
||||
self.cache_manager.record_fetch_time(duration)
|
||||
|
||||
# Log performance
|
||||
self._log_fetch_performance(cache_key, duration, cache_hit, cache_source)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
duration = time.time() - start_time
|
||||
self.logger.error(f"Error fetching data for {cache_key} after {duration:.2f}s: {e}")
|
||||
self.cache_manager.record_fetch_time(duration)
|
||||
raise
|
||||
|
||||
def _log_fetch_performance(self, cache_key: str, duration: float, cache_hit: bool, cache_source: str):
|
||||
"""
|
||||
Log detailed performance metrics for fetch operations.
|
||||
|
||||
Args:
|
||||
cache_key: Cache key that was accessed
|
||||
duration: Fetch operation duration in seconds
|
||||
cache_hit: Whether this was a cache hit
|
||||
cache_source: Source of the data (cache, api_fresh, etc.)
|
||||
"""
|
||||
# Log basic performance info
|
||||
self.logger.info(f"Fetch completed for {cache_key} in {duration:.2f}s "
|
||||
f"(cache_hit={cache_hit}, source={cache_source})")
|
||||
|
||||
# Log detailed metrics every 10 operations
|
||||
if hasattr(self, '_fetch_count'):
|
||||
self._fetch_count += 1
|
||||
else:
|
||||
self._fetch_count = 1
|
||||
|
||||
if self._fetch_count % 10 == 0:
|
||||
metrics = self.cache_manager.get_cache_metrics()
|
||||
self.logger.info(f"Cache Performance Summary - "
|
||||
f"Hit Rate: {metrics['cache_hit_rate']:.2%}, "
|
||||
f"API Calls Saved: {metrics['api_calls_saved']}, "
|
||||
f"Avg Fetch Time: {metrics['average_fetch_time']:.2f}s")
|
||||
|
||||
def get_cache_performance_summary(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get cache performance summary for this manager.
|
||||
|
||||
Returns:
|
||||
Dictionary containing cache performance metrics
|
||||
"""
|
||||
return self.cache_manager.get_cache_metrics()
|
||||
|
||||
def log_cache_performance(self):
|
||||
"""Log current cache performance metrics."""
|
||||
self.cache_manager.log_cache_metrics()
|
||||
|
||||
def clear_cache_for_key(self, cache_key: str):
|
||||
"""Clear cache for a specific key."""
|
||||
self.cache_manager.clear_cache(cache_key)
|
||||
self.logger.info(f"Cleared cache for {cache_key}")
|
||||
|
||||
def get_cache_info(self, cache_key: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get information about a cached item.
|
||||
|
||||
Args:
|
||||
cache_key: Cache key to check
|
||||
|
||||
Returns:
|
||||
Dictionary with cache information
|
||||
"""
|
||||
# This would need to be implemented in CacheManager
|
||||
# For now, just return basic info
|
||||
return {
|
||||
'key': cache_key,
|
||||
'exists': self.cache_manager.get_cached_data(cache_key, 0) is not None,
|
||||
'ttl': 'unknown' # Would need to be implemented
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import logging
|
||||
from PIL import Image
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def scale_to_max_dimensions(img, max_width, max_height):
|
||||
h_to_w_ratio = img.height / img.width
|
||||
w_to_h_ratio = img.width / img.height
|
||||
|
||||
if img.height > max_height:
|
||||
img = img.resize((int(max_height * w_to_h_ratio), max_height), Image.Resampling.LANCZOS)
|
||||
|
||||
if img.width > max_width:
|
||||
img = img.resize((max_width, int(max_width * h_to_w_ratio)), Image.Resampling.LANCZOS)
|
||||
|
||||
return img
|
||||
@@ -1,409 +0,0 @@
|
||||
"""
|
||||
Layout Manager for LED Matrix Display
|
||||
Handles custom layouts, element positioning, and display composition.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
from typing import Dict, List, Any
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class LayoutManager:
|
||||
def __init__(self, display_manager=None, config_path="config/custom_layouts.json"):
|
||||
self.display_manager = display_manager
|
||||
self.config_path = config_path
|
||||
self.layouts = self.load_layouts()
|
||||
self.current_layout = None
|
||||
|
||||
def load_layouts(self) -> Dict[str, Any]:
|
||||
"""Load saved layouts from file."""
|
||||
try:
|
||||
if os.path.exists(self.config_path):
|
||||
with open(self.config_path, 'r') as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading layouts: {e}")
|
||||
return {}
|
||||
|
||||
def save_layouts(self) -> bool:
|
||||
"""Save layouts to file."""
|
||||
try:
|
||||
from pathlib import Path
|
||||
from src.common.permission_utils import (
|
||||
ensure_directory_permissions,
|
||||
get_config_dir_mode
|
||||
)
|
||||
config_path_obj = Path(self.config_path)
|
||||
ensure_directory_permissions(config_path_obj.parent, get_config_dir_mode())
|
||||
with open(self.config_path, 'w') as f:
|
||||
json.dump(self.layouts, f, indent=2)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving layouts: {e}")
|
||||
return False
|
||||
|
||||
def create_layout(self, name: str, elements: List[Dict], description: str = "") -> bool:
|
||||
"""Create a new layout."""
|
||||
try:
|
||||
self.layouts[name] = {
|
||||
'elements': elements,
|
||||
'description': description,
|
||||
'created': datetime.now().isoformat(),
|
||||
'modified': datetime.now().isoformat()
|
||||
}
|
||||
return self.save_layouts()
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating layout '{name}': {e}")
|
||||
return False
|
||||
|
||||
def update_layout(self, name: str, elements: List[Dict], description: str = None) -> bool:
|
||||
"""Update an existing layout."""
|
||||
try:
|
||||
if name not in self.layouts:
|
||||
return False
|
||||
|
||||
self.layouts[name]['elements'] = elements
|
||||
self.layouts[name]['modified'] = datetime.now().isoformat()
|
||||
|
||||
if description is not None:
|
||||
self.layouts[name]['description'] = description
|
||||
|
||||
return self.save_layouts()
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating layout '{name}': {e}")
|
||||
return False
|
||||
|
||||
def delete_layout(self, name: str) -> bool:
|
||||
"""Delete a layout."""
|
||||
try:
|
||||
if name in self.layouts:
|
||||
del self.layouts[name]
|
||||
return self.save_layouts()
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting layout '{name}': {e}")
|
||||
return False
|
||||
|
||||
def get_layout(self, name: str) -> Dict[str, Any]:
|
||||
"""Get a specific layout."""
|
||||
return self.layouts.get(name, {})
|
||||
|
||||
def list_layouts(self) -> List[str]:
|
||||
"""Get list of all layout names."""
|
||||
return list(self.layouts.keys())
|
||||
|
||||
def set_current_layout(self, name: str) -> bool:
|
||||
"""Set the current active layout."""
|
||||
if name in self.layouts:
|
||||
self.current_layout = name
|
||||
return True
|
||||
return False
|
||||
|
||||
def render_layout(self, layout_name: str = None, data_context: Dict = None) -> bool:
|
||||
"""Render a layout to the display."""
|
||||
if not self.display_manager:
|
||||
logger.error("No display manager available")
|
||||
return False
|
||||
|
||||
layout_name = layout_name or self.current_layout
|
||||
if not layout_name or layout_name not in self.layouts:
|
||||
logger.error(f"Layout '{layout_name}' not found")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Clear the display
|
||||
self.display_manager.clear()
|
||||
|
||||
# Get layout elements
|
||||
elements = self.layouts[layout_name]['elements']
|
||||
|
||||
# Render each element
|
||||
for element in elements:
|
||||
self.render_element(element, data_context or {})
|
||||
|
||||
# Update the display
|
||||
self.display_manager.update_display()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error rendering layout '{layout_name}': {e}")
|
||||
return False
|
||||
|
||||
def render_element(self, element: Dict, data_context: Dict) -> None:
|
||||
"""Render a single element."""
|
||||
element_type = element.get('type')
|
||||
x = element.get('x', 0)
|
||||
y = element.get('y', 0)
|
||||
properties = element.get('properties', {})
|
||||
|
||||
try:
|
||||
if element_type == 'text':
|
||||
self._render_text_element(x, y, properties, data_context)
|
||||
elif element_type == 'weather_icon':
|
||||
self._render_weather_icon_element(x, y, properties, data_context)
|
||||
elif element_type == 'rectangle':
|
||||
self._render_rectangle_element(x, y, properties)
|
||||
elif element_type == 'line':
|
||||
self._render_line_element(x, y, properties)
|
||||
elif element_type == 'clock':
|
||||
self._render_clock_element(x, y, properties)
|
||||
elif element_type == 'data_text':
|
||||
self._render_data_text_element(x, y, properties, data_context)
|
||||
else:
|
||||
logger.warning(f"Unknown element type: {element_type}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error rendering element {element_type}: {e}")
|
||||
|
||||
def _render_text_element(self, x: int, y: int, properties: Dict, data_context: Dict) -> None:
|
||||
"""Render a text element."""
|
||||
text = properties.get('text', 'Sample Text')
|
||||
color = tuple(properties.get('color', [255, 255, 255]))
|
||||
font_size = properties.get('font_size', 'normal')
|
||||
|
||||
# Support template variables in text
|
||||
text = self._process_template_text(text, data_context)
|
||||
|
||||
# Select font
|
||||
if font_size == 'small':
|
||||
font = self.display_manager.small_font
|
||||
elif font_size == 'large':
|
||||
font = self.display_manager.regular_font
|
||||
else:
|
||||
font = self.display_manager.regular_font
|
||||
|
||||
self.display_manager.draw_text(text, x, y, color, font=font)
|
||||
|
||||
def _render_weather_icon_element(self, x: int, y: int, properties: Dict, data_context: Dict) -> None:
|
||||
"""Render a weather icon element."""
|
||||
condition = properties.get('condition', 'sunny')
|
||||
size = properties.get('size', 16)
|
||||
|
||||
# Use weather data from context if available
|
||||
if 'weather' in data_context and 'condition' in data_context['weather']:
|
||||
condition = data_context['weather']['condition'].lower()
|
||||
|
||||
self.display_manager.draw_weather_icon(condition, x, y, size)
|
||||
|
||||
def _render_rectangle_element(self, x: int, y: int, properties: Dict) -> None:
|
||||
"""Render a rectangle element."""
|
||||
width = properties.get('width', 10)
|
||||
height = properties.get('height', 10)
|
||||
color = tuple(properties.get('color', [255, 255, 255]))
|
||||
filled = properties.get('filled', False)
|
||||
|
||||
if filled:
|
||||
self.display_manager.draw.rectangle(
|
||||
[x, y, x + width, y + height],
|
||||
fill=color
|
||||
)
|
||||
else:
|
||||
self.display_manager.draw.rectangle(
|
||||
[x, y, x + width, y + height],
|
||||
outline=color
|
||||
)
|
||||
|
||||
def _render_line_element(self, x: int, y: int, properties: Dict) -> None:
|
||||
"""Render a line element."""
|
||||
x2 = properties.get('x2', x + 10)
|
||||
y2 = properties.get('y2', y)
|
||||
color = tuple(properties.get('color', [255, 255, 255]))
|
||||
width = properties.get('width', 1)
|
||||
|
||||
self.display_manager.draw.line([x, y, x2, y2], fill=color, width=width)
|
||||
|
||||
def _render_clock_element(self, x: int, y: int, properties: Dict) -> None:
|
||||
"""Render a clock element."""
|
||||
format_str = properties.get('format', '%H:%M')
|
||||
color = tuple(properties.get('color', [255, 255, 255]))
|
||||
|
||||
current_time = datetime.now().strftime(format_str)
|
||||
self.display_manager.draw_text(current_time, x, y, color)
|
||||
|
||||
def _render_data_text_element(self, x: int, y: int, properties: Dict, data_context: Dict) -> None:
|
||||
"""Render a data-driven text element."""
|
||||
data_key = properties.get('data_key', '')
|
||||
format_str = properties.get('format', '{value}')
|
||||
color = tuple(properties.get('color', [255, 255, 255]))
|
||||
default_value = properties.get('default', 'N/A')
|
||||
|
||||
# Extract data from context
|
||||
value = self._get_nested_value(data_context, data_key, default_value)
|
||||
|
||||
# Format the text
|
||||
try:
|
||||
text = format_str.format(value=value)
|
||||
except (ValueError, TypeError, KeyError, IndexError):
|
||||
text = str(value)
|
||||
|
||||
self.display_manager.draw_text(text, x, y, color)
|
||||
|
||||
def _process_template_text(self, text: str, data_context: Dict) -> str:
|
||||
"""Process template variables in text."""
|
||||
try:
|
||||
# Simple template processing - replace {key} with values from context
|
||||
for key, value in data_context.items():
|
||||
placeholder = f"{{{key}}}"
|
||||
if placeholder in text:
|
||||
text = text.replace(placeholder, str(value))
|
||||
return text
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing template text: {e}")
|
||||
return text
|
||||
|
||||
def _get_nested_value(self, data: Dict, key: str, default=None):
|
||||
"""Get a nested value from a dictionary using dot notation."""
|
||||
try:
|
||||
keys = key.split('.')
|
||||
value = data
|
||||
for k in keys:
|
||||
value = value[k]
|
||||
return value
|
||||
except (KeyError, TypeError):
|
||||
return default
|
||||
|
||||
def create_preset_layouts(self) -> None:
|
||||
"""Create some preset layouts for common use cases."""
|
||||
# Basic clock layout
|
||||
clock_layout = [
|
||||
{
|
||||
'type': 'clock',
|
||||
'x': 10,
|
||||
'y': 10,
|
||||
'properties': {
|
||||
'format': '%H:%M',
|
||||
'color': [255, 255, 255]
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'clock',
|
||||
'x': 10,
|
||||
'y': 20,
|
||||
'properties': {
|
||||
'format': '%m/%d',
|
||||
'color': [100, 100, 255]
|
||||
}
|
||||
}
|
||||
]
|
||||
self.create_layout('basic_clock', clock_layout, 'Simple clock with date')
|
||||
|
||||
# Weather layout
|
||||
weather_layout = [
|
||||
{
|
||||
'type': 'weather_icon',
|
||||
'x': 5,
|
||||
'y': 5,
|
||||
'properties': {
|
||||
'condition': 'sunny',
|
||||
'size': 20
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'data_text',
|
||||
'x': 30,
|
||||
'y': 8,
|
||||
'properties': {
|
||||
'data_key': 'weather.temperature',
|
||||
'format': '{value}°',
|
||||
'color': [255, 200, 0],
|
||||
'default': '--°'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'data_text',
|
||||
'x': 30,
|
||||
'y': 18,
|
||||
'properties': {
|
||||
'data_key': 'weather.condition',
|
||||
'format': '{value}',
|
||||
'color': [200, 200, 200],
|
||||
'default': 'Unknown'
|
||||
}
|
||||
}
|
||||
]
|
||||
self.create_layout('weather_display', weather_layout, 'Weather icon with temperature and condition')
|
||||
|
||||
# Mixed dashboard layout
|
||||
dashboard_layout = [
|
||||
{
|
||||
'type': 'clock',
|
||||
'x': 2,
|
||||
'y': 2,
|
||||
'properties': {
|
||||
'format': '%H:%M',
|
||||
'color': [255, 255, 255]
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'weather_icon',
|
||||
'x': 50,
|
||||
'y': 2,
|
||||
'properties': {
|
||||
'size': 16
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'data_text',
|
||||
'x': 70,
|
||||
'y': 5,
|
||||
'properties': {
|
||||
'data_key': 'weather.temperature',
|
||||
'format': '{value}°',
|
||||
'color': [255, 200, 0],
|
||||
'default': '--°'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'line',
|
||||
'x': 0,
|
||||
'y': 15,
|
||||
'properties': {
|
||||
'x2': 128,
|
||||
'y2': 15,
|
||||
'color': [100, 100, 100]
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'data_text',
|
||||
'x': 2,
|
||||
'y': 18,
|
||||
'properties': {
|
||||
'data_key': 'stocks.AAPL.price',
|
||||
'format': 'AAPL: ${value}',
|
||||
'color': [0, 255, 0],
|
||||
'default': 'AAPL: N/A'
|
||||
}
|
||||
}
|
||||
]
|
||||
self.create_layout('dashboard', dashboard_layout, 'Mixed dashboard with clock, weather, and stocks')
|
||||
|
||||
logger.info("Created preset layouts")
|
||||
|
||||
def get_layout_preview(self, layout_name: str) -> Dict[str, Any]:
|
||||
"""Get a preview representation of a layout."""
|
||||
if layout_name not in self.layouts:
|
||||
return {}
|
||||
|
||||
layout = self.layouts[layout_name]
|
||||
elements = layout['elements']
|
||||
|
||||
# Create a simple preview representation
|
||||
preview = {
|
||||
'name': layout_name,
|
||||
'description': layout.get('description', ''),
|
||||
'element_count': len(elements),
|
||||
'elements': []
|
||||
}
|
||||
|
||||
for element in elements:
|
||||
preview['elements'].append({
|
||||
'type': element.get('type'),
|
||||
'position': f"({element.get('x', 0)}, {element.get('y', 0)})",
|
||||
'properties': list(element.get('properties', {}).keys())
|
||||
})
|
||||
|
||||
return preview
|
||||
@@ -139,23 +139,41 @@ def setup_logging(
|
||||
sys.stderr.write(f"Warning: Could not set up file logging to {log_file}: {e}\n")
|
||||
|
||||
|
||||
def get_logger(name: str, plugin_id: Optional[str] = None) -> logging.Logger:
|
||||
class PluginLoggerAdapter(logging.LoggerAdapter):
|
||||
"""LoggerAdapter that stamps every record with its plugin_id.
|
||||
|
||||
A plain `logging.Logger` attribute (the old approach) is never copied
|
||||
onto individual `LogRecord`s, so `ContextualFormatter`/`StructuredFormatter`
|
||||
only ever saw `plugin_id` on calls that explicitly passed
|
||||
`extra={'plugin_id': ...}` (i.e. `log_with_context`). This adapter injects
|
||||
it into `extra` on every call, so `self.logger.info(...)` in plugin code
|
||||
is tagged automatically.
|
||||
"""
|
||||
|
||||
def process(self, msg, kwargs):
|
||||
extra = dict(kwargs.get('extra') or {})
|
||||
extra.setdefault('plugin_id', self.extra.get('plugin_id'))
|
||||
kwargs['extra'] = extra
|
||||
return msg, kwargs
|
||||
|
||||
|
||||
def get_logger(name: str, plugin_id: Optional[str] = None):
|
||||
"""
|
||||
Get a logger with consistent configuration.
|
||||
|
||||
|
||||
Args:
|
||||
name: Logger name (typically __name__)
|
||||
plugin_id: Optional plugin ID for automatic context
|
||||
|
||||
|
||||
Returns:
|
||||
Configured logger instance
|
||||
Configured logger instance (or a PluginLoggerAdapter when plugin_id
|
||||
is given, which supports the same .debug/.info/.warning/.error API)
|
||||
"""
|
||||
logger = logging.getLogger(name)
|
||||
|
||||
# Add plugin_id as attribute for formatters
|
||||
|
||||
if plugin_id:
|
||||
logger.plugin_id = plugin_id
|
||||
|
||||
return PluginLoggerAdapter(logger, {'plugin_id': plugin_id})
|
||||
|
||||
return logger
|
||||
|
||||
|
||||
|
||||
@@ -15,6 +15,19 @@ import logging
|
||||
from src.logging_config import get_logger
|
||||
|
||||
|
||||
_shared_fallback_font_manager: Optional[Any] = None
|
||||
|
||||
|
||||
def _fallback_font_manager() -> Any:
|
||||
"""Shared FontManager for environments (unit tests, mocks) where the
|
||||
plugin manager doesn't carry one. Scans assets/fonts like the real one."""
|
||||
global _shared_fallback_font_manager
|
||||
if _shared_fallback_font_manager is None:
|
||||
from src.font_manager import FontManager
|
||||
_shared_fallback_font_manager = FontManager({})
|
||||
return _shared_fallback_font_manager
|
||||
|
||||
|
||||
class VegasDisplayMode(Enum):
|
||||
"""
|
||||
Display mode for Vegas scroll integration.
|
||||
@@ -73,7 +86,9 @@ class BasePlugin(ABC):
|
||||
self.display_manager: Any = display_manager
|
||||
self.cache_manager: Any = cache_manager
|
||||
self.plugin_manager: Any = plugin_manager
|
||||
self.logger: logging.Logger = get_logger(f"plugin.{plugin_id}", plugin_id=plugin_id)
|
||||
# get_logger returns a PluginLoggerAdapter here (plugin_id given), which
|
||||
# stamps every record with plugin_id so it survives into formatted output.
|
||||
self.logger = get_logger(f"plugin.{plugin_id}", plugin_id=plugin_id)
|
||||
self.enabled: bool = config.get("enabled", True)
|
||||
|
||||
self.logger.info("Initialized plugin: %s", plugin_id)
|
||||
@@ -130,6 +145,133 @@ class BasePlugin(ABC):
|
||||
"""
|
||||
raise NotImplementedError("Plugins must implement display()")
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Adaptive layout support (opt-in)
|
||||
# -------------------------------------------------------------------------
|
||||
@property
|
||||
def layout(self) -> Any:
|
||||
"""
|
||||
LayoutContext for the current logical display size.
|
||||
|
||||
Lazily built and rebuilt automatically when the display size changes
|
||||
(e.g. Vegas segment widths, double-sided logical screens). Provides
|
||||
Region carving (self.layout.bounds), breakpoint tiers, a geometry
|
||||
scale factor vs. the manifest's display.design_size, and fit-text
|
||||
queries against font ladders. See src/adaptive_layout.py.
|
||||
|
||||
Example:
|
||||
rows = self.layout.bounds.inset(1).split_v(3, 1, gap=1)
|
||||
self.draw_fit(big_text, rows[0], ladder=LADDER_ARCADE)
|
||||
self.draw_fit(small_text, rows[1])
|
||||
"""
|
||||
from src.adaptive_layout import LayoutContext
|
||||
|
||||
width = getattr(self.display_manager, "width", None)
|
||||
height = getattr(self.display_manager, "height", None)
|
||||
if not width or not height:
|
||||
matrix = getattr(self.display_manager, "matrix", None)
|
||||
width = getattr(matrix, "width", 128)
|
||||
height = getattr(matrix, "height", 32)
|
||||
|
||||
font_manager = self._get_font_manager()
|
||||
generation = getattr(font_manager, "cache_generation", 0)
|
||||
cached = getattr(self, "_layout_context", None)
|
||||
if (cached is not None
|
||||
and (cached.width, cached.height) == (width, height)
|
||||
and getattr(self, "_layout_font_generation", None) == generation):
|
||||
return cached
|
||||
|
||||
context = LayoutContext(
|
||||
width, height, font_manager,
|
||||
design_size=self._get_design_size(),
|
||||
)
|
||||
self._layout_context = context
|
||||
self._layout_font_generation = generation
|
||||
return context
|
||||
|
||||
def draw_fit(self, text: str, box: Any,
|
||||
color: tuple = (255, 255, 255),
|
||||
ladder: Optional[Any] = None,
|
||||
align: str = "center", valign: str = "center") -> Any:
|
||||
"""
|
||||
Fit text to a Region with the largest crisp font that fits, then draw
|
||||
it aligned within that region via the display manager.
|
||||
|
||||
Args:
|
||||
text: Text to display (ellipsized if even the smallest rung is too wide)
|
||||
box: Region (or (w, h) tuple anchored at 0,0) to fit and align within
|
||||
color: RGB color tuple
|
||||
ladder: FontLadder to walk (default LADDER_GRID; use LADDER_ARCADE
|
||||
for headline text like clocks and scores)
|
||||
align/valign: alignment of the text ink within the box
|
||||
|
||||
Returns:
|
||||
FitResult (font, family, size_px, text, ink metrics, fits flag)
|
||||
"""
|
||||
from src.adaptive_layout import LADDER_DEFAULT, draw_fitted_text
|
||||
|
||||
fit = self.layout.fit_text(text, box, ladder=ladder or LADDER_DEFAULT)
|
||||
draw_fitted_text(self.display_manager, fit, box,
|
||||
color=color, align=align, valign=valign)
|
||||
return fit
|
||||
|
||||
def draw_image(self, img: Any, box: Any, *,
|
||||
mode: str = "contain", align: str = "center",
|
||||
valign: str = "center", crop_to_ink: bool = False,
|
||||
anchor: str = "center", resample: Optional[Any] = None,
|
||||
cache_key: Optional[Any] = None,
|
||||
offset: tuple = (0, 0)) -> Any:
|
||||
"""
|
||||
Fit an image into a Region and paste it aligned within that region
|
||||
onto the display canvas — the image counterpart to draw_fit().
|
||||
|
||||
Args:
|
||||
img: Source PIL image (logos, art, icons)
|
||||
box: Region (or (w, h) tuple) to fit and align within
|
||||
mode: "contain" (letterbox), "cover" (crop-to-fill),
|
||||
"fill_height" (logo-style), "stretch"
|
||||
crop_to_ink: Trim transparent padding before fitting
|
||||
anchor: "center" or "top" for cover crops
|
||||
resample: PIL filter; default LANCZOS. Use RESAMPLE_NEAREST
|
||||
(from src.adaptive_images) for pixel art/flags
|
||||
cache_key: Stable identity (e.g. "logo:KC") for cross-reload
|
||||
caching; defaults to the image object's identity
|
||||
offset: Final (dx, dy) translation — the hook for user
|
||||
x/y-offset customization
|
||||
|
||||
Returns:
|
||||
ImageFitResult (processed image + dimensions + scale)
|
||||
"""
|
||||
from src.adaptive_images import draw_fitted_image
|
||||
|
||||
ifit = self.layout.fit_image(img, box, mode=mode,
|
||||
crop_to_ink=crop_to_ink, anchor=anchor,
|
||||
resample=resample, cache_key=cache_key)
|
||||
draw_fitted_image(self.display_manager, ifit, box,
|
||||
align=align, valign=valign, offset=offset)
|
||||
return ifit
|
||||
|
||||
def _get_font_manager(self) -> Any:
|
||||
"""The shared FontManager, or a module-level fallback when running
|
||||
under mocks/harnesses that don't provide one."""
|
||||
font_manager = getattr(self.plugin_manager, "font_manager", None)
|
||||
if font_manager is not None and hasattr(font_manager, "get_font"):
|
||||
return font_manager
|
||||
return _fallback_font_manager()
|
||||
|
||||
def _get_design_size(self) -> tuple:
|
||||
"""Panel size this plugin's layout was authored against, from the
|
||||
manifest's optional display.design_size (defaults to 128x32)."""
|
||||
from src.adaptive_layout import DEFAULT_DESIGN_SIZE
|
||||
|
||||
if self.plugin_manager and hasattr(self.plugin_manager, "plugin_manifests"):
|
||||
manifest = self.plugin_manager.plugin_manifests.get(self.plugin_id, {})
|
||||
declared = manifest.get("display", {}).get("design_size", {})
|
||||
width, height = declared.get("width"), declared.get("height")
|
||||
if width and height:
|
||||
return (int(width), int(height))
|
||||
return DEFAULT_DESIGN_SIZE
|
||||
|
||||
def get_display_duration(self) -> float:
|
||||
"""
|
||||
Get the display duration for this plugin instance.
|
||||
|
||||
@@ -437,8 +437,7 @@ class PluginLoader:
|
||||
if not Path(existing_file).resolve().is_relative_to(resolved_dir):
|
||||
evicted[mod_name] = sys.modules.pop(mod_name)
|
||||
self.logger.debug(
|
||||
"Evicted stale module '%s' (from %s) before loading plugin in %s",
|
||||
mod_name, existing_file, plugin_dir,
|
||||
"Evicted stale bare-name module '%s' before loading plugin", mod_name,
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
@@ -551,7 +550,7 @@ class PluginLoader:
|
||||
plugin_dir_str = str(plugin_dir)
|
||||
if plugin_dir_str not in sys.path:
|
||||
sys.path.insert(0, plugin_dir_str)
|
||||
self.logger.debug("Added plugin directory to sys.path: %s", plugin_dir_str)
|
||||
self.logger.debug("Added plugin %s's directory to sys.path", plugin_id)
|
||||
|
||||
# Import the plugin module
|
||||
module_name = f"plugin_{plugin_id.replace('-', '_')}"
|
||||
@@ -563,8 +562,8 @@ class PluginLoader:
|
||||
|
||||
spec = importlib.util.spec_from_file_location(module_name, entry_file)
|
||||
if spec is None or spec.loader is None:
|
||||
self.logger.error("Could not create module spec for plugin %s", plugin_id)
|
||||
error_msg = f"Could not create module spec for {entry_file}"
|
||||
self.logger.error(error_msg)
|
||||
raise PluginError(error_msg, plugin_id=plugin_id, context={'entry_file': str(entry_file)})
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
@@ -683,6 +682,55 @@ class PluginLoader:
|
||||
self.logger.error(error_msg, exc_info=True)
|
||||
raise PluginError(error_msg, plugin_id=plugin_id) from e
|
||||
|
||||
@staticmethod
|
||||
def _parse_semver(value: Any) -> Optional[Tuple[int, int, int]]:
|
||||
"""Parse 'X.Y.Z' (extra parts/suffixes ignored) into a comparable
|
||||
3-tuple, or None when unparseable."""
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
parts = value.strip().lstrip('v').split('.')
|
||||
try:
|
||||
nums = [int(''.join(ch for ch in p if ch.isdigit()) or 0) for p in parts[:3]]
|
||||
except ValueError:
|
||||
return None
|
||||
while len(nums) < 3:
|
||||
nums.append(0)
|
||||
return tuple(nums) # type: ignore[return-value]
|
||||
|
||||
def _warn_if_incompatible(self, plugin_id: str, manifest: Dict[str, Any]) -> None:
|
||||
"""Log one warning when a plugin declares a minimum LEDMatrix version
|
||||
newer than the running core. Advisory only — never raises — so a
|
||||
plugin that guards optional features with try/except keeps working.
|
||||
"""
|
||||
declared = (
|
||||
manifest.get('min_ledmatrix_version')
|
||||
or manifest.get('requires', {}).get('min_ledmatrix_version')
|
||||
)
|
||||
if not declared:
|
||||
versions = manifest.get('versions') or []
|
||||
if versions and isinstance(versions[0], dict):
|
||||
declared = (versions[0].get('ledmatrix_min_version')
|
||||
or versions[0].get('ledmatrix_min'))
|
||||
needed = self._parse_semver(declared)
|
||||
if needed is None:
|
||||
return
|
||||
|
||||
from src import __version__ as core_version
|
||||
current = self._parse_semver(core_version)
|
||||
# Anti-spam guard: if the core's own version number is stale (below
|
||||
# the ecosystem floor every shipped plugin declares), comparing would
|
||||
# warn on nearly everything — skip with a debug note instead.
|
||||
if current is None or current < (2, 0, 0):
|
||||
self.logger.debug(
|
||||
"Skipping version compatibility check for %s: core __version__ "
|
||||
"(%s) is below the ecosystem floor", plugin_id, core_version)
|
||||
return
|
||||
if needed > current:
|
||||
self.logger.warning(
|
||||
"Plugin %s declares min LEDMatrix version %s but this core is %s — "
|
||||
"features it relies on may be missing; update the core or expect "
|
||||
"degraded fallbacks", plugin_id, declared, core_version)
|
||||
|
||||
def load_plugin(
|
||||
self,
|
||||
plugin_id: str,
|
||||
@@ -715,6 +763,8 @@ class PluginLoader:
|
||||
Raises:
|
||||
PluginError: If loading fails
|
||||
"""
|
||||
self._warn_if_incompatible(plugin_id, manifest)
|
||||
|
||||
# Install dependencies if needed
|
||||
if install_deps:
|
||||
if plugins_dir is None:
|
||||
|
||||
@@ -8,12 +8,13 @@ API Version: 1.0.0
|
||||
"""
|
||||
|
||||
import json
|
||||
import queue
|
||||
import sys
|
||||
import time
|
||||
import threading
|
||||
import types
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Any
|
||||
from typing import Dict, List, Optional, Any, Tuple
|
||||
import logging
|
||||
from src.exceptions import PluginError, ConfigError
|
||||
from src.logging_config import get_logger
|
||||
@@ -76,6 +77,12 @@ class PluginManager:
|
||||
# concurrent mutation (background reconciliation) and reads (requests).
|
||||
self._discovery_lock = threading.RLock()
|
||||
|
||||
# Lock protecting plugin_last_update from concurrent mutation/iteration.
|
||||
# It's written from run_scheduled_updates()/update_all_plugins() (main
|
||||
# loop) and read/diffed by run_scheduled_updates_with_changes(), which
|
||||
# Vegas mode calls from its own background update-tick thread.
|
||||
self._plugin_last_update_lock = threading.RLock()
|
||||
|
||||
# Active plugins
|
||||
self.plugins: Dict[str, Any] = {}
|
||||
self.plugin_manifests: Dict[str, Dict[str, Any]] = {}
|
||||
@@ -91,6 +98,50 @@ class PluginManager:
|
||||
# Health tracking (optional, set by display_controller if available)
|
||||
self.health_tracker = None
|
||||
self.resource_monitor = None
|
||||
|
||||
# --- Asynchronous plugin updates -------------------------------
|
||||
# update() used to run inline in the render loop (execute_update's
|
||||
# internal thread.join(timeout=30) blocked it), so one slow plugin
|
||||
# HTTP fetch froze scrolling for the whole fetch. Scheduling still
|
||||
# happens on the render thread (run_scheduled_updates), but
|
||||
# execution moves to this single background worker. Per-plugin
|
||||
# locks keep a plugin's update() and display() mutually exclusive —
|
||||
# today's implicit guarantee, now explicit (and, unlike today,
|
||||
# also held across the post-timeout window).
|
||||
# Kill switch: plugin_system.synchronous_updates: true restores the
|
||||
# inline path.
|
||||
self._update_queue: "queue.Queue[Optional[Tuple[str, float]]]" = queue.Queue()
|
||||
self._pending_updates: set = set()
|
||||
self._pending_lock = threading.Lock()
|
||||
self._plugin_locks: Dict[str, threading.Lock] = {}
|
||||
self._plugin_locks_guard = threading.Lock()
|
||||
self._update_worker: Optional[threading.Thread] = None
|
||||
self._synchronous_updates = False
|
||||
if self.config_manager is not None:
|
||||
try:
|
||||
cfg = self.config_manager.get_config() or {}
|
||||
except (OSError, ValueError) as exc:
|
||||
self.logger.warning(
|
||||
"Could not load config to check plugin_system.synchronous_updates "
|
||||
"(%s: %s); defaulting to synchronous updates", type(exc).__name__, exc)
|
||||
self._synchronous_updates = True
|
||||
else:
|
||||
plugin_system_cfg = cfg.get('plugin_system', {})
|
||||
if not isinstance(plugin_system_cfg, dict):
|
||||
self.logger.warning(
|
||||
"config plugin_system must be a mapping, got %s; "
|
||||
"defaulting to synchronous updates",
|
||||
type(plugin_system_cfg).__name__)
|
||||
self._synchronous_updates = True
|
||||
else:
|
||||
sync_value = plugin_system_cfg.get('synchronous_updates', False)
|
||||
if not isinstance(sync_value, bool):
|
||||
self.logger.warning(
|
||||
"config plugin_system.synchronous_updates must be a boolean, "
|
||||
"got %r; defaulting to synchronous updates", sync_value)
|
||||
self._synchronous_updates = True
|
||||
else:
|
||||
self._synchronous_updates = sync_value
|
||||
|
||||
# Ensure plugins directory exists with proper permissions
|
||||
try:
|
||||
@@ -317,7 +368,8 @@ class PluginManager:
|
||||
|
||||
# Store plugin instance
|
||||
self.plugins[plugin_id] = plugin_instance
|
||||
self.plugin_last_update[plugin_id] = 0.0
|
||||
with self._plugin_last_update_lock:
|
||||
self.plugin_last_update[plugin_id] = 0.0
|
||||
# Invalidate cached interval so next tick re-derives it for this plugin
|
||||
self._update_interval_cache.pop(plugin_id, None)
|
||||
|
||||
@@ -429,7 +481,8 @@ class PluginManager:
|
||||
|
||||
# Remove from active plugins
|
||||
del self.plugins[plugin_id]
|
||||
self.plugin_last_update.pop(plugin_id, None)
|
||||
with self._plugin_last_update_lock:
|
||||
self.plugin_last_update.pop(plugin_id, None)
|
||||
self._update_interval_cache.pop(plugin_id, None)
|
||||
|
||||
# Remove main module from sys.modules if present
|
||||
@@ -698,7 +751,8 @@ class PluginManager:
|
||||
'recoverable': True,
|
||||
}
|
||||
self.logger.warning("Plugin %s update() failed; will retry after interval", plugin_id)
|
||||
self.plugin_last_update[plugin_id] = failure_time
|
||||
with self._plugin_last_update_lock:
|
||||
self.plugin_last_update[plugin_id] = failure_time
|
||||
self.state_manager.set_state_with_error(plugin_id, PluginState.ENABLED, error_info, error=err)
|
||||
if self.health_tracker:
|
||||
self.health_tracker.record_failure(plugin_id, err)
|
||||
@@ -731,49 +785,218 @@ class PluginManager:
|
||||
if interval is None:
|
||||
continue
|
||||
|
||||
last_update = self.plugin_last_update.get(plugin_id, 0.0)
|
||||
with self._plugin_last_update_lock:
|
||||
last_update = self.plugin_last_update.get(plugin_id, 0.0)
|
||||
|
||||
if last_update == 0.0 or (current_time - last_update) >= interval:
|
||||
# Update state to RUNNING
|
||||
self.state_manager.set_state(plugin_id, PluginState.RUNNING)
|
||||
|
||||
try:
|
||||
# Use PluginExecutor for safe execution
|
||||
success = False
|
||||
if self.resource_monitor:
|
||||
# If resource monitor exists, wrap the call
|
||||
def monitored_update():
|
||||
self.resource_monitor.monitor_call(plugin_id, plugin_instance.update)
|
||||
# SimpleNamespace stores `update` as an *instance*
|
||||
# attribute, so attribute lookup returns the plain
|
||||
# function object as-is. A dynamically-built class
|
||||
# (`type(..., {'update': monitored_update})`) instead
|
||||
# stores it as a *class* attribute, which the
|
||||
# descriptor protocol turns into a bound method on
|
||||
# access -- silently prepending the instance as an
|
||||
# implicit first argument to a function that takes
|
||||
# none, raising "monitored_update() takes 0
|
||||
# positional arguments but 1 was given" on every call.
|
||||
success = self.plugin_executor.execute_update(
|
||||
types.SimpleNamespace(update=monitored_update),
|
||||
plugin_id
|
||||
)
|
||||
else:
|
||||
success = self.plugin_executor.execute_update(plugin_instance, plugin_id)
|
||||
|
||||
if success:
|
||||
self.plugin_last_update[plugin_id] = current_time
|
||||
self.state_manager.record_update(plugin_id)
|
||||
# Update state back to ENABLED
|
||||
self.state_manager.set_state(plugin_id, PluginState.ENABLED)
|
||||
# Record success
|
||||
if self.health_tracker:
|
||||
self.health_tracker.record_success(plugin_id)
|
||||
else:
|
||||
self._record_update_failure(plugin_id)
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
self.logger.exception("Error updating plugin %s: %s", plugin_id, exc)
|
||||
if self._synchronous_updates:
|
||||
# Kill-switch path: the original inline execution
|
||||
# (blocks the caller until update() completes/times out)
|
||||
self.state_manager.set_state(plugin_id, PluginState.RUNNING)
|
||||
self._execute_update_now(plugin_id, plugin_instance, current_time)
|
||||
else:
|
||||
self._enqueue_update(plugin_id, current_time)
|
||||
|
||||
def get_plugin_lock(self, plugin_id: str) -> threading.Lock:
|
||||
"""Per-plugin lock keeping update() and display() mutually exclusive.
|
||||
|
||||
The update worker holds it for the duration of a plugin's update();
|
||||
the display side acquires it non-blocking and skips that frame's
|
||||
display() call when the plugin is mid-update.
|
||||
"""
|
||||
with self._plugin_locks_guard:
|
||||
lock = self._plugin_locks.get(plugin_id)
|
||||
if lock is None:
|
||||
lock = threading.Lock()
|
||||
self._plugin_locks[plugin_id] = lock
|
||||
return lock
|
||||
|
||||
def _enqueue_update(self, plugin_id: str, scheduled_time: float) -> None:
|
||||
"""Queue a due update for the background worker (dedup while pending)."""
|
||||
with self._pending_lock:
|
||||
if plugin_id in self._pending_updates:
|
||||
return
|
||||
self._pending_updates.add(plugin_id)
|
||||
# RUNNING is set at enqueue time so can_execute() blocks re-entry and
|
||||
# the web UI shows the truthful state while the item waits its turn.
|
||||
self.state_manager.set_state(plugin_id, PluginState.RUNNING)
|
||||
self._ensure_update_worker()
|
||||
self._update_queue.put((plugin_id, scheduled_time))
|
||||
|
||||
def _ensure_update_worker(self) -> None:
|
||||
if self._update_worker is not None and self._update_worker.is_alive():
|
||||
return
|
||||
self._update_worker = threading.Thread(
|
||||
target=self._update_worker_loop, name='plugin-update-worker',
|
||||
daemon=True)
|
||||
self._update_worker.start()
|
||||
|
||||
def _update_worker_loop(self) -> None:
|
||||
"""Single worker: dispatches queued updates off the render thread
|
||||
(matching the old inline behavior — no thundering herd of
|
||||
concurrent fetches).
|
||||
|
||||
The plugin's lock is acquired here, before its instance is looked
|
||||
up, and the instance is re-fetched under the lock — a concurrent
|
||||
unload_plugin() can't leave this loop about to run update() on an
|
||||
instance that's already been torn down. The lock — and RUNNING/
|
||||
pending lifecycle state — is released by the update itself once the
|
||||
real update() call genuinely finishes (see _execute_update_now),
|
||||
which can be after this dispatch returns if PluginExecutor's own
|
||||
timeout elapses first.
|
||||
"""
|
||||
while True:
|
||||
item = self._update_queue.get()
|
||||
if item is None: # shutdown sentinel
|
||||
return
|
||||
plugin_id, scheduled_time = item
|
||||
lock = self.get_plugin_lock(plugin_id)
|
||||
lock.acquire()
|
||||
plugin_instance = self.plugins.get(plugin_id)
|
||||
if plugin_instance is None: # unloaded while queued; its
|
||||
# lifecycle state was already cleared by unload_plugin —
|
||||
# leave it alone rather than resurrecting it to ENABLED
|
||||
lock.release()
|
||||
with self._pending_lock:
|
||||
self._pending_updates.discard(plugin_id)
|
||||
continue
|
||||
try:
|
||||
self._execute_update_now(plugin_id, plugin_instance,
|
||||
scheduled_time, lock=lock)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
# _execute_update_now guarantees the lock/pending bookkeeping
|
||||
# is released via its own _finish() before returning or
|
||||
# raising; this is a last-resort log only.
|
||||
self.logger.exception("update worker: unexpected error for %s",
|
||||
plugin_id)
|
||||
|
||||
def stop_update_worker(self, timeout: float = 5.0) -> None:
|
||||
"""Signal the worker to exit (used by cleanup; thread is a daemon)."""
|
||||
if self._update_worker is not None and self._update_worker.is_alive():
|
||||
self._update_queue.put(None)
|
||||
self._update_worker.join(timeout=timeout)
|
||||
if self._update_worker.is_alive():
|
||||
self.logger.warning(
|
||||
"Update worker did not stop within %.1fs; it is a daemon "
|
||||
"thread and will be abandoned on shutdown", timeout)
|
||||
|
||||
def _execute_update_now(self, plugin_id: str, plugin_instance: Any,
|
||||
scheduled_time: float,
|
||||
lock: Optional[threading.Lock] = None) -> None:
|
||||
"""Execute a plugin's update() via PluginExecutor, then bookkeep.
|
||||
|
||||
Caller is responsible for having set RUNNING state.
|
||||
|
||||
On the synchronous path (``lock=None``) this is the original,
|
||||
unchanged inline behavior. On the async worker path, PluginExecutor's
|
||||
internal thread.join(timeout) blocks only the calling thread -- on
|
||||
timeout the lingering daemon update-thread keeps running the real
|
||||
plugin.update() call unkillable in the background. So that the
|
||||
plugin's lock (and its RUNNING/pending lifecycle state) stays held
|
||||
for that real duration rather than just this bounded wait, ownership
|
||||
of both is carried by the wrapped update callable itself, released
|
||||
from whichever thread actually finishes it -- see _finish() below.
|
||||
"""
|
||||
finish_guard = threading.Lock()
|
||||
finished = {'done': False}
|
||||
|
||||
def _finish(success: bool, exc: Optional[Exception] = None) -> None:
|
||||
with finish_guard:
|
||||
if finished['done']:
|
||||
return
|
||||
finished['done'] = True
|
||||
try:
|
||||
if success:
|
||||
with self._plugin_last_update_lock:
|
||||
self.plugin_last_update[plugin_id] = scheduled_time
|
||||
self.state_manager.record_update(plugin_id)
|
||||
self.state_manager.set_state(plugin_id, PluginState.ENABLED)
|
||||
if self.health_tracker:
|
||||
self.health_tracker.record_success(plugin_id)
|
||||
else:
|
||||
self._record_update_failure(plugin_id, exc=exc)
|
||||
finally:
|
||||
if lock is not None:
|
||||
lock.release()
|
||||
with self._pending_lock:
|
||||
self._pending_updates.discard(plugin_id)
|
||||
|
||||
if lock is None:
|
||||
# Synchronous / no-lock path: unchanged behavior.
|
||||
try:
|
||||
if self.resource_monitor:
|
||||
def monitored_update():
|
||||
self.resource_monitor.monitor_call(plugin_id, plugin_instance.update)
|
||||
# SimpleNamespace stores `update` as an *instance*
|
||||
# attribute, so attribute lookup returns the plain
|
||||
# function object as-is. A dynamically-built class
|
||||
# (`type(..., {'update': monitored_update})`) instead
|
||||
# stores it as a *class* attribute, which the
|
||||
# descriptor protocol turns into a bound method on
|
||||
# access -- silently prepending the instance as an
|
||||
# implicit first argument to a function that takes
|
||||
# none, raising "monitored_update() takes 0
|
||||
# positional arguments but 1 was given" on every call.
|
||||
success = self.plugin_executor.execute_update(
|
||||
types.SimpleNamespace(update=monitored_update),
|
||||
plugin_id
|
||||
)
|
||||
else:
|
||||
success = self.plugin_executor.execute_update(plugin_instance, plugin_id)
|
||||
_finish(success)
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
self.logger.exception("Error updating plugin %s: %s", plugin_id, exc)
|
||||
_finish(False, exc=exc)
|
||||
return
|
||||
|
||||
# Async worker path: the real update() call -- through the resource
|
||||
# monitor, if configured -- owns finishing the lock/lifecycle
|
||||
# bookkeeping, from whichever thread actually runs it to completion.
|
||||
def _target_update() -> None:
|
||||
try:
|
||||
if self.resource_monitor:
|
||||
self.resource_monitor.monitor_call(plugin_id, plugin_instance.update)
|
||||
else:
|
||||
plugin_instance.update()
|
||||
except Exception as exc:
|
||||
_finish(False, exc=exc)
|
||||
raise
|
||||
else:
|
||||
_finish(True)
|
||||
|
||||
try:
|
||||
self.plugin_executor.execute_update(
|
||||
types.SimpleNamespace(update=_target_update), plugin_id)
|
||||
except Exception as exc: # pragma: no cover - defensive; execute_update
|
||||
# catches everything internally, but guarantee _finish still
|
||||
# runs (releasing the lock) if something unexpected slips through.
|
||||
self.logger.exception("Unexpected error dispatching update for %s: %s", plugin_id, exc)
|
||||
_finish(False, exc=exc)
|
||||
|
||||
def run_scheduled_updates_with_changes(self, current_time: Optional[float] = None) -> List[str]:
|
||||
"""
|
||||
Like run_scheduled_updates(), but also returns the plugin_ids whose
|
||||
plugin_last_update timestamp actually advanced during this call.
|
||||
|
||||
The before/after snapshots and the update pass itself are each
|
||||
individually lock-protected against concurrent plugin_last_update
|
||||
mutation (Vegas mode calls this from its own background
|
||||
update-tick thread, racing the main render loop's plugin updates),
|
||||
so callers get an atomic "who got fresh data" answer without
|
||||
reaching into plugin_last_update themselves. The lock is not held
|
||||
across the update pass so slow/blocking plugin update() calls don't
|
||||
serialize against other plugin_last_update readers.
|
||||
"""
|
||||
with self._plugin_last_update_lock:
|
||||
old_times = dict(self.plugin_last_update)
|
||||
|
||||
self.run_scheduled_updates(current_time)
|
||||
|
||||
with self._plugin_last_update_lock:
|
||||
return [
|
||||
plugin_id for plugin_id, new_time in self.plugin_last_update.items()
|
||||
if new_time > old_times.get(plugin_id, 0.0)
|
||||
]
|
||||
|
||||
def update_all_plugins(self) -> None:
|
||||
"""
|
||||
@@ -797,7 +1020,8 @@ class PluginManager:
|
||||
try:
|
||||
success = self.plugin_executor.execute_update(plugin_instance, plugin_id)
|
||||
if success:
|
||||
self.plugin_last_update[plugin_id] = time.time()
|
||||
with self._plugin_last_update_lock:
|
||||
self.plugin_last_update[plugin_id] = time.time()
|
||||
self.state_manager.record_update(plugin_id)
|
||||
self.state_manager.set_state(plugin_id, PluginState.ENABLED)
|
||||
else:
|
||||
|
||||
@@ -284,6 +284,19 @@ class SchemaManager:
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -354,6 +367,53 @@ class SchemaManager:
|
||||
self.logger.error(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:
|
||||
"""
|
||||
Format a validation error into a readable message.
|
||||
|
||||
@@ -142,9 +142,28 @@ class PluginStoreManager:
|
||||
# then get the result from the warm cache (double-checked locking).
|
||||
self._registry_fetch_lock = threading.Lock()
|
||||
|
||||
# Per-plugin locks for _reinstall_with_rollback: the web UI runs
|
||||
# Flask with threaded=True, so two overlapping requests for the
|
||||
# same plugin_id (double-click, two browser tabs) would otherwise
|
||||
# both rename the same directory aside — one succeeds, and the
|
||||
# loser can end up renaming the winner's in-progress install aside
|
||||
# mid-download, stealing its own rollback safety net. Keyed by
|
||||
# plugin_id so unrelated plugins still update concurrently.
|
||||
self._reinstall_locks: Dict[str, threading.Lock] = {}
|
||||
self._reinstall_locks_guard = threading.Lock()
|
||||
|
||||
# Ensure plugins directory exists
|
||||
self.plugins_dir.mkdir(exist_ok=True)
|
||||
|
||||
def _get_reinstall_lock(self, plugin_id: str) -> threading.Lock:
|
||||
"""Lazily create (or fetch) the per-plugin reinstall lock."""
|
||||
with self._reinstall_locks_guard:
|
||||
lock = self._reinstall_locks.get(plugin_id)
|
||||
if lock is None:
|
||||
lock = threading.Lock()
|
||||
self._reinstall_locks[plugin_id] = lock
|
||||
return lock
|
||||
|
||||
def _record_cache_backoff(self, cache_dict: Dict, cache_key: str,
|
||||
cache_timeout: int, payload: Any) -> None:
|
||||
"""Bump a cache entry's timestamp so subsequent lookups hit the
|
||||
@@ -1195,6 +1214,11 @@ class PluginStoreManager:
|
||||
self.logger.error(f"Plugin not found in registry: {plugin_id}")
|
||||
return False
|
||||
|
||||
# Visual skins share the registry but install to skins/, not to a
|
||||
# plugin directory (docs/SKIN_SYSTEM.md)
|
||||
if (plugin_info.get('type') or 'plugin') == 'skin':
|
||||
return self._install_skin_from_info(plugin_id, plugin_info, branch)
|
||||
|
||||
repo_url = plugin_info.get('repo')
|
||||
if not repo_url:
|
||||
self.logger.error(f"Plugin {plugin_id} missing repository URL")
|
||||
@@ -2235,19 +2259,171 @@ class PluginStoreManager:
|
||||
|
||||
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:
|
||||
"""
|
||||
Uninstall a plugin by removing its directory.
|
||||
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
|
||||
|
||||
Returns:
|
||||
True if uninstalled successfully (or already not installed)
|
||||
"""
|
||||
plugin_path = self._find_plugin_path(plugin_id)
|
||||
|
||||
|
||||
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)")
|
||||
return True # Already uninstalled, consider this success
|
||||
|
||||
@@ -2263,6 +2439,74 @@ class PluginStoreManager:
|
||||
self.logger.error(f"Error uninstalling plugin {plugin_id}: {e}")
|
||||
return False
|
||||
|
||||
def _reinstall_with_rollback(self, plugin_id: str, plugin_path: Path) -> bool:
|
||||
"""Replace an installed plugin with a fresh install, atomically.
|
||||
|
||||
The old install is renamed aside (not deleted) until the new install
|
||||
succeeds, then removed; on ANY install failure the old directory is
|
||||
restored. This is the difference between a failed update and a
|
||||
destroyed plugin: the previous delete-then-install flow permanently
|
||||
removed plugins whenever the download failed mid-update (seen in the
|
||||
field during the monorepo migration on a Pi with broken DNS — every
|
||||
old-remote plugin was deleted and none could be re-downloaded).
|
||||
|
||||
The aside name embeds '.standalone-backup-' so plugin discovery
|
||||
(plugin_manager._scan_directory_for_plugins) ignores it even though
|
||||
it still contains a manifest.json.
|
||||
|
||||
Held for the whole operation under a per-plugin_id lock: two
|
||||
overlapping requests for the same plugin (double-click, two
|
||||
browser tabs — the web UI runs Flask with threaded=True) must not
|
||||
interleave their renames, or the second could steal the first's
|
||||
rollback safety net mid-install. Other plugin_ids are unaffected.
|
||||
"""
|
||||
with self._get_reinstall_lock(plugin_id):
|
||||
backup_path = plugin_path.with_name(
|
||||
f"{plugin_path.name}.standalone-backup-migrating")
|
||||
# A stale aside from a previous crash would block the rename
|
||||
if backup_path.exists():
|
||||
if not self._safe_remove_directory(backup_path):
|
||||
self.logger.error(
|
||||
f"Could not clear stale backup for {plugin_id} at "
|
||||
f"{backup_path}; leaving old install in place")
|
||||
return False
|
||||
try:
|
||||
plugin_path.rename(backup_path)
|
||||
except OSError as e:
|
||||
self.logger.error(
|
||||
f"Could not set aside old plugin directory for {plugin_id}: {e}")
|
||||
return False
|
||||
|
||||
try:
|
||||
installed = self.install_plugin(plugin_id)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Reinstall of {plugin_id} raised: {e}")
|
||||
installed = False
|
||||
|
||||
if installed:
|
||||
if not self._safe_remove_directory(backup_path):
|
||||
self.logger.warning(
|
||||
f"Update of {plugin_id} succeeded but the old backup "
|
||||
f"at {backup_path} could not be removed; it will be "
|
||||
f"cleared on the next update")
|
||||
return True
|
||||
|
||||
# Install failed (bad network, registry error...) — put the old
|
||||
# version back so the user still has a working plugin.
|
||||
self.logger.error(
|
||||
f"Reinstall of {plugin_id} failed; restoring previous version")
|
||||
try:
|
||||
if plugin_path.exists():
|
||||
# partial download debris from the failed install
|
||||
self._safe_remove_directory(plugin_path)
|
||||
backup_path.rename(plugin_path)
|
||||
self.logger.info(f"Restored previous install of {plugin_id}")
|
||||
except OSError as e:
|
||||
self.logger.error(
|
||||
f"CRITICAL: could not restore {plugin_id} from {backup_path}: {e}. "
|
||||
f"The previous install is preserved there — rename it back manually.")
|
||||
return False
|
||||
|
||||
def update_plugin(self, plugin_id: str) -> bool:
|
||||
"""
|
||||
Update a plugin to the latest commit on its upstream branch.
|
||||
@@ -2325,10 +2569,7 @@ class PluginStoreManager:
|
||||
f"Plugin {resolved_id} git remote ({local_remote}) differs from registry ({registry_repo}). "
|
||||
f"Reinstalling from registry to migrate to new source."
|
||||
)
|
||||
if not self._safe_remove_directory(plugin_path):
|
||||
self.logger.error(f"Failed to remove old plugin directory for {resolved_id}")
|
||||
return False
|
||||
return self.install_plugin(resolved_id)
|
||||
return self._reinstall_with_rollback(resolved_id, plugin_path)
|
||||
|
||||
# Check if already up to date
|
||||
if remote_sha and local_sha and remote_sha.startswith(local_sha):
|
||||
@@ -2632,11 +2873,11 @@ class PluginStoreManager:
|
||||
# Plugin is not a git repo but is in registry and has a newer version - reinstall
|
||||
self.logger.info(f"Plugin {plugin_id} not installed via git; re-installing latest archive (registry id: {registry_id})")
|
||||
|
||||
# Remove directory and reinstall fresh
|
||||
if not self._safe_remove_directory(plugin_path):
|
||||
self.logger.error(f"Failed to remove old plugin directory for {plugin_id}")
|
||||
return False
|
||||
return self.install_plugin(registry_id)
|
||||
# Reinstall with the old version kept aside until the new
|
||||
# download succeeds — this is the path every routine store
|
||||
# update takes, and a mid-update network failure must not
|
||||
# destroy the user's plugin.
|
||||
return self._reinstall_with_rollback(registry_id, plugin_path)
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
@@ -10,8 +10,11 @@ that don't scale down to a smaller panel.
|
||||
|
||||
Limitations (documented on purpose):
|
||||
- Overflow past the LEFT or TOP edge (negative coordinates) is still clipped by
|
||||
PIL and not detected here. The dominant real-world breakage is content that is
|
||||
too wide/tall for a smaller panel, which this catches.
|
||||
PIL and not detected pixel-wise here. The dominant real-world breakage is
|
||||
content that is too wide/tall for a smaller panel, which this catches.
|
||||
As a partial net, draw_text/draw_image calls made with negative coordinates
|
||||
through this manager are recorded in `negative_coordinate_calls` — but draws
|
||||
made directly on the raw PIL canvas remain uncovered.
|
||||
- BDF text is clipped to the declared bounds by the parent's bitmap drawer, so
|
||||
BDF overflow is not flagged. Golden-image regression covers those plugins.
|
||||
- If a plugin replaces the canvas with its own image (display_manager.image = ...),
|
||||
@@ -56,6 +59,21 @@ class BoundsCheckingDisplayManager(VisualTestDisplayManager):
|
||||
super().__init__(self._canvas_width, self._canvas_height)
|
||||
# Plugins must see the DECLARED size, not the padded canvas size.
|
||||
self.matrix = _MatrixProxy(self._declared_width, self._declared_height)
|
||||
# (text-or-'image', x, y) for every mediated draw call given a
|
||||
# negative coordinate — PIL clips these silently, so record them.
|
||||
self.negative_coordinate_calls: list = []
|
||||
|
||||
# -- negative-coordinate (left/top overflow) recording --
|
||||
|
||||
def draw_text(self, text, x=None, y=None, *args, **kwargs):
|
||||
if (x is not None and x < 0) or (y is not None and y < 0):
|
||||
self.negative_coordinate_calls.append((text, x, y))
|
||||
return super().draw_text(text, x, y, *args, **kwargs)
|
||||
|
||||
def draw_image(self, image, x, y, *args, **kwargs):
|
||||
if x < 0 or y < 0:
|
||||
self.negative_coordinate_calls.append(('image', x, y))
|
||||
return super().draw_image(image, x, y, *args, **kwargs)
|
||||
|
||||
# -- declared dimensions (override parent's image-derived properties) --
|
||||
|
||||
|
||||
@@ -73,6 +73,10 @@ class RenderResult:
|
||||
golden_ok: Optional[bool] = None
|
||||
golden_diff_pixels: int = 0
|
||||
golden_max_delta: int = 0
|
||||
# fill / scale-up check (populated only for sizes >= 2x the design size)
|
||||
fill_checked: bool = False
|
||||
fill_ok: Optional[bool] = None # False only in strict mode
|
||||
fill_extent: Optional[Tuple[float, float]] = None # (extent_x, extent_y)
|
||||
|
||||
@property
|
||||
def size_label(self) -> str:
|
||||
@@ -86,6 +90,8 @@ class RenderResult:
|
||||
return False
|
||||
if self.golden_checked and self.golden_ok is False:
|
||||
return False
|
||||
if self.fill_ok is False:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@@ -301,6 +307,74 @@ def compare_to_goldens(results: List[RenderResult], golden_dir: Path,
|
||||
return results
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fill / scale-up check
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Overflow catches content that is too BIG for a panel; nothing catches
|
||||
# content that stays tiny on a panel much larger than the plugin's design
|
||||
# size (e.g. 128x32 content in the corner of a 256x128 renders "green").
|
||||
# These helpers measure how much of the panel the lit content spans so the
|
||||
# harness can flag plugins that don't scale up.
|
||||
|
||||
# A pixel counts as "lit" above this luminance — low enough to catch dim
|
||||
# content, high enough to ignore near-black noise.
|
||||
_LIT_THRESHOLD = 16
|
||||
# Content must span at least this fraction of an axis that is >= 2x the
|
||||
# design size. Lenient on purpose: margins are fine, a tiny corner is not.
|
||||
_MIN_FILL_EXTENT = 0.5
|
||||
|
||||
|
||||
def fill_metrics(image: Image.Image) -> Tuple[float, float, float]:
|
||||
"""Measure lit-content coverage: (extent_x, extent_y, ink_ratio).
|
||||
|
||||
extent_* are the lit bounding box's spans as fractions of the panel;
|
||||
ink_ratio is the fraction of pixels lit (reporting only — sparse pixel
|
||||
fonts legitimately have low ink ratios)."""
|
||||
lit = image.convert("L").point(lambda p: 255 if p > _LIT_THRESHOLD else 0)
|
||||
bbox = lit.getbbox()
|
||||
if bbox is None:
|
||||
return (0.0, 0.0, 0.0)
|
||||
extent_x = (bbox[2] - bbox[0]) / image.width
|
||||
extent_y = (bbox[3] - bbox[1]) / image.height
|
||||
ink = sum(1 for p in lit.getdata() if p) / (image.width * image.height)
|
||||
return (extent_x, extent_y, ink)
|
||||
|
||||
|
||||
def check_scale_up(results: List[RenderResult],
|
||||
design_size: Tuple[int, int] = (128, 32),
|
||||
min_extent: float = _MIN_FILL_EXTENT,
|
||||
strict: bool = False) -> List[RenderResult]:
|
||||
"""Flag renders that leave a big panel mostly empty.
|
||||
|
||||
For each result whose panel is at least 2x the design size on an axis,
|
||||
require the lit content to span >= min_extent of that axis. Mutates the
|
||||
results' fill_* fields. In the default warn-only mode fill_ok is left
|
||||
None (reported, never failing); strict=True sets fill_ok=False, which
|
||||
fails RenderResult.ok — opt in per plugin via harness.json
|
||||
{"fill_check": "strict"} once its adaptive layout is in place.
|
||||
"""
|
||||
design_w, design_h = design_size
|
||||
for r in results:
|
||||
if r.image is None or r.error is not None:
|
||||
continue
|
||||
check_x = r.width >= 2 * design_w
|
||||
check_y = r.height >= 2 * design_h
|
||||
if not (check_x or check_y):
|
||||
continue
|
||||
extent_x, extent_y, _ink = fill_metrics(r.image)
|
||||
r.fill_checked = True
|
||||
r.fill_extent = (round(extent_x, 3), round(extent_y, 3))
|
||||
underfilled = ((check_x and extent_x < min_extent)
|
||||
or (check_y and extent_y < min_extent))
|
||||
if underfilled and strict:
|
||||
r.fill_ok = False
|
||||
elif not underfilled:
|
||||
r.fill_ok = True
|
||||
# warn-only underfill: fill_ok stays None; fill_extent tells the story
|
||||
return results
|
||||
|
||||
|
||||
def write_goldens(results: List[RenderResult], golden_dir: Path) -> int:
|
||||
"""Write each successfully-rendered result to its golden path. Returns count."""
|
||||
written = 0
|
||||
|
||||
@@ -56,7 +56,15 @@ def load_harness_spec(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
|
||||
"config": {...}, # config overrides
|
||||
"mock_data": "fixtures/mock.json", # path (relative to plugin dir) to cache fixtures
|
||||
"freeze_time": "2025-08-01 15:25:00",
|
||||
"skip_update": false
|
||||
"skip_update": false,
|
||||
"fill_check": "warn", # or "strict": underfilled big panels FAIL
|
||||
"variants": [ # extra runs with config overlays and
|
||||
{ # their own golden dirs — e.g. an
|
||||
"name": "adaptive", # opt-in adaptive mode tested beside
|
||||
"config": {"layout_mode": "adaptive"}, # the classic default
|
||||
"golden_dir": "test/golden-adaptive"
|
||||
}
|
||||
]
|
||||
}
|
||||
Returns {} when no harness.json exists.
|
||||
"""
|
||||
@@ -80,3 +88,27 @@ def load_harness_spec(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
|
||||
with open(mock_path, 'r') as mf:
|
||||
spec['mock_data_contents'] = json.load(mf)
|
||||
return spec
|
||||
|
||||
|
||||
def build_full_config(
|
||||
plugin_dir: Union[str, Path],
|
||||
spec: Optional[Dict[str, Any]] = None,
|
||||
cli_config: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Build the config a plugin sees under test.
|
||||
|
||||
Merge order: config_schema.json defaults, then a forced ``enabled: True``,
|
||||
then harness.json's config overlay, then the caller's explicit config --
|
||||
most specific wins. `enabled` is re-asserted *after* the schema defaults
|
||||
so a plugin that reasonably ships `enabled: false` (e.g. a seasonal or
|
||||
opt-in plugin) can't silently make every harness run test "disabled, do
|
||||
nothing" by accident -- callers that genuinely want to test the disabled
|
||||
path can still do so via `cli_config={"enabled": False}`.
|
||||
"""
|
||||
spec = spec or {}
|
||||
config: Dict[str, Any] = {}
|
||||
config.update(load_config_defaults(plugin_dir))
|
||||
config["enabled"] = True
|
||||
config.update(spec.get("config", {}))
|
||||
config.update(cli_config or {})
|
||||
return config
|
||||
|
||||
@@ -71,6 +71,7 @@ class MockCacheManager:
|
||||
self.get_calls = []
|
||||
self.set_calls = []
|
||||
self.delete_calls = []
|
||||
self.get_cached_data_with_strategy_calls = []
|
||||
# Real temp dir for plugins that write/read files under cache_dir.
|
||||
# Registered for cleanup so each mock instance doesn't leak a tmp dir.
|
||||
self.cache_dir = tempfile.mkdtemp(prefix="ledmatrix-mock-cache-")
|
||||
@@ -108,6 +109,24 @@ class MockCacheManager:
|
||||
self.delete_calls.append(key)
|
||||
if key in self._cache:
|
||||
del self._cache[key]
|
||||
|
||||
def get_cached_data_with_strategy(self, key: str, data_type: str = 'default') -> Optional[Any]:
|
||||
"""Mock of CacheManager.get_cached_data_with_strategy (src/cache_manager.py).
|
||||
|
||||
The real method picks a max_age/memory_ttl strategy per data_type
|
||||
(and extends it during market-closed hours for market data) before
|
||||
delegating to get_cached_data(). None of that timing nuance matters
|
||||
for a mock -- plugins under test just need the method to exist and
|
||||
return whatever was cached, so this delegates straight to get().
|
||||
"""
|
||||
self.get_cached_data_with_strategy_calls.append({'key': key, 'data_type': data_type})
|
||||
return self.get(key)
|
||||
|
||||
def save_cache(self, key: str, data: Any) -> None:
|
||||
"""Mock of CacheManager.save_cache (src/cache_manager.py) -- the
|
||||
write-side counterpart to get_cached_data_with_strategy, used by the
|
||||
same real-CacheManager-oriented plugins. Delegates to set()."""
|
||||
self.set(key, data)
|
||||
if key in self._cache_timestamps:
|
||||
del self._cache_timestamps[key]
|
||||
|
||||
@@ -118,6 +137,7 @@ class MockCacheManager:
|
||||
self.get_calls = []
|
||||
self.set_calls = []
|
||||
self.delete_calls = []
|
||||
self.get_cached_data_with_strategy_calls = []
|
||||
|
||||
|
||||
class MockConfigManager:
|
||||
@@ -161,6 +181,13 @@ class MockPluginManager:
|
||||
self.plugin_manifests: Dict[str, Dict] = {}
|
||||
self.get_plugin_calls = []
|
||||
self.get_all_plugins_calls = []
|
||||
# Real FontManager so BasePlugin.layout / draw_fit behave identically
|
||||
# under the harness (it only needs assets/fonts on disk).
|
||||
try:
|
||||
from src.font_manager import FontManager
|
||||
self.font_manager: Optional[Any] = FontManager({})
|
||||
except Exception:
|
||||
self.font_manager = None
|
||||
|
||||
def get_plugin(self, plugin_id: str) -> Optional[Any]:
|
||||
"""Get a plugin instance."""
|
||||
|
||||
@@ -28,6 +28,7 @@ DEFAULT_TEST_SIZES: List[Tuple[int, int]] = [
|
||||
(64, 32), # 1x1 — single panel, the tightest common rectangle
|
||||
(128, 32), # 2x1 — the baseline most plugins are tuned for
|
||||
(64, 64), # 1x2 — stacked, exercises tall-narrow centering
|
||||
(96, 48), # non-64x32-module panel (e.g. Waveshare), off-grid dims
|
||||
(128, 64), # 2x2 — block, icon scaling / vertical centering
|
||||
(256, 32), # 4x1 — long strip, wide horizontal layout
|
||||
(128, 96), # 2x3 — tall, exercises vertical overflow
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
from src.skin_system.skin_base import (
|
||||
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",
|
||||
]
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"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": ""
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
|
After Width: | Height: | Size: 444 B |
|
After Width: | Height: | Size: 446 B |
@@ -0,0 +1,171 @@
|
||||
"""
|
||||
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
|
||||
@@ -0,0 +1,352 @@
|
||||
"""
|
||||
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,
|
||||
)
|
||||
@@ -66,6 +66,10 @@ class RenderPipeline:
|
||||
else display_manager.height
|
||||
)
|
||||
|
||||
# Reusable blank frame for cycle-end pushes (allocated lazily,
|
||||
# re-blacked before each reuse)
|
||||
self._blank_frame = None
|
||||
|
||||
# ScrollHelper for optimized scrolling
|
||||
self.scroll_helper = ScrollHelper(
|
||||
self.display_width,
|
||||
@@ -234,11 +238,19 @@ class RenderPipeline:
|
||||
)
|
||||
# Push blank immediately so the hardware never shows any
|
||||
# post-wrap content while the coordinator recomposes the
|
||||
# next cycle (~100 ms).
|
||||
# next cycle (~100 ms). The blank is allocated once and
|
||||
# reused across cycle wraps (fresh paste each time in case
|
||||
# a consumer drew on the previous one).
|
||||
try:
|
||||
from PIL import Image as _Image
|
||||
blank = _Image.new('RGB', (self.display_width, self.display_height))
|
||||
self.display_manager.image = blank
|
||||
if self._blank_frame is None or self._blank_frame.size != (
|
||||
self.display_width, self.display_height):
|
||||
self._blank_frame = Image.new(
|
||||
'RGB', (self.display_width, self.display_height))
|
||||
else:
|
||||
self._blank_frame.paste(
|
||||
(0, 0, 0),
|
||||
(0, 0, self.display_width, self.display_height))
|
||||
self.display_manager.image = self._blank_frame
|
||||
self.display_manager.update_display()
|
||||
except Exception:
|
||||
logger.exception("Failed to write blank frame to display at cycle end")
|
||||
@@ -297,6 +309,8 @@ class RenderPipeline:
|
||||
Returns True when:
|
||||
- Cycle is complete and we should start fresh
|
||||
- Staging buffer has new content
|
||||
- A plugin currently visible in the scroll has pending updated data
|
||||
(e.g. a live score changed) — standalone (non-sync) mode only
|
||||
"""
|
||||
if self._cycle_complete:
|
||||
return True
|
||||
@@ -314,6 +328,12 @@ class RenderPipeline:
|
||||
if buffer_status['staging_count'] > 0:
|
||||
return True
|
||||
|
||||
# Trigger recompose when pending updates affect visible segments, so
|
||||
# live score/status changes reach the display within a few seconds
|
||||
# instead of waiting for the next full cycle.
|
||||
if self.stream_manager.has_pending_updates_for_visible_segments():
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def hot_swap_content(self) -> bool:
|
||||
|
||||
@@ -2564,11 +2564,35 @@ address=/detectportal.firefox.com/192.168.4.1
|
||||
Returns:
|
||||
True if AP mode state changed, False otherwise
|
||||
"""
|
||||
changed, _status, _ethernet, _ap = self.check_and_manage_ap_mode_with_state()
|
||||
return changed
|
||||
|
||||
def check_and_manage_ap_mode_with_state(self) -> Tuple[bool, WiFiStatus, bool, bool]:
|
||||
"""Like check_and_manage_ap_mode, but also returns the state it
|
||||
observed, so callers (the wifi monitor daemon) don't have to re-run
|
||||
the same nmcli subprocess battery before AND after the check —
|
||||
each status fetch is several process forks.
|
||||
|
||||
Returns:
|
||||
(state_changed, WiFiStatus, ethernet_connected, ap_active_after)
|
||||
"""
|
||||
try:
|
||||
# Get status with retry for more reliable detection
|
||||
status = self._get_wifi_status_with_retry()
|
||||
ethernet_connected = self._is_ethernet_connected()
|
||||
ap_active = self._is_ap_mode_active()
|
||||
changed = self._manage_ap_mode(status, ethernet_connected, ap_active)
|
||||
# State only ever changes via one enable or one disable, so the
|
||||
# post-state is the inverse of the pre-state when changed.
|
||||
ap_after = (not ap_active) if changed else ap_active
|
||||
return changed, status, ethernet_connected, ap_after
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking AP mode: {e}", exc_info=True)
|
||||
return False, WiFiStatus(connected=False), False, False
|
||||
|
||||
def _manage_ap_mode(self, status: WiFiStatus, ethernet_connected: bool, ap_active: bool) -> bool:
|
||||
"""AP-mode decision logic against an already-fetched state snapshot."""
|
||||
try:
|
||||
auto_enable = self.config.get("auto_enable_ap_mode", True) # Default: True (safe due to grace period)
|
||||
|
||||
# Log current state for debugging
|
||||
|
||||
@@ -38,7 +38,11 @@ def mock_cache_manager():
|
||||
mock._memory_cache_timestamps = {}
|
||||
mock.cache_dir = "/tmp/test_cache"
|
||||
|
||||
def mock_get(key: str, max_age: int = 300) -> Optional[Dict]:
|
||||
def mock_get(key: str, max_age: Optional[int] = 300,
|
||||
memory_ttl: Optional[int] = None) -> Optional[Dict]:
|
||||
# Signature mirrors CacheManager.get — keep in sync or callers
|
||||
# passing keyword args (health tracker, resource monitor) break
|
||||
# only in tests, hiding real-API compatibility.
|
||||
return mock._memory_cache.get(key)
|
||||
|
||||
def mock_set(key: str, data: Dict, ttl: Optional[int] = None) -> None:
|
||||
|
||||
@@ -253,3 +253,61 @@ class TestCheckPluginHonorsHarnessJson:
|
||||
)
|
||||
assert captured["freeze_time"] == "2030-01-01 00:00:00"
|
||||
assert captured["config"]["timezone"] == "America/New_York"
|
||||
|
||||
|
||||
class TestBuildFullConfigForcesEnabled:
|
||||
"""Regression: a plugin's own config_schema.json may reasonably default
|
||||
enabled to False (e.g. a seasonal or opt-in plugin) -- march-madness and
|
||||
14 other real plugins do. The harness must still test it as enabled
|
||||
unless a caller explicitly asks otherwise, or every render silently
|
||||
becomes a same-shaped "disabled, do nothing" no-op."""
|
||||
|
||||
def _make_plugin_with_disabled_default(self, tmp_path):
|
||||
pdir = tmp_path / "plugins" / "demo-seasonal"
|
||||
pdir.mkdir(parents=True)
|
||||
(pdir / "manifest.json").write_text(json.dumps({
|
||||
"id": "demo-seasonal", "name": "Demo Seasonal", "version": "1.0.0",
|
||||
"author": "test", "entry_point": "manager.py",
|
||||
"class_name": "DemoSeasonal", "display_modes": ["demo-seasonal"],
|
||||
"compatible_versions": ["*"],
|
||||
}))
|
||||
(pdir / "config_schema.json").write_text(json.dumps({
|
||||
"type": "object",
|
||||
"properties": {"enabled": {"type": "boolean", "default": False}},
|
||||
}))
|
||||
return pdir
|
||||
|
||||
def test_schema_disabled_default_does_not_win(self, tmp_path):
|
||||
from src.plugin_system.testing.loading import build_full_config
|
||||
plugin_dir = self._make_plugin_with_disabled_default(tmp_path)
|
||||
config = build_full_config(plugin_dir)
|
||||
assert config["enabled"] is True
|
||||
|
||||
def test_harness_json_config_can_still_disable(self, tmp_path):
|
||||
from src.plugin_system.testing.loading import build_full_config
|
||||
plugin_dir = self._make_plugin_with_disabled_default(tmp_path)
|
||||
config = build_full_config(plugin_dir, spec={"config": {"enabled": False}})
|
||||
assert config["enabled"] is False
|
||||
|
||||
def test_explicit_cli_config_can_still_disable(self, tmp_path):
|
||||
from src.plugin_system.testing.loading import build_full_config
|
||||
plugin_dir = self._make_plugin_with_disabled_default(tmp_path)
|
||||
config = build_full_config(plugin_dir, cli_config={"enabled": False})
|
||||
assert config["enabled"] is False
|
||||
|
||||
def test_check_one_renders_a_schema_disabled_plugin_as_enabled(self, tmp_path, monkeypatch):
|
||||
"""End-to-end: check_plugin.py's check_one() must not blank-render a
|
||||
plugin just because its own schema defaults enabled to False."""
|
||||
mod = _load_check_plugin_cli()
|
||||
plugin_dir = self._make_plugin_with_disabled_default(tmp_path)
|
||||
captured = {}
|
||||
monkeypatch.setattr(mod, "render_plugin_matrix",
|
||||
lambda **kw: captured.update(kw) or [])
|
||||
monkeypatch.setattr(mod, "compare_to_goldens", lambda *a, **k: [])
|
||||
mod.check_one(
|
||||
plugin_id="demo-seasonal", search_dirs=[str(tmp_path / "plugins")],
|
||||
sizes=None, mock_data={}, config={}, run_update=True,
|
||||
out_dir=None, update_golden=False, golden_dir_override=None,
|
||||
freeze_time=None,
|
||||
)
|
||||
assert captured["config"]["enabled"] is True
|
||||
|
||||
@@ -22,7 +22,7 @@ import pytest
|
||||
from src.plugin_system.testing.harness import (
|
||||
render_plugin_matrix, compare_to_goldens,
|
||||
)
|
||||
from src.plugin_system.testing.loading import load_config_defaults, load_harness_spec
|
||||
from src.plugin_system.testing.loading import build_full_config, load_harness_spec
|
||||
from src.plugin_system.testing.sizes import resolve_test_sizes
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
@@ -81,9 +81,7 @@ def test_plugin_renders_across_sizes_and_screens(plugin_id: str) -> None:
|
||||
plugin_dir = _PLUGINS[plugin_id]
|
||||
spec = load_harness_spec(plugin_dir)
|
||||
|
||||
config = {"enabled": True}
|
||||
config.update(load_config_defaults(plugin_dir))
|
||||
config.update(spec.get("config", {}))
|
||||
config = build_full_config(plugin_dir, spec)
|
||||
|
||||
# Sizes: LEDMATRIX_TEST_SIZES env (test on real hardware) wins, then the
|
||||
# plugin's own harness.json "sizes", else the default representative sample.
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
"""Tests for adaptive image fitting (src/adaptive_images.py) and the
|
||||
LayoutContext image cache."""
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from src.adaptive_images import (
|
||||
RESAMPLE_LANCZOS,
|
||||
RESAMPLE_NEAREST,
|
||||
ImageFitResult,
|
||||
draw_fitted_image,
|
||||
fit_image,
|
||||
)
|
||||
from src.adaptive_layout import LayoutContext, Region
|
||||
from src.font_manager import FontManager
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def font_manager():
|
||||
return FontManager({})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ctx(font_manager):
|
||||
return LayoutContext(128, 32, font_manager)
|
||||
|
||||
|
||||
def _solid(w, h, color=(255, 0, 0, 255)):
|
||||
return Image.new("RGBA", (w, h), color)
|
||||
|
||||
|
||||
def _padded_logo(ink_w=10, ink_h=10, pad=10):
|
||||
"""Transparent canvas with a solid ink block in the middle — models a
|
||||
logo shipped with generous transparent padding."""
|
||||
img = Image.new("RGBA", (ink_w + 2 * pad, ink_h + 2 * pad), (0, 0, 0, 0))
|
||||
img.paste(_solid(ink_w, ink_h), (pad, pad))
|
||||
return img
|
||||
|
||||
|
||||
class TestFitModes:
|
||||
def test_contain_letterboxes_and_upscales(self):
|
||||
fit = fit_image(_solid(10, 5), (40, 40))
|
||||
assert (fit.width, fit.height) == (40, 20) # aspect preserved
|
||||
assert fit.scale == 4.0
|
||||
|
||||
def test_contain_no_upscale(self):
|
||||
fit = fit_image(_solid(10, 5), (40, 40), upscale=False)
|
||||
assert (fit.width, fit.height) == (10, 5)
|
||||
assert fit.scale == 1.0
|
||||
|
||||
def test_cover_fills_and_crops(self):
|
||||
fit = fit_image(_solid(10, 20), (40, 40), mode="cover")
|
||||
assert (fit.width, fit.height) == (40, 40)
|
||||
|
||||
def test_cover_top_anchor(self):
|
||||
# top half red, bottom half blue; cover-crop a wide box with top anchor
|
||||
img = Image.new("RGBA", (20, 40), (0, 0, 255, 255))
|
||||
img.paste(_solid(20, 20, (255, 0, 0, 255)), (0, 0))
|
||||
fit = fit_image(img, (20, 20), mode="cover", anchor="top")
|
||||
assert fit.image.getpixel((10, 5))[:3] == (255, 0, 0) # kept the top
|
||||
|
||||
def test_fill_height_matches_box_height(self):
|
||||
fit = fit_image(_solid(10, 10), (64, 32), mode="fill_height")
|
||||
assert fit.height == 32 and fit.width == 32
|
||||
|
||||
def test_fill_height_capped_by_width(self):
|
||||
# very wide source: height-fill would overflow the box width
|
||||
fit = fit_image(_solid(100, 10), (40, 32), mode="fill_height")
|
||||
assert fit.width <= 40
|
||||
|
||||
def test_stretch_exact(self):
|
||||
fit = fit_image(_solid(3, 7), (25, 13), mode="stretch")
|
||||
assert (fit.width, fit.height) == (25, 13)
|
||||
|
||||
def test_crop_to_ink(self):
|
||||
fit = fit_image(_padded_logo(), (30, 30), crop_to_ink=True)
|
||||
# 10x10 ink upscaled to fill 30x30 (padding would have kept it small)
|
||||
assert (fit.width, fit.height) == (30, 30)
|
||||
no_crop = fit_image(_padded_logo(), (30, 30), crop_to_ink=False)
|
||||
assert no_crop.width == 30 # whole padded canvas scaled instead
|
||||
|
||||
def test_fully_transparent_source(self):
|
||||
img = Image.new("RGBA", (10, 10), (0, 0, 0, 0))
|
||||
fit = fit_image(img, (20, 20), crop_to_ink=True)
|
||||
assert fit.is_empty
|
||||
|
||||
def test_degenerate_box(self):
|
||||
assert fit_image(_solid(10, 10), (0, 20)).is_empty
|
||||
assert fit_image(_solid(10, 10), Region(0, 0, 20, 0)).is_empty
|
||||
|
||||
def test_output_always_rgba(self):
|
||||
rgb = Image.new("RGB", (10, 10), (1, 2, 3))
|
||||
assert fit_image(rgb, (20, 20)).image.mode == "RGBA"
|
||||
|
||||
def test_nearest_keeps_hard_edges(self):
|
||||
# 2x2 checker scaled 8x: NEAREST keeps pure colors, LANCZOS blends
|
||||
img = Image.new("RGBA", (2, 2), (0, 0, 0, 255))
|
||||
img.putpixel((0, 0), (255, 255, 255, 255))
|
||||
near = fit_image(img, (16, 16), mode="stretch", resample=RESAMPLE_NEAREST)
|
||||
colors = {near.image.getpixel((x, y))[:3] for x in range(16) for y in range(16)}
|
||||
assert colors == {(255, 255, 255), (0, 0, 0)}
|
||||
|
||||
def test_unknown_mode_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
fit_image(_solid(4, 4), (8, 8), mode="tile")
|
||||
|
||||
|
||||
class TestDrawFittedImage:
|
||||
class _DM:
|
||||
def __init__(self, w=64, h=32):
|
||||
self.image = Image.new("RGB", (w, h), (0, 0, 0))
|
||||
|
||||
def test_pastes_aligned_in_region(self):
|
||||
dm = self._DM()
|
||||
box = Region(10, 4, 20, 20)
|
||||
fit = fit_image(_solid(10, 10), box)
|
||||
xy = draw_fitted_image(dm, fit, box)
|
||||
assert xy == box.align_xy(fit.width, fit.height)
|
||||
assert dm.image.getpixel((xy[0] + 1, xy[1] + 1)) == (255, 0, 0)
|
||||
|
||||
def test_offset_translates(self):
|
||||
dm = self._DM()
|
||||
box = Region(0, 0, 20, 20)
|
||||
fit = fit_image(_solid(10, 10), box)
|
||||
x, y = draw_fitted_image(dm, fit, box, align="left", valign="top",
|
||||
offset=(3, 5))
|
||||
assert (x, y) == (3, 5)
|
||||
|
||||
def test_empty_fit_noops(self):
|
||||
dm = self._DM()
|
||||
fit = fit_image(_solid(10, 10), (0, 0))
|
||||
assert draw_fitted_image(dm, fit, Region(0, 0, 10, 10)) is None
|
||||
|
||||
|
||||
class TestContextImageCache:
|
||||
def test_size_keyed_hit_and_miss(self, ctx):
|
||||
img = _solid(10, 10)
|
||||
a = ctx.fit_image(img, (20, 20), cache_key="logo:A")
|
||||
assert ctx.fit_image(img, (20, 20), cache_key="logo:A") is a
|
||||
b = ctx.fit_image(img, (30, 30), cache_key="logo:A")
|
||||
assert b is not a and b.width == 30 # different box size = new entry
|
||||
|
||||
def test_id_keyed_default(self, ctx):
|
||||
img = _solid(10, 10)
|
||||
a = ctx.fit_image(img, (20, 20))
|
||||
assert ctx.fit_image(img, (20, 20)) is a
|
||||
|
||||
def test_id_safety_pins_source(self, ctx):
|
||||
# id()-keyed entries must pin the source image so a recycled id
|
||||
# can't alias a dead image's cache entry.
|
||||
img = _solid(10, 10)
|
||||
ctx.fit_image(img, (20, 20))
|
||||
pinned = [entry[1] for entry in ctx._image_cache.values()]
|
||||
assert img in pinned
|
||||
|
||||
def test_cache_key_entries_do_not_pin(self, ctx):
|
||||
img = _solid(10, 10)
|
||||
ctx.fit_image(img, (20, 20), cache_key="logo:X")
|
||||
key = next(k for k in ctx._image_cache if k[1] == "logo:X")
|
||||
assert ctx._image_cache[key][1] is None
|
||||
|
||||
def test_lru_eviction(self, ctx):
|
||||
for i in range(ctx._IMAGE_CACHE_MAX + 5):
|
||||
ctx.fit_image(_solid(4, 4), (8, 8), cache_key=f"k{i}")
|
||||
assert len(ctx._image_cache) == ctx._IMAGE_CACHE_MAX
|
||||
assert not any(k[1] == "k0" for k in ctx._image_cache) # oldest evicted
|
||||
|
||||
def test_clear_cache_clears_images(self, ctx):
|
||||
ctx.fit_image(_solid(4, 4), (8, 8), cache_key="k")
|
||||
ctx.clear_cache()
|
||||
assert len(ctx._image_cache) == 0
|
||||
|
||||
|
||||
class TestBasePluginDrawImage:
|
||||
def test_draw_image_end_to_end(self):
|
||||
from src.plugin_system.base_plugin import BasePlugin
|
||||
from src.plugin_system.testing.mocks import (
|
||||
MockCacheManager, MockDisplayManager, MockPluginManager,
|
||||
)
|
||||
|
||||
class _P(BasePlugin):
|
||||
def update(self):
|
||||
pass
|
||||
|
||||
def display(self, force_clear=False):
|
||||
pass
|
||||
|
||||
plugin = _P("t", {}, MockDisplayManager(64, 32),
|
||||
MockCacheManager(), MockPluginManager())
|
||||
logo = _padded_logo()
|
||||
box = plugin.layout.bounds.left_col(32)
|
||||
ifit = plugin.draw_image(logo, box, mode="fill_height",
|
||||
crop_to_ink=True, cache_key="logo:T")
|
||||
assert ifit.height == 32
|
||||
# pasted onto the mock's canvas
|
||||
assert plugin.display_manager.image.getpixel((16, 16)) != (0, 0, 0)
|
||||
|
||||
|
||||
class TestResultIndependence:
|
||||
def test_same_size_fit_never_aliases_the_source(self):
|
||||
"""LayoutContext caches ImageFitResults — an aliased image would let
|
||||
later mutations of the source corrupt cached fits (or vice versa)."""
|
||||
from PIL import ImageDraw
|
||||
src = Image.new("RGBA", (20, 20), (255, 0, 0, 255))
|
||||
fit = fit_image(src, (20, 20))
|
||||
assert fit.image is not src
|
||||
ImageDraw.Draw(src).rectangle([0, 0, 19, 19], fill=(0, 255, 0, 255))
|
||||
assert fit.image.getpixel((5, 5)) == (255, 0, 0, 255)
|
||||
@@ -0,0 +1,454 @@
|
||||
"""Tests for the adaptive layout system (src/adaptive_layout.py)."""
|
||||
|
||||
import pytest
|
||||
|
||||
from src.adaptive_layout import (
|
||||
DEFAULT_DESIGN_SIZE,
|
||||
LADDER_ARCADE,
|
||||
LADDER_GRID,
|
||||
LayoutContext,
|
||||
Region,
|
||||
draw_fitted_text,
|
||||
measure_font_crispness,
|
||||
measure_ink,
|
||||
media_row,
|
||||
scoreboard_regions,
|
||||
)
|
||||
from src.plugin_system.testing.sizes import DEFAULT_TEST_SIZES
|
||||
from src.font_manager import FontManager
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def font_manager():
|
||||
"""Real FontManager over assets/fonts — the ladders depend on it."""
|
||||
return FontManager({})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ctx(font_manager):
|
||||
return LayoutContext(128, 32, font_manager)
|
||||
|
||||
|
||||
class TestRegion:
|
||||
"""Pure integer rect algebra."""
|
||||
|
||||
def test_bands_partition_without_overlap(self):
|
||||
r = Region(0, 0, 128, 32)
|
||||
top = r.top_band(7)
|
||||
bottom = r.bottom_band(7)
|
||||
middle = r.middle(7, 7)
|
||||
assert top.bottom == middle.y
|
||||
assert middle.bottom == bottom.y
|
||||
assert top.h + middle.h + bottom.h == r.h
|
||||
|
||||
def test_bands_clamp_on_short_panel(self):
|
||||
# The classic failure: y=1 top band and y=height-7 bottom band
|
||||
# overlapping on a short panel. Bands can't exceed the region.
|
||||
r = Region(0, 0, 32, 8)
|
||||
assert r.top_band(16).h == 8
|
||||
assert r.bottom_band(16).h == 8
|
||||
assert r.middle(8, 8).h == 0 # degenerate, never negative
|
||||
|
||||
def test_split_v_weights_sum_to_height(self):
|
||||
r = Region(0, 0, 64, 33)
|
||||
rows = r.split_v(3, 1, 1, gap=1)
|
||||
assert len(rows) == 3
|
||||
assert sum(row.h for row in rows) == 33 - 2 # two 1px gaps
|
||||
assert rows[0].h > rows[1].h
|
||||
assert rows[-1].bottom == r.bottom
|
||||
|
||||
def test_split_h_columns_advance(self):
|
||||
r = Region(0, 0, 100, 32)
|
||||
cols = r.split_h(1, 1, gap=2)
|
||||
assert cols[0].right + 2 == cols[1].x
|
||||
assert cols[1].right == r.right
|
||||
|
||||
def test_degenerate_sizes_never_negative(self):
|
||||
for w, h in [(8, 8), (32, 16), (1, 1)]:
|
||||
r = Region(0, 0, w, h).inset(4)
|
||||
assert r.w >= 0 and r.h >= 0
|
||||
for sub in r.split_v(1, 1) + r.split_h(1, 1, gap=3):
|
||||
assert sub.w >= 0 and sub.h >= 0
|
||||
|
||||
def test_align_xy(self):
|
||||
r = Region(10, 10, 100, 20)
|
||||
assert r.align_xy(20, 10, "left", "top") == (10, 10)
|
||||
assert r.align_xy(20, 10, "right", "bottom") == (90, 20)
|
||||
assert r.align_xy(20, 10) == (50, 15)
|
||||
|
||||
def test_left_right_cols(self):
|
||||
r = Region(0, 0, 128, 32)
|
||||
assert r.left_col(32) == Region(0, 0, 32, 32)
|
||||
assert r.right_col(32) == Region(96, 0, 32, 32)
|
||||
|
||||
def test_offset_translates_without_resizing(self):
|
||||
r = Region(5, 5, 20, 10).offset(3, -2)
|
||||
assert r == Region(8, 3, 20, 10)
|
||||
|
||||
|
||||
class TestScoreboardRegions:
|
||||
@pytest.mark.parametrize("w,h", DEFAULT_TEST_SIZES + [(8, 8)])
|
||||
def test_invariants_at_all_sizes(self, w, h):
|
||||
regs = scoreboard_regions(Region(0, 0, w, h))
|
||||
assert regs.logo_slot <= min(h, w // 2)
|
||||
# slots hug the edges and never overlap the center column
|
||||
assert regs.away_slot.x == 0 and regs.home_slot.right == w
|
||||
assert regs.away_slot.right <= regs.center_col.x or regs.center_col.w == 0
|
||||
assert regs.center_col.right <= regs.home_slot.x or regs.center_col.w == 0
|
||||
# bands stack inside the center column without overlap
|
||||
assert regs.status_band.bottom <= regs.score_area.y or regs.score_area.h == 0
|
||||
assert regs.score_area.bottom <= regs.detail_band.y or regs.score_area.h == 0
|
||||
# everything within bounds, nothing negative
|
||||
for reg in (regs.away_slot, regs.home_slot, regs.center_col,
|
||||
regs.status_band, regs.score_area, regs.detail_band,
|
||||
regs.bottom_left, regs.bottom_right):
|
||||
assert reg.w >= 0 and reg.h >= 0
|
||||
assert reg.x >= 0 and reg.y >= 0
|
||||
assert reg.right <= w and reg.bottom <= h
|
||||
|
||||
@pytest.mark.parametrize("w,h", [(96, 48), (128, 64), (256, 128), (64, 32), (128, 96)])
|
||||
def test_2to1_aspect_gets_a_center_reserve(self, w, h):
|
||||
"""These sizes are all <= 2:1 aspect, where the raw min(h, w//2)
|
||||
formula claims the entire width for logos and leaves zero pixels
|
||||
for a center column — the bug this reserve exists to fix."""
|
||||
regs = scoreboard_regions(Region(0, 0, w, h))
|
||||
assert regs.center_col.w >= int(w * 0.15) - 1 # -1 for int() rounding
|
||||
|
||||
@pytest.mark.parametrize("w,h", [(128, 32), (192, 48), (256, 32)])
|
||||
def test_wide_panels_unaffected_by_center_reserve(self, w, h):
|
||||
"""Wide (>= ~4:1) panels already have height as the tighter
|
||||
constraint, so the center reserve must be a no-op there — the
|
||||
design-size baseline's proportions shouldn't shift."""
|
||||
regs = scoreboard_regions(Region(0, 0, w, h))
|
||||
assert regs.logo_slot == min(h, w // 2)
|
||||
|
||||
def test_center_reserve_fraction_is_configurable(self):
|
||||
# min_center_design_px=0 isolates the fraction term (otherwise the
|
||||
# scaled absolute floor can dominate and mask a fraction change).
|
||||
regs_default = scoreboard_regions(Region(0, 0, 128, 64), min_center_design_px=0)
|
||||
regs_wider = scoreboard_regions(Region(0, 0, 128, 64), min_center_fraction=0.5,
|
||||
min_center_design_px=0)
|
||||
assert regs_wider.center_col.w > regs_default.center_col.w
|
||||
assert regs_wider.logo_slot < regs_default.logo_slot
|
||||
|
||||
def test_score_bleed_extends_past_center_col(self):
|
||||
regs = scoreboard_regions(Region(0, 0, 128, 64), score_bleed_fraction=0.5)
|
||||
assert regs.score_area.w > regs.center_col.w
|
||||
assert regs.score_area.x < regs.center_col.x
|
||||
assert regs.score_area.right > regs.center_col.right
|
||||
|
||||
def test_score_bleed_zero_matches_center_col(self):
|
||||
regs = scoreboard_regions(Region(0, 0, 128, 64), score_bleed_fraction=0.0)
|
||||
assert regs.score_area.w == regs.center_col.w
|
||||
assert regs.score_area.x == regs.center_col.x
|
||||
|
||||
@pytest.mark.parametrize("w,h", [(64, 32), (96, 48), (128, 64), (256, 128), (128, 96)])
|
||||
def test_score_never_needs_ellipsis_for_a_short_score(self, w, h, font_manager):
|
||||
"""The concrete regression this whole reserve/bleed system exists to
|
||||
prevent: a real game score like '17-21' must always render in full,
|
||||
never truncated, at every 2:1-or-tighter aspect ratio in the sample."""
|
||||
ctx = LayoutContext(w, h, font_manager)
|
||||
regs = scoreboard_regions(Region(0, 0, w, h), ctx=ctx)
|
||||
height_scale = h / 32.0
|
||||
fit = ctx.fit_text_proportional("17-21", regs.score_area, base_size_px=10,
|
||||
ladder=LADDER_ARCADE, scale=height_scale)
|
||||
assert fit.text == "17-21"
|
||||
assert fit.fits
|
||||
|
||||
def test_ctx_scales_band_heights(self, font_manager):
|
||||
small = scoreboard_regions(Region(0, 0, 128, 32),
|
||||
ctx=LayoutContext(128, 32, font_manager))
|
||||
big = scoreboard_regions(Region(0, 0, 256, 64),
|
||||
ctx=LayoutContext(256, 64, font_manager))
|
||||
assert big.status_band.h > small.status_band.h
|
||||
|
||||
def test_works_on_offset_card_region(self):
|
||||
card = Region(10, 4, 100, 24)
|
||||
regs = scoreboard_regions(card)
|
||||
assert regs.away_slot.x == 10
|
||||
assert regs.home_slot.right == card.right
|
||||
|
||||
|
||||
class TestMediaRow:
|
||||
def test_square_art_plus_body(self):
|
||||
row = media_row(Region(0, 0, 128, 32))
|
||||
assert row.art == Region(0, 0, 32, 32)
|
||||
assert row.body.x == 32 + 2 and row.body.right == 128
|
||||
|
||||
def test_non_square(self):
|
||||
row = media_row(Region(0, 0, 100, 20), square=False, gap=4)
|
||||
assert row.art.w == 50
|
||||
assert row.body.x == 54
|
||||
|
||||
def test_narrow_panel_clamps(self):
|
||||
row = media_row(Region(0, 0, 16, 32))
|
||||
assert row.art.w == 16 and row.body.w == 0
|
||||
|
||||
|
||||
class TestLayoutContext:
|
||||
def test_tiers(self, font_manager):
|
||||
assert LayoutContext(128, 32, font_manager).tier == "sm"
|
||||
assert LayoutContext(96, 48, font_manager).tier == "md"
|
||||
assert LayoutContext(128, 64, font_manager).tier == "lg"
|
||||
assert LayoutContext(64, 16, font_manager).tier == "xs"
|
||||
assert LayoutContext(256, 128, font_manager).tier == "xl"
|
||||
|
||||
def test_wide_short_flag(self, font_manager):
|
||||
assert LayoutContext(128, 32, font_manager).is_wide_short
|
||||
assert not LayoutContext(128, 64, font_manager).is_wide_short
|
||||
|
||||
def test_scale_against_design_size(self, font_manager):
|
||||
assert LayoutContext(128, 32, font_manager).scale == 1.0
|
||||
assert LayoutContext(256, 64, font_manager).scale == 2.0
|
||||
# min() of the two axes: don't overscale the constrained one
|
||||
assert LayoutContext(256, 32, font_manager).scale == 1.0
|
||||
assert DEFAULT_DESIGN_SIZE == (128, 32)
|
||||
|
||||
def test_px_scales_and_clamps(self, font_manager):
|
||||
big = LayoutContext(256, 64, font_manager)
|
||||
assert big.px(4) == 8
|
||||
assert big.px(4, maximum=6) == 6
|
||||
tiny = LayoutContext(32, 16, font_manager)
|
||||
assert tiny.px(4, minimum=2) == 2
|
||||
|
||||
def test_by_tier_nearest_at_or_below(self, font_manager):
|
||||
mapping = {"sm": 10, "lg": 18}
|
||||
assert LayoutContext(128, 32, font_manager).by_tier(mapping) == 10
|
||||
assert LayoutContext(96, 48, font_manager).by_tier(mapping) == 10 # md -> sm
|
||||
assert LayoutContext(128, 64, font_manager).by_tier(mapping) == 18
|
||||
assert LayoutContext(256, 128, font_manager).by_tier(mapping) == 18 # xl -> lg
|
||||
# nothing at-or-below: fall forward to smallest defined above
|
||||
assert LayoutContext(64, 16, font_manager).by_tier(mapping) == 10
|
||||
|
||||
|
||||
class TestFontFitting:
|
||||
def test_ladder_monotonic(self, font_manager):
|
||||
"""Each ladder rung must render no taller than the one before it."""
|
||||
for ladder in (LADDER_GRID, LADDER_ARCADE):
|
||||
heights = []
|
||||
for step in ladder:
|
||||
font = font_manager.get_font(step.family, step.size_px)
|
||||
heights.append(measure_ink("Ay0", font)[1])
|
||||
assert heights == sorted(heights, reverse=True), (
|
||||
f"ladder not monotonically shrinking: {heights}")
|
||||
|
||||
def test_ladder_grid_is_crisp(self, font_manager):
|
||||
"""LADDER_GRID's BDF fonts are real bitmaps — always 0% antialiased."""
|
||||
for step in LADDER_GRID:
|
||||
font = font_manager.get_font(step.family, step.size_px)
|
||||
assert measure_font_crispness(font, "Ay0") == 0.0
|
||||
|
||||
def test_ladder_arcade_is_crisp(self, font_manager):
|
||||
"""PressStart2P only rasterizes without antialiasing at exact
|
||||
multiples of its 8px design grid — every LADDER_ARCADE rung must
|
||||
land on one."""
|
||||
for step in LADDER_ARCADE:
|
||||
assert step.size_px % 8 == 0, f"{step} is not a multiple of 8"
|
||||
font = font_manager.get_font(step.family, step.size_px)
|
||||
assert measure_font_crispness(font, "17-21") == 0.0
|
||||
|
||||
def test_crispness_catches_a_bad_size(self, font_manager):
|
||||
"""Sanity check the measurement itself: a known-bad size for a
|
||||
pixel-grid font must NOT read as crisp."""
|
||||
font = font_manager.get_font("press_start", 10) # not a multiple of 8
|
||||
assert measure_font_crispness(font, "17-21") > 0.1
|
||||
|
||||
def test_fit_text_grows_on_taller_panel(self, font_manager):
|
||||
small = LayoutContext(64, 32, font_manager)
|
||||
large = LayoutContext(128, 64, font_manager)
|
||||
text = "12:34"
|
||||
fit_small = small.fit_text(text, small.bounds, ladder=LADDER_ARCADE)
|
||||
fit_large = large.fit_text(text, large.bounds, ladder=LADDER_ARCADE)
|
||||
assert fit_small.fits and fit_large.fits
|
||||
assert fit_large.size_px > fit_small.size_px
|
||||
|
||||
def test_fit_text_fits_the_box(self, ctx):
|
||||
box = ctx.bounds.inset(1)
|
||||
fit = ctx.fit_text("HELLO WORLD", box)
|
||||
assert fit.fits
|
||||
assert fit.width <= box.w and fit.height <= box.h
|
||||
|
||||
def test_fit_text_ellipsizes_overlong_text(self, font_manager):
|
||||
tiny = LayoutContext(32, 16, font_manager)
|
||||
fit = tiny.fit_text("SUPERCALIFRAGILISTIC", tiny.bounds)
|
||||
assert fit.text != "SUPERCALIFRAGILISTIC"
|
||||
assert fit.text.endswith("…")
|
||||
assert fit.width <= tiny.bounds.w
|
||||
|
||||
def test_fit_text_cached(self, ctx):
|
||||
first = ctx.fit_text("CACHED", ctx.bounds)
|
||||
second = ctx.fit_text("CACHED", ctx.bounds)
|
||||
assert first is second
|
||||
ctx.clear_cache()
|
||||
assert ctx.fit_text("CACHED", ctx.bounds) is not first
|
||||
|
||||
def test_fit_text_proportional_tracks_design_scale(self, font_manager):
|
||||
# design size 128x32, base_size_px=10 (a typical classic score size):
|
||||
# at 2x scale the target is 20px -> nearest LADDER_ARCADE rung <= 20
|
||||
# is 16px, not the largest that merely fits the box (32).
|
||||
ctx = LayoutContext(256, 64, font_manager) # scale = min(2,2) = 2
|
||||
fit = ctx.fit_text_proportional("17-21", ctx.bounds, base_size_px=10,
|
||||
ladder=LADDER_ARCADE)
|
||||
assert fit.size_px == 16
|
||||
|
||||
def test_fit_text_proportional_does_not_exceed_max_fit(self, ctx):
|
||||
# at scale=1 (128x32, the design size itself) the target equals
|
||||
# base_size_px, so proportional should never pick something LARGER
|
||||
# than plain fit_text would for the same box.
|
||||
prop = ctx.fit_text_proportional("17-21", ctx.bounds, base_size_px=10,
|
||||
ladder=LADDER_ARCADE)
|
||||
maxed = ctx.fit_text("17-21", ctx.bounds, ladder=LADDER_ARCADE)
|
||||
assert prop.size_px <= maxed.size_px
|
||||
|
||||
def test_fit_text_proportional_floors_at_smallest_rung(self, font_manager):
|
||||
# scale so small the target is below every rung -> use the smallest
|
||||
# rung as a floor rather than refusing to render anything.
|
||||
ctx = LayoutContext(32, 8, font_manager) # scale = min(32/128, 8/32) = 0.25
|
||||
fit = ctx.fit_text_proportional("HI", ctx.bounds, base_size_px=10,
|
||||
ladder=LADDER_ARCADE)
|
||||
assert fit.size_px == min(s.size_px for s in LADDER_ARCADE)
|
||||
|
||||
def test_fit_text_proportional_falls_through_when_target_rung_overflows(self, font_manager):
|
||||
# a long string at the target rung might not fit a narrow box even
|
||||
# though the target size is "correct" -- must fall through to a
|
||||
# smaller rung exactly like fit_text does, not just refuse to fit.
|
||||
ctx = LayoutContext(256, 64, font_manager)
|
||||
narrow_box = Region(0, 0, 40, 64)
|
||||
fit = ctx.fit_text_proportional("A REALLY LONG STRING HERE", narrow_box,
|
||||
base_size_px=10, ladder=LADDER_ARCADE)
|
||||
assert fit.fits or fit.text.endswith("…")
|
||||
|
||||
def test_fit_text_proportional_cached(self, ctx):
|
||||
first = ctx.fit_text_proportional("X", ctx.bounds, base_size_px=10)
|
||||
second = ctx.fit_text_proportional("X", ctx.bounds, base_size_px=10)
|
||||
assert first is second
|
||||
|
||||
def test_fit_text_proportional_scale_override(self, font_manager):
|
||||
# 128x64 vs design 128x32: self.scale (min of both axes) is 1.0
|
||||
# since width didn't grow, but a caller whose composition scales by
|
||||
# HEIGHT alone (e.g. logo_slot = min(h, w//2)) should be able to
|
||||
# override the reference scale so text grows with it too.
|
||||
ctx = LayoutContext(128, 64, font_manager)
|
||||
assert ctx.scale == 1.0
|
||||
default_fit = ctx.fit_text_proportional("17-21", ctx.bounds, base_size_px=10,
|
||||
ladder=LADDER_ARCADE)
|
||||
height_scale = 64 / 32 # matches design height
|
||||
scaled_fit = ctx.fit_text_proportional("17-21", ctx.bounds, base_size_px=10,
|
||||
ladder=LADDER_ARCADE, scale=height_scale)
|
||||
assert scaled_fit.size_px > default_fit.size_px
|
||||
|
||||
def test_fit_lines_stacks_within_height(self, ctx):
|
||||
box = ctx.bounds
|
||||
lines = ["LINE ONE", "LINE TWO", "LINE THREE"]
|
||||
fit = ctx.fit_lines(lines, box, spacing=1)
|
||||
assert fit.fits
|
||||
assert 3 * fit.line_height + 2 <= box.h
|
||||
|
||||
def test_font_for_rows(self, ctx):
|
||||
fit = ctx.font_for_rows(4, 32)
|
||||
assert fit.fits
|
||||
assert 4 * fit.line_height <= 32
|
||||
|
||||
def test_ellipsize_returns_original_when_it_fits(self, ctx):
|
||||
font = ctx.font_manager.get_font("4x6", 6)
|
||||
assert ctx.ellipsize("HI", font, 1000) == "HI"
|
||||
|
||||
|
||||
class TestDrawFittedText:
|
||||
def test_draws_within_region(self, ctx):
|
||||
calls = []
|
||||
|
||||
class _DM:
|
||||
def draw_text(self, text, x=None, y=None, color=None, font=None):
|
||||
calls.append((text, x, y))
|
||||
|
||||
box = Region(10, 4, 100, 24)
|
||||
fit = ctx.fit_text("SCORE", box)
|
||||
draw_fitted_text(_DM(), fit, box)
|
||||
text, x, y = calls[0]
|
||||
assert text == "SCORE"
|
||||
assert box.x <= x <= box.right - fit.width
|
||||
# the ink (y + y_offset .. + height) must land inside the box
|
||||
assert box.y <= y + fit.y_offset
|
||||
assert y + fit.y_offset + fit.height <= box.bottom
|
||||
|
||||
|
||||
class TestBasePluginIntegration:
|
||||
def test_layout_property_and_draw_fit(self):
|
||||
from src.plugin_system.base_plugin import BasePlugin
|
||||
from src.plugin_system.testing.mocks import (
|
||||
MockCacheManager, MockDisplayManager, MockPluginManager,
|
||||
)
|
||||
|
||||
class _Plugin(BasePlugin):
|
||||
def update(self):
|
||||
pass
|
||||
|
||||
def display(self, force_clear=False):
|
||||
pass
|
||||
|
||||
plugin = _Plugin("test-plugin", {}, MockDisplayManager(96, 48),
|
||||
MockCacheManager(), MockPluginManager())
|
||||
assert (plugin.layout.width, plugin.layout.height) == (96, 48)
|
||||
assert plugin.layout is plugin.layout # cached
|
||||
fit = plugin.draw_fit("HELLO", plugin.layout.bounds.inset(1))
|
||||
assert fit.fits
|
||||
assert plugin.display_manager.draw_calls # actually drew
|
||||
|
||||
def test_layout_rebuilds_on_size_change(self):
|
||||
from src.plugin_system.base_plugin import BasePlugin
|
||||
from src.plugin_system.testing.mocks import (
|
||||
MockCacheManager, MockDisplayManager, MockPluginManager,
|
||||
)
|
||||
|
||||
class _Plugin(BasePlugin):
|
||||
def update(self):
|
||||
pass
|
||||
|
||||
def display(self, force_clear=False):
|
||||
pass
|
||||
|
||||
dm = MockDisplayManager(128, 32)
|
||||
plugin = _Plugin("test-plugin", {}, dm,
|
||||
MockCacheManager(), MockPluginManager())
|
||||
assert plugin.layout.tier == "sm"
|
||||
dm.width, dm.height = 128, 64
|
||||
assert plugin.layout.tier == "lg"
|
||||
|
||||
def test_design_size_from_manifest(self):
|
||||
from src.plugin_system.base_plugin import BasePlugin
|
||||
from src.plugin_system.testing.mocks import (
|
||||
MockCacheManager, MockDisplayManager, MockPluginManager,
|
||||
)
|
||||
|
||||
class _Plugin(BasePlugin):
|
||||
def update(self):
|
||||
pass
|
||||
|
||||
def display(self, force_clear=False):
|
||||
pass
|
||||
|
||||
pm = MockPluginManager()
|
||||
pm.plugin_manifests["test-plugin"] = {
|
||||
"display": {"design_size": {"width": 64, "height": 32}}
|
||||
}
|
||||
plugin = _Plugin("test-plugin", {}, MockDisplayManager(128, 64),
|
||||
MockCacheManager(), pm)
|
||||
assert plugin.layout.design_size == (64, 32)
|
||||
assert plugin.layout.scale == 2.0
|
||||
|
||||
|
||||
class TestFitCacheBound:
|
||||
def test_fit_cache_is_lru_bounded(self, ctx):
|
||||
"""A plugin fitting changing text (live game clock, ticker) on a
|
||||
24/7 service must not grow the fit cache without bound."""
|
||||
for i in range(ctx._FIT_CACHE_MAX + 100):
|
||||
ctx.fit_text(f"tick {i}", Region(0, 0, 100, 20))
|
||||
assert len(ctx._fit_cache) <= ctx._FIT_CACHE_MAX
|
||||
|
||||
def test_lru_keeps_recent_entries_hot(self, ctx):
|
||||
hot = ctx.fit_text("stay hot", Region(0, 0, 100, 20))
|
||||
for i in range(ctx._FIT_CACHE_MAX - 1):
|
||||
ctx.fit_text(f"cold {i}", Region(0, 0, 100, 20))
|
||||
ctx.fit_text("stay hot", Region(0, 0, 100, 20)) # keep touching it
|
||||
assert ctx.fit_text("stay hot", Region(0, 0, 100, 20)) is hot
|
||||
@@ -0,0 +1,270 @@
|
||||
"""Tests for asynchronous plugin updates (plugin_manager background worker).
|
||||
|
||||
The invariants that keep this change safe:
|
||||
1. run_scheduled_updates returns immediately — a slow update() can never
|
||||
again freeze the render loop (the original defect: 30s scroll freezes).
|
||||
2. A plugin's update() and display() are NEVER concurrent — the per-plugin
|
||||
lock makes the old implicit no-overlap guarantee explicit, and it stays
|
||||
held through PluginExecutor's own timeout: a lingering, still-running
|
||||
update() keeps the lock even after PluginExecutor gives up waiting on it.
|
||||
3. Failure/timeout bookkeeping is unchanged (same executor, same
|
||||
_record_update_failure path, same last-update stamping).
|
||||
4. The kill switch (plugin_system.synchronous_updates) restores the
|
||||
inline path exactly.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from src.plugin_system.plugin_manager import PluginManager # noqa: E402
|
||||
from src.plugin_system.plugin_state import PluginState # noqa: E402
|
||||
|
||||
|
||||
class SlowPlugin:
|
||||
"""Fake plugin whose update() sleeps and records overlap violations."""
|
||||
|
||||
def __init__(self, update_seconds=0.5):
|
||||
self.enabled = True
|
||||
self.update_seconds = update_seconds
|
||||
self.update_calls = 0
|
||||
self.display_calls = 0
|
||||
self.in_update = False
|
||||
self.overlap_detected = False
|
||||
|
||||
def update(self):
|
||||
self.in_update = True
|
||||
self.update_calls += 1
|
||||
time.sleep(self.update_seconds)
|
||||
self.in_update = False
|
||||
return True
|
||||
|
||||
def display(self, force_clear=False):
|
||||
if self.in_update:
|
||||
self.overlap_detected = True
|
||||
self.display_calls += 1
|
||||
return True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pm(tmp_path):
|
||||
manager = PluginManager(plugins_dir=str(tmp_path), config_manager=None,
|
||||
display_manager=None, cache_manager=None)
|
||||
yield manager
|
||||
manager.stop_update_worker()
|
||||
|
||||
|
||||
def _install(pm, plugin, plugin_id="slow-plugin"):
|
||||
pm.plugins[plugin_id] = plugin
|
||||
pm._update_interval_cache[plugin_id] = 0.01 # always due
|
||||
# load_plugin normally registers state; can_execute() gates on it
|
||||
pm.state_manager.set_state(plugin_id, PluginState.ENABLED)
|
||||
return plugin_id
|
||||
|
||||
|
||||
class TestSchedulerNonBlocking:
|
||||
def test_run_scheduled_updates_returns_immediately(self, pm):
|
||||
plugin_id = _install(pm, SlowPlugin(update_seconds=2.0))
|
||||
start = time.monotonic()
|
||||
pm.run_scheduled_updates()
|
||||
elapsed = time.monotonic() - start
|
||||
assert elapsed < 0.1, f"scheduler blocked for {elapsed:.2f}s"
|
||||
# the update actually runs in the background
|
||||
deadline = time.monotonic() + 5
|
||||
while pm.plugins[plugin_id].update_calls == 0 and time.monotonic() < deadline:
|
||||
time.sleep(0.05)
|
||||
assert pm.plugins[plugin_id].update_calls == 1
|
||||
|
||||
def test_no_double_enqueue_while_pending(self, pm):
|
||||
plugin_id = _install(pm, SlowPlugin(update_seconds=0.8))
|
||||
for _ in range(20):
|
||||
pm.run_scheduled_updates()
|
||||
time.sleep(0.01)
|
||||
time.sleep(1.5) # let the single queued update finish
|
||||
assert pm.plugins[plugin_id].update_calls == 1
|
||||
|
||||
|
||||
class TestUpdateDisplayExclusion:
|
||||
def test_display_lock_held_during_update(self, pm):
|
||||
plugin_id = _install(pm, SlowPlugin(update_seconds=0.6))
|
||||
pm.run_scheduled_updates()
|
||||
# give the worker a moment to take the lock and enter update()
|
||||
deadline = time.monotonic() + 2
|
||||
while not pm.plugins[plugin_id].in_update and time.monotonic() < deadline:
|
||||
time.sleep(0.01)
|
||||
lock = pm.get_plugin_lock(plugin_id)
|
||||
assert lock.acquire(blocking=False) is False, \
|
||||
"lock must be held while update() runs"
|
||||
# and released afterwards
|
||||
deadline = time.monotonic() + 3
|
||||
while pm.plugins[plugin_id].in_update and time.monotonic() < deadline:
|
||||
time.sleep(0.05)
|
||||
time.sleep(0.1)
|
||||
assert lock.acquire(blocking=False) is True
|
||||
lock.release()
|
||||
|
||||
def test_no_overlap_under_hammering_display_loop(self, pm):
|
||||
"""Simulate the render loop's try-lock display pattern at high rate
|
||||
while updates fire — the plugin itself asserts no overlap."""
|
||||
plugin = SlowPlugin(update_seconds=0.15)
|
||||
plugin_id = _install(pm, plugin)
|
||||
stop = threading.Event()
|
||||
|
||||
def render_loop():
|
||||
while not stop.is_set():
|
||||
lock = pm.get_plugin_lock(plugin_id)
|
||||
if lock.acquire(blocking=False):
|
||||
try:
|
||||
plugin.display()
|
||||
finally:
|
||||
lock.release()
|
||||
time.sleep(0.002)
|
||||
|
||||
renderer = threading.Thread(target=render_loop, daemon=True)
|
||||
renderer.start()
|
||||
try:
|
||||
for _ in range(6):
|
||||
pm.plugin_last_update.pop(plugin_id, None) # force due
|
||||
pm.run_scheduled_updates()
|
||||
time.sleep(0.3)
|
||||
finally:
|
||||
stop.set()
|
||||
renderer.join(timeout=2)
|
||||
assert plugin.update_calls >= 3
|
||||
assert plugin.display_calls > 10
|
||||
assert plugin.overlap_detected is False
|
||||
|
||||
def test_lock_held_through_timeout_until_real_update_finishes(self, pm):
|
||||
"""PluginExecutor's join(timeout) can return before the real
|
||||
update() call does -- the lingering daemon thread keeps running it.
|
||||
The plugin's lock must stay held for that whole real duration, not
|
||||
just PluginExecutor's bounded wait, or display() could run
|
||||
concurrently with a still-executing update()."""
|
||||
plugin = SlowPlugin(update_seconds=0.3)
|
||||
plugin_id = _install(pm, plugin)
|
||||
pm.plugin_executor.default_timeout = 0.05 # times out well before
|
||||
# update_seconds elapses
|
||||
|
||||
pm.run_scheduled_updates()
|
||||
|
||||
deadline = time.monotonic() + 2
|
||||
while not plugin.in_update and time.monotonic() < deadline:
|
||||
time.sleep(0.01)
|
||||
assert plugin.in_update is True
|
||||
|
||||
# PluginExecutor's own timeout has now elapsed, but the real
|
||||
# update() (0.3s) is still running in its lingering daemon thread.
|
||||
time.sleep(0.15)
|
||||
assert plugin.in_update is True, "test setup: update should still be running"
|
||||
lock = pm.get_plugin_lock(plugin_id)
|
||||
assert lock.acquire(blocking=False) is False, \
|
||||
"lock must stay held through PluginExecutor's timeout while the real update() runs"
|
||||
|
||||
# Once the real update() genuinely finishes, the lock is released.
|
||||
deadline = time.monotonic() + 2
|
||||
while plugin.in_update and time.monotonic() < deadline:
|
||||
time.sleep(0.02)
|
||||
time.sleep(0.1)
|
||||
assert lock.acquire(blocking=False) is True
|
||||
lock.release()
|
||||
|
||||
def test_state_returns_to_enabled_after_update(self, pm):
|
||||
"""RUNNING is set at enqueue (blocks re-entry via can_execute) and
|
||||
must return to an executable state once the update finishes."""
|
||||
plugin_id = _install(pm, SlowPlugin(update_seconds=0.1))
|
||||
pm.run_scheduled_updates()
|
||||
# while queued/running, re-entry is blocked
|
||||
assert pm.state_manager.can_execute(plugin_id) is False
|
||||
deadline = time.monotonic() + 3
|
||||
while time.monotonic() < deadline:
|
||||
if (pm.plugins[plugin_id].update_calls
|
||||
and pm.state_manager.can_execute(plugin_id)):
|
||||
break
|
||||
time.sleep(0.05)
|
||||
assert pm.plugins[plugin_id].update_calls == 1
|
||||
assert pm.state_manager.can_execute(plugin_id) is True
|
||||
|
||||
|
||||
class TestFailurePaths:
|
||||
def test_update_failure_routes_through_failure_bookkeeping(self, pm):
|
||||
class FailingPlugin(SlowPlugin):
|
||||
def update(self):
|
||||
self.update_calls += 1
|
||||
raise RuntimeError("boom")
|
||||
|
||||
plugin_id = _install(pm, FailingPlugin())
|
||||
pm.run_scheduled_updates()
|
||||
deadline = time.monotonic() + 3
|
||||
while pm.plugins[plugin_id].update_calls == 0 and time.monotonic() < deadline:
|
||||
time.sleep(0.05)
|
||||
time.sleep(0.2)
|
||||
# failure stamped so the interval gate holds (no hot retry loop)
|
||||
assert pm.plugin_last_update.get(plugin_id, 0) > 0
|
||||
# lock released after failure
|
||||
assert pm.get_plugin_lock(plugin_id).acquire(blocking=False) is True
|
||||
pm.get_plugin_lock(plugin_id).release()
|
||||
|
||||
def test_unloaded_while_queued_is_harmless(self, pm):
|
||||
"""Exercise the public unload_plugin() lifecycle rather than
|
||||
deleting pm.plugins directly: queue the target's update behind a
|
||||
deterministic blocker (occupying the single worker), unload the
|
||||
target while its item still sits queued, then release the blocker
|
||||
and confirm the target's update never ran and its state stayed
|
||||
unloaded rather than being resurrected to ENABLED."""
|
||||
blocker_event = threading.Event()
|
||||
|
||||
class BlockerPlugin(SlowPlugin):
|
||||
def update(self):
|
||||
self.update_calls += 1
|
||||
blocker_event.wait(timeout=5)
|
||||
return True
|
||||
|
||||
blocker_id = _install(pm, BlockerPlugin(), plugin_id="blocker-plugin")
|
||||
target = SlowPlugin(update_seconds=0.05)
|
||||
target_id = _install(pm, target, plugin_id="slow-plugin")
|
||||
|
||||
# Dispatch the blocker first so it occupies the single worker
|
||||
# thread, then enqueue the target behind it -- deterministically
|
||||
# queued, not yet started.
|
||||
pm._enqueue_update(blocker_id, time.time())
|
||||
deadline = time.monotonic() + 2
|
||||
while pm.plugins[blocker_id].update_calls == 0 and time.monotonic() < deadline:
|
||||
time.sleep(0.01)
|
||||
assert pm.plugins[blocker_id].update_calls == 1
|
||||
|
||||
pm._enqueue_update(target_id, time.time())
|
||||
assert target_id in pm._pending_updates
|
||||
|
||||
assert pm.unload_plugin(target_id) is True
|
||||
assert target_id not in pm.plugins
|
||||
|
||||
blocker_event.set() # let the blocker finish; worker moves on to
|
||||
# the target's queued item
|
||||
|
||||
deadline = time.monotonic() + 3
|
||||
while target_id in pm._pending_updates and time.monotonic() < deadline:
|
||||
time.sleep(0.02)
|
||||
|
||||
assert target.update_calls == 0, "update() must not run for an unloaded plugin"
|
||||
assert target_id not in pm._pending_updates
|
||||
assert pm.state_manager.get_state(target_id) == PluginState.UNLOADED
|
||||
|
||||
|
||||
class TestKillSwitch:
|
||||
def test_synchronous_mode_blocks_like_before(self, pm):
|
||||
pm._synchronous_updates = True
|
||||
plugin_id = _install(pm, SlowPlugin(update_seconds=0.4))
|
||||
start = time.monotonic()
|
||||
pm.run_scheduled_updates()
|
||||
elapsed = time.monotonic() - start
|
||||
assert elapsed >= 0.4, "synchronous mode must run inline"
|
||||
assert pm.plugins[plugin_id].update_calls == 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main([__file__, "-v"]))
|
||||
@@ -400,3 +400,61 @@ class TestDiskCache:
|
||||
assert stats['fetch_count'] == 3
|
||||
assert stats['total_fetch_time'] == 1.8
|
||||
assert stats['average_fetch_time'] == pytest.approx(0.6, abs=0.01)
|
||||
|
||||
|
||||
class TestDiskCacheWriteEconomy:
|
||||
"""SD-card wear guards: identical payloads skip the disk, files are
|
||||
compact, and TTL semantics survive the skip (see PR: fix/diskcache-sd-wear)."""
|
||||
|
||||
def test_identical_set_skips_rewrite(self, tmp_path):
|
||||
import os
|
||||
cache = DiskCache(cache_dir=str(tmp_path))
|
||||
cache.set("k", {"data": "v"})
|
||||
path = cache.get_cache_path("k")
|
||||
first = os.stat(path)
|
||||
os.utime(path, (first.st_atime - 100, first.st_mtime - 100)) # age it
|
||||
aged_mtime = os.stat(path).st_mtime
|
||||
ino_before = os.stat(path).st_ino
|
||||
cache.set("k", {"data": "v"}) # identical payload
|
||||
after = os.stat(path)
|
||||
# mtime refreshed (TTL for mtime-based records preserved)...
|
||||
assert after.st_mtime > aged_mtime
|
||||
# ...but the file was NOT rewritten (same inode: no replace happened)
|
||||
assert after.st_ino == ino_before
|
||||
|
||||
def test_changed_data_rewrites(self, tmp_path):
|
||||
import os
|
||||
cache = DiskCache(cache_dir=str(tmp_path))
|
||||
cache.set("k", {"data": "v1"})
|
||||
cache.set("k", {"data": "v2"})
|
||||
assert cache.get("k") == {"data": "v2"}
|
||||
|
||||
def test_clear_resets_digest(self, tmp_path):
|
||||
import os
|
||||
cache = DiskCache(cache_dir=str(tmp_path))
|
||||
cache.set("k", {"data": "v"})
|
||||
cache.clear("k")
|
||||
assert cache.get("k") is None
|
||||
cache.set("k", {"data": "v"}) # same payload after clear must WRITE
|
||||
assert cache.get("k") == {"data": "v"}
|
||||
|
||||
def test_skip_self_heals_when_file_deleted_externally(self, tmp_path):
|
||||
import os
|
||||
cache = DiskCache(cache_dir=str(tmp_path))
|
||||
cache.set("k", {"data": "v"})
|
||||
os.remove(cache.get_cache_path("k")) # e.g. expiry cleanup
|
||||
cache.set("k", {"data": "v"}) # digest matches but file is gone
|
||||
assert cache.get("k") == {"data": "v"}
|
||||
|
||||
def test_files_are_compact_json(self, tmp_path):
|
||||
cache = DiskCache(cache_dir=str(tmp_path))
|
||||
cache.set("k", {"a": 1, "b": [1, 2, 3]})
|
||||
raw = open(cache.get_cache_path("k")).read()
|
||||
assert "\n" not in raw.strip() # no indent
|
||||
assert cache.get("k") == {"a": 1, "b": [1, 2, 3]}
|
||||
|
||||
def test_datetime_round_trip_still_works(self, tmp_path):
|
||||
from datetime import datetime
|
||||
cache = DiskCache(cache_dir=str(tmp_path))
|
||||
cache.set("k", {"when": datetime(2026, 7, 12, 10, 30)})
|
||||
assert cache.get("k") == {"when": "2026-07-12T10:30:00"}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Tests for load_config's mtime fast path (src/config_manager.py).
|
||||
|
||||
load_config used to re-read + re-parse config.json, the template (with a
|
||||
recursive migration diff) and secrets on EVERY call — ~30 web request
|
||||
handlers call it, some 2-3x per request. The fast path skips all of it
|
||||
when the three files' (mtime_ns, size) signatures are unchanged.
|
||||
|
||||
The invariant that matters most: cross-process freshness — a save from
|
||||
the web process must be picked up by the display process's next load.
|
||||
That's guaranteed because the signature is re-stat'd on every call.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from src.config_manager import ConfigManager # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mgr(tmp_path):
|
||||
config = tmp_path / "config.json"
|
||||
secrets = tmp_path / "secrets.json"
|
||||
template = tmp_path / "template.json"
|
||||
config.write_text(json.dumps({"display": {"brightness": 90}, "timezone": "UTC"}))
|
||||
secrets.write_text(json.dumps({"weather": {"api_key": "sek"}}))
|
||||
template.write_text(json.dumps({"display": {"brightness": 90}, "timezone": "UTC"}))
|
||||
m = ConfigManager(config_path=str(config), secrets_path=str(secrets))
|
||||
m.template_path = str(template)
|
||||
return m, config, secrets, template
|
||||
|
||||
|
||||
def _count_opens(monkeypatch, mgr_paths):
|
||||
"""Count open() calls hitting the config files."""
|
||||
counts = {"n": 0}
|
||||
real_open = open
|
||||
|
||||
def counting_open(file, *args, **kwargs):
|
||||
if str(file) in mgr_paths:
|
||||
counts["n"] += 1
|
||||
return real_open(file, *args, **kwargs)
|
||||
|
||||
import builtins
|
||||
monkeypatch.setattr(builtins, "open", counting_open)
|
||||
return counts
|
||||
|
||||
|
||||
class TestFastPath:
|
||||
def test_unchanged_files_are_not_reread(self, mgr, monkeypatch):
|
||||
m, config, secrets, template = mgr
|
||||
first = m.load_config()
|
||||
assert first["weather"]["api_key"] == "sek" # secrets merged
|
||||
counts = _count_opens(monkeypatch, {str(config), str(secrets), str(template)})
|
||||
for _ in range(10):
|
||||
again = m.load_config()
|
||||
assert counts["n"] == 0, "fast path must not re-open any config file"
|
||||
assert again is first # same aliasing semantics as the full path
|
||||
|
||||
def test_config_change_triggers_reload(self, mgr):
|
||||
m, config, secrets, template = mgr
|
||||
m.load_config()
|
||||
data = json.loads(config.read_text())
|
||||
data["display"]["brightness"] = 55
|
||||
config.write_text(json.dumps(data))
|
||||
os.utime(config, (os.stat(config).st_atime, os.stat(config).st_mtime + 2))
|
||||
assert m.load_config()["display"]["brightness"] == 55
|
||||
|
||||
def test_secrets_change_triggers_reload(self, mgr):
|
||||
m, config, secrets, template = mgr
|
||||
m.load_config()
|
||||
secrets.write_text(json.dumps({"weather": {"api_key": "NEW"}}))
|
||||
os.utime(secrets, (os.stat(secrets).st_atime, os.stat(secrets).st_mtime + 2))
|
||||
assert m.load_config()["weather"]["api_key"] == "NEW"
|
||||
|
||||
def test_template_change_triggers_reload_and_migration(self, mgr):
|
||||
m, config, secrets, template = mgr
|
||||
m.load_config()
|
||||
template.write_text(json.dumps({
|
||||
"display": {"brightness": 90}, "timezone": "UTC",
|
||||
"brand_new_key": {"added": True}}))
|
||||
os.utime(template, (os.stat(template).st_atime, os.stat(template).st_mtime + 2))
|
||||
reloaded = m.load_config()
|
||||
assert reloaded.get("brand_new_key") == {"added": True}
|
||||
|
||||
def test_same_second_edit_detected_via_mtime_ns_or_size(self, mgr):
|
||||
"""Coarse-mtime same-second edits: size difference still busts it."""
|
||||
m, config, secrets, template = mgr
|
||||
m.load_config()
|
||||
st = os.stat(config)
|
||||
data = json.loads(config.read_text())
|
||||
data["timezone"] = "America/New_York" # different byte length
|
||||
config.write_text(json.dumps(data))
|
||||
os.utime(config, (st.st_atime, st.st_mtime)) # force same mtime
|
||||
assert m.load_config()["timezone"] == "America/New_York"
|
||||
|
||||
|
||||
class TestSaveCoherence:
|
||||
def test_save_config_then_load_returns_saved_data(self, mgr, monkeypatch):
|
||||
m, config, secrets, template = mgr
|
||||
m.load_config()
|
||||
new = {"display": {"brightness": 42}, "timezone": "UTC",
|
||||
"weather": {"api_key": "sek"}}
|
||||
m.save_config(new)
|
||||
counts = _count_opens(monkeypatch, {str(config), str(secrets), str(template)})
|
||||
loaded = m.load_config()
|
||||
assert loaded["display"]["brightness"] == 42
|
||||
assert loaded["weather"]["api_key"] == "sek" # secrets survive in memory
|
||||
assert counts["n"] == 0 # signature refreshed by save; no re-read
|
||||
|
||||
def test_cross_process_save_is_picked_up(self, mgr):
|
||||
"""Another process writing config.json (different mtime) must bust
|
||||
this process's fast path — the core cross-process guarantee."""
|
||||
m, config, secrets, template = mgr
|
||||
m.load_config()
|
||||
other = ConfigManager(config_path=str(config), secrets_path=str(secrets))
|
||||
other.template_path = str(template)
|
||||
other.load_config()
|
||||
other.save_config({"display": {"brightness": 11}, "timezone": "UTC",
|
||||
"weather": {"api_key": "sek"}})
|
||||
os.utime(config, (os.stat(config).st_atime, os.stat(config).st_mtime + 2))
|
||||
assert m.load_config()["display"]["brightness"] == 11
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main([__file__, "-v"]))
|
||||
@@ -0,0 +1,88 @@
|
||||
"""
|
||||
Regression tests for DisplayController._tick_plugin_updates_for_vegas().
|
||||
|
||||
PR #299 added logic to detect which plugins actually got fresh data on a
|
||||
scheduled-update tick and notify Vegas mode via
|
||||
vegas_coordinator.mark_plugin_updated(), so a live score change reaches the
|
||||
scroll within seconds instead of waiting for a full cycle. PR #330's
|
||||
multi-display sync refactor deleted this method (folding the callback back
|
||||
to the plain _tick_plugin_updates(), which reports nothing), silently
|
||||
orphaning VegasModeCoordinator.mark_plugin_updated() -- it has had zero
|
||||
callers since.
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Optional
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from src.display_controller import DisplayController
|
||||
|
||||
|
||||
def _make_controller(updated: Optional[List[str]] = None, vegas_coordinator: Optional[MagicMock] = None) -> DisplayController:
|
||||
dc = object.__new__(DisplayController)
|
||||
dc.plugin_manager = MagicMock()
|
||||
dc.plugin_manager.run_scheduled_updates_with_changes.return_value = list(updated or [])
|
||||
dc.vegas_coordinator = vegas_coordinator
|
||||
return dc
|
||||
|
||||
|
||||
class TestTickPluginUpdatesForVegas:
|
||||
def test_marks_only_plugins_whose_timestamp_advanced(self):
|
||||
vc = MagicMock()
|
||||
dc = _make_controller(updated=["stock-news"], vegas_coordinator=vc)
|
||||
|
||||
dc._tick_plugin_updates_for_vegas()
|
||||
|
||||
vc.mark_plugin_updated.assert_called_once_with("stock-news")
|
||||
|
||||
def test_no_advance_marks_nothing(self):
|
||||
vc = MagicMock()
|
||||
dc = _make_controller(updated=[], vegas_coordinator=vc)
|
||||
|
||||
dc._tick_plugin_updates_for_vegas()
|
||||
|
||||
vc.mark_plugin_updated.assert_not_called()
|
||||
|
||||
def test_no_vegas_coordinator_does_not_raise(self):
|
||||
dc = _make_controller(updated=["stock-news"], vegas_coordinator=None)
|
||||
|
||||
dc._tick_plugin_updates_for_vegas() # must not raise
|
||||
|
||||
def test_mark_plugin_updated_exception_does_not_propagate(self):
|
||||
"""One plugin's mark_plugin_updated failing must not stop the tick
|
||||
or crash the update loop it runs in."""
|
||||
vc = MagicMock()
|
||||
vc.mark_plugin_updated.side_effect = [RuntimeError("boom"), None]
|
||||
dc = _make_controller(updated=["a", "b"], vegas_coordinator=vc)
|
||||
|
||||
dc._tick_plugin_updates_for_vegas() # must not raise
|
||||
|
||||
assert vc.mark_plugin_updated.call_count == 2
|
||||
|
||||
|
||||
class TestVegasCoordinatorCallbackWiring:
|
||||
def test_initialize_wires_vegas_aware_tick_as_update_callback(self):
|
||||
"""The Vegas coordinator must be given the Vegas-aware
|
||||
_tick_plugin_updates_for_vegas as its update callback, not the plain
|
||||
_tick_plugin_updates() -- that's the exact wiring PR #330 dropped."""
|
||||
dc = object.__new__(DisplayController)
|
||||
dc.config = {"display": {"vegas_scroll": {"enabled": True}}, "sync": {}}
|
||||
dc.display_manager = MagicMock()
|
||||
dc.plugin_manager = MagicMock()
|
||||
dc.sync_manager = MagicMock()
|
||||
dc._check_live_priority = MagicMock()
|
||||
dc._check_vegas_interrupt = MagicMock(return_value=False)
|
||||
|
||||
fake_coordinator = MagicMock()
|
||||
|
||||
import src.display_controller as dc_module
|
||||
original_imported = dc_module._vegas_mode_imported
|
||||
original_class = dc_module.VegasModeCoordinator
|
||||
try:
|
||||
dc_module._vegas_mode_imported = True
|
||||
dc_module.VegasModeCoordinator = MagicMock(return_value=fake_coordinator)
|
||||
dc._initialize_vegas_mode()
|
||||
finally:
|
||||
dc_module._vegas_mode_imported = original_imported
|
||||
dc_module.VegasModeCoordinator = original_class
|
||||
|
||||
fake_coordinator.set_update_callback.assert_called_once_with(dc._tick_plugin_updates_for_vegas)
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Tests for update_display dirty tracking (src/display_manager.py).
|
||||
|
||||
Runs against RGBMatrixEmulator (EMULATOR=true), exercising the REAL
|
||||
DisplayManager — not a mock — so the skip logic, its invalidation hooks,
|
||||
and the kill switch are verified off-Pi.
|
||||
|
||||
The invariants:
|
||||
- identical frames are pushed exactly once (SwapOnVSync not re-called)
|
||||
- ANY pixel change pushes
|
||||
- clear() and set_brightness() invalidate (the two paths that alter panel
|
||||
state outside the digest's view)
|
||||
- the kill switch (display.dirty_tracking: false) restores always-push
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
os.environ["EMULATOR"] = "true"
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def dm():
|
||||
"""One real DisplayManager on the emulator (it's a process singleton)."""
|
||||
from src.display_manager import DisplayManager
|
||||
DisplayManager._instance = None
|
||||
DisplayManager._initialized = False
|
||||
manager = DisplayManager({
|
||||
"display": {
|
||||
"hardware": {"rows": 32, "cols": 64, "chain_length": 2,
|
||||
"parallel": 1, "brightness": 90},
|
||||
"runtime": {"gpio_slowdown": 0},
|
||||
},
|
||||
}, suppress_test_pattern=True)
|
||||
yield manager
|
||||
DisplayManager._instance = None
|
||||
DisplayManager._initialized = False
|
||||
|
||||
|
||||
class _SwapSpy:
|
||||
"""Counts SwapOnVSync calls through the real matrix object."""
|
||||
|
||||
def __init__(self, matrix):
|
||||
self.matrix = matrix
|
||||
self.count = 0
|
||||
self._orig = matrix.SwapOnVSync
|
||||
|
||||
def __enter__(self):
|
||||
def counting(canvas):
|
||||
self.count += 1
|
||||
return self._orig(canvas)
|
||||
self.matrix.SwapOnVSync = counting
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
self.matrix.SwapOnVSync = self._orig
|
||||
|
||||
|
||||
class TestDirtyTracking:
|
||||
def test_identical_frames_push_once(self, dm):
|
||||
dm.draw.rectangle([0, 0, 10, 10], fill=(255, 0, 0))
|
||||
with _SwapSpy(dm.matrix) as spy:
|
||||
dm.update_display()
|
||||
dm.update_display()
|
||||
dm.update_display()
|
||||
assert spy.count == 1
|
||||
|
||||
def test_pixel_change_pushes(self, dm):
|
||||
dm.update_display()
|
||||
with _SwapSpy(dm.matrix) as spy:
|
||||
dm.draw.point((5, 5), fill=(0, 255, 0))
|
||||
dm.update_display()
|
||||
dm.update_display() # unchanged again
|
||||
assert spy.count == 1
|
||||
|
||||
def test_clear_invalidates(self, dm):
|
||||
dm.draw.rectangle([0, 0, 20, 20], fill=(0, 0, 255))
|
||||
dm.update_display()
|
||||
dm.clear() # writes to the matrix directly; digest must reset
|
||||
with _SwapSpy(dm.matrix) as spy:
|
||||
dm.update_display() # black frame after clear must still push
|
||||
assert spy.count == 1
|
||||
|
||||
def test_brightness_change_forces_push(self, dm):
|
||||
dm.draw.rectangle([0, 0, 20, 20], fill=(200, 200, 200))
|
||||
dm.update_display()
|
||||
with _SwapSpy(dm.matrix) as spy:
|
||||
dm.update_display() # identical -> skipped
|
||||
assert spy.count == 0
|
||||
dm.set_brightness(40) # dim schedule scenario
|
||||
dm.update_display() # same image, new brightness -> push
|
||||
assert spy.count == 1
|
||||
dm.set_brightness(90)
|
||||
|
||||
def test_snapshot_still_written_on_skip(self, dm, tmp_path):
|
||||
"""The web preview mirror must keep working through skipped panel
|
||||
pushes: _write_snapshot_if_due() still runs on the dirty-tracking
|
||||
skip path and applies its own write/touch policy rather than being
|
||||
bypassed entirely (see src/common/snapshot_policy.py — an unchanged
|
||||
frame is touched, not re-encoded, once TOUCH_INTERVAL elapses)."""
|
||||
dm._snapshot_path = str(tmp_path / "snap.png")
|
||||
dm._last_snapshot_ts = 0.0
|
||||
dm._last_snapshot_touch_ts = 0.0
|
||||
dm._last_snapshot_digest = None
|
||||
dm.draw.rectangle([0, 0, 30, 8], fill=(255, 255, 0))
|
||||
dm.update_display() # push + snapshot write (first frame)
|
||||
assert os.path.exists(dm._snapshot_path)
|
||||
first_mtime = os.path.getmtime(dm._snapshot_path)
|
||||
|
||||
# Age the write/touch bookkeeping past TOUCH_INTERVAL so the next
|
||||
# identical frame is due for a touch, then push it again: dirty
|
||||
# tracking must skip the panel write, but the snapshot mirror must
|
||||
# still get its mtime bumped so the health check doesn't go stale.
|
||||
from src.common import snapshot_policy
|
||||
stale_ts = time.time() - snapshot_policy.TOUCH_INTERVAL - 1.0
|
||||
dm._last_snapshot_ts = stale_ts
|
||||
dm._last_snapshot_touch_ts = stale_ts
|
||||
with _SwapSpy(dm.matrix) as spy:
|
||||
dm.update_display() # identical frame -> panel push skipped
|
||||
assert spy.count == 0
|
||||
assert os.path.getmtime(dm._snapshot_path) > first_mtime
|
||||
|
||||
|
||||
class TestKillSwitch:
|
||||
def test_dirty_tracking_can_be_disabled(self, dm):
|
||||
dm._dirty_tracking_enabled = False
|
||||
try:
|
||||
dm.draw.rectangle([0, 0, 10, 10], fill=(1, 2, 3))
|
||||
with _SwapSpy(dm.matrix) as spy:
|
||||
dm.update_display()
|
||||
dm.update_display()
|
||||
dm.update_display()
|
||||
assert spy.count == 3 # always-push, exactly the old behavior
|
||||
finally:
|
||||
dm._dirty_tracking_enabled = True
|
||||
dm._last_pushed_digest = None
|
||||
|
||||
def test_config_flag_wires_through(self):
|
||||
from src.display_manager import DisplayManager
|
||||
DisplayManager._instance = None
|
||||
DisplayManager._initialized = False
|
||||
try:
|
||||
manager = DisplayManager({
|
||||
"display": {
|
||||
"hardware": {"rows": 32, "cols": 64, "chain_length": 1,
|
||||
"parallel": 1},
|
||||
"runtime": {"gpio_slowdown": 0},
|
||||
"dirty_tracking": False,
|
||||
},
|
||||
}, suppress_test_pattern=True)
|
||||
assert manager._dirty_tracking_enabled is False
|
||||
finally:
|
||||
DisplayManager._instance = None
|
||||
DisplayManager._initialized = False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main([__file__, "-v"]))
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Tests for the harness fill / scale-up check (src/plugin_system/testing/harness.py)."""
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from src.plugin_system.testing.harness import (
|
||||
RenderResult,
|
||||
check_scale_up,
|
||||
fill_metrics,
|
||||
)
|
||||
|
||||
|
||||
def _canvas(w, h):
|
||||
return Image.new("RGB", (w, h), (0, 0, 0))
|
||||
|
||||
|
||||
def _with_block(w, h, bx, by, bw, bh, color=(255, 255, 255)):
|
||||
img = _canvas(w, h)
|
||||
img.paste(Image.new("RGB", (bw, bh), color), (bx, by))
|
||||
return img
|
||||
|
||||
|
||||
def _result(w, h, image):
|
||||
return RenderResult("p", w, h, "mode", image=image)
|
||||
|
||||
|
||||
class TestFillMetrics:
|
||||
def test_full_white(self):
|
||||
ex, ey, ink = fill_metrics(Image.new("RGB", (64, 32), (255, 255, 255)))
|
||||
assert (ex, ey, ink) == (1.0, 1.0, 1.0)
|
||||
|
||||
def test_black_is_empty(self):
|
||||
assert fill_metrics(_canvas(64, 32)) == (0.0, 0.0, 0.0)
|
||||
|
||||
def test_corner_dot(self):
|
||||
ex, ey, ink = fill_metrics(_with_block(100, 100, 0, 0, 10, 10))
|
||||
assert ex == 0.1 and ey == 0.1
|
||||
assert ink == 0.01
|
||||
|
||||
def test_centered_half(self):
|
||||
ex, ey, _ = fill_metrics(_with_block(100, 100, 25, 25, 50, 50))
|
||||
assert ex == 0.5 and ey == 0.5
|
||||
|
||||
def test_dim_pixels_ignored(self):
|
||||
img = _canvas(10, 10)
|
||||
img.putpixel((5, 5), (10, 10, 10)) # below the lit threshold
|
||||
assert fill_metrics(img) == (0.0, 0.0, 0.0)
|
||||
|
||||
|
||||
class TestCheckScaleUp:
|
||||
def test_not_checked_below_2x(self):
|
||||
# 128x64 vs design 128x32: only height is 2x -> checked on y only;
|
||||
# 128x32 itself: not checked at all
|
||||
r = _result(128, 32, _with_block(128, 32, 0, 0, 10, 10))
|
||||
check_scale_up([r], design_size=(128, 32))
|
||||
assert not r.fill_checked
|
||||
|
||||
def test_warn_mode_records_but_passes(self):
|
||||
# tiny corner content on a 256x128 (2x both axes)
|
||||
r = _result(256, 128, _with_block(256, 128, 0, 0, 20, 20))
|
||||
check_scale_up([r], design_size=(128, 32), strict=False)
|
||||
assert r.fill_checked
|
||||
assert r.fill_ok is None # warn-only: not a failure
|
||||
assert r.ok # still passes
|
||||
assert r.fill_extent[0] < 0.5
|
||||
|
||||
def test_strict_mode_fails_underfill(self):
|
||||
r = _result(256, 128, _with_block(256, 128, 0, 0, 20, 20))
|
||||
check_scale_up([r], design_size=(128, 32), strict=True)
|
||||
assert r.fill_ok is False
|
||||
assert not r.ok
|
||||
|
||||
def test_well_filled_passes_strict(self):
|
||||
r = _result(256, 128, _with_block(256, 128, 10, 10, 200, 100))
|
||||
check_scale_up([r], design_size=(128, 32), strict=True)
|
||||
assert r.fill_ok is True and r.ok
|
||||
|
||||
def test_axis_selection_wide_only(self):
|
||||
# 256x32 vs design 128x32: width is 2x, height is not -> only the
|
||||
# x-extent matters; content spanning full width but few rows passes
|
||||
r = _result(256, 32, _with_block(256, 32, 0, 12, 250, 8))
|
||||
check_scale_up([r], design_size=(128, 32), strict=True)
|
||||
assert r.fill_ok is True
|
||||
|
||||
def test_axis_selection_wide_only_underfill(self):
|
||||
r = _result(256, 32, _with_block(256, 32, 0, 12, 60, 8))
|
||||
check_scale_up([r], design_size=(128, 32), strict=True)
|
||||
assert r.fill_ok is False
|
||||
|
||||
def test_errored_render_skipped(self):
|
||||
r = RenderResult("p", 256, 128, "m", error="boom")
|
||||
check_scale_up([r], design_size=(128, 32), strict=True)
|
||||
assert not r.fill_checked
|
||||
|
||||
def test_custom_design_size(self):
|
||||
# 128x64 with design 64x32 IS 2x both axes
|
||||
r = _result(128, 64, _with_block(128, 64, 0, 0, 10, 10))
|
||||
check_scale_up([r], design_size=(64, 32), strict=False)
|
||||
assert r.fill_checked
|
||||
@@ -1,392 +0,0 @@
|
||||
"""
|
||||
Tests for LayoutManager.
|
||||
|
||||
Tests layout creation, management, rendering, and element positioning.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import json
|
||||
from unittest.mock import MagicMock
|
||||
from src.layout_manager import LayoutManager
|
||||
|
||||
|
||||
class TestLayoutManager:
|
||||
"""Test LayoutManager functionality."""
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_layout_file(self, tmp_path):
|
||||
"""Create a temporary layout file."""
|
||||
layout_file = tmp_path / "custom_layouts.json"
|
||||
return str(layout_file)
|
||||
|
||||
@pytest.fixture
|
||||
def mock_display_manager(self):
|
||||
"""Create a mock display manager."""
|
||||
dm = MagicMock()
|
||||
dm.clear = MagicMock()
|
||||
dm.update_display = MagicMock()
|
||||
dm.draw_text = MagicMock()
|
||||
dm.draw_weather_icon = MagicMock()
|
||||
dm.small_font = MagicMock()
|
||||
dm.regular_font = MagicMock()
|
||||
return dm
|
||||
|
||||
@pytest.fixture
|
||||
def layout_manager(self, tmp_layout_file, mock_display_manager):
|
||||
"""Create a LayoutManager instance."""
|
||||
return LayoutManager(
|
||||
display_manager=mock_display_manager,
|
||||
config_path=tmp_layout_file
|
||||
)
|
||||
|
||||
def test_init(self, tmp_layout_file, mock_display_manager):
|
||||
"""Test LayoutManager initialization."""
|
||||
lm = LayoutManager(
|
||||
display_manager=mock_display_manager,
|
||||
config_path=tmp_layout_file
|
||||
)
|
||||
|
||||
assert lm.display_manager == mock_display_manager
|
||||
assert lm.config_path == tmp_layout_file
|
||||
assert lm.layouts == {}
|
||||
assert lm.current_layout is None
|
||||
|
||||
def test_load_layouts_file_exists(self, tmp_path, mock_display_manager):
|
||||
"""Test loading layouts from existing file."""
|
||||
layout_file = tmp_path / "custom_layouts.json"
|
||||
layout_data = {
|
||||
"test_layout": {
|
||||
"elements": [{"type": "text", "x": 0, "y": 0}],
|
||||
"description": "Test layout"
|
||||
}
|
||||
}
|
||||
with open(layout_file, 'w') as f:
|
||||
json.dump(layout_data, f)
|
||||
|
||||
lm = LayoutManager(
|
||||
display_manager=mock_display_manager,
|
||||
config_path=str(layout_file)
|
||||
)
|
||||
|
||||
assert "test_layout" in lm.layouts
|
||||
assert lm.layouts["test_layout"]["description"] == "Test layout"
|
||||
|
||||
def test_load_layouts_file_not_exists(self, tmp_layout_file, mock_display_manager):
|
||||
"""Test loading layouts when file doesn't exist."""
|
||||
lm = LayoutManager(
|
||||
display_manager=mock_display_manager,
|
||||
config_path=tmp_layout_file
|
||||
)
|
||||
|
||||
assert lm.layouts == {}
|
||||
|
||||
def test_create_layout(self, layout_manager):
|
||||
"""Test creating a new layout."""
|
||||
elements = [{"type": "text", "x": 10, "y": 20, "properties": {"text": "Hello"}}]
|
||||
|
||||
result = layout_manager.create_layout("test_layout", elements, "Test description")
|
||||
|
||||
assert result is True
|
||||
assert "test_layout" in layout_manager.layouts
|
||||
assert layout_manager.layouts["test_layout"]["elements"] == elements
|
||||
assert layout_manager.layouts["test_layout"]["description"] == "Test description"
|
||||
assert "created" in layout_manager.layouts["test_layout"]
|
||||
assert "modified" in layout_manager.layouts["test_layout"]
|
||||
|
||||
def test_update_layout(self, layout_manager):
|
||||
"""Test updating an existing layout."""
|
||||
# Create a layout first
|
||||
elements1 = [{"type": "text", "x": 0, "y": 0}]
|
||||
layout_manager.create_layout("test_layout", elements1, "Original")
|
||||
|
||||
# Update it
|
||||
elements2 = [{"type": "text", "x": 10, "y": 20}]
|
||||
result = layout_manager.update_layout("test_layout", elements2, "Updated")
|
||||
|
||||
assert result is True
|
||||
assert layout_manager.layouts["test_layout"]["elements"] == elements2
|
||||
assert layout_manager.layouts["test_layout"]["description"] == "Updated"
|
||||
assert "modified" in layout_manager.layouts["test_layout"]
|
||||
|
||||
def test_update_layout_not_exists(self, layout_manager):
|
||||
"""Test updating a non-existent layout."""
|
||||
elements = [{"type": "text", "x": 0, "y": 0}]
|
||||
result = layout_manager.update_layout("nonexistent", elements)
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_delete_layout(self, layout_manager):
|
||||
"""Test deleting a layout."""
|
||||
elements = [{"type": "text", "x": 0, "y": 0}]
|
||||
layout_manager.create_layout("test_layout", elements)
|
||||
|
||||
result = layout_manager.delete_layout("test_layout")
|
||||
|
||||
assert result is True
|
||||
assert "test_layout" not in layout_manager.layouts
|
||||
|
||||
def test_delete_layout_not_exists(self, layout_manager):
|
||||
"""Test deleting a non-existent layout."""
|
||||
result = layout_manager.delete_layout("nonexistent")
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_get_layout(self, layout_manager):
|
||||
"""Test getting a specific layout."""
|
||||
elements = [{"type": "text", "x": 0, "y": 0}]
|
||||
layout_manager.create_layout("test_layout", elements)
|
||||
|
||||
layout = layout_manager.get_layout("test_layout")
|
||||
|
||||
assert layout is not None
|
||||
assert layout["elements"] == elements
|
||||
|
||||
def test_get_layout_not_exists(self, layout_manager):
|
||||
"""Test getting a non-existent layout."""
|
||||
layout = layout_manager.get_layout("nonexistent")
|
||||
|
||||
assert layout == {}
|
||||
|
||||
def test_list_layouts(self, layout_manager):
|
||||
"""Test listing all layouts."""
|
||||
layout_manager.create_layout("layout1", [])
|
||||
layout_manager.create_layout("layout2", [])
|
||||
layout_manager.create_layout("layout3", [])
|
||||
|
||||
layouts = layout_manager.list_layouts()
|
||||
|
||||
assert len(layouts) == 3
|
||||
assert "layout1" in layouts
|
||||
assert "layout2" in layouts
|
||||
assert "layout3" in layouts
|
||||
|
||||
def test_set_current_layout(self, layout_manager):
|
||||
"""Test setting the current layout."""
|
||||
layout_manager.create_layout("test_layout", [])
|
||||
|
||||
result = layout_manager.set_current_layout("test_layout")
|
||||
|
||||
assert result is True
|
||||
assert layout_manager.current_layout == "test_layout"
|
||||
|
||||
def test_set_current_layout_not_exists(self, layout_manager):
|
||||
"""Test setting a non-existent layout as current."""
|
||||
result = layout_manager.set_current_layout("nonexistent")
|
||||
|
||||
assert result is False
|
||||
assert layout_manager.current_layout is None
|
||||
|
||||
def test_render_layout(self, layout_manager, mock_display_manager):
|
||||
"""Test rendering a layout."""
|
||||
elements = [
|
||||
{"type": "text", "x": 0, "y": 0, "properties": {"text": "Hello"}},
|
||||
{"type": "text", "x": 10, "y": 10, "properties": {"text": "World"}}
|
||||
]
|
||||
layout_manager.create_layout("test_layout", elements)
|
||||
|
||||
result = layout_manager.render_layout("test_layout")
|
||||
|
||||
assert result is True
|
||||
mock_display_manager.clear.assert_called_once()
|
||||
mock_display_manager.update_display.assert_called_once()
|
||||
assert mock_display_manager.draw_text.call_count == 2
|
||||
|
||||
def test_render_layout_no_display_manager(self, tmp_layout_file):
|
||||
"""Test rendering without display manager."""
|
||||
lm = LayoutManager(display_manager=None, config_path=tmp_layout_file)
|
||||
lm.create_layout("test_layout", [])
|
||||
|
||||
result = lm.render_layout("test_layout")
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_render_layout_not_exists(self, layout_manager):
|
||||
"""Test rendering a non-existent layout."""
|
||||
result = layout_manager.render_layout("nonexistent")
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_render_element_text(self, layout_manager, mock_display_manager):
|
||||
"""Test rendering a text element."""
|
||||
element = {
|
||||
"type": "text",
|
||||
"x": 10,
|
||||
"y": 20,
|
||||
"properties": {
|
||||
"text": "Hello",
|
||||
"color": [255, 0, 0],
|
||||
"font_size": "small"
|
||||
}
|
||||
}
|
||||
|
||||
layout_manager.render_element(element, {})
|
||||
|
||||
mock_display_manager.draw_text.assert_called_once()
|
||||
call_args = mock_display_manager.draw_text.call_args
|
||||
assert call_args[0][0] == "Hello" # text
|
||||
assert call_args[0][1] == 10 # x
|
||||
assert call_args[0][2] == 20 # y
|
||||
|
||||
def test_render_element_weather_icon(self, layout_manager, mock_display_manager):
|
||||
"""Test rendering a weather icon element."""
|
||||
element = {
|
||||
"type": "weather_icon",
|
||||
"x": 10,
|
||||
"y": 20,
|
||||
"properties": {
|
||||
"condition": "sunny",
|
||||
"size": 16
|
||||
}
|
||||
}
|
||||
|
||||
layout_manager.render_element(element, {})
|
||||
|
||||
mock_display_manager.draw_weather_icon.assert_called_once_with("sunny", 10, 20, 16)
|
||||
|
||||
def test_render_element_weather_icon_from_context(self, layout_manager, mock_display_manager):
|
||||
"""Test rendering weather icon with data from context."""
|
||||
element = {
|
||||
"type": "weather_icon",
|
||||
"x": 10,
|
||||
"y": 20,
|
||||
"properties": {"size": 16}
|
||||
}
|
||||
data_context = {
|
||||
"weather": {
|
||||
"condition": "cloudy"
|
||||
}
|
||||
}
|
||||
|
||||
layout_manager.render_element(element, data_context)
|
||||
|
||||
mock_display_manager.draw_weather_icon.assert_called_once_with("cloudy", 10, 20, 16)
|
||||
|
||||
def test_render_element_rectangle(self, layout_manager, mock_display_manager):
|
||||
"""Test rendering a rectangle element."""
|
||||
element = {
|
||||
"type": "rectangle",
|
||||
"x": 10,
|
||||
"y": 20,
|
||||
"properties": {
|
||||
"width": 50,
|
||||
"height": 30,
|
||||
"color": [255, 0, 0],
|
||||
"filled": True
|
||||
}
|
||||
}
|
||||
|
||||
# Mock the draw object and rectangle method
|
||||
mock_draw = MagicMock()
|
||||
mock_display_manager.draw = mock_draw
|
||||
|
||||
layout_manager.render_element(element, {})
|
||||
|
||||
# Verify rectangle was drawn
|
||||
mock_draw.rectangle.assert_called_once()
|
||||
|
||||
def test_render_element_unknown_type(self, layout_manager):
|
||||
"""Test rendering an unknown element type."""
|
||||
element = {
|
||||
"type": "unknown_type",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"properties": {}
|
||||
}
|
||||
|
||||
# Should not raise an exception
|
||||
layout_manager.render_element(element, {})
|
||||
|
||||
def test_process_template_text(self, layout_manager):
|
||||
"""Test template text processing."""
|
||||
text = "Hello {name}, temperature is {temp}°F"
|
||||
data_context = {
|
||||
"name": "World",
|
||||
"temp": 72
|
||||
}
|
||||
|
||||
result = layout_manager._process_template_text(text, data_context)
|
||||
|
||||
assert result == "Hello World, temperature is 72°F"
|
||||
|
||||
def test_process_template_text_no_context(self, layout_manager):
|
||||
"""Test template text with missing context."""
|
||||
text = "Hello {name}"
|
||||
data_context = {}
|
||||
|
||||
result = layout_manager._process_template_text(text, data_context)
|
||||
|
||||
# Should leave template as-is or handle gracefully
|
||||
assert "{name}" in result or result == "Hello "
|
||||
|
||||
def test_save_layouts_error_handling(self, layout_manager):
|
||||
"""Test error handling when saving layouts."""
|
||||
# Create a layout
|
||||
layout_manager.create_layout("test", [])
|
||||
|
||||
# Make save fail by using invalid path
|
||||
layout_manager.config_path = "/nonexistent/directory/layouts.json"
|
||||
|
||||
result = layout_manager.save_layouts()
|
||||
|
||||
# Should handle error gracefully
|
||||
assert result is False
|
||||
|
||||
def test_render_element_line(self, layout_manager, mock_display_manager):
|
||||
"""Test rendering a line element."""
|
||||
element = {
|
||||
"type": "line",
|
||||
"x": 10,
|
||||
"y": 20,
|
||||
"properties": {
|
||||
"x2": 50,
|
||||
"y2": 30,
|
||||
"color": [255, 0, 0],
|
||||
"width": 2
|
||||
}
|
||||
}
|
||||
|
||||
mock_draw = MagicMock()
|
||||
mock_display_manager.draw = mock_draw
|
||||
|
||||
layout_manager.render_element(element, {})
|
||||
|
||||
mock_draw.line.assert_called_once()
|
||||
|
||||
def test_render_element_clock(self, layout_manager, mock_display_manager):
|
||||
"""Test rendering a clock element."""
|
||||
element = {
|
||||
"type": "clock",
|
||||
"x": 10,
|
||||
"y": 20,
|
||||
"properties": {
|
||||
"format": "%H:%M",
|
||||
"color": [255, 255, 255]
|
||||
}
|
||||
}
|
||||
|
||||
layout_manager.render_element(element, {})
|
||||
|
||||
mock_display_manager.draw_text.assert_called_once()
|
||||
|
||||
def test_render_element_data_text(self, layout_manager, mock_display_manager):
|
||||
"""Test rendering a data text element."""
|
||||
element = {
|
||||
"type": "data_text",
|
||||
"x": 10,
|
||||
"y": 20,
|
||||
"properties": {
|
||||
"data_key": "weather.temperature",
|
||||
"format": "Temp: {value}°F",
|
||||
"color": [255, 255, 255],
|
||||
"default": "N/A"
|
||||
}
|
||||
}
|
||||
data_context = {
|
||||
"weather": {
|
||||
"temperature": 72
|
||||
}
|
||||
}
|
||||
|
||||
layout_manager.render_element(element, data_context)
|
||||
|
||||
mock_display_manager.draw_text.assert_called_once()
|
||||