Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd7e16e58e | ||
|
|
47e3021fc3 | ||
|
|
e319540c6e |
@@ -5,10 +5,6 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
# Both jobs only check out the repo and run pytest.
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
plugin-safety:
|
plugin-safety:
|
||||||
name: Plugin safety harness + unit tests
|
name: Plugin safety harness + unit tests
|
||||||
@@ -35,41 +31,3 @@ jobs:
|
|||||||
test/plugins/test_harness.py \
|
test/plugins/test_harness.py \
|
||||||
test/plugins/test_visual_rendering.py \
|
test/plugins/test_visual_rendering.py \
|
||||||
test/plugins/test_plugin_matrix.py
|
test/plugins/test_plugin_matrix.py
|
||||||
|
|
||||||
unit-tests:
|
|
||||||
name: Core unit tests
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
cache: pip
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
python -m pip install --upgrade pip
|
|
||||||
pip install -r requirements.txt -r requirements-test.txt
|
|
||||||
pip install RGBMatrixEmulator
|
|
||||||
|
|
||||||
# Safety net for the shared sports/scroll/style infrastructure. These
|
|
||||||
# suites existed but were not enrolled in CI, so a refactor of
|
|
||||||
# src/base_classes or src/common could regress them silently. Enrolled
|
|
||||||
# explicitly (not `pytest test/`) so known hardware-only suites don't
|
|
||||||
# break CI; grow this list as more suites are made headless.
|
|
||||||
- name: Run core unit suites
|
|
||||||
run: |
|
|
||||||
pytest --no-cov \
|
|
||||||
test/test_skin_system.py \
|
|
||||||
test/test_font_manager.py \
|
|
||||||
test/test_data_sources.py \
|
|
||||||
test/test_api_extractors.py \
|
|
||||||
test/test_scroll_helper.py \
|
|
||||||
test/test_scroll_helper_continuous.py \
|
|
||||||
test/test_adaptive_layout.py \
|
|
||||||
test/test_loader_compat_warning.py \
|
|
||||||
test/test_sports_base_characterization.py \
|
|
||||||
test/test_element_style.py
|
|
||||||
|
|||||||
@@ -48,4 +48,3 @@ config/backups/
|
|||||||
|
|
||||||
# Starlark apps runtime storage (installed .star files and cached renders)
|
# Starlark apps runtime storage (installed .star files and cached renders)
|
||||||
/starlark-apps/
|
/starlark-apps/
|
||||||
skin_renders/
|
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
# Changelog
|
|
||||||
|
|
||||||
Notable changes to the LEDMatrix core. The version below is the value of
|
|
||||||
`src.__version__`, which the plugin loader reports to compatibility checks and
|
|
||||||
which plugin manifests reference via `ledmatrix_min_version`.
|
|
||||||
|
|
||||||
**Why this file exists:** the plugin monorepo bundles fallback copies of several
|
|
||||||
core modules (see `docs/plugin-development/08-shared-sports-code.md` in
|
|
||||||
[ledmatrix-plugins](https://github.com/ChuckBuilds/ledmatrix-plugins)). A plugin
|
|
||||||
may delete its bundled copy only when its manifest floors on the first core
|
|
||||||
release that ships the module — which requires module additions to be recorded
|
|
||||||
here, against a version number. When you add a module plugins will import via
|
|
||||||
`src.*`, note it in the Unreleased section and bump `src/__init__.py` in the
|
|
||||||
release that ships it.
|
|
||||||
|
|
||||||
## Unreleased
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- `src/element_style.py` — per-element style resolver backing the
|
|
||||||
`x-style-elements` config-schema extension. Already consumed (behind guarded
|
|
||||||
imports with classic fallbacks) by the `of-the-day`, `ledmatrix-music`, and
|
|
||||||
`football-scoreboard` plugins.
|
|
||||||
- Core unit-test CI job enrolling the previously unenrolled suites (skin
|
|
||||||
system, data sources, API extractors, scroll helper, adaptive layout, loader
|
|
||||||
compatibility warning) plus new characterization tests for
|
|
||||||
`src/base_classes/sports.py` ahead of the shared sports-code unification.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- `FontManager` resolves `assets/fonts` against the core install root instead
|
|
||||||
of the process working directory, so font loading works when the process
|
|
||||||
starts elsewhere (e.g. the plugin safety harness on CI).
|
|
||||||
|
|
||||||
## 3.1.0
|
|
||||||
|
|
||||||
Baseline for this changelog. Highlights already shipped at this version:
|
|
||||||
skin system for sports scoreboards (#419), Vegas continuous-scroll overhaul
|
|
||||||
(#423), plugin update surfacing (#421).
|
|
||||||
@@ -31,14 +31,6 @@
|
|||||||
- Plugin configs stored in `config/config.json`, NOT in plugin directories — safe across reinstalls
|
- Plugin configs stored in `config/config.json`, NOT in plugin directories — safe across reinstalls
|
||||||
- Third-party plugins can use their own repo URL with empty `plugin_path`
|
- Third-party plugins can use their own repo URL with empty `plugin_path`
|
||||||
|
|
||||||
## Skin System (visual overlays for sports scoreboards)
|
|
||||||
- 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
|
## Common Pitfalls
|
||||||
- paho-mqtt 2.x needs `callback_api_version=mqtt.CallbackAPIVersion.VERSION1` for v1 compat
|
- paho-mqtt 2.x needs `callback_api_version=mqtt.CallbackAPIVersion.VERSION1` for v1 compat
|
||||||
- BasePlugin uses `get_logger()` from `src.logging_config`, not standard `logging.getLogger()`
|
- BasePlugin uses `get_logger()` from `src.logging_config`, not standard `logging.getLogger()`
|
||||||
|
|||||||
@@ -440,16 +440,6 @@ 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.
|
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.
|
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>
|
</details>
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 16 KiB |
@@ -1,29 +0,0 @@
|
|||||||
# 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,7 +121,6 @@
|
|||||||
"axis": "horizontal"
|
"axis": "horizontal"
|
||||||
},
|
},
|
||||||
"display_durations": {},
|
"display_durations": {},
|
||||||
"plugin_rotation_order": [],
|
|
||||||
"use_short_date_format": true,
|
"use_short_date_format": true,
|
||||||
"vegas_scroll": {
|
"vegas_scroll": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
@@ -130,25 +129,7 @@
|
|||||||
"plugin_order": [],
|
"plugin_order": [],
|
||||||
"excluded_plugins": [],
|
"excluded_plugins": [],
|
||||||
"target_fps": 125,
|
"target_fps": 125,
|
||||||
"buffer_ahead": 2,
|
"buffer_ahead": 2
|
||||||
"intra_plugin_gap": 8,
|
|
||||||
"render_width_pct": 100,
|
|
||||||
"min_content_separation": 24,
|
|
||||||
"min_cut_gap": 6,
|
|
||||||
"continuous_scroll": true,
|
|
||||||
"smooth_scroll": true,
|
|
||||||
"extend_threshold_screens": 2.0,
|
|
||||||
"auto_trim": true,
|
|
||||||
"trim_threshold": 10,
|
|
||||||
"content_padding": 8,
|
|
||||||
"min_plugin_width": 8,
|
|
||||||
"lead_in_width": 0,
|
|
||||||
"plugins_per_cycle": 6,
|
|
||||||
"max_plugin_width_ratio": 3.0,
|
|
||||||
"overflow_mode": "rotate",
|
|
||||||
"dynamic_duration_enabled": true,
|
|
||||||
"min_cycle_duration": 60,
|
|
||||||
"max_cycle_duration": 240
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sync": {
|
"sync": {
|
||||||
|
|||||||
@@ -1,242 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -248,6 +248,7 @@ test/
|
|||||||
├── test_config_service.py # Config service tests
|
├── test_config_service.py # Config service tests
|
||||||
├── test_config_validation_edge_cases.py # Config edge cases
|
├── test_config_validation_edge_cases.py # Config edge cases
|
||||||
├── test_font_manager.py # Font manager tests
|
├── test_font_manager.py # Font manager tests
|
||||||
|
├── test_layout_manager.py # Layout manager tests
|
||||||
├── test_text_helper.py # Text helper tests
|
├── test_text_helper.py # Text helper tests
|
||||||
├── test_error_handling.py # Error handling tests
|
├── test_error_handling.py # Error handling tests
|
||||||
├── test_error_aggregator.py # Error aggregation tests
|
├── test_error_aggregator.py # Error aggregation tests
|
||||||
|
|||||||
@@ -10,12 +10,6 @@ This guide explains how to set up a development workflow for plugins that are ma
|
|||||||
> scale. Existing plugins keep their classic rendering unless they adopt
|
> scale. Existing plugins keep their classic rendering unless they adopt
|
||||||
> those APIs; nothing migrates automatically.
|
> those APIs; nothing migrates automatically.
|
||||||
|
|
||||||
> **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
|
## Overview
|
||||||
|
|
||||||
When developing plugins in separate repositories, you need a way to:
|
When developing plugins in separate repositories, you need a way to:
|
||||||
|
|||||||
@@ -1,170 +0,0 @@
|
|||||||
# 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,40 +206,6 @@ 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.
|
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
|
## Creating Custom Widgets
|
||||||
|
|
||||||
### Step 1: Create Widget File
|
### Step 1: Create Widget File
|
||||||
|
|||||||
@@ -8,11 +8,16 @@ numpy>=1.24.0 # For fast array operations in ScrollHelper (compatible with 2.x)
|
|||||||
|
|
||||||
# Timezone handling
|
# Timezone handling
|
||||||
pytz>=2024.2,<2025.0 # Updated for latest timezone data
|
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
|
# HTTP requests
|
||||||
requests>=2.33.0,<3.0.0
|
requests>=2.33.0,<3.0.0
|
||||||
|
|
||||||
# Google API integration
|
# 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
|
# Font rendering
|
||||||
freetype-py>=2.5.1,<3.0.0
|
freetype-py>=2.5.1,<3.0.0
|
||||||
@@ -24,8 +29,10 @@ spotipy>=2.25.2,<3.0.0
|
|||||||
Flask>=3.1.3,<4.0.0
|
Flask>=3.1.3,<4.0.0
|
||||||
|
|
||||||
# Text processing
|
# Text processing
|
||||||
|
unidecode>=1.3.8,<2.0.0
|
||||||
|
|
||||||
# Calendar integration
|
# Calendar integration
|
||||||
|
icalevents>=0.1.27,<1.0.0
|
||||||
|
|
||||||
# WebSocket support
|
# WebSocket support
|
||||||
python-socketio>=5.14.0,<6.0.0
|
python-socketio>=5.14.0,<6.0.0
|
||||||
|
|||||||
@@ -1,344 +0,0 @@
|
|||||||
#!/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())
|
|
||||||
@@ -55,7 +55,7 @@ def main():
|
|||||||
failures += not check("draw.textbbox",
|
failures += not check("draw.textbbox",
|
||||||
lambda: draw.textbbox((0, 0), "Test", font=font))
|
lambda: draw.textbbox((0, 0), "Test", font=font))
|
||||||
|
|
||||||
print("\nResampling (used in logo_helper, sports base):")
|
print("\nResampling (used in logo_helper, image_utils, sports base):")
|
||||||
logo = Image.new('RGBA', (200, 200), (255, 128, 0, 200))
|
logo = Image.new('RGBA', (200, 200), (255, 128, 0, 200))
|
||||||
failures += not check("Image.Resampling.LANCZOS exists",
|
failures += not check("Image.Resampling.LANCZOS exists",
|
||||||
lambda: str(Image.Resampling.LANCZOS))
|
lambda: str(Image.Resampling.LANCZOS))
|
||||||
|
|||||||
@@ -1,384 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Vegas Mode Density Audit
|
|
||||||
|
|
||||||
Reports how much of the Vegas ticker is actually showing something. Loads the
|
|
||||||
real enabled plugins, pulls each one's content through the real
|
|
||||||
``PluginAdapter``, composes the strip through the real ``ScrollHelper``, then
|
|
||||||
measures the result.
|
|
||||||
|
|
||||||
The headline number is the **dead-frame ratio**: the fraction of viewport
|
|
||||||
positions across a full cycle that are effectively blank. Because the panel
|
|
||||||
only ever shows ``display_width`` columns at a time, a blank stretch wider than
|
|
||||||
the viewport is a stretch where the display looks switched off — so this ratio
|
|
||||||
tracks perceived dead time rather than just counting unlit pixels.
|
|
||||||
|
|
||||||
Runs entirely off-hardware, so it is safe to run alongside a live display.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
# Audit every enabled plugin at the display size from config.json
|
|
||||||
python scripts/dev/vegas_audit.py
|
|
||||||
|
|
||||||
# Specific plugins, dump each segment as a PNG for eyeballing
|
|
||||||
python scripts/dev/vegas_audit.py -p of-the-day,youtube-stats --dump-dir /tmp/vg
|
|
||||||
|
|
||||||
# Machine-readable, for before/after comparison
|
|
||||||
python scripts/dev/vegas_audit.py --json > after.json
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Dict, List
|
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
|
||||||
sys.path.insert(0, str(PROJECT_ROOT))
|
|
||||||
|
|
||||||
# Must precede any src import that may reach for hardware.
|
|
||||||
os.environ.setdefault('EMULATOR', 'true')
|
|
||||||
|
|
||||||
from PIL import Image # noqa: E402
|
|
||||||
|
|
||||||
from src.common.scroll_helper import ScrollHelper # noqa: E402
|
|
||||||
from src.plugin_system.testing.loading import ( # noqa: E402
|
|
||||||
build_full_config,
|
|
||||||
find_plugin_dir,
|
|
||||||
load_manifest,
|
|
||||||
)
|
|
||||||
from src.vegas_mode.config import VegasModeConfig # noqa: E402
|
|
||||||
from src.vegas_mode.geometry import ( # noqa: E402
|
|
||||||
DEFAULT_INK_THRESHOLD,
|
|
||||||
column_has_ink,
|
|
||||||
content_bounds,
|
|
||||||
dead_window_stats,
|
|
||||||
window_coverage_stats,
|
|
||||||
)
|
|
||||||
from src.vegas_mode.plugin_adapter import PluginAdapter # noqa: E402
|
|
||||||
|
|
||||||
# Sampling stride for the dead-window scan. A full cycle can be 30,000px wide;
|
|
||||||
# 4px granularity keeps the scan instant while staying well under the ~10px a
|
|
||||||
# single scroll step ever covers, so no dead stretch is missed.
|
|
||||||
DEAD_SCAN_STEP = 4
|
|
||||||
|
|
||||||
|
|
||||||
def load_main_config(path: Path) -> Dict[str, Any]:
|
|
||||||
with open(path, 'r') as fh:
|
|
||||||
return json.load(fh)
|
|
||||||
|
|
||||||
|
|
||||||
def display_size_from_config(config: Dict[str, Any]) -> tuple:
|
|
||||||
"""Derive the logical ticker size the way DisplayManager does."""
|
|
||||||
hw = config.get('display', {}).get('hardware', {})
|
|
||||||
cols = int(hw.get('cols', 64))
|
|
||||||
chain = int(hw.get('chain_length', 1))
|
|
||||||
rows = int(hw.get('rows', 32))
|
|
||||||
parallel = int(hw.get('parallel', 1))
|
|
||||||
return cols * chain, rows * parallel
|
|
||||||
|
|
||||||
|
|
||||||
def enabled_plugin_ids(config: Dict[str, Any]) -> List[str]:
|
|
||||||
"""Plugin IDs that are enabled in config, excluding non-plugin sections."""
|
|
||||||
ids = []
|
|
||||||
for key, value in config.items():
|
|
||||||
if isinstance(value, dict) and value.get('enabled') is True:
|
|
||||||
ids.append(key)
|
|
||||||
return ids
|
|
||||||
|
|
||||||
|
|
||||||
def instantiate(plugin_id: str, display_manager, cache_manager, plugin_manager):
|
|
||||||
"""Load one plugin offline. Returns the instance or None."""
|
|
||||||
from src.plugin_system.plugin_loader import PluginLoader
|
|
||||||
|
|
||||||
search_dirs = [
|
|
||||||
str(PROJECT_ROOT / 'plugin-repos'),
|
|
||||||
str(PROJECT_ROOT / 'plugins'),
|
|
||||||
]
|
|
||||||
plugin_dir = find_plugin_dir(plugin_id, search_dirs)
|
|
||||||
if not plugin_dir:
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
manifest = load_manifest(Path(plugin_dir))
|
|
||||||
cfg = build_full_config(Path(plugin_dir))
|
|
||||||
instance, _ = PluginLoader().load_plugin(
|
|
||||||
plugin_id=plugin_id,
|
|
||||||
manifest=manifest,
|
|
||||||
plugin_dir=Path(plugin_dir),
|
|
||||||
config=cfg,
|
|
||||||
display_manager=display_manager,
|
|
||||||
cache_manager=cache_manager,
|
|
||||||
plugin_manager=plugin_manager,
|
|
||||||
install_deps=False,
|
|
||||||
)
|
|
||||||
return instance
|
|
||||||
except Exception as exc: # noqa: BLE001 - audit tool must survive any plugin
|
|
||||||
print(f" ! {plugin_id}: load failed ({type(exc).__name__}: {exc})",
|
|
||||||
file=sys.stderr)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def join_rows(images: List[Image.Image], gap: int) -> Image.Image:
|
|
||||||
"""Concatenate one plugin's rows, matching RenderPipeline._join_plugin_rows."""
|
|
||||||
if len(images) == 1:
|
|
||||||
return images[0]
|
|
||||||
gap = max(0, gap)
|
|
||||||
width = sum(img.width for img in images) + gap * (len(images) - 1)
|
|
||||||
height = max(img.height for img in images)
|
|
||||||
block = Image.new('RGB', (width, height), (0, 0, 0))
|
|
||||||
x = 0
|
|
||||||
for img in images:
|
|
||||||
block.paste(img, (x, 0))
|
|
||||||
x += img.width + gap
|
|
||||||
return block
|
|
||||||
|
|
||||||
|
|
||||||
def measure_segment(images: List[Image.Image], display_width: int,
|
|
||||||
scroll_speed: float, threshold: int) -> Dict[str, Any]:
|
|
||||||
"""Geometry of one plugin's contribution to the ticker."""
|
|
||||||
total_width = sum(img.width for img in images)
|
|
||||||
combined = Image.new('RGB', (max(1, total_width), images[0].height))
|
|
||||||
x = 0
|
|
||||||
for img in images:
|
|
||||||
combined.paste(img, (x, 0))
|
|
||||||
x += img.width
|
|
||||||
|
|
||||||
ink = column_has_ink(combined, threshold)
|
|
||||||
bounds = content_bounds(combined, threshold)
|
|
||||||
ink_cols = int(ink.sum())
|
|
||||||
|
|
||||||
return {
|
|
||||||
'images': len(images),
|
|
||||||
'width_px': total_width,
|
|
||||||
'ink_cols': ink_cols,
|
|
||||||
'ink_pct': round(100.0 * ink_cols / total_width, 1) if total_width else 0.0,
|
|
||||||
'lead_black_px': bounds[0] if bounds else total_width,
|
|
||||||
'trail_black_px': (total_width - 1 - bounds[1]) if bounds else 0,
|
|
||||||
'seconds_on_screen': round(total_width / scroll_speed, 1) if scroll_speed else 0.0,
|
|
||||||
'widths': [img.width for img in images],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description='Audit Vegas mode content density')
|
|
||||||
parser.add_argument('--config', default=str(PROJECT_ROOT / 'config' / 'config.json'),
|
|
||||||
help='Path to main config.json')
|
|
||||||
parser.add_argument('-p', '--plugins', default=None,
|
|
||||||
help='Comma-separated plugin IDs (default: all enabled)')
|
|
||||||
parser.add_argument('--width', type=int, default=None,
|
|
||||||
help='Override display width (default: from config hardware)')
|
|
||||||
parser.add_argument('--height', type=int, default=None,
|
|
||||||
help='Override display height (default: from config hardware)')
|
|
||||||
parser.add_argument('--dump-dir', default=None,
|
|
||||||
help='Write each segment and the composed strip as PNGs here')
|
|
||||||
parser.add_argument('--threshold', type=int, default=DEFAULT_INK_THRESHOLD,
|
|
||||||
help=f'Ink threshold (default: {DEFAULT_INK_THRESHOLD})')
|
|
||||||
parser.add_argument('--per-cycle', type=int, default=None,
|
|
||||||
help='Plugins composed per cycle '
|
|
||||||
'(default: buffer_ahead + 1, matching production)')
|
|
||||||
parser.add_argument('--json', action='store_true',
|
|
||||||
help='Emit JSON instead of a text report')
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
config = load_main_config(Path(args.config))
|
|
||||||
vegas = VegasModeConfig.from_config(config)
|
|
||||||
|
|
||||||
cfg_w, cfg_h = display_size_from_config(config)
|
|
||||||
width = args.width or cfg_w
|
|
||||||
height = args.height or cfg_h
|
|
||||||
speed = vegas.scroll_speed
|
|
||||||
|
|
||||||
if args.plugins:
|
|
||||||
plugin_ids = [p.strip() for p in args.plugins.split(',') if p.strip()]
|
|
||||||
else:
|
|
||||||
plugin_ids = vegas.get_ordered_plugins(enabled_plugin_ids(config))
|
|
||||||
|
|
||||||
dump_dir = Path(args.dump_dir) if args.dump_dir else None
|
|
||||||
if dump_dir:
|
|
||||||
dump_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
from src.plugin_system.testing import (
|
|
||||||
MockCacheManager, MockPluginManager, VisualTestDisplayManager,
|
|
||||||
)
|
|
||||||
|
|
||||||
display_manager = VisualTestDisplayManager(width=width, height=height)
|
|
||||||
cache_manager = MockCacheManager()
|
|
||||||
plugin_manager = MockPluginManager()
|
|
||||||
# Pass the loaded config, exactly as VegasModeCoordinator does. Omitting it
|
|
||||||
# makes PluginAdapter fall back to VegasModeConfig() defaults, so the audit
|
|
||||||
# would silently report trimming and width-budget behaviour that differs
|
|
||||||
# from the user's config.json — the same drift the lead_gap and grouping
|
|
||||||
# arguments below exist to avoid.
|
|
||||||
adapter = PluginAdapter(display_manager, vegas)
|
|
||||||
|
|
||||||
if not args.json:
|
|
||||||
print(f"Vegas audit — display {width}x{height}, scroll {speed:g}px/s, "
|
|
||||||
f"separator {vegas.separator_width}px")
|
|
||||||
print(f"One display width = {width / speed:.1f}s of screen time\n")
|
|
||||||
|
|
||||||
results: List[Dict[str, Any]] = []
|
|
||||||
segments: List[Image.Image] = []
|
|
||||||
|
|
||||||
for plugin_id in plugin_ids:
|
|
||||||
started = time.time()
|
|
||||||
instance = instantiate(plugin_id, display_manager, cache_manager, plugin_manager)
|
|
||||||
if instance is None:
|
|
||||||
results.append({'plugin': plugin_id, 'status': 'load_failed'})
|
|
||||||
continue
|
|
||||||
|
|
||||||
plugin_manager.plugins[plugin_id] = instance
|
|
||||||
adapter.invalidate_cache(plugin_id)
|
|
||||||
|
|
||||||
try:
|
|
||||||
images = adapter.get_content(instance, plugin_id)
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
results.append({'plugin': plugin_id, 'status': 'fetch_error',
|
|
||||||
'error': f'{type(exc).__name__}: {exc}'})
|
|
||||||
continue
|
|
||||||
|
|
||||||
fetch_ms = round((time.time() - started) * 1000)
|
|
||||||
|
|
||||||
if not images:
|
|
||||||
results.append({'plugin': plugin_id, 'status': 'no_content',
|
|
||||||
'fetch_ms': fetch_ms})
|
|
||||||
if not args.json:
|
|
||||||
print(f" {plugin_id:28s} NO CONTENT ({fetch_ms}ms)")
|
|
||||||
continue
|
|
||||||
|
|
||||||
entry = {'plugin': plugin_id, 'status': 'ok', 'fetch_ms': fetch_ms}
|
|
||||||
entry.update(measure_segment(images, width, speed, args.threshold))
|
|
||||||
results.append(entry)
|
|
||||||
segments.extend(images)
|
|
||||||
|
|
||||||
if dump_dir:
|
|
||||||
for idx, img in enumerate(images):
|
|
||||||
img.save(dump_dir / f"{plugin_id}__{idx:02d}.png")
|
|
||||||
|
|
||||||
if not args.json:
|
|
||||||
print(f" {plugin_id:28s} {entry['width_px']:>6d}px "
|
|
||||||
f"{entry['images']:>2d} img ink {entry['ink_pct']:>5.1f}% "
|
|
||||||
f"lead {entry['lead_black_px']:>4d} tail {entry['trail_black_px']:>4d} "
|
|
||||||
f"{entry['seconds_on_screen']:>6.1f}s ({fetch_ms}ms)")
|
|
||||||
|
|
||||||
summary: Dict[str, Any] = {
|
|
||||||
'display_width': width,
|
|
||||||
'display_height': height,
|
|
||||||
'scroll_speed': speed,
|
|
||||||
'separator_width': vegas.separator_width,
|
|
||||||
'plugins_audited': len(plugin_ids),
|
|
||||||
'plugins_with_content': sum(1 for r in results if r.get('status') == 'ok'),
|
|
||||||
}
|
|
||||||
|
|
||||||
# Production composes only the plugins sitting in the active buffer, so
|
|
||||||
# measuring one giant strip of every plugin would hide the per-cycle costs
|
|
||||||
# (most importantly the leading gap, which is charged once per cycle).
|
|
||||||
# Group the segments the way the running service does.
|
|
||||||
per_cycle = max(1, args.per_cycle or vegas.plugins_per_cycle)
|
|
||||||
|
|
||||||
cycles: List[Dict[str, Any]] = []
|
|
||||||
with_content = [r for r in results if r.get('status') == 'ok']
|
|
||||||
|
|
||||||
if segments:
|
|
||||||
logger = logging.getLogger('vegas_audit')
|
|
||||||
seg_index = 0
|
|
||||||
for start in range(0, len(with_content), per_cycle):
|
|
||||||
group = with_content[start:start + per_cycle]
|
|
||||||
|
|
||||||
# Mirror RenderPipeline: each plugin's rows are joined by
|
|
||||||
# intra_plugin_gap into one block, and separator_width is applied
|
|
||||||
# only between blocks. Measuring a flat list here would report gaps
|
|
||||||
# the service does not emit.
|
|
||||||
blocks: List[Image.Image] = []
|
|
||||||
for entry in group:
|
|
||||||
count = entry['images']
|
|
||||||
rows = segments[seg_index:seg_index + count]
|
|
||||||
seg_index += count
|
|
||||||
if rows:
|
|
||||||
blocks.append(join_rows(rows, vegas.intra_plugin_gap))
|
|
||||||
if not blocks:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# ScrollHelper logs unconditionally, so it needs a real logger.
|
|
||||||
helper = ScrollHelper(width, height, logger)
|
|
||||||
helper.create_scrolling_image(
|
|
||||||
content_items=blocks,
|
|
||||||
item_gap=vegas.separator_width,
|
|
||||||
element_gap=0,
|
|
||||||
# Must match RenderPipeline. Omitting this made the audit
|
|
||||||
# measure a full-display-width leading gap the service no
|
|
||||||
# longer emits, overstating dead space by 512px per cycle.
|
|
||||||
lead_gap=vegas.lead_in_width,
|
|
||||||
)
|
|
||||||
composed = helper.cached_image
|
|
||||||
if composed is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
dead = dead_window_stats(composed, width, args.threshold, step=DEAD_SCAN_STEP)
|
|
||||||
cover = window_coverage_stats(
|
|
||||||
composed, width, args.threshold, step=DEAD_SCAN_STEP)
|
|
||||||
|
|
||||||
if dump_dir:
|
|
||||||
composed.save(dump_dir / f"_cycle{len(cycles):02d}.png")
|
|
||||||
|
|
||||||
cycles.append({
|
|
||||||
'plugins': [e['plugin'] for e in group],
|
|
||||||
'width_px': composed.width,
|
|
||||||
'seconds': round(composed.width / speed, 1) if speed else 0.0,
|
|
||||||
'dead_pct': round(100 * dead.dead_ratio, 1),
|
|
||||||
'longest_dead_seconds': round(
|
|
||||||
dead.longest_dead_run * DEAD_SCAN_STEP / speed, 1) if speed else 0.0,
|
|
||||||
'mean_ink_pct': round(100 * cover.mean_ink_ratio, 1),
|
|
||||||
'sparse_pct': round(100 * cover.sparse_ratio, 1),
|
|
||||||
'longest_sparse_seconds': round(
|
|
||||||
cover.longest_sparse_run * DEAD_SCAN_STEP / speed, 1) if speed else 0.0,
|
|
||||||
})
|
|
||||||
|
|
||||||
if cycles:
|
|
||||||
total_px = sum(c['width_px'] for c in cycles)
|
|
||||||
# Weight each cycle by its width so a long cycle counts proportionally.
|
|
||||||
summary.update({
|
|
||||||
'cycles': len(cycles),
|
|
||||||
'total_px': total_px,
|
|
||||||
'full_rotation_seconds': round(total_px / speed, 1) if speed else 0.0,
|
|
||||||
'dead_pct': round(
|
|
||||||
sum(c['dead_pct'] * c['width_px'] for c in cycles) / total_px, 1),
|
|
||||||
'mean_ink_pct': round(
|
|
||||||
sum(c['mean_ink_pct'] * c['width_px'] for c in cycles) / total_px, 1),
|
|
||||||
'sparse_pct': round(
|
|
||||||
sum(c['sparse_pct'] * c['width_px'] for c in cycles) / total_px, 1),
|
|
||||||
'worst_dead_seconds': max(c['longest_dead_seconds'] for c in cycles),
|
|
||||||
'worst_sparse_seconds': max(c['longest_sparse_seconds'] for c in cycles),
|
|
||||||
})
|
|
||||||
|
|
||||||
if args.json:
|
|
||||||
print(json.dumps({'summary': summary, 'cycles': cycles, 'plugins': results},
|
|
||||||
indent=2))
|
|
||||||
else:
|
|
||||||
print(f"\n Cycles ({per_cycle} plugins each, as production composes them):")
|
|
||||||
for idx, cyc in enumerate(cycles):
|
|
||||||
print(f" [{idx}] {cyc['width_px']:>6d}px {cyc['seconds']:>6.1f}s "
|
|
||||||
f"ink {cyc['mean_ink_pct']:>5.1f}% blank {cyc['dead_pct']:>5.1f}% "
|
|
||||||
f"worst blank {cyc['longest_dead_seconds']:>5.1f}s "
|
|
||||||
f"| {', '.join(cyc['plugins'])}")
|
|
||||||
|
|
||||||
print(f"\n {'-' * 66}")
|
|
||||||
print(f" full rotation {summary.get('full_rotation_seconds', 0):>7.1f}s "
|
|
||||||
f"over {summary.get('cycles', 0)} cycles")
|
|
||||||
print(f" mean ink coverage {summary.get('mean_ink_pct', 0):>7.1f}% "
|
|
||||||
f"(higher is better; target >25%)")
|
|
||||||
print(f" fully blank {summary.get('dead_pct', 0):>7.1f}% (target <2%)")
|
|
||||||
print(f" reads as empty {summary.get('sparse_pct', 0):>7.1f}% (target <15%)")
|
|
||||||
print(f" worst blank stretch {summary.get('worst_dead_seconds', 0):>7.1f}s "
|
|
||||||
f"(target <1.5s)")
|
|
||||||
print(f" plugins w/ content {summary.get('plugins_with_content', 0):>7d}"
|
|
||||||
f" of {summary['plugins_audited']}")
|
|
||||||
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
raise SystemExit(main())
|
|
||||||
@@ -1,356 +0,0 @@
|
|||||||
#!/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())
|
|
||||||
@@ -1,593 +0,0 @@
|
|||||||
#!/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())
|
|
||||||
@@ -1,248 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Headless skin validator — render a skin against bundled fixture games at
|
|
||||||
multiple panel sizes without hardware, a network, or a running service.
|
|
||||||
|
|
||||||
python scripts/validate_skin.py --skin my-skin
|
|
||||||
python scripts/validate_skin.py --skin my-skin --sport baseball \
|
|
||||||
--size 128x32 --size 64x32 --output-dir /tmp/skin_renders
|
|
||||||
|
|
||||||
For each (mode x size) it checks: the manifest loads and its API version
|
|
||||||
matches, the render raises no exception, the canvas isn't blank, and the
|
|
||||||
render finishes inside a time budget (warn — the live renderer runs every
|
|
||||||
display-loop pass, and a Pi is far slower than your dev machine). PNGs are
|
|
||||||
saved (native plus 4x nearest-neighbor previews) so you can eyeball the
|
|
||||||
result. Exit code is non-zero when any check fails.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
sys.path.insert(0, str(PROJECT_ROOT))
|
|
||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont # noqa: E402
|
|
||||||
|
|
||||||
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())
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
# 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.
|
|
||||||
|
Before Width: | Height: | Size: 5.3 KiB |
@@ -1,25 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "example-classic-baseball",
|
|
||||||
"name": "Example: Classic Baseball",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"author": "LEDMatrix",
|
|
||||||
"description": "Reference skin: a restyled baseball scorebug demonstrating the skin API. Copy this directory to start your own skin.",
|
|
||||||
"skin_api_version": "1.0.0",
|
|
||||||
"targets": {
|
|
||||||
"sports": [
|
|
||||||
"baseball"
|
|
||||||
],
|
|
||||||
"sport_keys": [
|
|
||||||
"mlb",
|
|
||||||
"milb"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"entry_point": "skin.py",
|
|
||||||
"class_name": "ClassicBaseballSkin",
|
|
||||||
"modes": [
|
|
||||||
"live",
|
|
||||||
"recent",
|
|
||||||
"upcoming"
|
|
||||||
],
|
|
||||||
"preview": "preview.png"
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
"""
|
|
||||||
Example: Classic Baseball — the reference skin.
|
|
||||||
|
|
||||||
Shows the whole skin API surface on purpose: adaptive regions
|
|
||||||
(scoreboard_regions), fitted text (ctx.layout.fit_text + ctx.draw_fit),
|
|
||||||
logos (ctx.load_logo + ctx.draw_image), raw PIL (ctx.draw for the bases
|
|
||||||
diamond), and per-user options (ctx.options). Everything is derived from
|
|
||||||
ctx and the game dict — a skin holds no state, does no I/O, and never
|
|
||||||
touches the display.
|
|
||||||
|
|
||||||
Copy this directory to skins/<your-skin-id>/, rename the class and the
|
|
||||||
manifest fields, and run:
|
|
||||||
|
|
||||||
python scripts/validate_skin.py --skin <your-skin-id>
|
|
||||||
"""
|
|
||||||
|
|
||||||
from src.adaptive_layout import LADDER_GRID, scoreboard_regions
|
|
||||||
from src.skin_system.skin_base import ScoreboardSkin, SkinContext
|
|
||||||
|
|
||||||
DEFAULT_ACCENT = (255, 200, 0)
|
|
||||||
|
|
||||||
|
|
||||||
class ClassicBaseballSkin(ScoreboardSkin):
|
|
||||||
"""Reference baseball skin: classic scorebug with bases/outs/count."""
|
|
||||||
|
|
||||||
def __init__(self, manifest: dict, options: dict):
|
|
||||||
super().__init__(manifest, options)
|
|
||||||
# Validate user options once at load time (fail fast, fall back
|
|
||||||
# gracefully) rather than surprising every render.
|
|
||||||
accent = self.options.get("accent_color", DEFAULT_ACCENT)
|
|
||||||
if (isinstance(accent, (list, tuple)) and len(accent) == 3
|
|
||||||
and all(isinstance(c, int) and 0 <= c <= 255 for c in accent)):
|
|
||||||
self._accent_color = tuple(accent)
|
|
||||||
else:
|
|
||||||
import logging
|
|
||||||
logging.getLogger(__name__).error(
|
|
||||||
"accent_color must be three 0-255 integers, got %r; using default", accent)
|
|
||||||
self._accent_color = DEFAULT_ACCENT
|
|
||||||
|
|
||||||
# -- shared pieces ----------------------------------------------------
|
|
||||||
|
|
||||||
def _accent(self, ctx: SkinContext) -> tuple:
|
|
||||||
"""Users can recolor the skin from config via skin_options."""
|
|
||||||
return self._accent_color
|
|
||||||
|
|
||||||
def _draw_card(self, ctx: SkinContext, game: dict, status: str,
|
|
||||||
center_lines: list, detail: str) -> None:
|
|
||||||
"""The common card: logos left/right, status on top, the given
|
|
||||||
center content, detail along the bottom."""
|
|
||||||
regions = scoreboard_regions(ctx.layout.bounds, ctx=ctx.layout)
|
|
||||||
|
|
||||||
ctx.draw_image(ctx.load_logo("away"), regions.away_slot,
|
|
||||||
cache_key=f"logo:{game.get('away_abbr')}")
|
|
||||||
ctx.draw_image(ctx.load_logo("home"), regions.home_slot,
|
|
||||||
cache_key=f"logo:{game.get('home_abbr')}")
|
|
||||||
|
|
||||||
if status:
|
|
||||||
fit = ctx.layout.fit_text(status, regions.status_band, LADDER_GRID)
|
|
||||||
ctx.draw_fit(fit, regions.status_band, color=self._accent(ctx))
|
|
||||||
|
|
||||||
if center_lines:
|
|
||||||
rows = regions.score_area.split_v(*[1] * len(center_lines))
|
|
||||||
for line, row in zip(center_lines, rows):
|
|
||||||
if line:
|
|
||||||
fit = ctx.layout.fit_text(line, row, LADDER_GRID)
|
|
||||||
ctx.draw_fit(fit, row)
|
|
||||||
|
|
||||||
if detail:
|
|
||||||
fit = ctx.layout.fit_text(detail, regions.detail_band, LADDER_GRID)
|
|
||||||
ctx.draw_fit(fit, regions.detail_band, color=(160, 160, 160))
|
|
||||||
|
|
||||||
def _draw_bases_and_outs(self, ctx: SkinContext, game: dict) -> None:
|
|
||||||
"""Raw-PIL escape hatch: a bases diamond + out dots in the bottom
|
|
||||||
band, sized from the layout scale so it works on any panel."""
|
|
||||||
size = ctx.layout.px(3, minimum=2) # half-diagonal of one base
|
|
||||||
gap = ctx.layout.px(1)
|
|
||||||
cx = ctx.width // 2
|
|
||||||
cy = ctx.height - (size * 2) - 1
|
|
||||||
|
|
||||||
bases = game.get("bases_occupied") or [False, False, False]
|
|
||||||
# (dx, dy) per base: first (right), second (top), third (left)
|
|
||||||
offsets = [(size + gap, 0), (0, -(size + gap)), (-(size + gap), 0)]
|
|
||||||
for occupied, (dx, dy) in zip(bases, offsets):
|
|
||||||
x, y = cx + dx, cy + dy
|
|
||||||
diamond = [(x, y - size), (x + size, y), (x, y + size), (x - size, y)]
|
|
||||||
if occupied:
|
|
||||||
ctx.draw.polygon(diamond, fill=self._accent(ctx))
|
|
||||||
else:
|
|
||||||
ctx.draw.polygon(diamond, outline=(110, 110, 110))
|
|
||||||
|
|
||||||
outs = min(int(game.get("outs") or 0), 3)
|
|
||||||
r = max(1, size - 1)
|
|
||||||
for i in range(3):
|
|
||||||
x = cx + (i - 1) * (2 * r + 2 * gap)
|
|
||||||
y = ctx.height - r - 1
|
|
||||||
dot = [x - r, y - r, x + r, y + r]
|
|
||||||
if i < outs:
|
|
||||||
ctx.draw.ellipse(dot, fill=(255, 255, 255))
|
|
||||||
else:
|
|
||||||
ctx.draw.ellipse(dot, outline=(110, 110, 110))
|
|
||||||
|
|
||||||
# -- the three modes --------------------------------------------------
|
|
||||||
|
|
||||||
def render_live(self, ctx: SkinContext, game: dict) -> bool:
|
|
||||||
half = "▲" if game.get("inning_half") == "top" else "▼"
|
|
||||||
inning = game.get("inning") or ""
|
|
||||||
status = f"{half}{inning}" if inning else game.get("status_text", "")
|
|
||||||
score = f"{game.get('away_score', '0')}-{game.get('home_score', '0')}"
|
|
||||||
count = f"{game.get('balls', 0)}-{game.get('strikes', 0)}"
|
|
||||||
|
|
||||||
self._draw_card(ctx, game, status, [score], "")
|
|
||||||
self._draw_bases_and_outs(ctx, game)
|
|
||||||
|
|
||||||
# Ball-strike count in the top-left corner, over the away logo.
|
|
||||||
fit = ctx.layout.fit_text(count, (ctx.width // 4, ctx.layout.px(8, minimum=6)), LADDER_GRID)
|
|
||||||
ctx.draw_fit(fit, ctx.layout.bounds.top_band(fit.height + 1).left_col(fit.width + 2),
|
|
||||||
color=(200, 200, 200))
|
|
||||||
return True
|
|
||||||
|
|
||||||
def render_recent(self, ctx: SkinContext, game: dict) -> bool:
|
|
||||||
score = f"{game.get('away_score', '0')}-{game.get('home_score', '0')}"
|
|
||||||
self._draw_card(ctx, game, game.get("status_text", "Final"),
|
|
||||||
[score], game.get("series_summary", ""))
|
|
||||||
return True
|
|
||||||
|
|
||||||
def render_upcoming(self, ctx: SkinContext, game: dict) -> bool:
|
|
||||||
matchup = f"{game.get('away_abbr', '')}@{game.get('home_abbr', '')}"
|
|
||||||
self._draw_card(ctx, game, game.get("game_date", ""),
|
|
||||||
[matchup, game.get("game_time", "")],
|
|
||||||
f"{game.get('away_record', '')} {game.get('home_record', '')}".strip())
|
|
||||||
return True
|
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"""
|
||||||
|
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,10 +29,6 @@ except ImportError:
|
|||||||
|
|
||||||
|
|
||||||
class SportsCore(ABC):
|
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):
|
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str):
|
||||||
self.logger = logger
|
self.logger = logger
|
||||||
self.config = config
|
self.config = config
|
||||||
@@ -104,17 +100,6 @@ class SportsCore(ABC):
|
|||||||
self.current_game = None
|
self.current_game = None
|
||||||
self.fonts = self._load_fonts()
|
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
|
# Initialize dynamic team resolver and resolve favorite teams
|
||||||
self.dynamic_resolver = DynamicTeamResolver()
|
self.dynamic_resolver = DynamicTeamResolver()
|
||||||
raw_favorite_teams = self.mode_config.get("favorite_teams", [])
|
raw_favorite_teams = self.mode_config.get("favorite_teams", [])
|
||||||
@@ -220,95 +205,6 @@ class SportsCore(ABC):
|
|||||||
self.logger.error(f"Error in base _draw_scorebug_layout: {e}", exc_info=True)
|
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:
|
def display(self, force_clear: bool = False) -> bool:
|
||||||
"""Common display method for all NCAA FB managers""" # Updated docstring
|
"""Common display method for all NCAA FB managers""" # Updated docstring
|
||||||
if not self.is_enabled: # Check if module is enabled
|
if not self.is_enabled: # Check if module is enabled
|
||||||
@@ -333,7 +229,7 @@ class SportsCore(ABC):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._render_game(self.current_game, force_clear)
|
self._draw_scorebug_layout(self.current_game, force_clear)
|
||||||
# display_manager.update_display() should be called within subclass draw methods
|
# 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.
|
# or after calling display() in the main loop. Let's keep it out of the base display.
|
||||||
return True
|
return True
|
||||||
@@ -750,8 +646,6 @@ class SportsCore(ABC):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
class SportsUpcoming(SportsCore):
|
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):
|
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)
|
super().__init__(config, display_manager, cache_manager, logger, sport_key)
|
||||||
self.upcoming_games = [] # Store all fetched upcoming games initially
|
self.upcoming_games = [] # Store all fetched upcoming games initially
|
||||||
@@ -1079,7 +973,7 @@ class SportsUpcoming(SportsCore):
|
|||||||
self.logger.debug(f"Switched to game index {self.current_game_index}")
|
self.logger.debug(f"Switched to game index {self.current_game_index}")
|
||||||
|
|
||||||
if self.current_game:
|
if self.current_game:
|
||||||
self._render_game(self.current_game, force_clear)
|
self._draw_scorebug_layout(self.current_game, force_clear)
|
||||||
return True
|
return True
|
||||||
# update_display() is called within _draw_scorebug_layout for upcoming
|
# update_display() is called within _draw_scorebug_layout for upcoming
|
||||||
return False
|
return False
|
||||||
@@ -1090,7 +984,6 @@ class SportsUpcoming(SportsCore):
|
|||||||
|
|
||||||
|
|
||||||
class SportsRecent(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):
|
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)
|
super().__init__(config, display_manager, cache_manager, logger, sport_key)
|
||||||
@@ -1381,7 +1274,7 @@ class SportsRecent(SportsCore):
|
|||||||
self.logger.debug(f"Switched to game index {self.current_game_index}")
|
self.logger.debug(f"Switched to game index {self.current_game_index}")
|
||||||
|
|
||||||
if self.current_game:
|
if self.current_game:
|
||||||
self._render_game(self.current_game, force_clear)
|
self._draw_scorebug_layout(self.current_game, force_clear)
|
||||||
return True
|
return True
|
||||||
# update_display() is called within _draw_scorebug_layout for recent
|
# update_display() is called within _draw_scorebug_layout for recent
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -0,0 +1,328 @@
|
|||||||
|
"""
|
||||||
|
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.
|
||||||
|
"""
|
||||||
@@ -146,60 +146,6 @@ def ensure_file_permissions(path: Path, mode: int = 0o644) -> None:
|
|||||||
raise
|
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:
|
def get_config_file_mode(file_path: Path) -> int:
|
||||||
"""
|
"""
|
||||||
Return appropriate permission mode for config files.
|
Return appropriate permission mode for config files.
|
||||||
|
|||||||
@@ -112,8 +112,7 @@ class ScrollHelper:
|
|||||||
|
|
||||||
def create_scrolling_image(self, content_items: list,
|
def create_scrolling_image(self, content_items: list,
|
||||||
item_gap: int = 32,
|
item_gap: int = 32,
|
||||||
element_gap: int = 16,
|
element_gap: int = 16) -> Image.Image:
|
||||||
lead_gap: Optional[int] = None) -> Image.Image:
|
|
||||||
"""
|
"""
|
||||||
Create a wide image containing all content items for scrolling.
|
Create a wide image containing all content items for scrolling.
|
||||||
|
|
||||||
@@ -121,19 +120,10 @@ class ScrollHelper:
|
|||||||
content_items: List of PIL Images to include in scroll
|
content_items: List of PIL Images to include in scroll
|
||||||
item_gap: Gap between different items
|
item_gap: Gap between different items
|
||||||
element_gap: Gap between elements within an item
|
element_gap: Gap between elements within an item
|
||||||
lead_gap: Blank columns before the first item. Defaults to a full
|
|
||||||
display width, which makes a standalone ticker scroll in from
|
|
||||||
off-screen. Callers that loop many plugins back-to-back (Vegas
|
|
||||||
mode) pass a smaller value, since a full display width of black
|
|
||||||
reads as the panel being switched off at the start of every
|
|
||||||
cycle.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
PIL Image containing all content arranged horizontally
|
PIL Image containing all content arranged horizontally
|
||||||
"""
|
"""
|
||||||
if lead_gap is None:
|
|
||||||
lead_gap = self.display_width
|
|
||||||
lead_gap = max(0, int(lead_gap))
|
|
||||||
if not content_items:
|
if not content_items:
|
||||||
# Create empty image if no content
|
# Create empty image if no content
|
||||||
# Still set total_scroll_width to 0 to indicate no scrollable content
|
# Still set total_scroll_width to 0 to indicate no scrollable content
|
||||||
@@ -154,13 +144,13 @@ class ScrollHelper:
|
|||||||
total_width += element_gap * len(content_items)
|
total_width += element_gap * len(content_items)
|
||||||
|
|
||||||
# Add initial gap before first item
|
# Add initial gap before first item
|
||||||
total_width += lead_gap
|
total_width += self.display_width
|
||||||
|
|
||||||
# Create the full scrolling image
|
# Create the full scrolling image
|
||||||
full_image = Image.new('RGB', (total_width, self.display_height), (0, 0, 0))
|
full_image = Image.new('RGB', (total_width, self.display_height), (0, 0, 0))
|
||||||
|
|
||||||
# Position items
|
# Position items
|
||||||
current_x = lead_gap # Start with initial gap
|
current_x = self.display_width # Start with initial gap
|
||||||
|
|
||||||
for i, img in enumerate(content_items):
|
for i, img in enumerate(content_items):
|
||||||
# Paste the item image
|
# Paste the item image
|
||||||
@@ -349,72 +339,13 @@ class ScrollHelper:
|
|||||||
if not self.cached_image or self.cached_array is None:
|
if not self.cached_image or self.cached_array is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Use integer pixel positioning for high FPS scrolling (like stock ticker)
|
||||||
start_x_int = int(self.scroll_position)
|
start_x_int = int(self.scroll_position)
|
||||||
end_x_int = start_x_int + self.display_width
|
end_x_int = start_x_int + self.display_width
|
||||||
|
|
||||||
# Integer positioning quantises motion to whole pixels, so the number of
|
# Fast integer pixel path (no interpolation - high frame rate provides smoothness)
|
||||||
# distinct frames per second equals the scroll speed in px/s, no matter
|
|
||||||
# how fast the loop renders. At 50px/s and 78fps that made 36% of frames
|
|
||||||
# identical: the extra frames cost work and bought nothing. Blending
|
|
||||||
# between the two neighbouring positions gives motion at the frame rate
|
|
||||||
# instead of the step rate.
|
|
||||||
if self.sub_pixel_scrolling:
|
|
||||||
fractional = self.scroll_position - start_x_int
|
|
||||||
if fractional > 0.0:
|
|
||||||
return self._blend_visible_portion(start_x_int, fractional)
|
|
||||||
|
|
||||||
return self._get_visible_portion_integer(start_x_int, end_x_int)
|
return self._get_visible_portion_integer(start_x_int, end_x_int)
|
||||||
|
|
||||||
def _blend_visible_portion(self, start_x: int, fractional: float) -> Image.Image:
|
|
||||||
"""
|
|
||||||
Linear blend between the frames at ``start_x`` and ``start_x + 1``.
|
|
||||||
|
|
||||||
Implemented with numpy rather than scipy.ndimage.shift: scipy is not
|
|
||||||
installed on the target devices (HAS_SCIPY is False there), which is why
|
|
||||||
the pre-existing sub-pixel path was dead code — get_visible_portion never
|
|
||||||
consulted the flag, and the scipy fallback would not have interpolated
|
|
||||||
anyway.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
start_x: Left column of the earlier of the two frames
|
|
||||||
fractional: How far between the two, in [0, 1)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The blended frame
|
|
||||||
"""
|
|
||||||
width = self.display_width
|
|
||||||
strip_width = self.cached_array.shape[1]
|
|
||||||
|
|
||||||
if start_x + width + 1 <= strip_width:
|
|
||||||
# Slice the backing array directly. Going via
|
|
||||||
# _get_visible_portion_integer would build two PIL images only for
|
|
||||||
# them to be converted straight back to arrays, which measured 15x
|
|
||||||
# the cost of the integer path.
|
|
||||||
near = self.cached_array[:, start_x:start_x + width]
|
|
||||||
far = self.cached_array[:, start_x + 1:start_x + 1 + width]
|
|
||||||
else:
|
|
||||||
# Close enough to the end that one of the slices wraps; let the
|
|
||||||
# integer path handle that and pay the conversion. Continuous mode
|
|
||||||
# extends the strip before reaching here, so this is the rare case.
|
|
||||||
near = np.asarray(
|
|
||||||
self._get_visible_portion_integer(start_x, start_x + width))
|
|
||||||
far = np.asarray(
|
|
||||||
self._get_visible_portion_integer(start_x + 1, start_x + 1 + width))
|
|
||||||
|
|
||||||
# Fixed-point rather than float32: integer multiply-add on uint16 is
|
|
||||||
# markedly faster than float maths on the Pi's ARM cores, and 8 bits of
|
|
||||||
# weight is finer than the panel can show.
|
|
||||||
weight = int(fractional * 256.0)
|
|
||||||
blended = (
|
|
||||||
(near.astype(np.uint16) * (256 - weight)
|
|
||||||
+ far.astype(np.uint16) * weight) >> 8
|
|
||||||
).astype(np.uint8)
|
|
||||||
|
|
||||||
return Image.frombytes(
|
|
||||||
'RGB', (width, self.display_height),
|
|
||||||
np.ascontiguousarray(blended).tobytes()
|
|
||||||
)
|
|
||||||
|
|
||||||
def _get_visible_portion_integer(self, start_x: int, end_x: int) -> Image.Image:
|
def _get_visible_portion_integer(self, start_x: int, end_x: int) -> Image.Image:
|
||||||
"""Fast integer pixel extraction (no interpolation).
|
"""Fast integer pixel extraction (no interpolation).
|
||||||
|
|
||||||
@@ -707,128 +638,6 @@ class ScrollHelper:
|
|||||||
"""
|
"""
|
||||||
return self.scroll_complete
|
return self.scroll_complete
|
||||||
|
|
||||||
def append_content(self, content_items: list,
|
|
||||||
item_gap: int = 32,
|
|
||||||
element_gap: int = 0) -> bool:
|
|
||||||
"""
|
|
||||||
Append items to the right of the existing strip, preserving scroll state.
|
|
||||||
|
|
||||||
Lets a caller keep one continuous strip instead of replacing it. Vegas
|
|
||||||
mode uses this so the next group of plugins scrolls in from the right
|
|
||||||
rather than the strip being swapped out underneath the viewer — a swap
|
|
||||||
shows as a flash and a hard cut to already-full-screen content.
|
|
||||||
|
|
||||||
``scroll_position`` and ``total_distance_scrolled`` are untouched, so
|
|
||||||
motion continues uninterrupted; only the strip gets longer. Because
|
|
||||||
completion is measured against ``total_scroll_width``, extending the
|
|
||||||
strip also defers completion, which is the intent.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
content_items: Images to append, in order
|
|
||||||
item_gap: Gap between appended items, and between the existing
|
|
||||||
content and the first appended item
|
|
||||||
element_gap: Extra gap after each item, mirroring
|
|
||||||
create_scrolling_image
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if content was appended
|
|
||||||
"""
|
|
||||||
if not content_items:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if self.cached_image is None or self.cached_array is None:
|
|
||||||
# Nothing to extend yet — this is just the first build.
|
|
||||||
self.create_scrolling_image(
|
|
||||||
content_items, item_gap=item_gap, element_gap=element_gap, lead_gap=0)
|
|
||||||
return True
|
|
||||||
|
|
||||||
gap = max(0, item_gap)
|
|
||||||
addition_width = (
|
|
||||||
sum(img.width for img in content_items)
|
|
||||||
+ gap * len(content_items) # one leading gap per item
|
|
||||||
+ element_gap * len(content_items)
|
|
||||||
)
|
|
||||||
|
|
||||||
addition = Image.new('RGB', (addition_width, self.display_height), (0, 0, 0))
|
|
||||||
x = 0
|
|
||||||
for img in content_items:
|
|
||||||
x += gap # separate from whatever precedes
|
|
||||||
addition.paste(img, (x, 0))
|
|
||||||
x += img.width + element_gap
|
|
||||||
|
|
||||||
# numpy concatenate then one conversion back, rather than allocating a
|
|
||||||
# full-width PIL image and pasting twice: the strip can be tens of
|
|
||||||
# thousands of columns wide and this runs on the render path.
|
|
||||||
self.cached_array = np.concatenate(
|
|
||||||
(self.cached_array, np.array(addition)), axis=1)
|
|
||||||
self.cached_image = Image.fromarray(self.cached_array)
|
|
||||||
self.total_scroll_width = self.cached_image.width
|
|
||||||
self.scroll_complete = False
|
|
||||||
|
|
||||||
self.logger.info(
|
|
||||||
"Appended %d item(s) (%dpx) to scroll strip: now %dpx, position %.0f",
|
|
||||||
len(content_items), addition_width, self.total_scroll_width,
|
|
||||||
self.scroll_position
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
|
|
||||||
def drop_scrolled_prefix(self, keep_before: int = 0) -> int:
|
|
||||||
"""
|
|
||||||
Discard columns that have already scrolled past, to bound memory.
|
|
||||||
|
|
||||||
A continuously extended strip would otherwise grow without limit. All
|
|
||||||
the positional state is shifted by the amount removed so the visible
|
|
||||||
frame and the completion arithmetic are unchanged:
|
|
||||||
``total_distance_scrolled`` and ``total_scroll_width`` both shrink by the
|
|
||||||
same amount, preserving their difference.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
keep_before: Columns to retain behind the current position, as a
|
|
||||||
safety margin against a caller reading slightly behind it
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Number of columns actually removed
|
|
||||||
"""
|
|
||||||
if self.cached_image is None or self.cached_array is None:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
# While the viewport wraps, get_visible_portion fills its right-hand side
|
|
||||||
# from the *head* of the strip, so trimming the head would change what
|
|
||||||
# is on screen. Continuous mode extends before ever reaching that state;
|
|
||||||
# refusing here keeps "trimming is invisible" true unconditionally.
|
|
||||||
if self.scroll_position + self.display_width > self.cached_image.width:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
cut = int(self.scroll_position) - max(0, keep_before)
|
|
||||||
if cut <= 0:
|
|
||||||
return 0
|
|
||||||
# Never trim so far that the remaining strip is narrower than the
|
|
||||||
# viewport, or get_visible_portion has nothing to slice.
|
|
||||||
cut = min(cut, max(0, self.cached_image.width - self.display_width))
|
|
||||||
if cut <= 0:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
# .copy() so the original buffer is released rather than kept alive by
|
|
||||||
# a numpy view.
|
|
||||||
self.cached_array = self.cached_array[:, cut:].copy()
|
|
||||||
self.cached_image = Image.fromarray(self.cached_array)
|
|
||||||
self.total_scroll_width = self.cached_image.width
|
|
||||||
self.scroll_position -= cut
|
|
||||||
self.total_distance_scrolled = max(0.0, self.total_distance_scrolled - cut)
|
|
||||||
|
|
||||||
self.logger.debug(
|
|
||||||
"Dropped %dpx of scrolled strip: now %dpx, position %.0f",
|
|
||||||
cut, self.total_scroll_width, self.scroll_position
|
|
||||||
)
|
|
||||||
return cut
|
|
||||||
|
|
||||||
def remaining_unscrolled(self) -> int:
|
|
||||||
"""Columns of strip still to the right of the viewport."""
|
|
||||||
if self.cached_image is None:
|
|
||||||
return 0
|
|
||||||
return max(0, self.total_scroll_width - int(self.scroll_position)
|
|
||||||
- self.display_width)
|
|
||||||
|
|
||||||
def reset_scroll(self) -> None:
|
def reset_scroll(self) -> None:
|
||||||
"""
|
"""
|
||||||
Reset scroll position to beginning.
|
Reset scroll position to beginning.
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ from src.config_manager_atomic import (
|
|||||||
from src.common.permission_utils import (
|
from src.common.permission_utils import (
|
||||||
ensure_directory_permissions,
|
ensure_directory_permissions,
|
||||||
ensure_file_permissions,
|
ensure_file_permissions,
|
||||||
ensure_shared_group_ownership,
|
|
||||||
get_config_file_mode,
|
get_config_file_mode,
|
||||||
get_config_dir_mode
|
get_config_dir_mode
|
||||||
)
|
)
|
||||||
@@ -235,11 +234,6 @@ class ConfigManager:
|
|||||||
|
|
||||||
# Load and merge secrets if they exist (be permissive on errors)
|
# Load and merge secrets if they exist (be permissive on errors)
|
||||||
if os.path.exists(self.secrets_path):
|
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:
|
try:
|
||||||
with open(self.secrets_path, 'r') as f:
|
with open(self.secrets_path, 'r') as f:
|
||||||
secrets = json.load(f)
|
secrets = json.load(f)
|
||||||
@@ -369,7 +363,6 @@ class ConfigManager:
|
|||||||
# Set proper file permissions after creation
|
# Set proper file permissions after creation
|
||||||
config_path_obj = Path(self.config_path)
|
config_path_obj = Path(self.config_path)
|
||||||
ensure_file_permissions(config_path_obj, get_config_file_mode(config_path_obj))
|
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)}")
|
self.logger.info(f"Created config.json from template at {os.path.abspath(self.config_path)}")
|
||||||
|
|
||||||
@@ -482,11 +475,6 @@ class ConfigManager:
|
|||||||
self.logger.error(error_msg)
|
self.logger.error(error_msg)
|
||||||
raise ConfigError(error_msg, config_path=path_to_load)
|
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:
|
try:
|
||||||
with open(path_to_load, 'r') as f:
|
with open(path_to_load, 'r') as f:
|
||||||
return json.load(f)
|
return json.load(f)
|
||||||
@@ -494,18 +482,7 @@ class ConfigManager:
|
|||||||
error_msg = f"Error parsing {file_type} configuration file: {path_to_load}"
|
error_msg = f"Error parsing {file_type} configuration file: {path_to_load}"
|
||||||
self.logger.error(error_msg, exc_info=True)
|
self.logger.error(error_msg, exc_info=True)
|
||||||
raise ConfigError(error_msg, config_path=path_to_load) from e
|
raise ConfigError(error_msg, config_path=path_to_load) from e
|
||||||
except PermissionError as e:
|
except (IOError, OSError, 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)}"
|
error_msg = f"Error loading {file_type} configuration file {path_to_load}: {str(e)}"
|
||||||
self.logger.error(error_msg, exc_info=True)
|
self.logger.error(error_msg, exc_info=True)
|
||||||
raise ConfigError(error_msg, config_path=path_to_load) from e
|
raise ConfigError(error_msg, config_path=path_to_load) from e
|
||||||
@@ -562,7 +539,6 @@ class ConfigManager:
|
|||||||
# Ensure final file has correct permissions
|
# Ensure final file has correct permissions
|
||||||
try:
|
try:
|
||||||
ensure_file_permissions(path_obj, file_mode)
|
ensure_file_permissions(path_obj, file_mode)
|
||||||
ensure_shared_group_ownership(path_obj)
|
|
||||||
except OSError as perm_error:
|
except OSError as perm_error:
|
||||||
# If we can't set permissions but file was written, log warning but don't fail
|
# If we can't set permissions but file was written, log warning but don't fail
|
||||||
self.logger.warning(
|
self.logger.warning(
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ from enum import Enum
|
|||||||
|
|
||||||
from src.exceptions import ConfigError
|
from src.exceptions import ConfigError
|
||||||
from src.logging_config import get_logger
|
from src.logging_config import get_logger
|
||||||
from src.common.permission_utils import ensure_shared_group_ownership
|
|
||||||
|
|
||||||
|
|
||||||
class SaveResultStatus(Enum):
|
class SaveResultStatus(Enum):
|
||||||
@@ -412,13 +411,6 @@ class AtomicConfigManager:
|
|||||||
# and we need root service to be able to read config.json
|
# and we need root service to be able to read config.json
|
||||||
os.chmod(destination, target_mode)
|
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:
|
except Exception as e:
|
||||||
raise ConfigError(f"Error during atomic move: {e}") from e
|
raise ConfigError(f"Error during atomic move: {e}") from e
|
||||||
|
|
||||||
|
|||||||
@@ -381,10 +381,6 @@ class DisplayController:
|
|||||||
logger.debug("%d plugin(s) disabled in config", disabled_count)
|
logger.debug("%d plugin(s) disabled in config", disabled_count)
|
||||||
|
|
||||||
logger.info("Plugin system initialized in %.3f seconds", time.time() - plugin_time)
|
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("Total available modes: %d", len(self.available_modes))
|
||||||
logger.info("Available modes: %s", self.available_modes)
|
logger.info("Available modes: %s", self.available_modes)
|
||||||
|
|
||||||
@@ -1137,29 +1133,6 @@ class DisplayController:
|
|||||||
remaining = self.on_demand_expires_at - time.time()
|
remaining = self.on_demand_expires_at - time.time()
|
||||||
return max(0.0, remaining)
|
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:
|
def _publish_on_demand_state(self) -> None:
|
||||||
"""Publish current on-demand state to cache for external consumers."""
|
"""Publish current on-demand state to cache for external consumers."""
|
||||||
try:
|
try:
|
||||||
@@ -1679,7 +1652,6 @@ class DisplayController:
|
|||||||
logger.info("Starting display with cached data (fast startup mode)")
|
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'
|
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)})")
|
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:
|
while True:
|
||||||
# Apply plugin enable/disable edits saved via the web UI. The
|
# Apply plugin enable/disable edits saved via the web UI. The
|
||||||
@@ -1740,11 +1712,9 @@ class DisplayController:
|
|||||||
logger.debug(f"Error clearing display when inactive: {e}")
|
logger.debug(f"Error clearing display when inactive: {e}")
|
||||||
|
|
||||||
logger.info(f"Display not active (is_display_active={self.is_display_active}), sleeping...")
|
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)
|
self._sleep_with_plugin_updates(60)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
self._publish_current_mode_state_if_changed()
|
|
||||||
logger.debug("Display active, processing mode: %s", self.current_display_mode)
|
logger.debug("Display active, processing mode: %s", self.current_display_mode)
|
||||||
|
|
||||||
# Plugins update on their own schedules - no forced sync updates needed
|
# Plugins update on their own schedules - no forced sync updates needed
|
||||||
@@ -2873,52 +2843,11 @@ class DisplayController:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Plugin reconcile: error enabling %s: %s", plugin_id, e, exc_info=True)
|
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)
|
self._resync_mode_index_after_change(previous_mode)
|
||||||
logger.info("[DisplayController] Plugin reconcile complete: +%s -%s (%d modes)",
|
logger.info("Plugin reconcile complete: +%s -%s (%d modes)",
|
||||||
sorted(to_add), sorted(to_remove), len(self.available_modes))
|
sorted(to_add), sorted(to_remove), len(self.available_modes))
|
||||||
return True
|
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:
|
def _resync_mode_index_after_change(self, previous_mode: Optional[str]) -> None:
|
||||||
"""Clamp rotation state after available_modes changed. Stays on the
|
"""Clamp rotation state after available_modes changed. Stays on the
|
||||||
previous mode if it survived, otherwise restarts cleanly within range."""
|
previous mode if it survived, otherwise restarts cleanly within range."""
|
||||||
|
|||||||
@@ -186,14 +186,8 @@ class DisplayManager:
|
|||||||
self.config = config or {}
|
self.config = config or {}
|
||||||
self._force_fallback = force_fallback
|
self._force_fallback = force_fallback
|
||||||
self._suppress_test_pattern = suppress_test_pattern
|
self._suppress_test_pattern = suppress_test_pattern
|
||||||
# Per-thread capture state. update_display() and clear() skip hardware
|
# When True, update_display() and clear() skip hardware writes (used during off-screen content capture)
|
||||||
# writes while the *calling* thread is capturing content off-screen.
|
self._capture_mode_active = False
|
||||||
#
|
|
||||||
# Thread-local rather than a plain flag because Vegas mode prepares
|
|
||||||
# upcoming content on a background thread: a shared flag set there would
|
|
||||||
# suppress the render loop's own frame pushes for the duration, freezing
|
|
||||||
# the panel exactly when the point was to avoid a freeze.
|
|
||||||
self._capture_state = threading.local()
|
|
||||||
# Double-sided mode state (resolved in _setup_matrix). When disabled,
|
# Double-sided mode state (resolved in _setup_matrix). When disabled,
|
||||||
# the logical image is blitted to the matrix unchanged.
|
# the logical image is blitted to the matrix unchanged.
|
||||||
self._double_sided = None # dict {copies, axis, logical_width, logical_height} or None
|
self._double_sided = None # dict {copies, axis, logical_width, logical_height} or None
|
||||||
@@ -526,15 +520,6 @@ class DisplayManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error drawing test pattern: {e}", exc_info=True)
|
logger.error(f"Error drawing test pattern: {e}", exc_info=True)
|
||||||
|
|
||||||
@property
|
|
||||||
def _capture_mode_active(self) -> bool:
|
|
||||||
"""True while the calling thread is capturing content off-screen."""
|
|
||||||
return getattr(self._capture_state, 'active', False)
|
|
||||||
|
|
||||||
@_capture_mode_active.setter
|
|
||||||
def _capture_mode_active(self, value: bool) -> None:
|
|
||||||
self._capture_state.active = bool(value)
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def capture_mode(self):
|
def capture_mode(self):
|
||||||
"""Suppress hardware output during off-screen content capture.
|
"""Suppress hardware output during off-screen content capture.
|
||||||
@@ -551,59 +536,6 @@ class DisplayManager:
|
|||||||
finally:
|
finally:
|
||||||
self._capture_mode_active = False
|
self._capture_mode_active = False
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def render_size(self, width: int, height: Optional[int] = None):
|
|
||||||
"""Temporarily present a smaller logical canvas to plugins.
|
|
||||||
|
|
||||||
Plugins lay out against ``display_manager.matrix.width`` (and the
|
|
||||||
``width``/``height`` properties, which defer to it), so the only way to
|
|
||||||
get a *narrower layout* rather than a cropped one is to tell the plugin
|
|
||||||
the screen is narrower while it renders. Trimming after the fact cannot
|
|
||||||
fix a forecast spread across five columns or a progress bar drawn at
|
|
||||||
100% width — those need the plugin to make different layout decisions.
|
|
||||||
|
|
||||||
Vegas mode uses this so a plugin can occupy a fraction of a wide panel
|
|
||||||
and still look deliberately composed. Reuses the same _LogicalMatrix
|
|
||||||
indirection that double-sided mode relies on, so plugins see a
|
|
||||||
consistent size from every accessor.
|
|
||||||
|
|
||||||
Only meaningful inside :meth:`capture_mode` — this swaps the shared
|
|
||||||
image buffer, so the render loop must not be writing to it concurrently.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
width: Logical width to report, clamped to at least 1 and to the
|
|
||||||
real panel width (a larger canvas would overflow the hardware).
|
|
||||||
height: Logical height, defaulting to the current height.
|
|
||||||
"""
|
|
||||||
real_matrix = self.matrix
|
|
||||||
prev_image = getattr(self, 'image', None)
|
|
||||||
prev_draw = getattr(self, 'draw', None)
|
|
||||||
|
|
||||||
current_w = self.width
|
|
||||||
current_h = self.height
|
|
||||||
target_w = max(1, min(int(width), current_w))
|
|
||||||
target_h = max(1, min(int(height) if height else current_h, current_h))
|
|
||||||
|
|
||||||
if target_w == current_w and target_h == current_h:
|
|
||||||
# Nothing to do; avoid pointless wrapping and buffer churn.
|
|
||||||
yield
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
if real_matrix is not None:
|
|
||||||
self.matrix = _LogicalMatrix(real_matrix, target_w, target_h)
|
|
||||||
# With no hardware, the width/height properties fall through to
|
|
||||||
# self.image, so swapping the buffer below is enough on its own.
|
|
||||||
self.image = Image.new('RGB', (target_w, target_h))
|
|
||||||
self.draw = ImageDraw.Draw(self.image)
|
|
||||||
yield
|
|
||||||
finally:
|
|
||||||
self.matrix = real_matrix
|
|
||||||
if prev_image is not None:
|
|
||||||
self.image = prev_image
|
|
||||||
if prev_draw is not None:
|
|
||||||
self.draw = prev_draw
|
|
||||||
|
|
||||||
def _composite_double_sided(self):
|
def _composite_double_sided(self):
|
||||||
"""Tile the logical screen across the full physical chain.
|
"""Tile the logical screen across the full physical chain.
|
||||||
|
|
||||||
|
|||||||
@@ -1,621 +0,0 @@
|
|||||||
"""
|
|
||||||
Shared per-element style resolution for plugins (the x-style-elements system).
|
|
||||||
|
|
||||||
Plugins expose user-customizable text styling — font, size, color, and x/y
|
|
||||||
pixel offsets per named element — through their ``config_schema.json``. Two
|
|
||||||
declaration forms exist in the plugin ecosystem:
|
|
||||||
|
|
||||||
- The compact ``x-style-elements`` map on the ``customization`` object
|
|
||||||
(of-the-day is the reference). ``expand_style_elements()`` turns it into
|
|
||||||
the full per-element property blocks the web-UI config form renders.
|
|
||||||
- The manual ``customization`` block: hand-written per-element objects with
|
|
||||||
``font`` / ``font_size`` / ``text_color`` defaults (the scoreboards,
|
|
||||||
ledmatrix-music). No expansion needed — the defaults are read as-is.
|
|
||||||
|
|
||||||
At render time a plugin builds an ``ElementStyleResolver`` from its config
|
|
||||||
and the schema-file defaults, then asks for each element's resolved style::
|
|
||||||
|
|
||||||
from src.element_style import ElementStyleResolver, defaults_from_schema_file
|
|
||||||
|
|
||||||
resolver = ElementStyleResolver(config, defaults_from_schema_file(schema_path))
|
|
||||||
title = resolver.style('title_text', classic_font='PressStart2P-Regular.ttf',
|
|
||||||
classic_size=8, classic_color=(255, 255, 255))
|
|
||||||
# title.font (PIL font / freetype.Face), title.color (RGB tuple),
|
|
||||||
# title.offset ((dx, dy)), title.user_forced, title.user_forced_color
|
|
||||||
|
|
||||||
The central subtlety is what "the user set it" means. The web UI's save flow
|
|
||||||
(``schema_manager.merge_with_defaults``) writes the FULL schema-default
|
|
||||||
object into ``config.json`` on every save, whether or not the user touched
|
|
||||||
the styling section — so a value merely being *present* in config is not an
|
|
||||||
override. A value only counts as user-forced when it genuinely differs from
|
|
||||||
the schema default for that element. When nothing is forced, ``style()``
|
|
||||||
returns exactly the ``classic_*`` values the caller passes (the plugin's
|
|
||||||
pre-customization styling), so an untouched config renders byte-identically
|
|
||||||
to the classic code path. Note the classic values and the schema defaults
|
|
||||||
may legitimately differ (e.g. football's status_text: schema declares 4x6,
|
|
||||||
the classic loader fell back to PressStart) — the schema default is the
|
|
||||||
override *reference*, the classic values are the *fallback*.
|
|
||||||
|
|
||||||
``style()`` never raises: any malformed config value degrades to the classic
|
|
||||||
style with a logged warning. Font faces are cached module-wide by
|
|
||||||
(resolved path, size), and font files resolve independently of the caller's
|
|
||||||
cwd (cwd ``assets/fonts/`` first for compatibility, then the core install
|
|
||||||
root derived from this module's own location).
|
|
||||||
"""
|
|
||||||
|
|
||||||
import copy
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Any, Dict, Optional, Tuple, Union
|
|
||||||
|
|
||||||
from PIL import ImageFont
|
|
||||||
|
|
||||||
try:
|
|
||||||
import freetype
|
|
||||||
except ImportError: # pragma: no cover - freetype ships with the core
|
|
||||||
freetype = None
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Core install root (the directory that contains src/ and assets/fonts/),
|
|
||||||
# derived from this file so fonts resolve regardless of the caller's cwd.
|
|
||||||
_CORE_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
|
|
||||||
_FONTS_SUBDIR = os.path.join('assets', 'fonts')
|
|
||||||
|
|
||||||
# Last-resort font when a requested file can't be found or loaded.
|
|
||||||
_FALLBACK_FONT_NAME = 'PressStart2P-Regular.ttf'
|
|
||||||
|
|
||||||
# (resolved absolute path, size) -> loaded font face. BDF faces are stateful
|
|
||||||
# in principle, but the core's own FontManager shares faces the same way.
|
|
||||||
_font_cache: Dict[Tuple[str, int], Any] = {}
|
|
||||||
|
|
||||||
# Config keys a style element block carries, in schema/UI order.
|
|
||||||
_STYLE_KEYS = ('font', 'font_size', 'text_color')
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ElementStyle:
|
|
||||||
"""A fully resolved style for one named element."""
|
|
||||||
|
|
||||||
font: Any # PIL ImageFont or freetype.Face
|
|
||||||
color: Tuple[int, int, int] # resolved RGB
|
|
||||||
offset: Tuple[int, int] # user layout (x, y) offset, default (0, 0)
|
|
||||||
font_name: str # resolved font filename
|
|
||||||
font_size: int # resolved pixel size
|
|
||||||
user_forced: bool # font or size genuinely overridden
|
|
||||||
user_forced_color: bool # color genuinely overridden
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Font loading (cwd-independent, cached)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def resolve_font_path(font_name: str) -> Optional[str]:
|
|
||||||
"""Locate a font file by name, independent of the caller's cwd.
|
|
||||||
|
|
||||||
Tries, in order: an absolute path as given; ``assets/fonts/<name>``
|
|
||||||
relative to the cwd (the classic loaders' behavior, kept first so a
|
|
||||||
process running from a different checkout keeps its own fonts); then
|
|
||||||
``assets/fonts/<name>`` under the core install root. Returns an
|
|
||||||
absolute path, or None when the file doesn't exist anywhere.
|
|
||||||
"""
|
|
||||||
if not font_name or not isinstance(font_name, str):
|
|
||||||
return None
|
|
||||||
if os.path.isabs(font_name):
|
|
||||||
return font_name if os.path.isfile(font_name) else None
|
|
||||||
candidates = (
|
|
||||||
os.path.join(os.getcwd(), _FONTS_SUBDIR, font_name),
|
|
||||||
os.path.join(_CORE_ROOT, _FONTS_SUBDIR, font_name),
|
|
||||||
)
|
|
||||||
for candidate in candidates:
|
|
||||||
if os.path.isfile(candidate):
|
|
||||||
return os.path.abspath(candidate)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def load_font(font_name: str, size: int) -> Any:
|
|
||||||
"""Load a font by filename at a pixel size, with caching and fallback.
|
|
||||||
|
|
||||||
``.bdf`` files load as ``freetype.Face`` (matching FontManager), other
|
|
||||||
files through ``PIL.ImageFont.truetype``. A missing or unloadable font
|
|
||||||
degrades to ``PressStart2P-Regular.ttf`` at the requested size, then to
|
|
||||||
PIL's built-in default — this function never raises.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
size = max(1, int(size))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
size = 8
|
|
||||||
|
|
||||||
path = resolve_font_path(font_name)
|
|
||||||
if path is None:
|
|
||||||
logger.warning("Font file not found: %s, using fallback", font_name)
|
|
||||||
return _load_fallback_font(size)
|
|
||||||
|
|
||||||
cache_key = (path, size)
|
|
||||||
cached = _font_cache.get(cache_key)
|
|
||||||
if cached is not None:
|
|
||||||
return cached
|
|
||||||
|
|
||||||
try:
|
|
||||||
if path.lower().endswith('.bdf'):
|
|
||||||
if freetype is None:
|
|
||||||
raise RuntimeError("freetype not available for BDF fonts")
|
|
||||||
face = freetype.Face(path)
|
|
||||||
# Character size in 1/64th points at 72dpi == pixel size.
|
|
||||||
face.set_char_size(size * 64, size * 64, 72, 72)
|
|
||||||
font: Any = face
|
|
||||||
else:
|
|
||||||
font = ImageFont.truetype(path, size)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Error loading font %s at %spx: %s, using fallback",
|
|
||||||
path, size, e)
|
|
||||||
return _load_fallback_font(size)
|
|
||||||
|
|
||||||
_font_cache[cache_key] = font
|
|
||||||
return font
|
|
||||||
|
|
||||||
|
|
||||||
def _load_fallback_font(size: int) -> Any:
|
|
||||||
"""PressStart2P at the requested size, else PIL's built-in default."""
|
|
||||||
path = resolve_font_path(_FALLBACK_FONT_NAME)
|
|
||||||
if path is not None:
|
|
||||||
cache_key = (path, size)
|
|
||||||
cached = _font_cache.get(cache_key)
|
|
||||||
if cached is not None:
|
|
||||||
return cached
|
|
||||||
try:
|
|
||||||
font = ImageFont.truetype(path, size)
|
|
||||||
_font_cache[cache_key] = font
|
|
||||||
return font
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Error loading fallback font: %s", e)
|
|
||||||
return ImageFont.load_default()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Schema parsing
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def expand_style_elements(schema: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
"""Expand a ``customization.x-style-elements`` declaration into the full
|
|
||||||
per-element property blocks the web-UI config form renders.
|
|
||||||
|
|
||||||
Each declared element becomes an object with ``font`` / ``font_size`` /
|
|
||||||
``text_color`` properties (only the sub-fields the declaration carries),
|
|
||||||
tagged ``x-style-managed: true``; elements declaring ``offsets: true``
|
|
||||||
additionally get an entry under ``customization.layout`` with
|
|
||||||
``x_offset`` / ``y_offset`` integers defaulting to 0. Hand-written
|
|
||||||
element blocks with the same key are left untouched.
|
|
||||||
|
|
||||||
Returns the schema unchanged (same object) when there is nothing to
|
|
||||||
expand; otherwise returns an expanded deep copy. Never raises.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
customization = schema.get('properties', {}).get('customization')
|
|
||||||
if not isinstance(customization, dict):
|
|
||||||
return schema
|
|
||||||
declaration = customization.get('x-style-elements')
|
|
||||||
if not isinstance(declaration, dict) or not declaration:
|
|
||||||
return schema
|
|
||||||
|
|
||||||
expanded = copy.deepcopy(schema)
|
|
||||||
customization = expanded['properties']['customization']
|
|
||||||
customization.setdefault('type', 'object')
|
|
||||||
props = customization.setdefault('properties', {})
|
|
||||||
layout_props: Dict[str, Any] = {}
|
|
||||||
|
|
||||||
for element_key, spec in declaration.items():
|
|
||||||
if not isinstance(spec, dict):
|
|
||||||
continue
|
|
||||||
if element_key not in props:
|
|
||||||
props[element_key] = _element_block_from_spec(element_key, spec)
|
|
||||||
if spec.get('offsets'):
|
|
||||||
layout_props[element_key] = _offset_block_from_spec(
|
|
||||||
element_key, spec)
|
|
||||||
|
|
||||||
if layout_props:
|
|
||||||
layout = props.setdefault('layout', {
|
|
||||||
'type': 'object',
|
|
||||||
'title': 'Layout Offsets',
|
|
||||||
'description': 'Pixel offsets applied to each element '
|
|
||||||
'(positive x moves right, positive y moves down)',
|
|
||||||
'x-advanced': True,
|
|
||||||
'properties': {},
|
|
||||||
'additionalProperties': False,
|
|
||||||
})
|
|
||||||
layout.setdefault('properties', {})
|
|
||||||
for element_key, block in layout_props.items():
|
|
||||||
layout['properties'].setdefault(element_key, block)
|
|
||||||
|
|
||||||
return expanded
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Error expanding x-style-elements: %s", e)
|
|
||||||
return schema
|
|
||||||
|
|
||||||
|
|
||||||
def _element_block_from_spec(element_key: str,
|
|
||||||
spec: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
"""Build one expanded per-element schema block from its declaration."""
|
|
||||||
properties: Dict[str, Any] = {}
|
|
||||||
order = []
|
|
||||||
|
|
||||||
font_spec = spec.get('font')
|
|
||||||
if isinstance(font_spec, dict):
|
|
||||||
font_prop: Dict[str, Any] = {
|
|
||||||
'type': 'string',
|
|
||||||
'title': 'Font Family',
|
|
||||||
'x-advanced': True,
|
|
||||||
}
|
|
||||||
if 'default' in font_spec:
|
|
||||||
font_prop['default'] = font_spec['default']
|
|
||||||
if isinstance(font_spec.get('enum'), list):
|
|
||||||
font_prop['enum'] = list(font_spec['enum'])
|
|
||||||
properties['font'] = font_prop
|
|
||||||
order.append('font')
|
|
||||||
|
|
||||||
size_spec = spec.get('size')
|
|
||||||
if isinstance(size_spec, dict):
|
|
||||||
size_prop: Dict[str, Any] = {
|
|
||||||
'type': 'integer',
|
|
||||||
'title': 'Font Size',
|
|
||||||
'description': 'Font size in pixels',
|
|
||||||
'x-advanced': True,
|
|
||||||
}
|
|
||||||
if 'default' in size_spec:
|
|
||||||
size_prop['default'] = size_spec['default']
|
|
||||||
if 'min' in size_spec:
|
|
||||||
size_prop['minimum'] = size_spec['min']
|
|
||||||
if 'max' in size_spec:
|
|
||||||
size_prop['maximum'] = size_spec['max']
|
|
||||||
properties['font_size'] = size_prop
|
|
||||||
order.append('font_size')
|
|
||||||
|
|
||||||
color_spec = spec.get('color')
|
|
||||||
if isinstance(color_spec, dict):
|
|
||||||
color_prop: Dict[str, Any] = {
|
|
||||||
'type': 'array',
|
|
||||||
'title': 'Text Color',
|
|
||||||
'items': {'type': 'integer', 'minimum': 0, 'maximum': 255},
|
|
||||||
'minItems': 3,
|
|
||||||
'maxItems': 3,
|
|
||||||
'x-widget': 'color-picker',
|
|
||||||
}
|
|
||||||
if 'default' in color_spec:
|
|
||||||
color_prop['default'] = list(color_spec['default'])
|
|
||||||
properties['text_color'] = color_prop
|
|
||||||
order.append('text_color')
|
|
||||||
|
|
||||||
return {
|
|
||||||
'type': 'object',
|
|
||||||
'title': spec.get('title', element_key),
|
|
||||||
'x-style-managed': True,
|
|
||||||
'x-propertyOrder': order,
|
|
||||||
'additionalProperties': False,
|
|
||||||
'properties': properties,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _offset_block_from_spec(element_key: str,
|
|
||||||
spec: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
"""Build one layout.<element> offset block (x/y, default 0)."""
|
|
||||||
axis = {
|
|
||||||
'type': 'integer',
|
|
||||||
'default': 0,
|
|
||||||
'x-advanced': True,
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
'type': 'object',
|
|
||||||
'title': spec.get('title', element_key),
|
|
||||||
'x-style-managed': True,
|
|
||||||
'additionalProperties': False,
|
|
||||||
'properties': {
|
|
||||||
'x_offset': dict(axis, title='X Offset'),
|
|
||||||
'y_offset': dict(axis, title='Y Offset'),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def defaults_from_schema(schema: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
"""Extract per-element style defaults from a config schema dict.
|
|
||||||
|
|
||||||
Understands both declaration forms: the compact ``x-style-elements``
|
|
||||||
map, and hand-written per-element blocks under
|
|
||||||
``customization.properties`` (their ``font`` / ``font_size`` /
|
|
||||||
``text_color`` property defaults). Returns a config-shaped dict::
|
|
||||||
|
|
||||||
{"customization": {"<element>": {"font": ..., "font_size": ...,
|
|
||||||
"text_color": [...]}, ...}}
|
|
||||||
|
|
||||||
Elements with no declared defaults are omitted. Never raises.
|
|
||||||
"""
|
|
||||||
elements: Dict[str, Dict[str, Any]] = {}
|
|
||||||
try:
|
|
||||||
customization = schema.get('properties', {}).get('customization')
|
|
||||||
if not isinstance(customization, dict):
|
|
||||||
return {'customization': elements}
|
|
||||||
|
|
||||||
declaration = customization.get('x-style-elements')
|
|
||||||
if isinstance(declaration, dict):
|
|
||||||
for element_key, spec in declaration.items():
|
|
||||||
if not isinstance(spec, dict):
|
|
||||||
continue
|
|
||||||
defaults: Dict[str, Any] = {}
|
|
||||||
font_spec = spec.get('font')
|
|
||||||
if isinstance(font_spec, dict) and 'default' in font_spec:
|
|
||||||
defaults['font'] = font_spec['default']
|
|
||||||
size_spec = spec.get('size')
|
|
||||||
if isinstance(size_spec, dict) and 'default' in size_spec:
|
|
||||||
defaults['font_size'] = size_spec['default']
|
|
||||||
color_spec = spec.get('color')
|
|
||||||
if isinstance(color_spec, dict) and 'default' in color_spec:
|
|
||||||
defaults['text_color'] = list(color_spec['default'])
|
|
||||||
if defaults:
|
|
||||||
elements[element_key] = defaults
|
|
||||||
|
|
||||||
properties = customization.get('properties')
|
|
||||||
if isinstance(properties, dict):
|
|
||||||
for element_key, block in properties.items():
|
|
||||||
if element_key == 'layout' or element_key in elements:
|
|
||||||
continue
|
|
||||||
if not isinstance(block, dict):
|
|
||||||
continue
|
|
||||||
block_props = block.get('properties')
|
|
||||||
if not isinstance(block_props, dict):
|
|
||||||
continue
|
|
||||||
defaults = {}
|
|
||||||
for style_key in _STYLE_KEYS:
|
|
||||||
prop = block_props.get(style_key)
|
|
||||||
if isinstance(prop, dict) and 'default' in prop:
|
|
||||||
defaults[style_key] = prop['default']
|
|
||||||
if defaults:
|
|
||||||
elements[element_key] = defaults
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Error extracting style defaults from schema: %s", e)
|
|
||||||
return {'customization': elements}
|
|
||||||
|
|
||||||
|
|
||||||
def defaults_from_schema_file(schema_path: Union[str, os.PathLike]) -> Dict[str, Any]:
|
|
||||||
"""``defaults_from_schema`` for a schema file on disk. A missing or
|
|
||||||
malformed file yields empty defaults (with a logged warning) — every
|
|
||||||
configured value then counts as a user override, which is the safe
|
|
||||||
degradation. Never raises."""
|
|
||||||
try:
|
|
||||||
with open(schema_path, 'r', encoding='utf-8') as f:
|
|
||||||
schema = json.load(f)
|
|
||||||
if not isinstance(schema, dict):
|
|
||||||
raise ValueError("schema is not a JSON object")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Could not read style defaults from %s: %s",
|
|
||||||
schema_path, e)
|
|
||||||
return {'customization': {}}
|
|
||||||
return defaults_from_schema(schema)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Resolver
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _normalize_color(value: Any) -> Optional[Tuple[int, int, int]]:
|
|
||||||
"""An (r, g, b) tuple of ints in 0..255, or None for anything else."""
|
|
||||||
if isinstance(value, (list, tuple)) and len(value) == 3:
|
|
||||||
try:
|
|
||||||
rgb = tuple(int(c) for c in value)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
if all(0 <= c <= 255 for c in rgb):
|
|
||||||
return rgb # type: ignore[return-value]
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class ElementStyleResolver:
|
|
||||||
"""Resolves per-element user styling against schema defaults.
|
|
||||||
|
|
||||||
Built from a plugin's live config dict and the defaults extracted from
|
|
||||||
its own ``config_schema.json`` (``defaults_from_schema_file``). The
|
|
||||||
config dict is held by reference as ``_config`` — consumers compare
|
|
||||||
identity (``resolver._config is not self.config``) to decide when a
|
|
||||||
resolver must be rebuilt after ``on_config_change`` swaps the dict.
|
|
||||||
|
|
||||||
A configured font/size/color counts as user-forced only when it differs
|
|
||||||
from the schema default (see module docstring); otherwise ``style()``
|
|
||||||
returns the caller's classic values verbatim, keeping untouched configs
|
|
||||||
byte-identical to pre-customization rendering.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, config: Optional[Dict[str, Any]],
|
|
||||||
defaults: Optional[Dict[str, Any]] = None):
|
|
||||||
# Keep the exact object for identity-based invalidation, even if the
|
|
||||||
# caller hands us something odd; reads are guarded.
|
|
||||||
self._config = config
|
|
||||||
if isinstance(defaults, dict):
|
|
||||||
element_defaults = defaults.get('customization', {})
|
|
||||||
else:
|
|
||||||
element_defaults = {}
|
|
||||||
self._defaults: Dict[str, Any] = (
|
|
||||||
element_defaults if isinstance(element_defaults, dict) else {})
|
|
||||||
self._memo: Dict[Any, ElementStyle] = {}
|
|
||||||
|
|
||||||
# -- internal accessors -------------------------------------------------
|
|
||||||
|
|
||||||
def _customization(self) -> Dict[str, Any]:
|
|
||||||
config = self._config if isinstance(self._config, dict) else {}
|
|
||||||
customization = config.get('customization', {})
|
|
||||||
return customization if isinstance(customization, dict) else {}
|
|
||||||
|
|
||||||
def _element_config(self, element_key: str) -> Dict[str, Any]:
|
|
||||||
element = self._customization().get(element_key, {})
|
|
||||||
return element if isinstance(element, dict) else {}
|
|
||||||
|
|
||||||
def _element_defaults(self, element_key: str) -> Dict[str, Any]:
|
|
||||||
defaults = self._defaults.get(element_key, {})
|
|
||||||
return defaults if isinstance(defaults, dict) else {}
|
|
||||||
|
|
||||||
# -- public API ---------------------------------------------------------
|
|
||||||
|
|
||||||
def style(self, element_key: str,
|
|
||||||
classic_font: str = _FALLBACK_FONT_NAME,
|
|
||||||
classic_size: int = 8,
|
|
||||||
classic_color: Optional[Tuple[int, int, int]] = None) -> ElementStyle:
|
|
||||||
"""Resolve one element's style. Never raises.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
element_key: Key under ``config['customization']`` (e.g.
|
|
||||||
``'title_text'``).
|
|
||||||
classic_font: Font filename the plugin's classic (pre-
|
|
||||||
customization) code used for this element.
|
|
||||||
classic_size: Classic pixel size.
|
|
||||||
classic_color: Classic RGB color, or None when the caller only
|
|
||||||
cares about the font (``.color`` then falls back to the
|
|
||||||
schema default color, else white).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
ElementStyle with the loaded font face, RGB color, (x, y)
|
|
||||||
offset, and the ``user_forced`` / ``user_forced_color`` flags.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
memo_key = (element_key, classic_font, classic_size,
|
|
||||||
_normalize_color(classic_color) or classic_color)
|
|
||||||
memoized = self._memo.get(memo_key)
|
|
||||||
if memoized is not None:
|
|
||||||
return memoized
|
|
||||||
except Exception:
|
|
||||||
memo_key = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
resolved = self._resolve(element_key, classic_font,
|
|
||||||
classic_size, classic_color)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Error resolving style for element '%s': %s — "
|
|
||||||
"using classic style", element_key, e)
|
|
||||||
resolved = self._classic_style(classic_font, classic_size,
|
|
||||||
classic_color)
|
|
||||||
if memo_key is not None:
|
|
||||||
self._memo[memo_key] = resolved
|
|
||||||
return resolved
|
|
||||||
|
|
||||||
def offset(self, element_key: str) -> Tuple[int, int]:
|
|
||||||
"""The user's ``customization.layout.<element>`` (x, y) pixel
|
|
||||||
offset, defaulting to (0, 0). Never raises."""
|
|
||||||
return (self.offset_value(element_key, 'x_offset', 0),
|
|
||||||
self.offset_value(element_key, 'y_offset', 0))
|
|
||||||
|
|
||||||
def offset_value(self, element_key: str, axis: str, default: int = 0) -> int:
|
|
||||||
"""One ``customization.layout.<element>.<axis>`` value as an int.
|
|
||||||
|
|
||||||
``axis`` is usually ``'x_offset'`` / ``'y_offset'`` but any key is
|
|
||||||
honored (e.g. the scoreboards' ``'away_x_offset'``). Numeric
|
|
||||||
strings are coerced; anything else degrades to ``default``. Never
|
|
||||||
raises.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
layout = self._customization().get('layout', {})
|
|
||||||
if not isinstance(layout, dict):
|
|
||||||
return int(default)
|
|
||||||
element = layout.get(element_key, {})
|
|
||||||
if not isinstance(element, dict):
|
|
||||||
return int(default)
|
|
||||||
value = element.get(axis, default)
|
|
||||||
if isinstance(value, bool):
|
|
||||||
return int(default)
|
|
||||||
if isinstance(value, (int, float)):
|
|
||||||
return int(value)
|
|
||||||
if isinstance(value, str):
|
|
||||||
try:
|
|
||||||
return int(float(value))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
logger.warning(
|
|
||||||
"Invalid layout offset for %s.%s: %r, using %s",
|
|
||||||
element_key, axis, value, default)
|
|
||||||
return int(default)
|
|
||||||
return int(default)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Error reading layout offset %s.%s: %s",
|
|
||||||
element_key, axis, e)
|
|
||||||
try:
|
|
||||||
return int(default)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return 0
|
|
||||||
|
|
||||||
# -- resolution internals -----------------------------------------------
|
|
||||||
|
|
||||||
def _resolve(self, element_key: str, classic_font: str,
|
|
||||||
classic_size: int,
|
|
||||||
classic_color: Optional[Tuple[int, int, int]]) -> ElementStyle:
|
|
||||||
element_config = self._element_config(element_key)
|
|
||||||
element_defaults = self._element_defaults(element_key)
|
|
||||||
|
|
||||||
# Font family: forced only when it differs from the schema default
|
|
||||||
# (falling back to the classic font as the reference when the
|
|
||||||
# schema declares none).
|
|
||||||
default_font = element_defaults.get('font', classic_font)
|
|
||||||
configured_font = element_config.get('font')
|
|
||||||
font_forced = (isinstance(configured_font, str) and configured_font
|
|
||||||
and configured_font != default_font)
|
|
||||||
|
|
||||||
# Font size: same rule, with defensive int coercion.
|
|
||||||
default_size = self._coerce_size(
|
|
||||||
element_defaults.get('font_size'), None)
|
|
||||||
if default_size is None:
|
|
||||||
default_size = self._coerce_size(classic_size, 8)
|
|
||||||
configured_size = self._coerce_size(element_config.get('font_size'),
|
|
||||||
None)
|
|
||||||
size_forced = (configured_size is not None
|
|
||||||
and configured_size != default_size)
|
|
||||||
|
|
||||||
font_name = configured_font if font_forced else classic_font
|
|
||||||
font_size = configured_size if size_forced else self._coerce_size(
|
|
||||||
classic_size, 8)
|
|
||||||
user_forced = bool(font_forced or size_forced)
|
|
||||||
|
|
||||||
# Color: forced only when it differs from the schema default (or,
|
|
||||||
# absent one, from the classic color).
|
|
||||||
default_color = _normalize_color(element_defaults.get('text_color'))
|
|
||||||
configured_color = _normalize_color(element_config.get('text_color'))
|
|
||||||
reference_color = (default_color if default_color is not None
|
|
||||||
else _normalize_color(classic_color))
|
|
||||||
color_forced = (configured_color is not None
|
|
||||||
and configured_color != reference_color)
|
|
||||||
if color_forced:
|
|
||||||
color = configured_color
|
|
||||||
else:
|
|
||||||
color = (_normalize_color(classic_color) or default_color
|
|
||||||
or (255, 255, 255))
|
|
||||||
|
|
||||||
return ElementStyle(
|
|
||||||
font=load_font(font_name, font_size),
|
|
||||||
color=color,
|
|
||||||
offset=self.offset(element_key),
|
|
||||||
font_name=font_name,
|
|
||||||
font_size=font_size,
|
|
||||||
user_forced=user_forced,
|
|
||||||
user_forced_color=bool(color_forced),
|
|
||||||
)
|
|
||||||
|
|
||||||
def _classic_style(self, classic_font: str, classic_size: int,
|
|
||||||
classic_color: Optional[Tuple[int, int, int]]) -> ElementStyle:
|
|
||||||
"""The untouched fallback style — used when resolution itself
|
|
||||||
fails, so ``style()`` can keep its never-raises promise."""
|
|
||||||
size = self._coerce_size(classic_size, 8)
|
|
||||||
return ElementStyle(
|
|
||||||
font=load_font(classic_font, size),
|
|
||||||
color=_normalize_color(classic_color) or (255, 255, 255),
|
|
||||||
offset=(0, 0),
|
|
||||||
font_name=classic_font,
|
|
||||||
font_size=size,
|
|
||||||
user_forced=False,
|
|
||||||
user_forced_color=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _coerce_size(value: Any, default: Optional[int]) -> Optional[int]:
|
|
||||||
"""An int pixel size, or ``default`` for None/garbage."""
|
|
||||||
if value is None or isinstance(value, bool):
|
|
||||||
return default
|
|
||||||
try:
|
|
||||||
size = int(value)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return default
|
|
||||||
return size if size > 0 else default
|
|
||||||
@@ -659,25 +659,6 @@ class FontManager:
|
|||||||
|
|
||||||
# ==================== Font Discovery ====================
|
# ==================== Font Discovery ====================
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _resolve_asset_path(relative_path: str) -> str:
|
|
||||||
"""Resolve a repo-relative asset path independently of the process cwd.
|
|
||||||
|
|
||||||
Prefers the working directory (preserving behavior when the process
|
|
||||||
runs from the install root), then falls back to the install root
|
|
||||||
derived from this module's own location. Without the fallback, any
|
|
||||||
process started outside the install root (e.g. the plugin safety
|
|
||||||
harness on CI) silently loses every font and degrades to PIL's
|
|
||||||
default face.
|
|
||||||
"""
|
|
||||||
if os.path.exists(relative_path):
|
|
||||||
return relative_path
|
|
||||||
install_root = Path(__file__).resolve().parent.parent
|
|
||||||
candidate = install_root / relative_path
|
|
||||||
if candidate.exists():
|
|
||||||
return str(candidate)
|
|
||||||
return relative_path
|
|
||||||
|
|
||||||
def _initialize_fonts(self):
|
def _initialize_fonts(self):
|
||||||
"""Initialize font catalog and validate configuration."""
|
"""Initialize font catalog and validate configuration."""
|
||||||
self._scan_fonts_directory()
|
self._scan_fonts_directory()
|
||||||
@@ -686,7 +667,7 @@ class FontManager:
|
|||||||
|
|
||||||
def _scan_fonts_directory(self):
|
def _scan_fonts_directory(self):
|
||||||
"""Scan assets/fonts directory for available fonts."""
|
"""Scan assets/fonts directory for available fonts."""
|
||||||
fonts_dir = self._resolve_asset_path("assets/fonts")
|
fonts_dir = "assets/fonts"
|
||||||
if not os.path.exists(fonts_dir):
|
if not os.path.exists(fonts_dir):
|
||||||
logger.warning(f"Fonts directory not found: {fonts_dir}")
|
logger.warning(f"Fonts directory not found: {fonts_dir}")
|
||||||
return
|
return
|
||||||
@@ -702,7 +683,6 @@ class FontManager:
|
|||||||
def _register_common_fonts(self):
|
def _register_common_fonts(self):
|
||||||
"""Register common font aliases from common_fonts dictionary."""
|
"""Register common font aliases from common_fonts dictionary."""
|
||||||
for family_name, font_path in self.common_fonts.items():
|
for family_name, font_path in self.common_fonts.items():
|
||||||
font_path = self._resolve_asset_path(font_path)
|
|
||||||
# Check if font file exists
|
# Check if font file exists
|
||||||
if os.path.exists(font_path):
|
if os.path.exists(font_path):
|
||||||
# Register the common font name (overrides auto-generated name if exists)
|
# Register the common font name (overrides auto-generated name if exists)
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
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))
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"""Deprecated: use src/adaptive_images.py (fit_image) instead.
|
||||||
|
|
||||||
|
This module predates the adaptive image system and has no known callers.
|
||||||
|
It is kept only so any out-of-tree code importing it keeps working.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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
|
||||||
@@ -0,0 +1,409 @@
|
|||||||
|
"""
|
||||||
|
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,25 +139,7 @@ def setup_logging(
|
|||||||
sys.stderr.write(f"Warning: Could not set up file logging to {log_file}: {e}\n")
|
sys.stderr.write(f"Warning: Could not set up file logging to {log_file}: {e}\n")
|
||||||
|
|
||||||
|
|
||||||
class PluginLoggerAdapter(logging.LoggerAdapter):
|
def get_logger(name: str, plugin_id: Optional[str] = None) -> logging.Logger:
|
||||||
"""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.
|
Get a logger with consistent configuration.
|
||||||
|
|
||||||
@@ -166,13 +148,13 @@ def get_logger(name: str, plugin_id: Optional[str] = None):
|
|||||||
plugin_id: Optional plugin ID for automatic context
|
plugin_id: Optional plugin ID for automatic context
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Configured logger instance (or a PluginLoggerAdapter when plugin_id
|
Configured logger instance
|
||||||
is given, which supports the same .debug/.info/.warning/.error API)
|
|
||||||
"""
|
"""
|
||||||
logger = logging.getLogger(name)
|
logger = logging.getLogger(name)
|
||||||
|
|
||||||
|
# Add plugin_id as attribute for formatters
|
||||||
if plugin_id:
|
if plugin_id:
|
||||||
return PluginLoggerAdapter(logger, {'plugin_id': plugin_id})
|
logger.plugin_id = plugin_id
|
||||||
|
|
||||||
return logger
|
return logger
|
||||||
|
|
||||||
|
|||||||
@@ -86,9 +86,7 @@ class BasePlugin(ABC):
|
|||||||
self.display_manager: Any = display_manager
|
self.display_manager: Any = display_manager
|
||||||
self.cache_manager: Any = cache_manager
|
self.cache_manager: Any = cache_manager
|
||||||
self.plugin_manager: Any = plugin_manager
|
self.plugin_manager: Any = plugin_manager
|
||||||
# get_logger returns a PluginLoggerAdapter here (plugin_id given), which
|
self.logger: logging.Logger = get_logger(f"plugin.{plugin_id}", plugin_id=plugin_id)
|
||||||
# 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.enabled: bool = config.get("enabled", True)
|
||||||
|
|
||||||
self.logger.info("Initialized plugin: %s", plugin_id)
|
self.logger.info("Initialized plugin: %s", plugin_id)
|
||||||
@@ -505,40 +503,6 @@ class BasePlugin(ABC):
|
|||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# Vegas scroll mode support
|
# Vegas scroll mode support
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
def get_vegas_render_width(self) -> int:
|
|
||||||
"""
|
|
||||||
Width the Vegas ticker wants this plugin's content to occupy.
|
|
||||||
|
|
||||||
On a wide panel a layout built to fill the screen reads as sparse in a
|
|
||||||
ticker — a forecast spread over five columns, a progress bar drawn at
|
|
||||||
100% width, a stat block with the panel's whole width between its
|
|
||||||
elements. Vegas asks for a narrower render so the plugin can choose a
|
|
||||||
tighter arrangement instead of being cropped afterwards.
|
|
||||||
|
|
||||||
Vegas also narrows ``display_manager`` for the duration of the call, so
|
|
||||||
a plugin that already sizes itself from ``matrix.width`` needs no
|
|
||||||
changes. Read this only when you size content some other way.
|
|
||||||
|
|
||||||
Controlled by the plugin's own ``vegas_width_pct`` config value, else
|
|
||||||
the global ``display.vegas_scroll.render_width_pct``.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Target width in pixels. Outside a Vegas content request, the full
|
|
||||||
display width.
|
|
||||||
"""
|
|
||||||
requested = getattr(self, '_vegas_render_width', None)
|
|
||||||
if isinstance(requested, int) and requested > 0:
|
|
||||||
return requested
|
|
||||||
|
|
||||||
display_manager = getattr(self, 'display_manager', None)
|
|
||||||
matrix = getattr(display_manager, 'matrix', None)
|
|
||||||
if matrix is not None and getattr(matrix, 'width', None):
|
|
||||||
return int(matrix.width)
|
|
||||||
width = getattr(display_manager, 'width', None)
|
|
||||||
if callable(width):
|
|
||||||
width = width()
|
|
||||||
return int(width) if width else 128
|
|
||||||
|
|
||||||
def get_vegas_content(self) -> Optional[Any]:
|
def get_vegas_content(self) -> Optional[Any]:
|
||||||
"""
|
"""
|
||||||
Get content for Vegas-style continuous scroll mode.
|
Get content for Vegas-style continuous scroll mode.
|
||||||
|
|||||||
@@ -116,16 +116,6 @@ class SchemaManager:
|
|||||||
self.logger.error(f"Invalid schema format for {plugin_id}: not a dictionary")
|
self.logger.error(f"Invalid schema format for {plugin_id}: not a dictionary")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Expand any customization.x-style-elements declaration into the
|
|
||||||
# full per-element style blocks (font/size/color + layout
|
|
||||||
# offsets) the web-UI config form renders. No-op for schemas
|
|
||||||
# without the declaration; never raises.
|
|
||||||
try:
|
|
||||||
from src.element_style import expand_style_elements
|
|
||||||
schema = expand_style_elements(schema)
|
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Cache the schema
|
# Cache the schema
|
||||||
self._schema_cache[plugin_id] = schema
|
self._schema_cache[plugin_id] = schema
|
||||||
|
|
||||||
@@ -294,19 +284,6 @@ class SchemaManager:
|
|||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"default": False,
|
"default": False,
|
||||||
"description": "Enable live priority takeover when plugin has live content"
|
"description": "Enable live priority takeover when plugin has live content"
|
||||||
},
|
|
||||||
# Skin selection (docs/SKIN_SYSTEM.md). Deliberately NOT an
|
|
||||||
# enum here: validation must keep passing when a configured
|
|
||||||
# skin gets uninstalled (rendering falls back to built-in).
|
|
||||||
# The install-dependent enum is injected only at serve time
|
|
||||||
# (inject_skin_selector) for the web UI dropdown.
|
|
||||||
"skin": {
|
|
||||||
"type": ["string", "object", "null"],
|
|
||||||
"description": "Visual skin id, or a per-mode mapping like {\"live\": \"my-skin\"}"
|
|
||||||
},
|
|
||||||
"skin_options": {
|
|
||||||
"type": "object",
|
|
||||||
"description": "Options passed through to the selected skin"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,53 +354,6 @@ class SchemaManager:
|
|||||||
self.logger.error(error_msg)
|
self.logger.error(error_msg)
|
||||||
return False, [error_msg]
|
return False, [error_msg]
|
||||||
|
|
||||||
def inject_skin_selector(self, schema: Dict[str, Any], plugin_id: str,
|
|
||||||
current_value: Any = None) -> Dict[str, Any]:
|
|
||||||
"""Return a copy of a plugin's schema with a "skin" dropdown added
|
|
||||||
when installed skins target this plugin (docs/SKIN_SYSTEM.md).
|
|
||||||
|
|
||||||
Serve-time only — validation never sees this enum, so a config
|
|
||||||
referencing an uninstalled skin stays valid (rendering falls back
|
|
||||||
to the built-in layout). The currently-configured value is always
|
|
||||||
included in the enum for the same reason: the dropdown must be able
|
|
||||||
to display a selection whose skin was removed.
|
|
||||||
"""
|
|
||||||
# A per-mode mapping ({"live": ..., "recent": ...}) can't be edited
|
|
||||||
# through a string dropdown — injecting one would let the form save
|
|
||||||
# a string over the mapping. Leave the schema alone; per-mode users
|
|
||||||
# edit via the raw JSON config editor.
|
|
||||||
if isinstance(current_value, dict):
|
|
||||||
return schema
|
|
||||||
|
|
||||||
try:
|
|
||||||
from src.skin_system import skin_runtime
|
|
||||||
matching = skin_runtime.skins_for_plugin(plugin_id)
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.debug(f"Skin discovery failed for {plugin_id}: {e}")
|
|
||||||
return schema
|
|
||||||
|
|
||||||
choices = sorted(matching.keys())
|
|
||||||
if isinstance(current_value, str) and current_value and \
|
|
||||||
current_value != "built-in" and current_value not in choices:
|
|
||||||
choices.append(current_value)
|
|
||||||
if not choices:
|
|
||||||
return schema
|
|
||||||
|
|
||||||
enhanced = copy.deepcopy(schema)
|
|
||||||
enhanced.setdefault("properties", {})
|
|
||||||
if "skin" not in enhanced["properties"]:
|
|
||||||
names = {sid: (matching.get(sid, {}).get("name") or sid) for sid in choices}
|
|
||||||
enhanced["properties"]["skin"] = {
|
|
||||||
"type": "string",
|
|
||||||
"title": "Visual Skin",
|
|
||||||
"description": "Replace this scoreboard's look with an installed skin "
|
|
||||||
"(data, scheduling, and vegas mode are unaffected)",
|
|
||||||
"enum": ["built-in", *choices],
|
|
||||||
"enumNames": ["Built-in", *(names[sid] for sid in choices)],
|
|
||||||
"default": "built-in"
|
|
||||||
}
|
|
||||||
return enhanced
|
|
||||||
|
|
||||||
def _format_validation_error(self, error: ValidationError, plugin_id: Optional[str] = None) -> str:
|
def _format_validation_error(self, error: ValidationError, plugin_id: Optional[str] = None) -> str:
|
||||||
"""
|
"""
|
||||||
Format a validation error into a readable message.
|
Format a validation error into a readable message.
|
||||||
|
|||||||
@@ -1214,11 +1214,6 @@ class PluginStoreManager:
|
|||||||
self.logger.error(f"Plugin not found in registry: {plugin_id}")
|
self.logger.error(f"Plugin not found in registry: {plugin_id}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# 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')
|
repo_url = plugin_info.get('repo')
|
||||||
if not repo_url:
|
if not repo_url:
|
||||||
self.logger.error(f"Plugin {plugin_id} missing repository URL")
|
self.logger.error(f"Plugin {plugin_id} missing repository URL")
|
||||||
@@ -2259,152 +2254,6 @@ class PluginStoreManager:
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
_SKIN_ID_PATTERN = re.compile(r'^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$')
|
|
||||||
|
|
||||||
def _resolve_skin_target(self, skin_id: str) -> Optional[Path]:
|
|
||||||
"""Validate an externally-supplied skin id and resolve it to a path
|
|
||||||
strictly inside the skins directory. Returns None (after logging)
|
|
||||||
for ids that are malformed or would escape the directory — registry
|
|
||||||
entries and manifests are external input and must not be able to
|
|
||||||
write or delete outside skins/."""
|
|
||||||
from src.skin_system import skin_runtime
|
|
||||||
|
|
||||||
if not isinstance(skin_id, str) or not self._SKIN_ID_PATTERN.match(skin_id) \
|
|
||||||
or '..' in skin_id:
|
|
||||||
self.logger.error(f"Rejecting unsafe skin id: {skin_id!r}")
|
|
||||||
return None
|
|
||||||
skins_dir = skin_runtime.get_skins_directory().resolve()
|
|
||||||
target = (skins_dir / skin_id).resolve()
|
|
||||||
if target.parent != skins_dir:
|
|
||||||
self.logger.error(f"Skin id {skin_id!r} escapes the skins directory; rejecting")
|
|
||||||
return None
|
|
||||||
return target
|
|
||||||
|
|
||||||
def _install_skin_from_info(self, skin_id: str, skin_info: Dict,
|
|
||||||
branch: Optional[str] = None) -> bool:
|
|
||||||
"""Install a registry entry of type "skin" into skins/<id>/.
|
|
||||||
|
|
||||||
Reuses the plugin download machinery (git / monorepo zip / archive)
|
|
||||||
but validates skin.json instead of manifest.json and never installs
|
|
||||||
dependencies — skins are render-only (stdlib + PIL + the provided
|
|
||||||
SkinContext), which is also what keeps them safe to iterate on.
|
|
||||||
|
|
||||||
Downloads into a staging directory and validates there; the
|
|
||||||
existing installation is only replaced after the new one passes,
|
|
||||||
so a failed download or bad manifest can't destroy a working skin.
|
|
||||||
"""
|
|
||||||
from src.skin_system import skin_runtime
|
|
||||||
from src.skin_system.skin_base import SKIN_API_VERSION
|
|
||||||
|
|
||||||
repo_url = skin_info.get('repo')
|
|
||||||
if not repo_url:
|
|
||||||
self.logger.error(f"Skin {skin_id} missing repository URL")
|
|
||||||
return False
|
|
||||||
|
|
||||||
target = self._resolve_skin_target(skin_id)
|
|
||||||
if target is None:
|
|
||||||
return False
|
|
||||||
skins_dir = target.parent
|
|
||||||
skins_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
# Leading "_" keeps staging invisible to skin discovery
|
|
||||||
staging = skins_dir / f"_staging-{skin_id}"
|
|
||||||
if staging.exists() and not self._safe_remove_directory(staging):
|
|
||||||
return False
|
|
||||||
|
|
||||||
subpath = skin_info.get('plugin_path')
|
|
||||||
branch_candidates = self._distinct_sequence([
|
|
||||||
branch,
|
|
||||||
skin_info.get('branch'),
|
|
||||||
skin_info.get('default_branch'),
|
|
||||||
skin_info.get('last_commit_branch'),
|
|
||||||
'main',
|
|
||||||
'master'
|
|
||||||
])
|
|
||||||
|
|
||||||
try:
|
|
||||||
branch_used = None
|
|
||||||
if subpath:
|
|
||||||
for candidate in branch_candidates:
|
|
||||||
download_url = f"{repo_url}/archive/refs/heads/{candidate}.zip"
|
|
||||||
if self._install_from_monorepo(download_url, subpath, staging):
|
|
||||||
branch_used = candidate
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
branch_used = self._install_via_git(repo_url, staging, branch_candidates)
|
|
||||||
if branch_used is None and not staging.exists():
|
|
||||||
for candidate in branch_candidates:
|
|
||||||
download_url = f"{repo_url}/archive/refs/heads/{candidate}.zip"
|
|
||||||
if self._install_via_download(download_url, staging):
|
|
||||||
branch_used = candidate
|
|
||||||
break
|
|
||||||
|
|
||||||
if branch_used is None and not staging.exists():
|
|
||||||
self.logger.error(f"Failed to install skin {skin_id} via git or archive download")
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(staging / 'skin.json', 'r', encoding='utf-8') as f:
|
|
||||||
manifest = json.load(f)
|
|
||||||
except (OSError, json.JSONDecodeError) as e:
|
|
||||||
self.logger.error(f"Skin {skin_id} has no valid skin.json: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
missing = [k for k in ('id', 'name', 'version', 'skin_api_version', 'class_name')
|
|
||||||
if not manifest.get(k)]
|
|
||||||
if missing:
|
|
||||||
self.logger.error(f"Skin {skin_id} manifest missing fields: {missing}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Unlike plugins, a mismatched id is rejected rather than
|
|
||||||
# renamed: the manifest id is external input, and the registry
|
|
||||||
# id is what the user asked to install.
|
|
||||||
if manifest['id'] != skin_id:
|
|
||||||
self.logger.error(
|
|
||||||
f"Skin manifest id {manifest['id']!r} doesn't match registry id "
|
|
||||||
f"{skin_id!r}; not installing")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _api_major(v):
|
|
||||||
try:
|
|
||||||
return int(str(v).split('.')[0])
|
|
||||||
except (ValueError, IndexError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
if _api_major(manifest['skin_api_version']) != _api_major(SKIN_API_VERSION):
|
|
||||||
self.logger.error(
|
|
||||||
f"Skin {skin_id} targets skin API {manifest['skin_api_version']} but this "
|
|
||||||
f"LEDMatrix provides {SKIN_API_VERSION}; not installing")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Validated — swap into place
|
|
||||||
if target.exists() and not self._safe_remove_directory(target):
|
|
||||||
self.logger.error(f"Could not replace existing skin directory: {target}")
|
|
||||||
return False
|
|
||||||
shutil.move(str(staging), str(target))
|
|
||||||
skin_runtime.discover_skins(force_refresh=True)
|
|
||||||
self.logger.info(f"Successfully installed skin: {skin_id} (branch: {branch_used})")
|
|
||||||
return True
|
|
||||||
finally:
|
|
||||||
if staging.exists():
|
|
||||||
self._safe_remove_directory(staging)
|
|
||||||
|
|
||||||
def uninstall_skin(self, skin_id: str) -> bool:
|
|
||||||
"""Remove an installed skin. Plugin configs referencing it keep
|
|
||||||
validating; rendering falls back to the built-in layout."""
|
|
||||||
from src.skin_system import skin_runtime
|
|
||||||
|
|
||||||
target = self._resolve_skin_target(skin_id)
|
|
||||||
if target is None:
|
|
||||||
return False
|
|
||||||
if not target.exists():
|
|
||||||
self.logger.info(f"Skin {skin_id} not found (already uninstalled)")
|
|
||||||
return True
|
|
||||||
if self._safe_remove_directory(target):
|
|
||||||
skin_runtime.discover_skins(force_refresh=True)
|
|
||||||
self.logger.info(f"Successfully uninstalled skin: {skin_id}")
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def uninstall_plugin(self, plugin_id: str) -> bool:
|
def uninstall_plugin(self, plugin_id: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Uninstall a plugin by removing its directory.
|
Uninstall a plugin by removing its directory.
|
||||||
@@ -2418,12 +2267,6 @@ class PluginStoreManager:
|
|||||||
plugin_path = self._find_plugin_path(plugin_id)
|
plugin_path = self._find_plugin_path(plugin_id)
|
||||||
|
|
||||||
if plugin_path is None or not plugin_path.exists():
|
if plugin_path is None or not plugin_path.exists():
|
||||||
# A skin id passed to the plugin uninstall path (the store UI
|
|
||||||
# uses one uninstall flow) removes the skin instead
|
|
||||||
skin_target = self._resolve_skin_target(plugin_id) \
|
|
||||||
if self._SKIN_ID_PATTERN.match(str(plugin_id)) else None
|
|
||||||
if skin_target is not None and skin_target.exists():
|
|
||||||
return self.uninstall_skin(plugin_id)
|
|
||||||
self.logger.info(f"Plugin {plugin_id} not found (already uninstalled)")
|
self.logger.info(f"Plugin {plugin_id} not found (already uninstalled)")
|
||||||
return True # Already uninstalled, consider this success
|
return True # Already uninstalled, consider this success
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ PIL Image canvas and draws text using the actual project fonts.
|
|||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
from contextlib import contextmanager
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, List, Optional, Tuple
|
from typing import Any, List, Optional, Tuple
|
||||||
|
|
||||||
@@ -63,9 +62,6 @@ class VisualTestDisplayManager:
|
|||||||
# Matrix proxy (plugins access display_manager.matrix.width/height)
|
# Matrix proxy (plugins access display_manager.matrix.width/height)
|
||||||
self.matrix = _MatrixProxy(width, height)
|
self.matrix = _MatrixProxy(width, height)
|
||||||
|
|
||||||
# Set while inside capture_mode(); mirrors DisplayManager's flag.
|
|
||||||
self._capture_mode_active = False
|
|
||||||
|
|
||||||
# Scrolling state (interface compat, no-op)
|
# Scrolling state (interface compat, no-op)
|
||||||
self._scrolling_state = {
|
self._scrolling_state = {
|
||||||
'is_scrolling': False,
|
'is_scrolling': False,
|
||||||
@@ -178,50 +174,6 @@ class VisualTestDisplayManager:
|
|||||||
"""No-op for hardware; marks that display was updated."""
|
"""No-op for hardware; marks that display was updated."""
|
||||||
self.update_called = True
|
self.update_called = True
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def render_size(self, width: int, height: Optional[int] = None):
|
|
||||||
"""
|
|
||||||
Interface parity with DisplayManager.render_size().
|
|
||||||
|
|
||||||
Vegas mode narrows the canvas so plugins lay out compactly instead of
|
|
||||||
being cropped. The harness must offer the same context or that path
|
|
||||||
cannot be exercised offline — and because the adapter catches broadly,
|
|
||||||
a missing method shows up as "no content" rather than an error.
|
|
||||||
"""
|
|
||||||
prev_image = self.image
|
|
||||||
prev_draw = self.draw
|
|
||||||
prev_w, prev_h = self._width, self._height
|
|
||||||
|
|
||||||
target_w = max(1, min(int(width), prev_w))
|
|
||||||
target_h = max(1, min(int(height) if height else prev_h, prev_h))
|
|
||||||
|
|
||||||
try:
|
|
||||||
self._width, self._height = target_w, target_h
|
|
||||||
self.matrix = _MatrixProxy(target_w, target_h)
|
|
||||||
self.image = Image.new('RGB', (target_w, target_h), (0, 0, 0))
|
|
||||||
self.draw = ImageDraw.Draw(self.image)
|
|
||||||
yield
|
|
||||||
finally:
|
|
||||||
self._width, self._height = prev_w, prev_h
|
|
||||||
self.matrix = _MatrixProxy(prev_w, prev_h)
|
|
||||||
self.image = prev_image
|
|
||||||
self.draw = prev_draw
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def capture_mode(self):
|
|
||||||
"""
|
|
||||||
Interface parity with DisplayManager.capture_mode().
|
|
||||||
|
|
||||||
There is no hardware to suppress here, but Vegas mode's PluginAdapter
|
|
||||||
wraps every off-screen content fetch in this context, so the harness
|
|
||||||
must provide it for that code path to be exercisable in tests.
|
|
||||||
"""
|
|
||||||
self._capture_mode_active = True
|
|
||||||
try:
|
|
||||||
yield
|
|
||||||
finally:
|
|
||||||
self._capture_mode_active = False
|
|
||||||
|
|
||||||
def draw_text(self, text: str, x: Optional[int] = None, y: Optional[int] = None,
|
def draw_text(self, text: str, x: Optional[int] = None, y: Optional[int] = None,
|
||||||
color: Tuple[int, int, int] = (255, 255, 255), small_font: bool = False,
|
color: Tuple[int, int, int] = (255, 255, 255), small_font: bool = False,
|
||||||
font: Optional[Any] = None, centered: bool = False) -> None:
|
font: Optional[Any] = None, centered: bool = False) -> None:
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
"""
|
|
||||||
Skin system: user-installable visual overlays for sports scoreboards.
|
|
||||||
|
|
||||||
A skin replaces only the rendering of a scoreboard (live / recent /
|
|
||||||
upcoming) while the host plugin keeps doing data fetching, scheduling,
|
|
||||||
caching, live priority, and vegas mode. See docs/SKIN_SYSTEM.md.
|
|
||||||
"""
|
|
||||||
|
|
||||||
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",
|
|
||||||
]
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "401570001",
|
|
||||||
"game_time": "7:05PM",
|
|
||||||
"game_date": "Jul 16th",
|
|
||||||
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
|
||||||
"status_text": "Bot 7th",
|
|
||||||
"is_live": true,
|
|
||||||
"is_final": false,
|
|
||||||
"is_upcoming": false,
|
|
||||||
"is_halftime": false,
|
|
||||||
"is_period_break": false,
|
|
||||||
"home_abbr": "LAD",
|
|
||||||
"home_id": "19",
|
|
||||||
"home_score": "5",
|
|
||||||
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
|
||||||
"home_logo_url": null,
|
|
||||||
"home_record": "58-33",
|
|
||||||
"away_abbr": "SF",
|
|
||||||
"away_id": "26",
|
|
||||||
"away_score": "3",
|
|
||||||
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
|
||||||
"away_logo_url": null,
|
|
||||||
"away_record": "49-42",
|
|
||||||
"is_within_window": true,
|
|
||||||
"status": "STATUS_IN_PROGRESS",
|
|
||||||
"status_state": "in",
|
|
||||||
"inning": 7,
|
|
||||||
"inning_half": "bottom",
|
|
||||||
"balls": 3,
|
|
||||||
"strikes": 2,
|
|
||||||
"outs": 2,
|
|
||||||
"bases_occupied": [
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
true
|
|
||||||
],
|
|
||||||
"start_time": "2026-07-16T23:05:00Z",
|
|
||||||
"series_summary": "LAD leads 2-1"
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "401570001",
|
|
||||||
"game_time": "7:05PM",
|
|
||||||
"game_date": "Jul 16th",
|
|
||||||
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
|
||||||
"status_text": "Final",
|
|
||||||
"is_live": false,
|
|
||||||
"is_final": true,
|
|
||||||
"is_upcoming": false,
|
|
||||||
"is_halftime": false,
|
|
||||||
"is_period_break": false,
|
|
||||||
"home_abbr": "LAD",
|
|
||||||
"home_id": "19",
|
|
||||||
"home_score": "5",
|
|
||||||
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
|
||||||
"home_logo_url": null,
|
|
||||||
"home_record": "58-33",
|
|
||||||
"away_abbr": "SF",
|
|
||||||
"away_id": "26",
|
|
||||||
"away_score": "3",
|
|
||||||
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
|
||||||
"away_logo_url": null,
|
|
||||||
"away_record": "49-42",
|
|
||||||
"is_within_window": true,
|
|
||||||
"status": "STATUS_FINAL",
|
|
||||||
"status_state": "post",
|
|
||||||
"inning": 9,
|
|
||||||
"inning_half": "top",
|
|
||||||
"balls": 0,
|
|
||||||
"strikes": 0,
|
|
||||||
"outs": 3,
|
|
||||||
"bases_occupied": [
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false
|
|
||||||
],
|
|
||||||
"start_time": "2026-07-16T23:05:00Z",
|
|
||||||
"series_summary": "Series tied 2-2"
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "401570001",
|
|
||||||
"game_time": "7:05PM",
|
|
||||||
"game_date": "Jul 16th",
|
|
||||||
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
|
||||||
"status_text": "7:05 PM",
|
|
||||||
"is_live": false,
|
|
||||||
"is_final": false,
|
|
||||||
"is_upcoming": true,
|
|
||||||
"is_halftime": false,
|
|
||||||
"is_period_break": false,
|
|
||||||
"home_abbr": "LAD",
|
|
||||||
"home_id": "19",
|
|
||||||
"home_score": "0",
|
|
||||||
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
|
||||||
"home_logo_url": null,
|
|
||||||
"home_record": "58-33",
|
|
||||||
"away_abbr": "SF",
|
|
||||||
"away_id": "26",
|
|
||||||
"away_score": "0",
|
|
||||||
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
|
||||||
"away_logo_url": null,
|
|
||||||
"away_record": "49-42",
|
|
||||||
"is_within_window": true,
|
|
||||||
"status": "STATUS_SCHEDULED",
|
|
||||||
"status_state": "pre",
|
|
||||||
"inning": 0,
|
|
||||||
"inning_half": "top",
|
|
||||||
"balls": 0,
|
|
||||||
"strikes": 0,
|
|
||||||
"outs": 0,
|
|
||||||
"bases_occupied": [
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false
|
|
||||||
],
|
|
||||||
"start_time": "2026-07-16T23:05:00Z",
|
|
||||||
"series_summary": ""
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "401570001",
|
|
||||||
"game_time": "7:05PM",
|
|
||||||
"game_date": "Jul 16th",
|
|
||||||
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
|
||||||
"status_text": "Q4 2:34",
|
|
||||||
"is_live": true,
|
|
||||||
"is_final": false,
|
|
||||||
"is_upcoming": false,
|
|
||||||
"is_halftime": false,
|
|
||||||
"is_period_break": false,
|
|
||||||
"home_abbr": "OKC",
|
|
||||||
"home_id": "19",
|
|
||||||
"home_score": "5",
|
|
||||||
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
|
||||||
"home_logo_url": null,
|
|
||||||
"home_record": "",
|
|
||||||
"away_abbr": "MIN",
|
|
||||||
"away_id": "26",
|
|
||||||
"away_score": "3",
|
|
||||||
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
|
||||||
"away_logo_url": null,
|
|
||||||
"away_record": "",
|
|
||||||
"is_within_window": true,
|
|
||||||
"period": 4,
|
|
||||||
"period_text": "Q4",
|
|
||||||
"clock": "2:34"
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "401570001",
|
|
||||||
"game_time": "7:05PM",
|
|
||||||
"game_date": "Jul 16th",
|
|
||||||
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
|
||||||
"status_text": "Final",
|
|
||||||
"is_live": false,
|
|
||||||
"is_final": true,
|
|
||||||
"is_upcoming": false,
|
|
||||||
"is_halftime": false,
|
|
||||||
"is_period_break": false,
|
|
||||||
"home_abbr": "OKC",
|
|
||||||
"home_id": "19",
|
|
||||||
"home_score": "5",
|
|
||||||
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
|
||||||
"home_logo_url": null,
|
|
||||||
"home_record": "",
|
|
||||||
"away_abbr": "MIN",
|
|
||||||
"away_id": "26",
|
|
||||||
"away_score": "3",
|
|
||||||
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
|
||||||
"away_logo_url": null,
|
|
||||||
"away_record": "",
|
|
||||||
"is_within_window": true,
|
|
||||||
"period": 4,
|
|
||||||
"period_text": "Final",
|
|
||||||
"clock": "0:00"
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "401570001",
|
|
||||||
"game_time": "7:05PM",
|
|
||||||
"game_date": "Jul 16th",
|
|
||||||
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
|
||||||
"status_text": "7:05 PM",
|
|
||||||
"is_live": false,
|
|
||||||
"is_final": false,
|
|
||||||
"is_upcoming": true,
|
|
||||||
"is_halftime": false,
|
|
||||||
"is_period_break": false,
|
|
||||||
"home_abbr": "OKC",
|
|
||||||
"home_id": "19",
|
|
||||||
"home_score": "0",
|
|
||||||
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
|
||||||
"home_logo_url": null,
|
|
||||||
"home_record": "",
|
|
||||||
"away_abbr": "MIN",
|
|
||||||
"away_id": "26",
|
|
||||||
"away_score": "0",
|
|
||||||
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
|
||||||
"away_logo_url": null,
|
|
||||||
"away_record": "",
|
|
||||||
"is_within_window": true,
|
|
||||||
"period": 0,
|
|
||||||
"period_text": "",
|
|
||||||
"clock": "0:00"
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "401570001",
|
|
||||||
"game_time": "7:05PM",
|
|
||||||
"game_date": "Jul 16th",
|
|
||||||
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
|
||||||
"status_text": "Q3 8:12",
|
|
||||||
"is_live": true,
|
|
||||||
"is_final": false,
|
|
||||||
"is_upcoming": false,
|
|
||||||
"is_halftime": false,
|
|
||||||
"is_period_break": false,
|
|
||||||
"home_abbr": "KC",
|
|
||||||
"home_id": "19",
|
|
||||||
"home_score": "21",
|
|
||||||
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
|
||||||
"home_logo_url": null,
|
|
||||||
"home_record": "58-33",
|
|
||||||
"away_abbr": "BUF",
|
|
||||||
"away_id": "26",
|
|
||||||
"away_score": "17",
|
|
||||||
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
|
||||||
"away_logo_url": null,
|
|
||||||
"away_record": "49-42",
|
|
||||||
"is_within_window": true,
|
|
||||||
"period": 3,
|
|
||||||
"period_text": "Q3",
|
|
||||||
"clock": "8:12",
|
|
||||||
"home_timeouts": 2,
|
|
||||||
"away_timeouts": 3,
|
|
||||||
"down_distance_text": "3rd & 4",
|
|
||||||
"down_distance_text_long": "3rd & 4 at KC 22",
|
|
||||||
"is_redzone": true,
|
|
||||||
"possession": "12",
|
|
||||||
"possession_indicator": "away",
|
|
||||||
"scoring_event": null
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "401570001",
|
|
||||||
"game_time": "7:05PM",
|
|
||||||
"game_date": "Jul 16th",
|
|
||||||
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
|
||||||
"status_text": "Final",
|
|
||||||
"is_live": false,
|
|
||||||
"is_final": true,
|
|
||||||
"is_upcoming": false,
|
|
||||||
"is_halftime": false,
|
|
||||||
"is_period_break": false,
|
|
||||||
"home_abbr": "KC",
|
|
||||||
"home_id": "19",
|
|
||||||
"home_score": "21",
|
|
||||||
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
|
||||||
"home_logo_url": null,
|
|
||||||
"home_record": "58-33",
|
|
||||||
"away_abbr": "BUF",
|
|
||||||
"away_id": "26",
|
|
||||||
"away_score": "17",
|
|
||||||
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
|
||||||
"away_logo_url": null,
|
|
||||||
"away_record": "49-42",
|
|
||||||
"is_within_window": true,
|
|
||||||
"period": 4,
|
|
||||||
"period_text": "Final",
|
|
||||||
"clock": "0:00",
|
|
||||||
"home_timeouts": 0,
|
|
||||||
"away_timeouts": 0,
|
|
||||||
"down_distance_text": "",
|
|
||||||
"down_distance_text_long": "",
|
|
||||||
"is_redzone": false,
|
|
||||||
"possession": null,
|
|
||||||
"possession_indicator": null,
|
|
||||||
"scoring_event": null
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "401570001",
|
|
||||||
"game_time": "7:05PM",
|
|
||||||
"game_date": "Jul 16th",
|
|
||||||
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
|
||||||
"status_text": "7:05 PM",
|
|
||||||
"is_live": false,
|
|
||||||
"is_final": false,
|
|
||||||
"is_upcoming": true,
|
|
||||||
"is_halftime": false,
|
|
||||||
"is_period_break": false,
|
|
||||||
"home_abbr": "KC",
|
|
||||||
"home_id": "19",
|
|
||||||
"home_score": "0",
|
|
||||||
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
|
||||||
"home_logo_url": null,
|
|
||||||
"home_record": "58-33",
|
|
||||||
"away_abbr": "BUF",
|
|
||||||
"away_id": "26",
|
|
||||||
"away_score": "0",
|
|
||||||
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
|
||||||
"away_logo_url": null,
|
|
||||||
"away_record": "49-42",
|
|
||||||
"is_within_window": true,
|
|
||||||
"period": 0,
|
|
||||||
"period_text": "",
|
|
||||||
"clock": "0:00",
|
|
||||||
"home_timeouts": 3,
|
|
||||||
"away_timeouts": 3,
|
|
||||||
"down_distance_text": "",
|
|
||||||
"down_distance_text_long": "",
|
|
||||||
"is_redzone": false,
|
|
||||||
"possession": null,
|
|
||||||
"possession_indicator": null,
|
|
||||||
"scoring_event": null
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "401570001",
|
|
||||||
"game_time": "7:05PM",
|
|
||||||
"game_date": "Jul 16th",
|
|
||||||
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
|
||||||
"status_text": "P3 14:55",
|
|
||||||
"is_live": true,
|
|
||||||
"is_final": false,
|
|
||||||
"is_upcoming": false,
|
|
||||||
"is_halftime": false,
|
|
||||||
"is_period_break": false,
|
|
||||||
"home_abbr": "COL",
|
|
||||||
"home_id": "19",
|
|
||||||
"home_score": "2",
|
|
||||||
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
|
||||||
"home_logo_url": null,
|
|
||||||
"home_record": "58-33",
|
|
||||||
"away_abbr": "VGK",
|
|
||||||
"away_id": "26",
|
|
||||||
"away_score": "2",
|
|
||||||
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
|
||||||
"away_logo_url": null,
|
|
||||||
"away_record": "49-42",
|
|
||||||
"is_within_window": true,
|
|
||||||
"period": 3,
|
|
||||||
"period_text": "P3",
|
|
||||||
"clock": "14:55",
|
|
||||||
"power_play": true,
|
|
||||||
"penalties": [],
|
|
||||||
"home_shots": 27,
|
|
||||||
"away_shots": 31
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "401570001",
|
|
||||||
"game_time": "7:05PM",
|
|
||||||
"game_date": "Jul 16th",
|
|
||||||
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
|
||||||
"status_text": "Final/OT",
|
|
||||||
"is_live": false,
|
|
||||||
"is_final": true,
|
|
||||||
"is_upcoming": false,
|
|
||||||
"is_halftime": false,
|
|
||||||
"is_period_break": false,
|
|
||||||
"home_abbr": "COL",
|
|
||||||
"home_id": "19",
|
|
||||||
"home_score": "3",
|
|
||||||
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
|
||||||
"home_logo_url": null,
|
|
||||||
"home_record": "58-33",
|
|
||||||
"away_abbr": "VGK",
|
|
||||||
"away_id": "26",
|
|
||||||
"away_score": "2",
|
|
||||||
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
|
||||||
"away_logo_url": null,
|
|
||||||
"away_record": "49-42",
|
|
||||||
"is_within_window": true,
|
|
||||||
"period": 5,
|
|
||||||
"period_text": "Final/OT",
|
|
||||||
"clock": "0:00",
|
|
||||||
"power_play": false,
|
|
||||||
"penalties": [],
|
|
||||||
"home_shots": 35,
|
|
||||||
"away_shots": 33
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "401570001",
|
|
||||||
"game_time": "7:05PM",
|
|
||||||
"game_date": "Jul 16th",
|
|
||||||
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
|
||||||
"status_text": "7:05 PM",
|
|
||||||
"is_live": false,
|
|
||||||
"is_final": false,
|
|
||||||
"is_upcoming": true,
|
|
||||||
"is_halftime": false,
|
|
||||||
"is_period_break": false,
|
|
||||||
"home_abbr": "COL",
|
|
||||||
"home_id": "19",
|
|
||||||
"home_score": "0",
|
|
||||||
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
|
||||||
"home_logo_url": null,
|
|
||||||
"home_record": "58-33",
|
|
||||||
"away_abbr": "VGK",
|
|
||||||
"away_id": "26",
|
|
||||||
"away_score": "0",
|
|
||||||
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
|
||||||
"away_logo_url": null,
|
|
||||||
"away_record": "49-42",
|
|
||||||
"is_within_window": true,
|
|
||||||
"period": 0,
|
|
||||||
"period_text": "",
|
|
||||||
"clock": "0:00",
|
|
||||||
"power_play": false,
|
|
||||||
"penalties": [],
|
|
||||||
"home_shots": 0,
|
|
||||||
"away_shots": 0
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 444 B |
|
Before Width: | Height: | Size: 446 B |
@@ -1,171 +0,0 @@
|
|||||||
"""
|
|
||||||
Skin API: the classes a skin author works with.
|
|
||||||
|
|
||||||
A skin is a directory under skins/<skin-id>/ containing a skin.json
|
|
||||||
manifest and a Python module exposing a ScoreboardSkin subclass. The
|
|
||||||
host (a sports scoreboard's base classes) builds a SkinContext per
|
|
||||||
render and calls render_live / render_recent / render_upcoming with the
|
|
||||||
game view model. The skin draws onto ctx.canvas and returns True; the
|
|
||||||
host composites the canvas onto the display. A skin never talks to the
|
|
||||||
display, the network, or the plugin directly.
|
|
||||||
|
|
||||||
Skin API Version: 1.0.0
|
|
||||||
View Model Version: 1.0
|
|
||||||
"""
|
|
||||||
|
|
||||||
from abc import ABC
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Any, Callable, Dict, Optional, Tuple, Union
|
|
||||||
|
|
||||||
from PIL import Image, ImageDraw
|
|
||||||
|
|
||||||
try:
|
|
||||||
import freetype
|
|
||||||
except ImportError: # pragma: no cover - freetype ships with the project deps
|
|
||||||
freetype = None
|
|
||||||
|
|
||||||
from src.adaptive_layout import FitResult, LayoutContext, Region
|
|
||||||
|
|
||||||
# Major must match a skin manifest's skin_api_version major or the skin
|
|
||||||
# is refused at load time (renames/removals bump major; additions minor).
|
|
||||||
SKIN_API_VERSION = "1.0.0"
|
|
||||||
|
|
||||||
# Version of the guaranteed `game` dict keys (see docs/CREATING_SKINS.md).
|
|
||||||
VIEW_MODEL_VERSION = "1.0"
|
|
||||||
|
|
||||||
|
|
||||||
def _draw_bdf_text_on(draw: ImageDraw.ImageDraw, text: str, x: int, y: int,
|
|
||||||
color: Tuple[int, int, int], face: Any,
|
|
||||||
clip_w: int, clip_h: int) -> None:
|
|
||||||
"""Render a freetype BDF face glyph-by-glyph onto an arbitrary canvas.
|
|
||||||
|
|
||||||
DisplayManager._draw_bdf_text only draws onto the panel image; skins
|
|
||||||
draw onto their own canvas, so the fitted-font path (fit_text can
|
|
||||||
return freetype faces) needs this standalone equivalent.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
ascender_px = face.size.ascender >> 6
|
|
||||||
except Exception:
|
|
||||||
ascender_px = 0
|
|
||||||
baseline_y = y + ascender_px
|
|
||||||
for char in text:
|
|
||||||
face.load_char(char)
|
|
||||||
bitmap = face.glyph.bitmap
|
|
||||||
glyph_left = face.glyph.bitmap_left
|
|
||||||
glyph_top = face.glyph.bitmap_top
|
|
||||||
for i in range(bitmap.rows):
|
|
||||||
for j in range(bitmap.width):
|
|
||||||
byte_index = i * bitmap.pitch + (j // 8)
|
|
||||||
if byte_index < len(bitmap.buffer) and \
|
|
||||||
bitmap.buffer[byte_index] & (1 << (7 - (j % 8))):
|
|
||||||
px = x + glyph_left + j
|
|
||||||
py = baseline_y - glyph_top + i
|
|
||||||
if 0 <= px < clip_w and 0 <= py < clip_h:
|
|
||||||
draw.point((px, py), fill=color)
|
|
||||||
x += face.glyph.advance.x >> 6
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class SkinContext:
|
|
||||||
"""Everything a skin may touch during one render call.
|
|
||||||
|
|
||||||
The canvas is a fresh RGB image sized to the current display (or
|
|
||||||
vegas card). Draw onto it via the helpers below or raw ``draw``;
|
|
||||||
never call display/update methods — the host composites the canvas.
|
|
||||||
"""
|
|
||||||
|
|
||||||
canvas: Image.Image
|
|
||||||
draw: ImageDraw.ImageDraw
|
|
||||||
layout: LayoutContext
|
|
||||||
width: int
|
|
||||||
height: int
|
|
||||||
fonts: Dict[str, Any]
|
|
||||||
options: Dict[str, Any]
|
|
||||||
logger: Any
|
|
||||||
sport: Optional[str] = None
|
|
||||||
view_model_version: str = VIEW_MODEL_VERSION
|
|
||||||
# load_logo("home") / load_logo("away") -> RGBA PIL image or None.
|
|
||||||
# Bound to the current game; hits the host's logo cache (never loads
|
|
||||||
# from disk twice), downloads missing logos like the built-in layout.
|
|
||||||
load_logo: Callable[[str], Optional[Image.Image]] = field(default=lambda side: None)
|
|
||||||
# draw_text_outlined(text, (x, y), font, fill=..., outline_color=...)
|
|
||||||
# — the classic scorebug outlined text, drawn onto this canvas.
|
|
||||||
# TTF fonts only (ctx.fonts values are TTF); for ladder-fitted fonts
|
|
||||||
# use draw_fit / draw_text, which handle BDF faces too.
|
|
||||||
draw_text_outlined: Callable[..., None] = field(default=lambda *a, **k: None)
|
|
||||||
|
|
||||||
def draw_text(self, text: str, x: int, y: int,
|
|
||||||
color: Tuple[int, int, int] = (255, 255, 255),
|
|
||||||
font: Any = None) -> None:
|
|
||||||
"""Draw text at a top-left position, handling both PIL fonts and
|
|
||||||
the freetype BDF faces that layout.fit_text can return."""
|
|
||||||
if font is None:
|
|
||||||
font = self.fonts.get('time')
|
|
||||||
if freetype is not None and isinstance(font, freetype.Face):
|
|
||||||
_draw_bdf_text_on(self.draw, text, int(x), int(y), color, font,
|
|
||||||
self.width, self.height)
|
|
||||||
else:
|
|
||||||
self.draw.text((int(x), int(y)), text, font=font, fill=color)
|
|
||||||
|
|
||||||
def draw_fit(self, fit: FitResult, box: Union[Region, Tuple[int, int]],
|
|
||||||
color: Tuple[int, int, int] = (255, 255, 255),
|
|
||||||
align: str = "center", valign: str = "center") -> None:
|
|
||||||
"""Draw a layout.fit_text() result aligned within a Region — the
|
|
||||||
canvas-local equivalent of adaptive_layout.draw_fitted_text."""
|
|
||||||
region = box if isinstance(box, Region) else Region(0, 0, box[0], box[1])
|
|
||||||
x, y = region.align_xy(fit.width, fit.height, align, valign)
|
|
||||||
self.draw_text(fit.text, x, y - fit.y_offset, color=color, font=fit.font)
|
|
||||||
|
|
||||||
def draw_image(self, img: Optional[Image.Image],
|
|
||||||
box: Union[Region, Tuple[int, int]], *,
|
|
||||||
mode: str = "contain", align: str = "center",
|
|
||||||
valign: str = "center", cache_key: Any = None) -> None:
|
|
||||||
"""Fit an image (a logo, art) into a Region and paste it, honoring
|
|
||||||
alpha. Silently no-ops on None so `ctx.draw_image(ctx.load_logo(
|
|
||||||
'home'), ...)` stays safe when a logo is missing."""
|
|
||||||
if img is None:
|
|
||||||
return
|
|
||||||
region = box if isinstance(box, Region) else Region(0, 0, box[0], box[1])
|
|
||||||
fitted = self.layout.fit_image(img, region, mode=mode,
|
|
||||||
cache_key=cache_key)
|
|
||||||
result = fitted.image # fit_image returns an ImageFitResult (always RGBA)
|
|
||||||
if result is None:
|
|
||||||
return
|
|
||||||
x, y = region.align_xy(result.width, result.height, align, valign)
|
|
||||||
self.canvas.paste(result, (int(x), int(y)), result)
|
|
||||||
|
|
||||||
|
|
||||||
class ScoreboardSkin(ABC):
|
|
||||||
"""Base class for scoreboard skins.
|
|
||||||
|
|
||||||
Override only the modes you want to restyle; any mode you leave
|
|
||||||
unimplemented (or return False from) falls back to the plugin's
|
|
||||||
built-in renderer, so a live-only skin still gets recent/upcoming
|
|
||||||
screens for free.
|
|
||||||
|
|
||||||
Skins should be stateless: three host instances (live, recent,
|
|
||||||
upcoming) each hold their own skin instance, and a render must be
|
|
||||||
derivable from (ctx, game) alone.
|
|
||||||
"""
|
|
||||||
|
|
||||||
SKIN_API_VERSION = SKIN_API_VERSION
|
|
||||||
|
|
||||||
def __init__(self, manifest: Dict[str, Any], options: Dict[str, Any]):
|
|
||||||
self.manifest = manifest
|
|
||||||
self.options = options or {}
|
|
||||||
|
|
||||||
def render_live(self, ctx: SkinContext, game: Dict[str, Any]) -> bool:
|
|
||||||
return False
|
|
||||||
|
|
||||||
def render_recent(self, ctx: SkinContext, game: Dict[str, Any]) -> bool:
|
|
||||||
return False
|
|
||||||
|
|
||||||
def render_upcoming(self, ctx: SkinContext, game: Dict[str, Any]) -> bool:
|
|
||||||
return False
|
|
||||||
|
|
||||||
def render_vegas_card(self, ctx: SkinContext,
|
|
||||||
game: Dict[str, Any]) -> Optional[Image.Image]:
|
|
||||||
"""Render one vegas scroll card at ctx.width x ctx.height. Return
|
|
||||||
the finished image, or None to let the host use its default vegas
|
|
||||||
rendering (which captures the regular display output)."""
|
|
||||||
return None
|
|
||||||
@@ -1,352 +0,0 @@
|
|||||||
"""
|
|
||||||
Skin runtime: discovery, validation, loading, and context building.
|
|
||||||
|
|
||||||
Deliberately generic — this module knows nothing about sports beyond
|
|
||||||
passing a `sport` label through; the sports flavor lives in skin_base
|
|
||||||
(ScoreboardSkin) and in the hosts that call build_context.
|
|
||||||
|
|
||||||
Every failure path here logs and returns None: a broken or missing skin
|
|
||||||
must never take down the plugin that references it — the host falls
|
|
||||||
back to its built-in renderer.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import importlib.util
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
import threading
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Dict, Optional, Tuple
|
|
||||||
|
|
||||||
from PIL import Image, ImageDraw
|
|
||||||
|
|
||||||
from src.adaptive_layout import LayoutContext
|
|
||||||
from src.logging_config import get_logger
|
|
||||||
from src.skin_system.skin_base import (
|
|
||||||
SKIN_API_VERSION,
|
|
||||||
ScoreboardSkin,
|
|
||||||
SkinContext,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
|
||||||
|
|
||||||
_REQUIRED_MANIFEST_FIELDS = ("id", "name", "version", "skin_api_version", "class_name")
|
|
||||||
_DEFAULT_ENTRY_POINT = "skin.py"
|
|
||||||
|
|
||||||
_lock = threading.RLock()
|
|
||||||
# skins_dir -> (fingerprint, {skin_id: manifest+path})
|
|
||||||
_discovery_cache: Dict[str, Tuple[Tuple, Dict[str, Dict[str, Any]]]] = {}
|
|
||||||
|
|
||||||
_shared_layout_font_manager: Optional[Any] = None
|
|
||||||
|
|
||||||
|
|
||||||
def _get_font_manager() -> Any:
|
|
||||||
"""Shared FontManager for skin LayoutContexts. SportsCore hosts don't
|
|
||||||
carry a plugin_manager, so skins share one module-level FontManager —
|
|
||||||
the same shape as base_plugin._fallback_font_manager, constructed
|
|
||||||
directly so rendering never has to import the whole plugin system."""
|
|
||||||
global _shared_layout_font_manager
|
|
||||||
if _shared_layout_font_manager is None:
|
|
||||||
from src.font_manager import FontManager
|
|
||||||
_shared_layout_font_manager = FontManager({})
|
|
||||||
return _shared_layout_font_manager
|
|
||||||
|
|
||||||
|
|
||||||
def get_skins_directory() -> Path:
|
|
||||||
"""Central skins directory: <project_root>/skins. Lives outside the
|
|
||||||
plugin directories on purpose — plugin reinstall/update deletes the
|
|
||||||
whole plugin directory, and a skin must survive that."""
|
|
||||||
return Path(__file__).resolve().parents[2] / "skins"
|
|
||||||
|
|
||||||
|
|
||||||
def _major(version: str) -> Optional[int]:
|
|
||||||
try:
|
|
||||||
return int(str(version).split(".")[0])
|
|
||||||
except (ValueError, AttributeError, IndexError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _read_manifest(skin_dir: Path) -> Optional[Dict[str, Any]]:
|
|
||||||
manifest_path = skin_dir / "skin.json"
|
|
||||||
if not manifest_path.is_file():
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
with open(manifest_path, "r", encoding="utf-8") as f:
|
|
||||||
manifest = json.load(f)
|
|
||||||
except (OSError, json.JSONDecodeError) as e:
|
|
||||||
logger.error("Skin manifest %s is unreadable: %s", manifest_path, e)
|
|
||||||
return None
|
|
||||||
missing = [k for k in _REQUIRED_MANIFEST_FIELDS if not manifest.get(k)]
|
|
||||||
if missing:
|
|
||||||
logger.error("Skin manifest %s missing required fields: %s",
|
|
||||||
manifest_path, ", ".join(missing))
|
|
||||||
return None
|
|
||||||
if manifest["id"] != skin_dir.name:
|
|
||||||
logger.warning("Skin manifest id %r does not match directory name %r",
|
|
||||||
manifest["id"], skin_dir.name)
|
|
||||||
manifest["_skin_dir"] = str(skin_dir)
|
|
||||||
return manifest
|
|
||||||
|
|
||||||
|
|
||||||
def _discovery_fingerprint(skins_dir: Path) -> Optional[Tuple]:
|
|
||||||
"""Cache key for a skins directory: its mtime plus every skin.json's
|
|
||||||
(path, mtime). The directory mtime alone misses in-place manifest edits
|
|
||||||
(a skin updated without adding/removing entries)."""
|
|
||||||
try:
|
|
||||||
parts = [skins_dir.stat().st_mtime]
|
|
||||||
for manifest_path in sorted(skins_dir.glob("*/skin.json")):
|
|
||||||
parts.append((str(manifest_path), manifest_path.stat().st_mtime))
|
|
||||||
return tuple(parts)
|
|
||||||
except OSError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def discover_skins(skins_dir: Optional[Path] = None,
|
|
||||||
force_refresh: bool = False) -> Dict[str, Dict[str, Any]]:
|
|
||||||
"""Return {skin_id: manifest} for every valid skin package installed.
|
|
||||||
|
|
||||||
Cached per directory and invalidated when the directory or any
|
|
||||||
skin.json changes; pass force_refresh to bypass.
|
|
||||||
"""
|
|
||||||
skins_dir = Path(skins_dir) if skins_dir else get_skins_directory()
|
|
||||||
cache_key = str(skins_dir)
|
|
||||||
fingerprint = _discovery_fingerprint(skins_dir)
|
|
||||||
if fingerprint is None:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
with _lock:
|
|
||||||
cached = _discovery_cache.get(cache_key)
|
|
||||||
if cached and not force_refresh and cached[0] == fingerprint:
|
|
||||||
return dict(cached[1])
|
|
||||||
|
|
||||||
skins: Dict[str, Dict[str, Any]] = {}
|
|
||||||
for entry in sorted(skins_dir.iterdir()):
|
|
||||||
if not entry.is_dir() or entry.name.startswith((".", "_")):
|
|
||||||
continue
|
|
||||||
manifest = _read_manifest(entry)
|
|
||||||
if manifest:
|
|
||||||
skins[manifest["id"]] = manifest
|
|
||||||
_discovery_cache[cache_key] = (fingerprint, skins)
|
|
||||||
return dict(skins)
|
|
||||||
|
|
||||||
|
|
||||||
def skin_targets(manifest: Dict[str, Any]) -> Tuple[list, list]:
|
|
||||||
"""(sports, sport_keys) a skin declares it supports."""
|
|
||||||
targets = manifest.get("targets") or {}
|
|
||||||
return (list(targets.get("sports") or []),
|
|
||||||
list(targets.get("sport_keys") or []))
|
|
||||||
|
|
||||||
|
|
||||||
def skin_matches_target(manifest: Dict[str, Any], sport: Optional[str],
|
|
||||||
sport_key: Optional[str]) -> bool:
|
|
||||||
"""True when the skin declares support for this sport family or exact
|
|
||||||
sport key. A skin with no targets at all matches everything."""
|
|
||||||
sports, sport_keys = skin_targets(manifest)
|
|
||||||
if not sports and not sport_keys:
|
|
||||||
return True
|
|
||||||
if sport and sport in sports:
|
|
||||||
return True
|
|
||||||
if sport_key and sport_key in sport_keys:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def skins_for_plugin(plugin_id: str,
|
|
||||||
skins: Optional[Dict[str, Dict[str, Any]]] = None) -> Dict[str, Dict[str, Any]]:
|
|
||||||
"""Installed skins that plausibly apply to a plugin, for UI dropdowns.
|
|
||||||
|
|
||||||
A skin matches when the plugin id is listed in targets.plugins, or any
|
|
||||||
declared sport / sport_key appears as a token of the plugin id (so a
|
|
||||||
skin targeting sports=["baseball"] matches "baseball-scoreboard", and
|
|
||||||
sport_keys=["milb"] matches "milb-scoreboard")."""
|
|
||||||
if skins is None:
|
|
||||||
skins = discover_skins()
|
|
||||||
tokens = set(str(plugin_id).lower().replace("-", "_").split("_"))
|
|
||||||
matched = {}
|
|
||||||
for skin_id, manifest in skins.items():
|
|
||||||
targets = manifest.get("targets") or {}
|
|
||||||
if plugin_id in (targets.get("plugins") or []):
|
|
||||||
matched[skin_id] = manifest
|
|
||||||
continue
|
|
||||||
sports, sport_keys = skin_targets(manifest)
|
|
||||||
if any(str(t).lower() in tokens for t in sports + sport_keys):
|
|
||||||
matched[skin_id] = manifest
|
|
||||||
return matched
|
|
||||||
|
|
||||||
|
|
||||||
def _load_skin_module(skin_id: str, skin_dir: Path, entry_point: str) -> Optional[Any]:
|
|
||||||
"""Import the skin's entry module under a namespaced sys.modules key,
|
|
||||||
namespacing its sibling .py files the same way — the collision-
|
|
||||||
avoidance scheme plugins use (plugin_loader._namespace_plugin_modules),
|
|
||||||
so two skins can both ship a helpers.py.
|
|
||||||
|
|
||||||
The entry module is cached: the live/recent/upcoming hosts all load
|
|
||||||
the same skin, and only the first load executes any code. (A skin
|
|
||||||
whose *code* changed on disk needs a service restart to take effect —
|
|
||||||
Python modules can't be safely hot-swapped.)
|
|
||||||
"""
|
|
||||||
entry_path = skin_dir / entry_point
|
|
||||||
if not entry_path.is_file():
|
|
||||||
logger.error("Skin '%s' entry point not found: %s", skin_id, entry_path)
|
|
||||||
return None
|
|
||||||
|
|
||||||
module_name = f"_skin_{skin_id}_{Path(entry_point).stem}"
|
|
||||||
with _lock:
|
|
||||||
cached_entry = sys.modules.get(module_name)
|
|
||||||
if cached_entry is not None:
|
|
||||||
return cached_entry
|
|
||||||
|
|
||||||
# Import siblings under their namespaced alias, and *bind* the bare
|
|
||||||
# name (cached or fresh) so `import helpers` inside the entry module
|
|
||||||
# resolves to this skin's copy. The bare bindings are transient —
|
|
||||||
# restored below so another skin's identically-named sibling can't
|
|
||||||
# be shadowed by ours.
|
|
||||||
replaced_bare: Dict[str, Any] = {}
|
|
||||||
try:
|
|
||||||
for sibling in skin_dir.glob("*.py"):
|
|
||||||
if sibling.name == entry_point:
|
|
||||||
continue
|
|
||||||
alias = f"_skin_{skin_id}_{sibling.stem}"
|
|
||||||
module = sys.modules.get(alias)
|
|
||||||
if module is None:
|
|
||||||
spec = importlib.util.spec_from_file_location(alias, sibling)
|
|
||||||
if not spec or not spec.loader:
|
|
||||||
continue
|
|
||||||
module = importlib.util.module_from_spec(spec)
|
|
||||||
sys.modules[alias] = module
|
|
||||||
replaced_bare.setdefault(sibling.stem, sys.modules.get(sibling.stem))
|
|
||||||
sys.modules[sibling.stem] = module
|
|
||||||
try:
|
|
||||||
spec.loader.exec_module(module)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Skin '%s' sibling module %s failed to import: %s",
|
|
||||||
skin_id, sibling.name, e, exc_info=True)
|
|
||||||
sys.modules.pop(alias, None)
|
|
||||||
return None
|
|
||||||
else:
|
|
||||||
replaced_bare.setdefault(sibling.stem, sys.modules.get(sibling.stem))
|
|
||||||
sys.modules[sibling.stem] = module
|
|
||||||
|
|
||||||
try:
|
|
||||||
spec = importlib.util.spec_from_file_location(module_name, entry_path)
|
|
||||||
if not spec or not spec.loader:
|
|
||||||
logger.error("Skin '%s': could not create import spec for %s",
|
|
||||||
skin_id, entry_path)
|
|
||||||
return None
|
|
||||||
module = importlib.util.module_from_spec(spec)
|
|
||||||
sys.modules[module_name] = module
|
|
||||||
spec.loader.exec_module(module)
|
|
||||||
return module
|
|
||||||
except Exception as e:
|
|
||||||
sys.modules.pop(module_name, None)
|
|
||||||
logger.error("Skin '%s' failed to import: %s", skin_id, e, exc_info=True)
|
|
||||||
return None
|
|
||||||
finally:
|
|
||||||
for bare_name, previous in replaced_bare.items():
|
|
||||||
if previous is None:
|
|
||||||
sys.modules.pop(bare_name, None)
|
|
||||||
else:
|
|
||||||
sys.modules[bare_name] = previous
|
|
||||||
|
|
||||||
|
|
||||||
def load_skin(skin_id: str, sport: Optional[str] = None,
|
|
||||||
sport_key: Optional[str] = None,
|
|
||||||
options: Optional[Dict[str, Any]] = None,
|
|
||||||
skins_dir: Optional[Path] = None) -> Optional[ScoreboardSkin]:
|
|
||||||
"""Load and instantiate a skin. Returns None (after logging why) on
|
|
||||||
any failure — callers treat None as 'use the built-in renderer'."""
|
|
||||||
skins = discover_skins(skins_dir)
|
|
||||||
manifest = skins.get(skin_id)
|
|
||||||
if manifest is None:
|
|
||||||
logger.warning("Skin '%s' is configured but not installed under %s; "
|
|
||||||
"using built-in renderer",
|
|
||||||
skin_id, skins_dir or get_skins_directory())
|
|
||||||
return None
|
|
||||||
|
|
||||||
manifest_major = _major(manifest.get("skin_api_version"))
|
|
||||||
api_major = _major(SKIN_API_VERSION)
|
|
||||||
if manifest_major != api_major:
|
|
||||||
logger.error("Skin '%s' targets skin API %s but this LEDMatrix "
|
|
||||||
"provides %s — the skin needs an update; using "
|
|
||||||
"built-in renderer",
|
|
||||||
skin_id, manifest.get("skin_api_version"), SKIN_API_VERSION)
|
|
||||||
return None
|
|
||||||
|
|
||||||
if not skin_matches_target(manifest, sport, sport_key):
|
|
||||||
# Soft: the user explicitly configured it, so warn but load anyway
|
|
||||||
# (a baseball skin may render an acceptable generic scoreboard).
|
|
||||||
logger.warning("Skin '%s' does not declare support for sport=%r / "
|
|
||||||
"sport_key=%r; loading anyway", skin_id, sport, sport_key)
|
|
||||||
|
|
||||||
skin_dir = Path(manifest["_skin_dir"])
|
|
||||||
module = _load_skin_module(skin_id, skin_dir,
|
|
||||||
manifest.get("entry_point", _DEFAULT_ENTRY_POINT))
|
|
||||||
if module is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
class_name = manifest["class_name"]
|
|
||||||
skin_class = getattr(module, class_name, None)
|
|
||||||
if skin_class is None or not isinstance(skin_class, type) or \
|
|
||||||
not issubclass(skin_class, ScoreboardSkin):
|
|
||||||
logger.error("Skin '%s': %s is missing or not a ScoreboardSkin subclass",
|
|
||||||
skin_id, class_name)
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
return skin_class(manifest, options or {})
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Skin '%s' failed to instantiate: %s", skin_id, e, exc_info=True)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def build_context(host: Any, game: Dict[str, Any],
|
|
||||||
size: Optional[Tuple[int, int]] = None) -> SkinContext:
|
|
||||||
"""Build a SkinContext for one render call.
|
|
||||||
|
|
||||||
`host` is a SportsCore-style object: display_manager, fonts, logger,
|
|
||||||
sport, skin_options, _load_and_resize_logo, _draw_text_with_outline.
|
|
||||||
`size` overrides the canvas size (vegas cards); default is the
|
|
||||||
current display size read live from the display manager.
|
|
||||||
"""
|
|
||||||
if size is not None:
|
|
||||||
width, height = int(size[0]), int(size[1])
|
|
||||||
else:
|
|
||||||
dm = host.display_manager
|
|
||||||
width = getattr(dm, "width", None) or dm.matrix.width
|
|
||||||
height = getattr(dm, "height", None) or dm.matrix.height
|
|
||||||
|
|
||||||
canvas = Image.new("RGB", (width, height), (0, 0, 0))
|
|
||||||
draw = ImageDraw.Draw(canvas)
|
|
||||||
layout = LayoutContext(width, height, _get_font_manager())
|
|
||||||
|
|
||||||
def load_logo(side: str) -> Optional[Image.Image]:
|
|
||||||
if side not in ("home", "away"):
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
logo_path = game.get(f"{side}_logo_path")
|
|
||||||
if logo_path is not None and not isinstance(logo_path, Path):
|
|
||||||
logo_path = Path(logo_path)
|
|
||||||
return host._load_and_resize_logo(
|
|
||||||
game.get(f"{side}_id"), game.get(f"{side}_abbr"),
|
|
||||||
logo_path, game.get(f"{side}_logo_url"))
|
|
||||||
except Exception as e:
|
|
||||||
host.logger.warning("Skin logo load failed for %s: %s", side, e)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def draw_text_outlined(text, position, font, fill=(255, 255, 255),
|
|
||||||
outline_color=(0, 0, 0)):
|
|
||||||
host._draw_text_with_outline(draw, text, position, font,
|
|
||||||
fill=fill, outline_color=outline_color)
|
|
||||||
|
|
||||||
return SkinContext(
|
|
||||||
canvas=canvas,
|
|
||||||
draw=draw,
|
|
||||||
layout=layout,
|
|
||||||
width=width,
|
|
||||||
height=height,
|
|
||||||
fonts=dict(host.fonts),
|
|
||||||
options=dict(getattr(host, "skin_options", {}) or {}),
|
|
||||||
logger=host.logger,
|
|
||||||
sport=getattr(host, "sport", None),
|
|
||||||
load_logo=load_logo,
|
|
||||||
draw_text_outlined=draw_text_outlined,
|
|
||||||
)
|
|
||||||
@@ -21,94 +21,6 @@ class VegasModeConfig:
|
|||||||
scroll_speed: float = 50.0 # Pixels per second
|
scroll_speed: float = 50.0 # Pixels per second
|
||||||
separator_width: int = 32 # Gap between plugins (pixels)
|
separator_width: int = 32 # Gap between plugins (pixels)
|
||||||
|
|
||||||
# Fraction of the panel width a plugin is told it has while rendering for
|
|
||||||
# the ticker, as a percentage. Trimming can only remove blank margins; it
|
|
||||||
# cannot compact a layout that genuinely spans the display — a five-column
|
|
||||||
# forecast, a full-width progress bar, a centred stat block with the panel's
|
|
||||||
# whole width between its elements. Rendering at a narrower size makes the
|
|
||||||
# plugin choose a tighter layout instead. 100 disables it.
|
|
||||||
render_width_pct: int = 100
|
|
||||||
|
|
||||||
# Minimum blank columns guaranteed between adjacent content, measured from
|
|
||||||
# actual ink rather than added blindly. A flat additive gap leaves
|
|
||||||
# card-style content nearly touching when the cards are drawn flush to their
|
|
||||||
# own edges, while padding out content that already has wide margins.
|
|
||||||
min_content_separation: int = 24
|
|
||||||
|
|
||||||
# Gap between rows contributed by the *same* plugin. separator_width marks
|
|
||||||
# the handoff from one plugin to the next; applying it between every image
|
|
||||||
# forced a 32px chasm between each row of a per-row ticker (the F1
|
|
||||||
# scoreboard renders its own rows 4px apart), which both looked wrong and
|
|
||||||
# silently inflated the width that plugin occupied.
|
|
||||||
intra_plugin_gap: int = 8
|
|
||||||
|
|
||||||
# Content density
|
|
||||||
#
|
|
||||||
# Plugins that render onto a full-display canvas contribute that whole
|
|
||||||
# canvas to the ticker, blank margins included. On a wide panel that is the
|
|
||||||
# dominant source of dead air: a plugin drawing 35px of text on a 512px
|
|
||||||
# canvas otherwise buys 9.5s of black at 50px/s. Trimming reclaims it.
|
|
||||||
auto_trim: bool = True
|
|
||||||
trim_threshold: int = 10 # Per-channel value a pixel must exceed to be "ink"
|
|
||||||
content_padding: int = 8 # Blank columns kept either side of trimmed content
|
|
||||||
min_plugin_width: int = 8 # Segments narrower than this after trim are dropped
|
|
||||||
|
|
||||||
# Columns of blank lead-in before the first item of a cycle. ScrollHelper
|
|
||||||
# defaults this to a full display width, which reads as the display being
|
|
||||||
# switched off at the start of every cycle.
|
|
||||||
lead_in_width: int = 0
|
|
||||||
|
|
||||||
# Blend between neighbouring pixel positions so motion happens at the frame
|
|
||||||
# rate rather than the scroll speed. With integer positioning the number of
|
|
||||||
# distinct frames per second equals scroll_speed, so at 50px/s the motion is
|
|
||||||
# 50 discrete 1px steps however fast the loop runs. The trade is a slight
|
|
||||||
# horizontal softening of text, since each frame is a blend of two positions.
|
|
||||||
smooth_scroll: bool = True
|
|
||||||
|
|
||||||
# Keep one continuous strip, extending it with the next group of plugins as
|
|
||||||
# the scroll approaches the end, instead of composing a fresh strip and
|
|
||||||
# swapping it in. A swap stops the motion, substitutes every pixel at once
|
|
||||||
# and restarts with the viewport already full — read as a freeze, a flash
|
|
||||||
# and a jump. Extending means the next group simply scrolls in from the
|
|
||||||
# right. Set false to restore the swap behaviour.
|
|
||||||
continuous_scroll: bool = True
|
|
||||||
|
|
||||||
# Extend once the unscrolled remainder falls below this many screen widths.
|
|
||||||
# Needs to be more than one so the join is prepared before it is on screen.
|
|
||||||
extend_threshold_screens: float = 2.0
|
|
||||||
|
|
||||||
# How many plugins are composed into one scroll cycle. Kept separate from
|
|
||||||
# buffer_ahead (which is only a prefetch low-water mark) because the two
|
|
||||||
# were previously the same number: a buffer_ahead of 2 meant just 3 plugins
|
|
||||||
# per cycle, so a 20-plugin install took seven cycles to come around.
|
|
||||||
plugins_per_cycle: int = 6
|
|
||||||
|
|
||||||
# Minimum run of blank columns that counts as a boundary between items when
|
|
||||||
# an oversized segment has to be narrowed. Measured on rendered text, the
|
|
||||||
# gaps between characters are a single column while gaps between items are
|
|
||||||
# 8px and up, so anything above 1 stops a cut landing inside a word. Cutting
|
|
||||||
# mid-word orphaned the tail into the next cycle, which showed up as a lone
|
|
||||||
# letter floating between two unrelated plugins.
|
|
||||||
min_cut_gap: int = 6
|
|
||||||
|
|
||||||
# What to do when a plugin's content exceeds its width budget.
|
|
||||||
#
|
|
||||||
# "rotate" — advance a window each cycle so everything is seen eventually.
|
|
||||||
# Right for interchangeable items: news headlines, odds, stocks.
|
|
||||||
# "truncate" — always show the start. Right for ordered content, where a
|
|
||||||
# window into the middle is meaningless: a league table that
|
|
||||||
# shows ranks 1-6 then resumes at 7 two rotations later reads
|
|
||||||
# as out of order and out of context.
|
|
||||||
#
|
|
||||||
# Override per plugin with vegas_overflow.
|
|
||||||
overflow_mode: str = "rotate"
|
|
||||||
|
|
||||||
# Cap on one plugin's share of a cycle, as a multiple of display width.
|
|
||||||
# A single ticker returning 7,000px would otherwise hold the panel for over
|
|
||||||
# two minutes. Overflow is deferred to later cycles rather than discarded.
|
|
||||||
# 0 disables the cap.
|
|
||||||
max_plugin_width_ratio: float = 3.0
|
|
||||||
|
|
||||||
# Plugin management
|
# Plugin management
|
||||||
plugin_order: List[str] = field(default_factory=list)
|
plugin_order: List[str] = field(default_factory=list)
|
||||||
excluded_plugins: Set[str] = field(default_factory=set)
|
excluded_plugins: Set[str] = field(default_factory=set)
|
||||||
@@ -143,24 +55,6 @@ class VegasModeConfig:
|
|||||||
enabled=vegas_config.get('enabled', False),
|
enabled=vegas_config.get('enabled', False),
|
||||||
scroll_speed=float(vegas_config.get('scroll_speed', 50.0)),
|
scroll_speed=float(vegas_config.get('scroll_speed', 50.0)),
|
||||||
separator_width=int(vegas_config.get('separator_width', 32)),
|
separator_width=int(vegas_config.get('separator_width', 32)),
|
||||||
intra_plugin_gap=int(vegas_config.get('intra_plugin_gap', 8)),
|
|
||||||
render_width_pct=int(vegas_config.get('render_width_pct', 100)),
|
|
||||||
min_content_separation=int(
|
|
||||||
vegas_config.get('min_content_separation', 24)),
|
|
||||||
min_cut_gap=int(vegas_config.get('min_cut_gap', 6)),
|
|
||||||
smooth_scroll=vegas_config.get('smooth_scroll', True),
|
|
||||||
continuous_scroll=vegas_config.get('continuous_scroll', True),
|
|
||||||
extend_threshold_screens=float(
|
|
||||||
vegas_config.get('extend_threshold_screens', 2.0)),
|
|
||||||
auto_trim=vegas_config.get('auto_trim', True),
|
|
||||||
trim_threshold=int(vegas_config.get('trim_threshold', 10)),
|
|
||||||
content_padding=int(vegas_config.get('content_padding', 8)),
|
|
||||||
min_plugin_width=int(vegas_config.get('min_plugin_width', 8)),
|
|
||||||
lead_in_width=int(vegas_config.get('lead_in_width', 0)),
|
|
||||||
plugins_per_cycle=int(vegas_config.get('plugins_per_cycle', 6)),
|
|
||||||
max_plugin_width_ratio=float(
|
|
||||||
vegas_config.get('max_plugin_width_ratio', 3.0)),
|
|
||||||
overflow_mode=str(vegas_config.get('overflow_mode', 'rotate')),
|
|
||||||
plugin_order=list(vegas_config.get('plugin_order', [])),
|
plugin_order=list(vegas_config.get('plugin_order', [])),
|
||||||
excluded_plugins=set(vegas_config.get('excluded_plugins', [])),
|
excluded_plugins=set(vegas_config.get('excluded_plugins', [])),
|
||||||
target_fps=int(vegas_config.get('target_fps', 125)),
|
target_fps=int(vegas_config.get('target_fps', 125)),
|
||||||
@@ -178,21 +72,6 @@ class VegasModeConfig:
|
|||||||
'enabled': self.enabled,
|
'enabled': self.enabled,
|
||||||
'scroll_speed': self.scroll_speed,
|
'scroll_speed': self.scroll_speed,
|
||||||
'separator_width': self.separator_width,
|
'separator_width': self.separator_width,
|
||||||
'intra_plugin_gap': self.intra_plugin_gap,
|
|
||||||
'render_width_pct': self.render_width_pct,
|
|
||||||
'min_content_separation': self.min_content_separation,
|
|
||||||
'min_cut_gap': self.min_cut_gap,
|
|
||||||
'smooth_scroll': self.smooth_scroll,
|
|
||||||
'continuous_scroll': self.continuous_scroll,
|
|
||||||
'extend_threshold_screens': self.extend_threshold_screens,
|
|
||||||
'auto_trim': self.auto_trim,
|
|
||||||
'trim_threshold': self.trim_threshold,
|
|
||||||
'content_padding': self.content_padding,
|
|
||||||
'min_plugin_width': self.min_plugin_width,
|
|
||||||
'lead_in_width': self.lead_in_width,
|
|
||||||
'plugins_per_cycle': self.plugins_per_cycle,
|
|
||||||
'max_plugin_width_ratio': self.max_plugin_width_ratio,
|
|
||||||
'overflow_mode': self.overflow_mode,
|
|
||||||
'plugin_order': self.plugin_order,
|
'plugin_order': self.plugin_order,
|
||||||
'excluded_plugins': list(self.excluded_plugins),
|
'excluded_plugins': list(self.excluded_plugins),
|
||||||
'target_fps': self.target_fps,
|
'target_fps': self.target_fps,
|
||||||
@@ -278,74 +157,6 @@ class VegasModeConfig:
|
|||||||
if self.buffer_ahead > 5:
|
if self.buffer_ahead > 5:
|
||||||
errors.append(f"buffer_ahead must be <= 5, got {self.buffer_ahead}")
|
errors.append(f"buffer_ahead must be <= 5, got {self.buffer_ahead}")
|
||||||
|
|
||||||
if not 10 <= self.render_width_pct <= 100:
|
|
||||||
errors.append(
|
|
||||||
"render_width_pct must be between 10 and 100, "
|
|
||||||
f"got {self.render_width_pct}")
|
|
||||||
|
|
||||||
if not 0 <= self.min_content_separation <= 256:
|
|
||||||
errors.append(
|
|
||||||
"min_content_separation must be between 0 and 256, "
|
|
||||||
f"got {self.min_content_separation}")
|
|
||||||
|
|
||||||
if not 1.0 <= self.extend_threshold_screens <= 10.0:
|
|
||||||
errors.append(
|
|
||||||
"extend_threshold_screens must be between 1.0 and 10.0, "
|
|
||||||
f"got {self.extend_threshold_screens}")
|
|
||||||
|
|
||||||
if not 1 <= self.min_cut_gap <= 128:
|
|
||||||
errors.append(
|
|
||||||
"min_cut_gap must be between 1 and 128, "
|
|
||||||
f"got {self.min_cut_gap}")
|
|
||||||
|
|
||||||
if self.intra_plugin_gap < 0:
|
|
||||||
errors.append(
|
|
||||||
f"intra_plugin_gap must be >= 0, got {self.intra_plugin_gap}")
|
|
||||||
if self.intra_plugin_gap > 128:
|
|
||||||
errors.append(
|
|
||||||
f"intra_plugin_gap must be <= 128, got {self.intra_plugin_gap}")
|
|
||||||
|
|
||||||
if not 0 <= self.trim_threshold <= 254:
|
|
||||||
errors.append(
|
|
||||||
f"trim_threshold must be between 0 and 254, got {self.trim_threshold}")
|
|
||||||
|
|
||||||
if self.content_padding < 0:
|
|
||||||
errors.append(
|
|
||||||
f"content_padding must be >= 0, got {self.content_padding}")
|
|
||||||
if self.content_padding > 128:
|
|
||||||
errors.append(
|
|
||||||
f"content_padding must be <= 128, got {self.content_padding}")
|
|
||||||
|
|
||||||
if self.min_plugin_width < 0:
|
|
||||||
errors.append(
|
|
||||||
f"min_plugin_width must be >= 0, got {self.min_plugin_width}")
|
|
||||||
# Bounded because every segment narrower than this is dropped — an
|
|
||||||
# unbounded value would discard every plugin and leave a blank ticker.
|
|
||||||
if self.min_plugin_width > 512:
|
|
||||||
errors.append(
|
|
||||||
f"min_plugin_width must be <= 512, got {self.min_plugin_width}")
|
|
||||||
|
|
||||||
if self.lead_in_width < 0:
|
|
||||||
errors.append(
|
|
||||||
f"lead_in_width must be >= 0, got {self.lead_in_width}")
|
|
||||||
|
|
||||||
if self.plugins_per_cycle < 1:
|
|
||||||
errors.append(
|
|
||||||
f"plugins_per_cycle must be >= 1, got {self.plugins_per_cycle}")
|
|
||||||
if self.plugins_per_cycle > 50:
|
|
||||||
errors.append(
|
|
||||||
f"plugins_per_cycle must be <= 50, got {self.plugins_per_cycle}")
|
|
||||||
|
|
||||||
if self.overflow_mode not in ('rotate', 'truncate'):
|
|
||||||
errors.append(
|
|
||||||
"overflow_mode must be 'rotate' or 'truncate', "
|
|
||||||
f"got {self.overflow_mode!r}")
|
|
||||||
|
|
||||||
if self.max_plugin_width_ratio < 0:
|
|
||||||
errors.append(
|
|
||||||
"max_plugin_width_ratio must be >= 0 "
|
|
||||||
f"(0 disables the cap), got {self.max_plugin_width_ratio}")
|
|
||||||
|
|
||||||
return errors
|
return errors
|
||||||
|
|
||||||
def update(self, new_config: Dict[str, Any]) -> None:
|
def update(self, new_config: Dict[str, Any]) -> None:
|
||||||
@@ -363,39 +174,6 @@ class VegasModeConfig:
|
|||||||
self.scroll_speed = float(vegas_config['scroll_speed'])
|
self.scroll_speed = float(vegas_config['scroll_speed'])
|
||||||
if 'separator_width' in vegas_config:
|
if 'separator_width' in vegas_config:
|
||||||
self.separator_width = int(vegas_config['separator_width'])
|
self.separator_width = int(vegas_config['separator_width'])
|
||||||
if 'intra_plugin_gap' in vegas_config:
|
|
||||||
self.intra_plugin_gap = int(vegas_config['intra_plugin_gap'])
|
|
||||||
if 'render_width_pct' in vegas_config:
|
|
||||||
self.render_width_pct = int(vegas_config['render_width_pct'])
|
|
||||||
if 'min_content_separation' in vegas_config:
|
|
||||||
self.min_content_separation = int(
|
|
||||||
vegas_config['min_content_separation'])
|
|
||||||
if 'min_cut_gap' in vegas_config:
|
|
||||||
self.min_cut_gap = int(vegas_config['min_cut_gap'])
|
|
||||||
if 'smooth_scroll' in vegas_config:
|
|
||||||
self.smooth_scroll = vegas_config['smooth_scroll']
|
|
||||||
if 'continuous_scroll' in vegas_config:
|
|
||||||
self.continuous_scroll = vegas_config['continuous_scroll']
|
|
||||||
if 'extend_threshold_screens' in vegas_config:
|
|
||||||
self.extend_threshold_screens = float(
|
|
||||||
vegas_config['extend_threshold_screens'])
|
|
||||||
if 'auto_trim' in vegas_config:
|
|
||||||
self.auto_trim = vegas_config['auto_trim']
|
|
||||||
if 'trim_threshold' in vegas_config:
|
|
||||||
self.trim_threshold = int(vegas_config['trim_threshold'])
|
|
||||||
if 'content_padding' in vegas_config:
|
|
||||||
self.content_padding = int(vegas_config['content_padding'])
|
|
||||||
if 'min_plugin_width' in vegas_config:
|
|
||||||
self.min_plugin_width = int(vegas_config['min_plugin_width'])
|
|
||||||
if 'lead_in_width' in vegas_config:
|
|
||||||
self.lead_in_width = int(vegas_config['lead_in_width'])
|
|
||||||
if 'plugins_per_cycle' in vegas_config:
|
|
||||||
self.plugins_per_cycle = int(vegas_config['plugins_per_cycle'])
|
|
||||||
if 'max_plugin_width_ratio' in vegas_config:
|
|
||||||
self.max_plugin_width_ratio = float(
|
|
||||||
vegas_config['max_plugin_width_ratio'])
|
|
||||||
if 'overflow_mode' in vegas_config:
|
|
||||||
self.overflow_mode = str(vegas_config['overflow_mode'])
|
|
||||||
if 'plugin_order' in vegas_config:
|
if 'plugin_order' in vegas_config:
|
||||||
self.plugin_order = list(vegas_config['plugin_order'])
|
self.plugin_order = list(vegas_config['plugin_order'])
|
||||||
if 'excluded_plugins' in vegas_config:
|
if 'excluded_plugins' in vegas_config:
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ class VegasModeCoordinator:
|
|||||||
self.plugin_manager = plugin_manager
|
self.plugin_manager = plugin_manager
|
||||||
|
|
||||||
# Initialize components
|
# Initialize components
|
||||||
self.plugin_adapter = PluginAdapter(display_manager, self.vegas_config)
|
self.plugin_adapter = PluginAdapter(display_manager)
|
||||||
self.stream_manager = StreamManager(
|
self.stream_manager = StreamManager(
|
||||||
self.vegas_config,
|
self.vegas_config,
|
||||||
plugin_manager,
|
plugin_manager,
|
||||||
@@ -233,11 +233,6 @@ class VegasModeCoordinator:
|
|||||||
self._should_stop = False
|
self._should_stop = False
|
||||||
self._start_time = time.time()
|
self._start_time = time.time()
|
||||||
|
|
||||||
# Line up the next group immediately, so the first extension is already
|
|
||||||
# warm rather than stalling the scroll to fetch it.
|
|
||||||
if self.vegas_config.continuous_scroll:
|
|
||||||
self.render_pipeline.start_prefetch()
|
|
||||||
|
|
||||||
logger.info("Vegas mode started")
|
logger.info("Vegas mode started")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -306,43 +301,16 @@ class VegasModeCoordinator:
|
|||||||
if has_pending_update:
|
if has_pending_update:
|
||||||
self._apply_pending_config()
|
self._apply_pending_config()
|
||||||
|
|
||||||
if self.vegas_config.continuous_scroll:
|
# Check if we need to start a new cycle
|
||||||
# Drop cached content for plugins whose data just changed, so the
|
if self.render_pipeline.is_cycle_complete():
|
||||||
# next time each comes round it is composed from current data. The
|
if not self.render_pipeline.start_new_cycle():
|
||||||
# swap path's hot_swap_content() does this via process_updates(),
|
logger.warning("Failed to start new Vegas cycle")
|
||||||
# but it also rebuilds and repositions the whole strip, which is
|
return False
|
||||||
# the freeze-and-jump this mode exists to avoid. Without this the
|
self.stats['cycles_completed'] += 1
|
||||||
# pending-update flags are never consumed and a segment keeps
|
|
||||||
# rendering whatever it was first built from — last night's live
|
|
||||||
# game still shown as live the next morning.
|
|
||||||
self.render_pipeline.refresh_updated_plugins()
|
|
||||||
|
|
||||||
# Extend the strip before the scroll can reach its end, so the next
|
# Check for hot-swap opportunities
|
||||||
# group arrives from the right and motion never stops. No cycle
|
if self.render_pipeline.should_recompose():
|
||||||
# boundary, so no freeze, no substitution and no restart with the
|
self.render_pipeline.hot_swap_content()
|
||||||
# viewport already full.
|
|
||||||
# Trickle in the plugins that can only be fetched here, one per
|
|
||||||
# frame, before considering a further extension.
|
|
||||||
if self.render_pipeline.has_deferred():
|
|
||||||
self.render_pipeline.drain_deferred()
|
|
||||||
elif self.render_pipeline.needs_extension():
|
|
||||||
if self.render_pipeline.extend_scroll_content():
|
|
||||||
self.stats['cycles_completed'] += 1
|
|
||||||
elif self.render_pipeline.is_cycle_complete():
|
|
||||||
# Extension failed and the strip has run out: fall back to
|
|
||||||
# the swap rather than sitting on a dead frame.
|
|
||||||
self.render_pipeline.start_new_cycle()
|
|
||||||
else:
|
|
||||||
# Check if we need to start a new cycle
|
|
||||||
if self.render_pipeline.is_cycle_complete():
|
|
||||||
if not self.render_pipeline.start_new_cycle():
|
|
||||||
logger.warning("Failed to start new Vegas cycle")
|
|
||||||
return False
|
|
||||||
self.stats['cycles_completed'] += 1
|
|
||||||
|
|
||||||
# Check for hot-swap opportunities
|
|
||||||
if self.render_pipeline.should_recompose():
|
|
||||||
self.render_pipeline.hot_swap_content()
|
|
||||||
|
|
||||||
# Render frame
|
# Render frame
|
||||||
return self.render_pipeline.render_frame()
|
return self.render_pipeline.render_frame()
|
||||||
@@ -369,14 +337,7 @@ class VegasModeCoordinator:
|
|||||||
self._update_static_mode_plugins()
|
self._update_static_mode_plugins()
|
||||||
|
|
||||||
frame_interval = self.vegas_config.get_frame_interval()
|
frame_interval = self.vegas_config.get_frame_interval()
|
||||||
if self.vegas_config.continuous_scroll:
|
duration = self.render_pipeline.get_dynamic_duration()
|
||||||
# The strip is continuously extended and trimmed, so its width says
|
|
||||||
# nothing about how long to run. This is only how often control
|
|
||||||
# returns to the display controller; interrupts are still checked
|
|
||||||
# every few frames, so it costs nothing to make it a fixed period.
|
|
||||||
duration = float(self.vegas_config.max_cycle_duration)
|
|
||||||
else:
|
|
||||||
duration = self.render_pipeline.get_dynamic_duration()
|
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
frame_count = 0
|
frame_count = 0
|
||||||
fps_log_interval = 5.0 # Log FPS every 5 seconds
|
fps_log_interval = 5.0 # Log FPS every 5 seconds
|
||||||
@@ -386,8 +347,6 @@ class VegasModeCoordinator:
|
|||||||
logger.info("Starting Vegas iteration for %.1fs", duration)
|
logger.info("Starting Vegas iteration for %.1fs", duration)
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
frame_started = time.time()
|
|
||||||
|
|
||||||
# Check for STATIC mode plugin that should pause scroll
|
# Check for STATIC mode plugin that should pause scroll
|
||||||
static_plugin = self._check_static_plugin_trigger()
|
static_plugin = self._check_static_plugin_trigger()
|
||||||
if static_plugin:
|
if static_plugin:
|
||||||
@@ -408,14 +367,8 @@ class VegasModeCoordinator:
|
|||||||
# Paused for live priority - let caller handle
|
# Paused for live priority - let caller handle
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Sleep only the remainder of the frame budget. This used to sleep
|
# Sleep for frame interval
|
||||||
# the whole interval on top of however long the frame took, so at a
|
time.sleep(frame_interval)
|
||||||
# measured 31.6ms per frame a fixed 8ms of that was pure idle — a
|
|
||||||
# quarter of the budget spent not rendering. Subtracting the work
|
|
||||||
# already done keeps the pacing target while reclaiming that time,
|
|
||||||
# and yields the GIL either way so other threads still run.
|
|
||||||
frame_elapsed = time.time() - frame_started
|
|
||||||
time.sleep(max(0.0, frame_interval - frame_elapsed))
|
|
||||||
|
|
||||||
# Increment frame count and check for interrupt periodically
|
# Increment frame count and check for interrupt periodically
|
||||||
frame_count += 1
|
frame_count += 1
|
||||||
@@ -552,10 +505,6 @@ class VegasModeCoordinator:
|
|||||||
# Update components
|
# Update components
|
||||||
self.render_pipeline.update_config(new_vegas_config)
|
self.render_pipeline.update_config(new_vegas_config)
|
||||||
self.stream_manager.config = new_vegas_config
|
self.stream_manager.config = new_vegas_config
|
||||||
self.plugin_adapter.config = new_vegas_config
|
|
||||||
# Cached segments were trimmed under the old settings, so drop them
|
|
||||||
# or a changed trim/padding value would not visibly take effect.
|
|
||||||
self.plugin_adapter.invalidate_cache()
|
|
||||||
|
|
||||||
# Force refresh of stream manager to pick up plugin_order/buffer changes
|
# Force refresh of stream manager to pick up plugin_order/buffer changes
|
||||||
self.stream_manager._last_refresh = 0
|
self.stream_manager._last_refresh = 0
|
||||||
|
|||||||
@@ -1,474 +0,0 @@
|
|||||||
"""
|
|
||||||
Geometry primitives for Vegas Mode.
|
|
||||||
|
|
||||||
Pure, side-effect-free measurements over PIL images. Two consumers:
|
|
||||||
|
|
||||||
- ``PluginAdapter`` trims the blank margins plugins bake into their content
|
|
||||||
before it enters the ticker (see ``trim_to_content``).
|
|
||||||
- ``scripts/dev/vegas_audit.py`` reports how much of the composed ticker is
|
|
||||||
dead space (see ``dead_window_stats``).
|
|
||||||
|
|
||||||
Keeping both on the same primitives means the number the audit reports is the
|
|
||||||
number the trimmer acted on.
|
|
||||||
|
|
||||||
All column scans go through numpy: a Python-level per-column loop over a
|
|
||||||
17,000px-wide ticker image takes seconds, which is far too slow for the render
|
|
||||||
path.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import List, NamedTuple, Optional, Tuple
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
from PIL import Image
|
|
||||||
|
|
||||||
# A pixel counts as "ink" when any channel exceeds this. Chosen to ignore the
|
|
||||||
# 1-2/255 noise that JPEG-sourced logos and alpha compositing leave behind in
|
|
||||||
# nominally black areas, while still treating any deliberately drawn dark grey
|
|
||||||
# as real content.
|
|
||||||
DEFAULT_INK_THRESHOLD = 10
|
|
||||||
|
|
||||||
# A window counts as "dead" when this fraction of its columns carry no ink.
|
|
||||||
DEFAULT_DEAD_WINDOW_RATIO = 0.95
|
|
||||||
|
|
||||||
|
|
||||||
def column_has_ink(img: Image.Image, threshold: int = DEFAULT_INK_THRESHOLD) -> np.ndarray:
|
|
||||||
"""
|
|
||||||
Return a boolean array, one entry per image column, True where the column
|
|
||||||
contains at least one pixel brighter than ``threshold`` in any channel.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
img: Image to scan (converted to RGB internally)
|
|
||||||
threshold: Per-channel value a pixel must exceed to count as ink
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Bool array of shape (width,)
|
|
||||||
"""
|
|
||||||
arr = np.asarray(img if img.mode == 'RGB' else img.convert('RGB'))
|
|
||||||
if arr.ndim != 3:
|
|
||||||
# Degenerate/empty image — treat every column as blank.
|
|
||||||
return np.zeros(img.width, dtype=bool)
|
|
||||||
# Collapse rows and channels: a column is ink if any pixel in it is bright.
|
|
||||||
return arr.max(axis=(0, 2)) > threshold
|
|
||||||
|
|
||||||
|
|
||||||
def content_bounds(
|
|
||||||
img: Image.Image, threshold: int = DEFAULT_INK_THRESHOLD
|
|
||||||
) -> Optional[Tuple[int, int]]:
|
|
||||||
"""
|
|
||||||
Find the first and last columns containing ink.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
img: Image to measure
|
|
||||||
threshold: Ink threshold
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
(first_col, last_col) inclusive, or None if the image is entirely blank
|
|
||||||
"""
|
|
||||||
ink = column_has_ink(img, threshold)
|
|
||||||
if not ink.any():
|
|
||||||
return None
|
|
||||||
first = int(ink.argmax())
|
|
||||||
last = len(ink) - 1 - int(ink[::-1].argmax())
|
|
||||||
return first, last
|
|
||||||
|
|
||||||
|
|
||||||
class TrimResult(NamedTuple):
|
|
||||||
"""Outcome of a ``trim_to_content`` call."""
|
|
||||||
|
|
||||||
image: Optional[Image.Image] # None when the source was entirely blank
|
|
||||||
original_width: int
|
|
||||||
trimmed_left: int
|
|
||||||
trimmed_right: int
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_blank(self) -> bool:
|
|
||||||
"""True when the source image carried no ink at all."""
|
|
||||||
return self.image is None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def width(self) -> int:
|
|
||||||
"""Width after trimming (0 for a blank source)."""
|
|
||||||
return 0 if self.image is None else self.image.width
|
|
||||||
|
|
||||||
@property
|
|
||||||
def removed(self) -> int:
|
|
||||||
"""Total columns removed."""
|
|
||||||
return self.trimmed_left + self.trimmed_right
|
|
||||||
|
|
||||||
|
|
||||||
def trim_to_content(
|
|
||||||
img: Image.Image,
|
|
||||||
threshold: int = DEFAULT_INK_THRESHOLD,
|
|
||||||
padding: int = 0,
|
|
||||||
) -> TrimResult:
|
|
||||||
"""
|
|
||||||
Crop blank columns off the left and right edges of an image.
|
|
||||||
|
|
||||||
Only the outer edges are considered. Blank columns *between* two pieces of
|
|
||||||
content are deliberately preserved — those are the plugin's own layout
|
|
||||||
(e.g. a logo on the left and a score on the right), and closing them up
|
|
||||||
would corrupt the design rather than reclaim dead space.
|
|
||||||
|
|
||||||
A plugin drawing on a non-black background is unaffected: every column of a
|
|
||||||
filled background carries ink, so there is nothing to trim.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
img: Image to trim
|
|
||||||
threshold: Ink threshold
|
|
||||||
padding: Columns of the original blank margin to keep on each side, as
|
|
||||||
breathing room. Capped at what the margin actually contains, so
|
|
||||||
this never widens the image beyond its original bounds.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
TrimResult. When the image is entirely blank, ``image`` is None and the
|
|
||||||
caller decides whether to skip the plugin.
|
|
||||||
"""
|
|
||||||
bounds = content_bounds(img, threshold)
|
|
||||||
if bounds is None:
|
|
||||||
return TrimResult(None, img.width, 0, 0)
|
|
||||||
|
|
||||||
first, last = bounds
|
|
||||||
pad = max(0, padding)
|
|
||||||
left = max(0, first - pad)
|
|
||||||
right = min(img.width, last + 1 + pad)
|
|
||||||
|
|
||||||
if left == 0 and right == img.width:
|
|
||||||
return TrimResult(img, img.width, 0, 0)
|
|
||||||
|
|
||||||
cropped = img.crop((left, 0, right, img.height))
|
|
||||||
return TrimResult(cropped, img.width, left, img.width - right)
|
|
||||||
|
|
||||||
|
|
||||||
def edge_blank(
|
|
||||||
img: Image.Image, threshold: int = DEFAULT_INK_THRESHOLD
|
|
||||||
) -> Tuple[int, int]:
|
|
||||||
"""
|
|
||||||
Blank column counts at the left and right edges of an image.
|
|
||||||
|
|
||||||
Used to space items by *measured* separation rather than a flat added gap.
|
|
||||||
A fixed gap gets this wrong in both directions at once: card-style content
|
|
||||||
drawn flush to its own edges ends up nearly touching its neighbour, while
|
|
||||||
content that already carries wide margins gets pushed even further apart.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
img: Image to measure
|
|
||||||
threshold: Ink threshold
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
(left_blank, right_blank). For an entirely blank image both are the
|
|
||||||
full width, since there is no ink to be close to.
|
|
||||||
"""
|
|
||||||
bounds = content_bounds(img, threshold)
|
|
||||||
if bounds is None:
|
|
||||||
return img.width, img.width
|
|
||||||
first, last = bounds
|
|
||||||
return first, img.width - 1 - last
|
|
||||||
|
|
||||||
|
|
||||||
def separation_gap(
|
|
||||||
left_img: Image.Image,
|
|
||||||
right_img: Image.Image,
|
|
||||||
target: int,
|
|
||||||
minimum: int = 0,
|
|
||||||
threshold: int = DEFAULT_INK_THRESHOLD,
|
|
||||||
) -> int:
|
|
||||||
"""
|
|
||||||
Columns to insert between two images so their ink is ``target`` apart.
|
|
||||||
|
|
||||||
Only the shortfall is added: if the two images already carry enough blank
|
|
||||||
at the facing edges, nothing (beyond ``minimum``) is inserted.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
left_img: Image on the left
|
|
||||||
right_img: Image on the right
|
|
||||||
target: Desired blank columns between the two pieces of ink
|
|
||||||
minimum: Floor applied regardless of what the images already have
|
|
||||||
threshold: Ink threshold
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Number of columns to insert, never negative
|
|
||||||
"""
|
|
||||||
existing = edge_blank(left_img, threshold)[1] + edge_blank(right_img, threshold)[0]
|
|
||||||
return max(minimum, target - existing, 0)
|
|
||||||
|
|
||||||
|
|
||||||
def blank_runs(
|
|
||||||
img: Image.Image,
|
|
||||||
min_run: int,
|
|
||||||
threshold: int = DEFAULT_INK_THRESHOLD,
|
|
||||||
) -> List[Tuple[int, int]]:
|
|
||||||
"""
|
|
||||||
Find maximal runs of blank columns at least ``min_run`` wide.
|
|
||||||
|
|
||||||
Distinguishes item boundaries from letter spacing. Measured on real
|
|
||||||
rendered text, the gaps *between characters* are a single column, while the
|
|
||||||
gaps a plugin puts *between items* are 8px and up (the stocks ticker uses
|
|
||||||
32px, baseball 48px). Treating any blank column as a cut point therefore
|
|
||||||
slices words in half; requiring a run excludes letter spacing.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
img: Image to scan
|
|
||||||
min_run: Minimum consecutive blank columns to qualify
|
|
||||||
threshold: Ink threshold
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of (start, end) half-open column ranges, in left-to-right order
|
|
||||||
"""
|
|
||||||
blank = ~column_has_ink(img, threshold)
|
|
||||||
if not blank.any():
|
|
||||||
return []
|
|
||||||
|
|
||||||
# Vectorised run detection: pad with False so runs touching either edge get
|
|
||||||
# a boundary, then read starts and ends off the first difference. A Python
|
|
||||||
# loop here would be far too slow on a 17,000px ticker strip.
|
|
||||||
padded = np.concatenate(([False], blank, [False]))
|
|
||||||
diff = np.diff(padded.astype(np.int8))
|
|
||||||
starts = np.flatnonzero(diff == 1)
|
|
||||||
ends = np.flatnonzero(diff == -1)
|
|
||||||
|
|
||||||
long_enough = (ends - starts) >= max(1, min_run)
|
|
||||||
return list(zip(starts[long_enough].tolist(), ends[long_enough].tolist()))
|
|
||||||
|
|
||||||
|
|
||||||
def find_item_boundary(
|
|
||||||
img: Image.Image,
|
|
||||||
target: int,
|
|
||||||
min_run: int,
|
|
||||||
threshold: int = DEFAULT_INK_THRESHOLD,
|
|
||||||
) -> Optional[int]:
|
|
||||||
"""
|
|
||||||
Find the column nearest ``target`` that sits inside a gap between items.
|
|
||||||
|
|
||||||
Used to narrow an oversized segment without cutting through a word. Only
|
|
||||||
runs of at least ``min_run`` blank columns are considered, so the
|
|
||||||
single-column gaps between characters are never chosen — cutting there
|
|
||||||
orphaned the tail of a word into the following cycle, which is how a lone
|
|
||||||
"y" from "Wednesday" ended up floating between two unrelated plugins.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
img: Image to cut
|
|
||||||
target: Preferred cut column
|
|
||||||
min_run: Minimum blank-run width that counts as an item boundary
|
|
||||||
threshold: Ink threshold
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
A column inside a qualifying gap, or None when the image has no such
|
|
||||||
gap at all — in which case the caller must not cut it.
|
|
||||||
"""
|
|
||||||
runs = blank_runs(img, min_run, threshold)
|
|
||||||
if not runs:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Nearest point of the nearest run. For a run left of target that is its
|
|
||||||
# end (content resumes just after), for a run right of target its start
|
|
||||||
# (content stopped just before) — the right choice in both directions.
|
|
||||||
def clamp_to_run(run: Tuple[int, int]) -> int:
|
|
||||||
start, end = run
|
|
||||||
return max(start, min(target, end - 1))
|
|
||||||
|
|
||||||
return min((clamp_to_run(r) for r in runs), key=lambda c: abs(c - target))
|
|
||||||
|
|
||||||
|
|
||||||
def find_blank_cut(
|
|
||||||
img: Image.Image,
|
|
||||||
target: int,
|
|
||||||
search_radius: int,
|
|
||||||
threshold: int = DEFAULT_INK_THRESHOLD,
|
|
||||||
) -> int:
|
|
||||||
"""
|
|
||||||
Find a column near ``target`` that carries no ink, so an image can be cut
|
|
||||||
there without slicing through a glyph or logo.
|
|
||||||
|
|
||||||
Used when a single oversized segment has to be narrowed to fit a width
|
|
||||||
budget. Cutting at an arbitrary column would leave half a character
|
|
||||||
hanging at the panel edge; snapping to the nearest gap hides the cut.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
img: Image to cut
|
|
||||||
target: Preferred cut column
|
|
||||||
search_radius: How far either side of ``target`` to look
|
|
||||||
threshold: Ink threshold
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
A blank column within the search window, or ``target`` clamped to the
|
|
||||||
image bounds when the window contains no blank column at all.
|
|
||||||
"""
|
|
||||||
width = img.width
|
|
||||||
target = max(0, min(target, width))
|
|
||||||
if search_radius <= 0 or width == 0:
|
|
||||||
return target
|
|
||||||
|
|
||||||
ink = column_has_ink(img, threshold)
|
|
||||||
|
|
||||||
# target may legitimately equal width (a cut after the last column), but
|
|
||||||
# there is no column to inspect there, so both bounds stop at width - 1.
|
|
||||||
lo = max(0, min(target - search_radius, width - 1))
|
|
||||||
hi = max(0, min(target + search_radius, width - 1))
|
|
||||||
|
|
||||||
# Walk outwards from target so the nearest gap wins.
|
|
||||||
for offset in range(0, search_radius + 1):
|
|
||||||
right = target + offset
|
|
||||||
if lo <= right <= hi and not ink[right]:
|
|
||||||
return right
|
|
||||||
left = target - offset
|
|
||||||
if lo <= left <= hi and not ink[left]:
|
|
||||||
return left
|
|
||||||
|
|
||||||
return target
|
|
||||||
|
|
||||||
|
|
||||||
class DeadWindowStats(NamedTuple):
|
|
||||||
"""How much of a composed ticker reads as blank to a viewer."""
|
|
||||||
|
|
||||||
total_windows: int
|
|
||||||
dead_windows: int
|
|
||||||
longest_dead_run: int # consecutive dead windows (i.e. scroll steps)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def dead_ratio(self) -> float:
|
|
||||||
"""Fraction of viewport positions that are effectively blank."""
|
|
||||||
if self.total_windows <= 0:
|
|
||||||
return 0.0
|
|
||||||
return self.dead_windows / self.total_windows
|
|
||||||
|
|
||||||
|
|
||||||
def dead_window_stats(
|
|
||||||
img: Image.Image,
|
|
||||||
viewport_width: int,
|
|
||||||
threshold: int = DEFAULT_INK_THRESHOLD,
|
|
||||||
dead_ratio: float = DEFAULT_DEAD_WINDOW_RATIO,
|
|
||||||
step: int = 1,
|
|
||||||
) -> DeadWindowStats:
|
|
||||||
"""
|
|
||||||
Slide a viewport across a composed ticker image and count how many
|
|
||||||
positions are effectively blank.
|
|
||||||
|
|
||||||
This models what the viewer actually experiences: the ticker is only ever
|
|
||||||
seen ``viewport_width`` columns at a time, so a stretch of blank wider than
|
|
||||||
the viewport becomes a period where the panel looks switched off. Measuring
|
|
||||||
per-window rather than per-column is what makes the result correspond to
|
|
||||||
perceived dead time.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
img: Composed ticker image
|
|
||||||
viewport_width: Display width in pixels
|
|
||||||
threshold: Ink threshold
|
|
||||||
dead_ratio: Fraction of blank columns for a window to count as dead
|
|
||||||
step: Column stride between sampled windows. 1 is exact; larger values
|
|
||||||
trade precision for speed on very wide images.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
DeadWindowStats. ``longest_dead_run`` is in units of ``step`` columns,
|
|
||||||
so multiply by ``step`` for pixels.
|
|
||||||
"""
|
|
||||||
if viewport_width <= 0 or img.width <= 0:
|
|
||||||
return DeadWindowStats(0, 0, 0)
|
|
||||||
|
|
||||||
ink = column_has_ink(img, threshold)
|
|
||||||
step = max(1, step)
|
|
||||||
|
|
||||||
# Prefix sum of ink counts lets each window be evaluated in constant time,
|
|
||||||
# instead of re-summing viewport_width columns per position.
|
|
||||||
prefix = np.concatenate(([0], np.cumsum(ink)))
|
|
||||||
|
|
||||||
# Only whole windows are sampled; a partial tail window would report
|
|
||||||
# artificially dead because it has fewer columns to draw ink from.
|
|
||||||
last_start = img.width - viewport_width
|
|
||||||
if last_start < 0:
|
|
||||||
# Image narrower than the viewport — evaluate it as a single window.
|
|
||||||
blank_cols = len(ink) - int(prefix[-1])
|
|
||||||
is_dead = blank_cols >= dead_ratio * len(ink)
|
|
||||||
return DeadWindowStats(1, 1 if is_dead else 0, 1 if is_dead else 0)
|
|
||||||
|
|
||||||
starts = np.arange(0, last_start + 1, step)
|
|
||||||
ink_counts = prefix[starts + viewport_width] - prefix[starts]
|
|
||||||
blank_counts = viewport_width - ink_counts
|
|
||||||
dead = blank_counts >= dead_ratio * viewport_width
|
|
||||||
|
|
||||||
longest = _longest_true_run(dead)
|
|
||||||
return DeadWindowStats(len(starts), int(dead.sum()), longest)
|
|
||||||
|
|
||||||
|
|
||||||
class CoverageStats(NamedTuple):
|
|
||||||
"""How well-filled the viewport stays as the ticker scrolls past."""
|
|
||||||
|
|
||||||
total_windows: int
|
|
||||||
mean_ink_ratio: float # average fraction of the viewport carrying ink
|
|
||||||
min_ink_ratio: float # worst viewport position in the cycle
|
|
||||||
sparse_windows: int # positions below the "looks empty" threshold
|
|
||||||
longest_sparse_run: int # consecutive sparse positions, in steps
|
|
||||||
|
|
||||||
@property
|
|
||||||
def sparse_ratio(self) -> float:
|
|
||||||
"""Fraction of viewport positions that read as near-empty."""
|
|
||||||
if self.total_windows <= 0:
|
|
||||||
return 0.0
|
|
||||||
return self.sparse_windows / self.total_windows
|
|
||||||
|
|
||||||
|
|
||||||
def window_coverage_stats(
|
|
||||||
img: Image.Image,
|
|
||||||
viewport_width: int,
|
|
||||||
threshold: int = DEFAULT_INK_THRESHOLD,
|
|
||||||
sparse_ink_ratio: float = 0.10,
|
|
||||||
step: int = 1,
|
|
||||||
) -> CoverageStats:
|
|
||||||
"""
|
|
||||||
Measure how full the viewport stays across a whole scroll cycle.
|
|
||||||
|
|
||||||
``dead_window_stats`` only catches viewport positions that are *entirely*
|
|
||||||
blank. That misses the more common complaint: a position holding one narrow
|
|
||||||
sliver of content at the very edge, with the other 90% black. Such a
|
|
||||||
position is not "dead" by that definition but still looks switched off.
|
|
||||||
This function grades every position by how much ink it carries, so
|
|
||||||
"there is always something to see" becomes measurable.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
img: Composed ticker image
|
|
||||||
viewport_width: Display width in pixels
|
|
||||||
threshold: Ink threshold
|
|
||||||
sparse_ink_ratio: A position with less than this fraction of inked
|
|
||||||
columns counts as reading near-empty
|
|
||||||
step: Column stride between sampled positions
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
CoverageStats
|
|
||||||
"""
|
|
||||||
if viewport_width <= 0 or img.width <= 0:
|
|
||||||
return CoverageStats(0, 0.0, 0.0, 0, 0)
|
|
||||||
|
|
||||||
ink = column_has_ink(img, threshold)
|
|
||||||
step = max(1, step)
|
|
||||||
prefix = np.concatenate(([0], np.cumsum(ink)))
|
|
||||||
|
|
||||||
last_start = img.width - viewport_width
|
|
||||||
if last_start < 0:
|
|
||||||
ratio = float(prefix[-1]) / viewport_width
|
|
||||||
sparse = ratio < sparse_ink_ratio
|
|
||||||
return CoverageStats(1, ratio, ratio, 1 if sparse else 0, 1 if sparse else 0)
|
|
||||||
|
|
||||||
starts = np.arange(0, last_start + 1, step)
|
|
||||||
ratios = (prefix[starts + viewport_width] - prefix[starts]) / viewport_width
|
|
||||||
sparse_flags = ratios < sparse_ink_ratio
|
|
||||||
|
|
||||||
return CoverageStats(
|
|
||||||
total_windows=len(starts),
|
|
||||||
mean_ink_ratio=float(ratios.mean()),
|
|
||||||
min_ink_ratio=float(ratios.min()),
|
|
||||||
sparse_windows=int(sparse_flags.sum()),
|
|
||||||
longest_sparse_run=_longest_true_run(sparse_flags),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _longest_true_run(flags: np.ndarray) -> int:
|
|
||||||
"""Length of the longest consecutive run of True in a boolean array."""
|
|
||||||
if flags.size == 0 or not flags.any():
|
|
||||||
return 0
|
|
||||||
# Reset a running counter at every False by subtracting the cumulative max
|
|
||||||
# of the counter's value at the preceding False positions.
|
|
||||||
idx = np.arange(len(flags))
|
|
||||||
not_flag = ~flags
|
|
||||||
# For each position, the index of the most recent False at or before it.
|
|
||||||
last_false = np.maximum.accumulate(np.where(not_flag, idx, -1))
|
|
||||||
run_lengths = idx - last_false
|
|
||||||
return int(run_lengths[flags].max())
|
|
||||||
@@ -8,16 +8,9 @@ implement get_vegas_content() and fallback capture of display() output.
|
|||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from contextlib import nullcontext
|
|
||||||
from typing import Optional, List, Any, Tuple, Union, TYPE_CHECKING
|
from typing import Optional, List, Any, Tuple, Union, TYPE_CHECKING
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from src.vegas_mode.geometry import (
|
|
||||||
blank_runs,
|
|
||||||
separation_gap,
|
|
||||||
trim_to_content,
|
|
||||||
)
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from src.plugin_system.base_plugin import BasePlugin
|
from src.plugin_system.base_plugin import BasePlugin
|
||||||
|
|
||||||
@@ -33,21 +26,14 @@ class PluginAdapter:
|
|||||||
2. Fallback: Capture display_manager.image after calling plugin.display()
|
2. Fallback: Capture display_manager.image after calling plugin.display()
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, display_manager: Any, config: Optional[Any] = None):
|
def __init__(self, display_manager: Any):
|
||||||
"""
|
"""
|
||||||
Initialize the plugin adapter.
|
Initialize the plugin adapter.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
display_manager: DisplayManager instance for fallback capture
|
display_manager: DisplayManager instance for fallback capture
|
||||||
config: VegasModeConfig controlling trim behaviour. When omitted,
|
|
||||||
trimming runs with the dataclass defaults, so existing callers
|
|
||||||
and tests keep working unchanged.
|
|
||||||
"""
|
"""
|
||||||
self.display_manager = display_manager
|
self.display_manager = display_manager
|
||||||
if config is None:
|
|
||||||
from src.vegas_mode.config import VegasModeConfig
|
|
||||||
config = VegasModeConfig()
|
|
||||||
self.config = config
|
|
||||||
# Handle both property and method access patterns
|
# Handle both property and method access patterns
|
||||||
self.display_width = (
|
self.display_width = (
|
||||||
display_manager.width() if callable(display_manager.width)
|
display_manager.width() if callable(display_manager.width)
|
||||||
@@ -63,18 +49,12 @@ class PluginAdapter:
|
|||||||
self._cache_lock = threading.Lock()
|
self._cache_lock = threading.Lock()
|
||||||
self._cache_ttl = 5.0 # Cache for 5 seconds
|
self._cache_ttl = 5.0 # Cache for 5 seconds
|
||||||
|
|
||||||
# Per-plugin rotation offset, so a plugin whose content exceeds its
|
|
||||||
# width budget shows a different slice on each cycle rather than
|
|
||||||
# always the same opening items.
|
|
||||||
self._item_offsets: dict = {}
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"PluginAdapter initialized: display=%dx%d",
|
"PluginAdapter initialized: display=%dx%d",
|
||||||
self.display_width, self.display_height
|
self.display_width, self.display_height
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_content(self, plugin: 'BasePlugin', plugin_id: str,
|
def get_content(self, plugin: 'BasePlugin', plugin_id: str) -> Optional[List[Image.Image]]:
|
||||||
offscreen_only: bool = False) -> Optional[List[Image.Image]]:
|
|
||||||
"""
|
"""
|
||||||
Get scrollable content from a plugin.
|
Get scrollable content from a plugin.
|
||||||
|
|
||||||
@@ -83,13 +63,6 @@ class PluginAdapter:
|
|||||||
Args:
|
Args:
|
||||||
plugin: Plugin instance to get content from
|
plugin: Plugin instance to get content from
|
||||||
plugin_id: Plugin identifier for logging
|
plugin_id: Plugin identifier for logging
|
||||||
offscreen_only: Skip every path that touches the shared display
|
|
||||||
canvas, for callers running off the render thread. The canvas
|
|
||||||
and the matrix proxy are process-wide mutable state, so
|
|
||||||
narrowing or capturing through them from another thread would
|
|
||||||
corrupt the frame the render loop is pushing. Returns None when
|
|
||||||
the plugin can only be served that way, leaving the caller to
|
|
||||||
fetch it on the render thread.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of PIL Images representing plugin content, or None if no content
|
List of PIL Images representing plugin content, or None if no content
|
||||||
@@ -113,38 +86,32 @@ class PluginAdapter:
|
|||||||
has_native = hasattr(plugin, 'get_vegas_content')
|
has_native = hasattr(plugin, 'get_vegas_content')
|
||||||
logger.info("[%s] Has get_vegas_content: %s", plugin_id, has_native)
|
logger.info("[%s] Has get_vegas_content: %s", plugin_id, has_native)
|
||||||
if has_native:
|
if has_native:
|
||||||
content = self._get_native_content(plugin, plugin_id, offscreen_only)
|
content = self._get_native_content(plugin, plugin_id)
|
||||||
if content:
|
if content:
|
||||||
total_width = sum(img.width for img in content)
|
total_width = sum(img.width for img in content)
|
||||||
logger.info(
|
logger.info(
|
||||||
"[%s] Native content SUCCESS: %d images, %dpx total",
|
"[%s] Native content SUCCESS: %d images, %dpx total",
|
||||||
plugin_id, len(content), total_width
|
plugin_id, len(content), total_width
|
||||||
)
|
)
|
||||||
return self._finalize(content, plugin_id, 'native', plugin)
|
self._cache_content(plugin_id, content)
|
||||||
|
return content
|
||||||
logger.info("[%s] Native content returned None", plugin_id)
|
logger.info("[%s] Native content returned None", plugin_id)
|
||||||
|
|
||||||
# Try to get scroll_helper's cached image (for scrolling plugins like stocks/odds)
|
# Try to get scroll_helper's cached image (for scrolling plugins like stocks/odds)
|
||||||
has_scroll_helper = hasattr(plugin, 'scroll_helper')
|
has_scroll_helper = hasattr(plugin, 'scroll_helper')
|
||||||
logger.info("[%s] Has scroll_helper: %s", plugin_id, has_scroll_helper)
|
logger.info("[%s] Has scroll_helper: %s", plugin_id, has_scroll_helper)
|
||||||
content = self._get_scroll_helper_content(plugin, plugin_id, offscreen_only)
|
content = self._get_scroll_helper_content(plugin, plugin_id)
|
||||||
if content:
|
if content:
|
||||||
total_width = sum(img.width for img in content)
|
total_width = sum(img.width for img in content)
|
||||||
logger.info(
|
logger.info(
|
||||||
"[%s] ScrollHelper content SUCCESS: %d images, %dpx total",
|
"[%s] ScrollHelper content SUCCESS: %d images, %dpx total",
|
||||||
plugin_id, len(content), total_width
|
plugin_id, len(content), total_width
|
||||||
)
|
)
|
||||||
return self._finalize(content, plugin_id, 'scroll_helper', plugin)
|
self._cache_content(plugin_id, content)
|
||||||
|
return content
|
||||||
if has_scroll_helper:
|
if has_scroll_helper:
|
||||||
logger.info("[%s] ScrollHelper content returned None", plugin_id)
|
logger.info("[%s] ScrollHelper content returned None", plugin_id)
|
||||||
|
|
||||||
if offscreen_only:
|
|
||||||
# Display capture needs the shared canvas; leave it to the caller.
|
|
||||||
logger.info(
|
|
||||||
"[%s] Needs display capture, deferring to the render thread",
|
|
||||||
plugin_id
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Fall back to display capture
|
# Fall back to display capture
|
||||||
logger.info("[%s] Trying fallback display capture...", plugin_id)
|
logger.info("[%s] Trying fallback display capture...", plugin_id)
|
||||||
content = self._capture_display_content(plugin, plugin_id)
|
content = self._capture_display_content(plugin, plugin_id)
|
||||||
@@ -154,7 +121,8 @@ class PluginAdapter:
|
|||||||
"[%s] Fallback capture SUCCESS: %d images, %dpx total",
|
"[%s] Fallback capture SUCCESS: %d images, %dpx total",
|
||||||
plugin_id, len(content), total_width
|
plugin_id, len(content), total_width
|
||||||
)
|
)
|
||||||
return self._finalize(content, plugin_id, 'fallback', plugin)
|
self._cache_content(plugin_id, content)
|
||||||
|
return content
|
||||||
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"[%s] NO CONTENT from any method (native=%s, scroll_helper=%s, fallback=tried)",
|
"[%s] NO CONTENT from any method (native=%s, scroll_helper=%s, fallback=tried)",
|
||||||
@@ -162,397 +130,8 @@ class PluginAdapter:
|
|||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _finalize(
|
|
||||||
self, images: List[Image.Image], plugin_id: str, source: str,
|
|
||||||
plugin: Optional['BasePlugin'] = None
|
|
||||||
) -> Optional[List[Image.Image]]:
|
|
||||||
"""
|
|
||||||
Trim dead space off a segment, then cache it.
|
|
||||||
|
|
||||||
Every content path funnels through here so trimming is applied
|
|
||||||
uniformly. Previously only the scroll_helper path had its margins
|
|
||||||
stripped, which left plugins that render onto a full-display canvas
|
|
||||||
contributing their entire blank canvas to the ticker.
|
|
||||||
|
|
||||||
Each image is trimmed independently because compose_scroll_content()
|
|
||||||
treats every image as its own item and inserts separator_width between
|
|
||||||
them — so a per-image trim is what makes that separator the real gap.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
images: Raw content from one of the fetch paths
|
|
||||||
plugin_id: Plugin identifier for logging
|
|
||||||
source: Which path produced the content, for logging
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Trimmed image list, or None if nothing worth showing remains
|
|
||||||
"""
|
|
||||||
if not self.config.auto_trim:
|
|
||||||
# Trimming is off, but the width budget is a separate concern —
|
|
||||||
# turning off margin cropping should not let one plugin hold the
|
|
||||||
# panel for minutes. Skipping it here previously let a 14,848px
|
|
||||||
# segment through untouched.
|
|
||||||
kept = self._apply_width_budget(list(images), plugin_id, plugin)
|
|
||||||
self._cache_content(plugin_id, kept)
|
|
||||||
return kept
|
|
||||||
|
|
||||||
original_width = sum(img.width for img in images)
|
|
||||||
kept: List[Image.Image] = []
|
|
||||||
dropped_blank = 0
|
|
||||||
|
|
||||||
for img in images:
|
|
||||||
result = trim_to_content(
|
|
||||||
img,
|
|
||||||
threshold=self.config.trim_threshold,
|
|
||||||
padding=self.config.content_padding,
|
|
||||||
)
|
|
||||||
if result.is_blank:
|
|
||||||
dropped_blank += 1
|
|
||||||
continue
|
|
||||||
kept.append(result.image)
|
|
||||||
|
|
||||||
if not kept:
|
|
||||||
logger.info(
|
|
||||||
"[%s] All %d image(s) from %s were blank — contributing nothing",
|
|
||||||
plugin_id, len(images), source
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
trimmed_width = sum(img.width for img in kept)
|
|
||||||
|
|
||||||
if trimmed_width < self.config.min_plugin_width:
|
|
||||||
logger.info(
|
|
||||||
"[%s] Trimmed content %dpx is below min_plugin_width %dpx — skipping",
|
|
||||||
plugin_id, trimmed_width, self.config.min_plugin_width
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
if trimmed_width != original_width or dropped_blank:
|
|
||||||
logger.info(
|
|
||||||
"[%s] Trimmed %s content: %dpx -> %dpx (%.0f%% reclaimed), "
|
|
||||||
"%d image(s) kept, %d blank dropped",
|
|
||||||
plugin_id, source, original_width, trimmed_width,
|
|
||||||
100.0 * (original_width - trimmed_width) / original_width
|
|
||||||
if original_width else 0.0,
|
|
||||||
len(kept), dropped_blank
|
|
||||||
)
|
|
||||||
|
|
||||||
kept = self._apply_width_budget(kept, plugin_id, plugin)
|
|
||||||
|
|
||||||
self._cache_content(plugin_id, kept)
|
|
||||||
return kept
|
|
||||||
|
|
||||||
def _capture(self):
|
|
||||||
"""
|
|
||||||
Context manager suppressing hardware writes while plugin render code runs.
|
|
||||||
|
|
||||||
Degrades to a no-op when the display manager predates capture_mode. As
|
|
||||||
with _render_at, losing the suppression risks a visible flash, whereas
|
|
||||||
raising would be swallowed by the broad handlers upstream and drop the
|
|
||||||
plugin's content entirely — much worse.
|
|
||||||
"""
|
|
||||||
capture_mode = getattr(self.display_manager, 'capture_mode', None)
|
|
||||||
if capture_mode is None:
|
|
||||||
logger.debug(
|
|
||||||
"display_manager has no capture_mode(); plugin writes during "
|
|
||||||
"content capture may reach the panel"
|
|
||||||
)
|
|
||||||
return nullcontext()
|
|
||||||
return capture_mode()
|
|
||||||
|
|
||||||
def _render_at(self, width: int):
|
|
||||||
"""
|
|
||||||
Context manager narrowing the plugin-facing canvas to ``width``.
|
|
||||||
|
|
||||||
Degrades to a no-op when the display manager predates render_size (a
|
|
||||||
third-party or older test harness). Losing the narrowing is a cosmetic
|
|
||||||
regression; raising here would be caught by the broad handlers upstream
|
|
||||||
and silently drop the plugin's content entirely.
|
|
||||||
"""
|
|
||||||
render_size = getattr(self.display_manager, 'render_size', None)
|
|
||||||
if render_size is None:
|
|
||||||
logger.debug(
|
|
||||||
"display_manager has no render_size(); Vegas width requests "
|
|
||||||
"will be ignored"
|
|
||||||
)
|
|
||||||
return nullcontext()
|
|
||||||
return render_size(width)
|
|
||||||
|
|
||||||
def resolve_render_width(self, plugin: 'BasePlugin', plugin_id: str) -> int:
|
|
||||||
"""
|
|
||||||
Width to tell a plugin it has while it renders for the ticker.
|
|
||||||
|
|
||||||
Resolution order, most specific first:
|
|
||||||
1. the plugin's own ``vegas_width_pct`` config value
|
|
||||||
2. the global ``vegas_scroll.render_width_pct``
|
|
||||||
3. the full panel width
|
|
||||||
|
|
||||||
A percentage rather than an absolute width so one setting travels
|
|
||||||
across panel sizes.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
plugin: Plugin instance, consulted for a per-plugin override
|
|
||||||
plugin_id: Plugin identifier for logging
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Target width in pixels, never wider than the panel
|
|
||||||
"""
|
|
||||||
pct = self.config.render_width_pct
|
|
||||||
|
|
||||||
plugin_cfg = getattr(plugin, 'config', None)
|
|
||||||
if isinstance(plugin_cfg, dict):
|
|
||||||
raw = plugin_cfg.get('vegas_width_pct')
|
|
||||||
if raw not in (None, ''):
|
|
||||||
try:
|
|
||||||
candidate = int(raw)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
logger.warning(
|
|
||||||
"[%s] Invalid vegas_width_pct %r, ignoring", plugin_id, raw)
|
|
||||||
else:
|
|
||||||
if 10 <= candidate <= 100:
|
|
||||||
pct = candidate
|
|
||||||
else:
|
|
||||||
logger.warning(
|
|
||||||
"[%s] vegas_width_pct %d out of range 10-100, ignoring",
|
|
||||||
plugin_id, candidate)
|
|
||||||
|
|
||||||
if pct >= 100:
|
|
||||||
return self.display_width
|
|
||||||
return max(1, int(self.display_width * pct / 100))
|
|
||||||
|
|
||||||
def _row_gap(self, left: Image.Image, right: Image.Image) -> int:
|
|
||||||
"""
|
|
||||||
Gap the compositor will insert between two of a plugin's rows.
|
|
||||||
|
|
||||||
Mirrors RenderPipeline._join_plugin_rows so the width budget measures
|
|
||||||
what will actually be rendered.
|
|
||||||
"""
|
|
||||||
return separation_gap(
|
|
||||||
left, right,
|
|
||||||
target=max(0, self.config.min_content_separation),
|
|
||||||
minimum=max(0, self.config.intra_plugin_gap),
|
|
||||||
threshold=self.config.trim_threshold,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _plugin_setting(self, plugin: 'BasePlugin', key: str):
|
|
||||||
"""Read a per-plugin config override, or None if absent."""
|
|
||||||
plugin_cfg = getattr(plugin, 'config', None)
|
|
||||||
if not isinstance(plugin_cfg, dict):
|
|
||||||
return None
|
|
||||||
value = plugin_cfg.get(key)
|
|
||||||
return None if value in (None, '') else value
|
|
||||||
|
|
||||||
def resolve_overflow_mode(self, plugin: 'BasePlugin', plugin_id: str) -> str:
|
|
||||||
"""
|
|
||||||
How to handle content that exceeds this plugin's width budget.
|
|
||||||
|
|
||||||
'rotate' advances a window each cycle so everything is seen eventually,
|
|
||||||
which suits interchangeable items. 'truncate' always shows the start,
|
|
||||||
which suits ordered content — a league table that shows ranks 1-6 and
|
|
||||||
then resumes at 7 two rotations later reads as out of order, and nobody
|
|
||||||
needs rank 23 in a ticker anyway.
|
|
||||||
|
|
||||||
Per-plugin ``vegas_overflow`` wins over the global ``overflow_mode``.
|
|
||||||
"""
|
|
||||||
raw = self._plugin_setting(plugin, 'vegas_overflow')
|
|
||||||
if raw is not None:
|
|
||||||
candidate = str(raw).strip().lower()
|
|
||||||
if candidate in ('rotate', 'truncate'):
|
|
||||||
return candidate
|
|
||||||
logger.warning(
|
|
||||||
"[%s] Invalid vegas_overflow %r, expected 'rotate' or 'truncate'",
|
|
||||||
plugin_id, raw
|
|
||||||
)
|
|
||||||
return self.config.overflow_mode
|
|
||||||
|
|
||||||
def _width_budget(self, plugin: Optional['BasePlugin'] = None,
|
|
||||||
plugin_id: str = '') -> int:
|
|
||||||
"""
|
|
||||||
Maximum columns one plugin may occupy in a cycle. 0 means unlimited.
|
|
||||||
|
|
||||||
A per-plugin ``vegas_max_width_screens`` overrides the global ratio, so
|
|
||||||
content that has to stay whole can be given room (or uncapped with 0)
|
|
||||||
without lifting the cap on every ticker.
|
|
||||||
"""
|
|
||||||
ratio = self.config.max_plugin_width_ratio
|
|
||||||
|
|
||||||
if plugin is not None:
|
|
||||||
raw = self._plugin_setting(plugin, 'vegas_max_width_screens')
|
|
||||||
if raw is not None:
|
|
||||||
try:
|
|
||||||
candidate = float(raw)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
logger.warning(
|
|
||||||
"[%s] Invalid vegas_max_width_screens %r, ignoring",
|
|
||||||
plugin_id, raw
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
if candidate >= 0:
|
|
||||||
ratio = candidate
|
|
||||||
else:
|
|
||||||
logger.warning(
|
|
||||||
"[%s] vegas_max_width_screens must be >= 0, got %s",
|
|
||||||
plugin_id, candidate
|
|
||||||
)
|
|
||||||
|
|
||||||
if ratio <= 0:
|
|
||||||
return 0
|
|
||||||
return int(self.display_width * ratio)
|
|
||||||
|
|
||||||
def _apply_width_budget(
|
|
||||||
self, images: List[Image.Image], plugin_id: str,
|
|
||||||
plugin: Optional['BasePlugin'] = None
|
|
||||||
) -> List[Image.Image]:
|
|
||||||
"""
|
|
||||||
Hold one plugin to its share of a cycle.
|
|
||||||
|
|
||||||
A ticker returning 7,000px would otherwise own the panel for over two
|
|
||||||
minutes, which defeats the point of a rotation. Overflow is deferred
|
|
||||||
rather than discarded: the starting offset advances each time this
|
|
||||||
plugin is fetched, so later items appear on subsequent cycles instead
|
|
||||||
of never being seen.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
images: Trimmed images for this plugin
|
|
||||||
plugin_id: Plugin identifier, used to track its rotation offset
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Images that fit the budget, starting from the plugin's current
|
|
||||||
rotation offset.
|
|
||||||
"""
|
|
||||||
budget = self._width_budget(plugin, plugin_id)
|
|
||||||
mode = (self.resolve_overflow_mode(plugin, plugin_id)
|
|
||||||
if plugin is not None else self.config.overflow_mode)
|
|
||||||
|
|
||||||
# Count the gaps the compositor will actually insert, not just the
|
|
||||||
# pixels of the rows — otherwise a plugin with many rows quietly
|
|
||||||
# occupies far more of the panel than its budget allows. These must use
|
|
||||||
# the same measured rule as RenderPipeline._join_plugin_rows; assuming
|
|
||||||
# the flat intra_plugin_gap here under-counted by up to
|
|
||||||
# (min_content_separation - intra_plugin_gap) per row.
|
|
||||||
total = sum(img.width for img in images) + sum(
|
|
||||||
self._row_gap(images[i], images[i + 1]) for i in range(len(images) - 1)
|
|
||||||
)
|
|
||||||
|
|
||||||
if not budget or total <= budget:
|
|
||||||
# Fits, so reset rotation — the whole segment is being shown.
|
|
||||||
self._item_offsets.pop(plugin_id, None)
|
|
||||||
return images
|
|
||||||
|
|
||||||
if len(images) == 1:
|
|
||||||
return [self._crop_to_budget(images[0], budget, plugin_id, mode)]
|
|
||||||
|
|
||||||
if mode == 'truncate':
|
|
||||||
# Ordered content: always show from the top. Deliberately does not
|
|
||||||
# advance the offset, so the same opening items appear every time
|
|
||||||
# rather than the viewer being shown the middle of a ranked list.
|
|
||||||
start = 0
|
|
||||||
else:
|
|
||||||
start = self._item_offsets.get(plugin_id, 0) % len(images)
|
|
||||||
selected: List[Image.Image] = []
|
|
||||||
used = 0
|
|
||||||
consumed = 0
|
|
||||||
|
|
||||||
# Walk forward from the rotation offset, taking whole items only, so a
|
|
||||||
# cut never lands in the middle of one.
|
|
||||||
for step in range(len(images)):
|
|
||||||
img = images[(start + step) % len(images)]
|
|
||||||
cost = img.width
|
|
||||||
if selected:
|
|
||||||
cost += self._row_gap(selected[-1], img)
|
|
||||||
if selected and used + cost > budget:
|
|
||||||
break
|
|
||||||
selected.append(img)
|
|
||||||
used += cost
|
|
||||||
consumed += 1
|
|
||||||
|
|
||||||
if mode == 'truncate':
|
|
||||||
logger.info(
|
|
||||||
"[%s] Width budget %dpx: showing the first %d of %d row(s) "
|
|
||||||
"(%dpx incl. gaps); the rest are not shown (overflow=truncate)",
|
|
||||||
plugin_id, budget, len(selected), len(images), used
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self._item_offsets[plugin_id] = (start + consumed) % len(images)
|
|
||||||
logger.info(
|
|
||||||
"[%s] Width budget %dpx: showing %d of %d row(s) (%dpx incl. gaps) "
|
|
||||||
"from offset %d; remainder deferred to a later cycle",
|
|
||||||
plugin_id, budget, len(selected), len(images), used, start
|
|
||||||
)
|
|
||||||
return selected
|
|
||||||
|
|
||||||
def _crop_to_budget(
|
|
||||||
self, img: Image.Image, budget: int, plugin_id: str,
|
|
||||||
mode: str = 'rotate'
|
|
||||||
) -> Image.Image:
|
|
||||||
"""
|
|
||||||
Narrow a single oversized image to the budget, advancing a window
|
|
||||||
through it across cycles.
|
|
||||||
|
|
||||||
The cut is snapped to the nearest blank column so it does not slice
|
|
||||||
through a glyph or logo and leave half a character at the panel edge.
|
|
||||||
"""
|
|
||||||
if mode == 'truncate':
|
|
||||||
# Always the start of the strip, so a ranked table is never entered
|
|
||||||
# from the middle.
|
|
||||||
offset = 0
|
|
||||||
else:
|
|
||||||
offset = self._item_offsets.get(plugin_id, 0)
|
|
||||||
if offset >= img.width:
|
|
||||||
offset = 0
|
|
||||||
|
|
||||||
# Cut only where the plugin left a real gap between items. Snapping to
|
|
||||||
# any blank column used to pick the single-column gaps between
|
|
||||||
# characters, splitting a word and orphaning its tail into the next
|
|
||||||
# cycle — a lone "y" from "Wednesday" floating between two unrelated
|
|
||||||
# plugins. Overshooting the budget is the lesser evil.
|
|
||||||
min_run = max(2, self.config.min_cut_gap)
|
|
||||||
gaps = blank_runs(img, min_run, self.config.trim_threshold)
|
|
||||||
|
|
||||||
if not gaps:
|
|
||||||
# No internal gaps means continuous content — a map, a chart, a
|
|
||||||
# photo — where any column is as good as any other, so cut to the
|
|
||||||
# budget exactly. The gap rule exists to protect discrete items
|
|
||||||
# (words, ticker entries); it would be wrong to let a solid image
|
|
||||||
# escape the cap in its name.
|
|
||||||
end = min(offset + budget, img.width)
|
|
||||||
if mode != 'truncate':
|
|
||||||
self._item_offsets[plugin_id] = 0 if end >= img.width else end
|
|
||||||
logger.info(
|
|
||||||
"[%s] Width budget %dpx: cropped continuous %dpx image to "
|
|
||||||
"[%d:%d] (no item gaps of %dpx+ to align to)%s",
|
|
||||||
plugin_id, budget, img.width, offset, end, min_run,
|
|
||||||
"" if mode != 'truncate' else "; showing the start only"
|
|
||||||
)
|
|
||||||
return img.crop((offset, 0, end, img.height))
|
|
||||||
|
|
||||||
# Cut mid-gap so the content either side keeps some breathing room.
|
|
||||||
cuts = sorted({0, img.width} | {(a + b) // 2 for a, b in gaps})
|
|
||||||
|
|
||||||
start = max((c for c in cuts if c <= offset), default=0)
|
|
||||||
later = [c for c in cuts if c > start]
|
|
||||||
if not later:
|
|
||||||
end = img.width
|
|
||||||
else:
|
|
||||||
within = [c for c in later if c <= start + budget]
|
|
||||||
# No boundary inside the budget: take the next one and overrun,
|
|
||||||
# because the alternative is cutting through an item.
|
|
||||||
end = max(within) if within else min(later)
|
|
||||||
|
|
||||||
if mode != 'truncate':
|
|
||||||
# Next cycle resumes where this one stopped; wrap when the strip ends.
|
|
||||||
self._item_offsets[plugin_id] = 0 if end >= img.width else end
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"[%s] Width budget %dpx: cropped single %dpx image to [%d:%d] "
|
|
||||||
"(%dpx) at item boundaries, %s",
|
|
||||||
plugin_id, budget, img.width, start, end, end - start,
|
|
||||||
"showing the start only (overflow=truncate)"
|
|
||||||
if mode == 'truncate' else "window advances next cycle"
|
|
||||||
)
|
|
||||||
return img.crop((start, 0, end, img.height))
|
|
||||||
|
|
||||||
def _get_native_content(
|
def _get_native_content(
|
||||||
self, plugin: 'BasePlugin', plugin_id: str, offscreen_only: bool = False
|
self, plugin: 'BasePlugin', plugin_id: str
|
||||||
) -> Optional[List[Image.Image]]:
|
) -> Optional[List[Image.Image]]:
|
||||||
"""
|
"""
|
||||||
Get content via plugin's native get_vegas_content() method.
|
Get content via plugin's native get_vegas_content() method.
|
||||||
@@ -566,40 +145,7 @@ class PluginAdapter:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
logger.info("[%s] Native: calling get_vegas_content()", plugin_id)
|
logger.info("[%s] Native: calling get_vegas_content()", plugin_id)
|
||||||
|
result = plugin.get_vegas_content()
|
||||||
# Tell the plugin how much width the ticker wants it to use, and
|
|
||||||
# narrow the canvas for the duration of the call. A plugin that
|
|
||||||
# sizes its own images from display_manager.matrix.width picks up
|
|
||||||
# the narrower value with no changes of its own; one that wants to
|
|
||||||
# be explicit can read get_vegas_render_width().
|
|
||||||
render_width = self.resolve_render_width(plugin, plugin_id)
|
|
||||||
if render_width != self.display_width:
|
|
||||||
logger.info(
|
|
||||||
"[%s] Native: requesting %dpx instead of %dpx",
|
|
||||||
plugin_id, render_width, self.display_width
|
|
||||||
)
|
|
||||||
|
|
||||||
plugin._vegas_render_width = render_width
|
|
||||||
try:
|
|
||||||
# capture_mode unconditionally, even at full width. Building
|
|
||||||
# Vegas content is an off-screen operation, but a plugin is free
|
|
||||||
# to call update_display() while doing it — and outside
|
|
||||||
# capture_mode that write lands on the hardware, flashing the
|
|
||||||
# panel mid-scroll. The narrowing context is separate because it
|
|
||||||
# is a no-op at full width.
|
|
||||||
if offscreen_only:
|
|
||||||
# _render_at swaps the shared canvas, so it is unsafe here.
|
|
||||||
# _vegas_render_width is set regardless: a plugin reading
|
|
||||||
# get_vegas_render_width() still gets its narrow size, and
|
|
||||||
# one that only reads matrix.width renders full width and is
|
|
||||||
# trimmed instead.
|
|
||||||
with self._capture():
|
|
||||||
result = plugin.get_vegas_content()
|
|
||||||
else:
|
|
||||||
with self._capture(), self._render_at(render_width):
|
|
||||||
result = plugin.get_vegas_content()
|
|
||||||
finally:
|
|
||||||
plugin._vegas_render_width = None
|
|
||||||
|
|
||||||
if result is None:
|
if result is None:
|
||||||
logger.info("[%s] Native: get_vegas_content() returned None", plugin_id)
|
logger.info("[%s] Native: get_vegas_content() returned None", plugin_id)
|
||||||
@@ -677,7 +223,7 @@ class PluginAdapter:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def _get_scroll_helper_content(
|
def _get_scroll_helper_content(
|
||||||
self, plugin: 'BasePlugin', plugin_id: str, offscreen_only: bool = False
|
self, plugin: 'BasePlugin', plugin_id: str
|
||||||
) -> Optional[List[Image.Image]]:
|
) -> Optional[List[Image.Image]]:
|
||||||
"""
|
"""
|
||||||
Get content from plugin's scroll_helper if available.
|
Get content from plugin's scroll_helper if available.
|
||||||
@@ -711,13 +257,6 @@ class PluginAdapter:
|
|||||||
"[%s] scroll_helper.cached_image is None, triggering content generation",
|
"[%s] scroll_helper.cached_image is None, triggering content generation",
|
||||||
plugin_id
|
plugin_id
|
||||||
)
|
)
|
||||||
if offscreen_only:
|
|
||||||
# Generating it calls display(), which needs the canvas.
|
|
||||||
logger.info(
|
|
||||||
"[%s] scroll_helper cache empty; deferring generation "
|
|
||||||
"to the render thread", plugin_id
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
# Try to trigger scroll content generation
|
# Try to trigger scroll content generation
|
||||||
cached_image = self._trigger_scroll_content_generation(
|
cached_image = self._trigger_scroll_content_generation(
|
||||||
plugin, plugin_id, scroll_helper
|
plugin, plugin_id, scroll_helper
|
||||||
@@ -866,7 +405,7 @@ class PluginAdapter:
|
|||||||
# Save display state to restore after
|
# Save display state to restore after
|
||||||
original_image = self.display_manager.image.copy()
|
original_image = self.display_manager.image.copy()
|
||||||
|
|
||||||
with self._capture():
|
with self.display_manager.capture_mode():
|
||||||
# Method 1: Try _create_scrolling_display (stocks pattern)
|
# Method 1: Try _create_scrolling_display (stocks pattern)
|
||||||
if hasattr(plugin, '_create_scrolling_display'):
|
if hasattr(plugin, '_create_scrolling_display'):
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -958,18 +497,7 @@ class PluginAdapter:
|
|||||||
|
|
||||||
# Clear and call plugin display — use capture_mode to suppress hardware writes
|
# Clear and call plugin display — use capture_mode to suppress hardware writes
|
||||||
# that plugins may trigger internally via update_display().
|
# that plugins may trigger internally via update_display().
|
||||||
#
|
with self.display_manager.capture_mode():
|
||||||
# render_size narrows the canvas the plugin lays out against, so a
|
|
||||||
# plugin that spreads across the whole panel produces a compact
|
|
||||||
# arrangement rather than one that has to be cropped afterwards.
|
|
||||||
render_width = self.resolve_render_width(plugin, plugin_id)
|
|
||||||
if render_width != self.display_width:
|
|
||||||
logger.info(
|
|
||||||
"[%s] Fallback: rendering at %dpx instead of %dpx",
|
|
||||||
plugin_id, render_width, self.display_width
|
|
||||||
)
|
|
||||||
|
|
||||||
with self._capture(), self._render_at(render_width):
|
|
||||||
self.display_manager.clear()
|
self.display_manager.clear()
|
||||||
logger.info("[%s] Fallback: display cleared, calling display()", plugin_id)
|
logger.info("[%s] Fallback: display cleared, calling display()", plugin_id)
|
||||||
|
|
||||||
@@ -1003,7 +531,7 @@ class PluginAdapter:
|
|||||||
plugin_id
|
plugin_id
|
||||||
)
|
)
|
||||||
# Try once more with force_clear=True
|
# Try once more with force_clear=True
|
||||||
with self._capture(), self._render_at(render_width):
|
with self.display_manager.capture_mode():
|
||||||
self.display_manager.clear()
|
self.display_manager.clear()
|
||||||
plugin.display(force_clear=True)
|
plugin.display(force_clear=True)
|
||||||
captured = self.display_manager.image.copy()
|
captured = self.display_manager.image.copy()
|
||||||
@@ -1135,53 +663,6 @@ class PluginAdapter:
|
|||||||
else:
|
else:
|
||||||
self._content_cache.clear()
|
self._content_cache.clear()
|
||||||
|
|
||||||
def invalidate_plugin_scroll_cache(
|
|
||||||
self, plugin: 'BasePlugin', plugin_id: str
|
|
||||||
) -> bool:
|
|
||||||
"""
|
|
||||||
Drop a plugin's own cached scroll image so its visual is rebuilt.
|
|
||||||
|
|
||||||
Invalidating only this adapter's cache is not enough. A plugin that
|
|
||||||
composes a scroll strip hands back the *same* image every time until its
|
|
||||||
own cache is cleared — the sports plugins' ``get_vegas_content()``
|
|
||||||
regenerates only "if the cache is empty" — so without this a segment
|
|
||||||
keeps rendering whatever data it was first built from. That is how a
|
|
||||||
game that was live last night can still be displayed as live the next
|
|
||||||
morning.
|
|
||||||
|
|
||||||
Two layouts to cover: a helper directly on the plugin (stocks, news,
|
|
||||||
odds-ticker) and one owned by a scroll-display manager (the sports
|
|
||||||
scoreboards). ``cached_image`` and ``cached_array`` must be cleared
|
|
||||||
together, since the array is the image's numpy mirror and code paths
|
|
||||||
read whichever is convenient.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if a cache was found and cleared.
|
|
||||||
"""
|
|
||||||
cleared = False
|
|
||||||
for owner in (plugin, getattr(plugin, '_scroll_manager', None),
|
|
||||||
getattr(plugin, 'scroll_manager', None)):
|
|
||||||
if owner is None:
|
|
||||||
continue
|
|
||||||
helper = getattr(owner, 'scroll_helper', None)
|
|
||||||
if helper is None:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
if getattr(helper, 'cached_image', None) is not None:
|
|
||||||
helper.cached_image = None
|
|
||||||
cleared = True
|
|
||||||
if getattr(helper, 'cached_array', None) is not None:
|
|
||||||
helper.cached_array = None
|
|
||||||
cleared = True
|
|
||||||
except Exception: # pylint: disable=broad-except
|
|
||||||
logger.exception(
|
|
||||||
"[%s] Could not clear scroll cache on %s",
|
|
||||||
plugin_id, type(owner).__name__
|
|
||||||
)
|
|
||||||
if cleared:
|
|
||||||
logger.debug("[%s] Cleared plugin scroll cache", plugin_id)
|
|
||||||
return cleared
|
|
||||||
|
|
||||||
def get_content_type(self, plugin: 'BasePlugin', plugin_id: str) -> str:
|
def get_content_type(self, plugin: 'BasePlugin', plugin_id: str) -> str:
|
||||||
"""
|
"""
|
||||||
Get the type of content a plugin provides.
|
Get the type of content a plugin provides.
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ Uses the existing ScrollHelper for numpy-optimized scroll operations.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import time
|
import time
|
||||||
import threading
|
import threading
|
||||||
from collections import deque
|
from collections import deque
|
||||||
@@ -15,7 +14,6 @@ from PIL import Image
|
|||||||
|
|
||||||
from src.common.scroll_helper import ScrollHelper
|
from src.common.scroll_helper import ScrollHelper
|
||||||
from src.vegas_mode.config import VegasModeConfig
|
from src.vegas_mode.config import VegasModeConfig
|
||||||
from src.vegas_mode.geometry import separation_gap
|
|
||||||
from src.vegas_mode.stream_manager import StreamManager
|
from src.vegas_mode.stream_manager import StreamManager
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -36,10 +34,6 @@ class RenderPipeline:
|
|||||||
- Track scroll cycle completion
|
- Track scroll cycle completion
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Minimum gap between fetches of canvas-bound plugins, so their individual
|
|
||||||
# stalls land in separate moments rather than one run of hitches.
|
|
||||||
DEFERRED_DRAIN_INTERVAL = 2.0
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
config: VegasModeConfig,
|
config: VegasModeConfig,
|
||||||
@@ -72,6 +66,10 @@ class RenderPipeline:
|
|||||||
else display_manager.height
|
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
|
# ScrollHelper for optimized scrolling
|
||||||
self.scroll_helper = ScrollHelper(
|
self.scroll_helper = ScrollHelper(
|
||||||
self.display_width,
|
self.display_width,
|
||||||
@@ -87,14 +85,6 @@ class RenderPipeline:
|
|||||||
self._staging_scroll_image: Optional[Image.Image] = None
|
self._staging_scroll_image: Optional[Image.Image] = None
|
||||||
self._buffer_lock = threading.Lock()
|
self._buffer_lock = threading.Lock()
|
||||||
|
|
||||||
# Group prepared off the render thread, waiting to be appended.
|
|
||||||
self._prepared_group = None
|
|
||||||
# Plugins that need the shared canvas, appended one at a time.
|
|
||||||
self._deferred_queue: List[str] = []
|
|
||||||
self._last_drain_time = 0.0
|
|
||||||
self._prefetch_thread: Optional[threading.Thread] = None
|
|
||||||
self._prefetch_lock = threading.Lock()
|
|
||||||
|
|
||||||
# Render state
|
# Render state
|
||||||
self._is_rendering = False
|
self._is_rendering = False
|
||||||
self._cycle_complete = False
|
self._cycle_complete = False
|
||||||
@@ -124,7 +114,6 @@ class RenderPipeline:
|
|||||||
"""Configure ScrollHelper with current settings."""
|
"""Configure ScrollHelper with current settings."""
|
||||||
self.scroll_helper.set_frame_based_scrolling(self.config.frame_based_scrolling)
|
self.scroll_helper.set_frame_based_scrolling(self.config.frame_based_scrolling)
|
||||||
self.scroll_helper.set_scroll_delay(self.config.scroll_delay)
|
self.scroll_helper.set_scroll_delay(self.config.scroll_delay)
|
||||||
self.scroll_helper.set_sub_pixel_scrolling(self.config.smooth_scroll)
|
|
||||||
|
|
||||||
# Config scroll_speed is always pixels per second, but ScrollHelper
|
# Config scroll_speed is always pixels per second, but ScrollHelper
|
||||||
# interprets it differently based on frame_based_scrolling mode:
|
# interprets it differently based on frame_based_scrolling mode:
|
||||||
@@ -152,37 +141,23 @@ class RenderPipeline:
|
|||||||
True if composition successful
|
True if composition successful
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Content grouped by plugin, so a separator can be placed at the
|
# Get all buffered content
|
||||||
# plugin boundaries only.
|
images = self.stream_manager.get_all_content_for_composition()
|
||||||
grouped = self.stream_manager.get_grouped_content_for_composition()
|
|
||||||
|
|
||||||
if not grouped:
|
if not images:
|
||||||
logger.warning("No content available for composition")
|
logger.warning("No content available for composition")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Collapse each plugin's rows into a single block, joined by
|
# Add separator gaps between images
|
||||||
# intra_plugin_gap. ScrollHelper applies one uniform gap between the
|
content_with_gaps = []
|
||||||
# items it is given, so handing it one item per plugin is what makes
|
for i, img in enumerate(images):
|
||||||
# separator_width mean "between plugins" instead of "between every
|
content_with_gaps.append(img)
|
||||||
# row". Without this, a per-row ticker such as the F1 scoreboard got
|
|
||||||
# the full separator between each of its ~116 rows.
|
|
||||||
blocks = []
|
|
||||||
total_rows = 0
|
|
||||||
for plugin_id, images in grouped:
|
|
||||||
total_rows += len(images)
|
|
||||||
blocks.append(self._join_plugin_rows(images))
|
|
||||||
|
|
||||||
# Create scrolling image via ScrollHelper.
|
# Create scrolling image via ScrollHelper
|
||||||
#
|
|
||||||
# lead_gap is explicit because ScrollHelper otherwise prepends a
|
|
||||||
# full display width of black — appropriate for a standalone ticker
|
|
||||||
# scrolling in from off-screen, but in Vegas mode it is charged
|
|
||||||
# once per cycle and reads as the panel switching off.
|
|
||||||
self.scroll_helper.create_scrolling_image(
|
self.scroll_helper.create_scrolling_image(
|
||||||
content_items=blocks,
|
content_items=content_with_gaps,
|
||||||
item_gap=self.config.separator_width,
|
item_gap=self.config.separator_width,
|
||||||
element_gap=0,
|
element_gap=0
|
||||||
lead_gap=self.config.lead_in_width
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Verify scroll image was created successfully
|
# Verify scroll image was created successfully
|
||||||
@@ -202,16 +177,11 @@ class RenderPipeline:
|
|||||||
self._cycle_complete = False
|
self._cycle_complete = False
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Composed scroll image: %dx%d, %d plugin block(s), %d rows, "
|
"Composed scroll image: %dx%d, %d plugins, %d items",
|
||||||
"separator=%dpx between plugins, rows spaced to %dpx of ink "
|
|
||||||
"(min added %dpx)",
|
|
||||||
self.scroll_helper.cached_image.width if self.scroll_helper.cached_image else 0,
|
self.scroll_helper.cached_image.width if self.scroll_helper.cached_image else 0,
|
||||||
self.display_height,
|
self.display_height,
|
||||||
len(blocks),
|
len(self._segments_in_scroll),
|
||||||
total_rows,
|
len(images)
|
||||||
self.config.separator_width,
|
|
||||||
self.config.min_content_separation,
|
|
||||||
self.config.intra_plugin_gap,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
@@ -221,264 +191,6 @@ class RenderPipeline:
|
|||||||
logger.exception("Error composing scroll content")
|
logger.exception("Error composing scroll content")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def needs_extension(self) -> bool:
|
|
||||||
"""
|
|
||||||
Whether the strip should be extended with the next group of plugins.
|
|
||||||
|
|
||||||
Cheap enough to call every frame: it is arithmetic over cached state.
|
|
||||||
"""
|
|
||||||
if not self.config.continuous_scroll or not self.scroll_helper.cached_image:
|
|
||||||
return False
|
|
||||||
threshold = int(self.display_width * self.config.extend_threshold_screens)
|
|
||||||
return self.scroll_helper.remaining_unscrolled() <= threshold
|
|
||||||
|
|
||||||
def start_prefetch(self) -> None:
|
|
||||||
"""
|
|
||||||
Begin preparing the next group in the background, if not already doing so.
|
|
||||||
|
|
||||||
This is what makes the join seamless rather than merely continuous:
|
|
||||||
fetching a group costs 0.5-4.8s (rendering leaderboard and baseball cards
|
|
||||||
dominates), and doing it on the render thread stalls the scroll for that
|
|
||||||
long. Off the render thread there is a whole group's scroll time to work
|
|
||||||
in, so by the time the strip needs extending the content is already sat
|
|
||||||
waiting.
|
|
||||||
|
|
||||||
Only paths that avoid the shared display canvas run here; anything
|
|
||||||
needing it is marked and picked up on the render thread, where it is
|
|
||||||
safe. Those are the cheap ones — display capture measured 12-14ms
|
|
||||||
against seconds for the native renders.
|
|
||||||
"""
|
|
||||||
if not self.config.continuous_scroll:
|
|
||||||
return
|
|
||||||
|
|
||||||
with self._prefetch_lock:
|
|
||||||
if self._prefetch_thread is not None and self._prefetch_thread.is_alive():
|
|
||||||
return
|
|
||||||
if self._prepared_group is not None:
|
|
||||||
return # already have one waiting
|
|
||||||
|
|
||||||
def _work():
|
|
||||||
# Deprioritise against the render loop. Linux applies nice
|
|
||||||
# per-thread, and the heavy lifting here is PIL and numpy work
|
|
||||||
# that releases the GIL, so the scheduler can actually act on
|
|
||||||
# it — without this the prefetch competes for the same cores and
|
|
||||||
# costs frames.
|
|
||||||
try:
|
|
||||||
os.nice(10)
|
|
||||||
except (OSError, AttributeError):
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
group = self.stream_manager.take_next_group(offscreen_only=True)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Background prefetch failed")
|
|
||||||
group = []
|
|
||||||
with self._prefetch_lock:
|
|
||||||
self._prepared_group = group
|
|
||||||
|
|
||||||
self._prefetch_thread = threading.Thread(
|
|
||||||
target=_work, daemon=True, name="vegas-strip-prefetch")
|
|
||||||
self._prefetch_thread.start()
|
|
||||||
|
|
||||||
def drain_deferred(self) -> bool:
|
|
||||||
"""
|
|
||||||
Fetch one queued canvas-bound plugin and append it to the strip.
|
|
||||||
|
|
||||||
Called once per frame. These plugins cannot be prepared off the render
|
|
||||||
thread — display capture and scroll-content generation both need the
|
|
||||||
shared canvas — so each costs roughly 290ms here. Doing one at a time
|
|
||||||
spreads that out instead of stalling for the whole group at once, and the
|
|
||||||
strip's lookahead means nothing runs dry while they arrive.
|
|
||||||
|
|
||||||
The cost is that a deferred plugin appears slightly after the group it
|
|
||||||
came with, which is a fair trade for a smooth scroll.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if a plugin was appended
|
|
||||||
"""
|
|
||||||
if not self._deferred_queue:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Space the drains out. Each costs 40-600ms, and taking them back to
|
|
||||||
# back turns one long stall into a train of short ones — barely better.
|
|
||||||
# With a healthy lookahead there is no hurry, so wait a beat between
|
|
||||||
# them; when the strip is actually running short, fetch immediately.
|
|
||||||
threshold = int(self.display_width * self.config.extend_threshold_screens)
|
|
||||||
urgent = self.scroll_helper.remaining_unscrolled() <= threshold
|
|
||||||
if not urgent:
|
|
||||||
now = time.time()
|
|
||||||
if now - self._last_drain_time < self.DEFERRED_DRAIN_INTERVAL:
|
|
||||||
return False
|
|
||||||
self._last_drain_time = now
|
|
||||||
else:
|
|
||||||
self._last_drain_time = time.time()
|
|
||||||
|
|
||||||
plugin_id = self._deferred_queue.pop(0)
|
|
||||||
plugins = getattr(self.stream_manager.plugin_manager, 'plugins', {})
|
|
||||||
plugin = plugins.get(plugin_id)
|
|
||||||
if plugin is None:
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
images = self.stream_manager.plugin_adapter.get_content(plugin, plugin_id)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("[%s] Error fetching deferred content", plugin_id)
|
|
||||||
return False
|
|
||||||
|
|
||||||
if not images:
|
|
||||||
return False
|
|
||||||
|
|
||||||
appended = self.scroll_helper.append_content(
|
|
||||||
content_items=[self._join_plugin_rows(images)],
|
|
||||||
item_gap=self.config.separator_width,
|
|
||||||
element_gap=0,
|
|
||||||
)
|
|
||||||
if appended:
|
|
||||||
with self._buffer_lock:
|
|
||||||
self._active_scroll_image = self.scroll_helper.cached_image
|
|
||||||
logger.info(
|
|
||||||
"[%s] Appended deferred content: strip now %dpx, %dpx ahead",
|
|
||||||
plugin_id, self.scroll_helper.total_scroll_width,
|
|
||||||
self.scroll_helper.remaining_unscrolled()
|
|
||||||
)
|
|
||||||
return appended
|
|
||||||
|
|
||||||
def has_deferred(self) -> bool:
|
|
||||||
"""Whether any canvas-bound plugins are still queued."""
|
|
||||||
return bool(self._deferred_queue)
|
|
||||||
|
|
||||||
def _claim_prepared_group(self):
|
|
||||||
"""Take the prefetched group, if one is ready."""
|
|
||||||
with self._prefetch_lock:
|
|
||||||
group = self._prepared_group
|
|
||||||
self._prepared_group = None
|
|
||||||
return group
|
|
||||||
|
|
||||||
def extend_scroll_content(self) -> bool:
|
|
||||||
"""
|
|
||||||
Append the next group of plugins to the strip, without interrupting motion.
|
|
||||||
|
|
||||||
This is what replaces the swap. Scroll position is untouched, so the new
|
|
||||||
content simply arrives from the right; there is no substitution to see
|
|
||||||
and no restart with the viewport already full.
|
|
||||||
|
|
||||||
Consumed columns behind the viewport are then released, keeping the strip
|
|
||||||
bounded however long Vegas runs.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if the strip was extended
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
grouped = self._claim_prepared_group()
|
|
||||||
if grouped is None:
|
|
||||||
# Nothing prepared (first extension, or prefetch still running).
|
|
||||||
# Fetch inline; the scroll hitches, but content keeps flowing.
|
|
||||||
logger.info("No prepared group ready; fetching inline")
|
|
||||||
grouped = self.stream_manager.take_next_group()
|
|
||||||
|
|
||||||
if not grouped:
|
|
||||||
logger.warning("No content available to extend the scroll strip")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Plugins the background thread had to defer need the shared canvas,
|
|
||||||
# so they can only be fetched here. Queue them rather than doing all
|
|
||||||
# of them now: measured, six in one go held the render thread for
|
|
||||||
# 1.75s. They are trickled in one per frame by drain_deferred(),
|
|
||||||
# which the strip's lookahead comfortably absorbs.
|
|
||||||
deferred = [pid for pid, images in grouped if images is None]
|
|
||||||
if deferred:
|
|
||||||
self._deferred_queue.extend(deferred)
|
|
||||||
logger.info(
|
|
||||||
"Queued %d plugin(s) needing the render thread: %s",
|
|
||||||
len(deferred), ', '.join(deferred)
|
|
||||||
)
|
|
||||||
|
|
||||||
grouped = [(pid, imgs) for pid, imgs in grouped if imgs]
|
|
||||||
|
|
||||||
if not grouped:
|
|
||||||
# Everything in this group is queued; the queue will extend the
|
|
||||||
# strip as it drains, so this is not a failure.
|
|
||||||
logger.info("Whole group deferred; strip will extend as it drains")
|
|
||||||
self.start_prefetch()
|
|
||||||
return bool(deferred)
|
|
||||||
|
|
||||||
blocks = []
|
|
||||||
total_rows = 0
|
|
||||||
for _plugin_id, images in grouped:
|
|
||||||
total_rows += len(images)
|
|
||||||
blocks.append(self._join_plugin_rows(images))
|
|
||||||
|
|
||||||
appended = self.scroll_helper.append_content(
|
|
||||||
content_items=blocks,
|
|
||||||
item_gap=self.config.separator_width,
|
|
||||||
element_gap=0,
|
|
||||||
)
|
|
||||||
if not appended:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Keep a screen's worth behind the viewport as a safety margin.
|
|
||||||
self.scroll_helper.drop_scrolled_prefix(keep_before=self.display_width)
|
|
||||||
|
|
||||||
with self._buffer_lock:
|
|
||||||
self._active_scroll_image = self.scroll_helper.cached_image
|
|
||||||
|
|
||||||
self._segments_in_scroll = [pid for pid, _ in grouped]
|
|
||||||
self.stats['composition_count'] += 1
|
|
||||||
self.stats['extensions'] = self.stats.get('extensions', 0) + 1
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"Extended scroll strip with %d plugin block(s), %d rows: "
|
|
||||||
"strip now %dpx, %dpx still ahead of the viewport",
|
|
||||||
len(blocks), total_rows, self.scroll_helper.total_scroll_width,
|
|
||||||
self.scroll_helper.remaining_unscrolled()
|
|
||||||
)
|
|
||||||
|
|
||||||
# Line up the group after this one straight away, so it is ready
|
|
||||||
# well before the strip runs short again.
|
|
||||||
self.start_prefetch()
|
|
||||||
return True
|
|
||||||
|
|
||||||
except (ValueError, TypeError, OSError, RuntimeError):
|
|
||||||
logger.exception("Error extending scroll content")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _join_plugin_rows(self, images: List[Image.Image]) -> Image.Image:
|
|
||||||
"""
|
|
||||||
Concatenate one plugin's images into a single block.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
images: That plugin's content, in order
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
A single image with the rows laid out left to right, separated by
|
|
||||||
``intra_plugin_gap``. Returned unchanged when there is only one row,
|
|
||||||
which is the common case and avoids a pointless copy.
|
|
||||||
"""
|
|
||||||
if len(images) == 1:
|
|
||||||
return images[0]
|
|
||||||
|
|
||||||
floor = max(0, self.config.intra_plugin_gap)
|
|
||||||
target = max(0, self.config.min_content_separation)
|
|
||||||
threshold = self.config.trim_threshold
|
|
||||||
|
|
||||||
# Space by measured separation, not a flat gap. Rows drawn flush to
|
|
||||||
# their own edges (sports score cards) would otherwise end up nearly
|
|
||||||
# touching, while rows that already carry wide margins would be pushed
|
|
||||||
# needlessly further apart.
|
|
||||||
gaps = [
|
|
||||||
separation_gap(images[i], images[i + 1], target, floor, threshold)
|
|
||||||
for i in range(len(images) - 1)
|
|
||||||
]
|
|
||||||
|
|
||||||
width = sum(img.width for img in images) + sum(gaps)
|
|
||||||
height = max(img.height for img in images)
|
|
||||||
|
|
||||||
block = Image.new('RGB', (width, height), (0, 0, 0))
|
|
||||||
x = 0
|
|
||||||
for i, img in enumerate(images):
|
|
||||||
block.paste(img, (x, 0))
|
|
||||||
x += img.width + (gaps[i] if i < len(gaps) else 0)
|
|
||||||
return block
|
|
||||||
|
|
||||||
def render_frame(self) -> bool:
|
def render_frame(self) -> bool:
|
||||||
"""
|
"""
|
||||||
Render a single frame to the display.
|
Render a single frame to the display.
|
||||||
@@ -499,33 +211,21 @@ class RenderPipeline:
|
|||||||
|
|
||||||
# Determine if the cycle is done.
|
# Determine if the cycle is done.
|
||||||
#
|
#
|
||||||
# get_visible_portion wraps: once scroll_position + display_width
|
# scroll_helper considers a cycle complete only after
|
||||||
# passes the end of the strip it fills the right-hand side of the
|
# total_distance_scrolled >= total_scroll_width + display_width.
|
||||||
# frame from the *head* of the same strip. So the last
|
# That extra display_width of travel causes a "wrap-around" phase
|
||||||
# display_width of travel shows the cycle's first plugin re-entering
|
# where scroll_position resets to ~0 and the first plugin's content
|
||||||
# on the right while its last plugin exits on the left, and the
|
# re-enters from the right — the user sees this 2-3 s of re-entry
|
||||||
# recompose that follows then replaces both at once. That reads as
|
# as "a plugin partially displaying before the next one starts."
|
||||||
# the ticker "switching mid-scroll".
|
|
||||||
#
|
#
|
||||||
# This used to be hidden because the strip began with a full
|
# We end the cycle as soon as total_distance_scrolled reaches
|
||||||
# display_width of blank, so the wrapped-in region was black.
|
# total_scroll_width (the wrap-around point), before any second-pass
|
||||||
# lead_in_width now defaults to 0 (that blank was 10s of dead panel
|
# content becomes visible. The scroll_helper's own is_scroll_complete()
|
||||||
# at 50px/s), which exposed the wrap — so the cycle has to end
|
# check is kept as a fallback for any edge-cases where that threshold
|
||||||
# before it, one display width earlier.
|
# is never hit.
|
||||||
#
|
|
||||||
# A strip no wider than the display never wraps, and subtracting
|
|
||||||
# would make the cycle complete instantly, so clamp in that case.
|
|
||||||
# In continuous mode there is no cycle to complete: the strip is
|
|
||||||
# extended before the scroll can reach its end, so the wrap is never
|
|
||||||
# entered and motion never stops. The completion path below stays for
|
|
||||||
# the swap behaviour and as a backstop if an extension fails.
|
|
||||||
wrap_point = self.scroll_helper.total_scroll_width
|
|
||||||
if wrap_point > self.display_width:
|
|
||||||
wrap_point -= self.display_width
|
|
||||||
|
|
||||||
at_wrap_point = (
|
at_wrap_point = (
|
||||||
not self._cycle_complete and
|
not self._cycle_complete and
|
||||||
self.scroll_helper.total_distance_scrolled >= wrap_point
|
self.scroll_helper.total_distance_scrolled >= self.scroll_helper.total_scroll_width
|
||||||
)
|
)
|
||||||
|
|
||||||
if at_wrap_point or self.scroll_helper.is_scroll_complete():
|
if at_wrap_point or self.scroll_helper.is_scroll_complete():
|
||||||
@@ -536,17 +236,24 @@ class RenderPipeline:
|
|||||||
"Scroll cycle complete after %.1fs",
|
"Scroll cycle complete after %.1fs",
|
||||||
time.time() - self._cycle_start_time
|
time.time() - self._cycle_start_time
|
||||||
)
|
)
|
||||||
# Deliberately leave the last rendered frame on the panel.
|
# Push blank immediately so the hardware never shows any
|
||||||
#
|
# post-wrap content while the coordinator recomposes the
|
||||||
# This used to push a blank frame so no post-wrap content
|
# next cycle (~100 ms). The blank is allocated once and
|
||||||
# could be seen while the next cycle was composed. But
|
# reused across cycle wraps (fresh paste each time in case
|
||||||
# recomposing is synchronous and fetches plugin content:
|
# a consumer drew on the previous one).
|
||||||
# measured 84ms at best and 4.8s at worst on a 512px panel,
|
try:
|
||||||
# and every millisecond of it was black. Holding the last
|
if self._blank_frame is None or self._blank_frame.size != (
|
||||||
# frame instead turns that into a brief freeze, which reads
|
self.display_width, self.display_height):
|
||||||
# as far less broken than the display switching off. The
|
self._blank_frame = Image.new(
|
||||||
# frame is already past the end of the content, so there is
|
'RGB', (self.display_width, self.display_height))
|
||||||
# no second-pass content to leak.
|
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")
|
||||||
return True # Cycle done; coordinator starts new cycle next frame
|
return True # Cycle done; coordinator starts new cycle next frame
|
||||||
|
|
||||||
# Get visible portion
|
# Get visible portion
|
||||||
@@ -629,25 +336,6 @@ class RenderPipeline:
|
|||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def refresh_updated_plugins(self) -> bool:
|
|
||||||
"""
|
|
||||||
Let changed plugin data reach the strip without interrupting motion.
|
|
||||||
|
|
||||||
Used instead of :meth:`hot_swap_content` when scrolling continuously.
|
|
||||||
The swap rebuilds the whole image and repositions the scroll, which is
|
|
||||||
visible as a freeze and a jump; the strip is extended here rather than
|
|
||||||
replaced, so it is enough to drop the stale caches and let the plugin
|
|
||||||
recompose when it next comes round.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if any plugin's cached content was dropped.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return bool(self.stream_manager.invalidate_pending_updates())
|
|
||||||
except Exception: # pylint: disable=broad-except
|
|
||||||
logger.exception("Failed to refresh updated plugins")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def hot_swap_content(self) -> bool:
|
def hot_swap_content(self) -> bool:
|
||||||
"""
|
"""
|
||||||
Hot-swap to new composed content.
|
Hot-swap to new composed content.
|
||||||
@@ -727,12 +415,11 @@ class RenderPipeline:
|
|||||||
result = self.compose_scroll_content()
|
result = self.compose_scroll_content()
|
||||||
|
|
||||||
if result and self.sync_manager:
|
if result and self.sync_manager:
|
||||||
# When sync is active, start the leader past the lead-in gap so it
|
# When sync is active, start the leader at display_width instead of 0.
|
||||||
# immediately shows content, leaving the follower on the blank gap
|
# This skips the initial black gap so the leader immediately shows content.
|
||||||
# for a clean transition rather than near-end content wrapping
|
# The follower starts at position 0 (the gap) which looks like a clean
|
||||||
# around. This tracks lead_in_width rather than assuming a full
|
# blank transition rather than near-end content wrapping around.
|
||||||
# display width of gap, which is no longer the default.
|
self.scroll_helper.scroll_position = float(self.display_width)
|
||||||
self.scroll_helper.scroll_position = float(self.config.lead_in_width)
|
|
||||||
|
|
||||||
if result and self.sync_manager:
|
if result and self.sync_manager:
|
||||||
# Signal follower that a new cycle started (triggers its own rebuild)
|
# Signal follower that a new cycle started (triggers its own rebuild)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ Supports three display modes:
|
|||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from typing import Optional, List, Dict, Any, Deque, Tuple, TYPE_CHECKING
|
from typing import Optional, List, Dict, Any, Deque, TYPE_CHECKING
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
@@ -116,11 +116,8 @@ class StreamManager:
|
|||||||
logger.warning("No plugins available for Vegas scroll")
|
logger.warning("No plugins available for Vegas scroll")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Fill the buffer to a whole cycle's worth of plugins. This used to be
|
# Prefetch initial content
|
||||||
# buffer_ahead + 1, which conflated prefetch depth with cycle size and
|
self._prefetch_content(count=min(self.config.buffer_ahead + 1, len(self._ordered_plugins)))
|
||||||
# meant a 20-plugin install only showed 3 plugins before recomposing.
|
|
||||||
self._prefetch_content(
|
|
||||||
count=min(self.config.plugins_per_cycle, len(self._ordered_plugins)))
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"StreamManager initialized with %d plugins, %d segments buffered",
|
"StreamManager initialized with %d plugins, %d segments buffered",
|
||||||
@@ -201,47 +198,6 @@ class StreamManager:
|
|||||||
|
|
||||||
logger.debug("Plugin %s marked for update", plugin_id)
|
logger.debug("Plugin %s marked for update", plugin_id)
|
||||||
|
|
||||||
def invalidate_pending_updates(self) -> List[str]:
|
|
||||||
"""
|
|
||||||
Drop cached content for plugins whose data changed, without refetching.
|
|
||||||
|
|
||||||
The continuous-scroll counterpart to :meth:`process_updates`. That method
|
|
||||||
belongs to the swap path: it refetches immediately and merges into the
|
|
||||||
active buffer, which continuous mode bypasses entirely, and doing that
|
|
||||||
work on the render thread would hitch the scroll.
|
|
||||||
|
|
||||||
Here it is enough to clear the caches and let the plugin come round in
|
|
||||||
the rotation, which recomposes it from current data a moment later. Left
|
|
||||||
uncalled, ``_pending_updates`` simply accumulates and no visual ever
|
|
||||||
refreshes — a game that was live last night keeps being drawn as live.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The plugin ids whose caches were dropped.
|
|
||||||
"""
|
|
||||||
with self._buffer_lock:
|
|
||||||
if not self._pending_updates:
|
|
||||||
return []
|
|
||||||
updated = list(self._pending_updates.keys())
|
|
||||||
self._pending_updates.clear()
|
|
||||||
|
|
||||||
plugins = getattr(self.plugin_manager, 'plugins', {})
|
|
||||||
for plugin_id in updated:
|
|
||||||
try:
|
|
||||||
self.plugin_adapter.invalidate_cache(plugin_id)
|
|
||||||
plugin = plugins.get(plugin_id)
|
|
||||||
if plugin is not None:
|
|
||||||
self.plugin_adapter.invalidate_plugin_scroll_cache(
|
|
||||||
plugin, plugin_id)
|
|
||||||
except Exception: # pylint: disable=broad-except
|
|
||||||
logger.exception(
|
|
||||||
"[%s] Could not invalidate cached content", plugin_id)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"Vegas: dropped cached content for %d updated plugin(s): %s",
|
|
||||||
len(updated), ', '.join(updated)
|
|
||||||
)
|
|
||||||
return updated
|
|
||||||
|
|
||||||
def has_pending_updates(self) -> bool:
|
def has_pending_updates(self) -> bool:
|
||||||
"""Check if any plugins have pending updates awaiting processing."""
|
"""Check if any plugins have pending updates awaiting processing."""
|
||||||
with self._buffer_lock:
|
with self._buffer_lock:
|
||||||
@@ -429,7 +385,7 @@ class StreamManager:
|
|||||||
return
|
return
|
||||||
|
|
||||||
for _ in range(count):
|
for _ in range(count):
|
||||||
if len(self._active_buffer) >= self.config.plugins_per_cycle:
|
if len(self._active_buffer) >= self.config.buffer_ahead + 1:
|
||||||
break
|
break
|
||||||
|
|
||||||
# Ensure index is valid (guard against empty list)
|
# Ensure index is valid (guard against empty list)
|
||||||
@@ -565,117 +521,28 @@ class StreamManager:
|
|||||||
logger.debug("Refreshed content for %s in staging buffer", plugin_id)
|
logger.debug("Refreshed content for %s in staging buffer", plugin_id)
|
||||||
|
|
||||||
def _ensure_buffer_filled(self) -> None:
|
def _ensure_buffer_filled(self) -> None:
|
||||||
"""
|
"""Ensure buffer has enough content prefetched."""
|
||||||
Top the buffer back up after segments have been served.
|
if len(self._active_buffer) < self.config.buffer_ahead:
|
||||||
|
needed = self.config.buffer_ahead - len(self._active_buffer)
|
||||||
buffer_ahead is the low-water mark only; plugins_per_cycle is the
|
self._prefetch_content(count=needed)
|
||||||
ceiling and is enforced inside _prefetch_content.
|
|
||||||
"""
|
|
||||||
low_water = min(self.config.buffer_ahead, self.config.plugins_per_cycle)
|
|
||||||
if len(self._active_buffer) < low_water:
|
|
||||||
self._prefetch_content(count=low_water - len(self._active_buffer))
|
|
||||||
|
|
||||||
def get_all_content_for_composition(self) -> List[Image.Image]:
|
def get_all_content_for_composition(self) -> List[Image.Image]:
|
||||||
"""
|
"""
|
||||||
Get all buffered content as a flat list of images.
|
Get all buffered content as a flat list of images.
|
||||||
|
|
||||||
|
Used when composing the full scroll image.
|
||||||
Skips STATIC segments as they don't have images to compose.
|
Skips STATIC segments as they don't have images to compose.
|
||||||
|
|
||||||
Prefer get_grouped_content_for_composition(): flattening loses the
|
|
||||||
plugin boundaries, which is what tells the compositor where a
|
|
||||||
separator belongs and where it does not.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of all images in buffer order
|
List of all images in buffer order
|
||||||
"""
|
"""
|
||||||
all_images = []
|
all_images = []
|
||||||
for _plugin_id, images in self.get_grouped_content_for_composition():
|
|
||||||
all_images.extend(images)
|
|
||||||
return all_images
|
|
||||||
|
|
||||||
def get_grouped_content_for_composition(self) -> List[Tuple[str, List[Image.Image]]]:
|
|
||||||
"""
|
|
||||||
Get buffered content grouped by the plugin that produced it.
|
|
||||||
|
|
||||||
The grouping matters: separator_width is meant to mark the handoff from
|
|
||||||
one plugin to the next, not to sit between every row a single plugin
|
|
||||||
contributes. A per-row ticker like the F1 scoreboard returns over a
|
|
||||||
hundred images that it renders 4px apart internally, so flattening them
|
|
||||||
into one list and applying a uniform gap forced 32px between each of
|
|
||||||
its rows — both inconsistent with how the plugin looks standalone, and
|
|
||||||
a large hidden addition to the width it occupies.
|
|
||||||
|
|
||||||
Skips STATIC segments, which trigger a pause rather than contributing
|
|
||||||
scroll content, and segments left with no images.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of (plugin_id, images) in buffer order
|
|
||||||
"""
|
|
||||||
grouped: List[Tuple[str, List[Image.Image]]] = []
|
|
||||||
with self._buffer_lock:
|
with self._buffer_lock:
|
||||||
for segment in self._active_buffer:
|
for segment in self._active_buffer:
|
||||||
if segment.display_mode == VegasDisplayMode.STATIC:
|
# Skip STATIC segments - they trigger pauses, not scroll content
|
||||||
continue
|
if segment.display_mode != VegasDisplayMode.STATIC:
|
||||||
if not segment.images:
|
all_images.extend(segment.images)
|
||||||
continue
|
return all_images
|
||||||
grouped.append((segment.plugin_id, list(segment.images)))
|
|
||||||
return grouped
|
|
||||||
|
|
||||||
def take_next_group(
|
|
||||||
self, count: Optional[int] = None, offscreen_only: bool = False
|
|
||||||
) -> List[Tuple[str, Optional[List[Image.Image]]]]:
|
|
||||||
"""
|
|
||||||
Fetch and hand over the next slice of the rotation.
|
|
||||||
|
|
||||||
For continuous scrolling, where the strip is extended rather than
|
|
||||||
replaced. Advances the rotation index so plugins come round in order
|
|
||||||
across an unbroken strip, and bypasses the active buffer entirely — that
|
|
||||||
buffer exists to stage a *replacement* cycle, which continuous mode has
|
|
||||||
no use for.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
count: Number of plugins to gather, defaulting to plugins_per_cycle
|
|
||||||
offscreen_only: Only use content paths that avoid the shared display
|
|
||||||
canvas, for use off the render thread
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Ordered list of (plugin_id, images). ``images`` is None when the
|
|
||||||
plugin could not be served under ``offscreen_only``, so the caller
|
|
||||||
can fetch just those on the render thread while keeping the order.
|
|
||||||
"""
|
|
||||||
if count is None:
|
|
||||||
count = self.config.plugins_per_cycle
|
|
||||||
|
|
||||||
self.refresh()
|
|
||||||
|
|
||||||
with self._buffer_lock:
|
|
||||||
if not self._ordered_plugins:
|
|
||||||
return []
|
|
||||||
total = len(self._ordered_plugins)
|
|
||||||
ids = []
|
|
||||||
for _ in range(min(max(1, count), total)):
|
|
||||||
ids.append(self._ordered_plugins[self._prefetch_index])
|
|
||||||
self._prefetch_index = (self._prefetch_index + 1) % total
|
|
||||||
|
|
||||||
plugins = getattr(self.plugin_manager, 'plugins', {})
|
|
||||||
group: List[Tuple[str, Optional[List[Image.Image]]]] = []
|
|
||||||
|
|
||||||
for plugin_id in ids:
|
|
||||||
plugin = plugins.get(plugin_id)
|
|
||||||
if not plugin:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
images = self.plugin_adapter.get_content(
|
|
||||||
plugin, plugin_id, offscreen_only=offscreen_only)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("[%s] ERROR fetching content", plugin_id)
|
|
||||||
self.stats['fetch_errors'] += 1
|
|
||||||
continue
|
|
||||||
if images:
|
|
||||||
self.stats['segments_fetched'] += 1
|
|
||||||
group.append((plugin_id, images if images else None))
|
|
||||||
|
|
||||||
return group
|
|
||||||
|
|
||||||
def advance_cycle(self) -> None:
|
def advance_cycle(self) -> None:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,396 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for src.element_style — the shared per-element style resolver behind
|
|
||||||
the x-style-elements system.
|
|
||||||
|
|
||||||
The contract under test (defined by the plugin consumers: of-the-day,
|
|
||||||
ledmatrix-music, football-scoreboard):
|
|
||||||
|
|
||||||
- defaults_from_schema_file parses BOTH declaration forms — the compact
|
|
||||||
x-style-elements map and hand-written customization blocks.
|
|
||||||
- expand_style_elements turns an x-style-elements declaration into the full
|
|
||||||
per-element blocks (plus layout offsets) the web-UI form renders.
|
|
||||||
- A config value counts as user-forced only when it genuinely differs from
|
|
||||||
the schema default; untouched (or schema-default-populated) configs
|
|
||||||
resolve to EXACTLY the classic font/size/color, keeping rendering
|
|
||||||
byte-identical.
|
|
||||||
- style() never raises; malformed input degrades to the classic style.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from PIL import ImageFont
|
|
||||||
|
|
||||||
from src.element_style import (
|
|
||||||
ElementStyleResolver,
|
|
||||||
defaults_from_schema,
|
|
||||||
defaults_from_schema_file,
|
|
||||||
expand_style_elements,
|
|
||||||
load_font,
|
|
||||||
resolve_font_path,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Schema fixtures
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
# Compact declaration form (of-the-day's shape).
|
|
||||||
STYLE_ELEMENTS_SCHEMA = {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"enabled": {"type": "boolean", "default": False},
|
|
||||||
"customization": {
|
|
||||||
"type": "object",
|
|
||||||
"x-style-elements": {
|
|
||||||
"title_text": {
|
|
||||||
"title": "Title",
|
|
||||||
"font": {"default": "PressStart2P-Regular.ttf"},
|
|
||||||
"size": {"default": 8, "min": 4, "max": 16},
|
|
||||||
"color": {"default": [255, 255, 255]},
|
|
||||||
"offsets": True,
|
|
||||||
},
|
|
||||||
"body_text": {
|
|
||||||
"title": "Body Text",
|
|
||||||
"font": {"default": "4x6-font.ttf"},
|
|
||||||
"size": {"default": 6, "min": 4, "max": 12},
|
|
||||||
"color": {"default": [200, 200, 200]},
|
|
||||||
"offsets": True,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
# Manual declaration form (the scoreboards' / music's shape).
|
|
||||||
MANUAL_SCHEMA = {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"customization": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"status_text": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"font": {"type": "string",
|
|
||||||
"default": "4x6-font.ttf"},
|
|
||||||
"font_size": {"type": "integer", "default": 6},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"score_text": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"font": {"type": "string",
|
|
||||||
"default": "PressStart2P-Regular.ttf"},
|
|
||||||
"font_size": {"type": "integer", "default": 10},
|
|
||||||
"text_color": {"type": "array",
|
|
||||||
"default": [255, 255, 0]},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"layout": {"type": "object", "properties": {}},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def style_schema_path(tmp_path):
|
|
||||||
path = tmp_path / "config_schema.json"
|
|
||||||
path.write_text(json.dumps(STYLE_ELEMENTS_SCHEMA))
|
|
||||||
return str(path)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def manual_schema_path(tmp_path):
|
|
||||||
path = tmp_path / "config_schema.json"
|
|
||||||
path.write_text(json.dumps(MANUAL_SCHEMA))
|
|
||||||
return str(path)
|
|
||||||
|
|
||||||
|
|
||||||
def _resolver(config, schema_path):
|
|
||||||
return ElementStyleResolver(config, defaults_from_schema_file(schema_path))
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Schema parsing
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestDefaultsFromSchema:
|
|
||||||
def test_x_style_elements_defaults(self, style_schema_path):
|
|
||||||
defaults = defaults_from_schema_file(style_schema_path)
|
|
||||||
cust = defaults["customization"]
|
|
||||||
assert cust["title_text"] == {"font": "PressStart2P-Regular.ttf",
|
|
||||||
"font_size": 8,
|
|
||||||
"text_color": [255, 255, 255]}
|
|
||||||
assert cust["body_text"]["font_size"] == 6
|
|
||||||
assert cust["body_text"]["text_color"] == [200, 200, 200]
|
|
||||||
|
|
||||||
def test_manual_block_defaults(self, manual_schema_path):
|
|
||||||
defaults = defaults_from_schema_file(manual_schema_path)
|
|
||||||
cust = defaults["customization"]
|
|
||||||
assert cust["status_text"] == {"font": "4x6-font.ttf", "font_size": 6}
|
|
||||||
assert cust["score_text"]["text_color"] == [255, 255, 0]
|
|
||||||
assert "layout" not in cust
|
|
||||||
|
|
||||||
def test_missing_file_degrades_to_empty(self, tmp_path):
|
|
||||||
defaults = defaults_from_schema_file(str(tmp_path / "nope.json"))
|
|
||||||
assert defaults == {"customization": {}}
|
|
||||||
|
|
||||||
def test_malformed_file_degrades_to_empty(self, tmp_path):
|
|
||||||
path = tmp_path / "bad.json"
|
|
||||||
path.write_text("{not json")
|
|
||||||
assert defaults_from_schema_file(str(path)) == {"customization": {}}
|
|
||||||
|
|
||||||
def test_schema_without_customization(self):
|
|
||||||
assert defaults_from_schema({"properties": {}}) == {"customization": {}}
|
|
||||||
|
|
||||||
|
|
||||||
class TestExpandStyleElements:
|
|
||||||
def test_expansion_generates_blocks(self):
|
|
||||||
expanded = expand_style_elements(STYLE_ELEMENTS_SCHEMA)
|
|
||||||
cust = expanded["properties"]["customization"]["properties"]
|
|
||||||
title = cust["title_text"]
|
|
||||||
assert title["x-style-managed"] is True
|
|
||||||
assert title["properties"]["font"]["default"] == \
|
|
||||||
"PressStart2P-Regular.ttf"
|
|
||||||
assert title["properties"]["font_size"]["default"] == 8
|
|
||||||
assert title["properties"]["font_size"]["minimum"] == 4
|
|
||||||
assert title["properties"]["font_size"]["maximum"] == 16
|
|
||||||
assert cust["body_text"]["properties"]["text_color"]["default"] == \
|
|
||||||
[200, 200, 200]
|
|
||||||
|
|
||||||
def test_expansion_generates_layout_offsets(self):
|
|
||||||
expanded = expand_style_elements(STYLE_ELEMENTS_SCHEMA)
|
|
||||||
layout = expanded["properties"]["customization"]["properties"]["layout"]
|
|
||||||
assert "title_text" in layout["properties"]
|
|
||||||
offsets = layout["properties"]["body_text"]["properties"]
|
|
||||||
assert offsets["x_offset"]["default"] == 0
|
|
||||||
assert offsets["y_offset"]["default"] == 0
|
|
||||||
|
|
||||||
def test_input_schema_not_mutated(self):
|
|
||||||
before = json.dumps(STYLE_ELEMENTS_SCHEMA, sort_keys=True)
|
|
||||||
expand_style_elements(STYLE_ELEMENTS_SCHEMA)
|
|
||||||
assert json.dumps(STYLE_ELEMENTS_SCHEMA, sort_keys=True) == before
|
|
||||||
|
|
||||||
def test_no_declaration_returns_same_object(self):
|
|
||||||
assert expand_style_elements(MANUAL_SCHEMA) is MANUAL_SCHEMA
|
|
||||||
empty = {"properties": {}}
|
|
||||||
assert expand_style_elements(empty) is empty
|
|
||||||
|
|
||||||
def test_garbage_input_never_raises(self):
|
|
||||||
bad = {"properties": {"customization": {"x-style-elements": "nope"}}}
|
|
||||||
assert expand_style_elements(bad) is bad
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Classic identity: untouched configs resolve to the classic style
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestClassicIdentity:
|
|
||||||
def test_bare_config_resolves_classic(self, style_schema_path):
|
|
||||||
r = _resolver({}, style_schema_path)
|
|
||||||
style = r.style("title_text", classic_font="PressStart2P-Regular.ttf",
|
|
||||||
classic_size=8, classic_color=(255, 255, 255))
|
|
||||||
assert style.font_name == "PressStart2P-Regular.ttf"
|
|
||||||
assert style.font_size == 8
|
|
||||||
assert style.color == (255, 255, 255)
|
|
||||||
assert style.offset == (0, 0)
|
|
||||||
assert not style.user_forced
|
|
||||||
assert not style.user_forced_color
|
|
||||||
assert isinstance(style.font, ImageFont.FreeTypeFont)
|
|
||||||
assert style.font.size == 8
|
|
||||||
|
|
||||||
def test_schema_populated_config_is_not_an_override(self, style_schema_path):
|
|
||||||
# The web UI's save flow writes the full schema defaults into config
|
|
||||||
# on every save — that must not count as a user override.
|
|
||||||
config = {"customization": {
|
|
||||||
"title_text": {"font": "PressStart2P-Regular.ttf", "font_size": 8,
|
|
||||||
"text_color": [255, 255, 255]},
|
|
||||||
"layout": {"title_text": {"x_offset": 0, "y_offset": 0}},
|
|
||||||
}}
|
|
||||||
style = _resolver(config, style_schema_path).style(
|
|
||||||
"title_text", classic_font="PressStart2P-Regular.ttf",
|
|
||||||
classic_size=8, classic_color=(255, 255, 255))
|
|
||||||
assert not style.user_forced
|
|
||||||
assert not style.user_forced_color
|
|
||||||
assert style.font_size == 8
|
|
||||||
assert style.color == (255, 255, 255)
|
|
||||||
assert style.offset == (0, 0)
|
|
||||||
|
|
||||||
def test_schema_default_falls_back_to_classic_not_schema_font(
|
|
||||||
self, manual_schema_path):
|
|
||||||
# Classic values and schema defaults can legitimately differ
|
|
||||||
# (football's status_text: schema says 4x6, classic loader used
|
|
||||||
# PressStart). A schema-default config value must yield the CLASSIC
|
|
||||||
# font, byte-identical to the old loader.
|
|
||||||
config = {"customization": {"status_text": {"font": "4x6-font.ttf",
|
|
||||||
"font_size": 6}}}
|
|
||||||
style = _resolver(config, manual_schema_path).style(
|
|
||||||
"status_text", classic_font="PressStart2P-Regular.ttf",
|
|
||||||
classic_size=6)
|
|
||||||
assert not style.user_forced
|
|
||||||
assert style.font_name == "PressStart2P-Regular.ttf"
|
|
||||||
assert style.font_size == 6
|
|
||||||
|
|
||||||
def test_same_font_object_from_cache(self, style_schema_path):
|
|
||||||
r = _resolver({}, style_schema_path)
|
|
||||||
s1 = r.style("title_text", classic_font="PressStart2P-Regular.ttf",
|
|
||||||
classic_size=8)
|
|
||||||
s2 = ElementStyleResolver({}, {}).style(
|
|
||||||
"title_text", classic_font="PressStart2P-Regular.ttf",
|
|
||||||
classic_size=8)
|
|
||||||
assert s1.font is s2.font
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# User overrides engage
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestUserOverrides:
|
|
||||||
def test_font_override(self, style_schema_path):
|
|
||||||
config = {"customization": {"title_text": {"font": "4x6-font.ttf"}}}
|
|
||||||
style = _resolver(config, style_schema_path).style(
|
|
||||||
"title_text", classic_font="PressStart2P-Regular.ttf",
|
|
||||||
classic_size=8)
|
|
||||||
assert style.user_forced
|
|
||||||
assert style.font_name == "4x6-font.ttf"
|
|
||||||
assert style.font_size == 8 # size untouched -> classic
|
|
||||||
|
|
||||||
def test_size_override(self, style_schema_path):
|
|
||||||
config = {"customization": {"title_text": {
|
|
||||||
"font": "PressStart2P-Regular.ttf", "font_size": 16}}}
|
|
||||||
style = _resolver(config, style_schema_path).style(
|
|
||||||
"title_text", classic_font="PressStart2P-Regular.ttf",
|
|
||||||
classic_size=8)
|
|
||||||
assert style.user_forced
|
|
||||||
assert style.font_name == "PressStart2P-Regular.ttf"
|
|
||||||
assert style.font_size == 16
|
|
||||||
assert style.font.size == 16
|
|
||||||
|
|
||||||
def test_size_override_detected_vs_schema_default(self, manual_schema_path):
|
|
||||||
# font_size 8 differs from the schema default 6 -> forced.
|
|
||||||
config = {"customization": {"status_text": {"font": "4x6-font.ttf",
|
|
||||||
"font_size": 8}}}
|
|
||||||
style = _resolver(config, manual_schema_path).style(
|
|
||||||
"status_text", classic_font="PressStart2P-Regular.ttf",
|
|
||||||
classic_size=6)
|
|
||||||
assert style.user_forced
|
|
||||||
assert style.font_size == 8
|
|
||||||
|
|
||||||
def test_color_override(self, style_schema_path):
|
|
||||||
config = {"customization": {"title_text": {"text_color": [255, 0, 0]}}}
|
|
||||||
style = _resolver(config, style_schema_path).style(
|
|
||||||
"title_text", classic_font="PressStart2P-Regular.ttf",
|
|
||||||
classic_size=8, classic_color=(255, 255, 255))
|
|
||||||
assert style.user_forced_color
|
|
||||||
assert not style.user_forced
|
|
||||||
assert style.color == (255, 0, 0)
|
|
||||||
|
|
||||||
def test_offsets(self, style_schema_path):
|
|
||||||
config = {"customization": {"layout": {
|
|
||||||
"title_text": {"x_offset": 4, "y_offset": -2}}}}
|
|
||||||
r = _resolver(config, style_schema_path)
|
|
||||||
assert r.offset("title_text") == (4, -2)
|
|
||||||
assert r.offset("body_text") == (0, 0)
|
|
||||||
style = r.style("title_text", classic_font="PressStart2P-Regular.ttf",
|
|
||||||
classic_size=8)
|
|
||||||
assert style.offset == (4, -2)
|
|
||||||
|
|
||||||
def test_offset_value_arbitrary_axis_and_strings(self, style_schema_path):
|
|
||||||
# The scoreboards read non-standard axes (away_x_offset) and configs
|
|
||||||
# can carry numeric strings/floats.
|
|
||||||
config = {"customization": {"layout": {"records": {
|
|
||||||
"away_x_offset": "3", "home_x_offset": 2.7}}}}
|
|
||||||
r = _resolver(config, style_schema_path)
|
|
||||||
assert r.offset_value("records", "away_x_offset", 0) == 3
|
|
||||||
assert r.offset_value("records", "home_x_offset", 0) == 2
|
|
||||||
assert r.offset_value("records", "missing_axis", 5) == 5
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Defensive degradation
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestDegradation:
|
|
||||||
@pytest.mark.parametrize("config", [
|
|
||||||
None,
|
|
||||||
{"customization": "not a dict"},
|
|
||||||
{"customization": {"title_text": "not a dict"}},
|
|
||||||
{"customization": {"title_text": {"font": 42, "font_size": "huge",
|
|
||||||
"text_color": "red"}}},
|
|
||||||
{"customization": {"layout": {"title_text": {"x_offset": "junk"}}}},
|
|
||||||
])
|
|
||||||
def test_bad_config_degrades_to_classic(self, config, style_schema_path):
|
|
||||||
style = _resolver(config, style_schema_path).style(
|
|
||||||
"title_text", classic_font="PressStart2P-Regular.ttf",
|
|
||||||
classic_size=8, classic_color=(10, 20, 30))
|
|
||||||
assert not style.user_forced
|
|
||||||
assert not style.user_forced_color
|
|
||||||
assert style.font_name == "PressStart2P-Regular.ttf"
|
|
||||||
assert style.font_size == 8
|
|
||||||
assert style.color == (10, 20, 30)
|
|
||||||
assert style.offset == (0, 0)
|
|
||||||
|
|
||||||
def test_unknown_font_falls_back(self, style_schema_path):
|
|
||||||
config = {"customization": {"title_text": {"font": "no-such.ttf"}}}
|
|
||||||
style = _resolver(config, style_schema_path).style(
|
|
||||||
"title_text", classic_font="PressStart2P-Regular.ttf",
|
|
||||||
classic_size=8)
|
|
||||||
# The override IS honored as forced, but the face degrades safely.
|
|
||||||
assert style.user_forced
|
|
||||||
assert style.font is not None
|
|
||||||
|
|
||||||
def test_empty_defaults_treats_config_as_reference_to_classic(self):
|
|
||||||
# No schema defaults at all: a config value equal to the classic
|
|
||||||
# value is not forced; a different one is.
|
|
||||||
r = ElementStyleResolver(
|
|
||||||
{"customization": {"e": {"font": "4x6-font.ttf"}}}, {})
|
|
||||||
assert not r.style("e", classic_font="4x6-font.ttf",
|
|
||||||
classic_size=6).user_forced
|
|
||||||
assert r.style("e", classic_font="PressStart2P-Regular.ttf",
|
|
||||||
classic_size=6).user_forced
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Resolver plumbing the consumers rely on
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestResolverPlumbing:
|
|
||||||
def test_config_identity_exposed(self, style_schema_path):
|
|
||||||
# Consumers rebuild the resolver when the config dict is swapped:
|
|
||||||
# `resolver._config is not self.config`.
|
|
||||||
config = {"customization": {}}
|
|
||||||
r = _resolver(config, style_schema_path)
|
|
||||||
assert r._config is config
|
|
||||||
|
|
||||||
def test_font_path_resolution_is_cwd_independent(self, tmp_path,
|
|
||||||
monkeypatch):
|
|
||||||
monkeypatch.chdir(tmp_path) # no assets/fonts under cwd
|
|
||||||
path = resolve_font_path("PressStart2P-Regular.ttf")
|
|
||||||
assert path is not None and os.path.isfile(path)
|
|
||||||
font = load_font("PressStart2P-Regular.ttf", 8)
|
|
||||||
assert isinstance(font, ImageFont.FreeTypeFont)
|
|
||||||
|
|
||||||
def test_bdf_font_loads_as_freetype_face(self):
|
|
||||||
import freetype
|
|
||||||
font = load_font("5x7.bdf", 7)
|
|
||||||
assert isinstance(font, freetype.Face)
|
|
||||||
|
|
||||||
def test_schema_manager_expands_on_load(self, tmp_path):
|
|
||||||
# The web-UI form path: SchemaManager.load_schema serves the
|
|
||||||
# expanded schema so the style blocks actually appear in the UI.
|
|
||||||
from src.plugin_system.schema_manager import SchemaManager
|
|
||||||
plugin_dir = tmp_path / "plugins" / "styled"
|
|
||||||
plugin_dir.mkdir(parents=True)
|
|
||||||
(plugin_dir / "config_schema.json").write_text(
|
|
||||||
json.dumps(STYLE_ELEMENTS_SCHEMA))
|
|
||||||
(plugin_dir / "manifest.json").write_text(json.dumps({
|
|
||||||
"id": "styled", "config_schema": "config_schema.json"}))
|
|
||||||
manager = SchemaManager(plugins_dir=tmp_path / "plugins",
|
|
||||||
project_root=tmp_path)
|
|
||||||
schema = manager.load_schema("styled")
|
|
||||||
assert schema is not None
|
|
||||||
cust = schema["properties"]["customization"]["properties"]
|
|
||||||
assert cust["title_text"]["x-style-managed"] is True
|
|
||||||
assert "title_text" in cust["layout"]["properties"]
|
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
"""
|
||||||
|
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()
|
||||||
@@ -1,336 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for ScrollHelper's continuous-strip primitives.
|
|
||||||
|
|
||||||
append_content extends the strip to the right without disturbing motion, and
|
|
||||||
drop_scrolled_prefix reclaims what has already gone past. Together they let a
|
|
||||||
caller keep one endless strip instead of swapping a new one in, which is what
|
|
||||||
shows as a flash and a hard cut to already-full-screen content.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import pytest
|
|
||||||
from PIL import Image
|
|
||||||
|
|
||||||
from src.common.scroll_helper import ScrollHelper
|
|
||||||
from src.vegas_mode.geometry import column_has_ink
|
|
||||||
|
|
||||||
W, H = 128, 32
|
|
||||||
|
|
||||||
|
|
||||||
def helper():
|
|
||||||
return ScrollHelper(W, H)
|
|
||||||
|
|
||||||
|
|
||||||
def block(width, colour=(255, 255, 255), height=H):
|
|
||||||
return Image.new('RGB', (width, height), colour)
|
|
||||||
|
|
||||||
|
|
||||||
class TestAppendContent:
|
|
||||||
def test_first_append_builds_the_strip(self):
|
|
||||||
sh = helper()
|
|
||||||
assert sh.append_content([block(100)], item_gap=0)
|
|
||||||
assert sh.cached_image is not None
|
|
||||||
assert sh.total_scroll_width == sh.cached_image.width
|
|
||||||
|
|
||||||
def test_strip_grows_by_content_plus_gaps(self):
|
|
||||||
sh = helper()
|
|
||||||
sh.create_scrolling_image([block(100)], item_gap=0, element_gap=0, lead_gap=0)
|
|
||||||
assert sh.cached_image.width == 100
|
|
||||||
|
|
||||||
sh.append_content([block(50)], item_gap=10, element_gap=0)
|
|
||||||
# one leading gap of 10 then the 50px block
|
|
||||||
assert sh.cached_image.width == 160
|
|
||||||
assert sh.total_scroll_width == 160
|
|
||||||
|
|
||||||
def test_scroll_position_is_preserved(self):
|
|
||||||
sh = helper()
|
|
||||||
sh.create_scrolling_image([block(400)], item_gap=0, element_gap=0, lead_gap=0)
|
|
||||||
sh.scroll_position = 137.0
|
|
||||||
sh.total_distance_scrolled = 137.0
|
|
||||||
|
|
||||||
sh.append_content([block(200)], item_gap=16)
|
|
||||||
assert sh.scroll_position == 137.0
|
|
||||||
assert sh.total_distance_scrolled == 137.0
|
|
||||||
|
|
||||||
def test_appending_defers_completion(self):
|
|
||||||
sh = helper()
|
|
||||||
sh.create_scrolling_image([block(200)], item_gap=0, element_gap=0, lead_gap=0)
|
|
||||||
sh.scroll_complete = True
|
|
||||||
|
|
||||||
sh.append_content([block(200)], item_gap=0)
|
|
||||||
assert not sh.scroll_complete
|
|
||||||
assert sh.total_distance_scrolled < sh.total_scroll_width
|
|
||||||
|
|
||||||
def test_existing_pixels_are_untouched(self):
|
|
||||||
sh = helper()
|
|
||||||
original = block(80, (10, 200, 10))
|
|
||||||
sh.create_scrolling_image([original], item_gap=0, element_gap=0, lead_gap=0)
|
|
||||||
before = sh.cached_image.crop((0, 0, 80, H)).tobytes()
|
|
||||||
|
|
||||||
sh.append_content([block(40, (200, 10, 10))], item_gap=8)
|
|
||||||
assert sh.cached_image.crop((0, 0, 80, H)).tobytes() == before
|
|
||||||
|
|
||||||
def test_appended_content_sits_after_the_gap(self):
|
|
||||||
sh = helper()
|
|
||||||
sh.create_scrolling_image([block(50)], item_gap=0, element_gap=0, lead_gap=0)
|
|
||||||
sh.append_content([block(30)], item_gap=12)
|
|
||||||
|
|
||||||
ink = column_has_ink(sh.cached_image)
|
|
||||||
assert ink[:50].all()
|
|
||||||
assert not ink[50:62].any() # the 12px gap
|
|
||||||
assert ink[62:92].all()
|
|
||||||
|
|
||||||
def test_array_and_image_stay_consistent(self):
|
|
||||||
# get_visible_portion slices cached_array but bounds-checks against
|
|
||||||
# cached_image.width, so a mismatch corrupts frames.
|
|
||||||
sh = helper()
|
|
||||||
sh.create_scrolling_image([block(200)], item_gap=0, element_gap=0, lead_gap=0)
|
|
||||||
sh.append_content([block(100)], item_gap=8)
|
|
||||||
assert sh.cached_array.shape[1] == sh.cached_image.width
|
|
||||||
assert sh.cached_array.shape[0] == sh.cached_image.height
|
|
||||||
|
|
||||||
def test_visible_portion_still_renders_after_append(self):
|
|
||||||
sh = helper()
|
|
||||||
sh.create_scrolling_image([block(300)], item_gap=0, element_gap=0, lead_gap=0)
|
|
||||||
sh.append_content([block(300)], item_gap=8)
|
|
||||||
sh.scroll_position = 250.0
|
|
||||||
frame = sh.get_visible_portion()
|
|
||||||
assert frame is not None and frame.size == (W, H)
|
|
||||||
|
|
||||||
def test_empty_append_is_a_no_op(self):
|
|
||||||
sh = helper()
|
|
||||||
sh.create_scrolling_image([block(100)], item_gap=0, element_gap=0, lead_gap=0)
|
|
||||||
assert sh.append_content([]) is False
|
|
||||||
assert sh.cached_image.width == 100
|
|
||||||
|
|
||||||
def test_repeated_appends_accumulate(self):
|
|
||||||
sh = helper()
|
|
||||||
sh.append_content([block(100)], item_gap=0)
|
|
||||||
for _ in range(5):
|
|
||||||
sh.append_content([block(100)], item_gap=0)
|
|
||||||
assert sh.cached_image.width == 600
|
|
||||||
|
|
||||||
|
|
||||||
class TestDropScrolledPrefix:
|
|
||||||
def test_removes_consumed_columns(self):
|
|
||||||
sh = helper()
|
|
||||||
sh.create_scrolling_image([block(1000)], item_gap=0, element_gap=0, lead_gap=0)
|
|
||||||
sh.scroll_position = 500.0
|
|
||||||
sh.total_distance_scrolled = 500.0
|
|
||||||
|
|
||||||
removed = sh.drop_scrolled_prefix(keep_before=0)
|
|
||||||
assert removed == 500
|
|
||||||
assert sh.cached_image.width == 500
|
|
||||||
assert sh.scroll_position == 0.0
|
|
||||||
|
|
||||||
def test_keeps_the_requested_margin(self):
|
|
||||||
sh = helper()
|
|
||||||
sh.create_scrolling_image([block(1000)], item_gap=0, element_gap=0, lead_gap=0)
|
|
||||||
sh.scroll_position = 500.0
|
|
||||||
sh.drop_scrolled_prefix(keep_before=100)
|
|
||||||
assert sh.scroll_position == 100.0
|
|
||||||
assert sh.cached_image.width == 600
|
|
||||||
|
|
||||||
def test_completion_difference_is_preserved(self):
|
|
||||||
# total_distance_scrolled and total_scroll_width must shift together, or
|
|
||||||
# trimming would spuriously complete or un-complete the cycle.
|
|
||||||
sh = helper()
|
|
||||||
sh.create_scrolling_image([block(1000)], item_gap=0, element_gap=0, lead_gap=0)
|
|
||||||
sh.scroll_position = 600.0
|
|
||||||
sh.total_distance_scrolled = 600.0
|
|
||||||
before = sh.total_scroll_width - sh.total_distance_scrolled
|
|
||||||
|
|
||||||
sh.drop_scrolled_prefix(keep_before=0)
|
|
||||||
assert sh.total_scroll_width - sh.total_distance_scrolled == before
|
|
||||||
|
|
||||||
def test_never_trims_below_the_viewport(self):
|
|
||||||
sh = helper()
|
|
||||||
sh.create_scrolling_image([block(200)], item_gap=0, element_gap=0, lead_gap=0)
|
|
||||||
sh.scroll_position = 190.0
|
|
||||||
sh.drop_scrolled_prefix(keep_before=0)
|
|
||||||
assert sh.cached_image.width >= W
|
|
||||||
|
|
||||||
def test_no_op_before_anything_has_scrolled(self):
|
|
||||||
sh = helper()
|
|
||||||
sh.create_scrolling_image([block(500)], item_gap=0, element_gap=0, lead_gap=0)
|
|
||||||
assert sh.drop_scrolled_prefix(keep_before=0) == 0
|
|
||||||
assert sh.cached_image.width == 500
|
|
||||||
|
|
||||||
def test_no_op_with_no_strip(self):
|
|
||||||
assert helper().drop_scrolled_prefix() == 0
|
|
||||||
|
|
||||||
def test_visible_frame_is_unchanged_by_trimming(self):
|
|
||||||
# The whole point: trimming is invisible. Same pixels on screen before
|
|
||||||
# and after. Position chosen so the viewport is well clear of the end,
|
|
||||||
# i.e. not wrapping.
|
|
||||||
sh = helper()
|
|
||||||
items = [block(200, (255, 0, 0)), block(200, (0, 255, 0)),
|
|
||||||
block(200, (0, 0, 255))]
|
|
||||||
sh.create_scrolling_image(items, item_gap=20, element_gap=0, lead_gap=0)
|
|
||||||
sh.scroll_position = 300.0
|
|
||||||
before = sh.get_visible_portion().tobytes()
|
|
||||||
|
|
||||||
assert sh.drop_scrolled_prefix(keep_before=0) > 0, "trim should have run"
|
|
||||||
after = sh.get_visible_portion().tobytes()
|
|
||||||
assert after == before
|
|
||||||
|
|
||||||
def test_refuses_to_trim_while_the_viewport_wraps(self):
|
|
||||||
# Wrapping reads the head of the strip into the right of the frame, so
|
|
||||||
# trimming the head there would visibly change the picture.
|
|
||||||
sh = helper()
|
|
||||||
sh.create_scrolling_image([block(200)], item_gap=0, element_gap=0, lead_gap=0)
|
|
||||||
sh.scroll_position = 150.0 # 150 + 128 > 200, so wrapping
|
|
||||||
before = sh.get_visible_portion().tobytes()
|
|
||||||
assert sh.drop_scrolled_prefix(keep_before=0) == 0
|
|
||||||
assert sh.get_visible_portion().tobytes() == before
|
|
||||||
|
|
||||||
def test_array_and_image_stay_consistent_after_trim(self):
|
|
||||||
sh = helper()
|
|
||||||
sh.create_scrolling_image([block(900)], item_gap=0, element_gap=0, lead_gap=0)
|
|
||||||
sh.scroll_position = 400.0
|
|
||||||
sh.drop_scrolled_prefix(keep_before=0)
|
|
||||||
assert sh.cached_array.shape[1] == sh.cached_image.width
|
|
||||||
|
|
||||||
|
|
||||||
class TestRemainingUnscrolled:
|
|
||||||
def test_counts_content_right_of_the_viewport(self):
|
|
||||||
sh = helper()
|
|
||||||
sh.create_scrolling_image([block(500)], item_gap=0, element_gap=0, lead_gap=0)
|
|
||||||
assert sh.remaining_unscrolled() == 500 - W
|
|
||||||
|
|
||||||
def test_shrinks_as_the_strip_scrolls(self):
|
|
||||||
sh = helper()
|
|
||||||
sh.create_scrolling_image([block(500)], item_gap=0, element_gap=0, lead_gap=0)
|
|
||||||
sh.scroll_position = 200.0
|
|
||||||
assert sh.remaining_unscrolled() == 500 - 200 - W
|
|
||||||
|
|
||||||
def test_never_negative(self):
|
|
||||||
sh = helper()
|
|
||||||
sh.create_scrolling_image([block(200)], item_gap=0, element_gap=0, lead_gap=0)
|
|
||||||
sh.scroll_position = 500.0
|
|
||||||
assert sh.remaining_unscrolled() == 0
|
|
||||||
|
|
||||||
def test_zero_with_no_strip(self):
|
|
||||||
assert helper().remaining_unscrolled() == 0
|
|
||||||
|
|
||||||
def test_grows_when_content_is_appended(self):
|
|
||||||
sh = helper()
|
|
||||||
sh.create_scrolling_image([block(600)], item_gap=0, element_gap=0, lead_gap=0)
|
|
||||||
sh.scroll_position = 100.0
|
|
||||||
before = sh.remaining_unscrolled()
|
|
||||||
assert before > 0, "fixture should leave content ahead of the viewport"
|
|
||||||
sh.append_content([block(400)], item_gap=0)
|
|
||||||
assert sh.remaining_unscrolled() == before + 400
|
|
||||||
|
|
||||||
|
|
||||||
class TestContinuousScrollingEndToEnd:
|
|
||||||
def test_strip_can_be_extended_indefinitely_at_bounded_size(self):
|
|
||||||
"""The invariant that makes this viable: extend + trim keeps the strip
|
|
||||||
bounded while motion never stops."""
|
|
||||||
sh = helper()
|
|
||||||
sh.create_scrolling_image([block(600)], item_gap=0, element_gap=0, lead_gap=0)
|
|
||||||
|
|
||||||
widths = []
|
|
||||||
for _ in range(20):
|
|
||||||
sh.scroll_position += 200
|
|
||||||
sh.total_distance_scrolled += 200
|
|
||||||
if sh.remaining_unscrolled() < 2 * W:
|
|
||||||
sh.append_content([block(600)], item_gap=16)
|
|
||||||
sh.drop_scrolled_prefix(keep_before=W)
|
|
||||||
widths.append(sh.cached_image.width)
|
|
||||||
# A frame must always be renderable.
|
|
||||||
assert sh.get_visible_portion() is not None
|
|
||||||
|
|
||||||
assert max(widths) < 3000, f"strip grew unbounded: max {max(widths)}"
|
|
||||||
assert not sh.scroll_complete, "continuous strip should never complete"
|
|
||||||
|
|
||||||
|
|
||||||
class TestSubPixelBlending:
|
|
||||||
"""
|
|
||||||
Integer positioning quantises motion to whole pixels, so distinct frames per
|
|
||||||
second equals scroll speed regardless of frame rate — at 50px/s and 78fps,
|
|
||||||
36% of frames were identical. Blending between neighbouring positions gives
|
|
||||||
motion at the frame rate instead.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def _strip(self, width=2000):
|
|
||||||
rng = np.random.default_rng(0)
|
|
||||||
arr = (rng.random((H, width, 3)) * 255).astype(np.uint8)
|
|
||||||
sh = helper()
|
|
||||||
sh.create_scrolling_image([Image.fromarray(arr)],
|
|
||||||
item_gap=0, element_gap=0, lead_gap=0)
|
|
||||||
return sh
|
|
||||||
|
|
||||||
def _frame(self, sh, pos, subpixel):
|
|
||||||
sh.sub_pixel_scrolling = subpixel
|
|
||||||
sh.scroll_position = pos
|
|
||||||
return np.asarray(sh.get_visible_portion()).astype(int)
|
|
||||||
|
|
||||||
def test_integer_mode_ignores_the_fraction(self):
|
|
||||||
sh = self._strip()
|
|
||||||
a = self._frame(sh, 500.0, False)
|
|
||||||
b = self._frame(sh, 500.9, False)
|
|
||||||
assert np.array_equal(a, b), "integer positioning should not move sub-pixel"
|
|
||||||
|
|
||||||
def test_blending_moves_within_a_pixel(self):
|
|
||||||
sh = self._strip()
|
|
||||||
a = self._frame(sh, 500.0, True)
|
|
||||||
b = self._frame(sh, 500.5, True)
|
|
||||||
assert not np.array_equal(a, b)
|
|
||||||
|
|
||||||
def test_zero_fraction_matches_the_integer_frame(self):
|
|
||||||
# No interpolation to do, so it must be pixel-identical and take the
|
|
||||||
# cheap path.
|
|
||||||
sh = self._strip()
|
|
||||||
assert np.array_equal(self._frame(sh, 700.0, True),
|
|
||||||
self._frame(sh, 700.0, False))
|
|
||||||
|
|
||||||
def test_blend_is_monotonic_between_neighbours(self):
|
|
||||||
# Marching the fraction from 0 to 1 should approach the next integer
|
|
||||||
# frame, not wander.
|
|
||||||
sh = self._strip()
|
|
||||||
target = self._frame(sh, 501.0, False)
|
|
||||||
dists = []
|
|
||||||
for frac in (0.0, 0.25, 0.5, 0.75):
|
|
||||||
f = self._frame(sh, 500.0 + frac, True)
|
|
||||||
dists.append(np.abs(f - target).mean())
|
|
||||||
assert dists == sorted(dists, reverse=True), f"not converging: {dists}"
|
|
||||||
|
|
||||||
def test_blend_endpoints_bracket_the_two_frames(self):
|
|
||||||
sh = self._strip()
|
|
||||||
near = self._frame(sh, 500.0, False)
|
|
||||||
far = self._frame(sh, 501.0, False)
|
|
||||||
mid = self._frame(sh, 500.5, True)
|
|
||||||
# Every blended pixel must lie between its two sources.
|
|
||||||
lo = np.minimum(near, far)
|
|
||||||
hi = np.maximum(near, far)
|
|
||||||
assert (mid >= lo - 1).all() and (mid <= hi + 1).all()
|
|
||||||
|
|
||||||
def test_output_size_and_mode_are_unchanged(self):
|
|
||||||
sh = self._strip()
|
|
||||||
sh.sub_pixel_scrolling = True
|
|
||||||
sh.scroll_position = 300.4
|
|
||||||
frame = sh.get_visible_portion()
|
|
||||||
assert frame.size == (W, H)
|
|
||||||
assert frame.mode == 'RGB'
|
|
||||||
|
|
||||||
def test_works_near_the_end_of_the_strip(self):
|
|
||||||
# One of the two slices wraps here; must not raise or missize.
|
|
||||||
sh = self._strip(width=600)
|
|
||||||
sh.sub_pixel_scrolling = True
|
|
||||||
sh.scroll_position = float(600 - W // 2) + 0.5
|
|
||||||
frame = sh.get_visible_portion()
|
|
||||||
assert frame is not None and frame.size == (W, H)
|
|
||||||
|
|
||||||
def test_works_at_the_very_last_column(self):
|
|
||||||
sh = self._strip(width=600)
|
|
||||||
sh.sub_pixel_scrolling = True
|
|
||||||
sh.scroll_position = 599.5
|
|
||||||
assert sh.get_visible_portion().size == (W, H)
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("frac", [0.01, 0.1, 0.33, 0.5, 0.67, 0.9, 0.99])
|
|
||||||
def test_never_raises_across_the_fraction_range(self, frac):
|
|
||||||
sh = self._strip()
|
|
||||||
sh.sub_pixel_scrolling = True
|
|
||||||
sh.scroll_position = 400.0 + frac
|
|
||||||
assert sh.get_visible_portion().size == (W, H)
|
|
||||||
@@ -1,452 +0,0 @@
|
|||||||
"""Tests for the skin system: discovery, version gating, fallback
|
|
||||||
semantics, module isolation, and the view-model contract."""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from PIL import Image, ImageFont
|
|
||||||
|
|
||||||
# src.base_classes.sports transitively imports the hardware matrix driver;
|
|
||||||
# stub it so the fallback-semantics tests can import SportsCore off-device.
|
|
||||||
sys.modules.setdefault("rgbmatrix", MagicMock())
|
|
||||||
|
|
||||||
from src.skin_system import skin_runtime
|
|
||||||
from src.skin_system.skin_base import (
|
|
||||||
SKIN_API_VERSION,
|
|
||||||
ScoreboardSkin,
|
|
||||||
SkinContext,
|
|
||||||
)
|
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
FIXTURES_DIR = PROJECT_ROOT / "src" / "skin_system" / "fixtures"
|
|
||||||
|
|
||||||
# The v1.0 guaranteed view-model keys (docs/CREATING_SKINS.md). Renaming
|
|
||||||
# or removing any of these is a breaking change to every published skin:
|
|
||||||
# it requires a VIEW_MODEL_VERSION major bump and a compat shim.
|
|
||||||
GUARANTEED_KEYS = [
|
|
||||||
"id", "game_time", "game_date", "start_time_utc", "status_text",
|
|
||||||
"is_live", "is_final", "is_upcoming", "is_halftime",
|
|
||||||
"home_abbr", "home_id", "home_score", "home_logo_path", "home_record",
|
|
||||||
"away_abbr", "away_id", "away_score", "away_logo_path", "away_record",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def write_skin(skins_dir: Path, skin_id: str, *, api_version: str = SKIN_API_VERSION,
|
|
||||||
body: str = None, extra_files: dict = None,
|
|
||||||
class_name: str = "TestSkin") -> Path:
|
|
||||||
skin_dir = skins_dir / skin_id
|
|
||||||
skin_dir.mkdir(parents=True)
|
|
||||||
manifest = {
|
|
||||||
"id": skin_id, "name": skin_id, "version": "1.0.0",
|
|
||||||
"skin_api_version": api_version, "class_name": class_name,
|
|
||||||
"targets": {"sports": ["baseball"]},
|
|
||||||
}
|
|
||||||
(skin_dir / "skin.json").write_text(json.dumps(manifest))
|
|
||||||
if body is None:
|
|
||||||
body = (
|
|
||||||
"from src.skin_system.skin_base import ScoreboardSkin\n"
|
|
||||||
f"class {class_name}(ScoreboardSkin):\n"
|
|
||||||
" def render_live(self, ctx, game):\n"
|
|
||||||
" ctx.draw.rectangle([0, 0, 4, 4], fill=(255, 0, 0))\n"
|
|
||||||
" return True\n"
|
|
||||||
)
|
|
||||||
(skin_dir / "skin.py").write_text(body)
|
|
||||||
for name, content in (extra_files or {}).items():
|
|
||||||
(skin_dir / name).write_text(content)
|
|
||||||
return skin_dir
|
|
||||||
|
|
||||||
|
|
||||||
class TestDiscovery:
|
|
||||||
def test_discovers_valid_skin(self, tmp_path):
|
|
||||||
write_skin(tmp_path, "my-skin")
|
|
||||||
skins = skin_runtime.discover_skins(tmp_path, force_refresh=True)
|
|
||||||
assert "my-skin" in skins
|
|
||||||
assert skins["my-skin"]["_skin_dir"].endswith("my-skin")
|
|
||||||
|
|
||||||
def test_skips_manifest_missing_required_fields(self, tmp_path):
|
|
||||||
skin_dir = tmp_path / "broken"
|
|
||||||
skin_dir.mkdir()
|
|
||||||
(skin_dir / "skin.json").write_text(json.dumps({"id": "broken"}))
|
|
||||||
assert skin_runtime.discover_skins(tmp_path, force_refresh=True) == {}
|
|
||||||
|
|
||||||
def test_skips_unreadable_manifest_and_non_skin_dirs(self, tmp_path):
|
|
||||||
(tmp_path / "not-a-skin").mkdir()
|
|
||||||
bad = tmp_path / "bad-json"
|
|
||||||
bad.mkdir()
|
|
||||||
(bad / "skin.json").write_text("{nope")
|
|
||||||
write_skin(tmp_path, "good-skin")
|
|
||||||
skins = skin_runtime.discover_skins(tmp_path, force_refresh=True)
|
|
||||||
assert list(skins) == ["good-skin"]
|
|
||||||
|
|
||||||
def test_missing_directory_is_empty(self, tmp_path):
|
|
||||||
assert skin_runtime.discover_skins(tmp_path / "nope") == {}
|
|
||||||
|
|
||||||
def test_example_skin_in_repo_is_discoverable(self):
|
|
||||||
skins = skin_runtime.discover_skins(force_refresh=True)
|
|
||||||
assert "example-classic-baseball" in skins
|
|
||||||
|
|
||||||
|
|
||||||
class TestLoadSkin:
|
|
||||||
def test_loads_and_instantiates(self, tmp_path):
|
|
||||||
write_skin(tmp_path, "my-skin")
|
|
||||||
skin = skin_runtime.load_skin("my-skin", sport="baseball",
|
|
||||||
skins_dir=tmp_path)
|
|
||||||
assert isinstance(skin, ScoreboardSkin)
|
|
||||||
|
|
||||||
def test_unknown_skin_returns_none(self, tmp_path):
|
|
||||||
assert skin_runtime.load_skin("ghost", skins_dir=tmp_path) is None
|
|
||||||
|
|
||||||
def test_api_major_mismatch_is_refused(self, tmp_path):
|
|
||||||
write_skin(tmp_path, "old-skin", api_version="99.0.0")
|
|
||||||
assert skin_runtime.load_skin("old-skin", skins_dir=tmp_path) is None
|
|
||||||
|
|
||||||
def test_target_mismatch_still_loads(self, tmp_path):
|
|
||||||
write_skin(tmp_path, "my-skin") # targets baseball
|
|
||||||
skin = skin_runtime.load_skin("my-skin", sport="hockey",
|
|
||||||
skins_dir=tmp_path)
|
|
||||||
assert skin is not None # soft warning, not a hard block
|
|
||||||
|
|
||||||
def test_import_error_returns_none(self, tmp_path):
|
|
||||||
write_skin(tmp_path, "crashy", body="raise RuntimeError('boom')\n")
|
|
||||||
assert skin_runtime.load_skin("crashy", skins_dir=tmp_path) is None
|
|
||||||
|
|
||||||
def test_wrong_class_returns_none(self, tmp_path):
|
|
||||||
write_skin(tmp_path, "classless", body="x = 1\n")
|
|
||||||
assert skin_runtime.load_skin("classless", skins_dir=tmp_path) is None
|
|
||||||
|
|
||||||
def test_options_are_passed_through(self, tmp_path):
|
|
||||||
write_skin(tmp_path, "my-skin")
|
|
||||||
skin = skin_runtime.load_skin("my-skin", skins_dir=tmp_path,
|
|
||||||
options={"accent": [1, 2, 3]})
|
|
||||||
assert skin.options == {"accent": [1, 2, 3]}
|
|
||||||
|
|
||||||
def test_sibling_modules_are_isolated_between_skins(self, tmp_path):
|
|
||||||
helper = "VALUE = {!r}\n"
|
|
||||||
body = (
|
|
||||||
"import helpers\n"
|
|
||||||
"from src.skin_system.skin_base import ScoreboardSkin\n"
|
|
||||||
"class TestSkin(ScoreboardSkin):\n"
|
|
||||||
" def render_live(self, ctx, game):\n"
|
|
||||||
" ctx.logger.info(helpers.VALUE)\n"
|
|
||||||
" self.helper_value = helpers.VALUE\n"
|
|
||||||
" return False\n"
|
|
||||||
)
|
|
||||||
write_skin(tmp_path, "skin-a", body=body,
|
|
||||||
extra_files={"helpers.py": helper.format("A")})
|
|
||||||
write_skin(tmp_path, "skin-b", body=body,
|
|
||||||
extra_files={"helpers.py": helper.format("B")})
|
|
||||||
skin_a = skin_runtime.load_skin("skin-a", skins_dir=tmp_path)
|
|
||||||
skin_b = skin_runtime.load_skin("skin-b", skins_dir=tmp_path)
|
|
||||||
ctx = _make_context()
|
|
||||||
skin_a.render_live(ctx, {})
|
|
||||||
skin_b.render_live(ctx, {})
|
|
||||||
assert skin_a.helper_value == "A"
|
|
||||||
assert skin_b.helper_value == "B"
|
|
||||||
|
|
||||||
def test_same_skin_loads_repeatedly_with_siblings(self, tmp_path):
|
|
||||||
"""The live/recent/upcoming hosts each load the same skin — the
|
|
||||||
2nd and 3rd loads must still resolve sibling modules (regression:
|
|
||||||
cached siblings used to be skipped without rebinding)."""
|
|
||||||
body = (
|
|
||||||
"import reload_helpers\n"
|
|
||||||
"from src.skin_system.skin_base import ScoreboardSkin\n"
|
|
||||||
"class TestSkin(ScoreboardSkin):\n"
|
|
||||||
" def render_live(self, ctx, game):\n"
|
|
||||||
" self.helper_value = reload_helpers.VALUE\n"
|
|
||||||
" return False\n"
|
|
||||||
)
|
|
||||||
write_skin(tmp_path, "reload-skin", body=body,
|
|
||||||
extra_files={"reload_helpers.py": "VALUE = 'R'\n"})
|
|
||||||
ctx = _make_context()
|
|
||||||
for _ in range(3):
|
|
||||||
skin = skin_runtime.load_skin("reload-skin", skins_dir=tmp_path)
|
|
||||||
assert skin is not None
|
|
||||||
skin.render_live(ctx, {})
|
|
||||||
assert skin.helper_value == "R"
|
|
||||||
|
|
||||||
|
|
||||||
def _make_host(fonts=None):
|
|
||||||
host = MagicMock()
|
|
||||||
host.sport = "baseball"
|
|
||||||
host.sport_key = "mlb"
|
|
||||||
host.skin_options = {"accent": True}
|
|
||||||
host.fonts = fonts or {"time": ImageFont.load_default()}
|
|
||||||
host.logger = logging.getLogger("test_skin_system")
|
|
||||||
host.display_manager.width = 128
|
|
||||||
host.display_manager.height = 32
|
|
||||||
return host
|
|
||||||
|
|
||||||
|
|
||||||
def _make_context(width=128, height=32):
|
|
||||||
host = _make_host()
|
|
||||||
return skin_runtime.build_context(host, {}, size=(width, height))
|
|
||||||
|
|
||||||
|
|
||||||
class TestBuildContext:
|
|
||||||
def test_context_shape(self):
|
|
||||||
host = _make_host()
|
|
||||||
game = {"home_abbr": "LAD", "away_abbr": "SF"}
|
|
||||||
ctx = skin_runtime.build_context(host, game)
|
|
||||||
assert (ctx.width, ctx.height) == (128, 32)
|
|
||||||
assert ctx.canvas.size == (128, 32)
|
|
||||||
assert ctx.sport == "baseball"
|
|
||||||
assert ctx.options == {"accent": True}
|
|
||||||
assert ctx.layout.bounds.w == 128
|
|
||||||
|
|
||||||
def test_explicit_size_overrides_display(self):
|
|
||||||
ctx = skin_runtime.build_context(_make_host(), {}, size=(64, 64))
|
|
||||||
assert ctx.canvas.size == (64, 64)
|
|
||||||
|
|
||||||
def test_load_logo_binds_game_and_survives_failure(self):
|
|
||||||
host = _make_host()
|
|
||||||
host._load_and_resize_logo.side_effect = RuntimeError("disk gone")
|
|
||||||
ctx = skin_runtime.build_context(
|
|
||||||
host, {"home_id": "1", "home_abbr": "LAD",
|
|
||||||
"home_logo_path": "x.png", "home_logo_url": None})
|
|
||||||
assert ctx.load_logo("home") is None # exception swallowed
|
|
||||||
assert ctx.load_logo("elsewhere") is None # bad side rejected
|
|
||||||
|
|
||||||
def test_draw_helpers_draw_on_canvas(self):
|
|
||||||
ctx = _make_context()
|
|
||||||
ctx.draw_text("HI", 2, 2, font=ImageFont.load_default())
|
|
||||||
fit = ctx.layout.fit_text("42", ctx.layout.bounds)
|
|
||||||
ctx.draw_fit(fit, ctx.layout.bounds)
|
|
||||||
logo = Image.new("RGBA", (16, 16), (255, 0, 0, 255))
|
|
||||||
ctx.draw_image(logo, ctx.layout.bounds.left_col(20))
|
|
||||||
ctx.draw_image(None, ctx.layout.bounds) # None must no-op
|
|
||||||
assert ctx.canvas.convert("L").getbbox() is not None
|
|
||||||
|
|
||||||
|
|
||||||
class _FallbackProbe:
|
|
||||||
"""Bare-bones SportsCore stand-in that exercises the real _render_game."""
|
|
||||||
|
|
||||||
def __init__(self, skin):
|
|
||||||
from src.base_classes.sports import SportsCore
|
|
||||||
self._cls = SportsCore
|
|
||||||
self.SKIN_MODE = "live"
|
|
||||||
self.logger = logging.getLogger("test_skin_system")
|
|
||||||
self.sport = "baseball"
|
|
||||||
self.sport_key = "mlb"
|
|
||||||
self.skin_options = {}
|
|
||||||
self.fonts = {"time": ImageFont.load_default()}
|
|
||||||
self._skin = skin
|
|
||||||
self._skin_load_attempted = True
|
|
||||||
self._skin_failures = 0
|
|
||||||
self._skin_slow_renders = 0
|
|
||||||
self._skin_config = "test-skin"
|
|
||||||
self.display_manager = MagicMock()
|
|
||||||
self.display_manager.width = 128
|
|
||||||
self.display_manager.height = 32
|
|
||||||
self.display_manager.image = Image.new("RGB", (128, 32))
|
|
||||||
self.builtin_calls = 0
|
|
||||||
|
|
||||||
def _resolve_skin_id(self):
|
|
||||||
return "test-skin"
|
|
||||||
|
|
||||||
def _draw_scorebug_layout(self, game, force_clear=False):
|
|
||||||
self.builtin_calls += 1
|
|
||||||
|
|
||||||
def _render_game(self, game, force_clear=False):
|
|
||||||
from src.base_classes.sports import SportsCore
|
|
||||||
SportsCore._render_game(self, game, force_clear)
|
|
||||||
|
|
||||||
def _get_skin(self):
|
|
||||||
return self._skin
|
|
||||||
|
|
||||||
|
|
||||||
class TestRenderGameFallback:
|
|
||||||
def test_skin_handles_render(self):
|
|
||||||
class GoodSkin(ScoreboardSkin):
|
|
||||||
def render_live(self, ctx, game):
|
|
||||||
ctx.draw.rectangle([0, 0, 10, 10], fill=(0, 255, 0))
|
|
||||||
return True
|
|
||||||
|
|
||||||
probe = _FallbackProbe(GoodSkin({}, {}))
|
|
||||||
probe._render_game({"status_text": "Q1"})
|
|
||||||
assert probe.builtin_calls == 0
|
|
||||||
probe.display_manager.update_display.assert_called_once()
|
|
||||||
assert probe.display_manager.image.convert("L").getbbox() is not None
|
|
||||||
|
|
||||||
def test_skin_declining_falls_back(self):
|
|
||||||
probe = _FallbackProbe(ScoreboardSkin({}, {})) # all renders -> False
|
|
||||||
probe._render_game({"status_text": "Q1"})
|
|
||||||
assert probe.builtin_calls == 1
|
|
||||||
|
|
||||||
def test_no_skin_falls_back(self):
|
|
||||||
probe = _FallbackProbe(None)
|
|
||||||
probe._render_game({"status_text": "Q1"})
|
|
||||||
assert probe.builtin_calls == 1
|
|
||||||
|
|
||||||
def test_three_strikes_disables_skin(self):
|
|
||||||
class BrokenSkin(ScoreboardSkin):
|
|
||||||
calls = 0
|
|
||||||
|
|
||||||
def render_live(self, ctx, game):
|
|
||||||
BrokenSkin.calls += 1
|
|
||||||
raise ValueError("kaboom")
|
|
||||||
|
|
||||||
probe = _FallbackProbe(BrokenSkin({}, {}))
|
|
||||||
for i in range(5):
|
|
||||||
probe._render_game({"status_text": "Q1"})
|
|
||||||
# every render fell back to the built-in layout...
|
|
||||||
assert probe.builtin_calls == 5
|
|
||||||
# ...and the skin stopped being called after the 3rd failure
|
|
||||||
assert BrokenSkin.calls == 3
|
|
||||||
assert probe._skin_failures == 3
|
|
||||||
|
|
||||||
def test_skin_cannot_mutate_callers_game_dict(self):
|
|
||||||
class MutatingSkin(ScoreboardSkin):
|
|
||||||
def render_live(self, ctx, game):
|
|
||||||
game.clear()
|
|
||||||
game["hacked"] = True
|
|
||||||
return True
|
|
||||||
|
|
||||||
probe = _FallbackProbe(MutatingSkin({}, {}))
|
|
||||||
game = {"status_text": "Q1", "home_score": "3"}
|
|
||||||
probe._render_game(game)
|
|
||||||
assert game == {"status_text": "Q1", "home_score": "3"}
|
|
||||||
|
|
||||||
|
|
||||||
class TestSkinModeResolution:
|
|
||||||
def _core(self, skin_config, mode="live"):
|
|
||||||
from src.base_classes.sports import SportsCore
|
|
||||||
probe = _FallbackProbe(None)
|
|
||||||
probe.SKIN_MODE = mode
|
|
||||||
probe._skin_config = skin_config
|
|
||||||
return SportsCore._resolve_skin_id(probe)
|
|
||||||
|
|
||||||
def test_plain_id_applies_to_all_modes(self):
|
|
||||||
assert self._core("retro", "live") == "retro"
|
|
||||||
assert self._core("retro", "recent") == "retro"
|
|
||||||
|
|
||||||
def test_per_mode_mapping(self):
|
|
||||||
cfg = {"live": "retro", "recent": "built-in"}
|
|
||||||
assert self._core(cfg, "live") == "retro"
|
|
||||||
assert self._core(cfg, "recent") is None
|
|
||||||
assert self._core(cfg, "upcoming") is None
|
|
||||||
|
|
||||||
def test_builtin_and_empty_mean_none(self):
|
|
||||||
assert self._core("built-in") is None
|
|
||||||
assert self._core("") is None
|
|
||||||
assert self._core(None) is None
|
|
||||||
|
|
||||||
|
|
||||||
class TestViewModelContract:
|
|
||||||
@pytest.mark.parametrize("sport", ["baseball", "basketball", "football", "hockey"])
|
|
||||||
@pytest.mark.parametrize("mode", ["live", "recent", "upcoming"])
|
|
||||||
def test_fixtures_carry_all_guaranteed_keys(self, sport, mode):
|
|
||||||
with open(FIXTURES_DIR / f"{sport}_{mode}.json") as f:
|
|
||||||
game = json.load(f)
|
|
||||||
missing = [k for k in GUARANTEED_KEYS if k not in game]
|
|
||||||
assert not missing, f"{sport}_{mode} fixture missing {missing}"
|
|
||||||
|
|
||||||
def test_extractor_produces_guaranteed_keys(self):
|
|
||||||
"""The real extractor's output must be a superset of the documented
|
|
||||||
contract — this is the test that catches accidental renames."""
|
|
||||||
import pytz
|
|
||||||
from src.base_classes.sports import SportsCore
|
|
||||||
|
|
||||||
event = {
|
|
||||||
"id": "401570001",
|
|
||||||
"date": "2026-07-16T23:05:00Z",
|
|
||||||
"competitions": [{
|
|
||||||
"status": {"type": {"name": "STATUS_IN_PROGRESS", "state": "in",
|
|
||||||
"shortDetail": "Bot 7th"}},
|
|
||||||
"competitors": [
|
|
||||||
{"homeAway": "home", "id": "19",
|
|
||||||
"team": {"abbreviation": "LAD"}, "score": "5",
|
|
||||||
"records": [{"summary": "58-33"}]},
|
|
||||||
{"homeAway": "away", "id": "26",
|
|
||||||
"team": {"abbreviation": "SF"}, "score": "3",
|
|
||||||
"records": [{"summary": "49-42"}]},
|
|
||||||
],
|
|
||||||
}],
|
|
||||||
}
|
|
||||||
probe = MagicMock()
|
|
||||||
probe.logger = logging.getLogger("test_skin_system")
|
|
||||||
probe.favorite_teams = []
|
|
||||||
probe.config = {}
|
|
||||||
probe.logo_dir = Path("assets/logos")
|
|
||||||
probe._get_timezone.return_value = pytz.utc
|
|
||||||
probe.display_manager.format_date_with_ordinal.return_value = "Jul 16th"
|
|
||||||
|
|
||||||
details, _, _, _, _ = SportsCore._extract_game_details_common(probe, event)
|
|
||||||
assert details is not None
|
|
||||||
missing = [k for k in GUARANTEED_KEYS if k not in details]
|
|
||||||
assert not missing, (
|
|
||||||
f"_extract_game_details_common no longer emits {missing}. "
|
|
||||||
"These keys are part of the frozen skin view-model contract "
|
|
||||||
"(VIEW_MODEL_VERSION) — renaming or removing them breaks every "
|
|
||||||
"published skin. Add a compat shim or bump the major version.")
|
|
||||||
|
|
||||||
|
|
||||||
class TestPluginMatching:
|
|
||||||
def test_matches_by_sport_token_and_sport_key(self, tmp_path):
|
|
||||||
write_skin(tmp_path, "bb-skin") # targets sports=["baseball"]
|
|
||||||
skins = skin_runtime.discover_skins(tmp_path, force_refresh=True)
|
|
||||||
assert "bb-skin" in skin_runtime.skins_for_plugin("baseball-scoreboard", skins)
|
|
||||||
assert "bb-skin" not in skin_runtime.skins_for_plugin("football-scoreboard", skins)
|
|
||||||
|
|
||||||
def test_matches_by_explicit_plugin_list(self, tmp_path):
|
|
||||||
skin_dir = write_skin(tmp_path, "exact-skin")
|
|
||||||
manifest = json.loads((skin_dir / "skin.json").read_text())
|
|
||||||
manifest["targets"] = {"plugins": ["my-custom-plugin"]}
|
|
||||||
(skin_dir / "skin.json").write_text(json.dumps(manifest))
|
|
||||||
skins = skin_runtime.discover_skins(tmp_path, force_refresh=True)
|
|
||||||
assert "exact-skin" in skin_runtime.skins_for_plugin("my-custom-plugin", skins)
|
|
||||||
assert "exact-skin" not in skin_runtime.skins_for_plugin("baseball-scoreboard", skins)
|
|
||||||
|
|
||||||
|
|
||||||
class TestSchemaInjection:
|
|
||||||
def _manager(self):
|
|
||||||
from src.plugin_system.schema_manager import SchemaManager
|
|
||||||
return SchemaManager()
|
|
||||||
|
|
||||||
def test_injects_enum_with_installed_and_configured_skins(self):
|
|
||||||
sm = self._manager()
|
|
||||||
schema = {"type": "object", "properties": {}}
|
|
||||||
out = sm.inject_skin_selector(schema, "baseball-scoreboard",
|
|
||||||
current_value="gone-skin")
|
|
||||||
enum = out["properties"]["skin"]["enum"]
|
|
||||||
assert enum[0] == "built-in"
|
|
||||||
assert "example-classic-baseball" in enum
|
|
||||||
# an uninstalled-but-configured skin must stay selectable so the
|
|
||||||
# saved config never becomes invalid in the UI
|
|
||||||
assert "gone-skin" in enum
|
|
||||||
assert "skin" not in schema["properties"] # source schema untouched
|
|
||||||
|
|
||||||
def test_no_matching_skins_leaves_schema_alone(self):
|
|
||||||
sm = self._manager()
|
|
||||||
schema = {"type": "object", "properties": {}}
|
|
||||||
out = sm.inject_skin_selector(schema, "totally-unrelated-plugin")
|
|
||||||
assert "skin" not in out.get("properties", {})
|
|
||||||
|
|
||||||
def test_validation_accepts_skin_keys_without_enum(self):
|
|
||||||
sm = self._manager()
|
|
||||||
schema = {"type": "object", "properties": {"foo": {"type": "string"}}}
|
|
||||||
ok, errors = sm.validate_config_against_schema(
|
|
||||||
{"skin": "any-id-even-uninstalled", "skin_options": {"x": 1}},
|
|
||||||
schema, "baseball-scoreboard")
|
|
||||||
assert ok, errors
|
|
||||||
ok, errors = sm.validate_config_against_schema(
|
|
||||||
{"skin": {"live": "a", "recent": "built-in"}}, schema, "p")
|
|
||||||
assert ok, errors
|
|
||||||
|
|
||||||
|
|
||||||
class TestExampleSkin:
|
|
||||||
@pytest.mark.parametrize("mode", ["live", "recent", "upcoming"])
|
|
||||||
@pytest.mark.parametrize("size", [(128, 32), (64, 32), (128, 64)])
|
|
||||||
def test_renders_all_modes_and_sizes(self, mode, size):
|
|
||||||
skin = skin_runtime.load_skin("example-classic-baseball", sport="baseball")
|
|
||||||
assert skin is not None
|
|
||||||
host = _make_host()
|
|
||||||
host._load_and_resize_logo.return_value = Image.new("RGBA", (32, 32), (200, 0, 0, 255))
|
|
||||||
with open(FIXTURES_DIR / f"baseball_{mode}.json") as f:
|
|
||||||
game = json.load(f)
|
|
||||||
ctx = skin_runtime.build_context(host, game, size=size)
|
|
||||||
assert getattr(skin, f"render_{mode}")(ctx, game) is True
|
|
||||||
assert ctx.canvas.convert("L").getbbox() is not None
|
|
||||||
@@ -1,617 +0,0 @@
|
|||||||
"""Characterization tests for src/base_classes/sports.py.
|
|
||||||
|
|
||||||
These tests PIN the current behavior of SportsCore / SportsUpcoming /
|
|
||||||
SportsRecent / SportsLive ahead of the sports-unification merge (features
|
|
||||||
from nine drifted plugin copies are about to be folded in). They assert
|
|
||||||
what the code DOES today, not what it should do — a few pinned behaviors
|
|
||||||
look like bugs and are flagged inline with "PINNED AS-IS".
|
|
||||||
|
|
||||||
Coverage:
|
|
||||||
- `_extract_game_details_common` + the four sport extractors
|
|
||||||
(football/hockey/baseball/basketball) against realistic ESPN scoreboard
|
|
||||||
events (adapted from the ledmatrix-plugins monorepo test fixtures).
|
|
||||||
The output must remain a superset of the frozen skin view-model
|
|
||||||
contract (GUARANTEED_KEYS, imported from test_skin_system).
|
|
||||||
- update() flow for concrete SportsUpcoming/SportsRecent/SportsLive
|
|
||||||
subclasses: population, favorite-team filtering, empty/failed-fetch
|
|
||||||
tolerance. All offline: `_fetch_data` reads a pre-seeded mocked cache
|
|
||||||
and every instance's requests session raises ConnectionError.
|
|
||||||
- Rendering smoke: one `display()` per mode class at 128x32 draws
|
|
||||||
non-zero ink onto a real PIL image.
|
|
||||||
- Guard rails: the skin-system seam methods on SportsCore must survive
|
|
||||||
the merge.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import sys
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import pytz
|
|
||||||
import requests
|
|
||||||
from freezegun import freeze_time
|
|
||||||
from PIL import Image
|
|
||||||
|
|
||||||
# src.base_classes.sports transitively imports the hardware matrix driver;
|
|
||||||
# stub it so these tests can import the sports base classes off-device.
|
|
||||||
sys.modules.setdefault("rgbmatrix", MagicMock())
|
|
||||||
|
|
||||||
from src.base_classes.baseball import Baseball
|
|
||||||
from src.base_classes.basketball import Basketball
|
|
||||||
from src.base_classes.football import Football
|
|
||||||
from src.base_classes.hockey import Hockey, HockeyLive
|
|
||||||
from src.base_classes.sports import (
|
|
||||||
SportsCore,
|
|
||||||
SportsLive,
|
|
||||||
SportsRecent,
|
|
||||||
SportsUpcoming,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Reuse the frozen v1.0 skin view-model contract rather than redeclaring it.
|
|
||||||
from test.test_skin_system import GUARANTEED_KEYS
|
|
||||||
|
|
||||||
SPORT_CLASSES = [Football, Hockey, Baseball, Basketball]
|
|
||||||
SPORT_IDS = ["football", "hockey", "baseball", "basketball"]
|
|
||||||
|
|
||||||
# All update()-flow tests run at this frozen instant so the 21-day
|
|
||||||
# SportsRecent window and time.time() interval gates are deterministic.
|
|
||||||
FROZEN_NOW = "2026-01-20 12:00:00"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# ESPN scoreboard event builders (shape adapted from the monorepo fixtures,
|
|
||||||
# e.g. ledmatrix-plugins/plugins/hockey-scoreboard/test/fixtures/mock.json:
|
|
||||||
# team-shaped competitors with status/score/records).
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _competitor(abbr, team_id, score, home_away, record="30-10-5"):
|
|
||||||
return {
|
|
||||||
"homeAway": home_away,
|
|
||||||
"id": team_id,
|
|
||||||
"score": score,
|
|
||||||
"team": {
|
|
||||||
"id": team_id,
|
|
||||||
"abbreviation": abbr,
|
|
||||||
"name": abbr.title(),
|
|
||||||
"displayName": abbr.title(),
|
|
||||||
"logo": None,
|
|
||||||
},
|
|
||||||
"records": [{"summary": record}],
|
|
||||||
# The hockey extractor iterates competitor["statistics"] and
|
|
||||||
# returns None for the whole event when the key is absent (see
|
|
||||||
# test_hockey_event_without_statistics_returns_none).
|
|
||||||
"statistics": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def make_event(event_id, state, date, home=("TB", "20", "3"),
|
|
||||||
away=("DAL", "9", "2"), period=2, clock="12:45",
|
|
||||||
name=None, short_detail=None, situation=None,
|
|
||||||
home_record="30-10-5", away_record="25-14-6"):
|
|
||||||
"""Build a realistic ESPN scoreboard event in the given state
|
|
||||||
('in' / 'post' / 'pre')."""
|
|
||||||
defaults = {
|
|
||||||
"in": ("STATUS_IN_PROGRESS", f"P{period} {clock}"),
|
|
||||||
"post": ("STATUS_FINAL", "Final"),
|
|
||||||
"pre": ("STATUS_SCHEDULED", "1/15 - 6:30 PM"),
|
|
||||||
}
|
|
||||||
default_name, default_detail = defaults[state]
|
|
||||||
status = {
|
|
||||||
"clock": 0.0,
|
|
||||||
"displayClock": clock,
|
|
||||||
"period": period,
|
|
||||||
"type": {
|
|
||||||
"id": "2",
|
|
||||||
"name": name or default_name,
|
|
||||||
"state": state,
|
|
||||||
"completed": state == "post",
|
|
||||||
"description": short_detail or default_detail,
|
|
||||||
"detail": short_detail or default_detail,
|
|
||||||
"shortDetail": short_detail or default_detail,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
competition = {
|
|
||||||
"id": event_id,
|
|
||||||
"date": date,
|
|
||||||
"status": status,
|
|
||||||
"competitors": [
|
|
||||||
_competitor(home[0], home[1], home[2], "home", home_record),
|
|
||||||
_competitor(away[0], away[1], away[2], "away", away_record),
|
|
||||||
],
|
|
||||||
}
|
|
||||||
if situation is not None:
|
|
||||||
competition["situation"] = situation
|
|
||||||
return {
|
|
||||||
"id": event_id,
|
|
||||||
"date": date,
|
|
||||||
"name": f"{away[0]} at {home[0]}",
|
|
||||||
"shortName": f"{away[0]} @ {home[0]}",
|
|
||||||
"competitions": [competition],
|
|
||||||
# Real ESPN payloads duplicate status at the event top level; the
|
|
||||||
# baseball extractor reads it there for live innings.
|
|
||||||
"status": status,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def make_probe(favorites=None):
|
|
||||||
"""Bare-bones SportsCore stand-in for exercising the real extractors
|
|
||||||
unbound (same pattern as TestViewModelContract in test_skin_system)."""
|
|
||||||
probe = MagicMock()
|
|
||||||
probe.logger = logging.getLogger("test_sports_base_characterization")
|
|
||||||
probe.favorite_teams = list(favorites or [])
|
|
||||||
probe.config = {}
|
|
||||||
probe.logo_dir = Path("assets/logos")
|
|
||||||
probe._get_timezone.return_value = pytz.utc
|
|
||||||
probe.display_manager.format_date_with_ordinal.return_value = "Jan 15th"
|
|
||||||
# The sport extractors call self._extract_game_details_common — route
|
|
||||||
# it to the real implementation instead of a MagicMock.
|
|
||||||
probe._extract_game_details_common = (
|
|
||||||
lambda event: SportsCore._extract_game_details_common(probe, event))
|
|
||||||
return probe
|
|
||||||
|
|
||||||
|
|
||||||
def extract(sport_cls, event, favorites=None):
|
|
||||||
return sport_cls._extract_game_details(make_probe(favorites), event)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 1. _extract_game_details_common contract, per wired sport
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestExtractGameDetailsContract:
|
|
||||||
@pytest.mark.parametrize("sport_cls", SPORT_CLASSES, ids=SPORT_IDS)
|
|
||||||
def test_live_event_guaranteed_keys_and_values(self, sport_cls):
|
|
||||||
event = make_event("401", "in", "2026-01-15T18:30:00Z")
|
|
||||||
details = extract(sport_cls, event)
|
|
||||||
assert details is not None
|
|
||||||
missing = [k for k in GUARANTEED_KEYS if k not in details]
|
|
||||||
assert not missing, (
|
|
||||||
f"{sport_cls.__name__} extractor no longer emits {missing} — "
|
|
||||||
"these keys are the frozen skin view-model contract.")
|
|
||||||
assert details["id"] == "401"
|
|
||||||
assert details["home_abbr"] == "TB"
|
|
||||||
assert details["away_abbr"] == "DAL"
|
|
||||||
assert details["home_id"] == "20"
|
|
||||||
assert details["away_id"] == "9"
|
|
||||||
assert details["home_score"] == "3"
|
|
||||||
assert details["away_score"] == "2"
|
|
||||||
assert details["home_record"] == "30-10-5"
|
|
||||||
assert details["away_record"] == "25-14-6"
|
|
||||||
assert details["is_live"] is True
|
|
||||||
assert details["is_final"] is False
|
|
||||||
assert details["is_upcoming"] is False
|
|
||||||
assert details["status_text"] == "P2 12:45"
|
|
||||||
assert details["start_time_utc"] == datetime(
|
|
||||||
2026, 1, 15, 18, 30, tzinfo=timezone.utc)
|
|
||||||
# Sport-specific formatting of the same event:
|
|
||||||
if sport_cls in (Football, Basketball):
|
|
||||||
assert details["period_text"] == "Q2"
|
|
||||||
assert details["clock"] == "12:45"
|
|
||||||
elif sport_cls is Hockey:
|
|
||||||
assert details["period_text"] == "P2"
|
|
||||||
assert details["clock"] == "12:45"
|
|
||||||
else: # Baseball keys inning/status instead of period_text
|
|
||||||
assert details["inning"] == 2
|
|
||||||
assert details["status_state"] == "in"
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("sport_cls", SPORT_CLASSES, ids=SPORT_IDS)
|
|
||||||
def test_final_event_classification(self, sport_cls):
|
|
||||||
event = make_event("402", "post", "2026-01-14T00:00:00Z",
|
|
||||||
home=("BOS", "1", "4"), away=("TOR", "21", "2"),
|
|
||||||
period=3, clock="0:00")
|
|
||||||
details = extract(sport_cls, event)
|
|
||||||
assert details is not None
|
|
||||||
assert details["is_final"] is True
|
|
||||||
assert details["is_live"] is False
|
|
||||||
assert details["is_upcoming"] is False
|
|
||||||
assert details["home_score"] == "4"
|
|
||||||
assert details["away_score"] == "2"
|
|
||||||
if sport_cls in (Football, Hockey, Basketball):
|
|
||||||
assert details["period_text"] == "Final"
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("sport_cls", SPORT_CLASSES, ids=SPORT_IDS)
|
|
||||||
def test_upcoming_event_classification(self, sport_cls):
|
|
||||||
event = make_event("403", "pre", "2026-01-15T18:30:00Z",
|
|
||||||
home=("NYR", "13", "0"), away=("PIT", "16", "0"),
|
|
||||||
period=0, clock="0:00")
|
|
||||||
details = extract(sport_cls, event)
|
|
||||||
assert details is not None
|
|
||||||
assert details["is_upcoming"] is True
|
|
||||||
assert details["is_live"] is False
|
|
||||||
assert details["is_final"] is False
|
|
||||||
# Local time formatting (probe timezone is UTC): 18:30Z -> 6:30PM,
|
|
||||||
# date rendered through display_manager.format_date_with_ordinal.
|
|
||||||
assert details["game_time"] == "6:30PM"
|
|
||||||
assert details["game_date"] == "Jan 15th"
|
|
||||||
|
|
||||||
def test_halftime_state_flags(self):
|
|
||||||
# is_halftime keys off name STATUS_HALFTIME (or state "halftime")
|
|
||||||
# while state "in" still counts as live.
|
|
||||||
event = make_event("404", "in", "2026-01-15T18:30:00Z",
|
|
||||||
name="STATUS_HALFTIME", short_detail="Halftime")
|
|
||||||
details, *_ = SportsCore._extract_game_details_common(
|
|
||||||
make_probe(), event)
|
|
||||||
assert details["is_live"] is True
|
|
||||||
assert details["is_halftime"] is True
|
|
||||||
|
|
||||||
def test_state_name_conflict_is_both_final_and_upcoming(self):
|
|
||||||
# PINNED AS-IS (looks like a bug): is_upcoming also matches on
|
|
||||||
# status.type.name ('scheduled'/'pre-game'/'status_scheduled'), so
|
|
||||||
# an event with state="post" but name="Scheduled" reports BOTH
|
|
||||||
# is_final and is_upcoming True.
|
|
||||||
event = make_event("405", "post", "2026-01-14T00:00:00Z",
|
|
||||||
name="Scheduled")
|
|
||||||
details, *_ = SportsCore._extract_game_details_common(
|
|
||||||
make_probe(), event)
|
|
||||||
assert details["is_final"] is True
|
|
||||||
assert details["is_upcoming"] is True
|
|
||||||
|
|
||||||
def test_zero_zero_record_blanked(self):
|
|
||||||
event = make_event("406", "pre", "2026-01-15T18:30:00Z",
|
|
||||||
home_record="0-0", away_record="0-0-0")
|
|
||||||
details, *_ = SportsCore._extract_game_details_common(
|
|
||||||
make_probe(), event)
|
|
||||||
assert details["home_record"] == ""
|
|
||||||
assert details["away_record"] == ""
|
|
||||||
|
|
||||||
def test_missing_abbreviation_uses_name_prefix(self):
|
|
||||||
event = make_event("407", "pre", "2026-01-15T18:30:00Z")
|
|
||||||
for comp in event["competitions"][0]["competitors"]:
|
|
||||||
del comp["team"]["abbreviation"]
|
|
||||||
comp["team"]["name"] = "Sharks" if comp["homeAway"] == "home" \
|
|
||||||
else "Penguins"
|
|
||||||
details, *_ = SportsCore._extract_game_details_common(
|
|
||||||
make_probe(), event)
|
|
||||||
assert details["home_abbr"] == "Sha"
|
|
||||||
assert details["away_abbr"] == "Pen"
|
|
||||||
|
|
||||||
def test_empty_or_malformed_event_returns_none_tuple(self):
|
|
||||||
probe = make_probe()
|
|
||||||
assert SportsCore._extract_game_details_common(probe, {}) == \
|
|
||||||
(None, None, None, None, None)
|
|
||||||
assert SportsCore._extract_game_details_common(probe, None) == \
|
|
||||||
(None, None, None, None, None)
|
|
||||||
# Malformed event (no competitions) is swallowed, not raised.
|
|
||||||
assert SportsCore._extract_game_details_common(
|
|
||||||
probe, {"id": "999"}) == (None, None, None, None, None)
|
|
||||||
|
|
||||||
def test_football_live_situation_fields(self):
|
|
||||||
event = make_event(
|
|
||||||
"408", "in", "2026-01-15T18:30:00Z",
|
|
||||||
situation={
|
|
||||||
"shortDownDistanceText": "3rd & 4",
|
|
||||||
"downDistanceText": "3rd & 4 at TB 30",
|
|
||||||
"isRedZone": False,
|
|
||||||
"possession": "20",
|
|
||||||
"homeTimeouts": 2,
|
|
||||||
"awayTimeouts": 3,
|
|
||||||
})
|
|
||||||
details = extract(Football, event)
|
|
||||||
assert details["down_distance_text"] == "3rd & 4"
|
|
||||||
assert details["down_distance_text_long"] == "3rd & 4 at TB 30"
|
|
||||||
assert details["possession"] == "20"
|
|
||||||
assert details["possession_indicator"] == "home" # matches home id
|
|
||||||
assert details["home_timeouts"] == 2
|
|
||||||
assert details["away_timeouts"] == 3
|
|
||||||
|
|
||||||
def test_hockey_live_power_play_and_default_shots(self):
|
|
||||||
event = make_event("409", "in", "2026-01-15T18:30:00Z",
|
|
||||||
situation={"isPowerPlay": True, "penalties": ""})
|
|
||||||
details = extract(Hockey, event)
|
|
||||||
assert details["power_play"] is True
|
|
||||||
# Empty statistics arrays -> save-percentage math yields 0 shots.
|
|
||||||
assert details["home_shots"] == 0
|
|
||||||
assert details["away_shots"] == 0
|
|
||||||
|
|
||||||
def test_hockey_event_without_statistics_returns_none(self):
|
|
||||||
# PINNED AS-IS: the hockey extractor unconditionally iterates
|
|
||||||
# competitor["statistics"]; a competitor without the key raises
|
|
||||||
# KeyError internally and the WHOLE event is dropped (returns
|
|
||||||
# None), even though scores/status are present.
|
|
||||||
event = make_event("410", "in", "2026-01-15T18:30:00Z")
|
|
||||||
for comp in event["competitions"][0]["competitors"]:
|
|
||||||
del comp["statistics"]
|
|
||||||
assert extract(Hockey, event) is None
|
|
||||||
|
|
||||||
def test_baseball_live_inning_and_count(self):
|
|
||||||
event = make_event(
|
|
||||||
"411", "in", "2026-07-16T23:05:00Z",
|
|
||||||
home=("LAD", "19", "5"), away=("SF", "26", "3"),
|
|
||||||
period=7, short_detail="Bot 7th",
|
|
||||||
situation={
|
|
||||||
"count": {"balls": 2, "strikes": 1},
|
|
||||||
"outs": 2,
|
|
||||||
"onFirst": True,
|
|
||||||
"onSecond": False,
|
|
||||||
"onThird": True,
|
|
||||||
})
|
|
||||||
details = extract(Baseball, event)
|
|
||||||
assert details["inning"] == 7 # from top-level status period
|
|
||||||
assert details["inning_half"] == "bottom"
|
|
||||||
assert details["balls"] == 2
|
|
||||||
assert details["strikes"] == 1
|
|
||||||
assert details["outs"] == 2
|
|
||||||
assert details["bases_occupied"] == [True, False, True]
|
|
||||||
assert details["status"] == "status_in_progress"
|
|
||||||
assert details["series_summary"] == ""
|
|
||||||
|
|
||||||
def test_baseball_live_without_top_level_status_returns_none(self):
|
|
||||||
# PINNED AS-IS: for live games the baseball extractor reads
|
|
||||||
# game_event["status"] (the event TOP-LEVEL status, not the
|
|
||||||
# competition status) for the inning; an otherwise-valid live
|
|
||||||
# event lacking that duplicate key is dropped entirely.
|
|
||||||
event = make_event("412", "in", "2026-07-16T23:05:00Z")
|
|
||||||
del event["status"]
|
|
||||||
assert extract(Baseball, event) is None
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 2. update() flow on concrete subclasses (offline, cache-fed)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class _UpcomingHarness(Hockey, SportsUpcoming):
|
|
||||||
"""Cheapest concrete SportsUpcoming: hockey extractor + cache-fed data."""
|
|
||||||
|
|
||||||
def _fetch_data(self):
|
|
||||||
return self.cache_manager.get(f"{self.sport_key}_schedule")
|
|
||||||
|
|
||||||
|
|
||||||
class _RecentHarness(Hockey, SportsRecent):
|
|
||||||
def _fetch_data(self):
|
|
||||||
return self.cache_manager.get(f"{self.sport_key}_schedule")
|
|
||||||
|
|
||||||
|
|
||||||
class _LiveHarness(HockeyLive):
|
|
||||||
def _fetch_data(self):
|
|
||||||
return self.cache_manager.get(f"{self.sport_key}_schedule")
|
|
||||||
|
|
||||||
|
|
||||||
def make_schedule():
|
|
||||||
"""A mixed schedule around the frozen 'now' of 2026-01-20."""
|
|
||||||
return {"events": [
|
|
||||||
# Final 6 days ago — inside the recent 21-day window.
|
|
||||||
make_event("9001", "post", "2026-01-14T00:00:00Z",
|
|
||||||
home=("BOS", "1", "4"), away=("TOR", "21", "2"),
|
|
||||||
period=3, clock="0:00"),
|
|
||||||
# Live game.
|
|
||||||
make_event("9002", "in", "2026-01-15T00:30:00Z",
|
|
||||||
home=("TB", "20", "3"), away=("DAL", "9", "2")),
|
|
||||||
# Two scheduled games.
|
|
||||||
make_event("9003", "pre", "2026-01-16T00:00:00Z",
|
|
||||||
home=("NYR", "13", "0"), away=("PIT", "16", "0"),
|
|
||||||
period=0),
|
|
||||||
make_event("9004", "pre", "2026-01-17T00:00:00Z",
|
|
||||||
home=("BOS", "1", "0"), away=("MTL", "10", "0"),
|
|
||||||
period=0),
|
|
||||||
# Final from November — outside the recent 21-day window.
|
|
||||||
make_event("9005", "post", "2025-11-01T00:00:00Z",
|
|
||||||
home=("SEA", "124292", "1"), away=("VAN", "22", "5"),
|
|
||||||
period=3, clock="0:00"),
|
|
||||||
]}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def build_manager(monkeypatch, tmp_path):
|
|
||||||
"""Factory for concrete sports managers: mocked display/cache managers,
|
|
||||||
logo dir redirected to tmp, background service stubbed, and the
|
|
||||||
requests session rigged to prove nothing hits the network."""
|
|
||||||
monkeypatch.setattr(
|
|
||||||
SportsCore, "_initialize_logo_dir", lambda self, configured: tmp_path)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"src.base_classes.sports.get_background_service",
|
|
||||||
lambda *args, **kwargs: MagicMock())
|
|
||||||
|
|
||||||
def build(cls, schedule, **mode_cfg):
|
|
||||||
config = {
|
|
||||||
"timezone": "UTC",
|
|
||||||
"display": {},
|
|
||||||
"nhl_scoreboard": {"enabled": True, **mode_cfg},
|
|
||||||
}
|
|
||||||
display_manager = MagicMock()
|
|
||||||
display_manager.matrix.width = 128
|
|
||||||
display_manager.matrix.height = 32
|
|
||||||
display_manager.width = 128
|
|
||||||
display_manager.height = 32
|
|
||||||
display_manager.image = Image.new("RGB", (128, 32))
|
|
||||||
display_manager.format_date_with_ordinal.side_effect = (
|
|
||||||
lambda dt: dt.strftime("%b %d"))
|
|
||||||
cache_manager = MagicMock()
|
|
||||||
cache_manager.get.return_value = schedule
|
|
||||||
cache_manager.cache_dir = str(tmp_path)
|
|
||||||
manager = cls(config, display_manager, cache_manager,
|
|
||||||
logging.getLogger("test_sports_base_characterization"),
|
|
||||||
"nhl")
|
|
||||||
# Safety net: any accidental network fetch must fail loudly.
|
|
||||||
manager.session = MagicMock()
|
|
||||||
manager.session.get.side_effect = requests.exceptions.ConnectionError(
|
|
||||||
"characterization tests are offline")
|
|
||||||
return manager
|
|
||||||
|
|
||||||
return build
|
|
||||||
|
|
||||||
|
|
||||||
def _ids(games):
|
|
||||||
return [g["id"] for g in games]
|
|
||||||
|
|
||||||
|
|
||||||
@freeze_time(FROZEN_NOW)
|
|
||||||
class TestUpcomingUpdateFlow:
|
|
||||||
def test_populates_games_list_sorted_by_start_time(self, build_manager):
|
|
||||||
manager = build_manager(_UpcomingHarness, make_schedule())
|
|
||||||
manager.update()
|
|
||||||
# PINNED AS-IS: SportsUpcoming filters purely on is_upcoming
|
|
||||||
# (state 'pre') — there is NO date filter, so 'pre' games whose
|
|
||||||
# start time is already in the past (9003/9004 vs frozen 1/20)
|
|
||||||
# are still shown.
|
|
||||||
assert _ids(manager.games_list) == ["9003", "9004"]
|
|
||||||
assert manager.current_game["id"] == "9003"
|
|
||||||
|
|
||||||
def test_filters_by_favorite_teams(self, build_manager):
|
|
||||||
manager = build_manager(_UpcomingHarness, make_schedule(),
|
|
||||||
show_favorite_teams_only=True,
|
|
||||||
favorite_teams=["BOS"])
|
|
||||||
manager.update()
|
|
||||||
assert _ids(manager.games_list) == ["9004"]
|
|
||||||
assert manager.current_game["id"] == "9004"
|
|
||||||
|
|
||||||
def test_favorites_only_with_no_favorites_shows_nothing(
|
|
||||||
self, build_manager):
|
|
||||||
# PINNED AS-IS: show_favorite_teams_only=True with an empty
|
|
||||||
# favorite_teams list drops every game rather than falling back
|
|
||||||
# to showing all games.
|
|
||||||
manager = build_manager(_UpcomingHarness, make_schedule(),
|
|
||||||
show_favorite_teams_only=True,
|
|
||||||
favorite_teams=[])
|
|
||||||
manager.update()
|
|
||||||
assert manager.games_list == []
|
|
||||||
assert manager.current_game is None
|
|
||||||
|
|
||||||
def test_caps_at_upcoming_games_to_show(self, build_manager):
|
|
||||||
manager = build_manager(_UpcomingHarness, make_schedule(),
|
|
||||||
upcoming_games_to_show=1)
|
|
||||||
manager.update()
|
|
||||||
assert _ids(manager.games_list) == ["9003"]
|
|
||||||
|
|
||||||
def test_tolerates_empty_events_list(self, build_manager):
|
|
||||||
manager = build_manager(_UpcomingHarness, {"events": []})
|
|
||||||
manager.update() # must not raise
|
|
||||||
assert manager.games_list == []
|
|
||||||
assert manager.current_game is None
|
|
||||||
|
|
||||||
def test_tolerates_fetch_returning_none(self, build_manager):
|
|
||||||
manager = build_manager(_UpcomingHarness, None)
|
|
||||||
manager.update() # must not raise
|
|
||||||
assert manager.games_list == []
|
|
||||||
assert manager.current_game is None
|
|
||||||
|
|
||||||
def test_disabled_manager_update_is_noop(self, build_manager):
|
|
||||||
manager = build_manager(_UpcomingHarness, make_schedule(),
|
|
||||||
enabled=False)
|
|
||||||
manager.update()
|
|
||||||
assert manager.games_list == []
|
|
||||||
manager.cache_manager.get.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
@freeze_time(FROZEN_NOW)
|
|
||||||
class TestRecentUpdateFlow:
|
|
||||||
def test_populates_only_finals_within_21_day_window(self, build_manager):
|
|
||||||
manager = build_manager(_RecentHarness, make_schedule())
|
|
||||||
manager.update()
|
|
||||||
# 9001 (final, 6 days old) kept; 9005 (final, ~80 days old)
|
|
||||||
# excluded by the 21-day cutoff; live/pre games excluded.
|
|
||||||
assert _ids(manager.games_list) == ["9001"]
|
|
||||||
assert manager.current_game["id"] == "9001"
|
|
||||||
assert manager.current_game["is_final"] is True
|
|
||||||
|
|
||||||
def test_filters_by_favorite_teams(self, build_manager):
|
|
||||||
manager = build_manager(_RecentHarness, make_schedule(),
|
|
||||||
show_favorite_teams_only=True,
|
|
||||||
favorite_teams=["TOR"])
|
|
||||||
manager.update()
|
|
||||||
assert _ids(manager.games_list) == ["9001"]
|
|
||||||
|
|
||||||
stranger = build_manager(_RecentHarness, make_schedule(),
|
|
||||||
show_favorite_teams_only=True,
|
|
||||||
favorite_teams=["XXX"])
|
|
||||||
stranger.update()
|
|
||||||
assert stranger.games_list == []
|
|
||||||
assert stranger.current_game is None
|
|
||||||
|
|
||||||
def test_tolerates_empty_events_list(self, build_manager):
|
|
||||||
manager = build_manager(_RecentHarness, {"events": []})
|
|
||||||
manager.update() # must not raise
|
|
||||||
assert manager.games_list == []
|
|
||||||
assert manager.current_game is None
|
|
||||||
|
|
||||||
|
|
||||||
@freeze_time(FROZEN_NOW)
|
|
||||||
class TestLiveUpdateFlow:
|
|
||||||
def test_selects_only_live_games(self, build_manager):
|
|
||||||
manager = build_manager(_LiveHarness, make_schedule())
|
|
||||||
manager.update()
|
|
||||||
assert _ids(manager.live_games) == ["9002"]
|
|
||||||
assert manager.current_game["id"] == "9002"
|
|
||||||
assert manager.current_game["is_live"] is True
|
|
||||||
|
|
||||||
def test_no_live_games_clears_current_game(self, build_manager):
|
|
||||||
schedule = {"events": [
|
|
||||||
make_event("9001", "post", "2026-01-14T00:00:00Z"),
|
|
||||||
make_event("9003", "pre", "2026-01-16T00:00:00Z", period=0),
|
|
||||||
]}
|
|
||||||
manager = build_manager(_LiveHarness, schedule)
|
|
||||||
manager.update()
|
|
||||||
assert manager.live_games == []
|
|
||||||
assert manager.current_game is None
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 3. Rendering smoke — one display() per mode class at 128x32
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _fake_logo(*args, **kwargs):
|
|
||||||
return Image.new("RGBA", (24, 24), (180, 30, 30, 255))
|
|
||||||
|
|
||||||
|
|
||||||
@freeze_time(FROZEN_NOW)
|
|
||||||
class TestRenderingSmoke:
|
|
||||||
def _assert_rendered(self, manager):
|
|
||||||
manager.display_manager.update_display.assert_called()
|
|
||||||
assert manager.display_manager.image.convert("L").getbbox() is not None
|
|
||||||
|
|
||||||
def test_upcoming_display_draws_ink(self, build_manager):
|
|
||||||
manager = build_manager(_UpcomingHarness, make_schedule())
|
|
||||||
manager.update()
|
|
||||||
manager._load_and_resize_logo = _fake_logo
|
|
||||||
assert manager.display(force_clear=True) is True
|
|
||||||
self._assert_rendered(manager)
|
|
||||||
|
|
||||||
def test_recent_display_draws_ink(self, build_manager):
|
|
||||||
manager = build_manager(_RecentHarness, make_schedule())
|
|
||||||
manager.update()
|
|
||||||
manager._load_and_resize_logo = _fake_logo
|
|
||||||
assert manager.display(force_clear=True) is True
|
|
||||||
self._assert_rendered(manager)
|
|
||||||
|
|
||||||
def test_live_display_draws_ink(self, build_manager):
|
|
||||||
manager = build_manager(_LiveHarness, make_schedule())
|
|
||||||
manager.update()
|
|
||||||
manager._load_and_resize_logo = _fake_logo
|
|
||||||
assert manager.display(force_clear=True) is True
|
|
||||||
self._assert_rendered(manager)
|
|
||||||
|
|
||||||
def test_draw_scorebug_layout_direct_call_does_not_raise(
|
|
||||||
self, build_manager):
|
|
||||||
# The base-class placeholder renderer must also stay callable.
|
|
||||||
manager = build_manager(_UpcomingHarness, make_schedule())
|
|
||||||
game = manager._extract_game_details(make_schedule()["events"][2])
|
|
||||||
manager._load_and_resize_logo = _fake_logo
|
|
||||||
SportsCore._draw_scorebug_layout(manager, game)
|
|
||||||
assert manager.display_manager.image.convert("L").getbbox() is not None
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 4. Guard rails — seams the merge must not silently drop
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestGuardRails:
|
|
||||||
def test_skin_seam_methods_survive(self):
|
|
||||||
for name in ("_resolve_skin_id", "_get_skin", "_render_game",
|
|
||||||
"render_skin_card"):
|
|
||||||
assert callable(getattr(SportsCore, name, None)), (
|
|
||||||
f"SportsCore.{name} is part of the skin-system seam "
|
|
||||||
"(src/skin_system) — the sports-unification merge must "
|
|
||||||
"keep it.")
|
|
||||||
|
|
||||||
def test_skin_mode_per_class(self):
|
|
||||||
assert SportsCore.SKIN_MODE == "live"
|
|
||||||
assert SportsUpcoming.SKIN_MODE == "upcoming"
|
|
||||||
assert SportsRecent.SKIN_MODE == "recent"
|
|
||||||
assert SportsLive.SKIN_MODE == "live" # inherits the default
|
|
||||||
|
|
||||||
def test_core_display_and_extractor_seams_survive(self):
|
|
||||||
for name in ("display", "_draw_scorebug_layout",
|
|
||||||
"_extract_game_details_common", "update"):
|
|
||||||
owner = SportsCore if name != "update" else SportsUpcoming
|
|
||||||
assert callable(getattr(owner, name, None)), name
|
|
||||||
@@ -1,227 +0,0 @@
|
|||||||
"""
|
|
||||||
Regression tests: changed plugin data must reach the strip in continuous mode.
|
|
||||||
|
|
||||||
Two faults combined to freeze Vegas content indefinitely.
|
|
||||||
|
|
||||||
PR #291 added a call to ``plugin_adapter.invalidate_plugin_scroll_cache()`` so a
|
|
||||||
plugin's *own* cached scroll image would be rebuilt from fresh data. The method
|
|
||||||
was never implemented, and ``hot_swap_content()`` wraps the call in a broad
|
|
||||||
except, so every hot swap raised AttributeError and was silently swallowed.
|
|
||||||
|
|
||||||
Continuous scrolling then removed the only path that reached it at all:
|
|
||||||
``should_recompose()``/``hot_swap_content()`` are called from the non-continuous
|
|
||||||
branch, while ``continuous_scroll`` defaults to True.
|
|
||||||
|
|
||||||
Together, a plugin composed its scroll image once and handed back the same
|
|
||||||
picture forever, because the sports plugins' ``get_vegas_content()`` regenerates
|
|
||||||
only when its cache is empty. Symptom: a game that was live last night is still
|
|
||||||
drawn as live the following morning.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
from PIL import Image
|
|
||||||
|
|
||||||
from src.vegas_mode.config import VegasModeConfig
|
|
||||||
from src.vegas_mode.plugin_adapter import PluginAdapter
|
|
||||||
from src.vegas_mode.render_pipeline import RenderPipeline
|
|
||||||
from src.vegas_mode.stream_manager import StreamManager
|
|
||||||
|
|
||||||
|
|
||||||
class FakeDisplayManager:
|
|
||||||
width = 64
|
|
||||||
height = 32
|
|
||||||
|
|
||||||
|
|
||||||
def _helper():
|
|
||||||
"""A stand-in ScrollHelper holding both halves of its cache."""
|
|
||||||
image = Image.new('RGB', (128, 32), (10, 20, 30))
|
|
||||||
return SimpleNamespace(cached_image=image, cached_array=np.array(image))
|
|
||||||
|
|
||||||
|
|
||||||
class TestInvalidatePluginScrollCache:
|
|
||||||
"""The method PR #291 called but never defined."""
|
|
||||||
|
|
||||||
def test_method_exists(self):
|
|
||||||
# It was called for months without existing; the broad except in
|
|
||||||
# hot_swap_content() meant nothing ever surfaced.
|
|
||||||
assert hasattr(PluginAdapter, 'invalidate_plugin_scroll_cache')
|
|
||||||
|
|
||||||
def test_clears_helper_attached_to_the_plugin(self):
|
|
||||||
adapter = PluginAdapter(FakeDisplayManager(), VegasModeConfig())
|
|
||||||
helper = _helper()
|
|
||||||
plugin = SimpleNamespace(scroll_helper=helper)
|
|
||||||
|
|
||||||
assert adapter.invalidate_plugin_scroll_cache(plugin, 'stocks') is True
|
|
||||||
assert helper.cached_image is None
|
|
||||||
assert helper.cached_array is None
|
|
||||||
|
|
||||||
def test_clears_helper_owned_by_a_scroll_manager(self):
|
|
||||||
# The sports scoreboards keep theirs on _scroll_manager, which is the
|
|
||||||
# layout that produced the reported stale-scores bug.
|
|
||||||
adapter = PluginAdapter(FakeDisplayManager(), VegasModeConfig())
|
|
||||||
helper = _helper()
|
|
||||||
plugin = SimpleNamespace(_scroll_manager=SimpleNamespace(scroll_helper=helper))
|
|
||||||
|
|
||||||
assert adapter.invalidate_plugin_scroll_cache(plugin, 'baseball') is True
|
|
||||||
assert helper.cached_image is None
|
|
||||||
assert helper.cached_array is None
|
|
||||||
|
|
||||||
def test_clears_both_halves_together(self):
|
|
||||||
# cached_array is the image's numpy mirror; leaving one behind lets a
|
|
||||||
# reader pick up content the other no longer has.
|
|
||||||
adapter = PluginAdapter(FakeDisplayManager(), VegasModeConfig())
|
|
||||||
helper = _helper()
|
|
||||||
adapter.invalidate_plugin_scroll_cache(
|
|
||||||
SimpleNamespace(scroll_helper=helper), 'news')
|
|
||||||
assert (helper.cached_image, helper.cached_array) == (None, None)
|
|
||||||
|
|
||||||
def test_plugin_without_a_helper_is_not_an_error(self):
|
|
||||||
adapter = PluginAdapter(FakeDisplayManager(), VegasModeConfig())
|
|
||||||
assert adapter.invalidate_plugin_scroll_cache(SimpleNamespace(), 'clock') is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestInvalidatePendingUpdates:
|
|
||||||
def _manager(self, plugins):
|
|
||||||
stream = StreamManager(
|
|
||||||
VegasModeConfig(),
|
|
||||||
SimpleNamespace(plugins=plugins),
|
|
||||||
MagicMock(),
|
|
||||||
)
|
|
||||||
stream.plugin_adapter = MagicMock()
|
|
||||||
return stream
|
|
||||||
|
|
||||||
def test_drops_caches_for_updated_plugins(self):
|
|
||||||
helper = _helper()
|
|
||||||
plugin = SimpleNamespace(scroll_helper=helper)
|
|
||||||
stream = self._manager({'baseball': plugin})
|
|
||||||
stream.mark_plugin_updated('baseball')
|
|
||||||
|
|
||||||
assert stream.invalidate_pending_updates() == ['baseball']
|
|
||||||
stream.plugin_adapter.invalidate_cache.assert_called_once_with('baseball')
|
|
||||||
stream.plugin_adapter.invalidate_plugin_scroll_cache.assert_called_once_with(
|
|
||||||
plugin, 'baseball')
|
|
||||||
|
|
||||||
def test_pending_flags_are_consumed(self):
|
|
||||||
# Left unconsumed they accumulate forever and nothing ever refreshes.
|
|
||||||
stream = self._manager({'baseball': SimpleNamespace()})
|
|
||||||
stream.mark_plugin_updated('baseball')
|
|
||||||
assert stream.has_pending_updates() is True
|
|
||||||
|
|
||||||
stream.invalidate_pending_updates()
|
|
||||||
assert stream.has_pending_updates() is False
|
|
||||||
assert stream.invalidate_pending_updates() == []
|
|
||||||
|
|
||||||
def test_no_pending_updates_does_no_work(self):
|
|
||||||
stream = self._manager({})
|
|
||||||
assert stream.invalidate_pending_updates() == []
|
|
||||||
stream.plugin_adapter.invalidate_cache.assert_not_called()
|
|
||||||
|
|
||||||
def test_a_failing_plugin_does_not_stop_the_others(self):
|
|
||||||
stream = self._manager({'a': SimpleNamespace(), 'b': SimpleNamespace()})
|
|
||||||
stream.mark_plugin_updated('a')
|
|
||||||
stream.mark_plugin_updated('b')
|
|
||||||
stream.plugin_adapter.invalidate_cache.side_effect = [
|
|
||||||
RuntimeError('boom'), None]
|
|
||||||
|
|
||||||
assert sorted(stream.invalidate_pending_updates()) == ['a', 'b']
|
|
||||||
assert stream.plugin_adapter.invalidate_cache.call_count == 2
|
|
||||||
|
|
||||||
|
|
||||||
class TestContinuousModeReachesTheRefresh:
|
|
||||||
def _pipeline(self):
|
|
||||||
stream = MagicMock()
|
|
||||||
stream.get_buffer_status.return_value = {'staging_count': 0}
|
|
||||||
return RenderPipeline(VegasModeConfig(), FakeDisplayManager(), stream), stream
|
|
||||||
|
|
||||||
def test_refresh_delegates_to_the_stream_manager(self):
|
|
||||||
pipeline, stream = self._pipeline()
|
|
||||||
stream.invalidate_pending_updates.return_value = ['baseball']
|
|
||||||
assert pipeline.refresh_updated_plugins() is True
|
|
||||||
|
|
||||||
def test_refresh_reports_false_when_nothing_changed(self):
|
|
||||||
pipeline, stream = self._pipeline()
|
|
||||||
stream.invalidate_pending_updates.return_value = []
|
|
||||||
assert pipeline.refresh_updated_plugins() is False
|
|
||||||
|
|
||||||
def test_refresh_never_raises_into_the_render_loop(self):
|
|
||||||
pipeline, stream = self._pipeline()
|
|
||||||
stream.invalidate_pending_updates.side_effect = RuntimeError('boom')
|
|
||||||
assert pipeline.refresh_updated_plugins() is False
|
|
||||||
|
|
||||||
def test_refresh_does_not_reposition_the_scroll(self):
|
|
||||||
# The whole point of preferring this over hot_swap_content(): that path
|
|
||||||
# rebuilds and repositions, which reads as a freeze then a jump.
|
|
||||||
pipeline, stream = self._pipeline()
|
|
||||||
stream.invalidate_pending_updates.return_value = ['baseball']
|
|
||||||
pipeline.scroll_helper.scroll_position = 1234
|
|
||||||
|
|
||||||
pipeline.refresh_updated_plugins()
|
|
||||||
|
|
||||||
assert pipeline.scroll_helper.scroll_position == 1234
|
|
||||||
stream.swap_buffers.assert_not_called()
|
|
||||||
stream.process_updates.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
class TestCoordinatorWiring:
|
|
||||||
"""
|
|
||||||
The regression itself: continuous mode has to *call* the refresh.
|
|
||||||
|
|
||||||
should_recompose()/hot_swap_content() sit in the non-continuous branch, and
|
|
||||||
continuous_scroll defaults to True, so before this fix the refresh was
|
|
||||||
simply never reached on a default install.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def _coordinator(self, continuous):
|
|
||||||
import threading
|
|
||||||
|
|
||||||
from src.vegas_mode.coordinator import VegasModeCoordinator
|
|
||||||
|
|
||||||
config = VegasModeConfig()
|
|
||||||
config.continuous_scroll = continuous
|
|
||||||
# Built without __init__ so the test exercises run_frame's branching
|
|
||||||
# without standing up a display, stream and render stack.
|
|
||||||
coordinator = VegasModeCoordinator.__new__(VegasModeCoordinator)
|
|
||||||
coordinator.vegas_config = config
|
|
||||||
coordinator.render_pipeline = MagicMock()
|
|
||||||
coordinator.render_pipeline.has_deferred.return_value = False
|
|
||||||
coordinator.render_pipeline.needs_extension.return_value = False
|
|
||||||
coordinator.render_pipeline.is_cycle_complete.return_value = False
|
|
||||||
coordinator.render_pipeline.should_recompose.return_value = False
|
|
||||||
coordinator.stream_manager = MagicMock()
|
|
||||||
coordinator.stats = {'cycles_completed': 0}
|
|
||||||
coordinator._state_lock = threading.Lock()
|
|
||||||
coordinator._is_active = True
|
|
||||||
coordinator._is_paused = False
|
|
||||||
coordinator._should_stop = False
|
|
||||||
coordinator._pending_config_update = False
|
|
||||||
coordinator._live_priority_check = None
|
|
||||||
coordinator._interrupt_check = None
|
|
||||||
coordinator.sync_manager = None
|
|
||||||
return coordinator
|
|
||||||
|
|
||||||
def test_continuous_mode_refreshes_updated_plugins_every_frame(self):
|
|
||||||
coordinator = self._coordinator(continuous=True)
|
|
||||||
coordinator.run_frame()
|
|
||||||
coordinator.render_pipeline.refresh_updated_plugins.assert_called_once()
|
|
||||||
|
|
||||||
def test_continuous_mode_does_not_use_the_disruptive_swap(self):
|
|
||||||
coordinator = self._coordinator(continuous=True)
|
|
||||||
coordinator.run_frame()
|
|
||||||
coordinator.render_pipeline.hot_swap_content.assert_not_called()
|
|
||||||
|
|
||||||
def test_swap_mode_still_uses_hot_swap(self):
|
|
||||||
# The non-continuous path must keep its original behaviour.
|
|
||||||
coordinator = self._coordinator(continuous=False)
|
|
||||||
coordinator.render_pipeline.should_recompose.return_value = True
|
|
||||||
coordinator.run_frame()
|
|
||||||
coordinator.render_pipeline.hot_swap_content.assert_called_once()
|
|
||||||
coordinator.render_pipeline.refresh_updated_plugins.assert_not_called()
|
|
||||||
|
|
||||||
def test_a_frame_is_still_rendered_either_way(self):
|
|
||||||
for continuous in (True, False):
|
|
||||||
coordinator = self._coordinator(continuous=continuous)
|
|
||||||
coordinator.run_frame()
|
|
||||||
coordinator.render_pipeline.render_frame.assert_called_once()
|
|
||||||
@@ -1,370 +0,0 @@
|
|||||||
"""Tests for Vegas mode geometry primitives."""
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import pytest
|
|
||||||
from PIL import Image
|
|
||||||
|
|
||||||
from src.vegas_mode.geometry import (
|
|
||||||
DEFAULT_INK_THRESHOLD,
|
|
||||||
column_has_ink,
|
|
||||||
content_bounds,
|
|
||||||
dead_window_stats,
|
|
||||||
edge_blank,
|
|
||||||
find_blank_cut,
|
|
||||||
separation_gap,
|
|
||||||
trim_to_content,
|
|
||||||
window_coverage_stats,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def make_img(width, height=8, fill=(0, 0, 0)):
|
|
||||||
return Image.new('RGB', (width, height), fill)
|
|
||||||
|
|
||||||
|
|
||||||
def paint(img, x0, x1, color=(255, 255, 255)):
|
|
||||||
"""Fill columns [x0, x1) with a colour."""
|
|
||||||
block = Image.new('RGB', (x1 - x0, img.height), color)
|
|
||||||
img.paste(block, (x0, 0))
|
|
||||||
return img
|
|
||||||
|
|
||||||
|
|
||||||
class TestColumnHasInk:
|
|
||||||
def test_all_black_has_no_ink(self):
|
|
||||||
assert not column_has_ink(make_img(16)).any()
|
|
||||||
|
|
||||||
def test_marks_only_painted_columns(self):
|
|
||||||
img = paint(make_img(16), 4, 8)
|
|
||||||
ink = column_has_ink(img)
|
|
||||||
assert ink.tolist() == [False] * 4 + [True] * 4 + [False] * 8
|
|
||||||
|
|
||||||
def test_threshold_is_exclusive(self):
|
|
||||||
# A pixel exactly at the threshold is not ink; one above it is.
|
|
||||||
at = paint(make_img(4), 0, 4, (DEFAULT_INK_THRESHOLD,) * 3)
|
|
||||||
above = paint(make_img(4), 0, 4, (DEFAULT_INK_THRESHOLD + 1,) * 3)
|
|
||||||
assert not column_has_ink(at).any()
|
|
||||||
assert column_has_ink(above).all()
|
|
||||||
|
|
||||||
def test_single_bright_channel_counts(self):
|
|
||||||
img = paint(make_img(4), 1, 2, (0, 0, 200))
|
|
||||||
assert column_has_ink(img).tolist() == [False, True, False, False]
|
|
||||||
|
|
||||||
def test_one_lit_pixel_lights_the_column(self):
|
|
||||||
img = make_img(4, height=8)
|
|
||||||
img.putpixel((2, 5), (255, 255, 255))
|
|
||||||
assert column_has_ink(img).tolist() == [False, False, True, False]
|
|
||||||
|
|
||||||
|
|
||||||
class TestContentBounds:
|
|
||||||
def test_blank_returns_none(self):
|
|
||||||
assert content_bounds(make_img(16)) is None
|
|
||||||
|
|
||||||
def test_finds_inclusive_bounds(self):
|
|
||||||
assert content_bounds(paint(make_img(20), 5, 12)) == (5, 11)
|
|
||||||
|
|
||||||
def test_full_width_content(self):
|
|
||||||
assert content_bounds(paint(make_img(10), 0, 10)) == (0, 9)
|
|
||||||
|
|
||||||
def test_spans_interior_gap(self):
|
|
||||||
img = paint(make_img(30), 2, 5)
|
|
||||||
paint(img, 20, 25)
|
|
||||||
assert content_bounds(img) == (2, 24)
|
|
||||||
|
|
||||||
|
|
||||||
class TestTrimToContent:
|
|
||||||
def test_blank_image_reports_blank(self):
|
|
||||||
result = trim_to_content(make_img(512))
|
|
||||||
assert result.is_blank
|
|
||||||
assert result.image is None
|
|
||||||
assert result.width == 0
|
|
||||||
assert result.original_width == 512
|
|
||||||
|
|
||||||
def test_trims_both_edges(self):
|
|
||||||
result = trim_to_content(paint(make_img(512), 100, 150))
|
|
||||||
assert not result.is_blank
|
|
||||||
assert result.width == 50
|
|
||||||
assert result.trimmed_left == 100
|
|
||||||
assert result.trimmed_right == 362
|
|
||||||
assert result.removed == 462
|
|
||||||
|
|
||||||
def test_preserves_interior_gap(self):
|
|
||||||
# Two content blocks with a wide blank between them: the gap is the
|
|
||||||
# plugin's layout and must survive trimming.
|
|
||||||
img = paint(make_img(400), 50, 80)
|
|
||||||
paint(img, 300, 330)
|
|
||||||
result = trim_to_content(img)
|
|
||||||
assert result.width == 280 # 50..329 inclusive
|
|
||||||
assert column_has_ink(result.image).sum() == 60
|
|
||||||
|
|
||||||
def test_full_width_content_is_returned_unchanged(self):
|
|
||||||
img = paint(make_img(128), 0, 128)
|
|
||||||
result = trim_to_content(img)
|
|
||||||
assert result.image is img
|
|
||||||
assert result.removed == 0
|
|
||||||
|
|
||||||
def test_non_black_background_is_never_trimmed(self):
|
|
||||||
# A plugin drawing on a dark-but-not-black background fills every
|
|
||||||
# column with ink, so there is nothing to reclaim.
|
|
||||||
result = trim_to_content(make_img(256, fill=(0, 0, 40)))
|
|
||||||
assert result.removed == 0
|
|
||||||
assert result.width == 256
|
|
||||||
|
|
||||||
def test_padding_keeps_margin_up_to_what_exists(self):
|
|
||||||
result = trim_to_content(paint(make_img(512), 100, 150), padding=8)
|
|
||||||
assert result.trimmed_left == 92
|
|
||||||
assert result.width == 66 # 50 content + 8 each side
|
|
||||||
|
|
||||||
def test_padding_cannot_widen_beyond_original(self):
|
|
||||||
# Content starts 2px in; padding of 8 can only reclaim the 2 available.
|
|
||||||
result = trim_to_content(paint(make_img(64), 2, 60), padding=8)
|
|
||||||
assert result.trimmed_left == 0
|
|
||||||
assert result.trimmed_right == 0
|
|
||||||
assert result.width == 64
|
|
||||||
|
|
||||||
def test_height_is_preserved(self):
|
|
||||||
result = trim_to_content(paint(make_img(200, height=64), 10, 20))
|
|
||||||
assert result.image.height == 64
|
|
||||||
|
|
||||||
def test_real_world_of_the_day_case(self):
|
|
||||||
# Measured on devpi: "No Data" occupying 35px of a 512px canvas.
|
|
||||||
result = trim_to_content(paint(make_img(512, height=64), 4, 39))
|
|
||||||
assert result.width == 35
|
|
||||||
assert result.removed == 477
|
|
||||||
|
|
||||||
|
|
||||||
class TestDeadWindowStats:
|
|
||||||
def test_fully_inked_ticker_has_no_dead_windows(self):
|
|
||||||
stats = dead_window_stats(paint(make_img(400), 0, 400), viewport_width=100)
|
|
||||||
assert stats.dead_windows == 0
|
|
||||||
assert stats.dead_ratio == 0.0
|
|
||||||
assert stats.longest_dead_run == 0
|
|
||||||
|
|
||||||
def test_fully_blank_ticker_is_all_dead(self):
|
|
||||||
stats = dead_window_stats(make_img(400), viewport_width=100)
|
|
||||||
assert stats.total_windows == 301
|
|
||||||
assert stats.dead_windows == 301
|
|
||||||
assert stats.dead_ratio == 1.0
|
|
||||||
assert stats.longest_dead_run == 301
|
|
||||||
|
|
||||||
def test_leading_blank_run_is_measured(self):
|
|
||||||
# 512px of black then solid content: windows fully inside the black
|
|
||||||
# stretch are dead. With a 100px viewport, starts 0..412 exist and a
|
|
||||||
# window is dead while it holds >=95 blank columns.
|
|
||||||
img = paint(make_img(1024), 512, 1024)
|
|
||||||
stats = dead_window_stats(img, viewport_width=100)
|
|
||||||
assert stats.dead_windows == 418 # starts 0..417 keep >=95 blank cols
|
|
||||||
assert stats.longest_dead_run == 418
|
|
||||||
|
|
||||||
def test_narrow_content_island_still_leaves_dead_windows(self):
|
|
||||||
# 35px of content in a 512px field, viewed 100px at a time: no window
|
|
||||||
# can be 95% blank once it overlaps 35 lit columns, but the windows
|
|
||||||
# clear of it are dead.
|
|
||||||
img = paint(make_img(512), 100, 135)
|
|
||||||
stats = dead_window_stats(img, viewport_width=100)
|
|
||||||
assert stats.dead_windows > 0
|
|
||||||
assert stats.dead_ratio == pytest.approx(
|
|
||||||
stats.dead_windows / stats.total_windows
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_step_reduces_sampling(self):
|
|
||||||
img = paint(make_img(1000), 500, 1000)
|
|
||||||
exact = dead_window_stats(img, viewport_width=100, step=1)
|
|
||||||
strided = dead_window_stats(img, viewport_width=100, step=10)
|
|
||||||
assert strided.total_windows < exact.total_windows
|
|
||||||
# Same underlying shape, so the ratios should stay close.
|
|
||||||
assert strided.dead_ratio == pytest.approx(exact.dead_ratio, abs=0.02)
|
|
||||||
|
|
||||||
def test_image_narrower_than_viewport_is_one_window(self):
|
|
||||||
stats = dead_window_stats(make_img(50), viewport_width=100)
|
|
||||||
assert stats.total_windows == 1
|
|
||||||
assert stats.dead_windows == 1
|
|
||||||
|
|
||||||
def test_zero_viewport_is_handled(self):
|
|
||||||
stats = dead_window_stats(make_img(50), viewport_width=0)
|
|
||||||
assert stats.total_windows == 0
|
|
||||||
assert stats.dead_ratio == 0.0
|
|
||||||
|
|
||||||
def test_longest_run_picks_the_larger_of_two_gaps(self):
|
|
||||||
# Short blank gap, content, then a long blank gap.
|
|
||||||
img = make_img(1000)
|
|
||||||
paint(img, 150, 400)
|
|
||||||
paint(img, 500, 520)
|
|
||||||
stats = dead_window_stats(img, viewport_width=100)
|
|
||||||
# The 400..500 gap is only 100 wide; the tail from 520 is 480 wide.
|
|
||||||
assert stats.longest_dead_run >= 380
|
|
||||||
|
|
||||||
|
|
||||||
class TestWindowCoverageStats:
|
|
||||||
def test_solid_content_is_fully_covered(self):
|
|
||||||
stats = window_coverage_stats(paint(make_img(600), 0, 600), viewport_width=100)
|
|
||||||
assert stats.mean_ink_ratio == 1.0
|
|
||||||
assert stats.min_ink_ratio == 1.0
|
|
||||||
assert stats.sparse_windows == 0
|
|
||||||
|
|
||||||
def test_blank_strip_is_entirely_sparse(self):
|
|
||||||
stats = window_coverage_stats(make_img(600), viewport_width=100)
|
|
||||||
assert stats.mean_ink_ratio == 0.0
|
|
||||||
assert stats.sparse_ratio == 1.0
|
|
||||||
|
|
||||||
def test_catches_sliver_windows_that_dead_ratio_misses(self):
|
|
||||||
# Narrow content islands separated by more than the viewport. A window
|
|
||||||
# holding one whole 40px island carries 472 blank columns — under the
|
|
||||||
# 486 needed to count as "dead" — yet only 7.8% ink, so it still reads
|
|
||||||
# as an empty panel. Coverage must flag strictly more positions than
|
|
||||||
# the dead-window scan does.
|
|
||||||
img = paint(make_img(2000), 0, 40)
|
|
||||||
paint(img, 1000, 1040)
|
|
||||||
dead = dead_window_stats(img, viewport_width=512)
|
|
||||||
cover = window_coverage_stats(img, viewport_width=512, sparse_ink_ratio=0.10)
|
|
||||||
assert cover.sparse_windows > dead.dead_windows
|
|
||||||
assert cover.min_ink_ratio == 0.0
|
|
||||||
|
|
||||||
def test_adjacent_full_width_segments_stay_partially_covered(self):
|
|
||||||
# Documents why the dead-window scan alone understated the problem:
|
|
||||||
# two 512px segments with mid-canvas content never fully blank the
|
|
||||||
# viewport, they just hold it at a thin ~28%.
|
|
||||||
img = paint(make_img(1024), 185, 330)
|
|
||||||
paint(img, 697, 842)
|
|
||||||
dead = dead_window_stats(img, viewport_width=512)
|
|
||||||
cover = window_coverage_stats(img, viewport_width=512)
|
|
||||||
assert dead.dead_windows == 0
|
|
||||||
assert cover.mean_ink_ratio == pytest.approx(0.283, abs=0.01)
|
|
||||||
|
|
||||||
def test_min_ink_ratio_finds_the_worst_position(self):
|
|
||||||
# A wide blank tail guarantees at least one totally empty viewport.
|
|
||||||
img = paint(make_img(1200), 0, 200)
|
|
||||||
stats = window_coverage_stats(img, viewport_width=200)
|
|
||||||
assert stats.min_ink_ratio == 0.0
|
|
||||||
assert stats.mean_ink_ratio > 0.0
|
|
||||||
|
|
||||||
def test_sparse_threshold_is_respected(self):
|
|
||||||
# 40 inked columns in a 200px viewport = 20% coverage everywhere the
|
|
||||||
# island is fully inside the window.
|
|
||||||
img = paint(make_img(400), 100, 140)
|
|
||||||
lenient = window_coverage_stats(img, viewport_width=200, sparse_ink_ratio=0.05)
|
|
||||||
strict = window_coverage_stats(img, viewport_width=200, sparse_ink_ratio=0.50)
|
|
||||||
assert strict.sparse_windows > lenient.sparse_windows
|
|
||||||
|
|
||||||
def test_step_approximates_exact_scan(self):
|
|
||||||
img = paint(make_img(2000), 300, 500)
|
|
||||||
paint(img, 1200, 1400)
|
|
||||||
exact = window_coverage_stats(img, viewport_width=512, step=1)
|
|
||||||
strided = window_coverage_stats(img, viewport_width=512, step=4)
|
|
||||||
assert strided.mean_ink_ratio == pytest.approx(exact.mean_ink_ratio, abs=0.01)
|
|
||||||
|
|
||||||
def test_zero_viewport_is_handled(self):
|
|
||||||
stats = window_coverage_stats(make_img(50), viewport_width=0)
|
|
||||||
assert stats.total_windows == 0
|
|
||||||
assert stats.sparse_ratio == 0.0
|
|
||||||
|
|
||||||
def test_image_narrower_than_viewport(self):
|
|
||||||
stats = window_coverage_stats(paint(make_img(50), 0, 50), viewport_width=100)
|
|
||||||
assert stats.total_windows == 1
|
|
||||||
assert stats.mean_ink_ratio == pytest.approx(0.5)
|
|
||||||
|
|
||||||
|
|
||||||
class TestLongestRunHelper:
|
|
||||||
@pytest.mark.parametrize("flags,expected", [
|
|
||||||
([], 0),
|
|
||||||
([False, False], 0),
|
|
||||||
([True], 1),
|
|
||||||
([True, True, False, True], 2),
|
|
||||||
([False, True, True, True, False, True], 3),
|
|
||||||
([True, True, True], 3),
|
|
||||||
])
|
|
||||||
def test_run_lengths(self, flags, expected):
|
|
||||||
from src.vegas_mode.geometry import _longest_true_run
|
|
||||||
assert _longest_true_run(np.array(flags, dtype=bool)) == expected
|
|
||||||
|
|
||||||
|
|
||||||
class TestEdgeBlank:
|
|
||||||
def test_measures_both_edges(self):
|
|
||||||
assert edge_blank(paint(make_img(100), 20, 60)) == (20, 40)
|
|
||||||
|
|
||||||
def test_flush_content_has_no_blank(self):
|
|
||||||
assert edge_blank(paint(make_img(50), 0, 50)) == (0, 0)
|
|
||||||
|
|
||||||
def test_blank_image_reports_full_width_both_sides(self):
|
|
||||||
# No ink means nothing to be close to.
|
|
||||||
assert edge_blank(make_img(64)) == (64, 64)
|
|
||||||
|
|
||||||
|
|
||||||
class TestSeparationGap:
|
|
||||||
def test_flush_edges_get_the_full_target(self):
|
|
||||||
a = paint(make_img(50), 0, 50)
|
|
||||||
b = paint(make_img(50), 0, 50)
|
|
||||||
assert separation_gap(a, b, target=24) == 24
|
|
||||||
|
|
||||||
def test_existing_margins_reduce_the_added_gap(self):
|
|
||||||
# 8px blank on each facing edge already covers 16 of the 24 target.
|
|
||||||
a = paint(make_img(50), 0, 42)
|
|
||||||
b = paint(make_img(50), 8, 50)
|
|
||||||
assert separation_gap(a, b, target=24) == 8
|
|
||||||
|
|
||||||
def test_ample_existing_margin_adds_nothing(self):
|
|
||||||
a = paint(make_img(100), 0, 60)
|
|
||||||
b = paint(make_img(100), 40, 100)
|
|
||||||
assert separation_gap(a, b, target=24) == 0
|
|
||||||
|
|
||||||
def test_minimum_is_a_floor(self):
|
|
||||||
a = paint(make_img(100), 0, 60)
|
|
||||||
b = paint(make_img(100), 40, 100)
|
|
||||||
assert separation_gap(a, b, target=24, minimum=4) == 4
|
|
||||||
|
|
||||||
def test_never_negative(self):
|
|
||||||
a = paint(make_img(200), 0, 10)
|
|
||||||
b = paint(make_img(200), 190, 200)
|
|
||||||
assert separation_gap(a, b, target=8) == 0
|
|
||||||
|
|
||||||
def test_sports_card_case_gets_real_separation(self):
|
|
||||||
# The reported problem: cards drawn edge to edge sat 8px apart under a
|
|
||||||
# flat gap; measured separation lifts them to the 24px target.
|
|
||||||
card = paint(make_img(150), 0, 150)
|
|
||||||
assert separation_gap(card, card, target=24, minimum=8) == 24
|
|
||||||
|
|
||||||
|
|
||||||
class TestFindBlankCut:
|
|
||||||
def test_snaps_to_the_nearest_gap(self):
|
|
||||||
img = paint(make_img(200), 0, 90)
|
|
||||||
paint(img, 110, 200)
|
|
||||||
# 100 is inside the 90..110 gap already.
|
|
||||||
assert find_blank_cut(img, 100, 20) == 100
|
|
||||||
|
|
||||||
def test_walks_outwards_to_find_a_gap(self):
|
|
||||||
img = paint(make_img(200), 0, 95)
|
|
||||||
paint(img, 105, 200)
|
|
||||||
cut = find_blank_cut(img, 90, 20)
|
|
||||||
assert 95 <= cut < 105
|
|
||||||
|
|
||||||
def test_solid_ink_returns_the_target(self):
|
|
||||||
assert find_blank_cut(paint(make_img(200), 0, 200), 100, 20) == 100
|
|
||||||
|
|
||||||
def test_target_at_image_width_does_not_index_past_the_end(self):
|
|
||||||
# A cut after the last column is legal. Indexing ink[width] raised
|
|
||||||
# IndexError in the field, losing that plugin's content for the cycle.
|
|
||||||
# Reached once the rotation offset advances so start + budget lands
|
|
||||||
# exactly on the image width.
|
|
||||||
img = paint(make_img(1840), 0, 1840)
|
|
||||||
assert find_blank_cut(img, 1840, 32) == 1840
|
|
||||||
|
|
||||||
def test_target_past_image_width_is_clamped(self):
|
|
||||||
img = paint(make_img(100), 0, 100)
|
|
||||||
assert find_blank_cut(img, 500, 32) == 100
|
|
||||||
|
|
||||||
def test_target_at_width_with_a_trailing_gap_snaps_back(self):
|
|
||||||
# Content 0..179, blank 180..199. The nearest blank column to 200 is
|
|
||||||
# 199, not the start of the gap — nearest is what keeps the cut as
|
|
||||||
# close as possible to the requested budget.
|
|
||||||
img = paint(make_img(200), 0, 180)
|
|
||||||
assert find_blank_cut(img, 200, 32) == 199
|
|
||||||
|
|
||||||
def test_zero_radius_returns_the_target(self):
|
|
||||||
assert find_blank_cut(paint(make_img(100), 0, 100), 50, 0) == 50
|
|
||||||
|
|
||||||
def test_negative_target_is_clamped_to_zero(self):
|
|
||||||
assert find_blank_cut(paint(make_img(100), 0, 100), -20, 8) == 0
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("target", [0, 1, 50, 99, 100])
|
|
||||||
def test_never_raises_across_the_range(self, target):
|
|
||||||
img = paint(make_img(100), 0, 100)
|
|
||||||
cut = find_blank_cut(img, target, 16)
|
|
||||||
assert 0 <= cut <= 100
|
|
||||||
@@ -146,11 +146,6 @@ class TestConfigAPI:
|
|||||||
|
|
||||||
def test_save_double_sided_settings(self, client, mock_config_manager):
|
def test_save_double_sided_settings(self, client, mock_config_manager):
|
||||||
"""Double-sided form fields are persisted under display.double_sided."""
|
"""Double-sided form fields are persisted under display.double_sided."""
|
||||||
# 2 copies on the vertical axis needs parallel to be a multiple of 2.
|
|
||||||
mock_config_manager.load_config.return_value['display']['hardware'] = {
|
|
||||||
'chain_length': 2, 'parallel': 2,
|
|
||||||
}
|
|
||||||
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
'/api/v3/config/main',
|
'/api/v3/config/main',
|
||||||
data={
|
data={
|
||||||
@@ -180,91 +175,6 @@ class TestConfigAPI:
|
|||||||
assert ds['enabled'] is False
|
assert ds['enabled'] is False
|
||||||
assert ds['copies'] == 4
|
assert ds['copies'] == 4
|
||||||
|
|
||||||
def test_save_double_sided_disabled_skips_divisibility_check(self, client, mock_config_manager):
|
|
||||||
"""A copies/chain_length mismatch must not block saves while disabled.
|
|
||||||
|
|
||||||
The Display form posts copies/axis on every save, so validating them
|
|
||||||
with the feature off locked users out of every other display setting.
|
|
||||||
"""
|
|
||||||
mock_config_manager.load_config.return_value['display']['hardware'] = {
|
|
||||||
'chain_length': 3, 'parallel': 1,
|
|
||||||
}
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
'/api/v3/config/main',
|
|
||||||
data={
|
|
||||||
'double_sided_copies': '2',
|
|
||||||
'double_sided_axis': 'horizontal',
|
|
||||||
'brightness': '75',
|
|
||||||
},
|
|
||||||
content_type='application/x-www-form-urlencoded',
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
ds = mock_config_manager.save_config_atomic.call_args[0][0]['display']['double_sided']
|
|
||||||
assert ds['enabled'] is False
|
|
||||||
assert ds['copies'] == 2
|
|
||||||
|
|
||||||
def test_save_double_sided_enabled_enforces_divisibility(self, client, mock_config_manager):
|
|
||||||
"""The same mismatch is still rejected once the feature is turned on."""
|
|
||||||
mock_config_manager.load_config.return_value['display']['hardware'] = {
|
|
||||||
'chain_length': 3, 'parallel': 1,
|
|
||||||
}
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
'/api/v3/config/main',
|
|
||||||
data={
|
|
||||||
'double_sided_enabled': 'true',
|
|
||||||
'double_sided_copies': '2',
|
|
||||||
'double_sided_axis': 'horizontal',
|
|
||||||
},
|
|
||||||
content_type='application/x-www-form-urlencoded',
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert 'chain length' in response.get_json()['message']
|
|
||||||
mock_config_manager.save_config_atomic.assert_not_called()
|
|
||||||
|
|
||||||
def test_save_double_sided_vertical_checks_parallel(self, client, mock_config_manager):
|
|
||||||
"""The vertical axis is checked against parallel, not chain_length."""
|
|
||||||
mock_config_manager.load_config.return_value['display']['hardware'] = {
|
|
||||||
'chain_length': 2, 'parallel': 3,
|
|
||||||
}
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
'/api/v3/config/main',
|
|
||||||
data={
|
|
||||||
'double_sided_enabled': 'true',
|
|
||||||
'double_sided_copies': '2',
|
|
||||||
'double_sided_axis': 'vertical',
|
|
||||||
},
|
|
||||||
content_type='application/x-www-form-urlencoded',
|
|
||||||
)
|
|
||||||
|
|
||||||
# chain_length 2 would divide evenly — only parallel 3 rejects this.
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert 'parallel' in response.get_json()['message']
|
|
||||||
mock_config_manager.save_config_atomic.assert_not_called()
|
|
||||||
|
|
||||||
def test_save_double_sided_disabled_ignores_bad_values(self, client, mock_config_manager):
|
|
||||||
"""While disabled, unusable copies/axis are dropped rather than rejected."""
|
|
||||||
mock_config_manager.load_config.return_value['display']['double_sided'] = {
|
|
||||||
'enabled': True, 'copies': 2, 'axis': 'horizontal',
|
|
||||||
}
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
'/api/v3/config/main',
|
|
||||||
data={'double_sided_copies': 'abc', 'double_sided_axis': 'diagonal'},
|
|
||||||
content_type='application/x-www-form-urlencoded',
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
ds = mock_config_manager.save_config_atomic.call_args[0][0]['display']['double_sided']
|
|
||||||
assert ds['enabled'] is False
|
|
||||||
# Stored values left untouched rather than overwritten with junk.
|
|
||||||
assert ds['copies'] == 2
|
|
||||||
assert ds['axis'] == 'horizontal'
|
|
||||||
|
|
||||||
def test_save_double_sided_invalid_copies_rejected(self, client, mock_config_manager):
|
def test_save_double_sided_invalid_copies_rejected(self, client, mock_config_manager):
|
||||||
"""copies < 2 is rejected with a 400 before any save."""
|
"""copies < 2 is rejected with a 400 before any save."""
|
||||||
response = client.post(
|
response = client.post(
|
||||||
@@ -467,66 +377,6 @@ class TestPluginsAPI:
|
|||||||
data = json.loads(response.data)
|
data = json.loads(response.data)
|
||||||
assert isinstance(data, (list, dict))
|
assert isinstance(data, (list, dict))
|
||||||
|
|
||||||
def test_installed_plugins_report_update_available(self, client, mock_plugin_manager):
|
|
||||||
"""Installed-plugin entries surface latest_version + update_available
|
|
||||||
by comparing the on-disk manifest version to the registry."""
|
|
||||||
from web_interface.blueprints.api_v3 import api_v3
|
|
||||||
api_v3.plugin_manager = mock_plugin_manager
|
|
||||||
# No on-disk manifest to merge — keep the version we hand in below.
|
|
||||||
mock_plugin_manager.plugins_dir = '/nonexistent-plugins-dir'
|
|
||||||
mock_plugin_manager.get_all_plugin_info.return_value = [
|
|
||||||
{'id': 'weather', 'name': 'Weather', 'version': '1.0.0'}
|
|
||||||
]
|
|
||||||
# Avoid touching plugin instances (Vegas hooks, enabled fallback).
|
|
||||||
mock_plugin_manager.get_plugin.return_value = None
|
|
||||||
# Registry advertises a newer version than the installed one.
|
|
||||||
api_v3.plugin_store_manager.get_registry_info.return_value = {
|
|
||||||
'verified': True, 'latest_version': '1.2.0'
|
|
||||||
}
|
|
||||||
|
|
||||||
response = client.get('/api/v3/plugins/installed')
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
payload = json.loads(response.data)
|
|
||||||
entry = payload['data']['plugins'][0]
|
|
||||||
assert entry['version'] == '1.0.0'
|
|
||||||
assert entry['latest_version'] == '1.2.0'
|
|
||||||
assert entry['update_available'] is True
|
|
||||||
|
|
||||||
def test_installed_plugins_no_update_when_current(self, client, mock_plugin_manager):
|
|
||||||
"""No update is flagged when installed version matches the registry."""
|
|
||||||
from web_interface.blueprints.api_v3 import api_v3
|
|
||||||
api_v3.plugin_manager = mock_plugin_manager
|
|
||||||
mock_plugin_manager.plugins_dir = '/nonexistent-plugins-dir'
|
|
||||||
mock_plugin_manager.get_all_plugin_info.return_value = [
|
|
||||||
{'id': 'weather', 'name': 'Weather', 'version': '1.2.0'}
|
|
||||||
]
|
|
||||||
mock_plugin_manager.get_plugin.return_value = None
|
|
||||||
api_v3.plugin_store_manager.get_registry_info.return_value = {
|
|
||||||
'verified': True, 'latest_version': '1.2.0'
|
|
||||||
}
|
|
||||||
|
|
||||||
response = client.get('/api/v3/plugins/installed')
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
entry = json.loads(response.data)['data']['plugins'][0]
|
|
||||||
assert entry['latest_version'] == '1.2.0'
|
|
||||||
assert entry['update_available'] is False
|
|
||||||
|
|
||||||
def test_is_plugin_update_available_helper(self):
|
|
||||||
"""Unit-level checks for the semver-aware update comparison."""
|
|
||||||
from web_interface.blueprints.api_v3 import _is_plugin_update_available
|
|
||||||
assert _is_plugin_update_available('1.0.0', '1.0.1') is True
|
|
||||||
assert _is_plugin_update_available('1.0.1', '1.0.1') is False
|
|
||||||
# Local build ahead of the registry must not be flagged.
|
|
||||||
assert _is_plugin_update_available('2.0.0', '1.9.9') is False
|
|
||||||
# Missing either side yields no signal.
|
|
||||||
assert _is_plugin_update_available('', '1.0.0') is False
|
|
||||||
assert _is_plugin_update_available('1.0.0', '') is False
|
|
||||||
# Unparseable version differing from the installed one surfaces the
|
|
||||||
# mismatch rather than hiding a possible update.
|
|
||||||
assert _is_plugin_update_available('1.0.0', 'not-a-semver') is True
|
|
||||||
|
|
||||||
def test_get_plugin_health(self, client, mock_plugin_manager):
|
def test_get_plugin_health(self, client, mock_plugin_manager):
|
||||||
"""Test getting plugin health information."""
|
"""Test getting plugin health information."""
|
||||||
from web_interface.blueprints.api_v3 import api_v3
|
from web_interface.blueprints.api_v3 import api_v3
|
||||||
|
|||||||
@@ -1,184 +0,0 @@
|
|||||||
"""
|
|
||||||
Web-UI smoke tests: every page, partial, and critical static asset must render.
|
|
||||||
|
|
||||||
These boot the pages blueprint with the same dual registration app.py uses
|
|
||||||
(un-prefixed primary + /v3 legacy alias) and assert each surface returns 200
|
|
||||||
with its load-bearing markers present. They exist to catch, in CI, the class
|
|
||||||
of regression that only shows up when a real request renders a real template:
|
|
||||||
a broken partial, a missing tab wiring, a renamed element id that JS depends
|
|
||||||
on, or a static asset that stopped being served.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from flask import Flask
|
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).parent.parent
|
|
||||||
sys.path.insert(0, str(PROJECT_ROOT))
|
|
||||||
|
|
||||||
|
|
||||||
SMOKE_CONFIG = {
|
|
||||||
"web_display_autostart": True,
|
|
||||||
"timezone": "America/Chicago",
|
|
||||||
"location": {"city": "Dallas", "state": "Texas", "country": "US"},
|
|
||||||
"plugin_system": {
|
|
||||||
"auto_discover": True,
|
|
||||||
"auto_load_enabled": True,
|
|
||||||
"development_mode": False,
|
|
||||||
"plugins_directory": "plugin-repos",
|
|
||||||
},
|
|
||||||
"schedule": {},
|
|
||||||
"dim_schedule": {"dim_brightness": 30},
|
|
||||||
"sync": {"role": "standalone", "port": 5765, "follower_position": "left"},
|
|
||||||
"clock": {"enabled": True},
|
|
||||||
"ledmatrix-weather": {"enabled": True},
|
|
||||||
"display": {
|
|
||||||
"hardware": {
|
|
||||||
"rows": 32, "cols": 64, "chain_length": 2, "parallel": 1,
|
|
||||||
"brightness": 95, "hardware_mapping": "adafruit-hat-pwm",
|
|
||||||
"led_rgb_sequence": "RGB", "multiplexing": 0, "panel_type": "",
|
|
||||||
"row_address_type": 0, "scan_mode": 0, "pwm_bits": 9,
|
|
||||||
"pwm_dither_bits": 1, "pwm_lsb_nanoseconds": 130,
|
|
||||||
"limit_refresh_rate_hz": 120, "disable_hardware_pulsing": False,
|
|
||||||
"inverse_colors": False, "show_refresh_rate": False,
|
|
||||||
},
|
|
||||||
"runtime": {"gpio_slowdown": 3, "rp1_rio": 0},
|
|
||||||
"double_sided": {"enabled": False, "copies": 2, "axis": "horizontal"},
|
|
||||||
"use_short_date_format": False,
|
|
||||||
"dynamic_duration": {"max_duration_seconds": 180},
|
|
||||||
"vegas_scroll": {
|
|
||||||
"enabled": False, "scroll_speed": 50, "separator_width": 32,
|
|
||||||
"target_fps": 125, "buffer_ahead": 2,
|
|
||||||
"plugin_order": [], "excluded_plugins": [],
|
|
||||||
},
|
|
||||||
"display_durations": {"stale_saved_mode": 45},
|
|
||||||
"plugin_rotation_order": ["ledmatrix-weather", "clock"],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
PLUGIN_MODES = {
|
|
||||||
"clock": ["clock"],
|
|
||||||
"ledmatrix-weather": ["weather_current", "weather_daily"],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def client():
|
|
||||||
base = PROJECT_ROOT / "web_interface"
|
|
||||||
app = Flask(
|
|
||||||
__name__,
|
|
||||||
template_folder=str(base / "templates"),
|
|
||||||
static_folder=str(base / "static"),
|
|
||||||
)
|
|
||||||
app.config["TESTING"] = True
|
|
||||||
|
|
||||||
from web_interface.blueprints import pages_v3 as pv
|
|
||||||
|
|
||||||
# pages_v3 is a module-level Blueprint singleton shared by the whole test
|
|
||||||
# process (test_web_settings_ui.py mutates the same attributes) - save
|
|
||||||
# the originals and restore them on teardown so this fixture can't leak
|
|
||||||
# its mocks into tests that run afterward.
|
|
||||||
original_config_manager = getattr(pv.pages_v3, "config_manager", None)
|
|
||||||
original_plugin_manager = getattr(pv.pages_v3, "plugin_manager", None)
|
|
||||||
|
|
||||||
mock_cm = MagicMock()
|
|
||||||
mock_cm.load_config.return_value = SMOKE_CONFIG
|
|
||||||
mock_cm.get_raw_file_content.return_value = SMOKE_CONFIG
|
|
||||||
mock_cm.get_config_path.return_value = "config/config.json"
|
|
||||||
mock_cm.get_secrets_path.return_value = "config/config_secrets.json"
|
|
||||||
pv.pages_v3.config_manager = mock_cm
|
|
||||||
|
|
||||||
mock_pm = MagicMock()
|
|
||||||
mock_pm.plugins = {}
|
|
||||||
mock_pm.get_all_plugin_info.return_value = [
|
|
||||||
{"id": "clock", "name": "Clock"},
|
|
||||||
{"id": "ledmatrix-weather", "name": "Weather"},
|
|
||||||
]
|
|
||||||
mock_pm.get_plugin_display_modes.side_effect = (
|
|
||||||
lambda pid: PLUGIN_MODES.get(pid, [])
|
|
||||||
)
|
|
||||||
pv.pages_v3.plugin_manager = mock_pm
|
|
||||||
|
|
||||||
# Same dual registration as web_interface/app.py: un-prefixed primary,
|
|
||||||
# /v3 kept as a working legacy alias.
|
|
||||||
app.register_blueprint(pv.pages_v3, url_prefix="")
|
|
||||||
app.register_blueprint(pv.pages_v3, url_prefix="/v3", name="pages_v3_legacy")
|
|
||||||
try:
|
|
||||||
yield app.test_client()
|
|
||||||
finally:
|
|
||||||
pv.pages_v3.config_manager = original_config_manager
|
|
||||||
pv.pages_v3.plugin_manager = original_plugin_manager
|
|
||||||
|
|
||||||
|
|
||||||
# (path, [markers that must appear in the body])
|
|
||||||
PAGES = [
|
|
||||||
("/", ["site-nav", "mobileNavOpen", 'rel="manifest"',
|
|
||||||
"restart-pending-banner", "activeTab = 'durations'"]),
|
|
||||||
("/partials/overview", ["getting-started-card", "displayImage"]),
|
|
||||||
("/partials/general", ["timezone"]),
|
|
||||||
("/partials/display", ["display-section-advanced-hardware",
|
|
||||||
"display-resolution-value", "vegas_scroll_label"]),
|
|
||||||
("/partials/durations", ["rotation_plugin_order", "duration__clock",
|
|
||||||
"duration__weather_current",
|
|
||||||
"duration__stale_saved_mode"]),
|
|
||||||
("/partials/schedule", ["schedule"]),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("path,markers", PAGES, ids=[p for p, _ in PAGES])
|
|
||||||
def test_page_renders_with_markers(client, path, markers):
|
|
||||||
resp = client.get(path)
|
|
||||||
assert resp.status_code == 200, f"{path} -> {resp.status_code}"
|
|
||||||
body = resp.get_data(as_text=True)
|
|
||||||
for marker in markers:
|
|
||||||
assert marker in body, f"{path}: missing marker {marker!r}"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("path", [p for p, _ in PAGES if p != "/"])
|
|
||||||
def test_legacy_v3_alias_serves_the_same_partials(client, path):
|
|
||||||
assert client.get("/v3" + path).status_code == 200
|
|
||||||
|
|
||||||
|
|
||||||
STATIC_ASSETS = [
|
|
||||||
"/static/v3/app.css",
|
|
||||||
"/static/v3/app.js",
|
|
||||||
"/static/v3/manifest.json",
|
|
||||||
"/static/v3/icons/icon-192.png",
|
|
||||||
"/static/v3/js/app-shell.js",
|
|
||||||
"/static/v3/js/app-early.js",
|
|
||||||
"/static/v3/js/htmx-config.js",
|
|
||||||
"/static/v3/js/widgets/plugin-order-list.js",
|
|
||||||
"/static/v3/js/widgets/notification.js",
|
|
||||||
"/static/v3/vendor/fontawesome/css/all.min.css",
|
|
||||||
"/static/v3/vendor/codemirror/codemirror.min.js",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("asset", STATIC_ASSETS)
|
|
||||||
def test_static_asset_served(client, asset):
|
|
||||||
resp = client.get(asset)
|
|
||||||
assert resp.status_code == 200, f"{asset} -> {resp.status_code}"
|
|
||||||
assert len(resp.data) > 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_durations_page_groups_by_plugin(client):
|
|
||||||
"""One duration input per display mode of each enabled plugin, plus the
|
|
||||||
leftover group for saved keys no enabled plugin owns."""
|
|
||||||
body = client.get("/partials/durations").get_data(as_text=True)
|
|
||||||
assert body.count("duration__") >= 2 * len(
|
|
||||||
[m for modes in PLUGIN_MODES.values() for m in modes]
|
|
||||||
) # each mode: id= and name=
|
|
||||||
assert "Other saved entries" in body
|
|
||||||
|
|
||||||
|
|
||||||
def test_display_advanced_section_contains_tuning_fields(client):
|
|
||||||
body = client.get("/partials/display").get_data(as_text=True)
|
|
||||||
adv = body.find('id="display-section-advanced-hardware"')
|
|
||||||
adv_close = body.find("/#display-section-advanced-hardware")
|
|
||||||
assert 0 < adv < adv_close
|
|
||||||
for field in ["multiplexing", "pwm_bits", "inverse_colors"]:
|
|
||||||
pos = body.find(f'name="{field}"')
|
|
||||||
assert adv < pos < adv_close, f"{field} not inside the advanced section"
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
"""
|
|
||||||
Static-analysis audits for the web UI, as tests so CI enforces them.
|
|
||||||
|
|
||||||
1. Breakpoint utility audit: app.css hand-maintains a Tailwind-style utility
|
|
||||||
subset, so a template can reference a responsive class (e.g. sm:block)
|
|
||||||
that no CSS rule defines — it silently no-ops. This once left the header
|
|
||||||
search box and system stats invisible at every screen width. The audit
|
|
||||||
diffs classes used in templates against classes defined in app.css.
|
|
||||||
|
|
||||||
2. Asset reference audit: every url_for('static', filename=...) in the
|
|
||||||
templates must point to a file that exists, so a renamed/moved asset
|
|
||||||
can't ship as a broken <script>/<link>/<img>.
|
|
||||||
|
|
||||||
3. debugLog globals audit: any static JS file calling debugLog() (a global
|
|
||||||
defined in base.html) must declare it in a /* global */ header so linting
|
|
||||||
stays clean and the dependency is explicit.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import re
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).parent.parent
|
|
||||||
WEB = PROJECT_ROOT / "web_interface"
|
|
||||||
TEMPLATES = WEB / "templates"
|
|
||||||
STATIC = WEB / "static"
|
|
||||||
APP_CSS = STATIC / "v3" / "app.css"
|
|
||||||
|
|
||||||
BP_PREFIXES = ("sm", "md", "lg", "xl", "2xl")
|
|
||||||
|
|
||||||
|
|
||||||
def _template_files():
|
|
||||||
return sorted(TEMPLATES.rglob("*.html"))
|
|
||||||
|
|
||||||
|
|
||||||
def test_every_used_breakpoint_class_is_defined():
|
|
||||||
used = set()
|
|
||||||
class_attr = re.compile(r'class="([^"]*)"')
|
|
||||||
bp_class = re.compile(r"\b(%s):[A-Za-z0-9_.-]+" % "|".join(BP_PREFIXES))
|
|
||||||
for path in _template_files():
|
|
||||||
for attr in class_attr.findall(path.read_text()):
|
|
||||||
for m in bp_class.finditer(attr):
|
|
||||||
used.add(m.group(0))
|
|
||||||
|
|
||||||
css = APP_CSS.read_text()
|
|
||||||
defined = {
|
|
||||||
m.group(0).lstrip(".").replace("\\:", ":")
|
|
||||||
for m in re.finditer(
|
|
||||||
r"\.(%s)\\:[A-Za-z0-9_-]+" % "|".join(BP_PREFIXES), css
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
missing = sorted(used - defined)
|
|
||||||
assert not missing, (
|
|
||||||
"Responsive utility classes referenced in templates but never defined "
|
|
||||||
f"in app.css (they silently no-op): {missing}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_every_static_url_for_points_to_a_real_file():
|
|
||||||
ref = re.compile(
|
|
||||||
r"url_for\(\s*['\"]static['\"]\s*,\s*filename\s*=\s*['\"]([^'\"]+)['\"]"
|
|
||||||
)
|
|
||||||
missing = []
|
|
||||||
for path in _template_files():
|
|
||||||
for filename in ref.findall(path.read_text()):
|
|
||||||
if not (STATIC / filename).is_file():
|
|
||||||
missing.append(f"{path.relative_to(PROJECT_ROOT)}: {filename}")
|
|
||||||
assert not missing, f"Templates reference missing static assets: {missing}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_js_files_calling_debuglog_declare_the_global():
|
|
||||||
undeclared = []
|
|
||||||
for path in sorted((STATIC / "v3").rglob("*.js")):
|
|
||||||
if "vendor" in path.parts:
|
|
||||||
continue
|
|
||||||
text = path.read_text()
|
|
||||||
# Calls debugLog( but neither defines it nor declares the global
|
|
||||||
calls = re.search(r"(?<![.\w])debugLog\(", text)
|
|
||||||
defines = "window.debugLog" in text
|
|
||||||
declares = re.search(r"/\*\s*global[^*]*\bdebugLog\b", text)
|
|
||||||
if calls and not defines and not declares:
|
|
||||||
undeclared.append(str(path.relative_to(PROJECT_ROOT)))
|
|
||||||
assert not undeclared, (
|
|
||||||
f"JS files call debugLog() without a /* global debugLog */ header: {undeclared}"
|
|
||||||
)
|
|
||||||
@@ -59,20 +59,6 @@ except ImportError:
|
|||||||
# flask-limiter not installed, rate limiting disabled
|
# flask-limiter not installed, rate limiting disabled
|
||||||
limiter = None
|
limiter = None
|
||||||
|
|
||||||
# Enable gzip/brotli response compression (Flask-Compress skips streaming
|
|
||||||
# responses, so the SSE endpoints are unaffected). Optional, like limiter:
|
|
||||||
# missing package just means uncompressed responses.
|
|
||||||
try:
|
|
||||||
from flask_compress import Compress
|
|
||||||
|
|
||||||
Compress(app)
|
|
||||||
except ImportError:
|
|
||||||
logging.getLogger(__name__).warning(
|
|
||||||
"flask-compress not installed - responses will be served uncompressed. "
|
|
||||||
"Install it with the Tools tab's 'Install Base Requirements' button or "
|
|
||||||
"'pip install flask-compress'."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Import cache functions from separate module to avoid circular imports
|
# Import cache functions from separate module to avoid circular imports
|
||||||
|
|
||||||
# Initialize plugin managers - read plugins directory from config
|
# Initialize plugin managers - read plugins directory from config
|
||||||
@@ -190,12 +176,7 @@ except Exception as _hm_err: # pragma: no cover - defensive startup guard
|
|||||||
"Could not enable plugin health/metrics for web UI: %s", _hm_err
|
"Could not enable plugin health/metrics for web UI: %s", _hm_err
|
||||||
)
|
)
|
||||||
|
|
||||||
# Pages are served un-prefixed (the interface lives at /); the /v3 mount is a
|
app.register_blueprint(pages_v3, url_prefix='/v3')
|
||||||
# legacy alias kept so existing bookmarks and the hardcoded /v3/partials/...
|
|
||||||
# fetches in templates/JS keep working unchanged. url_for('pages_v3.*')
|
|
||||||
# resolves against the primary (un-prefixed) registration.
|
|
||||||
app.register_blueprint(pages_v3, url_prefix='')
|
|
||||||
app.register_blueprint(pages_v3, url_prefix='/v3', name='pages_v3_legacy')
|
|
||||||
app.register_blueprint(api_v3, url_prefix='/api/v3')
|
app.register_blueprint(api_v3, url_prefix='/api/v3')
|
||||||
|
|
||||||
# Route to serve plugin asset files (registered on main app, not blueprint, for /assets/... path)
|
# Route to serve plugin asset files (registered on main app, not blueprint, for /assets/... path)
|
||||||
@@ -426,11 +407,7 @@ def captive_portal_redirect():
|
|||||||
|
|
||||||
# List of paths that should NOT be redirected (allow normal operation)
|
# List of paths that should NOT be redirected (allow normal operation)
|
||||||
allowed_paths = [
|
allowed_paths = [
|
||||||
'/v3', # Legacy-prefixed interface and all sub-paths
|
'/v3', # Main interface and all sub-paths (includes /v3/setup)
|
||||||
'/setup', # Captive setup page itself (un-prefixed mount)
|
|
||||||
'/partials/', # HTMX partials (un-prefixed mount)
|
|
||||||
'/settings/', # Settings search index (un-prefixed mount)
|
|
||||||
'/plugin-ui/', # Plugin-provided web UI assets (un-prefixed mount)
|
|
||||||
'/api/v3/', # All API endpoints
|
'/api/v3/', # All API endpoints
|
||||||
'/static/', # Static files (CSS, JS, images)
|
'/static/', # Static files (CSS, JS, images)
|
||||||
'/hotspot-detect.html', # iOS/macOS detection
|
'/hotspot-detect.html', # iOS/macOS detection
|
||||||
@@ -629,6 +606,8 @@ def system_status_generator():
|
|||||||
def display_preview_generator():
|
def display_preview_generator():
|
||||||
"""Generate display preview updates from snapshot file"""
|
"""Generate display preview updates from snapshot file"""
|
||||||
import base64
|
import base64
|
||||||
|
from PIL import Image
|
||||||
|
import io
|
||||||
|
|
||||||
snapshot_path = "/tmp/led_matrix_preview.png" # nosec B108 - fixed path matches display_manager; only read here
|
snapshot_path = "/tmp/led_matrix_preview.png" # nosec B108 - fixed path matches display_manager; only read here
|
||||||
# Viewer marker: this generator only runs while the broadcaster has
|
# Viewer marker: this generator only runs while the broadcaster has
|
||||||
@@ -670,26 +649,24 @@ def display_preview_generator():
|
|||||||
# Only read if file is new or has been updated
|
# Only read if file is new or has been updated
|
||||||
if last_modified is None or current_modified > last_modified:
|
if last_modified is None or current_modified > last_modified:
|
||||||
try:
|
try:
|
||||||
# The snapshot is already a PNG, written atomically by
|
# Read and encode the image
|
||||||
# the display service (tmp + os.replace in
|
with Image.open(snapshot_path) as img:
|
||||||
# display_manager), so pass the raw bytes straight
|
# Convert to PNG and encode as base64
|
||||||
# through instead of PIL-decoding and re-encoding —
|
buffer = io.BytesIO()
|
||||||
# identical payload, much less CPU on the Pi.
|
img.save(buffer, format='PNG')
|
||||||
with open(snapshot_path, 'rb') as f:
|
img_str = base64.b64encode(buffer.getvalue()).decode('utf-8')
|
||||||
img_str = base64.b64encode(f.read()).decode('utf-8')
|
|
||||||
|
|
||||||
preview_data = {
|
preview_data = {
|
||||||
'timestamp': time.time(),
|
'timestamp': time.time(),
|
||||||
'width': width,
|
'width': width,
|
||||||
'height': height,
|
'height': height,
|
||||||
'image': img_str
|
'image': img_str
|
||||||
}
|
}
|
||||||
last_modified = current_modified
|
last_modified = current_modified
|
||||||
yield preview_data
|
yield preview_data
|
||||||
except OSError:
|
except Exception: # nosec B110 - SSE preview file may be mid-write; transient error, skip this update
|
||||||
# Transient filesystem race (file rotated/replaced
|
# File might be being written, skip this update
|
||||||
# between mtime check and read); skip this update.
|
pass
|
||||||
app.logger.debug("Preview snapshot read failed; skipping frame", exc_info=True)
|
|
||||||
else:
|
else:
|
||||||
# No snapshot available
|
# No snapshot available
|
||||||
yield {
|
yield {
|
||||||
@@ -822,8 +799,11 @@ if limiter:
|
|||||||
limiter.limit("200 per minute")(stream_display)
|
limiter.limit("200 per minute")(stream_display)
|
||||||
limiter.limit("200 per minute")(stream_logs)
|
limiter.limit("200 per minute")(stream_logs)
|
||||||
|
|
||||||
# The pages blueprint's index now serves '/' directly (see the un-prefixed
|
# Main route - redirect to v3 interface as default
|
||||||
# blueprint registration above), so no redirect route is needed here.
|
@app.route('/')
|
||||||
|
def index():
|
||||||
|
"""Redirect to v3 interface"""
|
||||||
|
return redirect(url_for('pages_v3.index'))
|
||||||
|
|
||||||
@app.route('/favicon.ico')
|
@app.route('/favicon.ico')
|
||||||
def favicon():
|
def favicon():
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import uuid
|
|||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Any, Optional
|
from typing import Dict, Any
|
||||||
from urllib.parse import urlparse, urlunparse
|
from urllib.parse import urlparse, urlunparse
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -120,33 +120,6 @@ def _get_plugin_version(plugin_id: str) -> str:
|
|||||||
logger.warning("[PluginVersion] Invalid JSON in manifest for %s at %s: %s", plugin_id, manifest_path, e)
|
logger.warning("[PluginVersion] Invalid JSON in manifest for %s at %s: %s", plugin_id, manifest_path, e)
|
||||||
return ''
|
return ''
|
||||||
|
|
||||||
def _is_plugin_update_available(installed_version: str, latest_version: str) -> bool:
|
|
||||||
"""Return True when the registry's ``latest_version`` is strictly newer
|
|
||||||
than the installed version.
|
|
||||||
|
|
||||||
Uses PEP 440 / semver-aware comparison so a locally modified plugin whose
|
|
||||||
version is *ahead* of the published registry is not flagged as needing an
|
|
||||||
update. If either version string can't be parsed, falls back to a plain
|
|
||||||
inequality check (any difference is surfaced so the user can reconcile).
|
|
||||||
"""
|
|
||||||
if not installed_version or not latest_version:
|
|
||||||
return False
|
|
||||||
if installed_version == latest_version:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
from packaging.version import parse as _parse_version, InvalidVersion
|
|
||||||
except ImportError:
|
|
||||||
# packaging is a core dependency, but if it's somehow unavailable we
|
|
||||||
# can't compare semantically — surface the mismatch we already know
|
|
||||||
# exists (the two strings differ).
|
|
||||||
return True
|
|
||||||
try:
|
|
||||||
return _parse_version(latest_version) > _parse_version(installed_version)
|
|
||||||
except InvalidVersion:
|
|
||||||
# Unparseable version string: we can't tell direction, so surface the
|
|
||||||
# mismatch rather than silently hiding a potential update.
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _ensure_cache_manager():
|
def _ensure_cache_manager():
|
||||||
"""Ensure cache manager is initialized."""
|
"""Ensure cache manager is initialized."""
|
||||||
global cache_manager
|
global cache_manager
|
||||||
@@ -864,15 +837,16 @@ def save_main_config():
|
|||||||
ds_config = current_config['display']['double_sided']
|
ds_config = current_config['display']['double_sided']
|
||||||
|
|
||||||
# Enabled checkbox: omitted from the form when unchecked.
|
# Enabled checkbox: omitted from the form when unchecked.
|
||||||
# The Display form posts copies/axis on every save regardless of this
|
ds_config['enabled'] = _coerce_to_bool(data.get('double_sided_enabled'))
|
||||||
# checkbox, so when the feature is off we accept the values without
|
|
||||||
# rejecting the whole save — otherwise a stale copies/chain_length
|
|
||||||
# mismatch locks the user out of every other display setting.
|
|
||||||
enabled = _coerce_to_bool(data.get('double_sided_enabled'))
|
|
||||||
ds_config['enabled'] = enabled
|
|
||||||
|
|
||||||
def _copies_fits_hardware(copies: int) -> Optional[str]:
|
if 'double_sided_copies' in data and data['double_sided_copies'] not in ('', None):
|
||||||
"""Error message if copies doesn't divide the panel evenly, else None."""
|
try:
|
||||||
|
copies = int(data['double_sided_copies'])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return jsonify({'status': 'error', 'message': "Double-sided copies must be an integer"}), 400
|
||||||
|
if not (2 <= copies <= 8):
|
||||||
|
return jsonify({'status': 'error', 'message': "Double-sided copies must be between 2 and 8"}), 400
|
||||||
|
# Validate divisibility against the relevant hardware dimension.
|
||||||
# Use axis from this request if provided, else from stored config.
|
# Use axis from this request if provided, else from stored config.
|
||||||
hw = current_config.get('display', {}).get('hardware', {})
|
hw = current_config.get('display', {}).get('hardware', {})
|
||||||
effective_axis = (data.get('double_sided_axis')
|
effective_axis = (data.get('double_sided_axis')
|
||||||
@@ -880,53 +854,22 @@ def save_main_config():
|
|||||||
if effective_axis == 'horizontal':
|
if effective_axis == 'horizontal':
|
||||||
chain_length = int(hw.get('chain_length', 2) or 2)
|
chain_length = int(hw.get('chain_length', 2) or 2)
|
||||||
if chain_length % copies != 0:
|
if chain_length % copies != 0:
|
||||||
return f"Double-sided copies ({copies}) must divide chain length ({chain_length}) evenly"
|
return jsonify({'status': 'error', 'message': f"Double-sided copies ({copies}) must divide chain length ({chain_length}) evenly"}), 400
|
||||||
elif effective_axis == 'vertical':
|
elif effective_axis == 'vertical':
|
||||||
parallel = int(hw.get('parallel', 1) or 1)
|
parallel = int(hw.get('parallel', 1) or 1)
|
||||||
if parallel % copies != 0:
|
if parallel % copies != 0:
|
||||||
return f"Double-sided copies ({copies}) must divide parallel ({parallel}) evenly"
|
return jsonify({'status': 'error', 'message': f"Double-sided copies ({copies}) must divide parallel ({parallel}) evenly"}), 400
|
||||||
return None
|
ds_config['copies'] = copies
|
||||||
|
|
||||||
if 'double_sided_copies' in data and data['double_sided_copies'] not in ('', None):
|
|
||||||
copies = None
|
|
||||||
try:
|
|
||||||
copies = int(data['double_sided_copies'])
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
if enabled:
|
|
||||||
return jsonify({'status': 'error', 'message': "Double-sided copies must be an integer"}), 400
|
|
||||||
if copies is not None and not (2 <= copies <= 8):
|
|
||||||
if enabled:
|
|
||||||
return jsonify({'status': 'error', 'message': "Double-sided copies must be between 2 and 8"}), 400
|
|
||||||
# Disabled: leave the stored value alone rather than writing junk.
|
|
||||||
copies = None
|
|
||||||
if copies is not None:
|
|
||||||
# Divisibility is a hardware-relational check — only meaningful
|
|
||||||
# when the feature is actually on.
|
|
||||||
if enabled:
|
|
||||||
fit_error = _copies_fits_hardware(copies)
|
|
||||||
if fit_error:
|
|
||||||
return jsonify({'status': 'error', 'message': fit_error}), 400
|
|
||||||
ds_config['copies'] = copies
|
|
||||||
|
|
||||||
if 'double_sided_axis' in data:
|
if 'double_sided_axis' in data:
|
||||||
axis = data['double_sided_axis']
|
axis = data['double_sided_axis']
|
||||||
if axis not in ('horizontal', 'vertical'):
|
if axis not in ('horizontal', 'vertical'):
|
||||||
if enabled:
|
return jsonify({'status': 'error', 'message': "Double-sided axis must be 'horizontal' or 'vertical'"}), 400
|
||||||
return jsonify({'status': 'error', 'message': "Double-sided axis must be 'horizontal' or 'vertical'"}), 400
|
ds_config['axis'] = axis
|
||||||
else:
|
|
||||||
ds_config['axis'] = axis
|
|
||||||
|
|
||||||
# Handle Vegas scroll mode settings
|
# Handle Vegas scroll mode settings
|
||||||
vegas_fields = ['vegas_scroll_enabled', 'vegas_scroll_speed', 'vegas_separator_width',
|
vegas_fields = ['vegas_scroll_enabled', 'vegas_scroll_speed', 'vegas_separator_width',
|
||||||
'vegas_target_fps', 'vegas_buffer_ahead', 'vegas_plugin_order', 'vegas_excluded_plugins',
|
'vegas_target_fps', 'vegas_buffer_ahead', 'vegas_plugin_order', 'vegas_excluded_plugins']
|
||||||
'vegas_auto_trim', 'vegas_trim_threshold', 'vegas_content_padding',
|
|
||||||
'vegas_min_plugin_width', 'vegas_lead_in_width', 'vegas_plugins_per_cycle',
|
|
||||||
'vegas_max_plugin_width_ratio', 'vegas_dynamic_duration_enabled',
|
|
||||||
'vegas_min_cycle_duration', 'vegas_max_cycle_duration',
|
|
||||||
'vegas_intra_plugin_gap', 'vegas_render_width_pct',
|
|
||||||
'vegas_min_content_separation', 'vegas_min_cut_gap',
|
|
||||||
'vegas_continuous_scroll', 'vegas_extend_threshold_screens',
|
|
||||||
'vegas_smooth_scroll', 'vegas_overflow_mode']
|
|
||||||
|
|
||||||
if any(k in data for k in vegas_fields):
|
if any(k in data for k in vegas_fields):
|
||||||
if 'display' not in current_config:
|
if 'display' not in current_config:
|
||||||
@@ -941,85 +884,13 @@ def save_main_config():
|
|||||||
# was submitted (any vegas field present) but enabled key is missing,
|
# was submitted (any vegas field present) but enabled key is missing,
|
||||||
# the checkbox was unchecked and we should set enabled=False
|
# the checkbox was unchecked and we should set enabled=False
|
||||||
vegas_config['enabled'] = _coerce_to_bool(data.get('vegas_scroll_enabled'))
|
vegas_config['enabled'] = _coerce_to_bool(data.get('vegas_scroll_enabled'))
|
||||||
vegas_config['auto_trim'] = _coerce_to_bool(data.get('vegas_auto_trim'))
|
|
||||||
vegas_config['dynamic_duration_enabled'] = _coerce_to_bool(
|
|
||||||
data.get('vegas_dynamic_duration_enabled'))
|
|
||||||
vegas_config['continuous_scroll'] = _coerce_to_bool(
|
|
||||||
data.get('vegas_continuous_scroll'))
|
|
||||||
vegas_config['smooth_scroll'] = _coerce_to_bool(
|
|
||||||
data.get('vegas_smooth_scroll'))
|
|
||||||
|
|
||||||
# max_plugin_width_ratio is the one fractional setting, so it is
|
# Handle numeric settings with validation
|
||||||
# handled outside the integer loop below.
|
|
||||||
if data.get('vegas_overflow_mode') not in ('', None):
|
|
||||||
mode = str(data['vegas_overflow_mode']).strip().lower()
|
|
||||||
if mode not in ('rotate', 'truncate'):
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': "Invalid value for vegas_overflow_mode: "
|
|
||||||
"must be 'rotate' or 'truncate'"
|
|
||||||
}), 400
|
|
||||||
vegas_config['overflow_mode'] = mode
|
|
||||||
|
|
||||||
if data.get('vegas_extend_threshold_screens') not in ('', None):
|
|
||||||
try:
|
|
||||||
screens = float(data['vegas_extend_threshold_screens'])
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': "Invalid value for vegas_extend_threshold_screens: "
|
|
||||||
"must be a number"
|
|
||||||
}), 400
|
|
||||||
if not (1.0 <= screens <= 10.0):
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': "Invalid value for vegas_extend_threshold_screens: "
|
|
||||||
"must be between 1.0 and 10.0"
|
|
||||||
}), 400
|
|
||||||
vegas_config['extend_threshold_screens'] = screens
|
|
||||||
|
|
||||||
if data.get('vegas_max_plugin_width_ratio') not in ('', None):
|
|
||||||
try:
|
|
||||||
ratio = float(data['vegas_max_plugin_width_ratio'])
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': "Invalid value for vegas_max_plugin_width_ratio: "
|
|
||||||
"must be a number"
|
|
||||||
}), 400
|
|
||||||
if not (0 <= ratio <= 20):
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': "Invalid value for vegas_max_plugin_width_ratio: "
|
|
||||||
"must be between 0 and 20 (0 disables the cap)"
|
|
||||||
}), 400
|
|
||||||
vegas_config['max_plugin_width_ratio'] = ratio
|
|
||||||
|
|
||||||
# Handle numeric settings with validation.
|
|
||||||
#
|
|
||||||
# These bounds must match VegasModeConfig.validate(), which is what
|
|
||||||
# actually gates Vegas starting. Where they were looser, a value
|
|
||||||
# saved with a 200 and then made VegasModeCoordinator.start() bail
|
|
||||||
# out with only a log line, so the ticker silently never ran.
|
|
||||||
# Where they were tighter (scroll_speed capped at 100 against a
|
|
||||||
# slider that goes to 200), a legitimate value was rejected with a
|
|
||||||
# 400. See test_vegas_api_bounds_match_validate.
|
|
||||||
numeric_fields = {
|
numeric_fields = {
|
||||||
'vegas_scroll_speed': ('scroll_speed', 1, 200),
|
'vegas_scroll_speed': ('scroll_speed', 1, 100),
|
||||||
'vegas_separator_width': ('separator_width', 0, 128),
|
'vegas_separator_width': ('separator_width', 0, 500),
|
||||||
'vegas_intra_plugin_gap': ('intra_plugin_gap', 0, 128),
|
'vegas_target_fps': ('target_fps', 1, 200),
|
||||||
'vegas_render_width_pct': ('render_width_pct', 10, 100),
|
'vegas_buffer_ahead': ('buffer_ahead', 1, 20),
|
||||||
'vegas_min_content_separation': ('min_content_separation', 0, 256),
|
|
||||||
'vegas_min_cut_gap': ('min_cut_gap', 1, 128),
|
|
||||||
'vegas_target_fps': ('target_fps', 30, 200),
|
|
||||||
'vegas_buffer_ahead': ('buffer_ahead', 1, 5),
|
|
||||||
'vegas_trim_threshold': ('trim_threshold', 0, 254),
|
|
||||||
'vegas_content_padding': ('content_padding', 0, 128),
|
|
||||||
'vegas_min_plugin_width': ('min_plugin_width', 0, 512),
|
|
||||||
'vegas_lead_in_width': ('lead_in_width', 0, 2048),
|
|
||||||
'vegas_plugins_per_cycle': ('plugins_per_cycle', 1, 50),
|
|
||||||
'vegas_min_cycle_duration': ('min_cycle_duration', 5, 3600),
|
|
||||||
'vegas_max_cycle_duration': ('max_cycle_duration', 10, 3600),
|
|
||||||
}
|
}
|
||||||
for field_name, (config_key, min_val, max_val) in numeric_fields.items():
|
for field_name, (config_key, min_val, max_val) in numeric_fields.items():
|
||||||
if field_name in data:
|
if field_name in data:
|
||||||
@@ -1090,31 +961,8 @@ def save_main_config():
|
|||||||
return jsonify({"status": "error", "message": "sync_follower_position must be left or right"}), 400
|
return jsonify({"status": "error", "message": "sync_follower_position must be left or right"}), 400
|
||||||
current_config["sync"]["follower_position"] = pos_val
|
current_config["sync"]["follower_position"] = pos_val
|
||||||
|
|
||||||
# Handle primary rotation order: must be a JSON array of plugin-id
|
# Handle display durations
|
||||||
# strings. Reject anything else with a 400 rather than silently
|
duration_fields = [k for k in data.keys() if k.endswith('_duration') or k in ['default_duration', 'transition_duration']]
|
||||||
# coercing, so a buggy client can't clear or corrupt the saved order.
|
|
||||||
if 'plugin_rotation_order' in data:
|
|
||||||
raw_order = data.pop('plugin_rotation_order')
|
|
||||||
try:
|
|
||||||
parsed = json.loads(raw_order) if isinstance(raw_order, str) else raw_order
|
|
||||||
except (json.JSONDecodeError, TypeError, ValueError):
|
|
||||||
return jsonify({'status': 'error',
|
|
||||||
'message': 'plugin_rotation_order must be valid JSON'}), 400
|
|
||||||
if not isinstance(parsed, list) or not all(isinstance(p, str) for p in parsed):
|
|
||||||
return jsonify({'status': 'error',
|
|
||||||
'message': 'plugin_rotation_order must be a list of plugin-id strings'}), 400
|
|
||||||
if 'display' not in current_config:
|
|
||||||
current_config['display'] = {}
|
|
||||||
current_config['display']['plugin_rotation_order'] = parsed
|
|
||||||
|
|
||||||
# Handle display durations. Popped from `data` (not just read) so
|
|
||||||
# they can never also fall through to the generic "remaining keys"
|
|
||||||
# merge near the end of this function, which would otherwise write
|
|
||||||
# them AGAIN as bogus top-level config keys (e.g. "clock_duration": 30
|
|
||||||
# sitting at config root alongside the correct
|
|
||||||
# display.display_durations.clock_duration).
|
|
||||||
duration_fields = [k for k in list(data.keys())
|
|
||||||
if k.endswith('_duration') or k in ('default_duration', 'transition_duration')]
|
|
||||||
if duration_fields:
|
if duration_fields:
|
||||||
if 'display' not in current_config:
|
if 'display' not in current_config:
|
||||||
current_config['display'] = {}
|
current_config['display'] = {}
|
||||||
@@ -1122,36 +970,8 @@ def save_main_config():
|
|||||||
current_config['display']['display_durations'] = {}
|
current_config['display']['display_durations'] = {}
|
||||||
|
|
||||||
for field in duration_fields:
|
for field in duration_fields:
|
||||||
raw_value = data.pop(field)
|
if field in data:
|
||||||
try:
|
current_config['display']['display_durations'][field] = int(data[field])
|
||||||
int_value = int(raw_value)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
return jsonify({'status': 'error',
|
|
||||||
'message': f"Invalid duration for {field}: must be an integer"}), 400
|
|
||||||
current_config['display']['display_durations'][field] = int_value
|
|
||||||
|
|
||||||
# Per-mode durations from the Rotation & Durations page, posted as
|
|
||||||
# duration__<mode_key> (mode keys are arbitrary plugin mode names, so
|
|
||||||
# they can't use the suffix convention above). Same pop-and-validate
|
|
||||||
# treatment, for the same reason.
|
|
||||||
mode_duration_fields = [k for k in list(data.keys()) if k.startswith('duration__')]
|
|
||||||
if mode_duration_fields:
|
|
||||||
if 'display' not in current_config:
|
|
||||||
current_config['display'] = {}
|
|
||||||
if 'display_durations' not in current_config['display']:
|
|
||||||
current_config['display']['display_durations'] = {}
|
|
||||||
|
|
||||||
for field in mode_duration_fields:
|
|
||||||
raw_value = data.pop(field)
|
|
||||||
mode_key = field[len('duration__'):]
|
|
||||||
if not mode_key:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
int_value = int(raw_value)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
return jsonify({'status': 'error',
|
|
||||||
'message': f"Invalid duration for mode '{mode_key}': must be an integer"}), 400
|
|
||||||
current_config['display']['display_durations'][mode_key] = int_value
|
|
||||||
|
|
||||||
# Handle plugin configurations dynamically
|
# Handle plugin configurations dynamically
|
||||||
# Any key that matches a plugin ID should be saved as plugin config
|
# Any key that matches a plugin ID should be saved as plugin config
|
||||||
@@ -1819,16 +1639,6 @@ def execute_system_action():
|
|||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
logger.warning("git stash timed out, proceeding with pull")
|
logger.warning("git stash timed out, proceeding with pull")
|
||||||
|
|
||||||
# Record HEAD before the pull so dependency changes can be detected
|
|
||||||
old_head = None
|
|
||||||
try:
|
|
||||||
_pre = subprocess.run(['git', 'rev-parse', 'HEAD'],
|
|
||||||
capture_output=True, text=True, timeout=10, cwd=project_dir)
|
|
||||||
if _pre.returncode == 0:
|
|
||||||
old_head = _pre.stdout.strip()
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
logger.warning("git rev-parse timed out before pull")
|
|
||||||
|
|
||||||
# Perform the git pull
|
# Perform the git pull
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
['git', 'pull', '--rebase'],
|
['git', 'pull', '--rebase'],
|
||||||
@@ -1845,54 +1655,6 @@ def execute_system_action():
|
|||||||
pull_message = f"Code updated successfully. Local changes were automatically stashed.{stash_info}"
|
pull_message = f"Code updated successfully. Local changes were automatically stashed.{stash_info}"
|
||||||
if result.stdout and "Already up to date" not in result.stdout:
|
if result.stdout and "Already up to date" not in result.stdout:
|
||||||
pull_message = f"Code updated successfully.{stash_info}"
|
pull_message = f"Code updated successfully.{stash_info}"
|
||||||
|
|
||||||
# Keep Python dependencies in sync automatically: if the pull
|
|
||||||
# changed a requirements file, install it now — users updating
|
|
||||||
# from the web UI (most of them) never SSH in to pip install.
|
|
||||||
# Installs go through the same root-visible path as the
|
|
||||||
# Tools-tab buttons (_pip_install_requirements).
|
|
||||||
dep_notes = []
|
|
||||||
try:
|
|
||||||
_post = subprocess.run(['git', 'rev-parse', 'HEAD'],
|
|
||||||
capture_output=True, text=True, timeout=10, cwd=project_dir)
|
|
||||||
new_head = _post.stdout.strip() if _post.returncode == 0 else None
|
|
||||||
if old_head and new_head and old_head != new_head:
|
|
||||||
diff = subprocess.run(
|
|
||||||
['git', 'diff', '--name-only', f'{old_head}..{new_head}'],
|
|
||||||
capture_output=True, text=True, timeout=15, cwd=project_dir)
|
|
||||||
changed = set(diff.stdout.split()) if diff.returncode == 0 else set()
|
|
||||||
for rel in ('requirements.txt', 'web_interface/requirements.txt'):
|
|
||||||
req_path = PROJECT_ROOT / rel
|
|
||||||
if rel not in changed or not req_path.exists():
|
|
||||||
continue
|
|
||||||
# Each file's install is isolated: a timeout or
|
|
||||||
# OSError (e.g. the sudo wrapper/interpreter
|
|
||||||
# missing) on one file must not abort the other.
|
|
||||||
try:
|
|
||||||
r = _pip_install_requirements(req_path, timeout=180)
|
|
||||||
if r.returncode == 0:
|
|
||||||
dep_notes.append(f"Dependencies from {rel} updated.")
|
|
||||||
else:
|
|
||||||
dep_notes.append(
|
|
||||||
f"Dependency install from {rel} failed — "
|
|
||||||
"run Install Base Requirements from the Tools tab.")
|
|
||||||
logger.warning("post-update pip install failed for %s: %s",
|
|
||||||
rel, _truncate_output(r.stdout, r.stderr))
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
dep_notes.append(
|
|
||||||
f"Dependency install from {rel} timed out — "
|
|
||||||
"run Install Base Requirements from the Tools tab.")
|
|
||||||
logger.warning("post-update pip install timed out for %s", rel)
|
|
||||||
except OSError as install_err:
|
|
||||||
dep_notes.append(
|
|
||||||
f"Dependency install from {rel} failed — "
|
|
||||||
"run Install Base Requirements from the Tools tab.")
|
|
||||||
logger.warning("post-update pip install errored for %s: %s",
|
|
||||||
rel, install_err)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
logger.warning("post-update dependency sync timed out")
|
|
||||||
if dep_notes:
|
|
||||||
pull_message += " " + " ".join(dep_notes)
|
|
||||||
# A `git pull` restores built-in plugins (committed under
|
# A `git pull` restores built-in plugins (committed under
|
||||||
# plugin-repos/) even if the user uninstalled them. Re-remove
|
# plugin-repos/) even if the user uninstalled them. Re-remove
|
||||||
# any the user previously uninstalled so the update doesn't
|
# any the user previously uninstalled so the update doesn't
|
||||||
@@ -1923,36 +1685,14 @@ def execute_system_action():
|
|||||||
result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix-web.service'],
|
result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix-web.service'],
|
||||||
capture_output=True, text=True, timeout=10)
|
capture_output=True, text=True, timeout=10)
|
||||||
elif action == 'install_base_requirements':
|
elif action == 'install_base_requirements':
|
||||||
# Base + web interface requirements: flask-compress and friends
|
req_file = PROJECT_ROOT / 'requirements.txt'
|
||||||
# live in web_interface/requirements.txt, not the root file.
|
if not req_file.exists():
|
||||||
req_files = [f for f in (PROJECT_ROOT / 'requirements.txt',
|
|
||||||
PROJECT_ROOT / 'web_interface' / 'requirements.txt')
|
|
||||||
if f.exists()]
|
|
||||||
if not req_files:
|
|
||||||
return jsonify({'status': 'error', 'message': 'No requirements.txt found at project root'})
|
return jsonify({'status': 'error', 'message': 'No requirements.txt found at project root'})
|
||||||
outputs = []
|
result = _pip_install_requirements(req_file, timeout=120)
|
||||||
all_ok = True
|
|
||||||
for req_file in req_files:
|
|
||||||
label = req_file.relative_to(PROJECT_ROOT)
|
|
||||||
# Isolate each file's install: a timeout or OSError on one
|
|
||||||
# (e.g. requirements.txt) must not abort the rest of the
|
|
||||||
# loop (e.g. web_interface/requirements.txt never attempted).
|
|
||||||
try:
|
|
||||||
result = _pip_install_requirements(req_file, timeout=120)
|
|
||||||
all_ok = all_ok and result.returncode == 0
|
|
||||||
outputs.append(f"== {label} ==\n" + _truncate_output(result.stdout, result.stderr))
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
all_ok = False
|
|
||||||
outputs.append(f"== {label} ==\nTimed out after 120s")
|
|
||||||
logger.warning("install_base_requirements timed out for %s", label)
|
|
||||||
except OSError as install_err:
|
|
||||||
all_ok = False
|
|
||||||
outputs.append(f"== {label} ==\nFailed: {install_err}")
|
|
||||||
logger.warning("install_base_requirements errored for %s: %s", label, install_err)
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'success' if all_ok else 'error',
|
'status': 'success' if result.returncode == 0 else 'error',
|
||||||
'message': 'Base requirements installed successfully' if all_ok else 'pip install failed',
|
'message': 'Base requirements installed successfully' if result.returncode == 0 else 'pip install failed',
|
||||||
'output': "\n".join(outputs)
|
'output': _truncate_output(result.stdout, result.stderr)
|
||||||
})
|
})
|
||||||
elif action == 'install_plugin_requirements':
|
elif action == 'install_plugin_requirements':
|
||||||
active_pm = getattr(api_v3, 'plugin_manager', None)
|
active_pm = getattr(api_v3, 'plugin_manager', None)
|
||||||
@@ -2371,12 +2111,9 @@ def get_installed_plugins():
|
|||||||
if enabled is None:
|
if enabled is None:
|
||||||
enabled = plugin_instance.enabled if plugin_instance else True
|
enabled = plugin_instance.enabled if plugin_instance else True
|
||||||
|
|
||||||
# Verified + latest published version from registry (no network call)
|
# Verified from registry (no network call)
|
||||||
store_info = api_v3.plugin_store_manager.get_registry_info(plugin_id)
|
store_info = api_v3.plugin_store_manager.get_registry_info(plugin_id)
|
||||||
verified = store_info.get('verified', False) if store_info else False
|
verified = store_info.get('verified', False) if store_info else False
|
||||||
latest_version = store_info.get('latest_version', '') if store_info else ''
|
|
||||||
installed_version = plugin_info.get('version', '')
|
|
||||||
update_available = _is_plugin_update_available(installed_version, latest_version)
|
|
||||||
|
|
||||||
# Local git info (single subprocess on cache miss, zero on hit)
|
# Local git info (single subprocess on cache miss, zero on hit)
|
||||||
plugin_path = Path(api_v3.plugin_manager.plugins_dir) / plugin_id
|
plugin_path = Path(api_v3.plugin_manager.plugins_dir) / plugin_id
|
||||||
@@ -2423,8 +2160,6 @@ def get_installed_plugins():
|
|||||||
'id': plugin_id,
|
'id': plugin_id,
|
||||||
'name': plugin_info.get('name', plugin_id),
|
'name': plugin_info.get('name', plugin_id),
|
||||||
'version': plugin_info.get('version', ''),
|
'version': plugin_info.get('version', ''),
|
||||||
'latest_version': latest_version,
|
|
||||||
'update_available': update_available,
|
|
||||||
'author': plugin_info.get('author', 'Unknown'),
|
'author': plugin_info.get('author', 'Unknown'),
|
||||||
'category': plugin_info.get('category', 'General'),
|
'category': plugin_info.get('category', 'General'),
|
||||||
'description': plugin_info.get('description', 'No description available'),
|
'description': plugin_info.get('description', 'No description available'),
|
||||||
@@ -5527,18 +5262,6 @@ def get_plugin_schema():
|
|||||||
schema = schema_mgr.load_schema(plugin_id, use_cache=True)
|
schema = schema_mgr.load_schema(plugin_id, use_cache=True)
|
||||||
|
|
||||||
if schema:
|
if schema:
|
||||||
# Offer installed visual skins as a dropdown (returns a copy;
|
|
||||||
# the cached schema and validation are never enum-restricted)
|
|
||||||
try:
|
|
||||||
current_skin = None
|
|
||||||
if api_v3.config_manager:
|
|
||||||
config = api_v3.config_manager.load_config()
|
|
||||||
current_skin = config.get(plugin_id, {}).get('skin')
|
|
||||||
injected = schema_mgr.inject_skin_selector(schema, plugin_id, current_skin)
|
|
||||||
if isinstance(injected, dict):
|
|
||||||
schema = injected
|
|
||||||
except Exception:
|
|
||||||
logger.debug('Skin selector injection failed for %s', plugin_id, exc_info=True)
|
|
||||||
return jsonify({'status': 'success', 'data': {'schema': schema}})
|
return jsonify({'status': 'success', 'data': {'schema': schema}})
|
||||||
|
|
||||||
# Return a simple default schema if file not found
|
# Return a simple default schema if file not found
|
||||||
@@ -5567,43 +5290,6 @@ def get_plugin_schema():
|
|||||||
logger.error('Error in get_plugin_schema', exc_info=True)
|
logger.error('Error in get_plugin_schema', exc_info=True)
|
||||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
||||||
|
|
||||||
@api_v3.route('/skins', methods=['GET'])
|
|
||||||
def list_skins():
|
|
||||||
"""List installed visual skins (docs/SKIN_SYSTEM.md).
|
|
||||||
|
|
||||||
Optional ?plugin_id=... filters to skins matching that plugin.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
from src.skin_system import skin_runtime
|
|
||||||
|
|
||||||
plugin_id = request.args.get('plugin_id')
|
|
||||||
if plugin_id:
|
|
||||||
skins = skin_runtime.skins_for_plugin(plugin_id)
|
|
||||||
else:
|
|
||||||
# The discovery cache self-invalidates on directory/manifest
|
|
||||||
# mtime changes, so no force_refresh — keeps Pi disk I/O down.
|
|
||||||
skins = skin_runtime.discover_skins()
|
|
||||||
|
|
||||||
payload = []
|
|
||||||
for skin_id, manifest in sorted(skins.items()):
|
|
||||||
skin_dir = Path(manifest['_skin_dir'])
|
|
||||||
preview = manifest.get('preview')
|
|
||||||
payload.append({
|
|
||||||
'id': skin_id,
|
|
||||||
'name': manifest.get('name', skin_id),
|
|
||||||
'version': manifest.get('version'),
|
|
||||||
'author': manifest.get('author'),
|
|
||||||
'description': manifest.get('description', ''),
|
|
||||||
'skin_api_version': manifest.get('skin_api_version'),
|
|
||||||
'targets': manifest.get('targets', {}),
|
|
||||||
'modes': manifest.get('modes', []),
|
|
||||||
'has_preview': bool(preview and (skin_dir / preview).is_file()),
|
|
||||||
})
|
|
||||||
return jsonify({'status': 'success', 'data': {'skins': payload}})
|
|
||||||
except Exception:
|
|
||||||
logger.error('Error in list_skins', exc_info=True)
|
|
||||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
|
||||||
|
|
||||||
@api_v3.route('/plugins/config/reset', methods=['POST'])
|
@api_v3.route('/plugins/config/reset', methods=['POST'])
|
||||||
def reset_plugin_config():
|
def reset_plugin_config():
|
||||||
"""Reset plugin configuration to schema defaults"""
|
"""Reset plugin configuration to schema defaults"""
|
||||||
@@ -7205,29 +6891,6 @@ def list_plugin_assets():
|
|||||||
logger.error('Unhandled exception', exc_info=True)
|
logger.error('Unhandled exception', exc_info=True)
|
||||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
||||||
|
|
||||||
@api_v3.route('/display/current-status', methods=['GET'])
|
|
||||||
def get_current_display_status():
|
|
||||||
"""Return the display mode/plugin currently intended to be shown.
|
|
||||||
|
|
||||||
Published by the display process (display_controller._publish_current_mode_state)
|
|
||||||
to the shared cache whenever the active mode changes, so the web UI (e.g. the
|
|
||||||
System Logs page) can show what's on screen without querying the display
|
|
||||||
process directly.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
cache = _ensure_cache_manager()
|
|
||||||
state = cache.get('display_current_state', max_age=120)
|
|
||||||
if state is None:
|
|
||||||
state = {
|
|
||||||
'mode': None,
|
|
||||||
'plugin_id': None,
|
|
||||||
'last_updated': None,
|
|
||||||
}
|
|
||||||
return jsonify({'status': 'success', 'data': state})
|
|
||||||
except Exception:
|
|
||||||
logger.error('Error in get_current_display_status', exc_info=True)
|
|
||||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
|
||||||
|
|
||||||
@api_v3.route('/logs', methods=['GET'])
|
@api_v3.route('/logs', methods=['GET'])
|
||||||
def get_logs():
|
def get_logs():
|
||||||
"""Get system logs from journalctl"""
|
"""Get system logs from journalctl"""
|
||||||
|
|||||||
@@ -0,0 +1,847 @@
|
|||||||
|
"""
|
||||||
|
Plugin Composer blueprint — drag-and-drop plugin builder for LEDMatrix.
|
||||||
|
|
||||||
|
Routes:
|
||||||
|
GET /composer/ — Composer page
|
||||||
|
POST /composer/api/generate — Generate and return plugin ZIP
|
||||||
|
POST /composer/api/install — Write plugin directly to plugins_dir
|
||||||
|
GET /composer/api/fonts/<name> — Serve TTF font files for canvas rendering
|
||||||
|
GET /composer/api/validate-id/<id> — Check if a plugin ID is already taken
|
||||||
|
"""
|
||||||
|
import ast
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import zipfile
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import jinja2
|
||||||
|
import jsonschema
|
||||||
|
from flask import Blueprint, jsonify, render_template, request, send_file
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
composer_bp = Blueprint('composer', __name__)
|
||||||
|
|
||||||
|
# Module-level attributes injected by app.py at registration time
|
||||||
|
composer_bp.config_manager = None
|
||||||
|
composer_bp.plugin_manager = None
|
||||||
|
composer_bp.plugins_dir = None
|
||||||
|
composer_bp.project_root = None
|
||||||
|
|
||||||
|
# Fonts safe to serve to the browser for canvas rendering
|
||||||
|
_ALLOWED_FONTS = frozenset({'PressStart2P-Regular.ttf', '4x6-font.ttf', '5by7.regular.ttf'})
|
||||||
|
|
||||||
|
# Map composer font keys → DisplayManager attribute names
|
||||||
|
_FONT_ATTR_MAP = {
|
||||||
|
'press_start': 'regular_font',
|
||||||
|
'four_by_six': 'extra_small_font',
|
||||||
|
'five_by_seven': 'bdf_5x7_font',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Font sizes in LED pixels (used to compute second-line Y offsets)
|
||||||
|
_FONT_SIZE_MAP = {
|
||||||
|
'press_start': 8,
|
||||||
|
'four_by_six': 6,
|
||||||
|
'five_by_seven': 7,
|
||||||
|
}
|
||||||
|
|
||||||
|
_PLUGIN_ID_RE = re.compile(r'^[a-z][a-z0-9-]{0,62}$')
|
||||||
|
_PYTHON_IDENT_RE = re.compile(r'^[a-zA-Z_][a-zA-Z0-9_]*$')
|
||||||
|
|
||||||
|
# ── Jinja2 environment (separate from Flask's; autoescape=False for code gen) ──
|
||||||
|
|
||||||
|
_jinja_env: jinja2.Environment | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_jinja_env() -> jinja2.Environment:
|
||||||
|
global _jinja_env
|
||||||
|
if _jinja_env is None:
|
||||||
|
template_dir = Path(__file__).parent.parent / 'templates' / 'v3' / 'composer'
|
||||||
|
_jinja_env = jinja2.Environment(
|
||||||
|
loader=jinja2.FileSystemLoader(str(template_dir)),
|
||||||
|
autoescape=False,
|
||||||
|
trim_blocks=True,
|
||||||
|
lstrip_blocks=True,
|
||||||
|
)
|
||||||
|
_jinja_env.filters['as_rgb'] = _as_rgb_filter
|
||||||
|
_jinja_env.filters['as_fill'] = _as_fill_filter
|
||||||
|
return _jinja_env
|
||||||
|
|
||||||
|
|
||||||
|
def _as_rgb_filter(val) -> str:
|
||||||
|
"""[r, g, b] → '(r, g, b)'"""
|
||||||
|
if val is None:
|
||||||
|
return 'None'
|
||||||
|
return f'({int(val[0])}, {int(val[1])}, {int(val[2])})'
|
||||||
|
|
||||||
|
|
||||||
|
def _as_fill_filter(val) -> str:
|
||||||
|
"""[r, g, b] or None → '(r, g, b)' or 'None'"""
|
||||||
|
if val is None:
|
||||||
|
return 'None'
|
||||||
|
return _as_rgb_filter(val)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helper functions ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _to_class_name(name: str) -> str:
|
||||||
|
"""'My Clock' → 'MyClockPlugin' (avoids double-suffix if name already ends with Plugin)"""
|
||||||
|
words = re.sub(r'[^a-zA-Z0-9]', ' ', name).split()
|
||||||
|
base = ''.join(w.capitalize() for w in words)
|
||||||
|
return base if base.endswith('Plugin') else base + 'Plugin'
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_pos_expr(val: int, anchor: str | None, dim_var: str) -> str:
|
||||||
|
"""Produce a Python expression string for an anchored or fixed position.
|
||||||
|
|
||||||
|
anchor=None/'left'/'top' → fixed pixel value
|
||||||
|
anchor='center' → dim_var // 2 ± offset
|
||||||
|
anchor='right'/'bottom' → dim_var - offset
|
||||||
|
"""
|
||||||
|
if not anchor or anchor in ('left', 'top'):
|
||||||
|
return str(val)
|
||||||
|
if anchor in ('center', 'middle'):
|
||||||
|
if val == 0:
|
||||||
|
return f"{dim_var} // 2"
|
||||||
|
return f"{dim_var} // 2 + {val}" if val > 0 else f"{dim_var} // 2 - {abs(val)}"
|
||||||
|
if anchor in ('right', 'bottom'):
|
||||||
|
return dim_var if val == 0 else f"{dim_var} - {val}"
|
||||||
|
return str(val)
|
||||||
|
|
||||||
|
|
||||||
|
# Character widths in LED pixels per font (for text-alignment x offset math)
|
||||||
|
_FONT_CHAR_W = {
|
||||||
|
'press_start': 8,
|
||||||
|
'four_by_six': 4,
|
||||||
|
'five_by_seven': 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _aligned_x_expr(x_base_expr: str, text_align: str, char_count: int, char_w: int) -> str:
|
||||||
|
"""Return Python x expression for text alignment.
|
||||||
|
|
||||||
|
left → x_base_expr (no change)
|
||||||
|
center → x_base_expr - half_text_width
|
||||||
|
right → x_base_expr - text_width
|
||||||
|
"""
|
||||||
|
if text_align == 'left' or not text_align:
|
||||||
|
return x_base_expr
|
||||||
|
text_px = char_count * char_w
|
||||||
|
if text_align == 'center':
|
||||||
|
offset = text_px // 2
|
||||||
|
return f"({x_base_expr}) - {offset}" if offset else x_base_expr
|
||||||
|
if text_align == 'right':
|
||||||
|
return f"({x_base_expr}) - {text_px}" if text_px else x_base_expr
|
||||||
|
return x_base_expr
|
||||||
|
|
||||||
|
|
||||||
|
def _preprocess_elements(elements: list) -> list:
|
||||||
|
"""Expand raw element dicts into template-ready dicts with anchor expressions.
|
||||||
|
|
||||||
|
Invisible elements (visible=False) are excluded from generated code entirely.
|
||||||
|
"""
|
||||||
|
result = []
|
||||||
|
for el in elements:
|
||||||
|
# Skip hidden elements — they exist only in the preview
|
||||||
|
if el.get('visible') is False:
|
||||||
|
continue
|
||||||
|
|
||||||
|
p = dict(el)
|
||||||
|
t = el.get('type', '')
|
||||||
|
|
||||||
|
# Section elements are layer-list annotations only — no canvas output
|
||||||
|
if t == 'section':
|
||||||
|
continue
|
||||||
|
|
||||||
|
x_anchor = el.get('xAnchor') or None
|
||||||
|
y_anchor = el.get('yAnchor') or None
|
||||||
|
p['min_width'] = int(el.get('minWidth', 0) or 0)
|
||||||
|
|
||||||
|
if t in ('text', 'clock'):
|
||||||
|
font_key = el.get('font', 'press_start')
|
||||||
|
p['font_attr'] = _FONT_ATTR_MAP.get(font_key, 'regular_font')
|
||||||
|
p['rgb_tuple'] = f"({el.get('r', 255)}, {el.get('g', 255)}, {el.get('b', 255)})"
|
||||||
|
text_align = el.get('textAlign', 'left')
|
||||||
|
raw_x = el.get('x', 0)
|
||||||
|
x_base_expr = _compute_pos_expr(raw_x, x_anchor, 'width')
|
||||||
|
p['y_expr'] = _compute_pos_expr(el.get('y', 0), y_anchor, 'height')
|
||||||
|
font_size = _FONT_SIZE_MAP.get(font_key, 8)
|
||||||
|
char_w = _FONT_CHAR_W.get(font_key, 8)
|
||||||
|
line_spacing = int(el.get('lineSpacing', 2))
|
||||||
|
y_expr = p['y_expr']
|
||||||
|
p['y2_expr'] = f"({y_expr}) + {font_size + line_spacing}"
|
||||||
|
if t == 'text':
|
||||||
|
t1 = el.get('text', '') or ''
|
||||||
|
t2 = el.get('text2', '') or ''
|
||||||
|
p['text2'] = t2
|
||||||
|
# Detect {variable} tokens — generate format_map() call instead of literal
|
||||||
|
_var_re = re.compile(r'\{([a-zA-Z_]\w*)\}')
|
||||||
|
p['text_is_template'] = bool(_var_re.search(t1) or _var_re.search(t2))
|
||||||
|
ref_len = max(len(t1), len(t2)) if t2 else len(t1)
|
||||||
|
p['x_expr'] = _aligned_x_expr(x_base_expr, text_align, ref_len, char_w)
|
||||||
|
p['x2_expr'] = p['x_expr'] # second line uses same x
|
||||||
|
else: # clock
|
||||||
|
fmt1 = el.get('format', '%H:%M') or '%H:%M'
|
||||||
|
fmt2 = el.get('format2', '') or ''
|
||||||
|
p['format2'] = fmt2
|
||||||
|
ref_len = max(len(fmt1), len(fmt2)) if fmt2 else len(fmt1)
|
||||||
|
p['x_expr'] = _aligned_x_expr(x_base_expr, text_align, ref_len, char_w)
|
||||||
|
p['x2_expr'] = p['x_expr']
|
||||||
|
p['blink'] = bool(el.get('blink', False))
|
||||||
|
|
||||||
|
elif t == 'dynamic_text':
|
||||||
|
binding = el.get('binding', {})
|
||||||
|
p['binding_source'] = binding.get('source', 'config')
|
||||||
|
p['binding_key'] = binding.get('key', '')
|
||||||
|
p['binding_format'] = binding.get('format')
|
||||||
|
font_key = el.get('font', 'press_start')
|
||||||
|
p['font_attr'] = _FONT_ATTR_MAP.get(font_key, 'regular_font')
|
||||||
|
p['rgb_tuple'] = f"({el.get('r', 255)}, {el.get('g', 200)}, {el.get('b', 100)})"
|
||||||
|
x_base_expr = _compute_pos_expr(el.get('x', 0), x_anchor, 'width')
|
||||||
|
p['x_expr'] = x_base_expr # dynamic text: runtime content determines width; use raw pos
|
||||||
|
p['y_expr'] = _compute_pos_expr(el.get('y', 0), y_anchor, 'height')
|
||||||
|
p['blink'] = bool(el.get('blink', False))
|
||||||
|
|
||||||
|
elif t == 'rectangle':
|
||||||
|
x_expr = _compute_pos_expr(el.get('x', 0), x_anchor, 'width')
|
||||||
|
y_expr = _compute_pos_expr(el.get('y', 0), y_anchor, 'height')
|
||||||
|
w = el.get('width', 10)
|
||||||
|
h = el.get('height', 8)
|
||||||
|
p['x_expr'] = x_expr
|
||||||
|
p['y_expr'] = y_expr
|
||||||
|
# x2/y2 as runtime expressions to support anchored positions
|
||||||
|
p['x2_expr'] = f"({x_expr}) + {w}"
|
||||||
|
p['y2_expr'] = f"({y_expr}) + {h}"
|
||||||
|
fill = (
|
||||||
|
[el.get('fillR', 0), el.get('fillG', 0), el.get('fillB', 128)]
|
||||||
|
if el.get('hasFill', True) else None
|
||||||
|
)
|
||||||
|
outline = (
|
||||||
|
[el.get('outR', 255), el.get('outG', 255), el.get('outB', 255)]
|
||||||
|
if el.get('hasOutline', True) else None
|
||||||
|
)
|
||||||
|
p['fill_tuple'] = _as_fill_filter(fill)
|
||||||
|
p['outline_tuple'] = _as_fill_filter(outline)
|
||||||
|
p['blink'] = bool(el.get('blink', False))
|
||||||
|
|
||||||
|
elif t in ('line', 'divider'):
|
||||||
|
if t == 'divider':
|
||||||
|
orient = el.get('orientation', 'horizontal')
|
||||||
|
if orient == 'horizontal':
|
||||||
|
y_val = el.get('y', 16)
|
||||||
|
y_expr = _compute_pos_expr(y_val, y_anchor, 'height')
|
||||||
|
p.update(x0_expr='0', y0_expr=y_expr, x1_expr='width - 1', y1_expr=y_expr)
|
||||||
|
else:
|
||||||
|
x_val = el.get('x', 64)
|
||||||
|
x_expr = _compute_pos_expr(x_val, x_anchor, 'width')
|
||||||
|
p.update(x0_expr=x_expr, y0_expr='0', x1_expr=x_expr, y1_expr='height - 1')
|
||||||
|
else:
|
||||||
|
p['x0_expr'] = _compute_pos_expr(el.get('x0', 0), x_anchor, 'width')
|
||||||
|
p['y0_expr'] = _compute_pos_expr(el.get('y0', 0), y_anchor, 'height')
|
||||||
|
p['x1_expr'] = str(el.get('x1', 127))
|
||||||
|
p['y1_expr'] = str(el.get('y1', 0))
|
||||||
|
p['rgb_tuple'] = f"({el.get('r', 180)}, {el.get('g', 180)}, {el.get('b', 180)})"
|
||||||
|
p['line_width'] = el.get('lineWidth', 1)
|
||||||
|
p['blink'] = bool(el.get('blink', False))
|
||||||
|
|
||||||
|
elif t == 'progress_bar':
|
||||||
|
p['x_expr'] = _compute_pos_expr(el.get('x', 0), x_anchor, 'width')
|
||||||
|
p['y_expr'] = _compute_pos_expr(el.get('y', 0), y_anchor, 'height')
|
||||||
|
p['bar_width'] = int(el.get('barWidth', 40))
|
||||||
|
p['bar_height'] = int(el.get('barHeight', 6))
|
||||||
|
binding = el.get('binding', {})
|
||||||
|
p['binding_key'] = binding.get('key', '')
|
||||||
|
p['fill_tuple'] = f"({el.get('r', 100)}, {el.get('g', 200)}, {el.get('b', 100)})"
|
||||||
|
bg = (
|
||||||
|
[el.get('bgR', 30), el.get('bgG', 30), el.get('bgB', 30)]
|
||||||
|
if el.get('hasBg', True) else None
|
||||||
|
)
|
||||||
|
outline = (
|
||||||
|
[el.get('outR', 100), el.get('outG', 100), el.get('outB', 100)]
|
||||||
|
if el.get('hasOutline', True) else None
|
||||||
|
)
|
||||||
|
p['bg_tuple'] = _as_fill_filter(bg)
|
||||||
|
p['outline_tuple'] = _as_fill_filter(outline)
|
||||||
|
p['blink'] = bool(el.get('blink', False))
|
||||||
|
|
||||||
|
elif t == 'arc':
|
||||||
|
x_expr = _compute_pos_expr(el.get('x', 0), x_anchor, 'width')
|
||||||
|
y_expr = _compute_pos_expr(el.get('y', 0), y_anchor, 'height')
|
||||||
|
w = el.get('width', 24)
|
||||||
|
h = el.get('height', 24)
|
||||||
|
p['x_expr'] = x_expr
|
||||||
|
p['y_expr'] = y_expr
|
||||||
|
p['x2_expr'] = f"({x_expr}) + {w}"
|
||||||
|
p['y2_expr'] = f"({y_expr}) + {h}"
|
||||||
|
p['start_angle'] = int(el.get('startAngle', 0))
|
||||||
|
p['end_angle'] = int(el.get('endAngle', 270))
|
||||||
|
p['line_width'] = max(1, int(el.get('lineWidth', 2)))
|
||||||
|
p['rgb_tuple'] = f"({el.get('r', 255)}, {el.get('g', 200)}, {el.get('b', 0)})"
|
||||||
|
p['blink'] = bool(el.get('blink', False))
|
||||||
|
|
||||||
|
elif t == 'ellipse':
|
||||||
|
x_expr = _compute_pos_expr(el.get('x', 0), x_anchor, 'width')
|
||||||
|
y_expr = _compute_pos_expr(el.get('y', 0), y_anchor, 'height')
|
||||||
|
w = el.get('width', 24)
|
||||||
|
h = el.get('height', 12)
|
||||||
|
p['x_expr'] = x_expr
|
||||||
|
p['y_expr'] = y_expr
|
||||||
|
p['x2_expr'] = f"({x_expr}) + {w}"
|
||||||
|
p['y2_expr'] = f"({y_expr}) + {h}"
|
||||||
|
fill = (
|
||||||
|
[el.get('fillR', 0), el.get('fillG', 100), el.get('fillB', 200)]
|
||||||
|
if el.get('hasFill', True) else None
|
||||||
|
)
|
||||||
|
outline = (
|
||||||
|
[el.get('outR', 100), el.get('outG', 180), el.get('outB', 255)]
|
||||||
|
if el.get('hasOutline', True) else None
|
||||||
|
)
|
||||||
|
p['fill_tuple'] = _as_fill_filter(fill)
|
||||||
|
p['outline_tuple'] = _as_fill_filter(outline)
|
||||||
|
p['blink'] = bool(el.get('blink', False))
|
||||||
|
|
||||||
|
elif t == 'pixel':
|
||||||
|
p['x_expr'] = _compute_pos_expr(el.get('x', 0), x_anchor, 'width')
|
||||||
|
p['y_expr'] = _compute_pos_expr(el.get('y', 0), y_anchor, 'height')
|
||||||
|
p['rgb_tuple'] = f"({el.get('r', 255)}, {el.get('g', 255)}, {el.get('b', 255)})"
|
||||||
|
p['blink'] = bool(el.get('blink', False))
|
||||||
|
|
||||||
|
elif t == 'rounded_rectangle':
|
||||||
|
x_expr = _compute_pos_expr(el.get('x', 0), x_anchor, 'width')
|
||||||
|
y_expr = _compute_pos_expr(el.get('y', 0), y_anchor, 'height')
|
||||||
|
w = el.get('width', 24)
|
||||||
|
h = el.get('height', 10)
|
||||||
|
p['x_expr'] = x_expr
|
||||||
|
p['y_expr'] = y_expr
|
||||||
|
p['x2_expr'] = f"({x_expr}) + {w}"
|
||||||
|
p['y2_expr'] = f"({y_expr}) + {h}"
|
||||||
|
p['border_radius'] = int(el.get('borderRadius', 3))
|
||||||
|
fill = (
|
||||||
|
[el.get('fillR', 0), el.get('fillG', 80), el.get('fillB', 180)]
|
||||||
|
if el.get('hasFill', True) else None
|
||||||
|
)
|
||||||
|
outline = (
|
||||||
|
[el.get('outR', 120), el.get('outG', 180), el.get('outB', 255)]
|
||||||
|
if el.get('hasOutline', True) else None
|
||||||
|
)
|
||||||
|
p['fill_tuple'] = _as_fill_filter(fill)
|
||||||
|
p['outline_tuple'] = _as_fill_filter(outline)
|
||||||
|
p['blink'] = bool(el.get('blink', False))
|
||||||
|
|
||||||
|
elif t == 'countdown':
|
||||||
|
font_key = el.get('font', 'four_by_six')
|
||||||
|
p['font_attr'] = _FONT_ATTR_MAP.get(font_key, 'extra_small_font')
|
||||||
|
p['rgb_tuple'] = f"({el.get('r', 255)}, {el.get('g', 180)}, {el.get('b', 0)})"
|
||||||
|
binding = el.get('binding', {})
|
||||||
|
p['binding_key'] = binding.get('key', '')
|
||||||
|
p['countdown_format'] = el.get('countdownFormat', 'dh')
|
||||||
|
x_base_expr = _compute_pos_expr(el.get('x', 0), x_anchor, 'width')
|
||||||
|
p['x_expr'] = x_base_expr
|
||||||
|
p['y_expr'] = _compute_pos_expr(el.get('y', 0), y_anchor, 'height')
|
||||||
|
p['blink'] = bool(el.get('blink', False))
|
||||||
|
|
||||||
|
elif t == 'pips':
|
||||||
|
p['x_expr'] = _compute_pos_expr(el.get('x', 0), x_anchor, 'width')
|
||||||
|
p['y_expr'] = _compute_pos_expr(el.get('y', 0), y_anchor, 'height')
|
||||||
|
p['pip_count'] = max(1, int(el.get('count', 5)))
|
||||||
|
p['pip_size'] = max(1, int(el.get('pipSize', 4)))
|
||||||
|
p['pip_spacing'] = max(0, int(el.get('pipSpacing', 2)))
|
||||||
|
p['show_empty'] = bool(el.get('showEmpty', True))
|
||||||
|
binding = el.get('binding', {})
|
||||||
|
p['binding_key'] = binding.get('key', '')
|
||||||
|
p['fill_tuple'] = f"({el.get('r', 255)}, {el.get('g', 200)}, {el.get('b', 0)})"
|
||||||
|
p['empty_tuple'] = f"({el.get('emptyR', 50)}, {el.get('emptyG', 50)}, {el.get('emptyB', 50)})"
|
||||||
|
p['blink'] = bool(el.get('blink', False))
|
||||||
|
|
||||||
|
elif t == 'sparkline':
|
||||||
|
x_expr = _compute_pos_expr(el.get('x', 0), x_anchor, 'width')
|
||||||
|
y_expr = _compute_pos_expr(el.get('y', 0), y_anchor, 'height')
|
||||||
|
p['x_expr'] = x_expr
|
||||||
|
p['y_expr'] = y_expr
|
||||||
|
p['bar_width_px'] = int(el.get('width', 40))
|
||||||
|
p['bar_height_px'] = int(el.get('height', 12))
|
||||||
|
p['bar_count'] = max(1, int(el.get('barCount', 8)))
|
||||||
|
p['bar_spacing'] = max(0, int(el.get('barSpacing', 1)))
|
||||||
|
binding = el.get('binding', {})
|
||||||
|
p['binding_key'] = binding.get('key', '')
|
||||||
|
p['fill_tuple'] = f"({el.get('r', 80)}, {el.get('g', 200)}, {el.get('b', 120)})"
|
||||||
|
bg = [el.get('bgR', 30), el.get('bgG', 30), el.get('bgB', 30)] if el.get('hasBg', False) else None
|
||||||
|
p['bg_tuple'] = _as_fill_filter(bg)
|
||||||
|
p['blink'] = bool(el.get('blink', False))
|
||||||
|
|
||||||
|
elif t == 'gauge':
|
||||||
|
x_expr = _compute_pos_expr(el.get('x', 0), x_anchor, 'width')
|
||||||
|
y_expr = _compute_pos_expr(el.get('y', 0), y_anchor, 'height')
|
||||||
|
w = el.get('width', 32)
|
||||||
|
h = el.get('height', 32)
|
||||||
|
p['x_expr'] = x_expr
|
||||||
|
p['y_expr'] = y_expr
|
||||||
|
p['x2_expr'] = f"({x_expr}) + {w}"
|
||||||
|
p['y2_expr'] = f"({y_expr}) + {h}"
|
||||||
|
p['start_angle'] = int(el.get('startAngle', 135))
|
||||||
|
p['end_angle'] = int(el.get('endAngle', 45))
|
||||||
|
p['line_width'] = max(1, int(el.get('lineWidth', 3)))
|
||||||
|
p['rgb_tuple'] = f"({el.get('r', 80)}, {el.get('g', 220)}, {el.get('b', 80)})"
|
||||||
|
track = (
|
||||||
|
[el.get('trackR', 40), el.get('trackG', 40), el.get('trackB', 40)]
|
||||||
|
if el.get('hasTrack', True) else None
|
||||||
|
)
|
||||||
|
p['track_tuple'] = _as_fill_filter(track)
|
||||||
|
binding = el.get('binding', {})
|
||||||
|
p['binding_key'] = binding.get('key', '')
|
||||||
|
font_key = el.get('font', 'four_by_six')
|
||||||
|
p['font_attr'] = _FONT_ATTR_MAP.get(font_key, 'extra_small_font')
|
||||||
|
p['show_label'] = bool(el.get('showLabel', True))
|
||||||
|
p['label_tuple'] = f"({el.get('labelR', 200)}, {el.get('labelG', 200)}, {el.get('labelB', 200)})"
|
||||||
|
p['blink'] = bool(el.get('blink', False))
|
||||||
|
|
||||||
|
elif t == 'marquee':
|
||||||
|
font_key = el.get('font', 'press_start')
|
||||||
|
p['font_attr'] = _FONT_ATTR_MAP.get(font_key, 'regular_font')
|
||||||
|
p['rgb_tuple'] = f"({el.get('r', 255)}, {el.get('g', 255)}, {el.get('b', 255)})"
|
||||||
|
p['y_expr'] = _compute_pos_expr(el.get('y', 0), y_anchor, 'height')
|
||||||
|
p['text'] = el.get('text', 'Scrolling text')
|
||||||
|
p['char_w'] = _FONT_CHAR_W.get(font_key, 8)
|
||||||
|
p['gap'] = int(el.get('gap', 16))
|
||||||
|
p['scroll_speed'] = max(1, int(el.get('scrollSpeed', 1)))
|
||||||
|
p['direction'] = el.get('direction', 'left')
|
||||||
|
# Data key stored in self._data for stateful scrolling across display() calls
|
||||||
|
raw_id = str(el.get('id', 0)).replace('-', '_')
|
||||||
|
p['data_key'] = f"mq_{raw_id}"
|
||||||
|
p['blink'] = bool(el.get('blink', False))
|
||||||
|
|
||||||
|
result.append(p)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_plugin_files(data: dict) -> dict:
|
||||||
|
"""
|
||||||
|
Generate all plugin file contents as strings.
|
||||||
|
|
||||||
|
Returns dict: {'manager.py', 'manifest.json', 'config_schema.json', 'requirements.txt'}
|
||||||
|
Raises ValueError with a human-readable message on any validation failure.
|
||||||
|
"""
|
||||||
|
metadata = data.get('metadata', {})
|
||||||
|
elements = data.get('elements', [])
|
||||||
|
data_model = data.get('dataModel', {})
|
||||||
|
config_vars = data_model.get('configVars', [])
|
||||||
|
|
||||||
|
plugin_id = metadata.get('id', '').strip()
|
||||||
|
if not _PLUGIN_ID_RE.match(plugin_id):
|
||||||
|
raise ValueError(
|
||||||
|
'Plugin ID must start with a lowercase letter and contain only '
|
||||||
|
'lowercase letters, numbers, and hyphens (max 63 chars).'
|
||||||
|
)
|
||||||
|
|
||||||
|
plugin_name = metadata.get('name', '').strip()
|
||||||
|
if not plugin_name:
|
||||||
|
raise ValueError('Plugin name is required.')
|
||||||
|
|
||||||
|
author = metadata.get('author', '').strip()
|
||||||
|
if not author:
|
||||||
|
raise ValueError('Author is required.')
|
||||||
|
|
||||||
|
version = metadata.get('version', '1.0.0').strip()
|
||||||
|
|
||||||
|
# Validate config var keys are valid Python identifiers
|
||||||
|
for cv in config_vars:
|
||||||
|
key = cv.get('key', '')
|
||||||
|
if not _PYTHON_IDENT_RE.match(key):
|
||||||
|
raise ValueError(f'Config variable key "{key}" is not a valid Python identifier.')
|
||||||
|
|
||||||
|
class_name = _to_class_name(plugin_name)
|
||||||
|
# Only consider visible elements for code generation flags
|
||||||
|
visible_elements = [e for e in elements if e.get('visible') is not False]
|
||||||
|
processed = _preprocess_elements(elements)
|
||||||
|
has_clock = any(e.get('type') == 'clock' for e in visible_elements)
|
||||||
|
has_blink = any(e.get('blink') for e in visible_elements)
|
||||||
|
has_countdown = any(e.get('type') == 'countdown' for e in visible_elements)
|
||||||
|
_var_re = re.compile(r'\{[a-zA-Z_]\w*\}')
|
||||||
|
has_text_template = any(
|
||||||
|
e.get('type') == 'text' and (
|
||||||
|
_var_re.search(e.get('text', '') or '') or
|
||||||
|
_var_re.search(e.get('text2', '') or '')
|
||||||
|
)
|
||||||
|
for e in visible_elements
|
||||||
|
)
|
||||||
|
|
||||||
|
# Background fill color (None → don't render, use LED panel's native black)
|
||||||
|
bg_color: str | None = None
|
||||||
|
bg_raw = metadata.get('bgColor')
|
||||||
|
if isinstance(bg_raw, dict):
|
||||||
|
r, g, b = int(bg_raw.get('r', 0)), int(bg_raw.get('g', 0)), int(bg_raw.get('b', 0))
|
||||||
|
if r or g or b:
|
||||||
|
bg_color = f'({r}, {g}, {b})'
|
||||||
|
|
||||||
|
# Render manager.py
|
||||||
|
env = _get_jinja_env()
|
||||||
|
try:
|
||||||
|
tmpl = env.get_template('manager.py.j2')
|
||||||
|
except jinja2.TemplateNotFound:
|
||||||
|
raise ValueError('Code generation template not found. This is a server configuration issue.')
|
||||||
|
|
||||||
|
manager_py = tmpl.render(
|
||||||
|
plugin_name=plugin_name,
|
||||||
|
class_name=class_name,
|
||||||
|
plugin_id=plugin_id,
|
||||||
|
generated_date=datetime.now().strftime('%Y-%m-%d'),
|
||||||
|
config_vars=config_vars,
|
||||||
|
elements=processed,
|
||||||
|
has_clock=has_clock,
|
||||||
|
has_blink=has_blink,
|
||||||
|
has_countdown=has_countdown,
|
||||||
|
has_text_template=has_text_template,
|
||||||
|
bg_color=bg_color,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Syntax-check the generated Python
|
||||||
|
try:
|
||||||
|
ast.parse(manager_py)
|
||||||
|
except SyntaxError as exc:
|
||||||
|
raise ValueError(f'Generated code has a syntax error: {exc}') from exc
|
||||||
|
|
||||||
|
# Build manifest
|
||||||
|
manifest = {
|
||||||
|
'id': plugin_id,
|
||||||
|
'name': plugin_name,
|
||||||
|
'version': version,
|
||||||
|
'author': author,
|
||||||
|
'description': metadata.get('description', 'Custom plugin created with LEDMatrix Plugin Composer'),
|
||||||
|
'category': metadata.get('category', 'custom'),
|
||||||
|
'tags': ['composer', 'custom'],
|
||||||
|
'entry_point': 'manager.py',
|
||||||
|
'class_name': class_name,
|
||||||
|
'display_modes': [plugin_id],
|
||||||
|
'compatible_versions': ['>=2.0.0'],
|
||||||
|
'last_updated': datetime.now().strftime('%Y-%m-%d'),
|
||||||
|
'update_interval': int(metadata.get('update_interval', 60)),
|
||||||
|
'default_duration': float(metadata.get('display_duration', 15)),
|
||||||
|
'versions': [
|
||||||
|
{'released': datetime.now().strftime('%Y-%m-%d'), 'version': version}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Validate manifest against the project's schema
|
||||||
|
if composer_bp.project_root:
|
||||||
|
schema_path = Path(composer_bp.project_root) / 'schema' / 'manifest_schema.json'
|
||||||
|
if schema_path.exists():
|
||||||
|
schema = json.loads(schema_path.read_text())
|
||||||
|
validator = jsonschema.Draft7Validator(schema)
|
||||||
|
errors = list(validator.iter_errors(manifest))
|
||||||
|
if errors:
|
||||||
|
msgs = '; '.join(e.message for e in errors[:3])
|
||||||
|
raise ValueError(f'Manifest validation failed: {msgs}')
|
||||||
|
|
||||||
|
# Build config_schema
|
||||||
|
type_map = {
|
||||||
|
'string': {'type': 'string'},
|
||||||
|
'number': {'type': 'number', 'minimum': 0},
|
||||||
|
'boolean': {'type': 'boolean'},
|
||||||
|
'color': {
|
||||||
|
'type': 'array',
|
||||||
|
'items': {'type': 'integer', 'minimum': 0, 'maximum': 255},
|
||||||
|
'minItems': 3,
|
||||||
|
'maxItems': 3,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
config_properties = {
|
||||||
|
'enabled': {'type': 'boolean', 'default': True},
|
||||||
|
'display_duration': {'type': 'number', 'minimum': 1, 'default': float(metadata.get('display_duration', 15))},
|
||||||
|
}
|
||||||
|
for cv in config_vars:
|
||||||
|
cv_type = cv.get('type', 'string')
|
||||||
|
prop = dict(type_map.get(cv_type, {'type': 'string'}))
|
||||||
|
if cv.get('description'):
|
||||||
|
prop['description'] = cv['description']
|
||||||
|
if cv.get('label'):
|
||||||
|
prop['title'] = cv['label']
|
||||||
|
default = cv.get('default', '')
|
||||||
|
if cv_type == 'number':
|
||||||
|
try:
|
||||||
|
prop['default'] = float(default) if default != '' else 0
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
prop['default'] = 0
|
||||||
|
elif cv_type == 'boolean':
|
||||||
|
prop['default'] = bool(default)
|
||||||
|
else:
|
||||||
|
prop['default'] = default
|
||||||
|
config_properties[cv['key']] = prop
|
||||||
|
|
||||||
|
config_schema = {
|
||||||
|
'$schema': 'http://json-schema.org/draft-07/schema#',
|
||||||
|
'type': 'object',
|
||||||
|
'properties': config_properties,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'manager.py': manager_py,
|
||||||
|
'manifest.json': json.dumps(manifest, indent=2),
|
||||||
|
'config_schema.json': json.dumps(config_schema, indent=2),
|
||||||
|
'requirements.txt': '',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _save_composer_state(target_dir: Path, payload: dict) -> None:
|
||||||
|
"""Persist the raw composer payload alongside the generated plugin files."""
|
||||||
|
(target_dir / '_composer_state.json').write_text(
|
||||||
|
json.dumps(payload, indent=2, ensure_ascii=False), encoding='utf-8'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _pack_zip(files: dict, plugin_id: str) -> io.BytesIO:
|
||||||
|
"""Pack generated plugin files into an in-memory ZIP."""
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buf, 'w', compression=zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
for filename, content in files.items():
|
||||||
|
info = zipfile.ZipInfo(f'{plugin_id}/{filename}')
|
||||||
|
info.compress_type = zipfile.ZIP_DEFLATED
|
||||||
|
zf.writestr(info, content.encode('utf-8') if isinstance(content, str) else content)
|
||||||
|
buf.seek(0)
|
||||||
|
return buf
|
||||||
|
|
||||||
|
|
||||||
|
# ── Routes ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@composer_bp.route('/')
|
||||||
|
def index():
|
||||||
|
return render_template('v3/composer.html')
|
||||||
|
|
||||||
|
|
||||||
|
@composer_bp.route('/api/generate', methods=['POST'])
|
||||||
|
def generate_zip():
|
||||||
|
data = request.get_json(force=True, silent=True)
|
||||||
|
if not data:
|
||||||
|
return jsonify({'status': 'error', 'message': 'No JSON body'}), 400
|
||||||
|
try:
|
||||||
|
files = _generate_plugin_files(data)
|
||||||
|
except ValueError as exc:
|
||||||
|
return jsonify({'status': 'error', 'message': str(exc)}), 422
|
||||||
|
|
||||||
|
plugin_id = data.get('metadata', {}).get('id', 'plugin')
|
||||||
|
files['_composer_state.json'] = json.dumps(data, indent=2, ensure_ascii=False)
|
||||||
|
zip_buf = _pack_zip(files, plugin_id)
|
||||||
|
return send_file(
|
||||||
|
zip_buf,
|
||||||
|
mimetype='application/zip',
|
||||||
|
as_attachment=True,
|
||||||
|
download_name=f'{plugin_id}.zip',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@composer_bp.route('/api/install', methods=['POST'])
|
||||||
|
def install_locally():
|
||||||
|
if not composer_bp.plugins_dir:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Plugin directory not configured'}), 503
|
||||||
|
|
||||||
|
data = request.get_json(force=True, silent=True)
|
||||||
|
if not data:
|
||||||
|
return jsonify({'status': 'error', 'message': 'No JSON body'}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
files = _generate_plugin_files(data)
|
||||||
|
except ValueError as exc:
|
||||||
|
return jsonify({'status': 'error', 'message': str(exc)}), 422
|
||||||
|
|
||||||
|
plugin_id = data.get('metadata', {}).get('id', '')
|
||||||
|
# _generate_plugin_files() above already validates metadata.id via this
|
||||||
|
# same regex before it will return, but that guarantee lives in a
|
||||||
|
# different function -- re-check here, at the point the path is actually
|
||||||
|
# built, so this route stays safe on its own if that call is ever
|
||||||
|
# reordered or changed.
|
||||||
|
if not _PLUGIN_ID_RE.match(plugin_id):
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid plugin ID'}), 400
|
||||||
|
target = Path(composer_bp.plugins_dir) / plugin_id
|
||||||
|
force = bool(data.get('_force', False))
|
||||||
|
|
||||||
|
if target.exists() and not force:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'conflict',
|
||||||
|
'message': f'Plugin "{plugin_id}" is already installed.',
|
||||||
|
}), 409
|
||||||
|
|
||||||
|
try:
|
||||||
|
if target.exists() and force:
|
||||||
|
import shutil as _shutil
|
||||||
|
_shutil.rmtree(target)
|
||||||
|
target.mkdir(parents=True, exist_ok=False)
|
||||||
|
for filename, content in files.items():
|
||||||
|
(target / filename).write_text(content, encoding='utf-8')
|
||||||
|
_save_composer_state(target, data)
|
||||||
|
except OSError as exc:
|
||||||
|
logger.error('Failed to write plugin files for %s: %s', plugin_id, exc)
|
||||||
|
return jsonify({'status': 'error', 'message': 'Failed to write plugin files'}), 500
|
||||||
|
|
||||||
|
# Trigger plugin discovery so it shows up in the Plugin Manager immediately
|
||||||
|
if composer_bp.plugin_manager:
|
||||||
|
try:
|
||||||
|
composer_bp.plugin_manager.discover_plugins()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning('discover_plugins() failed after composer install: %s', exc)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': f'Plugin "{plugin_id}" installed successfully.',
|
||||||
|
'plugin_id': plugin_id,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@composer_bp.route('/api/fonts/<font_name>')
|
||||||
|
def serve_font(font_name):
|
||||||
|
"""Serve an allowlisted font file for canvas FontFace loading."""
|
||||||
|
if font_name not in _ALLOWED_FONTS:
|
||||||
|
return '', 404
|
||||||
|
if not composer_bp.project_root:
|
||||||
|
return '', 503
|
||||||
|
font_path = Path(composer_bp.project_root) / 'assets' / 'fonts' / font_name
|
||||||
|
if not font_path.exists():
|
||||||
|
return '', 404
|
||||||
|
return send_file(str(font_path), mimetype='font/ttf')
|
||||||
|
|
||||||
|
|
||||||
|
@composer_bp.route('/api/validate-id/<plugin_id>')
|
||||||
|
def validate_id(plugin_id):
|
||||||
|
"""Check whether a plugin ID is valid and available."""
|
||||||
|
if not _PLUGIN_ID_RE.match(plugin_id):
|
||||||
|
return jsonify({'valid': False, 'available': False, 'reason': 'Invalid format'})
|
||||||
|
if composer_bp.plugins_dir:
|
||||||
|
taken = (Path(composer_bp.plugins_dir) / plugin_id).exists()
|
||||||
|
if taken:
|
||||||
|
return jsonify({'valid': True, 'available': False, 'reason': 'Already installed'})
|
||||||
|
return jsonify({'valid': True, 'available': True})
|
||||||
|
|
||||||
|
|
||||||
|
@composer_bp.route('/api/plugins')
|
||||||
|
def list_plugins():
|
||||||
|
"""List installed plugins, flagging which ones have a saved composer state."""
|
||||||
|
if not composer_bp.plugins_dir:
|
||||||
|
return jsonify([])
|
||||||
|
plugins_dir = Path(composer_bp.plugins_dir)
|
||||||
|
results = []
|
||||||
|
for entry in sorted(plugins_dir.iterdir()):
|
||||||
|
if not entry.is_dir():
|
||||||
|
continue
|
||||||
|
manifest_path = entry / 'manifest.json'
|
||||||
|
if not manifest_path.exists():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
manifest = json.loads(manifest_path.read_text())
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Skipping %s: unreadable manifest.json (%s)", entry.name, e)
|
||||||
|
continue
|
||||||
|
has_state = (entry / '_composer_state.json').exists()
|
||||||
|
results.append({
|
||||||
|
'id': manifest.get('id', entry.name),
|
||||||
|
'name': manifest.get('name', entry.name),
|
||||||
|
'version': manifest.get('version', ''),
|
||||||
|
'author': manifest.get('author', ''),
|
||||||
|
'has_composer_state': has_state,
|
||||||
|
})
|
||||||
|
return jsonify(results)
|
||||||
|
|
||||||
|
|
||||||
|
@composer_bp.route('/api/preview', methods=['POST'])
|
||||||
|
def preview_code():
|
||||||
|
"""Generate plugin files and return them as JSON for the code preview modal."""
|
||||||
|
data = request.get_json(force=True, silent=True)
|
||||||
|
if not data:
|
||||||
|
return jsonify({'status': 'error', 'message': 'No JSON body'}), 400
|
||||||
|
try:
|
||||||
|
files = _generate_plugin_files(data)
|
||||||
|
except ValueError as exc:
|
||||||
|
return jsonify({'status': 'error', 'message': str(exc)}), 422
|
||||||
|
return jsonify({
|
||||||
|
'status': 'ok',
|
||||||
|
'files': {
|
||||||
|
'manager.py': files['manager.py'],
|
||||||
|
'manifest.json': files['manifest.json'],
|
||||||
|
'config_schema.json': files['config_schema.json'],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@composer_bp.route('/api/load/<plugin_id>')
|
||||||
|
def load_plugin(plugin_id):
|
||||||
|
"""Load a plugin's composer state for editing.
|
||||||
|
|
||||||
|
If a _composer_state.json exists, return it verbatim.
|
||||||
|
Otherwise, extract config vars from config_schema.json for a partial import.
|
||||||
|
"""
|
||||||
|
if not composer_bp.plugins_dir:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Plugin directory not configured'}), 503
|
||||||
|
if not _PLUGIN_ID_RE.match(plugin_id):
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid plugin ID'}), 400
|
||||||
|
|
||||||
|
plugin_dir = Path(composer_bp.plugins_dir) / plugin_id
|
||||||
|
if not plugin_dir.exists():
|
||||||
|
return jsonify({'status': 'error', 'message': 'Plugin not found'}), 404
|
||||||
|
|
||||||
|
# Full composer state
|
||||||
|
state_path = plugin_dir / '_composer_state.json'
|
||||||
|
if state_path.exists():
|
||||||
|
try:
|
||||||
|
state = json.loads(state_path.read_text())
|
||||||
|
return jsonify({'status': 'ok', 'source': 'composer', 'state': state})
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error('Failed to read composer state for %s: %s', plugin_id, exc)
|
||||||
|
return jsonify({'status': 'error', 'message': 'Failed to read state'}), 500
|
||||||
|
|
||||||
|
# Partial import from config_schema.json
|
||||||
|
schema_path = plugin_dir / 'config_schema.json'
|
||||||
|
manifest_path = plugin_dir / 'manifest.json'
|
||||||
|
config_vars = []
|
||||||
|
|
||||||
|
if schema_path.exists():
|
||||||
|
try:
|
||||||
|
schema = json.loads(schema_path.read_text())
|
||||||
|
props = schema.get('properties', {})
|
||||||
|
skip = {'enabled', 'display_duration', 'update_interval'}
|
||||||
|
type_map = {'boolean': 'boolean', 'number': 'number', 'integer': 'number', 'string': 'string'}
|
||||||
|
for key, prop in props.items():
|
||||||
|
if key in skip:
|
||||||
|
continue
|
||||||
|
prop_type = prop.get('type', 'string')
|
||||||
|
if isinstance(prop_type, list):
|
||||||
|
prop_type = next((t for t in prop_type if t != 'null'), 'string')
|
||||||
|
# Detect color arrays
|
||||||
|
if prop_type == 'array' and prop.get('maxItems') == 3:
|
||||||
|
cv_type = 'color'
|
||||||
|
else:
|
||||||
|
cv_type = type_map.get(prop_type, 'string')
|
||||||
|
config_vars.append({
|
||||||
|
'key': key,
|
||||||
|
'label': prop.get('title', key.replace('_', ' ').title()),
|
||||||
|
'type': cv_type,
|
||||||
|
'default': prop.get('default', ''),
|
||||||
|
'description': prop.get('description', ''),
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to parse config_schema.json for %s: %s", plugin_id, e)
|
||||||
|
|
||||||
|
manifest = {}
|
||||||
|
if manifest_path.exists():
|
||||||
|
try:
|
||||||
|
manifest = json.loads(manifest_path.read_text())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
partial_state = {
|
||||||
|
'composer_version': '1.0',
|
||||||
|
'metadata': {
|
||||||
|
'id': manifest.get('id', plugin_id),
|
||||||
|
'name': manifest.get('name', plugin_id),
|
||||||
|
'author': manifest.get('author', ''),
|
||||||
|
'version': manifest.get('version', '1.0.0'),
|
||||||
|
'description': manifest.get('description', ''),
|
||||||
|
'category': manifest.get('category', 'custom'),
|
||||||
|
'display_duration': manifest.get('default_duration', 15),
|
||||||
|
'update_interval': manifest.get('update_interval', 60),
|
||||||
|
'api_requirements': manifest.get('api_requirements', []),
|
||||||
|
},
|
||||||
|
'elements': [],
|
||||||
|
'dataModel': {'configVars': config_vars, 'dataSources': [], 'computedVars': []},
|
||||||
|
}
|
||||||
|
return jsonify({'status': 'ok', 'source': 'schema_import', 'state': partial_state})
|
||||||
@@ -397,51 +397,12 @@ def _load_display_partial():
|
|||||||
return "Error loading partial", 500
|
return "Error loading partial", 500
|
||||||
|
|
||||||
def _load_durations_partial():
|
def _load_durations_partial():
|
||||||
"""Load rotation & durations partial.
|
"""Load display durations partial"""
|
||||||
|
|
||||||
Builds one duration entry per display mode of every enabled plugin
|
|
||||||
(falling back to the display controller's 30s default), overlaid with any
|
|
||||||
values saved in display.display_durations. Historically the template only
|
|
||||||
looped over saved keys, and nothing ever populated them, so the page
|
|
||||||
rendered empty.
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
if pages_v3.config_manager:
|
if pages_v3.config_manager:
|
||||||
main_config = pages_v3.config_manager.load_config()
|
main_config = pages_v3.config_manager.load_config()
|
||||||
duration_groups = []
|
|
||||||
covered_keys = set()
|
|
||||||
if pages_v3.plugin_manager:
|
|
||||||
try:
|
|
||||||
pages_v3.plugin_manager.discover_plugins()
|
|
||||||
saved = (main_config.get('display', {}) or {}).get('display_durations', {}) or {}
|
|
||||||
infos = sorted(pages_v3.plugin_manager.get_all_plugin_info(),
|
|
||||||
key=lambda i: (i.get('name') or i.get('id') or '').lower())
|
|
||||||
for info in infos:
|
|
||||||
pid = info.get('id')
|
|
||||||
if not pid or not (main_config.get(pid, {}) or {}).get('enabled', False):
|
|
||||||
continue
|
|
||||||
modes = pages_v3.plugin_manager.get_plugin_display_modes(pid) or [pid]
|
|
||||||
covered_keys.update(modes)
|
|
||||||
duration_groups.append({
|
|
||||||
'plugin_id': pid,
|
|
||||||
'plugin_name': info.get('name') or pid,
|
|
||||||
'modes': [{'key': m, 'value': saved.get(m, 30)} for m in modes],
|
|
||||||
})
|
|
||||||
# Saved keys not owned by any enabled plugin (disabled or
|
|
||||||
# uninstalled plugins) stay visible rather than vanishing.
|
|
||||||
leftovers = [{'key': k, 'value': v} for k, v in saved.items()
|
|
||||||
if k not in covered_keys]
|
|
||||||
if leftovers:
|
|
||||||
duration_groups.append({
|
|
||||||
'plugin_id': '',
|
|
||||||
'plugin_name': 'Other saved entries',
|
|
||||||
'modes': leftovers,
|
|
||||||
})
|
|
||||||
except Exception:
|
|
||||||
logger.warning("durations: could not enumerate plugin modes", exc_info=True)
|
|
||||||
return render_template('v3/partials/durations.html',
|
return render_template('v3/partials/durations.html',
|
||||||
main_config=main_config,
|
main_config=main_config)
|
||||||
duration_groups=duration_groups)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error loading partial", exc_info=True)
|
logger.error("Error loading partial", exc_info=True)
|
||||||
return "Error loading partial", 500
|
return "Error loading partial", 500
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ flask>=3.1.3,<4.0.0
|
|||||||
werkzeug>=3.1.6,<4.0.0
|
werkzeug>=3.1.6,<4.0.0
|
||||||
flask-wtf>=1.2.0 # CSRF protection (optional for local-only, but recommended)
|
flask-wtf>=1.2.0 # CSRF protection (optional for local-only, but recommended)
|
||||||
flask-limiter>=3.5.0 # Rate limiting (prevent accidental abuse)
|
flask-limiter>=3.5.0 # Rate limiting (prevent accidental abuse)
|
||||||
flask-compress>=1.14 # gzip/brotli response compression (big win for the large JS/HTML over WiFi)
|
|
||||||
|
|
||||||
# WebSocket support for plugins
|
# WebSocket support for plugins
|
||||||
# Note: Web interface uses Server-Sent Events (SSE) for real-time updates, not WebSockets
|
# Note: Web interface uses Server-Sent Events (SSE) for real-time updates, not WebSockets
|
||||||
|
|||||||
@@ -413,9 +413,6 @@ a, button, input, select, textarea {
|
|||||||
/* Responsive breakpoints */
|
/* Responsive breakpoints */
|
||||||
@media (min-width: 640px) {
|
@media (min-width: 640px) {
|
||||||
.sm\:px-6 { padding-left: 1.5rem; padding-right: 1.5rem; }
|
.sm\:px-6 { padding-left: 1.5rem; padding-right: 1.5rem; }
|
||||||
.sm\:block { display: block; }
|
|
||||||
.sm\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
||||||
.sm\:text-sm { font-size: 0.875rem; line-height: 1.25rem; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
@@ -424,8 +421,6 @@ a, button, input, select, textarea {
|
|||||||
.md\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
.md\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||||
.md\:flex { display: flex; }
|
.md\:flex { display: flex; }
|
||||||
.md\:hidden { display: none; }
|
.md\:hidden { display: none; }
|
||||||
.md\:block { display: block; }
|
|
||||||
.md\:w-auto { width: auto; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
@@ -436,14 +431,9 @@ a, button, input, select, textarea {
|
|||||||
.lg\:px-8 { padding-left: 2rem; padding-right: 2rem; }
|
.lg\:px-8 { padding-left: 2rem; padding-right: 2rem; }
|
||||||
.lg\:gap-x-3 { column-gap: 0.75rem; }
|
.lg\:gap-x-3 { column-gap: 0.75rem; }
|
||||||
.lg\:gap-x-6 { column-gap: 1.5rem; }
|
.lg\:gap-x-6 { column-gap: 1.5rem; }
|
||||||
.lg\:block { display: block; }
|
|
||||||
.lg\:flex { display: flex; }
|
|
||||||
.lg\:w-64 { width: 16rem; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1280px) {
|
@media (min-width: 1280px) {
|
||||||
.xl\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
||||||
.xl\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
|
||||||
.xl\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
.xl\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||||
.xl\:grid-cols-5 { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
.xl\:grid-cols-5 { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
||||||
.xl\:grid-cols-6 { grid-template-columns: repeat(6, minmax(0, 1fr)); }
|
.xl\:grid-cols-6 { grid-template-columns: repeat(6, minmax(0, 1fr)); }
|
||||||
@@ -456,9 +446,6 @@ a, button, input, select, textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1536px) {
|
@media (min-width: 1536px) {
|
||||||
.2xl\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
||||||
.2xl\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
|
||||||
.2xl\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
|
||||||
.2xl\:grid-cols-5 { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
.2xl\:grid-cols-5 { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
||||||
.2xl\:grid-cols-6 { grid-template-columns: repeat(6, minmax(0, 1fr)); }
|
.2xl\:grid-cols-6 { grid-template-columns: repeat(6, minmax(0, 1fr)); }
|
||||||
.2xl\:grid-cols-7 { grid-template-columns: repeat(7, minmax(0, 1fr)); }
|
.2xl\:grid-cols-7 { grid-template-columns: repeat(7, minmax(0, 1fr)); }
|
||||||
@@ -469,129 +456,6 @@ a, button, input, select, textarea {
|
|||||||
.2xl\:space-x-8 > * + * { margin-left: 2rem; }
|
.2xl\:space-x-8 > * + * { margin-left: 2rem; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Mobile navigation drawer =====
|
|
||||||
Below md the #site-nav wrapper becomes an off-canvas drawer; at md and up
|
|
||||||
none of these rules apply and the nav renders exactly as before. */
|
|
||||||
@media (max-width: 767.98px) {
|
|
||||||
.site-nav {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
bottom: 0;
|
|
||||||
z-index: 60;
|
|
||||||
width: min(85vw, 320px);
|
|
||||||
background-color: var(--color-surface);
|
|
||||||
border-right: 1px solid var(--color-border);
|
|
||||||
transform: translateX(-100%);
|
|
||||||
transition: transform 0.25s ease;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 1rem;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
}
|
|
||||||
.site-nav.open {
|
|
||||||
transform: translateX(0);
|
|
||||||
box-shadow: 0 0 24px rgba(0, 0, 0, 0.25);
|
|
||||||
}
|
|
||||||
/* Tabs become full-width rows with >=44px touch targets */
|
|
||||||
.site-nav .nav-tab {
|
|
||||||
display: flex;
|
|
||||||
width: 100%;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
text-align: left;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
min-height: 44px;
|
|
||||||
}
|
|
||||||
.site-nav nav.-mb-px {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
.nav-backdrop {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 55;
|
|
||||||
background-color: rgba(0, 0, 0, 0.4);
|
|
||||||
}
|
|
||||||
/* Header widgets relocated into the drawer (see placeHeaderWidgets in
|
|
||||||
app.js). The originals carry `hidden`/breakpoint classes tuned for the
|
|
||||||
header, so re-enable them explicitly in the drawer context. */
|
|
||||||
#drawer-widgets #settings-search-wrap {
|
|
||||||
display: block !important;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
#drawer-widgets #settings-search-wrap input {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
#drawer-widgets #settings-search-results {
|
|
||||||
position: static;
|
|
||||||
width: 100%;
|
|
||||||
max-height: 50vh;
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
}
|
|
||||||
#drawer-widgets #system-stats {
|
|
||||||
display: flex !important;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
/* Larger touch targets inside horizontally scrolling tables */
|
|
||||||
.overflow-x-auto table button {
|
|
||||||
min-width: 44px;
|
|
||||||
min-height: 44px;
|
|
||||||
}
|
|
||||||
.overflow-x-auto table input:not([type="checkbox"]),
|
|
||||||
.overflow-x-auto table select {
|
|
||||||
min-height: 40px;
|
|
||||||
}
|
|
||||||
.overflow-x-auto table input[type="checkbox"] {
|
|
||||||
width: 1.25rem;
|
|
||||||
height: 1.25rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
/* Hard guards: even if mobileNavOpen was left true when the viewport
|
|
||||||
crossed the breakpoint, the drawer/backdrop must render as plain
|
|
||||||
in-flow nav on desktop. */
|
|
||||||
.site-nav {
|
|
||||||
position: static;
|
|
||||||
transform: none;
|
|
||||||
width: auto;
|
|
||||||
padding: 0;
|
|
||||||
border-right: none;
|
|
||||||
box-shadow: none;
|
|
||||||
background-color: transparent;
|
|
||||||
overflow-y: visible;
|
|
||||||
}
|
|
||||||
.nav-backdrop {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
#drawer-widgets {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile modal sizing: every .modal-content dialog fits the viewport with
|
|
||||||
internal scrolling instead of overflowing it. */
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.modal-content {
|
|
||||||
width: 95vw !important;
|
|
||||||
max-width: 95vw !important;
|
|
||||||
max-height: 90vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Edge-fade hint that a container scrolls horizontally (pure CSS,
|
|
||||||
Lea Verou scrolling-shadows technique — backgrounds sit behind content). */
|
|
||||||
.overflow-x-auto {
|
|
||||||
background:
|
|
||||||
linear-gradient(90deg, var(--color-surface) 30%, rgba(255, 255, 255, 0)) left / 24px 100%,
|
|
||||||
linear-gradient(270deg, var(--color-surface) 30%, rgba(255, 255, 255, 0)) right / 24px 100%,
|
|
||||||
radial-gradient(farthest-side at 0 50%, rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0)) left / 12px 100%,
|
|
||||||
radial-gradient(farthest-side at 100% 50%, rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0)) right / 12px 100%;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-attachment: local, local, scroll, scroll;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* HTMX loading states */
|
/* HTMX loading states */
|
||||||
.htmx-request .loading {
|
.htmx-request .loading {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -1334,21 +1198,6 @@ button.bg-white {
|
|||||||
color: #93c5fd;
|
color: #93c5fd;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Installed-plugin "Update" button when a newer version is published.
|
|
||||||
A gentle pulsing ring draws the eye without being noisy. */
|
|
||||||
.plugin-update-available {
|
|
||||||
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.6);
|
|
||||||
animation: plugin-update-pulse 2s ease-out infinite;
|
|
||||||
}
|
|
||||||
@keyframes plugin-update-pulse {
|
|
||||||
0% { box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.55); }
|
|
||||||
70% { box-shadow: 0 0 0 6px rgba(59, 130, 246, 0); }
|
|
||||||
100% { box-shadow: 0 0 0 0 rgba(59, 130, 246, 0); }
|
|
||||||
}
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.plugin-update-available { animation: none; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Under-voltage / throttling warning banner */
|
/* Under-voltage / throttling warning banner */
|
||||||
.power-warning-banner {
|
.power-warning-banner {
|
||||||
background-color: #fef2f2;
|
background-color: #fef2f2;
|
||||||
@@ -1371,44 +1220,3 @@ button.bg-white {
|
|||||||
[data-theme="dark"] .power-warning-banner-dismiss {
|
[data-theme="dark"] .power-warning-banner-dismiss {
|
||||||
color: #fca5a5;
|
color: #fca5a5;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Floating live preview (all tabs except Overview) ===== */
|
|
||||||
.floating-preview {
|
|
||||||
position: fixed;
|
|
||||||
right: 1rem;
|
|
||||||
bottom: 1rem;
|
|
||||||
z-index: 70;
|
|
||||||
background-color: #111827;
|
|
||||||
border: 1px solid #374151;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
|
||||||
overflow: hidden;
|
|
||||||
/* Desktop: draggable resize handle (bottom-left visually, since the
|
|
||||||
panel is anchored to the right). Touch devices use the size button. */
|
|
||||||
resize: both;
|
|
||||||
min-width: 160px;
|
|
||||||
max-width: 90vw;
|
|
||||||
}
|
|
||||||
.floating-preview img {
|
|
||||||
background-color: #000;
|
|
||||||
}
|
|
||||||
.floating-preview-toggle {
|
|
||||||
position: fixed;
|
|
||||||
right: 1rem;
|
|
||||||
bottom: 1rem;
|
|
||||||
z-index: 70;
|
|
||||||
width: 44px;
|
|
||||||
height: 44px;
|
|
||||||
border-radius: 9999px;
|
|
||||||
background-color: var(--color-primary);
|
|
||||||
color: #ffffff;
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
/* Whatever size is chosen, never wider than the phone viewport */
|
|
||||||
.floating-preview {
|
|
||||||
max-width: calc(100vw - 2rem);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/* global showNotification, updateSystemStats, updateDisplayPreview, htmx, debugLog */
|
/* global showNotification, updateSystemStats, updateDisplayPreview, htmx */
|
||||||
// LED Matrix v3 JavaScript
|
// LED Matrix v3 JavaScript
|
||||||
// Additional helpers for HTMX and Alpine.js integration
|
// Additional helpers for HTMX and Alpine.js integration
|
||||||
|
|
||||||
@@ -12,8 +12,8 @@ window.showNotification = function(message, type = 'info') {
|
|||||||
});
|
});
|
||||||
document.dispatchEvent(event);
|
document.dispatchEvent(event);
|
||||||
} else {
|
} else {
|
||||||
// Fallback notification — user-facing last resort, so never gated
|
// Fallback notification
|
||||||
console.info(`${type}: ${message}`);
|
console.log(`${type}: ${message}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -49,111 +49,6 @@ document.body.addEventListener('htmx:afterRequest', function(event) {
|
|||||||
// Not JSON, ignore
|
// Not JSON, ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main-config saves (display hardware, rotation/durations, general) only
|
|
||||||
// take effect after a display-service restart — surface the reminder
|
|
||||||
// banner. Plugin config saves apply live and are deliberately excluded.
|
|
||||||
try {
|
|
||||||
const cfg = event.detail.requestConfig;
|
|
||||||
if (cfg && cfg.verb === 'post' &&
|
|
||||||
(cfg.path || '').includes('/api/v3/config/main') &&
|
|
||||||
response && response.status >= 200 && response.status < 300) {
|
|
||||||
window.showRestartPending();
|
|
||||||
}
|
|
||||||
} catch { /* banner is best-effort */ }
|
|
||||||
});
|
|
||||||
|
|
||||||
// ===== Unsaved-changes guard =====
|
|
||||||
// Plugin config panels are Alpine x-if templates: navigating away DESTROYS
|
|
||||||
// the panel and revisiting re-fetches it, silently discarding any edits.
|
|
||||||
// (System tabs use x-show + data-loaded and persist, so they're exempt.)
|
|
||||||
// Track dirty forms and confirm before a lossy navigation.
|
|
||||||
(function() {
|
|
||||||
function markDirty(e) {
|
|
||||||
const form = e.target && e.target.closest ? e.target.closest('form') : null;
|
|
||||||
if (form) form.setAttribute('data-dirty', '');
|
|
||||||
}
|
|
||||||
document.body.addEventListener('input', markDirty);
|
|
||||||
document.body.addEventListener('change', markDirty);
|
|
||||||
|
|
||||||
// A successful submit makes the form clean again
|
|
||||||
document.body.addEventListener('htmx:afterRequest', function(event) {
|
|
||||||
const xhr = event.detail.xhr;
|
|
||||||
const form = event.detail.elt && event.detail.elt.closest ? event.detail.elt.closest('form') : null;
|
|
||||||
if (form && xhr && xhr.status >= 200 && xhr.status < 300) {
|
|
||||||
form.removeAttribute('data-dirty');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Capture phase so this runs before Alpine's bubbling @click switches tabs
|
|
||||||
document.addEventListener('click', function(e) {
|
|
||||||
const tabBtn = e.target && e.target.closest ? e.target.closest('.nav-tab') : null;
|
|
||||||
if (!tabBtn) return;
|
|
||||||
const lossy = Array.prototype.filter.call(
|
|
||||||
document.querySelectorAll('.plugin-config-tab form[data-dirty]'),
|
|
||||||
function(f) { return f.offsetParent !== null; }
|
|
||||||
);
|
|
||||||
if (lossy.length === 0) return;
|
|
||||||
if (!window.confirm('You have unsaved plugin settings — leaving this page will discard them. Leave anyway?')) {
|
|
||||||
e.stopPropagation();
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
}, true);
|
|
||||||
|
|
||||||
// Full page unload loses every panel's edits
|
|
||||||
window.addEventListener('beforeunload', function(e) {
|
|
||||||
const dirty = Array.prototype.some.call(
|
|
||||||
document.querySelectorAll('form[data-dirty]'),
|
|
||||||
function(f) { return f.offsetParent !== null; }
|
|
||||||
);
|
|
||||||
if (dirty) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.returnValue = '';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
|
|
||||||
// ===== Restart-pending banner =====
|
|
||||||
// Shown after restart-requiring saves; persists across tab switches (and
|
|
||||||
// reloads, via sessionStorage) until the display restarts or it's dismissed.
|
|
||||||
window.showRestartPending = function() {
|
|
||||||
try { sessionStorage.setItem('ledmatrix-restart-pending', '1'); } catch { /* private browsing */ }
|
|
||||||
const banner = document.getElementById('restart-pending-banner');
|
|
||||||
if (banner) banner.style.display = 'block';
|
|
||||||
};
|
|
||||||
|
|
||||||
window.dismissRestartPending = function() {
|
|
||||||
try { sessionStorage.removeItem('ledmatrix-restart-pending'); } catch { /* no-op */ }
|
|
||||||
const banner = document.getElementById('restart-pending-banner');
|
|
||||||
if (banner) banner.style.display = 'none';
|
|
||||||
};
|
|
||||||
|
|
||||||
window.restartPendingNow = function() {
|
|
||||||
const btn = document.getElementById('restart-pending-btn');
|
|
||||||
if (btn) btn.disabled = true;
|
|
||||||
fetch('/api/v3/system/action', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ action: 'restart_display_service' })
|
|
||||||
})
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(data => {
|
|
||||||
showNotification(data.message || 'Display restarting…', data.status || 'success');
|
|
||||||
window.dismissRestartPending();
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
showNotification('Error restarting display: ' + err.message, 'error');
|
|
||||||
})
|
|
||||||
.finally(() => { if (btn) btn.disabled = false; });
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
try {
|
|
||||||
if (sessionStorage.getItem('ledmatrix-restart-pending') === '1') {
|
|
||||||
const banner = document.getElementById('restart-pending-banner');
|
|
||||||
if (banner) banner.style.display = 'block';
|
|
||||||
}
|
|
||||||
} catch { /* no-op */ }
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// SSE reconnection helper — closes and reopens both SSE streams,
|
// SSE reconnection helper — closes and reopens both SSE streams,
|
||||||
@@ -351,13 +246,13 @@ window.performanceMonitor = {
|
|||||||
logMetrics: function() {
|
logMetrics: function() {
|
||||||
const metrics = this.getMetrics();
|
const metrics = this.getMetrics();
|
||||||
console.group('Performance Metrics');
|
console.group('Performance Metrics');
|
||||||
debugLog('DOM Content Loaded:', metrics.domContentLoaded?.toFixed(2) || 'N/A', 'ms');
|
console.log('DOM Content Loaded:', metrics.domContentLoaded?.toFixed(2) || 'N/A', 'ms');
|
||||||
debugLog('Load Complete:', metrics.loadComplete?.toFixed(2) || 'N/A', 'ms');
|
console.log('Load Complete:', metrics.loadComplete?.toFixed(2) || 'N/A', 'ms');
|
||||||
debugLog('First Paint:', metrics.firstPaint?.toFixed(2) || 'N/A', 'ms');
|
console.log('First Paint:', metrics.firstPaint?.toFixed(2) || 'N/A', 'ms');
|
||||||
debugLog('First Contentful Paint:', metrics.firstContentfulPaint?.toFixed(2) || 'N/A', 'ms');
|
console.log('First Contentful Paint:', metrics.firstContentfulPaint?.toFixed(2) || 'N/A', 'ms');
|
||||||
debugLog('Resources:', metrics.resourceCount || 0, 'files,', (metrics.totalResourceSize / 1024).toFixed(2) || '0', 'KB');
|
console.log('Resources:', metrics.resourceCount || 0, 'files,', (metrics.totalResourceSize / 1024).toFixed(2) || '0', 'KB');
|
||||||
if (Object.keys(metrics.measures || {}).length > 0) {
|
if (Object.keys(metrics.measures || {}).length > 0) {
|
||||||
debugLog('Custom Measures:', metrics.measures);
|
console.log('Custom Measures:', metrics.measures);
|
||||||
}
|
}
|
||||||
console.groupEnd();
|
console.groupEnd();
|
||||||
}
|
}
|
||||||
@@ -378,170 +273,3 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
}, 100);
|
}, 100);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ===== Floating live preview =====
|
|
||||||
// A mini preview of the display, available on every tab except Overview
|
|
||||||
// (which has the full-size one). Open/closed state persists per browser;
|
|
||||||
// frames arrive via the existing SSE stream (updateDisplayPreview in
|
|
||||||
// app-shell.js feeds #floating-preview-img).
|
|
||||||
window.toggleFloatingPreview = function(open) {
|
|
||||||
try { localStorage.setItem('ledmatrix-floating-preview', open ? '1' : '0'); } catch { /* no-op */ }
|
|
||||||
window.updateFloatingPreviewVisibility();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Preset widths the size button cycles through (px). Desktop users can also
|
|
||||||
// drag the panel's native resize handle (CSS resize: both).
|
|
||||||
const FLOATING_PREVIEW_SIZES = [192, 256, 384, 512];
|
|
||||||
|
|
||||||
window.applyFloatingPreviewSize = function() {
|
|
||||||
const panel = document.getElementById('floating-preview');
|
|
||||||
if (!panel) return;
|
|
||||||
let size = 256;
|
|
||||||
try { size = parseInt(localStorage.getItem('ledmatrix-floating-preview-size'), 10) || 256; } catch { /* no-op */ }
|
|
||||||
panel.style.width = size + 'px';
|
|
||||||
// Clear any manual drag-resize height so the image's aspect ratio rules
|
|
||||||
panel.style.height = '';
|
|
||||||
};
|
|
||||||
|
|
||||||
window.cycleFloatingPreviewSize = function() {
|
|
||||||
let size = 256;
|
|
||||||
try { size = parseInt(localStorage.getItem('ledmatrix-floating-preview-size'), 10) || 256; } catch { /* no-op */ }
|
|
||||||
const idx = FLOATING_PREVIEW_SIZES.indexOf(size);
|
|
||||||
const next = FLOATING_PREVIEW_SIZES[(idx + 1) % FLOATING_PREVIEW_SIZES.length];
|
|
||||||
try { localStorage.setItem('ledmatrix-floating-preview-size', String(next)); } catch { /* no-op */ }
|
|
||||||
window.applyFloatingPreviewSize();
|
|
||||||
};
|
|
||||||
|
|
||||||
window.updateFloatingPreviewVisibility = function(tab) {
|
|
||||||
const panel = document.getElementById('floating-preview');
|
|
||||||
const toggle = document.getElementById('floating-preview-toggle');
|
|
||||||
if (!panel || !toggle) return;
|
|
||||||
let active = tab;
|
|
||||||
if (!active) {
|
|
||||||
const el = document.querySelector('[x-data="app()"]') || document.querySelector('[x-data]');
|
|
||||||
const data = el && el._x_dataStack && el._x_dataStack[0];
|
|
||||||
active = data && data.activeTab;
|
|
||||||
}
|
|
||||||
const onOverview = active === 'overview';
|
|
||||||
let open = false;
|
|
||||||
try { open = localStorage.getItem('ledmatrix-floating-preview') === '1'; } catch { /* no-op */ }
|
|
||||||
const showPanel = !onOverview && open;
|
|
||||||
panel.style.display = showPanel ? 'block' : 'none';
|
|
||||||
toggle.style.display = (!onOverview && !open) ? 'flex' : 'none';
|
|
||||||
if (showPanel) {
|
|
||||||
window.applyFloatingPreviewSize();
|
|
||||||
// Show the last cached frame immediately — SSE only pushes on
|
|
||||||
// display changes, so a freshly opened panel would otherwise stay
|
|
||||||
// empty until the next change.
|
|
||||||
const img = document.getElementById('floating-preview-img');
|
|
||||||
if (img && !img.src && window._lastPreviewFrame) {
|
|
||||||
img.src = 'data:image/png;base64,' + window._lastPreviewFrame;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
window.updateFloatingPreviewVisibility();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Run a plugin on the real display for 60s via the existing on-demand API
|
|
||||||
// and open the floating preview so the effect is visible while configuring.
|
|
||||||
window.previewPluginNow = function(pluginId) {
|
|
||||||
fetch('/api/v3/display/on-demand/start', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ plugin_id: pluginId, duration: 60 })
|
|
||||||
})
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(data => {
|
|
||||||
showNotification(data.message || ('Previewing ' + pluginId + ' for 60 seconds'),
|
|
||||||
data.status || 'success');
|
|
||||||
if (data.status === 'success') window.toggleFloatingPreview(true);
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
showNotification('Preview failed: ' + err.message, 'error');
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// ===== Nav accessibility =====
|
|
||||||
// aria-current tracks the active tab. Buttons are matched by their Alpine
|
|
||||||
// @click expression ("activeTab = '<tab>'"), which works for both the static
|
|
||||||
// system tabs and the dynamically injected plugin tabs.
|
|
||||||
window.updateNavAriaCurrent = function(tab) {
|
|
||||||
document.querySelectorAll('.nav-tab').forEach(function(btn) {
|
|
||||||
const expr = btn.getAttribute('@click') || btn.getAttribute('x-on:click') || '';
|
|
||||||
const isCurrent = expr.indexOf("activeTab = '" + tab + "'") !== -1;
|
|
||||||
if (isCurrent) {
|
|
||||||
btn.setAttribute('aria-current', 'page');
|
|
||||||
} else {
|
|
||||||
btn.removeAttribute('aria-current');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Escape closes the mobile nav drawer and returns focus to the hamburger;
|
|
||||||
// opening the drawer moves focus to its first tab.
|
|
||||||
(function() {
|
|
||||||
function appData() {
|
|
||||||
const el = document.querySelector('[x-data="app()"]') || document.querySelector('[x-data]');
|
|
||||||
return el && el._x_dataStack && el._x_dataStack[0];
|
|
||||||
}
|
|
||||||
document.addEventListener('keydown', function(e) {
|
|
||||||
if (e.key !== 'Escape') return;
|
|
||||||
const data = appData();
|
|
||||||
if (data && data.mobileNavOpen) {
|
|
||||||
data.mobileNavOpen = false;
|
|
||||||
const burger = document.querySelector('[aria-controls="site-nav"]');
|
|
||||||
if (burger) burger.focus();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
document.addEventListener('click', function(e) {
|
|
||||||
const burger = e.target && e.target.closest
|
|
||||||
? e.target.closest('[aria-controls="site-nav"]') : null;
|
|
||||||
if (!burger) return;
|
|
||||||
// The click handler toggles mobileNavOpen; focus the first tab once
|
|
||||||
// the drawer has slid in (matches the CSS transition timing).
|
|
||||||
setTimeout(function() {
|
|
||||||
const data = appData();
|
|
||||||
if (data && data.mobileNavOpen) {
|
|
||||||
const first = document.querySelector('#site-nav .nav-tab');
|
|
||||||
if (first) first.focus();
|
|
||||||
}
|
|
||||||
}, 120);
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
|
|
||||||
// ===== Mobile nav: header-widget relocation =====
|
|
||||||
// Below the md breakpoint the settings-search box and system-stats block are
|
|
||||||
// MOVED (same DOM nodes, listeners intact) from the header into the nav
|
|
||||||
// drawer's #drawer-widgets slot; at md and up they move back. Single-instance
|
|
||||||
// constraint: settings-search.js and the SSE stats updater both look these
|
|
||||||
// elements up by id, so they must never be duplicated.
|
|
||||||
window.placeHeaderWidgets = function() {
|
|
||||||
const drawer = document.getElementById('drawer-widgets');
|
|
||||||
const header = document.getElementById('header-widgets');
|
|
||||||
const search = document.getElementById('settings-search-wrap');
|
|
||||||
const stats = document.getElementById('system-stats');
|
|
||||||
if (!drawer || !header) return;
|
|
||||||
|
|
||||||
const desktop = window.matchMedia('(min-width: 768px)').matches;
|
|
||||||
if (desktop) {
|
|
||||||
// Restore original header order: search before the theme toggle,
|
|
||||||
// stats as the last item.
|
|
||||||
const themeToggle = document.getElementById('theme-toggle');
|
|
||||||
if (search && search.parentElement !== header) {
|
|
||||||
header.insertBefore(search, themeToggle || null);
|
|
||||||
}
|
|
||||||
if (stats && stats.parentElement !== header) {
|
|
||||||
header.appendChild(stats);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (search && search.parentElement !== drawer) drawer.appendChild(search);
|
|
||||||
if (stats && stats.parentElement !== drawer) drawer.appendChild(stats);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
window.placeHeaderWidgets();
|
|
||||||
window.matchMedia('(min-width: 768px)').addEventListener('change', window.placeHeaderWidgets);
|
|
||||||
});
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 7.4 KiB |
@@ -1,356 +0,0 @@
|
|||||||
/* global debugLog */
|
|
||||||
// Early helpers and the app() stub (must run before Alpine init)
|
|
||||||
// Extracted from templates/v3/base.html so browsers cache it as a static asset.
|
|
||||||
// Helper function to get installed plugins with fallback
|
|
||||||
// Must be defined before app() function that uses it
|
|
||||||
async function getInstalledPluginsSafe() {
|
|
||||||
if (window.PluginAPI && window.PluginAPI.getInstalledPlugins) {
|
|
||||||
try {
|
|
||||||
const plugins = await window.PluginAPI.getInstalledPlugins();
|
|
||||||
// Ensure plugins is always an array
|
|
||||||
const pluginsArray = Array.isArray(plugins) ? plugins : [];
|
|
||||||
return { status: 'success', data: { plugins: pluginsArray } };
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error using PluginAPI.getInstalledPlugins, falling back to direct fetch:', error);
|
|
||||||
// Fall through to direct fetch
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Fallback to direct fetch if PluginAPI not loaded
|
|
||||||
const response = await fetch('/api/v3/plugins/installed');
|
|
||||||
return await response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Global event listener for pluginsUpdated - works even if Alpine isn't ready yet
|
|
||||||
// This ensures tabs update when plugins_manager.js loads plugins
|
|
||||||
document.addEventListener('pluginsUpdated', function(event) {
|
|
||||||
debugLog('[GLOBAL] Received pluginsUpdated event:', event.detail?.plugins?.length || 0, 'plugins');
|
|
||||||
const plugins = event.detail?.plugins || [];
|
|
||||||
|
|
||||||
// Update window.installedPlugins
|
|
||||||
window.installedPlugins = plugins;
|
|
||||||
|
|
||||||
// Try to update Alpine component if it exists (only if using full implementation)
|
|
||||||
if (window.Alpine) {
|
|
||||||
const appElement = document.querySelector('[x-data="app()"]');
|
|
||||||
if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) {
|
|
||||||
const appComponent = appElement._x_dataStack[0];
|
|
||||||
appComponent.installedPlugins = plugins;
|
|
||||||
// Only call updatePluginTabs if it's the full implementation (has _doUpdatePluginTabs)
|
|
||||||
if (typeof appComponent.updatePluginTabs === 'function' &&
|
|
||||||
appComponent.updatePluginTabs.toString().includes('_doUpdatePluginTabs')) {
|
|
||||||
debugLog('[GLOBAL] Updating plugin tabs via Alpine component (full implementation)');
|
|
||||||
appComponent.updatePluginTabs();
|
|
||||||
return; // Full implementation handles it, don't do direct update
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only do direct DOM update if full implementation isn't available yet
|
|
||||||
const pluginTabsRow = document.getElementById('plugin-tabs-row');
|
|
||||||
const pluginTabsNav = pluginTabsRow?.querySelector('nav');
|
|
||||||
if (pluginTabsRow && pluginTabsNav && plugins.length > 0) {
|
|
||||||
// Clear existing plugin tabs (except Plugin Manager)
|
|
||||||
const existingTabs = pluginTabsNav.querySelectorAll('.plugin-tab');
|
|
||||||
existingTabs.forEach(tab => { tab.remove(); });
|
|
||||||
|
|
||||||
// Add tabs for each installed plugin
|
|
||||||
plugins.forEach(plugin => {
|
|
||||||
const tabButton = document.createElement('button');
|
|
||||||
tabButton.type = 'button';
|
|
||||||
tabButton.setAttribute('data-plugin-id', plugin.id);
|
|
||||||
tabButton.className = `plugin-tab nav-tab`;
|
|
||||||
tabButton.onclick = function() {
|
|
||||||
// Try to set activeTab via Alpine if available
|
|
||||||
if (window.Alpine) {
|
|
||||||
const appElement = document.querySelector('[x-data="app()"]');
|
|
||||||
if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) {
|
|
||||||
appElement._x_dataStack[0].activeTab = plugin.id;
|
|
||||||
// Only call updatePluginTabStates if it exists
|
|
||||||
if (typeof appElement._x_dataStack[0].updatePluginTabStates === 'function') {
|
|
||||||
appElement._x_dataStack[0].updatePluginTabStates();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// Built with DOM APIs (no innerHTML): the icon class and
|
|
||||||
// name come from plugin manifests, which are only
|
|
||||||
// semi-trusted input.
|
|
||||||
const tabIcon = document.createElement('i');
|
|
||||||
tabIcon.className = plugin.icon || 'fas fa-puzzle-piece';
|
|
||||||
tabButton.textContent = '';
|
|
||||||
tabButton.appendChild(tabIcon);
|
|
||||||
tabButton.appendChild(document.createTextNode(plugin.name || plugin.id));
|
|
||||||
pluginTabsNav.appendChild(tabButton);
|
|
||||||
});
|
|
||||||
debugLog('[GLOBAL] Updated plugin tabs directly:', plugins.length, 'tabs added');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Guard flag to prevent duplicate stub-to-full enhancement
|
|
||||||
window._appEnhanced = false;
|
|
||||||
|
|
||||||
// Define app() function early so Alpine can find it when it initializes
|
|
||||||
// This is a complete implementation that will work immediately
|
|
||||||
(function() {
|
|
||||||
const isAPMode = window.location.hostname === '192.168.4.1' ||
|
|
||||||
window.location.hostname.startsWith('192.168.4.');
|
|
||||||
|
|
||||||
// Create the app function - will be enhanced by full implementation later
|
|
||||||
window.app = function() {
|
|
||||||
return {
|
|
||||||
activeTab: isAPMode ? 'wifi' : 'overview',
|
|
||||||
mobileNavOpen: false,
|
|
||||||
installedPlugins: [],
|
|
||||||
|
|
||||||
init() {
|
|
||||||
// Try to enhance immediately with full implementation
|
|
||||||
const tryEnhance = () => {
|
|
||||||
if (window._appEnhanced) return true;
|
|
||||||
if (typeof window.app === 'function') {
|
|
||||||
const fullApp = window.app();
|
|
||||||
// Check if this is the full implementation (has updatePluginTabs with proper implementation)
|
|
||||||
if (fullApp && typeof fullApp.updatePluginTabs === 'function' && fullApp.updatePluginTabs.toString().includes('_doUpdatePluginTabs')) {
|
|
||||||
window._appEnhanced = true;
|
|
||||||
// Preserve runtime state that should not be reset
|
|
||||||
const preservedPlugins = this.installedPlugins;
|
|
||||||
const preservedTab = this.activeTab;
|
|
||||||
const defaultTab = isAPMode ? 'wifi' : 'overview';
|
|
||||||
const wasInitialized = this._initialized;
|
|
||||||
Object.assign(this, fullApp);
|
|
||||||
// Restore runtime state if non-default
|
|
||||||
if (preservedPlugins && preservedPlugins.length > 0) {
|
|
||||||
this.installedPlugins = preservedPlugins;
|
|
||||||
}
|
|
||||||
if (preservedTab && preservedTab !== defaultTab) {
|
|
||||||
this.activeTab = preservedTab;
|
|
||||||
}
|
|
||||||
if (wasInitialized) {
|
|
||||||
this._initialized = wasInitialized;
|
|
||||||
}
|
|
||||||
// Only call init if not already initialized
|
|
||||||
if (typeof this.init === 'function' && !this._initialized) {
|
|
||||||
this.init();
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Set up event listener for pluginsUpdated in stub (only if not already enhanced)
|
|
||||||
// The full implementation will have its own listener, so we only need this for the stub
|
|
||||||
if (!this._pluginsUpdatedListenerSet) {
|
|
||||||
const handlePluginsUpdated = (event) => {
|
|
||||||
debugLog('[STUB] Received pluginsUpdated event:', event.detail?.plugins?.length || 0, 'plugins');
|
|
||||||
const plugins = event.detail?.plugins || [];
|
|
||||||
// Only update if we're still in stub mode (not enhanced yet)
|
|
||||||
if (typeof this.updatePluginTabs === 'function' && !this.updatePluginTabs.toString().includes('_doUpdatePluginTabs')) {
|
|
||||||
this.installedPlugins = plugins;
|
|
||||||
if (this.$nextTick && typeof this.$nextTick === 'function') {
|
|
||||||
this.$nextTick(() => {
|
|
||||||
this.updatePluginTabs();
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setTimeout(() => {
|
|
||||||
this.updatePluginTabs();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener('pluginsUpdated', handlePluginsUpdated);
|
|
||||||
this._pluginsUpdatedListenerSet = true;
|
|
||||||
debugLog('[STUB] init: Set up pluginsUpdated event listener');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try immediately - if full implementation is already loaded, use it right away
|
|
||||||
if (!tryEnhance()) {
|
|
||||||
// Full implementation not ready yet, load plugins directly while waiting
|
|
||||||
this.loadInstalledPluginsDirectly();
|
|
||||||
// Try again very soon to enhance with full implementation
|
|
||||||
setTimeout(tryEnhance, 10);
|
|
||||||
|
|
||||||
// Also set up a periodic check to update tabs if plugins get loaded by plugins_manager.js
|
|
||||||
let retryCount = 0;
|
|
||||||
const maxRetries = 20; // Check for 2 seconds (20 * 100ms)
|
|
||||||
const checkAndUpdateTabs = () => {
|
|
||||||
if (retryCount >= maxRetries) {
|
|
||||||
// Fallback: if plugins_manager.js hasn't loaded after 2 seconds, fetch directly
|
|
||||||
if (!window.installedPlugins || window.installedPlugins.length === 0) {
|
|
||||||
debugLog('[STUB] checkAndUpdateTabs: Fallback - fetching plugins directly after timeout');
|
|
||||||
this.loadInstalledPluginsDirectly();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if plugins are available (either from window or component)
|
|
||||||
const plugins = window.installedPlugins || this.installedPlugins || [];
|
|
||||||
if (plugins.length > 0) {
|
|
||||||
debugLog('[STUB] checkAndUpdateTabs: Found', plugins.length, 'plugins, updating tabs');
|
|
||||||
this.installedPlugins = plugins;
|
|
||||||
if (typeof this.updatePluginTabs === 'function') {
|
|
||||||
this.updatePluginTabs();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
retryCount++;
|
|
||||||
setTimeout(checkAndUpdateTabs, 100);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// Start checking after a short delay
|
|
||||||
setTimeout(checkAndUpdateTabs, 200);
|
|
||||||
} else {
|
|
||||||
// Full implementation loaded, but still set up fallback timer
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!window.installedPlugins || window.installedPlugins.length === 0) {
|
|
||||||
debugLog('[STUB] init: Fallback timer - fetching plugins directly');
|
|
||||||
this.loadInstalledPluginsDirectly();
|
|
||||||
}
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Direct plugin loading for stub (before full implementation loads)
|
|
||||||
async loadInstalledPluginsDirectly() {
|
|
||||||
try {
|
|
||||||
debugLog('[STUB] loadInstalledPluginsDirectly: Starting...');
|
|
||||||
// Ensure DOM is ready
|
|
||||||
const ensureDOMReady = () => {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
if (document.readyState === 'complete' || document.readyState === 'interactive') {
|
|
||||||
// Use requestAnimationFrame to ensure DOM is painted
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
setTimeout(resolve, 50); // Small delay to ensure rendering
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
setTimeout(resolve, 50);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
await ensureDOMReady();
|
|
||||||
|
|
||||||
const data = await getInstalledPluginsSafe();
|
|
||||||
if (data.status === 'success') {
|
|
||||||
const plugins = data.data.plugins || [];
|
|
||||||
debugLog('[STUB] loadInstalledPluginsDirectly: Loaded', plugins.length, 'plugins');
|
|
||||||
|
|
||||||
// Update both component and window
|
|
||||||
this.installedPlugins = plugins;
|
|
||||||
window.installedPlugins = plugins;
|
|
||||||
|
|
||||||
// Dispatch event so global listener can update tabs
|
|
||||||
document.dispatchEvent(new CustomEvent('pluginsUpdated', {
|
|
||||||
detail: { plugins: plugins }
|
|
||||||
}));
|
|
||||||
debugLog('[STUB] loadInstalledPluginsDirectly: Dispatched pluginsUpdated event');
|
|
||||||
|
|
||||||
// Update tabs if we have the method - use $nextTick if available
|
|
||||||
if (typeof this.updatePluginTabs === 'function') {
|
|
||||||
if (this.$nextTick && typeof this.$nextTick === 'function') {
|
|
||||||
this.$nextTick(() => {
|
|
||||||
this.updatePluginTabs();
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Fallback: wait a bit for DOM
|
|
||||||
setTimeout(() => {
|
|
||||||
this.updatePluginTabs();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.warn('[STUB] loadInstalledPluginsDirectly: Failed to load plugins:', data.message);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[STUB] loadInstalledPluginsDirectly: Error loading plugins:', error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Stub methods that will be replaced by full implementation
|
|
||||||
loadTabContent: function(tab) {},
|
|
||||||
loadInstalledPlugins: async function() {
|
|
||||||
// Try to use global function if available, otherwise use direct loading
|
|
||||||
if (typeof window.loadInstalledPlugins === 'function') {
|
|
||||||
await window.loadInstalledPlugins();
|
|
||||||
// Update tabs after loading (window.installedPlugins should be set by the global function)
|
|
||||||
if (window.installedPlugins && Array.isArray(window.installedPlugins)) {
|
|
||||||
this.installedPlugins = window.installedPlugins;
|
|
||||||
this.updatePluginTabs();
|
|
||||||
}
|
|
||||||
} else if (typeof window.pluginManager?.loadInstalledPlugins === 'function') {
|
|
||||||
await window.pluginManager.loadInstalledPlugins();
|
|
||||||
// Update tabs after loading
|
|
||||||
if (window.installedPlugins && Array.isArray(window.installedPlugins)) {
|
|
||||||
this.installedPlugins = window.installedPlugins;
|
|
||||||
this.updatePluginTabs();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Fallback to direct loading (which already calls updatePluginTabs)
|
|
||||||
await this.loadInstalledPluginsDirectly();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
updatePluginTabs: function() {
|
|
||||||
// Basic implementation for stub - will be replaced by full implementation
|
|
||||||
// Debounce to prevent multiple rapid calls
|
|
||||||
if (this._updatePluginTabsTimeout) {
|
|
||||||
clearTimeout(this._updatePluginTabsTimeout);
|
|
||||||
}
|
|
||||||
|
|
||||||
this._updatePluginTabsTimeout = setTimeout(() => {
|
|
||||||
debugLog('[STUB] updatePluginTabs: Executing with', this.installedPlugins?.length || 0, 'plugins');
|
|
||||||
const pluginTabsRow = document.getElementById('plugin-tabs-row');
|
|
||||||
const pluginTabsNav = pluginTabsRow?.querySelector('nav');
|
|
||||||
if (!pluginTabsRow || !pluginTabsNav) {
|
|
||||||
console.warn('[STUB] updatePluginTabs: Plugin tabs container not found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!this.installedPlugins || this.installedPlugins.length === 0) {
|
|
||||||
debugLog('[STUB] updatePluginTabs: No plugins to display');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if tabs are already correct by comparing plugin IDs
|
|
||||||
const existingTabs = pluginTabsNav.querySelectorAll('.plugin-tab');
|
|
||||||
const existingIds = Array.from(existingTabs).map(tab => tab.getAttribute('data-plugin-id')).sort().join(',');
|
|
||||||
const currentIds = this.installedPlugins.map(p => p.id).sort().join(',');
|
|
||||||
|
|
||||||
if (existingIds === currentIds && existingTabs.length === this.installedPlugins.length) {
|
|
||||||
debugLog('[STUB] updatePluginTabs: Tabs already match, skipping update');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear existing plugin tabs (except Plugin Manager)
|
|
||||||
existingTabs.forEach(tab => { tab.remove(); });
|
|
||||||
debugLog('[STUB] updatePluginTabs: Cleared', existingTabs.length, 'existing tabs');
|
|
||||||
|
|
||||||
// Add tabs for each installed plugin
|
|
||||||
this.installedPlugins.forEach(plugin => {
|
|
||||||
const tabButton = document.createElement('button');
|
|
||||||
tabButton.type = 'button';
|
|
||||||
tabButton.setAttribute('data-plugin-id', plugin.id);
|
|
||||||
tabButton.className = `plugin-tab nav-tab ${this.activeTab === plugin.id ? 'nav-tab-active' : ''}`;
|
|
||||||
tabButton.onclick = () => {
|
|
||||||
this.activeTab = plugin.id;
|
|
||||||
if (typeof this.updatePluginTabStates === 'function') {
|
|
||||||
this.updatePluginTabStates();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// DOM APIs instead of innerHTML: manifest
|
|
||||||
// icon/name are semi-trusted input.
|
|
||||||
const tabIcon = document.createElement('i');
|
|
||||||
tabIcon.className = plugin.icon || 'fas fa-puzzle-piece';
|
|
||||||
tabButton.textContent = '';
|
|
||||||
tabButton.appendChild(tabIcon);
|
|
||||||
tabButton.appendChild(document.createTextNode(plugin.name || plugin.id));
|
|
||||||
pluginTabsNav.appendChild(tabButton);
|
|
||||||
});
|
|
||||||
debugLog('[STUB] updatePluginTabs: Added', this.installedPlugins.length, 'plugin tabs');
|
|
||||||
}, 100);
|
|
||||||
},
|
|
||||||
showNotification: function(message, type) {},
|
|
||||||
escapeHtml: function(text) { return String(text || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
|
||||||
};
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
@@ -0,0 +1,757 @@
|
|||||||
|
/**
|
||||||
|
* ComposerCanvas — stateless LED matrix canvas renderer.
|
||||||
|
*
|
||||||
|
* Coordinate system: LED pixels (integers). All drawing multiplies by SCALE.
|
||||||
|
* PIL draw.text(x,y) is top-left; canvas fillText(x,y) is baseline.
|
||||||
|
* → Canvas text cy = (actualY + fontSizePx) * SCALE
|
||||||
|
*
|
||||||
|
* Anchors: element x/y are offsets from their anchor point:
|
||||||
|
* xAnchor=null/'left' → x is fixed offset from left
|
||||||
|
* xAnchor='center' → x is offset from width/2
|
||||||
|
* xAnchor='right' → x is offset inward from right edge
|
||||||
|
* yAnchor follows the same pattern with 'top'/'middle'/'bottom'
|
||||||
|
*
|
||||||
|
* Breakpoints: elements with minWidth > currentMatrixW are rendered at 25% opacity.
|
||||||
|
*
|
||||||
|
* Resize handles: drawn on selected rectangles; 8 handles (corners + edge mids).
|
||||||
|
*/
|
||||||
|
window.ComposerCanvas = (() => {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
let _canvas = null;
|
||||||
|
let _ctx = null;
|
||||||
|
let _showGrid = true;
|
||||||
|
|
||||||
|
const DISPLAY_PRESETS = [
|
||||||
|
{ label: '64×32', w: 64, h: 32 },
|
||||||
|
{ label: '128×32', w: 128, h: 32 },
|
||||||
|
{ label: '128×64', w: 128, h: 64 },
|
||||||
|
{ label: '256×32', w: 256, h: 32 },
|
||||||
|
{ label: '256×64', w: 256, h: 64 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const FONT_MAP = {
|
||||||
|
press_start: { family: "'PressStart2P', monospace", sizePx: 8, charW: 8 },
|
||||||
|
four_by_six: { family: 'monospace', sizePx: 6, charW: 4 },
|
||||||
|
five_by_seven: { family: 'monospace', sizePx: 7, charW: 5 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const ELEMENT_DEFAULTS = {
|
||||||
|
text: {
|
||||||
|
text: 'Hello', font: 'press_start',
|
||||||
|
r: 255, g: 255, b: 255,
|
||||||
|
text2: '', lineSpacing: 2, textAlign: 'left',
|
||||||
|
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||||
|
},
|
||||||
|
dynamic_text: {
|
||||||
|
binding: { source: 'config', key: '', format: null },
|
||||||
|
font: 'press_start', textAlign: 'left',
|
||||||
|
r: 255, g: 200, b: 100,
|
||||||
|
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||||
|
},
|
||||||
|
clock: {
|
||||||
|
format: '%H:%M', font: 'press_start',
|
||||||
|
r: 100, g: 255, b: 100,
|
||||||
|
format2: '', lineSpacing: 2, textAlign: 'left',
|
||||||
|
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||||
|
},
|
||||||
|
rectangle: {
|
||||||
|
width: 20, height: 8,
|
||||||
|
fillR: 0, fillG: 0, fillB: 128, hasFill: true,
|
||||||
|
outR: 255, outG: 255, outB: 255, hasOutline: true,
|
||||||
|
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||||
|
},
|
||||||
|
ellipse: {
|
||||||
|
width: 24, height: 12,
|
||||||
|
fillR: 0, fillG: 100, fillB: 200, hasFill: true,
|
||||||
|
outR: 100, outG: 180, outB: 255, hasOutline: true,
|
||||||
|
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||||
|
},
|
||||||
|
arc: {
|
||||||
|
width: 24, height: 24,
|
||||||
|
startAngle: 0, endAngle: 270, lineWidth: 2,
|
||||||
|
r: 255, g: 200, b: 0,
|
||||||
|
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||||
|
},
|
||||||
|
pixel: {
|
||||||
|
r: 255, g: 255, b: 255,
|
||||||
|
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||||
|
},
|
||||||
|
rounded_rectangle: {
|
||||||
|
width: 24, height: 10, borderRadius: 3,
|
||||||
|
fillR: 0, fillG: 80, fillB: 180, hasFill: true,
|
||||||
|
outR: 120, outG: 180, outB: 255, hasOutline: true,
|
||||||
|
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||||
|
},
|
||||||
|
line: {
|
||||||
|
x0: 0, y0: 16, x1: 63, y1: 16,
|
||||||
|
r: 180, g: 180, b: 180, lineWidth: 1,
|
||||||
|
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||||
|
},
|
||||||
|
divider: {
|
||||||
|
orientation: 'horizontal', y: 16, x: 64,
|
||||||
|
r: 100, g: 100, b: 100,
|
||||||
|
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||||
|
},
|
||||||
|
progress_bar: {
|
||||||
|
barWidth: 60, barHeight: 6,
|
||||||
|
binding: { source: 'config', key: '', format: null },
|
||||||
|
r: 80, g: 200, b: 80,
|
||||||
|
bgR: 30, bgG: 30, bgB: 30, hasBg: true,
|
||||||
|
outR: 100, outG: 100, outB: 100, hasOutline: true,
|
||||||
|
previewPct: 65,
|
||||||
|
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||||
|
},
|
||||||
|
countdown: {
|
||||||
|
binding: { source: 'config', key: '', format: null },
|
||||||
|
countdownFormat: 'dh',
|
||||||
|
font: 'four_by_six', textAlign: 'left',
|
||||||
|
r: 255, g: 180, b: 0,
|
||||||
|
previewText: '42d 3h',
|
||||||
|
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||||
|
},
|
||||||
|
marquee: {
|
||||||
|
text: 'Scrolling text', font: 'press_start',
|
||||||
|
r: 255, g: 255, b: 255,
|
||||||
|
scrollSpeed: 1, gap: 16, direction: 'left',
|
||||||
|
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||||
|
},
|
||||||
|
section: {
|
||||||
|
label: 'Section',
|
||||||
|
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||||
|
},
|
||||||
|
pips: {
|
||||||
|
count: 5, filled: 3, pipSize: 4, pipSpacing: 2,
|
||||||
|
r: 255, g: 200, b: 0,
|
||||||
|
emptyR: 50, emptyG: 50, emptyB: 50, showEmpty: true,
|
||||||
|
binding: { source: 'config', key: '', format: null },
|
||||||
|
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||||
|
},
|
||||||
|
sparkline: {
|
||||||
|
width: 40, height: 12,
|
||||||
|
barCount: 8, barSpacing: 1,
|
||||||
|
r: 80, g: 200, b: 120,
|
||||||
|
bgR: 30, bgG: 30, bgB: 30, hasBg: false,
|
||||||
|
binding: { source: 'config', key: '', format: null },
|
||||||
|
previewData: '0.3,0.6,0.4,0.8,0.5,0.9,0.7,0.85',
|
||||||
|
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||||
|
},
|
||||||
|
gauge: {
|
||||||
|
width: 32, height: 32,
|
||||||
|
startAngle: 135, endAngle: 45, lineWidth: 3,
|
||||||
|
binding: { source: 'config', key: '', format: null },
|
||||||
|
r: 80, g: 220, b: 80,
|
||||||
|
trackR: 40, trackG: 40, trackB: 40, hasTrack: true,
|
||||||
|
showLabel: true, font: 'four_by_six', labelR: 200, labelG: 200, labelB: 200,
|
||||||
|
previewPct: 65,
|
||||||
|
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Anchor resolution ────────────────────────────────────────────────
|
||||||
|
function resolveAnchor(val, anchor, dim) {
|
||||||
|
if (!anchor || anchor === 'left' || anchor === 'top') return val;
|
||||||
|
if (anchor === 'center' || anchor === 'middle') return Math.floor(dim / 2) + val;
|
||||||
|
if (anchor === 'right' || anchor === 'bottom') return dim - val;
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeActualPos(el, matrixW, matrixH) {
|
||||||
|
const ax = resolveAnchor(el.x ?? el.x0 ?? 0, el.xAnchor, matrixW);
|
||||||
|
const ay = resolveAnchor(el.y ?? el.y0 ?? 0, el.yAnchor, matrixH);
|
||||||
|
return { x: ax, y: ay };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bounding box (LED pixel space) ──────────────────────────────────
|
||||||
|
function getBoundingBox(el, matrixW, matrixH) {
|
||||||
|
const { x: ax, y: ay } = computeActualPos(el, matrixW, matrixH);
|
||||||
|
const finfo = FONT_MAP[el.font] || FONT_MAP.press_start;
|
||||||
|
|
||||||
|
switch (el.type) {
|
||||||
|
case 'text': {
|
||||||
|
const t1 = el.text || '', t2 = el.text2 || '';
|
||||||
|
const w = Math.max(t1.length, t2.length) * finfo.charW;
|
||||||
|
const h = t2 ? finfo.sizePx * 2 + (el.lineSpacing ?? 2) : finfo.sizePx;
|
||||||
|
const bx = el.textAlign === 'center' ? ax - w / 2 : el.textAlign === 'right' ? ax - w : ax;
|
||||||
|
return { x: bx, y: ay, w, h };
|
||||||
|
}
|
||||||
|
case 'dynamic_text': {
|
||||||
|
const key = el.binding?.key || '?';
|
||||||
|
const w = (`{${key}}`).length * finfo.charW;
|
||||||
|
const bx = el.textAlign === 'center' ? ax - w / 2 : el.textAlign === 'right' ? ax - w : ax;
|
||||||
|
return { x: bx, y: ay, w, h: finfo.sizePx };
|
||||||
|
}
|
||||||
|
case 'clock': {
|
||||||
|
const t1 = el.format || '%H:%M', t2 = el.format2 || '';
|
||||||
|
const w = Math.max(t1.length, t2.length) * finfo.charW;
|
||||||
|
const h = t2 ? finfo.sizePx * 2 + (el.lineSpacing ?? 2) : finfo.sizePx;
|
||||||
|
const bx = el.textAlign === 'center' ? ax - w / 2 : el.textAlign === 'right' ? ax - w : ax;
|
||||||
|
return { x: bx, y: ay, w, h };
|
||||||
|
}
|
||||||
|
case 'countdown': {
|
||||||
|
const pt = el.previewText || '--d --h';
|
||||||
|
const w = pt.length * finfo.charW;
|
||||||
|
const bx = el.textAlign === 'center' ? ax - w / 2 : el.textAlign === 'right' ? ax - w : ax;
|
||||||
|
return { x: bx, y: ay, w, h: finfo.sizePx };
|
||||||
|
}
|
||||||
|
case 'rectangle':
|
||||||
|
case 'rounded_rectangle':
|
||||||
|
case 'ellipse':
|
||||||
|
case 'arc':
|
||||||
|
return { x: ax, y: ay, w: el.width, h: el.height };
|
||||||
|
case 'pixel':
|
||||||
|
return { x: ax, y: ay, w: 1, h: 1 };
|
||||||
|
case 'line':
|
||||||
|
return {
|
||||||
|
x: Math.min(el.x0, el.x1), y: Math.min(el.y0, el.y1),
|
||||||
|
w: Math.max(1, Math.abs(el.x1 - el.x0)),
|
||||||
|
h: Math.max(1, Math.abs(el.y1 - el.y0)),
|
||||||
|
};
|
||||||
|
case 'divider':
|
||||||
|
return el.orientation === 'horizontal'
|
||||||
|
? { x: 0, y: ay, w: matrixW, h: 1 }
|
||||||
|
: { x: ax, y: 0, w: 1, h: matrixH };
|
||||||
|
case 'progress_bar':
|
||||||
|
return { x: ax, y: ay, w: el.barWidth ?? 60, h: el.barHeight ?? 6 };
|
||||||
|
case 'marquee': {
|
||||||
|
const mfinfo = FONT_MAP[el.font] || FONT_MAP.press_start;
|
||||||
|
return { x: 0, y: ay, w: matrixW, h: mfinfo.sizePx };
|
||||||
|
}
|
||||||
|
case 'gauge':
|
||||||
|
return { x: ax, y: ay, w: el.width ?? 32, h: el.height ?? 32 };
|
||||||
|
case 'sparkline':
|
||||||
|
return { x: ax, y: ay, w: el.width ?? 40, h: el.height ?? 12 };
|
||||||
|
case 'pips': {
|
||||||
|
const pc = el.count ?? 5, ps = el.pipSize ?? 4, pg = el.pipSpacing ?? 2;
|
||||||
|
return { x: ax, y: ay, w: pc * ps + (pc - 1) * pg, h: ps };
|
||||||
|
}
|
||||||
|
case 'section':
|
||||||
|
return { x: ax, y: ay, w: 0, h: 0 };
|
||||||
|
default:
|
||||||
|
return { x: ax, y: ay, w: 4, h: 4 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Resize handle support ─────────────────────────────────────────────
|
||||||
|
// Returns 8 handle points for a rectangle in LED pixel space
|
||||||
|
function _getRectHandles(el, matrixW, matrixH) {
|
||||||
|
const { x: ax, y: ay } = computeActualPos(el, matrixW, matrixH);
|
||||||
|
const w = el.width, h = el.height;
|
||||||
|
const cx = ax + w / 2, cy = ay + h / 2;
|
||||||
|
return {
|
||||||
|
nw: { x: ax, y: ay },
|
||||||
|
n: { x: cx, y: ay },
|
||||||
|
ne: { x: ax + w, y: ay },
|
||||||
|
w: { x: ax, y: cy },
|
||||||
|
e: { x: ax + w, y: cy },
|
||||||
|
sw: { x: ax, y: ay + h },
|
||||||
|
s: { x: cx, y: ay + h },
|
||||||
|
se: { x: ax + w, y: ay + h },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the handle direction under LED-space point (lx, ly), or null
|
||||||
|
function getResizeHandle(el, lx, ly, matrixW, matrixH) {
|
||||||
|
if (!['rectangle', 'rounded_rectangle', 'ellipse', 'arc', 'gauge', 'sparkline'].includes(el.type)) return null;
|
||||||
|
const handles = _getRectHandles(el, matrixW, matrixH);
|
||||||
|
const PAD = 4;
|
||||||
|
for (const [dir, pt] of Object.entries(handles)) {
|
||||||
|
if (Math.abs(lx - pt.x) <= PAD && Math.abs(ly - pt.y) <= PAD) return dir;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _HANDLE_CURSORS = {
|
||||||
|
nw: 'nw-resize', n: 'n-resize', ne: 'ne-resize',
|
||||||
|
w: 'w-resize', e: 'e-resize',
|
||||||
|
sw: 'sw-resize', s: 's-resize', se: 'se-resize',
|
||||||
|
};
|
||||||
|
function getCursorForHandle(handle) {
|
||||||
|
return _HANDLE_CURSORS[handle] || 'crosshair';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hit test ─────────────────────────────────────────────────────────
|
||||||
|
function hitTest(el, lx, ly, matrixW, matrixH) {
|
||||||
|
const PAD = 3;
|
||||||
|
const bb = getBoundingBox(el, matrixW, matrixH);
|
||||||
|
return (
|
||||||
|
lx >= bb.x - PAD && lx <= bb.x + bb.w + PAD &&
|
||||||
|
ly >= bb.y - PAD && ly <= bb.y + bb.h + PAD
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Draw a single element ─────────────────────────────────────────────
|
||||||
|
function _drawElement(ctx, el, SCALE, matrixW, matrixH, opts = {}) {
|
||||||
|
const s = SCALE;
|
||||||
|
const { x: ax, y: ay } = computeActualPos(el, matrixW, matrixH);
|
||||||
|
const belowBreakpoint = el.minWidth > 0 && matrixW < el.minWidth;
|
||||||
|
const hidden = el.visible === false;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
if (hidden) ctx.globalAlpha = 0.12;
|
||||||
|
else if (belowBreakpoint) ctx.globalAlpha = 0.25;
|
||||||
|
|
||||||
|
// Blink animation: when blinkOff, fully hide blinking elements
|
||||||
|
if (el.blink) {
|
||||||
|
if (opts.blinkOff) { ctx.restore(); return; }
|
||||||
|
ctx.globalAlpha *= 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: compute draw X for text alignment
|
||||||
|
const _textX = (text, finfo) => {
|
||||||
|
const tw = text.length * finfo.charW * s;
|
||||||
|
if (el.textAlign === 'center') return ax * s - tw / 2;
|
||||||
|
if (el.textAlign === 'right') return ax * s - tw;
|
||||||
|
return ax * s;
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (el.type) {
|
||||||
|
case 'text':
|
||||||
|
case 'dynamic_text':
|
||||||
|
case 'clock': {
|
||||||
|
const finfo = FONT_MAP[el.font] || FONT_MAP.press_start;
|
||||||
|
const key = el.binding?.key || '?';
|
||||||
|
const pv = opts.previewValues?.[key];
|
||||||
|
// Substitute {variable} tokens in text using previewValues
|
||||||
|
const _subVars = str => (str || '').replace(/\{(\w+)\}/g, (_, k) => {
|
||||||
|
const v = opts.previewValues?.[k];
|
||||||
|
return v !== undefined && v !== '' ? String(v) : `{${k}}`;
|
||||||
|
});
|
||||||
|
const displayText =
|
||||||
|
el.type === 'text' ? _subVars(el.text || '')
|
||||||
|
: el.type === 'clock' ? (el.format || '%H:%M')
|
||||||
|
: (pv !== undefined && pv !== '' ? String(pv) : `{${key}}`);
|
||||||
|
ctx.font = `${finfo.sizePx * s}px ${finfo.family}`;
|
||||||
|
ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`;
|
||||||
|
ctx.fillText(displayText, _textX(displayText, finfo), (ay + finfo.sizePx) * s);
|
||||||
|
// Second line (text and clock)
|
||||||
|
if (el.type === 'text' && el.text2) {
|
||||||
|
const t2 = _subVars(el.text2);
|
||||||
|
const y2 = ay + finfo.sizePx + (el.lineSpacing ?? 2);
|
||||||
|
ctx.fillText(t2, _textX(t2, finfo), (y2 + finfo.sizePx) * s);
|
||||||
|
}
|
||||||
|
if (el.type === 'clock' && el.format2) {
|
||||||
|
const y2 = ay + finfo.sizePx + (el.lineSpacing ?? 2);
|
||||||
|
ctx.fillText(el.format2, _textX(el.format2, finfo), (y2 + finfo.sizePx) * s);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'countdown': {
|
||||||
|
const finfo = FONT_MAP[el.font] || FONT_MAP.press_start;
|
||||||
|
const t = el.previewText || '--d --h';
|
||||||
|
ctx.font = `${finfo.sizePx * s}px ${finfo.family}`;
|
||||||
|
ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`;
|
||||||
|
ctx.fillText(t, _textX(t, finfo), (ay + finfo.sizePx) * s);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'rectangle': {
|
||||||
|
const rx = ax * s, ry = ay * s;
|
||||||
|
const rw = el.width * s, rh = el.height * s;
|
||||||
|
if (el.hasFill) {
|
||||||
|
ctx.fillStyle = `rgb(${el.fillR},${el.fillG},${el.fillB})`;
|
||||||
|
ctx.fillRect(rx, ry, rw, rh);
|
||||||
|
}
|
||||||
|
if (el.hasOutline) {
|
||||||
|
ctx.strokeStyle = `rgb(${el.outR},${el.outG},${el.outB})`;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.strokeRect(rx, ry, rw, rh);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'ellipse': {
|
||||||
|
const cx = (ax + el.width / 2) * s;
|
||||||
|
const cy = (ay + el.height / 2) * s;
|
||||||
|
const rx = (el.width / 2) * s;
|
||||||
|
const ry = (el.height / 2) * s;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
|
||||||
|
if (el.hasFill) {
|
||||||
|
ctx.fillStyle = `rgb(${el.fillR},${el.fillG},${el.fillB})`;
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
if (el.hasOutline) {
|
||||||
|
ctx.strokeStyle = `rgb(${el.outR},${el.outG},${el.outB})`;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'arc': {
|
||||||
|
const cx = (ax + el.width / 2) * s;
|
||||||
|
const cy = (ay + el.height / 2) * s;
|
||||||
|
const rx = (el.width / 2) * s;
|
||||||
|
const ry = (el.height / 2) * s;
|
||||||
|
// PIL: 0°=right, clockwise. Canvas: same with anticlockwise=false
|
||||||
|
const startRad = (el.startAngle ?? 0) * Math.PI / 180;
|
||||||
|
const endRad = (el.endAngle ?? 270) * Math.PI / 180;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(cx, cy, rx, ry, 0, startRad, endRad, false);
|
||||||
|
ctx.strokeStyle = `rgb(${el.r},${el.g},${el.b})`;
|
||||||
|
ctx.lineWidth = Math.max(1, el.lineWidth || 2);
|
||||||
|
ctx.stroke();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'pixel': {
|
||||||
|
ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`;
|
||||||
|
ctx.fillRect(ax * s, ay * s, s, s);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'rounded_rectangle': {
|
||||||
|
const rx = ax * s, ry = ay * s;
|
||||||
|
const rw = el.width * s, rh = el.height * s;
|
||||||
|
const rad = Math.min((el.borderRadius ?? 3) * s, rw / 2, rh / 2);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(rx, ry, rw, rh, rad);
|
||||||
|
if (el.hasFill) {
|
||||||
|
ctx.fillStyle = `rgb(${el.fillR},${el.fillG},${el.fillB})`;
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
if (el.hasOutline) {
|
||||||
|
ctx.strokeStyle = `rgb(${el.outR},${el.outG},${el.outB})`;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'line': {
|
||||||
|
ctx.strokeStyle = `rgb(${el.r},${el.g},${el.b})`;
|
||||||
|
ctx.lineWidth = Math.max(1, el.lineWidth || 1);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(el.x0 * s, el.y0 * s);
|
||||||
|
ctx.lineTo(el.x1 * s, el.y1 * s);
|
||||||
|
ctx.stroke();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'divider': {
|
||||||
|
const isH = (el.orientation || 'horizontal') === 'horizontal';
|
||||||
|
ctx.strokeStyle = `rgb(${el.r},${el.g},${el.b})`;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
if (isH) {
|
||||||
|
ctx.moveTo(0, ay * s + 0.5);
|
||||||
|
ctx.lineTo(_canvas.width, ay * s + 0.5);
|
||||||
|
} else {
|
||||||
|
ctx.moveTo(ax * s + 0.5, 0);
|
||||||
|
ctx.lineTo(ax * s + 0.5, _canvas.height);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'pips': {
|
||||||
|
const pipCount = Math.max(1, el.count ?? 5);
|
||||||
|
const pvPips = opts.previewValues?.[el.binding?.key];
|
||||||
|
const filledN = pvPips !== undefined
|
||||||
|
? Math.max(0, Math.min(pipCount, Math.round(parseFloat(pvPips) || 0)))
|
||||||
|
: Math.max(0, Math.min(pipCount, el.filled ?? 3));
|
||||||
|
const ps = Math.max(1, el.pipSize ?? 4);
|
||||||
|
const pg = Math.max(0, el.pipSpacing ?? 2);
|
||||||
|
for (let i = 0; i < pipCount; i++) {
|
||||||
|
const isFilled = i < filledN;
|
||||||
|
if (!isFilled && !el.showEmpty) continue;
|
||||||
|
ctx.fillStyle = isFilled
|
||||||
|
? `rgb(${el.r},${el.g},${el.b})`
|
||||||
|
: `rgb(${el.emptyR ?? 50},${el.emptyG ?? 50},${el.emptyB ?? 50})`;
|
||||||
|
ctx.fillRect((ax + i * (ps + pg)) * s, ay * s, ps * s, ps * s);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'sparkline': {
|
||||||
|
const slW = el.width ?? 40, slH = el.height ?? 12;
|
||||||
|
const count = Math.max(1, el.barCount ?? 8);
|
||||||
|
const spacing = el.barSpacing ?? 1;
|
||||||
|
const barW = Math.max(1, Math.floor((slW - spacing * (count - 1)) / count));
|
||||||
|
const rawVals = (el.previewData || '').split(',')
|
||||||
|
.map(v => parseFloat(v.trim())).filter(n => !isNaN(n));
|
||||||
|
while (rawVals.length < count) rawVals.push(0);
|
||||||
|
const maxV = Math.max(...rawVals.slice(0, count), 0.001);
|
||||||
|
const rx = ax * s, ry = ay * s;
|
||||||
|
if (el.hasBg) {
|
||||||
|
ctx.fillStyle = `rgb(${el.bgR ?? 30},${el.bgG ?? 30},${el.bgB ?? 30})`;
|
||||||
|
ctx.fillRect(rx, ry, slW * s, slH * s);
|
||||||
|
}
|
||||||
|
ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`;
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const norm = Math.max(0, Math.min(1, rawVals[i] / maxV));
|
||||||
|
const barH = Math.max(1, Math.round(slH * norm));
|
||||||
|
const bx = rx + (barW + spacing) * i * s;
|
||||||
|
const by = ry + (slH - barH) * s;
|
||||||
|
ctx.fillRect(bx, by, barW * s, barH * s);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'gauge': {
|
||||||
|
const gw = (el.width ?? 32), gh = (el.height ?? 32);
|
||||||
|
const cx = (ax + gw / 2) * s, cy = (ay + gh / 2) * s;
|
||||||
|
const rx = (gw / 2) * s, ry = (gh / 2) * s;
|
||||||
|
const lw = Math.max(1, (el.lineWidth ?? 3));
|
||||||
|
const startDeg = el.startAngle ?? 135;
|
||||||
|
const endDeg = el.endAngle ?? 45;
|
||||||
|
// Arc sweep: from startDeg clockwise to endDeg (PIL convention)
|
||||||
|
const totalSweep = ((endDeg - startDeg) + 360) % 360 || 360;
|
||||||
|
const pvGauge = opts.previewValues?.[el.binding?.key];
|
||||||
|
const pct = pvGauge !== undefined
|
||||||
|
? Math.max(0, Math.min(100, parseFloat(pvGauge) || 0)) / 100
|
||||||
|
: Math.max(0, Math.min(100, el.previewPct ?? 65)) / 100;
|
||||||
|
const fillSweep = totalSweep * pct;
|
||||||
|
const toRad = deg => (deg - 90) * Math.PI / 180; // canvas 0=top, PIL 0=right → offset -90
|
||||||
|
|
||||||
|
// Track arc
|
||||||
|
if (el.hasTrack !== false) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(cx, cy, rx - lw / 2, ry - lw / 2, 0, toRad(startDeg), toRad(startDeg + totalSweep), false);
|
||||||
|
ctx.strokeStyle = `rgb(${el.trackR ?? 40},${el.trackG ?? 40},${el.trackB ?? 40})`;
|
||||||
|
ctx.lineWidth = lw * s;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
// Fill arc
|
||||||
|
if (pct > 0) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(cx, cy, rx - lw / 2, ry - lw / 2, 0, toRad(startDeg), toRad(startDeg + fillSweep), false);
|
||||||
|
ctx.strokeStyle = `rgb(${el.r},${el.g},${el.b})`;
|
||||||
|
ctx.lineWidth = lw * s;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
// Centre label
|
||||||
|
if (el.showLabel) {
|
||||||
|
const gfinfo = FONT_MAP[el.font || 'four_by_six'] || FONT_MAP.four_by_six;
|
||||||
|
const labelText = Math.round(pct * 100) + '%';
|
||||||
|
ctx.font = `${gfinfo.sizePx * s}px ${gfinfo.family}`;
|
||||||
|
ctx.fillStyle = `rgb(${el.labelR ?? 200},${el.labelG ?? 200},${el.labelB ?? 200})`;
|
||||||
|
const ltw = ctx.measureText(labelText).width;
|
||||||
|
ctx.fillText(labelText, cx - ltw / 2, cy + (gfinfo.sizePx * s) / 2);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'marquee': {
|
||||||
|
const finfo = FONT_MAP[el.font] || FONT_MAP.press_start;
|
||||||
|
const text = el.text || 'Scrolling text';
|
||||||
|
const tw = text.length * finfo.charW * s;
|
||||||
|
const gap = (el.gap ?? 16) * s;
|
||||||
|
const totalW = tw + gap;
|
||||||
|
const tick = opts.animTick ?? 0;
|
||||||
|
const speed = (el.scrollSpeed ?? 1) * 2;
|
||||||
|
const scrolled = (tick * speed) % totalW;
|
||||||
|
// left: text enters from right; right: text enters from left
|
||||||
|
const startX = el.direction === 'right'
|
||||||
|
? scrolled - tw
|
||||||
|
: matrixW * s - scrolled;
|
||||||
|
ctx.font = `${finfo.sizePx * s}px ${finfo.family}`;
|
||||||
|
ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`;
|
||||||
|
// Clip to canvas width so text doesn't bleed outside
|
||||||
|
ctx.save();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.rect(0, ay * s - 1, matrixW * s, (finfo.sizePx + 2) * s);
|
||||||
|
ctx.clip();
|
||||||
|
for (let i = -1; i <= 2; i++) {
|
||||||
|
ctx.fillText(text, startX + i * totalW, (ay + finfo.sizePx) * s);
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'progress_bar': {
|
||||||
|
const bw = el.barWidth ?? 60, bh = el.barHeight ?? 6;
|
||||||
|
const pvPb = opts.previewValues?.[el.binding?.key];
|
||||||
|
const pct = pvPb !== undefined
|
||||||
|
? Math.max(0, Math.min(100, parseFloat(pvPb) || 0)) / 100
|
||||||
|
: Math.max(0, Math.min(100, el.previewPct ?? 65)) / 100;
|
||||||
|
const rx = ax * s, ry = ay * s;
|
||||||
|
if (el.hasBg) {
|
||||||
|
ctx.fillStyle = `rgb(${el.bgR ?? 30},${el.bgG ?? 30},${el.bgB ?? 30})`;
|
||||||
|
ctx.fillRect(rx, ry, bw * s, bh * s);
|
||||||
|
}
|
||||||
|
const fillW = Math.max(0, Math.round(bw * pct));
|
||||||
|
if (fillW > 0) {
|
||||||
|
ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`;
|
||||||
|
ctx.fillRect(rx, ry, fillW * s, bh * s);
|
||||||
|
}
|
||||||
|
if (el.hasOutline) {
|
||||||
|
ctx.strokeStyle = `rgb(${el.outR ?? 100},${el.outG ?? 100},${el.outB ?? 100})`;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.strokeRect(rx, ry, bw * s, bh * s);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (belowBreakpoint) {
|
||||||
|
ctx.globalAlpha = 0.6;
|
||||||
|
const bb = getBoundingBox(el, matrixW, matrixH);
|
||||||
|
ctx.font = `${Math.max(8, s * 2)}px monospace`;
|
||||||
|
ctx.fillStyle = '#facc15';
|
||||||
|
ctx.fillText(`≥${el.minWidth}px`, bb.x * s, (bb.y + 4) * s);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Selection indicator ──────────────────────────────────────────────
|
||||||
|
function _drawSelection(ctx, el, SCALE, matrixW, matrixH) {
|
||||||
|
const bb = getBoundingBox(el, matrixW, matrixH);
|
||||||
|
const PAD = 2, s = SCALE;
|
||||||
|
const rx = bb.x * s - PAD, ry = bb.y * s - PAD;
|
||||||
|
const rw = bb.w * s + PAD * 2, rh = bb.h * s + PAD * 2;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.strokeStyle = '#3b82f6';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.setLineDash([3, 2]);
|
||||||
|
ctx.strokeRect(rx, ry, rw, rh);
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
|
||||||
|
if (el.xAnchor || el.yAnchor) {
|
||||||
|
ctx.font = `${Math.max(7, s)}px sans-serif`;
|
||||||
|
ctx.fillStyle = '#a78bfa';
|
||||||
|
const anchorText = [
|
||||||
|
el.xAnchor ? `x:${el.xAnchor[0]}` : '',
|
||||||
|
el.yAnchor ? `y:${el.yAnchor[0]}` : '',
|
||||||
|
].filter(Boolean).join(' ');
|
||||||
|
if (anchorText) ctx.fillText(anchorText, rx + 1, ry - 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resize handles: on rect, rounded rect, ellipse
|
||||||
|
if (['rectangle', 'rounded_rectangle', 'ellipse', 'arc', 'gauge', 'sparkline'].includes(el.type)) {
|
||||||
|
const handles = _getRectHandles(el, matrixW, matrixH);
|
||||||
|
const HS = 5;
|
||||||
|
ctx.fillStyle = 'white';
|
||||||
|
ctx.strokeStyle = '#2563eb';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
for (const pt of Object.values(handles)) {
|
||||||
|
const hx = pt.x * s - HS / 2;
|
||||||
|
const hy = pt.y * s - HS / 2;
|
||||||
|
ctx.fillRect(hx, hy, HS, HS);
|
||||||
|
ctx.strokeRect(hx, hy, HS, HS);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Corner dots for non-rectangle elements
|
||||||
|
ctx.fillStyle = '#3b82f6';
|
||||||
|
const HS = 4;
|
||||||
|
for (const [hx, hy] of [
|
||||||
|
[rx - HS / 2, ry - HS / 2], [rx + rw - HS / 2, ry - HS / 2],
|
||||||
|
[rx - HS / 2, ry + rh - HS / 2], [rx + rw - HS / 2, ry + rh - HS / 2],
|
||||||
|
]) ctx.fillRect(hx, hy, HS, HS);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Dimension tooltip while dragging ─────────────────────────────────
|
||||||
|
function drawDragTooltip(ctx, el, SCALE, matrixW, matrixH) {
|
||||||
|
const bb = getBoundingBox(el, matrixW, matrixH);
|
||||||
|
const label = el.type === 'rectangle'
|
||||||
|
? `${el.width}×${el.height}`
|
||||||
|
: `${bb.x},${bb.y}`;
|
||||||
|
const s = SCALE;
|
||||||
|
ctx.save();
|
||||||
|
ctx.font = `${Math.max(9, s * 1.5)}px monospace`;
|
||||||
|
const tw = ctx.measureText(label).width;
|
||||||
|
const tx = bb.x * s, ty = (bb.y - 2) * s;
|
||||||
|
ctx.fillStyle = 'rgba(0,0,0,0.7)';
|
||||||
|
ctx.fillRect(tx - 2, ty - 10, tw + 4, 12);
|
||||||
|
ctx.fillStyle = 'white';
|
||||||
|
ctx.fillText(label, tx, ty);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function init(canvasEl) {
|
||||||
|
_canvas = canvasEl;
|
||||||
|
_ctx = canvasEl.getContext('2d');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setGrid(show) { _showGrid = show; }
|
||||||
|
|
||||||
|
function updateCanvasSize(matrixW, matrixH, SCALE) {
|
||||||
|
if (!_canvas) return;
|
||||||
|
_canvas.width = matrixW * SCALE;
|
||||||
|
_canvas.height = matrixH * SCALE;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(elements, selectedId, matrixW, matrixH, SCALE, opts = {}) {
|
||||||
|
if (!_ctx) return;
|
||||||
|
const cW = matrixW * SCALE, cH = matrixH * SCALE;
|
||||||
|
|
||||||
|
const bg = opts.bgColor;
|
||||||
|
_ctx.fillStyle = bg ? `rgb(${bg.r},${bg.g},${bg.b})` : '#000';
|
||||||
|
_ctx.fillRect(0, 0, cW, cH);
|
||||||
|
|
||||||
|
if (_showGrid) {
|
||||||
|
_ctx.strokeStyle = 'rgba(255,255,255,0.07)';
|
||||||
|
_ctx.lineWidth = 0.5;
|
||||||
|
for (let x = SCALE; x < cW; x += SCALE) {
|
||||||
|
_ctx.beginPath(); _ctx.moveTo(x, 0); _ctx.lineTo(x, cH); _ctx.stroke();
|
||||||
|
}
|
||||||
|
for (let y = SCALE; y < cH; y += SCALE) {
|
||||||
|
_ctx.beginPath(); _ctx.moveTo(0, y); _ctx.lineTo(cW, y); _ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const el of elements) _drawElement(_ctx, el, SCALE, matrixW, matrixH, opts);
|
||||||
|
|
||||||
|
if (opts.showRuler) {
|
||||||
|
_ctx.save();
|
||||||
|
_ctx.fillStyle = 'rgba(255,255,255,0.08)';
|
||||||
|
_ctx.fillRect(0, 0, cW, SCALE); // top strip
|
||||||
|
_ctx.fillRect(0, 0, SCALE, cH); // left strip
|
||||||
|
_ctx.strokeStyle = 'rgba(255,255,255,0.5)';
|
||||||
|
_ctx.fillStyle = 'rgba(255,255,255,0.6)';
|
||||||
|
_ctx.font = `${Math.max(5, SCALE - 1)}px monospace`;
|
||||||
|
const step = SCALE >= 4 ? 8 : 16;
|
||||||
|
for (let px = 0; px <= matrixW; px += step) {
|
||||||
|
const cx = px * SCALE;
|
||||||
|
const major = px % 32 === 0;
|
||||||
|
_ctx.lineWidth = 0.5;
|
||||||
|
_ctx.beginPath(); _ctx.moveTo(cx, 0); _ctx.lineTo(cx, major ? SCALE : SCALE * 0.5); _ctx.stroke();
|
||||||
|
if (major && px > 0 && px < matrixW - 4) _ctx.fillText(String(px), cx + 1, SCALE - 1);
|
||||||
|
}
|
||||||
|
for (let py = 0; py <= matrixH; py += step) {
|
||||||
|
const cy = py * SCALE;
|
||||||
|
const major = py % 32 === 0;
|
||||||
|
_ctx.beginPath(); _ctx.moveTo(0, cy); _ctx.lineTo(major ? SCALE : SCALE * 0.5, cy); _ctx.stroke();
|
||||||
|
if (major && py > 0 && py < matrixH - 4) _ctx.fillText(String(py), 1, cy + SCALE - 1);
|
||||||
|
}
|
||||||
|
_ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.showGuides) {
|
||||||
|
_ctx.save();
|
||||||
|
_ctx.strokeStyle = 'rgba(255,60,60,0.45)';
|
||||||
|
_ctx.lineWidth = 1;
|
||||||
|
_ctx.setLineDash([4, 3]);
|
||||||
|
const mx = Math.floor(cW / 2) + 0.5;
|
||||||
|
const my = Math.floor(cH / 2) + 0.5;
|
||||||
|
_ctx.beginPath(); _ctx.moveTo(mx, 0); _ctx.lineTo(mx, cH); _ctx.stroke();
|
||||||
|
_ctx.beginPath(); _ctx.moveTo(0, my); _ctx.lineTo(cW, my); _ctx.stroke();
|
||||||
|
_ctx.setLineDash([]);
|
||||||
|
_ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
const sel = selectedId != null ? elements.find(e => e.id === selectedId) : null;
|
||||||
|
if (sel) {
|
||||||
|
_drawSelection(_ctx, sel, SCALE, matrixW, matrixH);
|
||||||
|
if (opts.showTooltip) drawDragTooltip(_ctx, sel, SCALE, matrixW, matrixH);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
init, render, setGrid, updateCanvasSize,
|
||||||
|
hitTest, getBoundingBox, computeActualPos, resolveAnchor,
|
||||||
|
getResizeHandle, getCursorForHandle,
|
||||||
|
ELEMENT_DEFAULTS, FONT_MAP, DISPLAY_PRESETS,
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -1,256 +0,0 @@
|
|||||||
/* global debugLog */
|
|
||||||
// HTMX swap/script-execution configuration and section toggle helpers
|
|
||||||
// Extracted from templates/v3/base.html so browsers cache it as a static asset.
|
|
||||||
// Configure HTMX to evaluate scripts in swapped content and fix insertBefore errors
|
|
||||||
(function() {
|
|
||||||
function setupScriptExecution() {
|
|
||||||
if (document.body) {
|
|
||||||
// Fix HTMX insertBefore errors by validating targets before swap
|
|
||||||
document.body.addEventListener('htmx:beforeSwap', function(event) {
|
|
||||||
try {
|
|
||||||
const target = event.detail.target;
|
|
||||||
if (!target) {
|
|
||||||
console.warn('[HTMX] Target is null, skipping swap');
|
|
||||||
event.detail.shouldSwap = false;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if target is a valid DOM element
|
|
||||||
if (!(target instanceof Element)) {
|
|
||||||
console.warn('[HTMX] Target is not a valid Element, skipping swap');
|
|
||||||
event.detail.shouldSwap = false;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if target has a parent node (required for insertBefore)
|
|
||||||
if (!target.parentNode) {
|
|
||||||
console.warn('[HTMX] Target has no parent node, skipping swap');
|
|
||||||
event.detail.shouldSwap = false;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure target is in the DOM
|
|
||||||
if (!document.body.contains(target) && !document.head.contains(target)) {
|
|
||||||
console.warn('[HTMX] Target is not in DOM, skipping swap');
|
|
||||||
event.detail.shouldSwap = false;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Additional check: ensure parent is also in DOM
|
|
||||||
if (target.parentNode && !document.body.contains(target.parentNode) && !document.head.contains(target.parentNode)) {
|
|
||||||
console.warn('[HTMX] Target parent is not in DOM, skipping swap');
|
|
||||||
event.detail.shouldSwap = false;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// All checks passed, allow swap
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
// If validation fails, cancel swap
|
|
||||||
console.warn('[HTMX] Error validating target:', e);
|
|
||||||
event.detail.shouldSwap = false;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Suppress HTMX insertBefore errors and other noisy errors - they're harmless but noisy
|
|
||||||
const originalError = console.error;
|
|
||||||
const originalWarn = console.warn;
|
|
||||||
|
|
||||||
console.error = function(...args) {
|
|
||||||
const errorStr = args.join(' ');
|
|
||||||
const errorStack = args.find(arg => arg && typeof arg === 'string' && arg.includes('htmx')) || '';
|
|
||||||
|
|
||||||
// Suppress HTMX insertBefore errors (comprehensive check)
|
|
||||||
// These occur when HTMX tries to swap content but the target element is null
|
|
||||||
// Usually happens due to timing/race conditions and is harmless
|
|
||||||
if (errorStr.includes("insertBefore") ||
|
|
||||||
errorStr.includes("Cannot read properties of null") ||
|
|
||||||
errorStr.includes("reading 'insertBefore'")) {
|
|
||||||
// Check if it's from HTMX by looking at stack trace or error string
|
|
||||||
// Also check the call stack if available
|
|
||||||
const isHtmxError = errorStr.includes('htmx') ||
|
|
||||||
errorStack.includes('htmx') ||
|
|
||||||
args.some(arg => {
|
|
||||||
if (typeof arg === 'string') {
|
|
||||||
return arg.includes('htmx');
|
|
||||||
}
|
|
||||||
// Check error objects for stack traces
|
|
||||||
if (arg && typeof arg === 'object' && arg.stack) {
|
|
||||||
return arg.stack.includes('htmx');
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isHtmxError) {
|
|
||||||
return; // Suppress - this is a harmless HTMX timing/race condition issue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Suppress script execution errors from malformed HTML
|
|
||||||
if (errorStr.includes("Failed to execute 'appendChild' on 'Node'") ||
|
|
||||||
errorStr.includes("Failed to execute 'insertBefore' on 'Node'")) {
|
|
||||||
if (errorStr.includes('Unexpected token')) {
|
|
||||||
return; // Suppress malformed HTML errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
originalError.apply(console, args);
|
|
||||||
};
|
|
||||||
|
|
||||||
console.warn = function(...args) {
|
|
||||||
const warnStr = args.join(' ');
|
|
||||||
// Suppress Permissions-Policy warnings (harmless browser warnings)
|
|
||||||
if (warnStr.includes('Permissions-Policy header') ||
|
|
||||||
warnStr.includes('Unrecognized feature') ||
|
|
||||||
warnStr.includes('Origin trial controlled feature') ||
|
|
||||||
warnStr.includes('browsing-topics') ||
|
|
||||||
warnStr.includes('run-ad-auction') ||
|
|
||||||
warnStr.includes('join-ad-interest-group') ||
|
|
||||||
warnStr.includes('private-state-token') ||
|
|
||||||
warnStr.includes('private-aggregation') ||
|
|
||||||
warnStr.includes('attribution-reporting')) {
|
|
||||||
return; // Suppress - these are harmless browser feature warnings
|
|
||||||
}
|
|
||||||
originalWarn.apply(console, args);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle HTMX errors gracefully with detailed logging
|
|
||||||
document.body.addEventListener('htmx:responseError', function(event) {
|
|
||||||
const detail = event.detail;
|
|
||||||
const xhr = detail.xhr;
|
|
||||||
const target = detail.target;
|
|
||||||
|
|
||||||
// Enhanced error logging
|
|
||||||
console.error('HTMX response error:', {
|
|
||||||
status: xhr?.status,
|
|
||||||
statusText: xhr?.statusText,
|
|
||||||
url: xhr?.responseURL,
|
|
||||||
target: target?.id || target?.tagName,
|
|
||||||
responseText: xhr?.responseText
|
|
||||||
});
|
|
||||||
|
|
||||||
// For form submissions, log field names only — values
|
|
||||||
// may contain API keys, passwords, or other secrets
|
|
||||||
// that must never reach the console.
|
|
||||||
if (target && target.tagName === 'FORM') {
|
|
||||||
const formData = new FormData(target);
|
|
||||||
const fieldNames = [];
|
|
||||||
for (const [key] of formData.entries()) {
|
|
||||||
fieldNames.push(key);
|
|
||||||
}
|
|
||||||
console.error('Form fields (values redacted):', fieldNames);
|
|
||||||
|
|
||||||
// Try to parse error response for validation details
|
|
||||||
if (xhr?.responseText) {
|
|
||||||
try {
|
|
||||||
const errorData = JSON.parse(xhr.responseText);
|
|
||||||
console.error('Error details:', {
|
|
||||||
message: errorData.message,
|
|
||||||
details: errorData.details,
|
|
||||||
validation_errors: errorData.validation_errors,
|
|
||||||
context: errorData.context
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
console.error('Error response (non-JSON):', xhr.responseText.substring(0, 500));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.body.addEventListener('htmx:swapError', function(event) {
|
|
||||||
// Log but don't break the app
|
|
||||||
console.warn('HTMX swap error:', event.detail);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Execute <script> tags in swapped content ourselves, on
|
|
||||||
// htmx:afterSwap (synchronous, right after the swap) rather
|
|
||||||
// than relying on htmx's own script handling, which runs
|
|
||||||
// during its later "settle" phase (~20ms after swap, per
|
|
||||||
// htmx's defaultSettleDelay). Alpine's MutationObserver
|
|
||||||
// processes newly-inserted x-data elements synchronously
|
|
||||||
// as soon as the swap lands, which is BEFORE htmx's settle
|
|
||||||
// phase - so any partial whose x-data component function
|
|
||||||
// (e.g. wifiSetup()) is defined by an inline <script> in
|
|
||||||
// that same partial would have that script still un-run
|
|
||||||
// when Alpine evaluates x-data, permanently failing with
|
|
||||||
// "wifiSetup is not defined" (Alpine does not retry).
|
|
||||||
// Disable htmx's own native script re-execution so the
|
|
||||||
// same script doesn't also run a second time via settle.
|
|
||||||
if (typeof htmx !== 'undefined' && htmx.config) {
|
|
||||||
htmx.config.allowScriptTags = false;
|
|
||||||
}
|
|
||||||
document.body.addEventListener('htmx:afterSwap', function(event) {
|
|
||||||
const target = event.detail && event.detail.target;
|
|
||||||
if (!target || !(target instanceof Element)) return;
|
|
||||||
target.querySelectorAll('script').forEach(function(oldScript) {
|
|
||||||
const newScript = document.createElement('script');
|
|
||||||
for (const attr of oldScript.attributes) {
|
|
||||||
newScript.setAttribute(attr.name, attr.value);
|
|
||||||
}
|
|
||||||
newScript.textContent = oldScript.textContent;
|
|
||||||
oldScript.replaceWith(newScript);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mark tab containers as loaded once their content settles, so switching
|
|
||||||
// away and back doesn't re-fetch. Scoped to the "loadtab" trigger (tab
|
|
||||||
// containers only) so modals and plugin config panels can still reload.
|
|
||||||
document.body.addEventListener('htmx:afterSettle', function(event) {
|
|
||||||
if (event.detail && event.detail.target) {
|
|
||||||
const target = event.detail.target;
|
|
||||||
const trigger = target.getAttribute('hx-trigger') || '';
|
|
||||||
if (trigger.includes('loadtab')) {
|
|
||||||
target.setAttribute('data-loaded', 'true');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', setupScriptExecution);
|
|
||||||
} else {
|
|
||||||
setTimeout(setupScriptExecution, 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setupScriptExecution();
|
|
||||||
|
|
||||||
// Section toggle function - define early so it's available for HTMX-loaded content
|
|
||||||
window.toggleSection = function(sectionId) {
|
|
||||||
const section = document.getElementById(sectionId);
|
|
||||||
const icon = document.getElementById(sectionId + '-icon');
|
|
||||||
if (!section) {
|
|
||||||
console.warn('toggleSection: Could not find section for', sectionId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!icon) {
|
|
||||||
console.warn('toggleSection: Could not find icon for', sectionId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if currently hidden by checking both class and computed display
|
|
||||||
const hasHiddenClass = section.classList.contains('hidden');
|
|
||||||
const computedDisplay = window.getComputedStyle(section).display;
|
|
||||||
const isHidden = hasHiddenClass || computedDisplay === 'none';
|
|
||||||
|
|
||||||
if (isHidden) {
|
|
||||||
// Show the section - remove hidden class and explicitly set display to block
|
|
||||||
section.classList.remove('hidden');
|
|
||||||
section.style.display = 'block';
|
|
||||||
icon.classList.remove('fa-chevron-right');
|
|
||||||
icon.classList.add('fa-chevron-down');
|
|
||||||
} else {
|
|
||||||
// Hide the section - add hidden class and set display to none
|
|
||||||
section.classList.add('hidden');
|
|
||||||
section.style.display = 'none';
|
|
||||||
icon.classList.remove('fa-chevron-down');
|
|
||||||
icon.classList.add('fa-chevron-right');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep assistive tech in sync: any toggle button that declares
|
|
||||||
// aria-controls for this section mirrors the expanded state.
|
|
||||||
const controlBtn = document.querySelector(`[aria-controls="${sectionId}"]`);
|
|
||||||
if (controlBtn) {
|
|
||||||
controlBtn.setAttribute('aria-expanded', String(isHidden));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
@@ -173,14 +173,7 @@
|
|||||||
|
|
||||||
function setActiveTab(tab) {
|
function setActiveTab(tab) {
|
||||||
var data = getAppData();
|
var data = getAppData();
|
||||||
if (data) {
|
if (data) { data.activeTab = tab; return true; }
|
||||||
data.activeTab = tab;
|
|
||||||
// Navigating from a search result should also dismiss the mobile
|
|
||||||
// nav drawer (harmless no-op on desktop, where the drawer CSS
|
|
||||||
// doesn't apply).
|
|
||||||
if ('mobileNavOpen' in data) data.mobileNavOpen = false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -209,7 +209,6 @@
|
|||||||
const removeButton = document.createElement('button');
|
const removeButton = document.createElement('button');
|
||||||
removeButton.type = 'button';
|
removeButton.type = 'button';
|
||||||
removeButton.className = 'text-red-600 hover:text-red-800 px-2 py-1';
|
removeButton.className = 'text-red-600 hover:text-red-800 px-2 py-1';
|
||||||
removeButton.setAttribute('aria-label', 'Remove feed');
|
|
||||||
removeButton.addEventListener('click', function() {
|
removeButton.addEventListener('click', function() {
|
||||||
window.removeCustomFeedRow(this);
|
window.removeCustomFeedRow(this);
|
||||||
});
|
});
|
||||||
@@ -334,7 +333,6 @@
|
|||||||
const removeButton = document.createElement('button');
|
const removeButton = document.createElement('button');
|
||||||
removeButton.type = 'button';
|
removeButton.type = 'button';
|
||||||
removeButton.className = 'text-red-600 hover:text-red-800 px-2 py-1';
|
removeButton.className = 'text-red-600 hover:text-red-800 px-2 py-1';
|
||||||
removeButton.setAttribute('aria-label', 'Remove feed');
|
|
||||||
removeButton.addEventListener('click', function() {
|
removeButton.addEventListener('click', function() {
|
||||||
window.removeCustomFeedRow(this);
|
window.removeCustomFeedRow(this);
|
||||||
});
|
});
|
||||||
@@ -406,16 +404,7 @@
|
|||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
// Backend contract (see api_v3.upload_plugin_asset): the request
|
formData.append('file', file);
|
||||||
// field must be named "files" (it does request.files.getlist('files')
|
|
||||||
// and 400s with "No files provided" otherwise), and the response
|
|
||||||
// carries the result in a top-level "uploaded_files" key, not nested
|
|
||||||
// under "data". file-upload-single.js's working upload flow uses this
|
|
||||||
// same contract.
|
|
||||||
// Backend contract (api_v3.upload_plugin_asset): field must be named
|
|
||||||
// "files" (request.files.getlist('files')), and the response carries
|
|
||||||
// results in a top-level "uploaded_files" key, not nested under "data".
|
|
||||||
formData.append('files', file);
|
|
||||||
formData.append('plugin_id', pluginId);
|
formData.append('plugin_id', pluginId);
|
||||||
|
|
||||||
fetch('/api/v3/plugins/assets/upload', {
|
fetch('/api/v3/plugins/assets/upload', {
|
||||||
@@ -432,8 +421,8 @@
|
|||||||
return response.json();
|
return response.json();
|
||||||
})
|
})
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.status === 'success' && data.uploaded_files && data.uploaded_files.length > 0) {
|
if (data.status === 'success' && data.data && data.data.files && data.data.files.length > 0) {
|
||||||
const uploadedFile = data.uploaded_files[0];
|
const uploadedFile = data.data.files[0];
|
||||||
const row = document.querySelector(`#${fieldId}_tbody tr[data-index="${index}"]`);
|
const row = document.querySelector(`#${fieldId}_tbody tr[data-index="${index}"]`);
|
||||||
if (row) {
|
if (row) {
|
||||||
const logoCell = row.querySelector('td:nth-child(3)');
|
const logoCell = row.querySelector('td:nth-child(3)');
|
||||||
@@ -506,6 +495,8 @@
|
|||||||
// Append container to logoCell
|
// Append container to logoCell
|
||||||
logoCell.appendChild(container);
|
logoCell.appendChild(container);
|
||||||
}
|
}
|
||||||
|
// Allow re-uploading the same file
|
||||||
|
event.target.value = '';
|
||||||
} else {
|
} else {
|
||||||
const notifyFn = window.showNotification || alert;
|
const notifyFn = window.showNotification || alert;
|
||||||
notifyFn('Upload failed: ' + (data.message || 'Unknown error'), 'error');
|
notifyFn('Upload failed: ' + (data.message || 'Unknown error'), 'error');
|
||||||
@@ -515,12 +506,6 @@
|
|||||||
console.error('Upload error:', error);
|
console.error('Upload error:', error);
|
||||||
const notifyFn = window.showNotification || alert;
|
const notifyFn = window.showNotification || alert;
|
||||||
notifyFn('Upload failed: ' + error.message, 'error');
|
notifyFn('Upload failed: ' + error.message, 'error');
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
// Reset regardless of outcome, so the same file can be re-selected
|
|
||||||
// to retry after a failure (browsers won't fire "change" again
|
|
||||||
// for an input that still holds that exact file).
|
|
||||||
event.target.value = '';
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -58,10 +58,6 @@
|
|||||||
|
|
||||||
// Track active notifications
|
// Track active notifications
|
||||||
let activeNotifications = [];
|
let activeNotifications = [];
|
||||||
// onAction callbacks for notifications with an inline action button,
|
|
||||||
// keyed by notification id (cleaned up on dismiss). A Map rather than a
|
|
||||||
// plain object so ids can never collide with prototype properties.
|
|
||||||
const actionCallbacks = new Map();
|
|
||||||
let notificationCounter = 0;
|
let notificationCounter = 0;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -117,7 +113,6 @@
|
|||||||
|
|
||||||
// Remove from tracking array
|
// Remove from tracking array
|
||||||
activeNotifications = activeNotifications.filter(id => id !== notificationId);
|
activeNotifications = activeNotifications.filter(id => id !== notificationId);
|
||||||
actionCallbacks.delete(notificationId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -163,20 +158,6 @@
|
|||||||
|
|
||||||
html += `<span class="flex-1 text-sm">${escapeHtml(message)}</span>`;
|
html += `<span class="flex-1 text-sm">${escapeHtml(message)}</span>`;
|
||||||
|
|
||||||
// Optional inline action button (e.g. "Restart Now" on a restart nudge).
|
|
||||||
// The callback is stored by id and invoked via triggerAction, which
|
|
||||||
// also dismisses the notification.
|
|
||||||
if (options.actionLabel && typeof options.onAction === 'function') {
|
|
||||||
actionCallbacks.set(notificationId, options.onAction);
|
|
||||||
html += `
|
|
||||||
<button type="button"
|
|
||||||
onclick="window.LEDMatrixWidgets.get('notification').triggerAction('${notificationId}')"
|
|
||||||
class="flex-shrink-0 ml-2 px-3 py-1 text-xs font-semibold rounded-md bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors duration-150">
|
|
||||||
${escapeHtml(options.actionLabel)}
|
|
||||||
</button>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dismissible) {
|
if (dismissible) {
|
||||||
html += `
|
html += `
|
||||||
<button type="button"
|
<button type="button"
|
||||||
@@ -246,17 +227,6 @@
|
|||||||
removeNotification(notificationId);
|
removeNotification(notificationId);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Invoke a notification's onAction callback (see options.actionLabel /
|
|
||||||
* options.onAction on show) and dismiss it.
|
|
||||||
* @param {string} notificationId - Notification ID whose action to run
|
|
||||||
*/
|
|
||||||
triggerAction: function(notificationId) {
|
|
||||||
const cb = actionCallbacks.get(notificationId);
|
|
||||||
removeNotification(notificationId);
|
|
||||||
if (typeof cb === 'function') cb();
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear all notifications
|
* Clear all notifications
|
||||||
*/
|
*/
|
||||||
@@ -292,11 +262,9 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Global shorthand function (backwards compatible with existing code).
|
// Global shorthand function (backwards compatible with existing code)
|
||||||
// Accepts either the legacy type string or a full options object
|
|
||||||
// ({ type, duration, actionLabel, onAction, ... }).
|
|
||||||
window.showNotification = function(message, type = 'info') {
|
window.showNotification = function(message, type = 'info') {
|
||||||
return showNotification(message, typeof type === 'string' ? { type: type } : (type || {}));
|
return showNotification(message, { type: type });
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initialize container on load
|
// Initialize container on load
|
||||||
|
|||||||
@@ -1,225 +0,0 @@
|
|||||||
/**
|
|
||||||
* Plugin Order List — shared drag-and-drop reorder list of enabled plugins.
|
|
||||||
*
|
|
||||||
* Factored out of the Vegas Scroll section of display.html so both Vegas mode
|
|
||||||
* and the primary rotation (Durations tab) use one implementation. Renders
|
|
||||||
* one draggable row per enabled plugin into a container and keeps a hidden
|
|
||||||
* input's value in sync as a JSON array of plugin ids in display order.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* PluginOrderList.init({
|
|
||||||
* containerId: 'vegas_plugin_order', // rows render here
|
|
||||||
* orderInputId: 'vegas_plugin_order_value', // hidden input, JSON array of ids
|
|
||||||
* excludedInputId: 'vegas_excluded_plugins_value', // optional: adds an
|
|
||||||
* // include-checkbox per row; unchecked ids collect here (JSON array)
|
|
||||||
* showVegasModeBadge: true // optional: Scroll/Fixed/Static badge
|
|
||||||
* });
|
|
||||||
*
|
|
||||||
* The container re-renders from /api/v3/plugins/installed each init; the
|
|
||||||
* hidden input(s) must already hold the saved order/exclusions (JSON).
|
|
||||||
*/
|
|
||||||
(function() {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const MODE_LABELS = new Map([
|
|
||||||
['scroll', { label: 'Scroll', icon: 'fa-scroll', color: 'text-blue-600' }],
|
|
||||||
['fixed', { label: 'Fixed', icon: 'fa-square', color: 'text-green-600' }],
|
|
||||||
['static', { label: 'Static', icon: 'fa-pause', color: 'text-orange-600' }]
|
|
||||||
]);
|
|
||||||
|
|
||||||
function init(options) {
|
|
||||||
const container = document.getElementById(options.containerId);
|
|
||||||
const orderInput = document.getElementById(options.orderInputId);
|
|
||||||
const excludedInput = options.excludedInputId ? document.getElementById(options.excludedInputId) : null;
|
|
||||||
if (!container || !orderInput) return;
|
|
||||||
|
|
||||||
function syncInputs() {
|
|
||||||
const order = [];
|
|
||||||
const excluded = [];
|
|
||||||
container.querySelectorAll('.plugin-order-item').forEach(item => {
|
|
||||||
const pluginId = item.dataset.pluginId;
|
|
||||||
order.push(pluginId);
|
|
||||||
const checkbox = item.querySelector('.plugin-order-include');
|
|
||||||
if (checkbox && !checkbox.checked) excluded.push(pluginId);
|
|
||||||
});
|
|
||||||
orderInput.value = JSON.stringify(order);
|
|
||||||
if (excludedInput) excludedInput.value = JSON.stringify(excluded);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupDragAndDrop() {
|
|
||||||
let draggedItem = null;
|
|
||||||
container.querySelectorAll('.plugin-order-item').forEach(item => {
|
|
||||||
item.addEventListener('dragstart', function(e) {
|
|
||||||
draggedItem = this;
|
|
||||||
this.style.opacity = '0.5';
|
|
||||||
e.dataTransfer.effectAllowed = 'move';
|
|
||||||
});
|
|
||||||
item.addEventListener('dragend', function() {
|
|
||||||
this.style.opacity = '1';
|
|
||||||
draggedItem = null;
|
|
||||||
syncInputs();
|
|
||||||
});
|
|
||||||
item.addEventListener('dragover', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.dataTransfer.dropEffect = 'move';
|
|
||||||
const rect = this.getBoundingClientRect();
|
|
||||||
const midY = rect.top + rect.height / 2;
|
|
||||||
if (e.clientY < midY) {
|
|
||||||
this.style.borderTop = '2px solid #3b82f6';
|
|
||||||
this.style.borderBottom = '';
|
|
||||||
} else {
|
|
||||||
this.style.borderBottom = '2px solid #3b82f6';
|
|
||||||
this.style.borderTop = '';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
item.addEventListener('dragleave', function() {
|
|
||||||
this.style.borderTop = '';
|
|
||||||
this.style.borderBottom = '';
|
|
||||||
});
|
|
||||||
item.addEventListener('drop', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
this.style.borderTop = '';
|
|
||||||
this.style.borderBottom = '';
|
|
||||||
if (draggedItem && draggedItem !== this) {
|
|
||||||
const rect = this.getBoundingClientRect();
|
|
||||||
const midY = rect.top + rect.height / 2;
|
|
||||||
if (e.clientY < midY) {
|
|
||||||
container.insertBefore(draggedItem, this);
|
|
||||||
} else {
|
|
||||||
container.insertBefore(draggedItem, this.nextSibling);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fetch('/api/v3/plugins/installed')
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
|
||||||
const allPlugins = (data.data && data.data.plugins) || data.plugins || [];
|
|
||||||
const plugins = allPlugins.filter(p => p.enabled);
|
|
||||||
if (plugins.length === 0) {
|
|
||||||
const empty = document.createElement('p');
|
|
||||||
empty.className = 'text-sm text-gray-500 italic';
|
|
||||||
empty.textContent = 'No enabled plugins';
|
|
||||||
container.textContent = '';
|
|
||||||
container.appendChild(empty);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let currentOrder = [];
|
|
||||||
let excluded = [];
|
|
||||||
try {
|
|
||||||
currentOrder = JSON.parse(orderInput.value || '[]');
|
|
||||||
if (excludedInput) excluded = JSON.parse(excludedInput.value || '[]');
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error parsing saved plugin order:', e);
|
|
||||||
}
|
|
||||||
// JSON.parse can succeed and still return null/objects
|
|
||||||
// (e.g. a saved value of "null"); normalize to arrays.
|
|
||||||
if (!Array.isArray(currentOrder)) currentOrder = [];
|
|
||||||
if (!Array.isArray(excluded)) excluded = [];
|
|
||||||
|
|
||||||
// Saved order first, then any newly enabled plugins.
|
|
||||||
const orderedPlugins = [];
|
|
||||||
currentOrder.forEach(id => {
|
|
||||||
const plugin = plugins.find(p => p.id === id);
|
|
||||||
if (plugin) orderedPlugins.push(plugin);
|
|
||||||
});
|
|
||||||
plugins.forEach(plugin => {
|
|
||||||
if (!orderedPlugins.find(p => p.id === plugin.id)) orderedPlugins.push(plugin);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Rows are built with DOM APIs rather than innerHTML — plugin
|
|
||||||
// ids/names come from installed manifests (semi-trusted).
|
|
||||||
container.textContent = '';
|
|
||||||
orderedPlugins.forEach(plugin => {
|
|
||||||
const row = document.createElement('div');
|
|
||||||
row.className = 'flex items-center p-2 bg-gray-50 rounded border border-gray-200 cursor-move plugin-order-item';
|
|
||||||
row.dataset.pluginId = plugin.id;
|
|
||||||
row.draggable = true;
|
|
||||||
|
|
||||||
const grip = document.createElement('i');
|
|
||||||
grip.className = 'fas fa-grip-vertical text-gray-400 mr-3';
|
|
||||||
row.appendChild(grip);
|
|
||||||
|
|
||||||
if (excludedInput) {
|
|
||||||
const isExcluded = excluded.includes(plugin.id);
|
|
||||||
const label = document.createElement('label');
|
|
||||||
label.className = 'flex items-center flex-1';
|
|
||||||
const checkbox = document.createElement('input');
|
|
||||||
checkbox.type = 'checkbox';
|
|
||||||
checkbox.className = 'plugin-order-include h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded mr-2';
|
|
||||||
checkbox.checked = !isExcluded;
|
|
||||||
const name = document.createElement('span');
|
|
||||||
name.className = 'text-sm font-medium text-gray-700';
|
|
||||||
name.textContent = plugin.name || plugin.id;
|
|
||||||
label.appendChild(checkbox);
|
|
||||||
label.appendChild(name);
|
|
||||||
row.appendChild(label);
|
|
||||||
} else {
|
|
||||||
const name = document.createElement('span');
|
|
||||||
name.className = 'text-sm font-medium text-gray-700 flex-1';
|
|
||||||
name.textContent = plugin.name || plugin.id;
|
|
||||||
row.appendChild(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.showVegasModeBadge) {
|
|
||||||
const vegasMode = plugin.vegas_mode || plugin.vegas_content_type || 'fixed';
|
|
||||||
const modeInfo = MODE_LABELS.get(vegasMode) || MODE_LABELS.get('fixed');
|
|
||||||
const badge = document.createElement('span');
|
|
||||||
badge.className = `text-xs ${modeInfo.color} ml-2`;
|
|
||||||
badge.title = `Vegas display mode: ${modeInfo.label}`;
|
|
||||||
const badgeIcon = document.createElement('i');
|
|
||||||
badgeIcon.className = `fas ${modeInfo.icon} mr-1`;
|
|
||||||
badge.appendChild(badgeIcon);
|
|
||||||
badge.appendChild(document.createTextNode(modeInfo.label));
|
|
||||||
row.appendChild(badge);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Up/down buttons: touch- and keyboard-accessible
|
|
||||||
// reordering alongside native drag-and-drop (HTML5 drag
|
|
||||||
// events don't fire on most mobile browsers).
|
|
||||||
const pluginLabel = plugin.name || plugin.id;
|
|
||||||
[['up', 'fa-chevron-up', `Move ${pluginLabel} up`],
|
|
||||||
['down', 'fa-chevron-down', `Move ${pluginLabel} down`]].forEach(([dir, iconCls, ariaLabel]) => {
|
|
||||||
const moveBtn = document.createElement('button');
|
|
||||||
moveBtn.type = 'button';
|
|
||||||
moveBtn.className = 'plugin-order-move text-gray-400 hover:text-gray-700 px-2 py-1';
|
|
||||||
moveBtn.setAttribute('aria-label', ariaLabel);
|
|
||||||
const moveIcon = document.createElement('i');
|
|
||||||
moveIcon.className = `fas ${iconCls} text-xs`;
|
|
||||||
moveBtn.appendChild(moveIcon);
|
|
||||||
moveBtn.addEventListener('click', function() {
|
|
||||||
if (dir === 'up' && row.previousElementSibling) {
|
|
||||||
container.insertBefore(row, row.previousElementSibling);
|
|
||||||
} else if (dir === 'down' && row.nextElementSibling) {
|
|
||||||
container.insertBefore(row.nextElementSibling, row);
|
|
||||||
}
|
|
||||||
syncInputs();
|
|
||||||
moveBtn.focus();
|
|
||||||
});
|
|
||||||
row.appendChild(moveBtn);
|
|
||||||
});
|
|
||||||
|
|
||||||
container.appendChild(row);
|
|
||||||
});
|
|
||||||
|
|
||||||
setupDragAndDrop();
|
|
||||||
container.querySelectorAll('.plugin-order-include').forEach(checkbox => {
|
|
||||||
checkbox.addEventListener('change', syncInputs);
|
|
||||||
});
|
|
||||||
syncInputs();
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Error fetching plugins:', error);
|
|
||||||
const err = document.createElement('p');
|
|
||||||
err.className = 'text-sm text-red-500';
|
|
||||||
err.textContent = 'Error loading plugins';
|
|
||||||
container.textContent = '';
|
|
||||||
container.appendChild(err);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
window.PluginOrderList = { init: init };
|
|
||||||
})();
|
|
||||||