mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 16:58:06 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b67f9e4a2a | ||
|
|
b7f5f8483a | ||
|
|
3872a68ff7 | ||
|
|
989162d28f | ||
|
|
cdf03fb107 | ||
|
|
6a9d8014e5 | ||
|
|
c90129285c |
@@ -48,3 +48,4 @@ config/backups/
|
||||
|
||||
# Starlark apps runtime storage (installed .star files and cached renders)
|
||||
/starlark-apps/
|
||||
skin_renders/
|
||||
|
||||
@@ -31,6 +31,14 @@
|
||||
- Plugin configs stored in `config/config.json`, NOT in plugin directories — safe across reinstalls
|
||||
- Third-party plugins can use their own repo URL with empty `plugin_path`
|
||||
|
||||
## Skin System (visual overlays for sports scoreboards)
|
||||
- Skins live in `skins/<skin-id>/` (skin.json + skin.py), NOT in plugin dirs — plugin reinstall deletes plugin dirs
|
||||
- Core: `src/skin_system/` (ScoreboardSkin, SkinContext, runtime); hook: `SportsCore._render_game()` in `src/base_classes/sports.py`
|
||||
- Skins render onto `ctx.canvas` only; fallback to built-in renderer on `False`/exception (3 strikes disables for session)
|
||||
- View-model guaranteed keys are frozen (see `test/test_skin_system.py::TestViewModelContract`) — renaming keys in `_extract_game_details_common` or sport extractors breaks published skins
|
||||
- Validate skins headlessly: `python scripts/validate_skin.py --skin <id>`; docs: `docs/SKIN_SYSTEM.md`, `docs/CREATING_SKINS.md`
|
||||
- Skins are NOT monorepo plugins: no manifest bump / update_registry.py needed
|
||||
|
||||
## Common Pitfalls
|
||||
- paho-mqtt 2.x needs `callback_api_version=mqtt.CallbackAPIVersion.VERSION1` for v1 compat
|
||||
- BasePlugin uses `get_logger()` from `src.logging_config`, not standard `logging.getLogger()`
|
||||
|
||||
@@ -440,6 +440,16 @@ See the [Plugin Store documentation](https://github.com/ChuckBuilds/ledmatrix-pl
|
||||
|
||||
For plugin development, check out the [Hello World Plugin](https://github.com/ChuckBuilds/ledmatrix-hello-world) repository as a starter template.
|
||||
|
||||
### Visual Skins for Scoreboards
|
||||
|
||||
Want a different look for a sports scoreboard without forking the plugin?
|
||||
**Skins** restyle the live/recent/upcoming screens while the plugin keeps
|
||||
handling data, scheduling, caching, and vegas mode. Install one with
|
||||
`git clone <skin repo> skins/<skin-id>`, select it in the plugin's config,
|
||||
and you're done — see [docs/SKIN_SYSTEM.md](docs/SKIN_SYSTEM.md) (how it
|
||||
works) and [docs/CREATING_SKINS.md](docs/CREATING_SKINS.md) (build your own,
|
||||
including a ready-made Claude Code prompt).
|
||||
|
||||
2. **Built-in Managers Deprecated**: The built-in managers (hockey, football, stocks, etc.) are now deprecated and have been moved to the plugin system. **You must install replacement plugins from the Plugin Store** in the web interface instead. The plugin system provides the same functionality with better maintainability and extensibility.
|
||||
</details>
|
||||
|
||||
|
||||
@@ -121,6 +121,7 @@
|
||||
"axis": "horizontal"
|
||||
},
|
||||
"display_durations": {},
|
||||
"plugin_rotation_order": [],
|
||||
"use_short_date_format": true,
|
||||
"vegas_scroll": {
|
||||
"enabled": false,
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
# Creating Skins
|
||||
|
||||
A skin restyles a sports scoreboard (live / recent / upcoming) without
|
||||
forking the plugin: the plugin keeps fetching data, scheduling, caching, and
|
||||
doing vegas mode; your skin only draws. Architecture background:
|
||||
[SKIN_SYSTEM.md](SKIN_SYSTEM.md).
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
cp -r skins/example-classic-baseball skins/my-skin
|
||||
# edit skins/my-skin/skin.json -> set id ("my-skin"), name, author, class_name
|
||||
# edit skins/my-skin/skin.py -> rename the class, start restyling
|
||||
python scripts/validate_skin.py --skin my-skin
|
||||
```
|
||||
|
||||
The validator renders your skin against bundled fixture games at several
|
||||
panel sizes with **no hardware, no network, no running service**, saves PNGs
|
||||
(plus 4x previews) to `skin_renders/`, and fails loudly on errors. Iterate:
|
||||
edit → validate → look at the PNGs.
|
||||
|
||||
To see it on your matrix, add to your plugin's section in `config/config.json`:
|
||||
|
||||
```json
|
||||
"baseball-scoreboard": {
|
||||
"skin": "my-skin",
|
||||
"skin_options": { }
|
||||
}
|
||||
```
|
||||
|
||||
or pick it from the **Visual Skin** dropdown in the web UI (it appears once a
|
||||
matching skin is installed). `"skin"` also accepts a per-mode mapping:
|
||||
`{"live": "my-skin", "recent": "built-in"}`.
|
||||
|
||||
## The manifest (`skin.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my-skin",
|
||||
"name": "My Skin",
|
||||
"version": "1.0.0",
|
||||
"author": "you",
|
||||
"description": "What it looks like",
|
||||
"skin_api_version": "1.0.0",
|
||||
"targets": {
|
||||
"sports": ["baseball"],
|
||||
"sport_keys": ["mlb", "milb"],
|
||||
"plugins": []
|
||||
},
|
||||
"entry_point": "skin.py",
|
||||
"class_name": "MySkin",
|
||||
"modes": ["live", "recent", "upcoming"],
|
||||
"preview": "preview.png"
|
||||
}
|
||||
```
|
||||
|
||||
Field notes: `id` must equal the directory name; `skin_api_version`'s major
|
||||
version must match the host's `SKIN_API_VERSION` or the skin is refused at
|
||||
load; `targets` takes sport families (`sports`), exact sport keys
|
||||
(`sport_keys`), and/or exact plugin ids (`plugins`) — any match applies.
|
||||
|
||||
## The renderer (`skin.py`)
|
||||
|
||||
```python
|
||||
from src.skin_system.skin_base import ScoreboardSkin, SkinContext
|
||||
|
||||
class MySkin(ScoreboardSkin):
|
||||
def render_live(self, ctx: SkinContext, game: dict) -> bool:
|
||||
score = f"{game.get('away_score', '0')}-{game.get('home_score', '0')}"
|
||||
fit = ctx.layout.fit_text(score, ctx.layout.bounds)
|
||||
ctx.draw_fit(fit, ctx.layout.bounds)
|
||||
return True # True = "I drew it"; False = use the built-in layout
|
||||
```
|
||||
|
||||
Implement only the modes you care about — anything else falls back to the
|
||||
plugin's built-in rendering. Return `False` to decline a specific game (e.g.
|
||||
a layout that only makes sense while a game is live).
|
||||
|
||||
### The rules (they keep your skin from breaking the display)
|
||||
|
||||
1. **Draw only onto `ctx.canvas`** (via the helpers or `ctx.draw`). Never
|
||||
reassign `ctx.canvas`, never touch the display or call any update method.
|
||||
2. **No I/O in render paths.** No network, no file loads per frame —
|
||||
`render_live` runs every display pass, and a slow render stalls the whole
|
||||
matrix (the host warns at >150 ms). Use `ctx.load_logo` (cached) and
|
||||
`cache_key=` for images.
|
||||
3. **Derive everything from `(ctx, game)`.** Skins must be stateless: the
|
||||
live/recent/upcoming modes each get their own instance.
|
||||
4. **Always `.get()` optional keys.** Only the guaranteed keys below are
|
||||
promised to exist.
|
||||
5. **Never hardcode pixel positions for the panel.** Use `ctx.width`/
|
||||
`ctx.height`, `ctx.layout` regions and `fit_text` — your skin will be run
|
||||
at sizes you didn't test (64x32, 128x64, vegas cards).
|
||||
6. **No third-party dependencies.** Stdlib + PIL + what `ctx` provides.
|
||||
|
||||
A skin that raises 3 renders in a row is disabled until the service restarts
|
||||
(the built-in layout takes over), so a bug is cosmetic — but check your logs.
|
||||
|
||||
## SkinContext reference
|
||||
|
||||
| Member | What it is |
|
||||
|---|---|
|
||||
| `ctx.canvas` / `ctx.draw` | Fresh RGB `PIL.Image` at display size + its `ImageDraw` (raw-PIL escape hatch) |
|
||||
| `ctx.width`, `ctx.height` | Canvas size — the only size truth |
|
||||
| `ctx.layout` | `LayoutContext` (see [ADAPTIVE_LAYOUT.md](ADAPTIVE_LAYOUT.md)): `bounds`, `fit_text`, `fit_text_proportional`, `fit_image`, `px`, `by_tier` |
|
||||
| `ctx.draw_fit(fit, box, color, align, valign)` | Draw a `fit_text` result aligned in a `Region` (handles BDF fonts) |
|
||||
| `ctx.draw_text(text, x, y, color, font)` | Positioned text (handles BDF fonts) |
|
||||
| `ctx.draw_image(img, box, mode, align, valign, cache_key)` | Fit + paste an image with alpha; no-ops on `None` |
|
||||
| `ctx.load_logo("home" \| "away")` | Team logo as RGBA, or `None` (always handle `None`). Cached after first use; see note below |
|
||||
| `ctx.draw_text_outlined(text, (x, y), font, fill, outline_color)` | The classic scorebug outlined text (TTF fonts only) |
|
||||
| `ctx.fonts` | The host's font dict — keys `score`, `time`, `team`, `status`, `detail`, `rank` |
|
||||
| `ctx.options` | Your user's `skin_options` from config |
|
||||
| `ctx.sport`, `ctx.view_model_version`, `ctx.logger` | Context metadata + logger |
|
||||
|
||||
**A note on `ctx.load_logo` vs the no-I/O rule:** `load_logo` is the one
|
||||
sanctioned exception. It goes through the host's logo cache — after the
|
||||
first call per team it's a pure in-memory lookup. If a logo file is missing
|
||||
on disk, the *first* call may download it, exactly like the built-in
|
||||
renderer does for the same game (a skin is never worse than built-in here).
|
||||
Always pass a stable `cache_key` when drawing it, never load image files
|
||||
yourself in a render path, and always handle `None`.
|
||||
|
||||
The default layout idiom — carve regions, then fit text into them:
|
||||
|
||||
```python
|
||||
from src.adaptive_layout import scoreboard_regions
|
||||
|
||||
regions = scoreboard_regions(ctx.layout.bounds, ctx=ctx.layout)
|
||||
ctx.draw_image(ctx.load_logo("away"), regions.away_slot, cache_key=f"logo:{game.get('away_abbr')}")
|
||||
ctx.draw_image(ctx.load_logo("home"), regions.home_slot, cache_key=f"logo:{game.get('home_abbr')}")
|
||||
fit = ctx.layout.fit_text("3-5", regions.score_area)
|
||||
ctx.draw_fit(fit, regions.score_area)
|
||||
```
|
||||
|
||||
`Region` supports `split_h`/`split_v`/`inset`/`top_band`/`bottom_band`/
|
||||
`left_col`/`right_col` for custom carves. Raw `ctx.draw.rectangle/polygon/
|
||||
ellipse/...` is always available for custom marks (see the bases diamond in
|
||||
the example skin).
|
||||
|
||||
## The game view model
|
||||
|
||||
Guaranteed for every sport (view model v1.0 — renaming these breaks skins and
|
||||
is treated as a breaking change upstream):
|
||||
|
||||
| Key | Notes |
|
||||
|---|---|
|
||||
| `id` | Event id (string) |
|
||||
| `status_text` | Display-ready status, e.g. `"Final"`, `"7:30 PM"`, `"Bot 7th"` |
|
||||
| `is_live`, `is_final`, `is_upcoming`, `is_halftime` | Booleans |
|
||||
| `game_date`, `game_time` | Pre-formatted local date/time strings |
|
||||
| `start_time_utc` | UTC `datetime` |
|
||||
| `home_abbr`, `away_abbr` | Team abbreviations (can be 2–5 chars — fit, don't assume) |
|
||||
| `home_id`, `away_id` | Team ids |
|
||||
| `home_score`, `away_score` | **Strings**, not ints |
|
||||
| `home_record`, `away_record` | `"58-33"` or `""` (0-0 records are blanked) |
|
||||
| `home_logo_path`, `away_logo_path` | Prefer `ctx.load_logo` over touching these |
|
||||
|
||||
Sport extras (present for that sport, still `.get()` defensively):
|
||||
|
||||
- **baseball**: `inning` (int), `inning_half` (`"top"`/`"bottom"`), `balls`,
|
||||
`strikes`, `outs` (ints), `bases_occupied` (`[first, second, third]`
|
||||
booleans), `series_summary` (str)
|
||||
- **football**: `period`, `period_text`, `clock`, `home_timeouts`,
|
||||
`away_timeouts`, `down_distance_text`, `down_distance_text_long`,
|
||||
`is_redzone`, `possession`, `possession_indicator` (`"home"`/`"away"`),
|
||||
`scoring_event`
|
||||
- **basketball**: `period`, `period_text`, `clock`
|
||||
- **hockey**: `period`, `period_text`, `clock`, `power_play`, `penalties`,
|
||||
`home_shots`, `away_shots`
|
||||
|
||||
Optional everywhere (only when the user enabled the feature): `odds` (dict),
|
||||
`series_summary`, rankings-related fields.
|
||||
|
||||
Fixture copies of these dicts live in `src/skin_system/fixtures/` — that's
|
||||
exactly what the validator feeds your skin.
|
||||
|
||||
## Vegas mode
|
||||
|
||||
You get vegas support for free: vegas captures the normal display output,
|
||||
which is already your skin's rendering. Optionally implement
|
||||
`render_vegas_card(ctx, game)` to return a purpose-built card at
|
||||
`ctx.width x ctx.height` (sizes vary — never assume 128x32).
|
||||
|
||||
## Building a skin with Claude Code
|
||||
|
||||
Skins are ideal Claude Code projects: small, isolated, and verifiable with
|
||||
one command. Paste this to start:
|
||||
|
||||
> You are building a **display skin** for LEDMatrix — a visual overlay for a
|
||||
> sports scoreboard on a small LED matrix (commonly 128x32 or 64x32 pixels).
|
||||
> First read `docs/CREATING_SKINS.md` and the reference skin in
|
||||
> `skins/example-classic-baseball/`.
|
||||
>
|
||||
> Rules:
|
||||
> - Create/modify files ONLY under `skins/<my-skin-id>/`. Do NOT modify
|
||||
> anything in `src/`, `scripts/`, the plugins, or any other skin.
|
||||
> - Render only from the `game` dict and `ctx` helpers. No network calls, no
|
||||
> per-frame file I/O, no new pip dependencies, no touching the display —
|
||||
> draw onto `ctx.canvas` and return True.
|
||||
> - Use `ctx.layout` regions and `fit_text` for positioning so the skin works
|
||||
> at any panel size; use `.get()` for every optional game key.
|
||||
> - After every change run
|
||||
> `python scripts/validate_skin.py --skin <my-skin-id>` and LOOK at the
|
||||
> PNGs it writes to `skin_renders/` (the `_x4.png` files are easiest to
|
||||
> read). Iterate until it passes and looks right at both 128x32 and 64x32.
|
||||
>
|
||||
> What I want it to look like: <describe your layout — where logos, score,
|
||||
> status go; colors; what shows during live vs upcoming vs final>
|
||||
|
||||
Tips that keep Claude (and you) out of trouble:
|
||||
|
||||
- One mode at a time: get `render_live` right before touching the others —
|
||||
unimplemented modes automatically use the built-in look.
|
||||
- Ask for edge-case renders: long team abbreviations, missing logos
|
||||
(`ctx.load_logo` returning `None`), 0-0 records, extra innings/OT.
|
||||
- If the render looks cramped at 64x32, ask Claude to use
|
||||
`ctx.layout.by_tier(...)` to drop elements on small panels rather than
|
||||
shrinking everything.
|
||||
- Never let it "fix" a problem by editing `src/` — if the skin can't do
|
||||
something within its directory, that's a feature request, not a workaround.
|
||||
|
||||
## Pre-publish checklist
|
||||
|
||||
- [ ] `python scripts/validate_skin.py --skin <id> --size 128x32 --size 64x32 --size 128x64` passes
|
||||
- [ ] Looked at every PNG in `skin_renders/` — nothing clipped or overlapping
|
||||
- [ ] Handles a missing logo (`None`) without crashing — temporarily point a
|
||||
fixture's logo path at a nonexistent file to test
|
||||
- [ ] Long abbreviations (`"TA&M"`, 4–5 chars) don't overflow
|
||||
- [ ] No render warning above the time budget
|
||||
- [ ] `skin.json`: `id` matches the directory, `version` set,
|
||||
`skin_api_version` matches the host, targets correct
|
||||
- [ ] `preview.png` added (grab your favorite `_x4` render)
|
||||
- [ ] Tested on real hardware if you have it — a Pi is much slower than your
|
||||
dev machine
|
||||
|
||||
Distribute by publishing the directory as a git repo (users
|
||||
`git clone <repo> skins/<id>`), or submit it to the plugin registry as an
|
||||
entry with `"type": "skin"` (see [SKIN_SYSTEM.md](SKIN_SYSTEM.md) §Distribution).
|
||||
|
||||
**Trust note:** a skin is Python running inside the display service — the
|
||||
same trust level as a plugin. Review code before installing skins from
|
||||
others.
|
||||
@@ -10,6 +10,12 @@ 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
|
||||
> those APIs; nothing migrates automatically.
|
||||
|
||||
> **Just want a different look for an existing sports scoreboard?** You may
|
||||
> not need a plugin at all — a **skin** restyles the live/recent/upcoming
|
||||
> rendering while the plugin keeps handling data, scheduling, caching, and
|
||||
> vegas mode, in ~100 lines of drawing code. See
|
||||
> [CREATING_SKINS.md](CREATING_SKINS.md).
|
||||
|
||||
## Overview
|
||||
|
||||
When developing plugins in separate repositories, you need a way to:
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
# Skin System Architecture
|
||||
|
||||
Skins are user-installable **visual overlays** for the sports scoreboards.
|
||||
A skin replaces only the *look* of a scoreboard — the host plugin keeps doing
|
||||
data fetching, scheduling, caching, dedup, live-priority takeover, and vegas
|
||||
mode. If you only want to **build** a skin, read
|
||||
[CREATING_SKINS.md](CREATING_SKINS.md); this document explains how the system
|
||||
works and why it is shaped this way.
|
||||
|
||||
## Why skins instead of forks
|
||||
|
||||
Before skins, changing a scoreboard's layout meant forking the whole plugin
|
||||
(e.g. the community MLB scoreboard fork). The fork gets the new look but loses
|
||||
everything the maintained plugin keeps earning: duration/scheduling behavior,
|
||||
vegas mode support, caching and background-fetch improvements, bug fixes. It
|
||||
also silently drifts: every upstream improvement now has to be re-ported by
|
||||
hand.
|
||||
|
||||
A skin inverts that trade. The plugin remains stock and keeps updating through
|
||||
the store; the skin is ~100 lines of pure rendering code that receives the
|
||||
plugin's already-fetched data each frame. Uninstalling the skin (or the skin
|
||||
crashing) simply restores the built-in look.
|
||||
|
||||
```text
|
||||
(unchanged) (the skin seam)
|
||||
ESPN API ──► update() ──► game view model ──► _render_game() ──► display
|
||||
fetching (a dict) │ │
|
||||
caching │ └─ built-in
|
||||
scheduling └─ skin.render_<mode>(ctx, game)
|
||||
live priority draws onto ctx.canvas
|
||||
```
|
||||
|
||||
## The render funnel
|
||||
|
||||
Every sports scoreboard (baseball, football, basketball, hockey — anything
|
||||
built on `src/base_classes/sports.py`) renders through exactly one seam:
|
||||
`SportsCore._render_game(game, force_clear)`.
|
||||
|
||||
1. The mode class's `display()` (live, `SportsUpcoming`, `SportsRecent`)
|
||||
picks `self.current_game` and calls `_render_game`.
|
||||
2. `_render_game` lazily loads the configured skin (once, on first render —
|
||||
a broken skin can never block plugin startup).
|
||||
3. If a skin is active, the host builds a `SkinContext` — a fresh black
|
||||
canvas at the current display size plus layout/font/logo helpers — and
|
||||
calls the skin's `render_live` / `render_recent` / `render_upcoming`
|
||||
with a **copy** of the game dict.
|
||||
4. If the skin returns `True`, the canvas is composited onto the display.
|
||||
If it returns `False`, isn't implemented for that mode, or raises, the
|
||||
built-in `_draw_scorebug_layout` runs instead.
|
||||
|
||||
Key properties that fall out of this design:
|
||||
|
||||
- **Per-mode fallback.** A skin that only implements `render_live` gets the
|
||||
stock recent/upcoming screens for free.
|
||||
- **Three strikes.** A skin that raises 3 times in a row is disabled for the
|
||||
rest of the session (one loud error log per failure); the display never
|
||||
goes dark. Restarting the service re-arms it.
|
||||
- **Copies, not references.** Skins receive a shallow copy of the game dict,
|
||||
so a buggy skin cannot corrupt the plugin's scheduling state.
|
||||
- **Vegas mode works untouched.** Vegas capture falls back to grabbing the
|
||||
regular `display()` output, which is already skin-rendered. Skins can
|
||||
additionally implement `render_vegas_card` for purpose-built scroll cards,
|
||||
and hosts can call `SportsCore.render_skin_card(game, size)` to use it.
|
||||
- **Hot-loop caution.** `render_live` runs every display-loop pass during a
|
||||
live game. The host logs a warning when a skin render exceeds 150 ms, and
|
||||
`scripts/validate_skin.py` enforces a budget at development time — but
|
||||
Python cannot forcibly time-out a stuck render, so a skin that blocks
|
||||
(network I/O, giant image ops) stalls the display. This is why the rules
|
||||
in CREATING_SKINS.md ban I/O in render paths.
|
||||
|
||||
## The view model contract
|
||||
|
||||
The `game` dict a skin receives is the plugin's already-extracted view model
|
||||
(`SportsCore._extract_game_details_common` plus per-sport extras from
|
||||
`src/base_classes/{baseball,basketball,football,hockey}.py`).
|
||||
|
||||
- **Guaranteed keys (view model v1.0)** — always present for every sport:
|
||||
`id`, `game_time`, `game_date`, `start_time_utc` (a UTC `datetime`),
|
||||
`status_text`, `is_live`, `is_final`, `is_upcoming`, `is_halftime`,
|
||||
`home_abbr`/`away_abbr`, `home_id`/`away_id`, `home_score`/`away_score`
|
||||
(**strings**), `home_logo_path`/`away_logo_path`, `home_record`/`away_record`.
|
||||
- **Sport extras** — documented per sport in CREATING_SKINS.md (e.g. baseball
|
||||
adds `inning`, `inning_half`, `balls`, `strikes`, `outs`, `bases_occupied`).
|
||||
- **Optional keys** (`odds`, rankings, `series_summary`, …) are present only
|
||||
when the feature is enabled — skins must always use `.get()`.
|
||||
|
||||
Versioning policy: additive changes bump the minor version
|
||||
(`VIEW_MODEL_VERSION` in `src/skin_system/skin_base.py`, surfaced to skins as
|
||||
`ctx.view_model_version`); renaming or removing a guaranteed key requires a
|
||||
major bump plus a compat shim. `test/test_skin_system.py::TestViewModelContract`
|
||||
fails CI if a guaranteed key disappears from the extractor.
|
||||
|
||||
Separately, `SKIN_API_VERSION` versions the Python API (`ScoreboardSkin`,
|
||||
`SkinContext`). The loader refuses a skin whose manifest declares a different
|
||||
major version and falls back to the built-in renderer with a clear
|
||||
"skin needs an update" log line.
|
||||
|
||||
## Package layout and lifecycle
|
||||
|
||||
```text
|
||||
skins/<skin-id>/
|
||||
skin.json # manifest (required)
|
||||
skin.py # ScoreboardSkin subclass (required)
|
||||
preview.png # optional, shown by the web UI
|
||||
assets/ # optional skin-local images
|
||||
helpers.py ... # optional extra modules (namespaced per skin at import)
|
||||
```
|
||||
|
||||
Skins live in the central `skins/` directory — deliberately **not** inside the
|
||||
plugin's directory, because plugin reinstall/update deletes the whole plugin
|
||||
directory and a skin must survive that. One skin can also target several
|
||||
plugins (mlb + milb).
|
||||
|
||||
Lifecycle: discovered lazily on first render → manifest validated → API major
|
||||
version gated → module imported under a namespaced `sys.modules` key (two
|
||||
skins can both ship a `helpers.py`, same scheme plugins use) → instantiated
|
||||
with `(manifest, options)`. Every failure logs and falls back to built-in.
|
||||
|
||||
Skins should be **stateless**: the live, recent, and upcoming mode classes
|
||||
each hold their own skin instance, so derive everything from `(ctx, game)`.
|
||||
|
||||
## Selection and configuration
|
||||
|
||||
Inside the plugin's own config section in `config/config.json`:
|
||||
|
||||
```json
|
||||
"baseball-scoreboard": {
|
||||
"skin": "retro-baseball",
|
||||
"skin_options": { "accent_color": [255, 80, 0] }
|
||||
}
|
||||
```
|
||||
|
||||
`"skin"` is either one id for all modes or a per-mode mapping
|
||||
(`{"live": "retro-baseball", "recent": "built-in"}`). Absent, empty, or
|
||||
`"built-in"` means the stock renderer. Because this rides the plugin's config
|
||||
section, it persists across plugin reinstalls like every other setting.
|
||||
|
||||
The web UI shows a **Visual Skin** dropdown for plugins that have matching
|
||||
skins installed: `SchemaManager.inject_skin_selector` adds an enum to the
|
||||
*served* schema only. Validation never sees the enum — so a config that
|
||||
references an uninstalled skin stays valid (rendering just falls back), and
|
||||
the currently-configured value is always kept selectable. `GET /api/v3/skins`
|
||||
lists installed skins (optionally filtered by `?plugin_id=`).
|
||||
|
||||
## Distribution
|
||||
|
||||
- **Manual:** `git clone <skin repo> skins/<skin-id>` — that's the whole
|
||||
install. No manifest bumps, no `update_registry.py`; skins are not monorepo
|
||||
plugins.
|
||||
- **Store:** registry entries with `"type": "skin"` install through the same
|
||||
`plugins.json` pipeline; `PluginStoreManager` routes them to `skins/`,
|
||||
validates `skin.json` (including the API major version) instead of
|
||||
`manifest.json`, and never installs dependencies — skins are render-only
|
||||
(stdlib + PIL + the provided context, no third-party packages in v1).
|
||||
|
||||
## Trust model
|
||||
|
||||
A skin is Python executing inside the display service — **exactly the same
|
||||
trust level as a plugin**, even though "skin" sounds cosmetic. Only install
|
||||
skins from sources you'd be willing to install a plugin from.
|
||||
|
||||
## v2 directions (not in v1)
|
||||
|
||||
- A generic `BasePlugin` opt-in (`render_with_skin()`) so non-sports plugins
|
||||
(weather, music) can offer skinnable layouts; `skin_runtime` is already
|
||||
sports-agnostic in anticipation.
|
||||
- Store UI: preview gallery, one-click install from the skin browser.
|
||||
- An update path for git-cloned skins (today: re-clone or store reinstall).
|
||||
- Animation support in skins (today the API is one frame per render call;
|
||||
stateful tricks work but are at-your-own-risk).
|
||||
@@ -206,6 +206,40 @@ To use an existing widget in your plugin's `config_schema.json`, simply add the
|
||||
|
||||
The widget will be automatically rendered when the plugin configuration form is loaded.
|
||||
|
||||
## Marking Fields as Advanced (`x-advanced`)
|
||||
|
||||
Add `"x-advanced": true` to any top-level, non-object property to move it out
|
||||
of the main form and into a single collapsed **Advanced Settings** section at
|
||||
the bottom of the plugin's configuration page:
|
||||
|
||||
```json
|
||||
{
|
||||
"properties": {
|
||||
"city": {
|
||||
"type": "string",
|
||||
"title": "City"
|
||||
},
|
||||
"request_timeout": {
|
||||
"type": "integer",
|
||||
"default": 10,
|
||||
"description": "HTTP timeout in seconds",
|
||||
"x-advanced": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Guidelines:
|
||||
|
||||
- Use it for fine-tuning knobs most users never touch (timeouts, retry
|
||||
behavior, cache TTLs, styling overrides). Anything a first-time user must
|
||||
set to get the plugin working should stay basic.
|
||||
- Nothing is hidden permanently — the section expands on click, and the
|
||||
settings search finds and auto-expands advanced fields like any others.
|
||||
- The flag is ignored on `object`-type properties (they already render as
|
||||
their own collapsible sections) and is safely ignored by older cores, so
|
||||
adding it never breaks compatibility.
|
||||
|
||||
## Creating Custom Widgets
|
||||
|
||||
### Step 1: Create Widget File
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Headless skin validator — render a skin against bundled fixture games at
|
||||
multiple panel sizes without hardware, a network, or a running service.
|
||||
|
||||
python scripts/validate_skin.py --skin my-skin
|
||||
python scripts/validate_skin.py --skin my-skin --sport baseball \
|
||||
--size 128x32 --size 64x32 --output-dir /tmp/skin_renders
|
||||
|
||||
For each (mode x size) it checks: the manifest loads and its API version
|
||||
matches, the render raises no exception, the canvas isn't blank, and the
|
||||
render finishes inside a time budget (warn — the live renderer runs every
|
||||
display-loop pass, and a Pi is far slower than your dev machine). PNGs are
|
||||
saved (native plus 4x nearest-neighbor previews) so you can eyeball the
|
||||
result. Exit code is non-zero when any check fails.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont # noqa: E402
|
||||
|
||||
FIXTURES_DIR = PROJECT_ROOT / "src" / "skin_system" / "fixtures"
|
||||
MODES = ("live", "recent", "upcoming")
|
||||
SPORTS = ("baseball", "basketball", "football", "hockey")
|
||||
RENDER_BUDGET_S = 0.100
|
||||
|
||||
|
||||
class FixtureHost:
|
||||
"""Stands in for a SportsCore instance: fonts, logger, logo loading,
|
||||
outlined text — everything build_context needs, no network."""
|
||||
|
||||
def __init__(self, sport: str, skin_options: dict) -> None:
|
||||
self.sport = sport
|
||||
self.sport_key = sport
|
||||
self.skin_options = skin_options
|
||||
self.logger = logging.getLogger(f"validate_skin.{sport}")
|
||||
self.fonts = self._load_fonts()
|
||||
self._logo_cache = {}
|
||||
self.display_manager = None # build_context is always given a size
|
||||
|
||||
def _load_fonts(self) -> dict:
|
||||
"""Load the SportsCore font set (TTF, with PIL default fallback)."""
|
||||
fonts = {}
|
||||
try:
|
||||
press = str(PROJECT_ROOT / "assets/fonts/PressStart2P-Regular.ttf")
|
||||
small = str(PROJECT_ROOT / "assets/fonts/4x6-font.ttf")
|
||||
fonts['score'] = ImageFont.truetype(press, 10)
|
||||
fonts['time'] = ImageFont.truetype(press, 8)
|
||||
fonts['team'] = ImageFont.truetype(press, 8)
|
||||
fonts['status'] = ImageFont.truetype(small, 6)
|
||||
fonts['detail'] = ImageFont.truetype(small, 6)
|
||||
fonts['rank'] = ImageFont.truetype(press, 10)
|
||||
except IOError:
|
||||
default = ImageFont.load_default()
|
||||
for key in ('score', 'time', 'team', 'status', 'detail', 'rank'):
|
||||
fonts[key] = default
|
||||
return fonts
|
||||
|
||||
def _load_and_resize_logo(self, team_id: str, team_abbrev: str,
|
||||
logo_path, logo_url) -> "Image.Image | None":
|
||||
"""Load a fixture logo from disk (no downloads), cached per team."""
|
||||
if team_abbrev in self._logo_cache:
|
||||
return self._logo_cache[team_abbrev]
|
||||
path = Path(logo_path)
|
||||
if not path.is_absolute():
|
||||
path = PROJECT_ROOT / path
|
||||
if not path.exists():
|
||||
return None
|
||||
logo = Image.open(path).convert('RGBA')
|
||||
self._logo_cache[team_abbrev] = logo
|
||||
return logo
|
||||
|
||||
def _draw_text_with_outline(self, draw: "ImageDraw.ImageDraw", text: str,
|
||||
position: tuple, font,
|
||||
fill: tuple = (255, 255, 255),
|
||||
outline_color: tuple = (0, 0, 0)) -> None:
|
||||
"""Classic outlined scorebug text, same as SportsCore's helper."""
|
||||
x, y = position
|
||||
for dx, dy in [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1),
|
||||
(1, -1), (1, 0), (1, 1)]:
|
||||
draw.text((x + dx, y + dy), text, font=font, fill=outline_color)
|
||||
draw.text((x, y), text, font=font, fill=fill)
|
||||
|
||||
|
||||
def load_fixture(sport: str, mode: str) -> dict:
|
||||
with open(FIXTURES_DIR / f"{sport}_{mode}.json", encoding="utf-8") as f:
|
||||
game = json.load(f)
|
||||
# Real view models carry start_time_utc as a UTC datetime, not a string.
|
||||
if isinstance(game.get("start_time_utc"), str):
|
||||
from datetime import datetime
|
||||
game["start_time_utc"] = datetime.fromisoformat(game["start_time_utc"])
|
||||
return game
|
||||
|
||||
|
||||
def parse_size(value: str) -> "tuple[int, int]":
|
||||
try:
|
||||
w_text, h_text = value.lower().split("x")
|
||||
w, h = int(w_text), int(h_text)
|
||||
except ValueError as exc:
|
||||
raise argparse.ArgumentTypeError(f"size must look like 128x32, got {value!r}") from exc
|
||||
if w <= 0 or h <= 0:
|
||||
raise argparse.ArgumentTypeError(f"size dimensions must be positive, got {value!r}")
|
||||
return w, h
|
||||
|
||||
|
||||
def parse_options(value: str) -> dict:
|
||||
try:
|
||||
options = json.loads(value)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise argparse.ArgumentTypeError(f"options must be valid JSON: {exc.msg}") from exc
|
||||
if not isinstance(options, dict):
|
||||
raise argparse.ArgumentTypeError("options must be a JSON object")
|
||||
return options
|
||||
|
||||
|
||||
def display_path(path: Path) -> str:
|
||||
"""Repo-relative when inside the repo, absolute otherwise (--output-dir
|
||||
may point anywhere, e.g. /tmp/skin_renders)."""
|
||||
try:
|
||||
return str(path.relative_to(PROJECT_ROOT))
|
||||
except ValueError:
|
||||
return str(path)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--skin", required=True, help="skin id (directory name under skins/)")
|
||||
parser.add_argument("--sport", choices=SPORTS,
|
||||
help="fixture sport (default: first sport the skin targets, else baseball)")
|
||||
parser.add_argument("--size", action="append", type=parse_size, dest="sizes",
|
||||
metavar="WxH", help="panel size to render at (repeatable; default 128x32 and 64x32)")
|
||||
parser.add_argument("--output-dir", type=Path,
|
||||
default=PROJECT_ROOT / "skin_renders",
|
||||
help="where rendered PNGs are written")
|
||||
parser.add_argument("--options", type=parse_options, default={},
|
||||
help="skin_options JSON to pass the skin")
|
||||
args = parser.parse_args()
|
||||
sizes = args.sizes or [(128, 32), (64, 32)]
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
||||
|
||||
from src.skin_system import skin_runtime
|
||||
from src.skin_system.skin_base import SKIN_API_VERSION
|
||||
|
||||
skins = skin_runtime.discover_skins()
|
||||
manifest = skins.get(args.skin)
|
||||
if manifest is None:
|
||||
print(f"FAIL: skin '{args.skin}' not found under {skin_runtime.get_skins_directory()}")
|
||||
if skins:
|
||||
print(f" installed skins: {', '.join(sorted(skins))}")
|
||||
return 1
|
||||
|
||||
sport = args.sport
|
||||
if sport is None:
|
||||
declared = skin_runtime.skin_targets(manifest)[0]
|
||||
sport = next((s for s in declared if s in SPORTS), "baseball")
|
||||
|
||||
skin = skin_runtime.load_skin(args.skin, sport=sport, sport_key=sport,
|
||||
options=args.options)
|
||||
if skin is None:
|
||||
print(f"FAIL: skin '{args.skin}' did not load "
|
||||
f"(see log above; host API is {SKIN_API_VERSION})")
|
||||
return 1
|
||||
|
||||
host = FixtureHost(sport, args.options)
|
||||
args.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
failures = 0
|
||||
rendered = 0
|
||||
|
||||
for mode in MODES:
|
||||
game = load_fixture(sport, mode)
|
||||
render = getattr(skin, f"render_{mode}")
|
||||
for width, height in sizes:
|
||||
label = f"{mode}@{width}x{height}"
|
||||
try:
|
||||
# Warm-up render absorbs one-time font/image loads, second
|
||||
# render is the one timed against the budget.
|
||||
ctx = skin_runtime.build_context(host, game, size=(width, height))
|
||||
handled = render(ctx, dict(game))
|
||||
if handled:
|
||||
ctx = skin_runtime.build_context(host, game, size=(width, height))
|
||||
started = time.monotonic()
|
||||
handled = render(ctx, dict(game))
|
||||
elapsed = time.monotonic() - started
|
||||
else:
|
||||
elapsed = 0.0
|
||||
except Exception as e:
|
||||
print(f"FAIL {label}: render raised {type(e).__name__}: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
failures += 1
|
||||
continue
|
||||
|
||||
if not handled:
|
||||
print(f"skip {label}: render_{mode} returned False (built-in renderer would be used)")
|
||||
continue
|
||||
|
||||
if ctx.canvas.size != (width, height):
|
||||
print(f"FAIL {label}: canvas was replaced/resized to {ctx.canvas.size} — draw onto ctx.canvas, never reassign it")
|
||||
failures += 1
|
||||
continue
|
||||
if ctx.canvas.convert("L").getbbox() is None:
|
||||
print(f"FAIL {label}: canvas is blank — render returned True but drew nothing")
|
||||
failures += 1
|
||||
continue
|
||||
if elapsed > RENDER_BUDGET_S:
|
||||
print(f"WARN {label}: render took {elapsed * 1000:.0f}ms "
|
||||
f"(budget {RENDER_BUDGET_S * 1000:.0f}ms; a Pi is much slower than this machine)")
|
||||
|
||||
out = args.output_dir / f"{args.skin}_{sport}_{mode}_{width}x{height}.png"
|
||||
ctx.canvas.save(out)
|
||||
preview = ctx.canvas.resize((width * 4, height * 4), Image.NEAREST)
|
||||
preview.save(out.with_name(out.stem + "_x4.png"))
|
||||
print(f"ok {label}: {elapsed * 1000:.0f}ms -> {display_path(out)}")
|
||||
rendered += 1
|
||||
|
||||
# Vegas card, once per mode at the first size (optional API)
|
||||
try:
|
||||
width, height = sizes[0]
|
||||
ctx = skin_runtime.build_context(host, game, size=(width, height))
|
||||
card = skin.render_vegas_card(ctx, dict(game))
|
||||
if card is not None:
|
||||
out = args.output_dir / f"{args.skin}_{sport}_{mode}_vegas.png"
|
||||
card.save(out)
|
||||
print(f"ok {mode} vegas card -> {display_path(out)}")
|
||||
except Exception as e:
|
||||
print(f"FAIL {mode} vegas card: {type(e).__name__}: {e}")
|
||||
failures += 1
|
||||
|
||||
if rendered == 0 and failures == 0:
|
||||
print(f"FAIL: skin '{args.skin}' rendered nothing — no render_<mode> returned True")
|
||||
return 1
|
||||
print(f"\n{'FAILED' if failures else 'PASSED'}: {rendered} renders, {failures} failures "
|
||||
f"(PNGs in {args.output_dir})")
|
||||
return 1 if failures else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,23 @@
|
||||
# skins/
|
||||
|
||||
User-installable **visual skins** for the sports scoreboards. Each
|
||||
subdirectory is one skin:
|
||||
|
||||
```text
|
||||
skins/<skin-id>/
|
||||
skin.json # manifest
|
||||
skin.py # renderer (a ScoreboardSkin subclass)
|
||||
preview.png # optional
|
||||
```
|
||||
|
||||
- Install a skin: `git clone <skin repo> skins/<skin-id>` (or via the Plugin
|
||||
Store for registry entries with `"type": "skin"`).
|
||||
- Select it: set `"skin": "<skin-id>"` in the plugin's section of
|
||||
`config/config.json`, or use the web UI's Visual Skin dropdown.
|
||||
- Build one: start from `example-classic-baseball/` and read
|
||||
[docs/CREATING_SKINS.md](../docs/CREATING_SKINS.md). Validate with
|
||||
`python scripts/validate_skin.py --skin <skin-id>`.
|
||||
|
||||
Skins survive plugin reinstalls/updates (that's why they live here and not in
|
||||
the plugin's directory). A skin is Python at the same trust level as a
|
||||
plugin — review before installing.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.3 KiB |
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"id": "example-classic-baseball",
|
||||
"name": "Example: Classic Baseball",
|
||||
"version": "1.0.0",
|
||||
"author": "LEDMatrix",
|
||||
"description": "Reference skin: a restyled baseball scorebug demonstrating the skin API. Copy this directory to start your own skin.",
|
||||
"skin_api_version": "1.0.0",
|
||||
"targets": {
|
||||
"sports": [
|
||||
"baseball"
|
||||
],
|
||||
"sport_keys": [
|
||||
"mlb",
|
||||
"milb"
|
||||
]
|
||||
},
|
||||
"entry_point": "skin.py",
|
||||
"class_name": "ClassicBaseballSkin",
|
||||
"modes": [
|
||||
"live",
|
||||
"recent",
|
||||
"upcoming"
|
||||
],
|
||||
"preview": "preview.png"
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
"""
|
||||
Example: Classic Baseball — the reference skin.
|
||||
|
||||
Shows the whole skin API surface on purpose: adaptive regions
|
||||
(scoreboard_regions), fitted text (ctx.layout.fit_text + ctx.draw_fit),
|
||||
logos (ctx.load_logo + ctx.draw_image), raw PIL (ctx.draw for the bases
|
||||
diamond), and per-user options (ctx.options). Everything is derived from
|
||||
ctx and the game dict — a skin holds no state, does no I/O, and never
|
||||
touches the display.
|
||||
|
||||
Copy this directory to skins/<your-skin-id>/, rename the class and the
|
||||
manifest fields, and run:
|
||||
|
||||
python scripts/validate_skin.py --skin <your-skin-id>
|
||||
"""
|
||||
|
||||
from src.adaptive_layout import LADDER_GRID, scoreboard_regions
|
||||
from src.skin_system.skin_base import ScoreboardSkin, SkinContext
|
||||
|
||||
DEFAULT_ACCENT = (255, 200, 0)
|
||||
|
||||
|
||||
class ClassicBaseballSkin(ScoreboardSkin):
|
||||
"""Reference baseball skin: classic scorebug with bases/outs/count."""
|
||||
|
||||
def __init__(self, manifest: dict, options: dict):
|
||||
super().__init__(manifest, options)
|
||||
# Validate user options once at load time (fail fast, fall back
|
||||
# gracefully) rather than surprising every render.
|
||||
accent = self.options.get("accent_color", DEFAULT_ACCENT)
|
||||
if (isinstance(accent, (list, tuple)) and len(accent) == 3
|
||||
and all(isinstance(c, int) and 0 <= c <= 255 for c in accent)):
|
||||
self._accent_color = tuple(accent)
|
||||
else:
|
||||
import logging
|
||||
logging.getLogger(__name__).error(
|
||||
"accent_color must be three 0-255 integers, got %r; using default", accent)
|
||||
self._accent_color = DEFAULT_ACCENT
|
||||
|
||||
# -- shared pieces ----------------------------------------------------
|
||||
|
||||
def _accent(self, ctx: SkinContext) -> tuple:
|
||||
"""Users can recolor the skin from config via skin_options."""
|
||||
return self._accent_color
|
||||
|
||||
def _draw_card(self, ctx: SkinContext, game: dict, status: str,
|
||||
center_lines: list, detail: str) -> None:
|
||||
"""The common card: logos left/right, status on top, the given
|
||||
center content, detail along the bottom."""
|
||||
regions = scoreboard_regions(ctx.layout.bounds, ctx=ctx.layout)
|
||||
|
||||
ctx.draw_image(ctx.load_logo("away"), regions.away_slot,
|
||||
cache_key=f"logo:{game.get('away_abbr')}")
|
||||
ctx.draw_image(ctx.load_logo("home"), regions.home_slot,
|
||||
cache_key=f"logo:{game.get('home_abbr')}")
|
||||
|
||||
if status:
|
||||
fit = ctx.layout.fit_text(status, regions.status_band, LADDER_GRID)
|
||||
ctx.draw_fit(fit, regions.status_band, color=self._accent(ctx))
|
||||
|
||||
if center_lines:
|
||||
rows = regions.score_area.split_v(*[1] * len(center_lines))
|
||||
for line, row in zip(center_lines, rows):
|
||||
if line:
|
||||
fit = ctx.layout.fit_text(line, row, LADDER_GRID)
|
||||
ctx.draw_fit(fit, row)
|
||||
|
||||
if detail:
|
||||
fit = ctx.layout.fit_text(detail, regions.detail_band, LADDER_GRID)
|
||||
ctx.draw_fit(fit, regions.detail_band, color=(160, 160, 160))
|
||||
|
||||
def _draw_bases_and_outs(self, ctx: SkinContext, game: dict) -> None:
|
||||
"""Raw-PIL escape hatch: a bases diamond + out dots in the bottom
|
||||
band, sized from the layout scale so it works on any panel."""
|
||||
size = ctx.layout.px(3, minimum=2) # half-diagonal of one base
|
||||
gap = ctx.layout.px(1)
|
||||
cx = ctx.width // 2
|
||||
cy = ctx.height - (size * 2) - 1
|
||||
|
||||
bases = game.get("bases_occupied") or [False, False, False]
|
||||
# (dx, dy) per base: first (right), second (top), third (left)
|
||||
offsets = [(size + gap, 0), (0, -(size + gap)), (-(size + gap), 0)]
|
||||
for occupied, (dx, dy) in zip(bases, offsets):
|
||||
x, y = cx + dx, cy + dy
|
||||
diamond = [(x, y - size), (x + size, y), (x, y + size), (x - size, y)]
|
||||
if occupied:
|
||||
ctx.draw.polygon(diamond, fill=self._accent(ctx))
|
||||
else:
|
||||
ctx.draw.polygon(diamond, outline=(110, 110, 110))
|
||||
|
||||
outs = min(int(game.get("outs") or 0), 3)
|
||||
r = max(1, size - 1)
|
||||
for i in range(3):
|
||||
x = cx + (i - 1) * (2 * r + 2 * gap)
|
||||
y = ctx.height - r - 1
|
||||
dot = [x - r, y - r, x + r, y + r]
|
||||
if i < outs:
|
||||
ctx.draw.ellipse(dot, fill=(255, 255, 255))
|
||||
else:
|
||||
ctx.draw.ellipse(dot, outline=(110, 110, 110))
|
||||
|
||||
# -- the three modes --------------------------------------------------
|
||||
|
||||
def render_live(self, ctx: SkinContext, game: dict) -> bool:
|
||||
half = "▲" if game.get("inning_half") == "top" else "▼"
|
||||
inning = game.get("inning") or ""
|
||||
status = f"{half}{inning}" if inning else game.get("status_text", "")
|
||||
score = f"{game.get('away_score', '0')}-{game.get('home_score', '0')}"
|
||||
count = f"{game.get('balls', 0)}-{game.get('strikes', 0)}"
|
||||
|
||||
self._draw_card(ctx, game, status, [score], "")
|
||||
self._draw_bases_and_outs(ctx, game)
|
||||
|
||||
# Ball-strike count in the top-left corner, over the away logo.
|
||||
fit = ctx.layout.fit_text(count, (ctx.width // 4, ctx.layout.px(8, minimum=6)), LADDER_GRID)
|
||||
ctx.draw_fit(fit, ctx.layout.bounds.top_band(fit.height + 1).left_col(fit.width + 2),
|
||||
color=(200, 200, 200))
|
||||
return True
|
||||
|
||||
def render_recent(self, ctx: SkinContext, game: dict) -> bool:
|
||||
score = f"{game.get('away_score', '0')}-{game.get('home_score', '0')}"
|
||||
self._draw_card(ctx, game, game.get("status_text", "Final"),
|
||||
[score], game.get("series_summary", ""))
|
||||
return True
|
||||
|
||||
def render_upcoming(self, ctx: SkinContext, game: dict) -> bool:
|
||||
matchup = f"{game.get('away_abbr', '')}@{game.get('home_abbr', '')}"
|
||||
self._draw_card(ctx, game, game.get("game_date", ""),
|
||||
[matchup, game.get("game_time", "")],
|
||||
f"{game.get('away_record', '')} {game.get('home_record', '')}".strip())
|
||||
return True
|
||||
+110
-3
@@ -29,6 +29,10 @@ except ImportError:
|
||||
|
||||
|
||||
class SportsCore(ABC):
|
||||
# Which ScoreboardSkin render method this class's display path maps to.
|
||||
# SportsLive inherits the default; SportsUpcoming/SportsRecent override.
|
||||
SKIN_MODE = "live"
|
||||
|
||||
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str):
|
||||
self.logger = logger
|
||||
self.config = config
|
||||
@@ -99,6 +103,17 @@ class SportsCore(ABC):
|
||||
self.last_update = 0
|
||||
self.current_game = None
|
||||
self.fonts = self._load_fonts()
|
||||
|
||||
# Optional visual skin (see docs/SKIN_SYSTEM.md). "skin" is either a
|
||||
# skin id applied to all modes, or a per-mode mapping like
|
||||
# {"live": "retro", "recent": "built-in"}. Loaded lazily on first
|
||||
# render so a broken skin can never block startup.
|
||||
self._skin_config = self.mode_config.get("skin")
|
||||
self.skin_options = self.mode_config.get("skin_options", {}) or {}
|
||||
self._skin = None
|
||||
self._skin_load_attempted = False
|
||||
self._skin_failures = 0
|
||||
self._skin_slow_renders = 0
|
||||
|
||||
# Initialize dynamic team resolver and resolve favorite teams
|
||||
self.dynamic_resolver = DynamicTeamResolver()
|
||||
@@ -205,6 +220,95 @@ class SportsCore(ABC):
|
||||
self.logger.error(f"Error in base _draw_scorebug_layout: {e}", exc_info=True)
|
||||
|
||||
|
||||
def _resolve_skin_id(self) -> Optional[str]:
|
||||
"""The skin id configured for this instance's mode, or None for the
|
||||
built-in renderer. Accepts a plain id (all modes) or a per-mode
|
||||
mapping ({"live": "retro-baseball", "recent": "built-in"})."""
|
||||
skin_id = self._skin_config
|
||||
if isinstance(skin_id, dict):
|
||||
skin_id = skin_id.get(self.SKIN_MODE)
|
||||
if not skin_id or not isinstance(skin_id, str) or skin_id == "built-in":
|
||||
return None
|
||||
return skin_id
|
||||
|
||||
def _get_skin(self):
|
||||
"""Lazily load the configured skin once. Returns None (built-in
|
||||
renderer) when no skin is configured or loading failed."""
|
||||
if not self._skin_load_attempted:
|
||||
self._skin_load_attempted = True
|
||||
skin_id = self._resolve_skin_id()
|
||||
if skin_id:
|
||||
try:
|
||||
from src.skin_system import skin_runtime
|
||||
self._skin = skin_runtime.load_skin(
|
||||
skin_id, sport=self.sport, sport_key=self.sport_key,
|
||||
options=self.skin_options)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to load skin '{skin_id}': {e}", exc_info=True)
|
||||
self._skin = None
|
||||
return self._skin
|
||||
|
||||
def _render_game(self, game: Dict, force_clear: bool = False) -> None:
|
||||
"""Render one game: try the configured skin first, fall back to the
|
||||
built-in _draw_scorebug_layout. A skin that raises 3 times in a row
|
||||
is disabled for the rest of the session."""
|
||||
skin = self._get_skin()
|
||||
if skin is not None and self._skin_failures < 3:
|
||||
try:
|
||||
from src.skin_system import skin_runtime
|
||||
ctx = skin_runtime.build_context(self, game)
|
||||
render = getattr(skin, f"render_{self.SKIN_MODE}")
|
||||
started = time.monotonic()
|
||||
handled = render(ctx, dict(game))
|
||||
elapsed = time.monotonic() - started
|
||||
if elapsed > 0.15 and self._skin_slow_renders < 5:
|
||||
self._skin_slow_renders += 1
|
||||
self.logger.warning(
|
||||
f"Skin '{self._resolve_skin_id()}' took {elapsed * 1000:.0f}ms to "
|
||||
f"render {self.SKIN_MODE} — slow renders stall the whole display loop")
|
||||
if handled:
|
||||
self._skin_failures = 0
|
||||
self.display_manager.image.paste(ctx.canvas, (0, 0))
|
||||
self.display_manager.update_display()
|
||||
return
|
||||
except Exception:
|
||||
self._skin_failures += 1
|
||||
outcome = ("disabling skin for this session" if self._skin_failures >= 3
|
||||
else "falling back to built-in renderer")
|
||||
self.logger.error(
|
||||
f"Skin '{self._resolve_skin_id()}' failed rendering {self.SKIN_MODE} "
|
||||
f"({self._skin_failures}/3); {outcome}", exc_info=True)
|
||||
self._draw_scorebug_layout(game, force_clear)
|
||||
|
||||
def render_skin_card(self, game: Dict, size: tuple) -> Optional[Image.Image]:
|
||||
"""Render one game as a standalone card via the configured skin —
|
||||
for vegas mode and previews. Tries render_vegas_card at the given
|
||||
size, then the mode renderer on a card-sized canvas. Returns None
|
||||
when no skin is active or the skin declined, so callers can use
|
||||
their default rendering."""
|
||||
skin = self._get_skin()
|
||||
if skin is None or self._skin_failures >= 3:
|
||||
return None
|
||||
try:
|
||||
from src.skin_system import skin_runtime
|
||||
ctx = skin_runtime.build_context(self, game, size=size)
|
||||
card = skin.render_vegas_card(ctx, dict(game))
|
||||
if card is not None:
|
||||
return card
|
||||
ctx = skin_runtime.build_context(self, game, size=size)
|
||||
render = getattr(skin, f"render_{self.SKIN_MODE}")
|
||||
if render(ctx, dict(game)):
|
||||
return ctx.canvas
|
||||
except Exception:
|
||||
# Card failures count toward the same 3-strike session disable
|
||||
# as display failures — a skin broken for vegas shouldn't get
|
||||
# to throw on every scroll tick forever.
|
||||
self._skin_failures += 1
|
||||
self.logger.error(
|
||||
f"Skin '{self._resolve_skin_id()}' card render failed "
|
||||
f"({self._skin_failures}/3)", exc_info=True)
|
||||
return None
|
||||
|
||||
def display(self, force_clear: bool = False) -> bool:
|
||||
"""Common display method for all NCAA FB managers""" # Updated docstring
|
||||
if not self.is_enabled: # Check if module is enabled
|
||||
@@ -229,7 +333,7 @@ class SportsCore(ABC):
|
||||
return False
|
||||
|
||||
try:
|
||||
self._draw_scorebug_layout(self.current_game, force_clear)
|
||||
self._render_game(self.current_game, force_clear)
|
||||
# display_manager.update_display() should be called within subclass draw methods
|
||||
# or after calling display() in the main loop. Let's keep it out of the base display.
|
||||
return True
|
||||
@@ -646,6 +750,8 @@ class SportsCore(ABC):
|
||||
pass
|
||||
|
||||
class SportsUpcoming(SportsCore):
|
||||
SKIN_MODE = "upcoming"
|
||||
|
||||
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str):
|
||||
super().__init__(config, display_manager, cache_manager, logger, sport_key)
|
||||
self.upcoming_games = [] # Store all fetched upcoming games initially
|
||||
@@ -973,7 +1079,7 @@ class SportsUpcoming(SportsCore):
|
||||
self.logger.debug(f"Switched to game index {self.current_game_index}")
|
||||
|
||||
if self.current_game:
|
||||
self._draw_scorebug_layout(self.current_game, force_clear)
|
||||
self._render_game(self.current_game, force_clear)
|
||||
return True
|
||||
# update_display() is called within _draw_scorebug_layout for upcoming
|
||||
return False
|
||||
@@ -984,6 +1090,7 @@ class SportsUpcoming(SportsCore):
|
||||
|
||||
|
||||
class SportsRecent(SportsCore):
|
||||
SKIN_MODE = "recent"
|
||||
|
||||
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str):
|
||||
super().__init__(config, display_manager, cache_manager, logger, sport_key)
|
||||
@@ -1274,7 +1381,7 @@ class SportsRecent(SportsCore):
|
||||
self.logger.debug(f"Switched to game index {self.current_game_index}")
|
||||
|
||||
if self.current_game:
|
||||
self._draw_scorebug_layout(self.current_game, force_clear)
|
||||
self._render_game(self.current_game, force_clear)
|
||||
return True
|
||||
# update_display() is called within _draw_scorebug_layout for recent
|
||||
return False
|
||||
|
||||
@@ -381,6 +381,10 @@ class DisplayController:
|
||||
logger.debug("%d plugin(s) disabled in config", disabled_count)
|
||||
|
||||
logger.info("Plugin system initialized in %.3f seconds", time.time() - plugin_time)
|
||||
# Parallel loading appends modes in load-completion order, which
|
||||
# varies between restarts; apply the user's configured rotation
|
||||
# order (no-op when not configured).
|
||||
self._apply_plugin_rotation_order()
|
||||
logger.info("Total available modes: %d", len(self.available_modes))
|
||||
logger.info("Available modes: %s", self.available_modes)
|
||||
|
||||
@@ -1133,6 +1137,29 @@ class DisplayController:
|
||||
remaining = self.on_demand_expires_at - time.time()
|
||||
return max(0.0, remaining)
|
||||
|
||||
def _publish_current_mode_state(self) -> None:
|
||||
"""Publish the currently active display mode/plugin to cache for the web UI."""
|
||||
try:
|
||||
state = {
|
||||
'mode': self.current_display_mode,
|
||||
'plugin_id': self.mode_to_plugin_id.get(self.current_display_mode),
|
||||
'mode_index': self.current_mode_index,
|
||||
'total_modes': len(self.available_modes),
|
||||
'on_demand_active': self.on_demand_active,
|
||||
'is_display_active': self.is_display_active,
|
||||
'last_updated': time.time(),
|
||||
}
|
||||
self.cache_manager.set('display_current_state', state)
|
||||
self._last_published_mode = self.current_display_mode
|
||||
except (OSError, RuntimeError, ValueError, TypeError) as err:
|
||||
logger.error("Failed to publish current display state: %s", err, exc_info=True)
|
||||
|
||||
def _publish_current_mode_state_if_changed(self) -> None:
|
||||
"""Publish current mode state only when it actually changed, to avoid
|
||||
writing to the shared cache on every render tick."""
|
||||
if self.current_display_mode != getattr(self, '_last_published_mode', None):
|
||||
self._publish_current_mode_state()
|
||||
|
||||
def _publish_on_demand_state(self) -> None:
|
||||
"""Publish current on-demand state to cache for external consumers."""
|
||||
try:
|
||||
@@ -1652,6 +1679,7 @@ class DisplayController:
|
||||
logger.info("Starting display with cached data (fast startup mode)")
|
||||
self.current_display_mode = self.available_modes[self.current_mode_index] if self.available_modes else 'none'
|
||||
logger.info(f"Initial mode set to: {self.current_display_mode} (index: {self.current_mode_index}, total modes: {len(self.available_modes)})")
|
||||
self._publish_current_mode_state()
|
||||
|
||||
while True:
|
||||
# Apply plugin enable/disable edits saved via the web UI. The
|
||||
@@ -1712,9 +1740,11 @@ class DisplayController:
|
||||
logger.debug(f"Error clearing display when inactive: {e}")
|
||||
|
||||
logger.info(f"Display not active (is_display_active={self.is_display_active}), sleeping...")
|
||||
self._publish_current_mode_state()
|
||||
self._sleep_with_plugin_updates(60)
|
||||
continue
|
||||
|
||||
self._publish_current_mode_state_if_changed()
|
||||
logger.debug("Display active, processing mode: %s", self.current_display_mode)
|
||||
|
||||
# Plugins update on their own schedules - no forced sync updates needed
|
||||
@@ -2843,11 +2873,52 @@ class DisplayController:
|
||||
except Exception as e:
|
||||
logger.error("Plugin reconcile: error enabling %s: %s", plugin_id, e, exc_info=True)
|
||||
|
||||
# Newly enabled plugins were appended at the end; put them in the
|
||||
# configured rotation slot before resyncing the index.
|
||||
self._apply_plugin_rotation_order()
|
||||
self._resync_mode_index_after_change(previous_mode)
|
||||
logger.info("Plugin reconcile complete: +%s -%s (%d modes)",
|
||||
logger.info("[DisplayController] Plugin reconcile complete: +%s -%s (%d modes)",
|
||||
sorted(to_add), sorted(to_remove), len(self.available_modes))
|
||||
return True
|
||||
|
||||
def _apply_plugin_rotation_order(self) -> None:
|
||||
"""Reorder available_modes to follow display.plugin_rotation_order.
|
||||
|
||||
The configured value is a list of plugin ids; their modes rotate in
|
||||
that order (each plugin's own modes keep their declared order), with
|
||||
any enabled-but-unlisted plugins appended afterwards in their current
|
||||
relative order. An empty/missing list leaves available_modes exactly
|
||||
as built (today's behavior). Mirrors vegas_mode/config.py's
|
||||
get_ordered_plugins() semantics for the primary rotation.
|
||||
"""
|
||||
configured = (self.config.get("display", {}) or {}).get("plugin_rotation_order", []) or []
|
||||
# Defensive: hand-edited or migrated configs may hold a non-list or
|
||||
# non-string entries; keep the existing rotation rather than applying
|
||||
# a garbage order.
|
||||
if not isinstance(configured, list):
|
||||
logger.warning("[DisplayController] Ignoring invalid plugin_rotation_order (not a list): %r",
|
||||
type(configured).__name__)
|
||||
return
|
||||
configured = [p for p in configured if isinstance(p, str)]
|
||||
if not configured or not self.available_modes:
|
||||
return
|
||||
|
||||
ordered_ids = [p for p in configured if p in self.plugin_display_modes]
|
||||
new_modes: List[str] = []
|
||||
for plugin_id in ordered_ids:
|
||||
for mode in self.plugin_display_modes[plugin_id]:
|
||||
if mode in self.available_modes and mode not in new_modes:
|
||||
new_modes.append(mode)
|
||||
# Unlisted plugins' modes (and any mode not attributable to a plugin)
|
||||
# follow in their existing relative order.
|
||||
for mode in self.available_modes:
|
||||
if mode not in new_modes:
|
||||
new_modes.append(mode)
|
||||
if new_modes != self.available_modes:
|
||||
self.available_modes = new_modes
|
||||
logger.info("[DisplayController] Applied plugin rotation order %s -> modes: %s",
|
||||
configured, self.available_modes)
|
||||
|
||||
def _resync_mode_index_after_change(self, previous_mode: Optional[str]) -> None:
|
||||
"""Clamp rotation state after available_modes changed. Stays on the
|
||||
previous mode if it survived, otherwise restarts cleanly within range."""
|
||||
|
||||
+26
-8
@@ -139,23 +139,41 @@ def setup_logging(
|
||||
sys.stderr.write(f"Warning: Could not set up file logging to {log_file}: {e}\n")
|
||||
|
||||
|
||||
def get_logger(name: str, plugin_id: Optional[str] = None) -> logging.Logger:
|
||||
class PluginLoggerAdapter(logging.LoggerAdapter):
|
||||
"""LoggerAdapter that stamps every record with its plugin_id.
|
||||
|
||||
A plain `logging.Logger` attribute (the old approach) is never copied
|
||||
onto individual `LogRecord`s, so `ContextualFormatter`/`StructuredFormatter`
|
||||
only ever saw `plugin_id` on calls that explicitly passed
|
||||
`extra={'plugin_id': ...}` (i.e. `log_with_context`). This adapter injects
|
||||
it into `extra` on every call, so `self.logger.info(...)` in plugin code
|
||||
is tagged automatically.
|
||||
"""
|
||||
|
||||
def process(self, msg, kwargs):
|
||||
extra = dict(kwargs.get('extra') or {})
|
||||
extra.setdefault('plugin_id', self.extra.get('plugin_id'))
|
||||
kwargs['extra'] = extra
|
||||
return msg, kwargs
|
||||
|
||||
|
||||
def get_logger(name: str, plugin_id: Optional[str] = None):
|
||||
"""
|
||||
Get a logger with consistent configuration.
|
||||
|
||||
|
||||
Args:
|
||||
name: Logger name (typically __name__)
|
||||
plugin_id: Optional plugin ID for automatic context
|
||||
|
||||
|
||||
Returns:
|
||||
Configured logger instance
|
||||
Configured logger instance (or a PluginLoggerAdapter when plugin_id
|
||||
is given, which supports the same .debug/.info/.warning/.error API)
|
||||
"""
|
||||
logger = logging.getLogger(name)
|
||||
|
||||
# Add plugin_id as attribute for formatters
|
||||
|
||||
if plugin_id:
|
||||
logger.plugin_id = plugin_id
|
||||
|
||||
return PluginLoggerAdapter(logger, {'plugin_id': plugin_id})
|
||||
|
||||
return logger
|
||||
|
||||
|
||||
|
||||
@@ -86,7 +86,9 @@ class BasePlugin(ABC):
|
||||
self.display_manager: Any = display_manager
|
||||
self.cache_manager: Any = cache_manager
|
||||
self.plugin_manager: Any = plugin_manager
|
||||
self.logger: logging.Logger = get_logger(f"plugin.{plugin_id}", plugin_id=plugin_id)
|
||||
# get_logger returns a PluginLoggerAdapter here (plugin_id given), which
|
||||
# stamps every record with plugin_id so it survives into formatted output.
|
||||
self.logger = get_logger(f"plugin.{plugin_id}", plugin_id=plugin_id)
|
||||
self.enabled: bool = config.get("enabled", True)
|
||||
|
||||
self.logger.info("Initialized plugin: %s", plugin_id)
|
||||
|
||||
@@ -284,6 +284,19 @@ class SchemaManager:
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": "Enable live priority takeover when plugin has live content"
|
||||
},
|
||||
# Skin selection (docs/SKIN_SYSTEM.md). Deliberately NOT an
|
||||
# enum here: validation must keep passing when a configured
|
||||
# skin gets uninstalled (rendering falls back to built-in).
|
||||
# The install-dependent enum is injected only at serve time
|
||||
# (inject_skin_selector) for the web UI dropdown.
|
||||
"skin": {
|
||||
"type": ["string", "object", "null"],
|
||||
"description": "Visual skin id, or a per-mode mapping like {\"live\": \"my-skin\"}"
|
||||
},
|
||||
"skin_options": {
|
||||
"type": "object",
|
||||
"description": "Options passed through to the selected skin"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -354,6 +367,53 @@ class SchemaManager:
|
||||
self.logger.error(error_msg)
|
||||
return False, [error_msg]
|
||||
|
||||
def inject_skin_selector(self, schema: Dict[str, Any], plugin_id: str,
|
||||
current_value: Any = None) -> Dict[str, Any]:
|
||||
"""Return a copy of a plugin's schema with a "skin" dropdown added
|
||||
when installed skins target this plugin (docs/SKIN_SYSTEM.md).
|
||||
|
||||
Serve-time only — validation never sees this enum, so a config
|
||||
referencing an uninstalled skin stays valid (rendering falls back
|
||||
to the built-in layout). The currently-configured value is always
|
||||
included in the enum for the same reason: the dropdown must be able
|
||||
to display a selection whose skin was removed.
|
||||
"""
|
||||
# A per-mode mapping ({"live": ..., "recent": ...}) can't be edited
|
||||
# through a string dropdown — injecting one would let the form save
|
||||
# a string over the mapping. Leave the schema alone; per-mode users
|
||||
# edit via the raw JSON config editor.
|
||||
if isinstance(current_value, dict):
|
||||
return schema
|
||||
|
||||
try:
|
||||
from src.skin_system import skin_runtime
|
||||
matching = skin_runtime.skins_for_plugin(plugin_id)
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Skin discovery failed for {plugin_id}: {e}")
|
||||
return schema
|
||||
|
||||
choices = sorted(matching.keys())
|
||||
if isinstance(current_value, str) and current_value and \
|
||||
current_value != "built-in" and current_value not in choices:
|
||||
choices.append(current_value)
|
||||
if not choices:
|
||||
return schema
|
||||
|
||||
enhanced = copy.deepcopy(schema)
|
||||
enhanced.setdefault("properties", {})
|
||||
if "skin" not in enhanced["properties"]:
|
||||
names = {sid: (matching.get(sid, {}).get("name") or sid) for sid in choices}
|
||||
enhanced["properties"]["skin"] = {
|
||||
"type": "string",
|
||||
"title": "Visual Skin",
|
||||
"description": "Replace this scoreboard's look with an installed skin "
|
||||
"(data, scheduling, and vegas mode are unaffected)",
|
||||
"enum": ["built-in", *choices],
|
||||
"enumNames": ["Built-in", *(names[sid] for sid in choices)],
|
||||
"default": "built-in"
|
||||
}
|
||||
return enhanced
|
||||
|
||||
def _format_validation_error(self, error: ValidationError, plugin_id: Optional[str] = None) -> str:
|
||||
"""
|
||||
Format a validation error into a readable message.
|
||||
|
||||
@@ -1214,6 +1214,11 @@ class PluginStoreManager:
|
||||
self.logger.error(f"Plugin not found in registry: {plugin_id}")
|
||||
return False
|
||||
|
||||
# Visual skins share the registry but install to skins/, not to a
|
||||
# plugin directory (docs/SKIN_SYSTEM.md)
|
||||
if (plugin_info.get('type') or 'plugin') == 'skin':
|
||||
return self._install_skin_from_info(plugin_id, plugin_info, branch)
|
||||
|
||||
repo_url = plugin_info.get('repo')
|
||||
if not repo_url:
|
||||
self.logger.error(f"Plugin {plugin_id} missing repository URL")
|
||||
@@ -2254,19 +2259,171 @@ class PluginStoreManager:
|
||||
|
||||
return None
|
||||
|
||||
_SKIN_ID_PATTERN = re.compile(r'^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$')
|
||||
|
||||
def _resolve_skin_target(self, skin_id: str) -> Optional[Path]:
|
||||
"""Validate an externally-supplied skin id and resolve it to a path
|
||||
strictly inside the skins directory. Returns None (after logging)
|
||||
for ids that are malformed or would escape the directory — registry
|
||||
entries and manifests are external input and must not be able to
|
||||
write or delete outside skins/."""
|
||||
from src.skin_system import skin_runtime
|
||||
|
||||
if not isinstance(skin_id, str) or not self._SKIN_ID_PATTERN.match(skin_id) \
|
||||
or '..' in skin_id:
|
||||
self.logger.error(f"Rejecting unsafe skin id: {skin_id!r}")
|
||||
return None
|
||||
skins_dir = skin_runtime.get_skins_directory().resolve()
|
||||
target = (skins_dir / skin_id).resolve()
|
||||
if target.parent != skins_dir:
|
||||
self.logger.error(f"Skin id {skin_id!r} escapes the skins directory; rejecting")
|
||||
return None
|
||||
return target
|
||||
|
||||
def _install_skin_from_info(self, skin_id: str, skin_info: Dict,
|
||||
branch: Optional[str] = None) -> bool:
|
||||
"""Install a registry entry of type "skin" into skins/<id>/.
|
||||
|
||||
Reuses the plugin download machinery (git / monorepo zip / archive)
|
||||
but validates skin.json instead of manifest.json and never installs
|
||||
dependencies — skins are render-only (stdlib + PIL + the provided
|
||||
SkinContext), which is also what keeps them safe to iterate on.
|
||||
|
||||
Downloads into a staging directory and validates there; the
|
||||
existing installation is only replaced after the new one passes,
|
||||
so a failed download or bad manifest can't destroy a working skin.
|
||||
"""
|
||||
from src.skin_system import skin_runtime
|
||||
from src.skin_system.skin_base import SKIN_API_VERSION
|
||||
|
||||
repo_url = skin_info.get('repo')
|
||||
if not repo_url:
|
||||
self.logger.error(f"Skin {skin_id} missing repository URL")
|
||||
return False
|
||||
|
||||
target = self._resolve_skin_target(skin_id)
|
||||
if target is None:
|
||||
return False
|
||||
skins_dir = target.parent
|
||||
skins_dir.mkdir(parents=True, exist_ok=True)
|
||||
# Leading "_" keeps staging invisible to skin discovery
|
||||
staging = skins_dir / f"_staging-{skin_id}"
|
||||
if staging.exists() and not self._safe_remove_directory(staging):
|
||||
return False
|
||||
|
||||
subpath = skin_info.get('plugin_path')
|
||||
branch_candidates = self._distinct_sequence([
|
||||
branch,
|
||||
skin_info.get('branch'),
|
||||
skin_info.get('default_branch'),
|
||||
skin_info.get('last_commit_branch'),
|
||||
'main',
|
||||
'master'
|
||||
])
|
||||
|
||||
try:
|
||||
branch_used = None
|
||||
if subpath:
|
||||
for candidate in branch_candidates:
|
||||
download_url = f"{repo_url}/archive/refs/heads/{candidate}.zip"
|
||||
if self._install_from_monorepo(download_url, subpath, staging):
|
||||
branch_used = candidate
|
||||
break
|
||||
else:
|
||||
branch_used = self._install_via_git(repo_url, staging, branch_candidates)
|
||||
if branch_used is None and not staging.exists():
|
||||
for candidate in branch_candidates:
|
||||
download_url = f"{repo_url}/archive/refs/heads/{candidate}.zip"
|
||||
if self._install_via_download(download_url, staging):
|
||||
branch_used = candidate
|
||||
break
|
||||
|
||||
if branch_used is None and not staging.exists():
|
||||
self.logger.error(f"Failed to install skin {skin_id} via git or archive download")
|
||||
return False
|
||||
|
||||
try:
|
||||
with open(staging / 'skin.json', 'r', encoding='utf-8') as f:
|
||||
manifest = json.load(f)
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
self.logger.error(f"Skin {skin_id} has no valid skin.json: {e}")
|
||||
return False
|
||||
|
||||
missing = [k for k in ('id', 'name', 'version', 'skin_api_version', 'class_name')
|
||||
if not manifest.get(k)]
|
||||
if missing:
|
||||
self.logger.error(f"Skin {skin_id} manifest missing fields: {missing}")
|
||||
return False
|
||||
|
||||
# Unlike plugins, a mismatched id is rejected rather than
|
||||
# renamed: the manifest id is external input, and the registry
|
||||
# id is what the user asked to install.
|
||||
if manifest['id'] != skin_id:
|
||||
self.logger.error(
|
||||
f"Skin manifest id {manifest['id']!r} doesn't match registry id "
|
||||
f"{skin_id!r}; not installing")
|
||||
return False
|
||||
|
||||
def _api_major(v):
|
||||
try:
|
||||
return int(str(v).split('.')[0])
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
|
||||
if _api_major(manifest['skin_api_version']) != _api_major(SKIN_API_VERSION):
|
||||
self.logger.error(
|
||||
f"Skin {skin_id} targets skin API {manifest['skin_api_version']} but this "
|
||||
f"LEDMatrix provides {SKIN_API_VERSION}; not installing")
|
||||
return False
|
||||
|
||||
# Validated — swap into place
|
||||
if target.exists() and not self._safe_remove_directory(target):
|
||||
self.logger.error(f"Could not replace existing skin directory: {target}")
|
||||
return False
|
||||
shutil.move(str(staging), str(target))
|
||||
skin_runtime.discover_skins(force_refresh=True)
|
||||
self.logger.info(f"Successfully installed skin: {skin_id} (branch: {branch_used})")
|
||||
return True
|
||||
finally:
|
||||
if staging.exists():
|
||||
self._safe_remove_directory(staging)
|
||||
|
||||
def uninstall_skin(self, skin_id: str) -> bool:
|
||||
"""Remove an installed skin. Plugin configs referencing it keep
|
||||
validating; rendering falls back to the built-in layout."""
|
||||
from src.skin_system import skin_runtime
|
||||
|
||||
target = self._resolve_skin_target(skin_id)
|
||||
if target is None:
|
||||
return False
|
||||
if not target.exists():
|
||||
self.logger.info(f"Skin {skin_id} not found (already uninstalled)")
|
||||
return True
|
||||
if self._safe_remove_directory(target):
|
||||
skin_runtime.discover_skins(force_refresh=True)
|
||||
self.logger.info(f"Successfully uninstalled skin: {skin_id}")
|
||||
return True
|
||||
return False
|
||||
|
||||
def uninstall_plugin(self, plugin_id: str) -> bool:
|
||||
"""
|
||||
Uninstall a plugin by removing its directory.
|
||||
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
|
||||
|
||||
Returns:
|
||||
True if uninstalled successfully (or already not installed)
|
||||
"""
|
||||
plugin_path = self._find_plugin_path(plugin_id)
|
||||
|
||||
|
||||
if plugin_path is None or not plugin_path.exists():
|
||||
# A skin id passed to the plugin uninstall path (the store UI
|
||||
# uses one uninstall flow) removes the skin instead
|
||||
skin_target = self._resolve_skin_target(plugin_id) \
|
||||
if self._SKIN_ID_PATTERN.match(str(plugin_id)) else None
|
||||
if skin_target is not None and skin_target.exists():
|
||||
return self.uninstall_skin(plugin_id)
|
||||
self.logger.info(f"Plugin {plugin_id} not found (already uninstalled)")
|
||||
return True # Already uninstalled, consider this success
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
Skin system: user-installable visual overlays for sports scoreboards.
|
||||
|
||||
A skin replaces only the rendering of a scoreboard (live / recent /
|
||||
upcoming) while the host plugin keeps doing data fetching, scheduling,
|
||||
caching, live priority, and vegas mode. See docs/SKIN_SYSTEM.md.
|
||||
"""
|
||||
|
||||
from src.skin_system.skin_base import (
|
||||
SKIN_API_VERSION,
|
||||
VIEW_MODEL_VERSION,
|
||||
ScoreboardSkin,
|
||||
SkinContext,
|
||||
)
|
||||
from src.skin_system.skin_runtime import (
|
||||
build_context,
|
||||
discover_skins,
|
||||
get_skins_directory,
|
||||
load_skin,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"SKIN_API_VERSION",
|
||||
"VIEW_MODEL_VERSION",
|
||||
"ScoreboardSkin",
|
||||
"SkinContext",
|
||||
"build_context",
|
||||
"discover_skins",
|
||||
"get_skins_directory",
|
||||
"load_skin",
|
||||
]
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"id": "401570001",
|
||||
"game_time": "7:05PM",
|
||||
"game_date": "Jul 16th",
|
||||
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
||||
"status_text": "Bot 7th",
|
||||
"is_live": true,
|
||||
"is_final": false,
|
||||
"is_upcoming": false,
|
||||
"is_halftime": false,
|
||||
"is_period_break": false,
|
||||
"home_abbr": "LAD",
|
||||
"home_id": "19",
|
||||
"home_score": "5",
|
||||
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
||||
"home_logo_url": null,
|
||||
"home_record": "58-33",
|
||||
"away_abbr": "SF",
|
||||
"away_id": "26",
|
||||
"away_score": "3",
|
||||
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
||||
"away_logo_url": null,
|
||||
"away_record": "49-42",
|
||||
"is_within_window": true,
|
||||
"status": "STATUS_IN_PROGRESS",
|
||||
"status_state": "in",
|
||||
"inning": 7,
|
||||
"inning_half": "bottom",
|
||||
"balls": 3,
|
||||
"strikes": 2,
|
||||
"outs": 2,
|
||||
"bases_occupied": [
|
||||
true,
|
||||
true,
|
||||
true
|
||||
],
|
||||
"start_time": "2026-07-16T23:05:00Z",
|
||||
"series_summary": "LAD leads 2-1"
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"id": "401570001",
|
||||
"game_time": "7:05PM",
|
||||
"game_date": "Jul 16th",
|
||||
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
||||
"status_text": "Final",
|
||||
"is_live": false,
|
||||
"is_final": true,
|
||||
"is_upcoming": false,
|
||||
"is_halftime": false,
|
||||
"is_period_break": false,
|
||||
"home_abbr": "LAD",
|
||||
"home_id": "19",
|
||||
"home_score": "5",
|
||||
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
||||
"home_logo_url": null,
|
||||
"home_record": "58-33",
|
||||
"away_abbr": "SF",
|
||||
"away_id": "26",
|
||||
"away_score": "3",
|
||||
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
||||
"away_logo_url": null,
|
||||
"away_record": "49-42",
|
||||
"is_within_window": true,
|
||||
"status": "STATUS_FINAL",
|
||||
"status_state": "post",
|
||||
"inning": 9,
|
||||
"inning_half": "top",
|
||||
"balls": 0,
|
||||
"strikes": 0,
|
||||
"outs": 3,
|
||||
"bases_occupied": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
],
|
||||
"start_time": "2026-07-16T23:05:00Z",
|
||||
"series_summary": "Series tied 2-2"
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"id": "401570001",
|
||||
"game_time": "7:05PM",
|
||||
"game_date": "Jul 16th",
|
||||
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
||||
"status_text": "7:05 PM",
|
||||
"is_live": false,
|
||||
"is_final": false,
|
||||
"is_upcoming": true,
|
||||
"is_halftime": false,
|
||||
"is_period_break": false,
|
||||
"home_abbr": "LAD",
|
||||
"home_id": "19",
|
||||
"home_score": "0",
|
||||
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
||||
"home_logo_url": null,
|
||||
"home_record": "58-33",
|
||||
"away_abbr": "SF",
|
||||
"away_id": "26",
|
||||
"away_score": "0",
|
||||
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
||||
"away_logo_url": null,
|
||||
"away_record": "49-42",
|
||||
"is_within_window": true,
|
||||
"status": "STATUS_SCHEDULED",
|
||||
"status_state": "pre",
|
||||
"inning": 0,
|
||||
"inning_half": "top",
|
||||
"balls": 0,
|
||||
"strikes": 0,
|
||||
"outs": 0,
|
||||
"bases_occupied": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
],
|
||||
"start_time": "2026-07-16T23:05:00Z",
|
||||
"series_summary": ""
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"id": "401570001",
|
||||
"game_time": "7:05PM",
|
||||
"game_date": "Jul 16th",
|
||||
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
||||
"status_text": "Q4 2:34",
|
||||
"is_live": true,
|
||||
"is_final": false,
|
||||
"is_upcoming": false,
|
||||
"is_halftime": false,
|
||||
"is_period_break": false,
|
||||
"home_abbr": "OKC",
|
||||
"home_id": "19",
|
||||
"home_score": "5",
|
||||
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
||||
"home_logo_url": null,
|
||||
"home_record": "",
|
||||
"away_abbr": "MIN",
|
||||
"away_id": "26",
|
||||
"away_score": "3",
|
||||
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
||||
"away_logo_url": null,
|
||||
"away_record": "",
|
||||
"is_within_window": true,
|
||||
"period": 4,
|
||||
"period_text": "Q4",
|
||||
"clock": "2:34"
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"id": "401570001",
|
||||
"game_time": "7:05PM",
|
||||
"game_date": "Jul 16th",
|
||||
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
||||
"status_text": "Final",
|
||||
"is_live": false,
|
||||
"is_final": true,
|
||||
"is_upcoming": false,
|
||||
"is_halftime": false,
|
||||
"is_period_break": false,
|
||||
"home_abbr": "OKC",
|
||||
"home_id": "19",
|
||||
"home_score": "5",
|
||||
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
||||
"home_logo_url": null,
|
||||
"home_record": "",
|
||||
"away_abbr": "MIN",
|
||||
"away_id": "26",
|
||||
"away_score": "3",
|
||||
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
||||
"away_logo_url": null,
|
||||
"away_record": "",
|
||||
"is_within_window": true,
|
||||
"period": 4,
|
||||
"period_text": "Final",
|
||||
"clock": "0:00"
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"id": "401570001",
|
||||
"game_time": "7:05PM",
|
||||
"game_date": "Jul 16th",
|
||||
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
||||
"status_text": "7:05 PM",
|
||||
"is_live": false,
|
||||
"is_final": false,
|
||||
"is_upcoming": true,
|
||||
"is_halftime": false,
|
||||
"is_period_break": false,
|
||||
"home_abbr": "OKC",
|
||||
"home_id": "19",
|
||||
"home_score": "0",
|
||||
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
||||
"home_logo_url": null,
|
||||
"home_record": "",
|
||||
"away_abbr": "MIN",
|
||||
"away_id": "26",
|
||||
"away_score": "0",
|
||||
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
||||
"away_logo_url": null,
|
||||
"away_record": "",
|
||||
"is_within_window": true,
|
||||
"period": 0,
|
||||
"period_text": "",
|
||||
"clock": "0:00"
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"id": "401570001",
|
||||
"game_time": "7:05PM",
|
||||
"game_date": "Jul 16th",
|
||||
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
||||
"status_text": "Q3 8:12",
|
||||
"is_live": true,
|
||||
"is_final": false,
|
||||
"is_upcoming": false,
|
||||
"is_halftime": false,
|
||||
"is_period_break": false,
|
||||
"home_abbr": "KC",
|
||||
"home_id": "19",
|
||||
"home_score": "21",
|
||||
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
||||
"home_logo_url": null,
|
||||
"home_record": "58-33",
|
||||
"away_abbr": "BUF",
|
||||
"away_id": "26",
|
||||
"away_score": "17",
|
||||
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
||||
"away_logo_url": null,
|
||||
"away_record": "49-42",
|
||||
"is_within_window": true,
|
||||
"period": 3,
|
||||
"period_text": "Q3",
|
||||
"clock": "8:12",
|
||||
"home_timeouts": 2,
|
||||
"away_timeouts": 3,
|
||||
"down_distance_text": "3rd & 4",
|
||||
"down_distance_text_long": "3rd & 4 at KC 22",
|
||||
"is_redzone": true,
|
||||
"possession": "12",
|
||||
"possession_indicator": "away",
|
||||
"scoring_event": null
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"id": "401570001",
|
||||
"game_time": "7:05PM",
|
||||
"game_date": "Jul 16th",
|
||||
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
||||
"status_text": "Final",
|
||||
"is_live": false,
|
||||
"is_final": true,
|
||||
"is_upcoming": false,
|
||||
"is_halftime": false,
|
||||
"is_period_break": false,
|
||||
"home_abbr": "KC",
|
||||
"home_id": "19",
|
||||
"home_score": "21",
|
||||
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
||||
"home_logo_url": null,
|
||||
"home_record": "58-33",
|
||||
"away_abbr": "BUF",
|
||||
"away_id": "26",
|
||||
"away_score": "17",
|
||||
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
||||
"away_logo_url": null,
|
||||
"away_record": "49-42",
|
||||
"is_within_window": true,
|
||||
"period": 4,
|
||||
"period_text": "Final",
|
||||
"clock": "0:00",
|
||||
"home_timeouts": 0,
|
||||
"away_timeouts": 0,
|
||||
"down_distance_text": "",
|
||||
"down_distance_text_long": "",
|
||||
"is_redzone": false,
|
||||
"possession": null,
|
||||
"possession_indicator": null,
|
||||
"scoring_event": null
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"id": "401570001",
|
||||
"game_time": "7:05PM",
|
||||
"game_date": "Jul 16th",
|
||||
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
||||
"status_text": "7:05 PM",
|
||||
"is_live": false,
|
||||
"is_final": false,
|
||||
"is_upcoming": true,
|
||||
"is_halftime": false,
|
||||
"is_period_break": false,
|
||||
"home_abbr": "KC",
|
||||
"home_id": "19",
|
||||
"home_score": "0",
|
||||
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
||||
"home_logo_url": null,
|
||||
"home_record": "58-33",
|
||||
"away_abbr": "BUF",
|
||||
"away_id": "26",
|
||||
"away_score": "0",
|
||||
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
||||
"away_logo_url": null,
|
||||
"away_record": "49-42",
|
||||
"is_within_window": true,
|
||||
"period": 0,
|
||||
"period_text": "",
|
||||
"clock": "0:00",
|
||||
"home_timeouts": 3,
|
||||
"away_timeouts": 3,
|
||||
"down_distance_text": "",
|
||||
"down_distance_text_long": "",
|
||||
"is_redzone": false,
|
||||
"possession": null,
|
||||
"possession_indicator": null,
|
||||
"scoring_event": null
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"id": "401570001",
|
||||
"game_time": "7:05PM",
|
||||
"game_date": "Jul 16th",
|
||||
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
||||
"status_text": "P3 14:55",
|
||||
"is_live": true,
|
||||
"is_final": false,
|
||||
"is_upcoming": false,
|
||||
"is_halftime": false,
|
||||
"is_period_break": false,
|
||||
"home_abbr": "COL",
|
||||
"home_id": "19",
|
||||
"home_score": "2",
|
||||
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
||||
"home_logo_url": null,
|
||||
"home_record": "58-33",
|
||||
"away_abbr": "VGK",
|
||||
"away_id": "26",
|
||||
"away_score": "2",
|
||||
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
||||
"away_logo_url": null,
|
||||
"away_record": "49-42",
|
||||
"is_within_window": true,
|
||||
"period": 3,
|
||||
"period_text": "P3",
|
||||
"clock": "14:55",
|
||||
"power_play": true,
|
||||
"penalties": [],
|
||||
"home_shots": 27,
|
||||
"away_shots": 31
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"id": "401570001",
|
||||
"game_time": "7:05PM",
|
||||
"game_date": "Jul 16th",
|
||||
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
||||
"status_text": "Final/OT",
|
||||
"is_live": false,
|
||||
"is_final": true,
|
||||
"is_upcoming": false,
|
||||
"is_halftime": false,
|
||||
"is_period_break": false,
|
||||
"home_abbr": "COL",
|
||||
"home_id": "19",
|
||||
"home_score": "3",
|
||||
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
||||
"home_logo_url": null,
|
||||
"home_record": "58-33",
|
||||
"away_abbr": "VGK",
|
||||
"away_id": "26",
|
||||
"away_score": "2",
|
||||
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
||||
"away_logo_url": null,
|
||||
"away_record": "49-42",
|
||||
"is_within_window": true,
|
||||
"period": 5,
|
||||
"period_text": "Final/OT",
|
||||
"clock": "0:00",
|
||||
"power_play": false,
|
||||
"penalties": [],
|
||||
"home_shots": 35,
|
||||
"away_shots": 33
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"id": "401570001",
|
||||
"game_time": "7:05PM",
|
||||
"game_date": "Jul 16th",
|
||||
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
||||
"status_text": "7:05 PM",
|
||||
"is_live": false,
|
||||
"is_final": false,
|
||||
"is_upcoming": true,
|
||||
"is_halftime": false,
|
||||
"is_period_break": false,
|
||||
"home_abbr": "COL",
|
||||
"home_id": "19",
|
||||
"home_score": "0",
|
||||
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
||||
"home_logo_url": null,
|
||||
"home_record": "58-33",
|
||||
"away_abbr": "VGK",
|
||||
"away_id": "26",
|
||||
"away_score": "0",
|
||||
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
||||
"away_logo_url": null,
|
||||
"away_record": "49-42",
|
||||
"is_within_window": true,
|
||||
"period": 0,
|
||||
"period_text": "",
|
||||
"clock": "0:00",
|
||||
"power_play": false,
|
||||
"penalties": [],
|
||||
"home_shots": 0,
|
||||
"away_shots": 0
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 444 B |
Binary file not shown.
|
After Width: | Height: | Size: 446 B |
@@ -0,0 +1,171 @@
|
||||
"""
|
||||
Skin API: the classes a skin author works with.
|
||||
|
||||
A skin is a directory under skins/<skin-id>/ containing a skin.json
|
||||
manifest and a Python module exposing a ScoreboardSkin subclass. The
|
||||
host (a sports scoreboard's base classes) builds a SkinContext per
|
||||
render and calls render_live / render_recent / render_upcoming with the
|
||||
game view model. The skin draws onto ctx.canvas and returns True; the
|
||||
host composites the canvas onto the display. A skin never talks to the
|
||||
display, the network, or the plugin directly.
|
||||
|
||||
Skin API Version: 1.0.0
|
||||
View Model Version: 1.0
|
||||
"""
|
||||
|
||||
from abc import ABC
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Dict, Optional, Tuple, Union
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
try:
|
||||
import freetype
|
||||
except ImportError: # pragma: no cover - freetype ships with the project deps
|
||||
freetype = None
|
||||
|
||||
from src.adaptive_layout import FitResult, LayoutContext, Region
|
||||
|
||||
# Major must match a skin manifest's skin_api_version major or the skin
|
||||
# is refused at load time (renames/removals bump major; additions minor).
|
||||
SKIN_API_VERSION = "1.0.0"
|
||||
|
||||
# Version of the guaranteed `game` dict keys (see docs/CREATING_SKINS.md).
|
||||
VIEW_MODEL_VERSION = "1.0"
|
||||
|
||||
|
||||
def _draw_bdf_text_on(draw: ImageDraw.ImageDraw, text: str, x: int, y: int,
|
||||
color: Tuple[int, int, int], face: Any,
|
||||
clip_w: int, clip_h: int) -> None:
|
||||
"""Render a freetype BDF face glyph-by-glyph onto an arbitrary canvas.
|
||||
|
||||
DisplayManager._draw_bdf_text only draws onto the panel image; skins
|
||||
draw onto their own canvas, so the fitted-font path (fit_text can
|
||||
return freetype faces) needs this standalone equivalent.
|
||||
"""
|
||||
try:
|
||||
ascender_px = face.size.ascender >> 6
|
||||
except Exception:
|
||||
ascender_px = 0
|
||||
baseline_y = y + ascender_px
|
||||
for char in text:
|
||||
face.load_char(char)
|
||||
bitmap = face.glyph.bitmap
|
||||
glyph_left = face.glyph.bitmap_left
|
||||
glyph_top = face.glyph.bitmap_top
|
||||
for i in range(bitmap.rows):
|
||||
for j in range(bitmap.width):
|
||||
byte_index = i * bitmap.pitch + (j // 8)
|
||||
if byte_index < len(bitmap.buffer) and \
|
||||
bitmap.buffer[byte_index] & (1 << (7 - (j % 8))):
|
||||
px = x + glyph_left + j
|
||||
py = baseline_y - glyph_top + i
|
||||
if 0 <= px < clip_w and 0 <= py < clip_h:
|
||||
draw.point((px, py), fill=color)
|
||||
x += face.glyph.advance.x >> 6
|
||||
|
||||
|
||||
@dataclass
|
||||
class SkinContext:
|
||||
"""Everything a skin may touch during one render call.
|
||||
|
||||
The canvas is a fresh RGB image sized to the current display (or
|
||||
vegas card). Draw onto it via the helpers below or raw ``draw``;
|
||||
never call display/update methods — the host composites the canvas.
|
||||
"""
|
||||
|
||||
canvas: Image.Image
|
||||
draw: ImageDraw.ImageDraw
|
||||
layout: LayoutContext
|
||||
width: int
|
||||
height: int
|
||||
fonts: Dict[str, Any]
|
||||
options: Dict[str, Any]
|
||||
logger: Any
|
||||
sport: Optional[str] = None
|
||||
view_model_version: str = VIEW_MODEL_VERSION
|
||||
# load_logo("home") / load_logo("away") -> RGBA PIL image or None.
|
||||
# Bound to the current game; hits the host's logo cache (never loads
|
||||
# from disk twice), downloads missing logos like the built-in layout.
|
||||
load_logo: Callable[[str], Optional[Image.Image]] = field(default=lambda side: None)
|
||||
# draw_text_outlined(text, (x, y), font, fill=..., outline_color=...)
|
||||
# — the classic scorebug outlined text, drawn onto this canvas.
|
||||
# TTF fonts only (ctx.fonts values are TTF); for ladder-fitted fonts
|
||||
# use draw_fit / draw_text, which handle BDF faces too.
|
||||
draw_text_outlined: Callable[..., None] = field(default=lambda *a, **k: None)
|
||||
|
||||
def draw_text(self, text: str, x: int, y: int,
|
||||
color: Tuple[int, int, int] = (255, 255, 255),
|
||||
font: Any = None) -> None:
|
||||
"""Draw text at a top-left position, handling both PIL fonts and
|
||||
the freetype BDF faces that layout.fit_text can return."""
|
||||
if font is None:
|
||||
font = self.fonts.get('time')
|
||||
if freetype is not None and isinstance(font, freetype.Face):
|
||||
_draw_bdf_text_on(self.draw, text, int(x), int(y), color, font,
|
||||
self.width, self.height)
|
||||
else:
|
||||
self.draw.text((int(x), int(y)), text, font=font, fill=color)
|
||||
|
||||
def draw_fit(self, fit: FitResult, box: Union[Region, Tuple[int, int]],
|
||||
color: Tuple[int, int, int] = (255, 255, 255),
|
||||
align: str = "center", valign: str = "center") -> None:
|
||||
"""Draw a layout.fit_text() result aligned within a Region — the
|
||||
canvas-local equivalent of adaptive_layout.draw_fitted_text."""
|
||||
region = box if isinstance(box, Region) else Region(0, 0, box[0], box[1])
|
||||
x, y = region.align_xy(fit.width, fit.height, align, valign)
|
||||
self.draw_text(fit.text, x, y - fit.y_offset, color=color, font=fit.font)
|
||||
|
||||
def draw_image(self, img: Optional[Image.Image],
|
||||
box: Union[Region, Tuple[int, int]], *,
|
||||
mode: str = "contain", align: str = "center",
|
||||
valign: str = "center", cache_key: Any = None) -> None:
|
||||
"""Fit an image (a logo, art) into a Region and paste it, honoring
|
||||
alpha. Silently no-ops on None so `ctx.draw_image(ctx.load_logo(
|
||||
'home'), ...)` stays safe when a logo is missing."""
|
||||
if img is None:
|
||||
return
|
||||
region = box if isinstance(box, Region) else Region(0, 0, box[0], box[1])
|
||||
fitted = self.layout.fit_image(img, region, mode=mode,
|
||||
cache_key=cache_key)
|
||||
result = fitted.image # fit_image returns an ImageFitResult (always RGBA)
|
||||
if result is None:
|
||||
return
|
||||
x, y = region.align_xy(result.width, result.height, align, valign)
|
||||
self.canvas.paste(result, (int(x), int(y)), result)
|
||||
|
||||
|
||||
class ScoreboardSkin(ABC):
|
||||
"""Base class for scoreboard skins.
|
||||
|
||||
Override only the modes you want to restyle; any mode you leave
|
||||
unimplemented (or return False from) falls back to the plugin's
|
||||
built-in renderer, so a live-only skin still gets recent/upcoming
|
||||
screens for free.
|
||||
|
||||
Skins should be stateless: three host instances (live, recent,
|
||||
upcoming) each hold their own skin instance, and a render must be
|
||||
derivable from (ctx, game) alone.
|
||||
"""
|
||||
|
||||
SKIN_API_VERSION = SKIN_API_VERSION
|
||||
|
||||
def __init__(self, manifest: Dict[str, Any], options: Dict[str, Any]):
|
||||
self.manifest = manifest
|
||||
self.options = options or {}
|
||||
|
||||
def render_live(self, ctx: SkinContext, game: Dict[str, Any]) -> bool:
|
||||
return False
|
||||
|
||||
def render_recent(self, ctx: SkinContext, game: Dict[str, Any]) -> bool:
|
||||
return False
|
||||
|
||||
def render_upcoming(self, ctx: SkinContext, game: Dict[str, Any]) -> bool:
|
||||
return False
|
||||
|
||||
def render_vegas_card(self, ctx: SkinContext,
|
||||
game: Dict[str, Any]) -> Optional[Image.Image]:
|
||||
"""Render one vegas scroll card at ctx.width x ctx.height. Return
|
||||
the finished image, or None to let the host use its default vegas
|
||||
rendering (which captures the regular display output)."""
|
||||
return None
|
||||
@@ -0,0 +1,352 @@
|
||||
"""
|
||||
Skin runtime: discovery, validation, loading, and context building.
|
||||
|
||||
Deliberately generic — this module knows nothing about sports beyond
|
||||
passing a `sport` label through; the sports flavor lives in skin_base
|
||||
(ScoreboardSkin) and in the hosts that call build_context.
|
||||
|
||||
Every failure path here logs and returns None: a broken or missing skin
|
||||
must never take down the plugin that references it — the host falls
|
||||
back to its built-in renderer.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from src.adaptive_layout import LayoutContext
|
||||
from src.logging_config import get_logger
|
||||
from src.skin_system.skin_base import (
|
||||
SKIN_API_VERSION,
|
||||
ScoreboardSkin,
|
||||
SkinContext,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_REQUIRED_MANIFEST_FIELDS = ("id", "name", "version", "skin_api_version", "class_name")
|
||||
_DEFAULT_ENTRY_POINT = "skin.py"
|
||||
|
||||
_lock = threading.RLock()
|
||||
# skins_dir -> (fingerprint, {skin_id: manifest+path})
|
||||
_discovery_cache: Dict[str, Tuple[Tuple, Dict[str, Dict[str, Any]]]] = {}
|
||||
|
||||
_shared_layout_font_manager: Optional[Any] = None
|
||||
|
||||
|
||||
def _get_font_manager() -> Any:
|
||||
"""Shared FontManager for skin LayoutContexts. SportsCore hosts don't
|
||||
carry a plugin_manager, so skins share one module-level FontManager —
|
||||
the same shape as base_plugin._fallback_font_manager, constructed
|
||||
directly so rendering never has to import the whole plugin system."""
|
||||
global _shared_layout_font_manager
|
||||
if _shared_layout_font_manager is None:
|
||||
from src.font_manager import FontManager
|
||||
_shared_layout_font_manager = FontManager({})
|
||||
return _shared_layout_font_manager
|
||||
|
||||
|
||||
def get_skins_directory() -> Path:
|
||||
"""Central skins directory: <project_root>/skins. Lives outside the
|
||||
plugin directories on purpose — plugin reinstall/update deletes the
|
||||
whole plugin directory, and a skin must survive that."""
|
||||
return Path(__file__).resolve().parents[2] / "skins"
|
||||
|
||||
|
||||
def _major(version: str) -> Optional[int]:
|
||||
try:
|
||||
return int(str(version).split(".")[0])
|
||||
except (ValueError, AttributeError, IndexError):
|
||||
return None
|
||||
|
||||
|
||||
def _read_manifest(skin_dir: Path) -> Optional[Dict[str, Any]]:
|
||||
manifest_path = skin_dir / "skin.json"
|
||||
if not manifest_path.is_file():
|
||||
return None
|
||||
try:
|
||||
with open(manifest_path, "r", encoding="utf-8") as f:
|
||||
manifest = json.load(f)
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
logger.error("Skin manifest %s is unreadable: %s", manifest_path, e)
|
||||
return None
|
||||
missing = [k for k in _REQUIRED_MANIFEST_FIELDS if not manifest.get(k)]
|
||||
if missing:
|
||||
logger.error("Skin manifest %s missing required fields: %s",
|
||||
manifest_path, ", ".join(missing))
|
||||
return None
|
||||
if manifest["id"] != skin_dir.name:
|
||||
logger.warning("Skin manifest id %r does not match directory name %r",
|
||||
manifest["id"], skin_dir.name)
|
||||
manifest["_skin_dir"] = str(skin_dir)
|
||||
return manifest
|
||||
|
||||
|
||||
def _discovery_fingerprint(skins_dir: Path) -> Optional[Tuple]:
|
||||
"""Cache key for a skins directory: its mtime plus every skin.json's
|
||||
(path, mtime). The directory mtime alone misses in-place manifest edits
|
||||
(a skin updated without adding/removing entries)."""
|
||||
try:
|
||||
parts = [skins_dir.stat().st_mtime]
|
||||
for manifest_path in sorted(skins_dir.glob("*/skin.json")):
|
||||
parts.append((str(manifest_path), manifest_path.stat().st_mtime))
|
||||
return tuple(parts)
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def discover_skins(skins_dir: Optional[Path] = None,
|
||||
force_refresh: bool = False) -> Dict[str, Dict[str, Any]]:
|
||||
"""Return {skin_id: manifest} for every valid skin package installed.
|
||||
|
||||
Cached per directory and invalidated when the directory or any
|
||||
skin.json changes; pass force_refresh to bypass.
|
||||
"""
|
||||
skins_dir = Path(skins_dir) if skins_dir else get_skins_directory()
|
||||
cache_key = str(skins_dir)
|
||||
fingerprint = _discovery_fingerprint(skins_dir)
|
||||
if fingerprint is None:
|
||||
return {}
|
||||
|
||||
with _lock:
|
||||
cached = _discovery_cache.get(cache_key)
|
||||
if cached and not force_refresh and cached[0] == fingerprint:
|
||||
return dict(cached[1])
|
||||
|
||||
skins: Dict[str, Dict[str, Any]] = {}
|
||||
for entry in sorted(skins_dir.iterdir()):
|
||||
if not entry.is_dir() or entry.name.startswith((".", "_")):
|
||||
continue
|
||||
manifest = _read_manifest(entry)
|
||||
if manifest:
|
||||
skins[manifest["id"]] = manifest
|
||||
_discovery_cache[cache_key] = (fingerprint, skins)
|
||||
return dict(skins)
|
||||
|
||||
|
||||
def skin_targets(manifest: Dict[str, Any]) -> Tuple[list, list]:
|
||||
"""(sports, sport_keys) a skin declares it supports."""
|
||||
targets = manifest.get("targets") or {}
|
||||
return (list(targets.get("sports") or []),
|
||||
list(targets.get("sport_keys") or []))
|
||||
|
||||
|
||||
def skin_matches_target(manifest: Dict[str, Any], sport: Optional[str],
|
||||
sport_key: Optional[str]) -> bool:
|
||||
"""True when the skin declares support for this sport family or exact
|
||||
sport key. A skin with no targets at all matches everything."""
|
||||
sports, sport_keys = skin_targets(manifest)
|
||||
if not sports and not sport_keys:
|
||||
return True
|
||||
if sport and sport in sports:
|
||||
return True
|
||||
if sport_key and sport_key in sport_keys:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def skins_for_plugin(plugin_id: str,
|
||||
skins: Optional[Dict[str, Dict[str, Any]]] = None) -> Dict[str, Dict[str, Any]]:
|
||||
"""Installed skins that plausibly apply to a plugin, for UI dropdowns.
|
||||
|
||||
A skin matches when the plugin id is listed in targets.plugins, or any
|
||||
declared sport / sport_key appears as a token of the plugin id (so a
|
||||
skin targeting sports=["baseball"] matches "baseball-scoreboard", and
|
||||
sport_keys=["milb"] matches "milb-scoreboard")."""
|
||||
if skins is None:
|
||||
skins = discover_skins()
|
||||
tokens = set(str(plugin_id).lower().replace("-", "_").split("_"))
|
||||
matched = {}
|
||||
for skin_id, manifest in skins.items():
|
||||
targets = manifest.get("targets") or {}
|
||||
if plugin_id in (targets.get("plugins") or []):
|
||||
matched[skin_id] = manifest
|
||||
continue
|
||||
sports, sport_keys = skin_targets(manifest)
|
||||
if any(str(t).lower() in tokens for t in sports + sport_keys):
|
||||
matched[skin_id] = manifest
|
||||
return matched
|
||||
|
||||
|
||||
def _load_skin_module(skin_id: str, skin_dir: Path, entry_point: str) -> Optional[Any]:
|
||||
"""Import the skin's entry module under a namespaced sys.modules key,
|
||||
namespacing its sibling .py files the same way — the collision-
|
||||
avoidance scheme plugins use (plugin_loader._namespace_plugin_modules),
|
||||
so two skins can both ship a helpers.py.
|
||||
|
||||
The entry module is cached: the live/recent/upcoming hosts all load
|
||||
the same skin, and only the first load executes any code. (A skin
|
||||
whose *code* changed on disk needs a service restart to take effect —
|
||||
Python modules can't be safely hot-swapped.)
|
||||
"""
|
||||
entry_path = skin_dir / entry_point
|
||||
if not entry_path.is_file():
|
||||
logger.error("Skin '%s' entry point not found: %s", skin_id, entry_path)
|
||||
return None
|
||||
|
||||
module_name = f"_skin_{skin_id}_{Path(entry_point).stem}"
|
||||
with _lock:
|
||||
cached_entry = sys.modules.get(module_name)
|
||||
if cached_entry is not None:
|
||||
return cached_entry
|
||||
|
||||
# Import siblings under their namespaced alias, and *bind* the bare
|
||||
# name (cached or fresh) so `import helpers` inside the entry module
|
||||
# resolves to this skin's copy. The bare bindings are transient —
|
||||
# restored below so another skin's identically-named sibling can't
|
||||
# be shadowed by ours.
|
||||
replaced_bare: Dict[str, Any] = {}
|
||||
try:
|
||||
for sibling in skin_dir.glob("*.py"):
|
||||
if sibling.name == entry_point:
|
||||
continue
|
||||
alias = f"_skin_{skin_id}_{sibling.stem}"
|
||||
module = sys.modules.get(alias)
|
||||
if module is None:
|
||||
spec = importlib.util.spec_from_file_location(alias, sibling)
|
||||
if not spec or not spec.loader:
|
||||
continue
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[alias] = module
|
||||
replaced_bare.setdefault(sibling.stem, sys.modules.get(sibling.stem))
|
||||
sys.modules[sibling.stem] = module
|
||||
try:
|
||||
spec.loader.exec_module(module)
|
||||
except Exception as e:
|
||||
logger.error("Skin '%s' sibling module %s failed to import: %s",
|
||||
skin_id, sibling.name, e, exc_info=True)
|
||||
sys.modules.pop(alias, None)
|
||||
return None
|
||||
else:
|
||||
replaced_bare.setdefault(sibling.stem, sys.modules.get(sibling.stem))
|
||||
sys.modules[sibling.stem] = module
|
||||
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location(module_name, entry_path)
|
||||
if not spec or not spec.loader:
|
||||
logger.error("Skin '%s': could not create import spec for %s",
|
||||
skin_id, entry_path)
|
||||
return None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
except Exception as e:
|
||||
sys.modules.pop(module_name, None)
|
||||
logger.error("Skin '%s' failed to import: %s", skin_id, e, exc_info=True)
|
||||
return None
|
||||
finally:
|
||||
for bare_name, previous in replaced_bare.items():
|
||||
if previous is None:
|
||||
sys.modules.pop(bare_name, None)
|
||||
else:
|
||||
sys.modules[bare_name] = previous
|
||||
|
||||
|
||||
def load_skin(skin_id: str, sport: Optional[str] = None,
|
||||
sport_key: Optional[str] = None,
|
||||
options: Optional[Dict[str, Any]] = None,
|
||||
skins_dir: Optional[Path] = None) -> Optional[ScoreboardSkin]:
|
||||
"""Load and instantiate a skin. Returns None (after logging why) on
|
||||
any failure — callers treat None as 'use the built-in renderer'."""
|
||||
skins = discover_skins(skins_dir)
|
||||
manifest = skins.get(skin_id)
|
||||
if manifest is None:
|
||||
logger.warning("Skin '%s' is configured but not installed under %s; "
|
||||
"using built-in renderer",
|
||||
skin_id, skins_dir or get_skins_directory())
|
||||
return None
|
||||
|
||||
manifest_major = _major(manifest.get("skin_api_version"))
|
||||
api_major = _major(SKIN_API_VERSION)
|
||||
if manifest_major != api_major:
|
||||
logger.error("Skin '%s' targets skin API %s but this LEDMatrix "
|
||||
"provides %s — the skin needs an update; using "
|
||||
"built-in renderer",
|
||||
skin_id, manifest.get("skin_api_version"), SKIN_API_VERSION)
|
||||
return None
|
||||
|
||||
if not skin_matches_target(manifest, sport, sport_key):
|
||||
# Soft: the user explicitly configured it, so warn but load anyway
|
||||
# (a baseball skin may render an acceptable generic scoreboard).
|
||||
logger.warning("Skin '%s' does not declare support for sport=%r / "
|
||||
"sport_key=%r; loading anyway", skin_id, sport, sport_key)
|
||||
|
||||
skin_dir = Path(manifest["_skin_dir"])
|
||||
module = _load_skin_module(skin_id, skin_dir,
|
||||
manifest.get("entry_point", _DEFAULT_ENTRY_POINT))
|
||||
if module is None:
|
||||
return None
|
||||
|
||||
class_name = manifest["class_name"]
|
||||
skin_class = getattr(module, class_name, None)
|
||||
if skin_class is None or not isinstance(skin_class, type) or \
|
||||
not issubclass(skin_class, ScoreboardSkin):
|
||||
logger.error("Skin '%s': %s is missing or not a ScoreboardSkin subclass",
|
||||
skin_id, class_name)
|
||||
return None
|
||||
|
||||
try:
|
||||
return skin_class(manifest, options or {})
|
||||
except Exception as e:
|
||||
logger.error("Skin '%s' failed to instantiate: %s", skin_id, e, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def build_context(host: Any, game: Dict[str, Any],
|
||||
size: Optional[Tuple[int, int]] = None) -> SkinContext:
|
||||
"""Build a SkinContext for one render call.
|
||||
|
||||
`host` is a SportsCore-style object: display_manager, fonts, logger,
|
||||
sport, skin_options, _load_and_resize_logo, _draw_text_with_outline.
|
||||
`size` overrides the canvas size (vegas cards); default is the
|
||||
current display size read live from the display manager.
|
||||
"""
|
||||
if size is not None:
|
||||
width, height = int(size[0]), int(size[1])
|
||||
else:
|
||||
dm = host.display_manager
|
||||
width = getattr(dm, "width", None) or dm.matrix.width
|
||||
height = getattr(dm, "height", None) or dm.matrix.height
|
||||
|
||||
canvas = Image.new("RGB", (width, height), (0, 0, 0))
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
layout = LayoutContext(width, height, _get_font_manager())
|
||||
|
||||
def load_logo(side: str) -> Optional[Image.Image]:
|
||||
if side not in ("home", "away"):
|
||||
return None
|
||||
try:
|
||||
logo_path = game.get(f"{side}_logo_path")
|
||||
if logo_path is not None and not isinstance(logo_path, Path):
|
||||
logo_path = Path(logo_path)
|
||||
return host._load_and_resize_logo(
|
||||
game.get(f"{side}_id"), game.get(f"{side}_abbr"),
|
||||
logo_path, game.get(f"{side}_logo_url"))
|
||||
except Exception as e:
|
||||
host.logger.warning("Skin logo load failed for %s: %s", side, e)
|
||||
return None
|
||||
|
||||
def draw_text_outlined(text, position, font, fill=(255, 255, 255),
|
||||
outline_color=(0, 0, 0)):
|
||||
host._draw_text_with_outline(draw, text, position, font,
|
||||
fill=fill, outline_color=outline_color)
|
||||
|
||||
return SkinContext(
|
||||
canvas=canvas,
|
||||
draw=draw,
|
||||
layout=layout,
|
||||
width=width,
|
||||
height=height,
|
||||
fonts=dict(host.fonts),
|
||||
options=dict(getattr(host, "skin_options", {}) or {}),
|
||||
logger=host.logger,
|
||||
sport=getattr(host, "sport", None),
|
||||
load_logo=load_logo,
|
||||
draw_text_outlined=draw_text_outlined,
|
||||
)
|
||||
@@ -0,0 +1,452 @@
|
||||
"""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
|
||||
+152
-2
@@ -146,6 +146,11 @@ class TestConfigAPI:
|
||||
|
||||
def test_save_double_sided_settings(self, client, mock_config_manager):
|
||||
"""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(
|
||||
'/api/v3/config/main',
|
||||
data={
|
||||
@@ -175,6 +180,91 @@ class TestConfigAPI:
|
||||
assert ds['enabled'] is False
|
||||
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):
|
||||
"""copies < 2 is rejected with a 400 before any save."""
|
||||
response = client.post(
|
||||
@@ -372,11 +462,71 @@ class TestPluginsAPI:
|
||||
}
|
||||
|
||||
response = client.get('/api/v3/plugins/installed')
|
||||
|
||||
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
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):
|
||||
"""Test getting plugin health information."""
|
||||
from web_interface.blueprints.api_v3 import api_v3
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
"""
|
||||
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"
|
||||
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
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}"
|
||||
)
|
||||
+47
-27
@@ -59,6 +59,20 @@ except ImportError:
|
||||
# flask-limiter not installed, rate limiting disabled
|
||||
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
|
||||
|
||||
# Initialize plugin managers - read plugins directory from config
|
||||
@@ -176,7 +190,12 @@ except Exception as _hm_err: # pragma: no cover - defensive startup guard
|
||||
"Could not enable plugin health/metrics for web UI: %s", _hm_err
|
||||
)
|
||||
|
||||
app.register_blueprint(pages_v3, url_prefix='/v3')
|
||||
# Pages are served un-prefixed (the interface lives at /); the /v3 mount is a
|
||||
# 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')
|
||||
|
||||
# Route to serve plugin asset files (registered on main app, not blueprint, for /assets/... path)
|
||||
@@ -407,7 +426,11 @@ def captive_portal_redirect():
|
||||
|
||||
# List of paths that should NOT be redirected (allow normal operation)
|
||||
allowed_paths = [
|
||||
'/v3', # Main interface and all sub-paths (includes /v3/setup)
|
||||
'/v3', # Legacy-prefixed interface and all sub-paths
|
||||
'/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
|
||||
'/static/', # Static files (CSS, JS, images)
|
||||
'/hotspot-detect.html', # iOS/macOS detection
|
||||
@@ -606,8 +629,6 @@ def system_status_generator():
|
||||
def display_preview_generator():
|
||||
"""Generate display preview updates from snapshot file"""
|
||||
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
|
||||
# Viewer marker: this generator only runs while the broadcaster has
|
||||
@@ -649,24 +670,26 @@ def display_preview_generator():
|
||||
# Only read if file is new or has been updated
|
||||
if last_modified is None or current_modified > last_modified:
|
||||
try:
|
||||
# Read and encode the image
|
||||
with Image.open(snapshot_path) as img:
|
||||
# Convert to PNG and encode as base64
|
||||
buffer = io.BytesIO()
|
||||
img.save(buffer, format='PNG')
|
||||
img_str = base64.b64encode(buffer.getvalue()).decode('utf-8')
|
||||
|
||||
preview_data = {
|
||||
'timestamp': time.time(),
|
||||
'width': width,
|
||||
'height': height,
|
||||
'image': img_str
|
||||
}
|
||||
last_modified = current_modified
|
||||
yield preview_data
|
||||
except Exception: # nosec B110 - SSE preview file may be mid-write; transient error, skip this update
|
||||
# File might be being written, skip this update
|
||||
pass
|
||||
# The snapshot is already a PNG, written atomically by
|
||||
# the display service (tmp + os.replace in
|
||||
# display_manager), so pass the raw bytes straight
|
||||
# through instead of PIL-decoding and re-encoding —
|
||||
# identical payload, much less CPU on the Pi.
|
||||
with open(snapshot_path, 'rb') as f:
|
||||
img_str = base64.b64encode(f.read()).decode('utf-8')
|
||||
|
||||
preview_data = {
|
||||
'timestamp': time.time(),
|
||||
'width': width,
|
||||
'height': height,
|
||||
'image': img_str
|
||||
}
|
||||
last_modified = current_modified
|
||||
yield preview_data
|
||||
except OSError:
|
||||
# Transient filesystem race (file rotated/replaced
|
||||
# between mtime check and read); skip this update.
|
||||
app.logger.debug("Preview snapshot read failed; skipping frame", exc_info=True)
|
||||
else:
|
||||
# No snapshot available
|
||||
yield {
|
||||
@@ -799,11 +822,8 @@ if limiter:
|
||||
limiter.limit("200 per minute")(stream_display)
|
||||
limiter.limit("200 per minute")(stream_logs)
|
||||
|
||||
# Main route - redirect to v3 interface as default
|
||||
@app.route('/')
|
||||
def index():
|
||||
"""Redirect to v3 interface"""
|
||||
return redirect(url_for('pages_v3.index'))
|
||||
# The pages blueprint's index now serves '/' directly (see the un-prefixed
|
||||
# blueprint registration above), so no redirect route is needed here.
|
||||
|
||||
@app.route('/favicon.ico')
|
||||
def favicon():
|
||||
|
||||
@@ -13,7 +13,7 @@ import uuid
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any
|
||||
from typing import Dict, Any, Optional
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -120,6 +120,33 @@ 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)
|
||||
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():
|
||||
"""Ensure cache manager is initialized."""
|
||||
global cache_manager
|
||||
@@ -837,16 +864,15 @@ def save_main_config():
|
||||
ds_config = current_config['display']['double_sided']
|
||||
|
||||
# Enabled checkbox: omitted from the form when unchecked.
|
||||
ds_config['enabled'] = _coerce_to_bool(data.get('double_sided_enabled'))
|
||||
# The Display form posts copies/axis on every save regardless of this
|
||||
# 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
|
||||
|
||||
if 'double_sided_copies' in data and data['double_sided_copies'] not in ('', 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.
|
||||
def _copies_fits_hardware(copies: int) -> Optional[str]:
|
||||
"""Error message if copies doesn't divide the panel evenly, else None."""
|
||||
# Use axis from this request if provided, else from stored config.
|
||||
hw = current_config.get('display', {}).get('hardware', {})
|
||||
effective_axis = (data.get('double_sided_axis')
|
||||
@@ -854,18 +880,41 @@ def save_main_config():
|
||||
if effective_axis == 'horizontal':
|
||||
chain_length = int(hw.get('chain_length', 2) or 2)
|
||||
if chain_length % copies != 0:
|
||||
return jsonify({'status': 'error', 'message': f"Double-sided copies ({copies}) must divide chain length ({chain_length}) evenly"}), 400
|
||||
return f"Double-sided copies ({copies}) must divide chain length ({chain_length}) evenly"
|
||||
elif effective_axis == 'vertical':
|
||||
parallel = int(hw.get('parallel', 1) or 1)
|
||||
if parallel % copies != 0:
|
||||
return jsonify({'status': 'error', 'message': f"Double-sided copies ({copies}) must divide parallel ({parallel}) evenly"}), 400
|
||||
ds_config['copies'] = copies
|
||||
return f"Double-sided copies ({copies}) must divide parallel ({parallel}) evenly"
|
||||
return None
|
||||
|
||||
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:
|
||||
axis = data['double_sided_axis']
|
||||
if axis not in ('horizontal', 'vertical'):
|
||||
return jsonify({'status': 'error', 'message': "Double-sided axis must be 'horizontal' or 'vertical'"}), 400
|
||||
ds_config['axis'] = axis
|
||||
if enabled:
|
||||
return jsonify({'status': 'error', 'message': "Double-sided axis must be 'horizontal' or 'vertical'"}), 400
|
||||
else:
|
||||
ds_config['axis'] = axis
|
||||
|
||||
# Handle Vegas scroll mode settings
|
||||
vegas_fields = ['vegas_scroll_enabled', 'vegas_scroll_speed', 'vegas_separator_width',
|
||||
@@ -961,8 +1010,31 @@ def save_main_config():
|
||||
return jsonify({"status": "error", "message": "sync_follower_position must be left or right"}), 400
|
||||
current_config["sync"]["follower_position"] = pos_val
|
||||
|
||||
# Handle display durations
|
||||
duration_fields = [k for k in data.keys() if k.endswith('_duration') or k in ['default_duration', 'transition_duration']]
|
||||
# Handle primary rotation order: must be a JSON array of plugin-id
|
||||
# strings. Reject anything else with a 400 rather than silently
|
||||
# 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 'display' not in current_config:
|
||||
current_config['display'] = {}
|
||||
@@ -970,8 +1042,36 @@ def save_main_config():
|
||||
current_config['display']['display_durations'] = {}
|
||||
|
||||
for field in duration_fields:
|
||||
if field in data:
|
||||
current_config['display']['display_durations'][field] = int(data[field])
|
||||
raw_value = data.pop(field)
|
||||
try:
|
||||
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
|
||||
# Any key that matches a plugin ID should be saved as plugin config
|
||||
@@ -1639,6 +1739,16 @@ def execute_system_action():
|
||||
except subprocess.TimeoutExpired:
|
||||
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
|
||||
result = subprocess.run(
|
||||
['git', 'pull', '--rebase'],
|
||||
@@ -1655,6 +1765,54 @@ def execute_system_action():
|
||||
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:
|
||||
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
|
||||
# plugin-repos/) even if the user uninstalled them. Re-remove
|
||||
# any the user previously uninstalled so the update doesn't
|
||||
@@ -1685,14 +1843,36 @@ def execute_system_action():
|
||||
result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix-web.service'],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
elif action == 'install_base_requirements':
|
||||
req_file = PROJECT_ROOT / 'requirements.txt'
|
||||
if not req_file.exists():
|
||||
# Base + web interface requirements: flask-compress and friends
|
||||
# live in web_interface/requirements.txt, not the root file.
|
||||
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'})
|
||||
result = _pip_install_requirements(req_file, timeout=120)
|
||||
outputs = []
|
||||
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({
|
||||
'status': 'success' if result.returncode == 0 else 'error',
|
||||
'message': 'Base requirements installed successfully' if result.returncode == 0 else 'pip install failed',
|
||||
'output': _truncate_output(result.stdout, result.stderr)
|
||||
'status': 'success' if all_ok else 'error',
|
||||
'message': 'Base requirements installed successfully' if all_ok else 'pip install failed',
|
||||
'output': "\n".join(outputs)
|
||||
})
|
||||
elif action == 'install_plugin_requirements':
|
||||
active_pm = getattr(api_v3, 'plugin_manager', None)
|
||||
@@ -2111,9 +2291,12 @@ def get_installed_plugins():
|
||||
if enabled is None:
|
||||
enabled = plugin_instance.enabled if plugin_instance else True
|
||||
|
||||
# Verified from registry (no network call)
|
||||
# Verified + latest published version from registry (no network call)
|
||||
store_info = api_v3.plugin_store_manager.get_registry_info(plugin_id)
|
||||
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)
|
||||
plugin_path = Path(api_v3.plugin_manager.plugins_dir) / plugin_id
|
||||
@@ -2160,6 +2343,8 @@ def get_installed_plugins():
|
||||
'id': plugin_id,
|
||||
'name': plugin_info.get('name', plugin_id),
|
||||
'version': plugin_info.get('version', ''),
|
||||
'latest_version': latest_version,
|
||||
'update_available': update_available,
|
||||
'author': plugin_info.get('author', 'Unknown'),
|
||||
'category': plugin_info.get('category', 'General'),
|
||||
'description': plugin_info.get('description', 'No description available'),
|
||||
@@ -5262,6 +5447,18 @@ def get_plugin_schema():
|
||||
schema = schema_mgr.load_schema(plugin_id, use_cache=True)
|
||||
|
||||
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 a simple default schema if file not found
|
||||
@@ -5290,6 +5487,43 @@ def get_plugin_schema():
|
||||
logger.error('Error in get_plugin_schema', exc_info=True)
|
||||
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'])
|
||||
def reset_plugin_config():
|
||||
"""Reset plugin configuration to schema defaults"""
|
||||
@@ -6891,6 +7125,29 @@ def list_plugin_assets():
|
||||
logger.error('Unhandled exception', exc_info=True)
|
||||
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'])
|
||||
def get_logs():
|
||||
"""Get system logs from journalctl"""
|
||||
|
||||
@@ -397,12 +397,51 @@ def _load_display_partial():
|
||||
return "Error loading partial", 500
|
||||
|
||||
def _load_durations_partial():
|
||||
"""Load display durations partial"""
|
||||
"""Load rotation & 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:
|
||||
if pages_v3.config_manager:
|
||||
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',
|
||||
main_config=main_config)
|
||||
main_config=main_config,
|
||||
duration_groups=duration_groups)
|
||||
except Exception as e:
|
||||
logger.error("Error loading partial", exc_info=True)
|
||||
return "Error loading partial", 500
|
||||
|
||||
@@ -7,6 +7,7 @@ flask>=3.1.3,<4.0.0
|
||||
werkzeug>=3.1.6,<4.0.0
|
||||
flask-wtf>=1.2.0 # CSRF protection (optional for local-only, but recommended)
|
||||
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
|
||||
# Note: Web interface uses Server-Sent Events (SSE) for real-time updates, not WebSockets
|
||||
|
||||
@@ -413,6 +413,9 @@ a, button, input, select, textarea {
|
||||
/* Responsive breakpoints */
|
||||
@media (min-width: 640px) {
|
||||
.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) {
|
||||
@@ -421,6 +424,8 @@ a, button, input, select, textarea {
|
||||
.md\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.md\:flex { display: flex; }
|
||||
.md\:hidden { display: none; }
|
||||
.md\:block { display: block; }
|
||||
.md\:w-auto { width: auto; }
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
@@ -431,9 +436,14 @@ a, button, input, select, textarea {
|
||||
.lg\:px-8 { padding-left: 2rem; padding-right: 2rem; }
|
||||
.lg\:gap-x-3 { column-gap: 0.75rem; }
|
||||
.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) {
|
||||
.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-5 { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
||||
.xl\:grid-cols-6 { grid-template-columns: repeat(6, minmax(0, 1fr)); }
|
||||
@@ -446,6 +456,9 @@ a, button, input, select, textarea {
|
||||
}
|
||||
|
||||
@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-6 { grid-template-columns: repeat(6, minmax(0, 1fr)); }
|
||||
.2xl\:grid-cols-7 { grid-template-columns: repeat(7, minmax(0, 1fr)); }
|
||||
@@ -456,6 +469,129 @@ a, button, input, select, textarea {
|
||||
.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-request .loading {
|
||||
display: inline-block;
|
||||
@@ -1198,6 +1334,21 @@ button.bg-white {
|
||||
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 */
|
||||
.power-warning-banner {
|
||||
background-color: #fef2f2;
|
||||
@@ -1220,3 +1371,44 @@ button.bg-white {
|
||||
[data-theme="dark"] .power-warning-banner-dismiss {
|
||||
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 */
|
||||
/* global showNotification, updateSystemStats, updateDisplayPreview, htmx, debugLog */
|
||||
// LED Matrix v3 JavaScript
|
||||
// Additional helpers for HTMX and Alpine.js integration
|
||||
|
||||
@@ -12,8 +12,8 @@ window.showNotification = function(message, type = 'info') {
|
||||
});
|
||||
document.dispatchEvent(event);
|
||||
} else {
|
||||
// Fallback notification
|
||||
console.log(`${type}: ${message}`);
|
||||
// Fallback notification — user-facing last resort, so never gated
|
||||
console.info(`${type}: ${message}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -49,6 +49,111 @@ document.body.addEventListener('htmx:afterRequest', function(event) {
|
||||
// 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,
|
||||
@@ -246,13 +351,13 @@ window.performanceMonitor = {
|
||||
logMetrics: function() {
|
||||
const metrics = this.getMetrics();
|
||||
console.group('Performance Metrics');
|
||||
console.log('DOM Content Loaded:', metrics.domContentLoaded?.toFixed(2) || 'N/A', 'ms');
|
||||
console.log('Load Complete:', metrics.loadComplete?.toFixed(2) || 'N/A', 'ms');
|
||||
console.log('First Paint:', metrics.firstPaint?.toFixed(2) || 'N/A', 'ms');
|
||||
console.log('First Contentful Paint:', metrics.firstContentfulPaint?.toFixed(2) || 'N/A', 'ms');
|
||||
console.log('Resources:', metrics.resourceCount || 0, 'files,', (metrics.totalResourceSize / 1024).toFixed(2) || '0', 'KB');
|
||||
debugLog('DOM Content Loaded:', metrics.domContentLoaded?.toFixed(2) || 'N/A', 'ms');
|
||||
debugLog('Load Complete:', metrics.loadComplete?.toFixed(2) || 'N/A', 'ms');
|
||||
debugLog('First Paint:', metrics.firstPaint?.toFixed(2) || 'N/A', 'ms');
|
||||
debugLog('First Contentful Paint:', metrics.firstContentfulPaint?.toFixed(2) || 'N/A', 'ms');
|
||||
debugLog('Resources:', metrics.resourceCount || 0, 'files,', (metrics.totalResourceSize / 1024).toFixed(2) || '0', 'KB');
|
||||
if (Object.keys(metrics.measures || {}).length > 0) {
|
||||
console.log('Custom Measures:', metrics.measures);
|
||||
debugLog('Custom Measures:', metrics.measures);
|
||||
}
|
||||
console.groupEnd();
|
||||
}
|
||||
@@ -273,3 +378,170 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}, 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);
|
||||
});
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.4 KiB |
@@ -0,0 +1,356 @@
|
||||
/* 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, '>'); }
|
||||
};
|
||||
};
|
||||
})();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,256 @@
|
||||
/* 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,7 +173,14 @@
|
||||
|
||||
function setActiveTab(tab) {
|
||||
var data = getAppData();
|
||||
if (data) { data.activeTab = tab; return true; }
|
||||
if (data) {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -209,6 +209,7 @@
|
||||
const removeButton = document.createElement('button');
|
||||
removeButton.type = 'button';
|
||||
removeButton.className = 'text-red-600 hover:text-red-800 px-2 py-1';
|
||||
removeButton.setAttribute('aria-label', 'Remove feed');
|
||||
removeButton.addEventListener('click', function() {
|
||||
window.removeCustomFeedRow(this);
|
||||
});
|
||||
@@ -333,6 +334,7 @@
|
||||
const removeButton = document.createElement('button');
|
||||
removeButton.type = 'button';
|
||||
removeButton.className = 'text-red-600 hover:text-red-800 px-2 py-1';
|
||||
removeButton.setAttribute('aria-label', 'Remove feed');
|
||||
removeButton.addEventListener('click', function() {
|
||||
window.removeCustomFeedRow(this);
|
||||
});
|
||||
@@ -404,9 +406,18 @@
|
||||
if (!file) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
// Backend contract (see api_v3.upload_plugin_asset): the request
|
||||
// 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);
|
||||
|
||||
|
||||
fetch('/api/v3/plugins/assets/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
@@ -421,8 +432,8 @@
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (data.status === 'success' && data.data && data.data.files && data.data.files.length > 0) {
|
||||
const uploadedFile = data.data.files[0];
|
||||
if (data.status === 'success' && data.uploaded_files && data.uploaded_files.length > 0) {
|
||||
const uploadedFile = data.uploaded_files[0];
|
||||
const row = document.querySelector(`#${fieldId}_tbody tr[data-index="${index}"]`);
|
||||
if (row) {
|
||||
const logoCell = row.querySelector('td:nth-child(3)');
|
||||
@@ -495,8 +506,6 @@
|
||||
// Append container to logoCell
|
||||
logoCell.appendChild(container);
|
||||
}
|
||||
// Allow re-uploading the same file
|
||||
event.target.value = '';
|
||||
} else {
|
||||
const notifyFn = window.showNotification || alert;
|
||||
notifyFn('Upload failed: ' + (data.message || 'Unknown error'), 'error');
|
||||
@@ -506,6 +515,12 @@
|
||||
console.error('Upload error:', error);
|
||||
const notifyFn = window.showNotification || alert;
|
||||
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,6 +58,10 @@
|
||||
|
||||
// Track active notifications
|
||||
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;
|
||||
|
||||
/**
|
||||
@@ -113,6 +117,7 @@
|
||||
|
||||
// Remove from tracking array
|
||||
activeNotifications = activeNotifications.filter(id => id !== notificationId);
|
||||
actionCallbacks.delete(notificationId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -158,6 +163,20 @@
|
||||
|
||||
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) {
|
||||
html += `
|
||||
<button type="button"
|
||||
@@ -227,6 +246,17 @@
|
||||
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
|
||||
*/
|
||||
@@ -262,9 +292,11 @@
|
||||
}
|
||||
});
|
||||
|
||||
// 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') {
|
||||
return showNotification(message, { type: type });
|
||||
return showNotification(message, typeof type === 'string' ? { type: type } : (type || {}));
|
||||
};
|
||||
|
||||
// Initialize container on load
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* 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 };
|
||||
})();
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "LED Matrix Control",
|
||||
"short_name": "LEDMatrix",
|
||||
"description": "Control panel for the LEDMatrix display",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#111827",
|
||||
"theme_color": "#111827",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/v3/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/static/v3/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(P){var t={pairs:"()[]{}''\"\"",closeBefore:")]}'\":;>",triples:"",explode:"[]{}"},S=P.Pos;function k(e,n){return"pairs"==n&&"string"==typeof e?e:("object"==typeof e&&null!=e[n]?e:t)[n]}P.defineOption("autoCloseBrackets",!1,function(e,n,t){t&&t!=P.Init&&(e.removeKeyMap(i),e.state.closeBrackets=null),n&&(r(k(n,"pairs")),e.state.closeBrackets=n,e.addKeyMap(i))});var i={Backspace:function(e){var n=y(e);if(!n||e.getOption("disableInput"))return P.Pass;for(var t=k(n,"pairs"),r=e.listSelections(),i=0;i<r.length;i++){if(!r[i].empty())return P.Pass;var a=s(e,r[i].head);if(!a||t.indexOf(a)%2!=0)return P.Pass}for(i=r.length-1;0<=i;i--){var o=r[i].head;e.replaceRange("",S(o.line,o.ch-1),S(o.line,o.ch+1),"+delete")}},Enter:function(r){var e=y(r),n=e&&k(e,"explode");if(!n||r.getOption("disableInput"))return P.Pass;for(var i=r.listSelections(),t=0;t<i.length;t++){if(!i[t].empty())return P.Pass;var a=s(r,i[t].head);if(!a||n.indexOf(a)%2!=0)return P.Pass}r.operation(function(){var e=r.lineSeparator()||"\n";r.replaceSelection(e+e,null),O(r,-1),i=r.listSelections();for(var n=0;n<i.length;n++){var t=i[n].head.line;r.indentLine(t,null,!0),r.indentLine(t+1,null,!0)}})}};function r(e){for(var n=0;n<e.length;n++){var t=e.charAt(n),r="'"+t+"'";i[r]||(i[r]=function(n){return function(e){return function(i,e){var n=y(i);if(!n||i.getOption("disableInput"))return P.Pass;var t=k(n,"pairs"),r=t.indexOf(e);if(-1==r)return P.Pass;for(var a,o=k(n,"closeBefore"),s=k(n,"triples"),l=t.charAt(r+1)==e,c=i.listSelections(),f=r%2==0,h=0;h<c.length;h++){var u,d=c[h],p=d.head,g=i.getRange(p,S(p.line,p.ch+1));if(f&&!d.empty())u="surround";else if(!l&&f||g!=e)if(l&&1<p.ch&&0<=s.indexOf(e)&&i.getRange(S(p.line,p.ch-2),p)==e+e){if(2<p.ch&&/\bstring/.test(i.getTokenTypeAt(S(p.line,p.ch-2))))return P.Pass;u="addFour"}else if(l){d=0==p.ch?" ":i.getRange(S(p.line,p.ch-1),p);if(P.isWordChar(g)||d==e||P.isWordChar(d))return P.Pass;u="both"}else{if(!f||!(0===g.length||/\s/.test(g)||-1<o.indexOf(g)))return P.Pass;u="both"}else u=l&&function(e,n){var t=e.getTokenAt(S(n.line,n.ch+1));return/\bstring/.test(t.type)&&t.start==n.ch&&(0==n.ch||!/\bstring/.test(e.getTokenTypeAt(n)))}(i,p)?"both":0<=s.indexOf(e)&&i.getRange(p,S(p.line,p.ch+3))==e+e+e?"skipThree":"skip";if(a){if(a!=u)return P.Pass}else a=u}var v=r%2?t.charAt(r-1):e,b=r%2?e:t.charAt(r+1);i.operation(function(){if("skip"==a)O(i,1);else if("skipThree"==a)O(i,3);else if("surround"==a){for(var e=i.getSelections(),n=0;n<e.length;n++)e[n]=v+e[n]+b;i.replaceSelections(e,"around");for(e=i.listSelections().slice(),n=0;n<e.length;n++)e[n]=(t=e[n],r=void 0,r=0<P.cmpPos(t.anchor,t.head),{anchor:new S(t.anchor.line,t.anchor.ch+(r?-1:1)),head:new S(t.head.line,t.head.ch+(r?1:-1))});i.setSelections(e)}else"both"==a?(i.replaceSelection(v+b,null),i.triggerElectric(v+b),O(i,-1)):"addFour"==a&&(i.replaceSelection(v+v+v+v,"before"),O(i,1));var t,r})}(e,n)}}(t))}}function y(e){var n=e.state.closeBrackets;return n&&!n.override&&e.getModeAt(e.getCursor()).closeBrackets||n}function O(e,n){for(var t=[],r=e.listSelections(),i=0,a=0;a<r.length;a++){var o=r[a];o.head==e.getCursor()&&(i=a);o=o.head.ch||0<n?{line:o.head.line,ch:o.head.ch+n}:{line:o.head.line-1};t.push({anchor:o,head:o})}e.setSelections(t,i)}function s(e,n){n=e.getRange(S(n.line,n.ch-1),S(n.line,n.ch+1));return 2==n.length?n:null}r(t.pairs+"`")});
|
||||
@@ -0,0 +1 @@
|
||||
!function(t){"object"==typeof exports&&"object"==typeof module?t(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],t):t(CodeMirror)}(function(r){var u=/MSIE \d/.test(navigator.userAgent)&&(null==document.documentMode||document.documentMode<8),k=r.Pos,p={"(":")>",")":"(<","[":"]>","]":"[<","{":"}>","}":"{<","<":">>",">":"<<"};function y(t){return t&&t.bracketRegex||/[(){}[\]]/}function f(t,e,n){var r=t.getLineHandle(e.line),i=e.ch-1,c=n&&n.afterCursor;null==c&&(c=/(^| )cm-fat-cursor($| )/.test(t.getWrapperElement().className));var a=y(n),c=!c&&0<=i&&a.test(r.text.charAt(i))&&p[r.text.charAt(i)]||a.test(r.text.charAt(i+1))&&p[r.text.charAt(++i)];if(!c)return null;a=">"==c.charAt(1)?1:-1;if(n&&n.strict&&0<a!=(i==e.ch))return null;r=t.getTokenTypeAt(k(e.line,i+1)),n=o(t,k(e.line,i+(0<a?1:0)),a,r,n);return null==n?null:{from:k(e.line,i),to:n&&n.pos,match:n&&n.ch==c.charAt(0),forward:0<a}}function o(t,e,n,r,i){for(var c=i&&i.maxScanLineLength||1e4,a=i&&i.maxScanLines||1e3,o=[],h=y(i),l=0<n?Math.min(e.line+a,t.lastLine()+1):Math.max(t.firstLine()-1,e.line-a),s=e.line;s!=l;s+=n){var u=t.getLine(s);if(u){var f=0<n?0:u.length-1,m=0<n?u.length:-1;if(!(u.length>c))for(s==e.line&&(f=e.ch-(n<0?1:0));f!=m;f+=n){var g=u.charAt(f);if(h.test(g)&&(void 0===r||(t.getTokenTypeAt(k(s,f+1))||"")==(r||""))){var d=p[g];if(d&&">"==d.charAt(1)==0<n)o.push(g);else{if(!o.length)return{pos:k(s,f),ch:g};o.pop()}}}}}return s-n!=(0<n?t.lastLine():t.firstLine())&&null}function e(t,e,n){for(var r=t.state.matchBrackets.maxHighlightLineLength||1e3,i=n&&n.highlightNonMatching,c=[],a=t.listSelections(),o=0;o<a.length;o++){var h,l=a[o].empty()&&f(t,a[o].head,n);l&&(l.match||!1!==i)&&t.getLine(l.from.line).length<=r&&(h=l.match?"CodeMirror-matchingbracket":"CodeMirror-nonmatchingbracket",c.push(t.markText(l.from,k(l.from.line,l.from.ch+1),{className:h})),l.to&&t.getLine(l.to.line).length<=r&&c.push(t.markText(l.to,k(l.to.line,l.to.ch+1),{className:h})))}if(c.length){u&&t.state.focused&&t.focus();function s(){t.operation(function(){for(var t=0;t<c.length;t++)c[t].clear()})}if(!e)return s;setTimeout(s,800)}}function i(t){t.operation(function(){t.state.matchBrackets.currentlyHighlighted&&(t.state.matchBrackets.currentlyHighlighted(),t.state.matchBrackets.currentlyHighlighted=null),t.state.matchBrackets.currentlyHighlighted=e(t,!1,t.state.matchBrackets)})}function c(t){t.state.matchBrackets&&t.state.matchBrackets.currentlyHighlighted&&(t.state.matchBrackets.currentlyHighlighted(),t.state.matchBrackets.currentlyHighlighted=null)}r.defineOption("matchBrackets",!1,function(t,e,n){n&&n!=r.Init&&(t.off("cursorActivity",i),t.off("focus",i),t.off("blur",c),c(t)),e&&(t.state.matchBrackets="object"==typeof e?e:{},t.on("cursorActivity",i),t.on("focus",i),t.on("blur",c))}),r.defineExtension("matchBrackets",function(){e(this,!0)}),r.defineExtension("findMatchingBracket",function(t,e,n){return f(this,t,e=n||"boolean"==typeof e?n?(n.strict=e,n):e?{strict:!0}:null:e)}),r.defineExtension("scanForBracket",function(t,e,n,r){return o(this,t,e,n,r)})});
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
.cm-s-monokai.CodeMirror{background:#272822;color:#f8f8f2}.cm-s-monokai div.CodeMirror-selected{background:#49483e}.cm-s-monokai .CodeMirror-line::selection,.cm-s-monokai .CodeMirror-line>span::selection,.cm-s-monokai .CodeMirror-line>span>span::selection{background:rgba(73,72,62,.99)}.cm-s-monokai .CodeMirror-line::-moz-selection,.cm-s-monokai .CodeMirror-line>span::-moz-selection,.cm-s-monokai .CodeMirror-line>span>span::-moz-selection{background:rgba(73,72,62,.99)}.cm-s-monokai .CodeMirror-gutters{background:#272822;border-right:0}.cm-s-monokai .CodeMirror-guttermarker{color:#fff}.cm-s-monokai .CodeMirror-guttermarker-subtle{color:#d0d0d0}.cm-s-monokai .CodeMirror-linenumber{color:#d0d0d0}.cm-s-monokai .CodeMirror-cursor{border-left:1px solid #f8f8f0}.cm-s-monokai span.cm-comment{color:#75715e}.cm-s-monokai span.cm-atom{color:#ae81ff}.cm-s-monokai span.cm-number{color:#ae81ff}.cm-s-monokai span.cm-comment.cm-attribute{color:#97b757}.cm-s-monokai span.cm-comment.cm-def{color:#bc9262}.cm-s-monokai span.cm-comment.cm-tag{color:#bc6283}.cm-s-monokai span.cm-comment.cm-type{color:#5998a6}.cm-s-monokai span.cm-attribute,.cm-s-monokai span.cm-property{color:#a6e22e}.cm-s-monokai span.cm-keyword{color:#f92672}.cm-s-monokai span.cm-builtin{color:#66d9ef}.cm-s-monokai span.cm-string{color:#e6db74}.cm-s-monokai span.cm-variable{color:#f8f8f2}.cm-s-monokai span.cm-variable-2{color:#9effff}.cm-s-monokai span.cm-type,.cm-s-monokai span.cm-variable-3{color:#66d9ef}.cm-s-monokai span.cm-def{color:#fd971f}.cm-s-monokai span.cm-bracket{color:#f8f8f2}.cm-s-monokai span.cm-tag{color:#f92672}.cm-s-monokai span.cm-header{color:#ae81ff}.cm-s-monokai span.cm-link{color:#ae81ff}.cm-s-monokai span.cm-error{background:#f92672;color:#f8f8f0}.cm-s-monokai .CodeMirror-activeline-background{background:#373831}.cm-s-monokai .CodeMirror-matchingbracket{text-decoration:underline;color:#fff!important}
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+186
-4255
File diff suppressed because it is too large
Load Diff
@@ -87,7 +87,7 @@ select:focus,input:focus{outline:none;border-color:#3b82f6;box-shadow:0 0 0 3px
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<a href="/v3">Open Full Interface</a>
|
||||
<a href="/">Open Full Interface</a>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
On Raspberry Pi 5: ensure the library was rebuilt from the latest submodule
|
||||
(<code class="bg-yellow-100 px-1 rounded">first_time_install.sh</code>)
|
||||
and try adjusting <strong>GPIO Slowdown</strong> (start at 3, reduce if the display looks dim or choppy).
|
||||
Check the <a href="/v3/logs" class="underline font-medium">Logs tab</a> for the full error.
|
||||
Check the <a href="#" @click.prevent="activeTab = 'logs'" class="underline font-medium">Logs tab</a> for the full error.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
hx-ext="json-enc"
|
||||
hx-headers='{"Content-Type": "application/json"}'
|
||||
hx-swap="none"
|
||||
hx-on:htmx:after-request="showNotification(event.detail.xhr.responseJSON?.message || 'Display settings saved', event.detail.xhr.responseJSON?.status || 'success')"
|
||||
hx-on:htmx:after-request="showDisplaySaveResult(event.detail.xhr)"
|
||||
class="space-y-6"
|
||||
novalidate
|
||||
onsubmit="fixInvalidNumberInputs(this); return true;">
|
||||
@@ -47,7 +47,7 @@
|
||||
name="rows"
|
||||
value="{{ main_config.display.hardware.rows or 32 }}"
|
||||
min="1"
|
||||
max="64"
|
||||
max="128"
|
||||
class="form-control">
|
||||
</div>
|
||||
|
||||
@@ -85,7 +85,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<!-- Live total-resolution readout: width = cols x chain_length, height = rows x parallel -->
|
||||
<p id="display-resolution-readout" class="text-sm text-gray-600 mb-4" aria-live="polite">
|
||||
<i class="fas fa-expand-arrows-alt mr-1 text-gray-400"></i>
|
||||
Your display: <strong id="display-resolution-value">—</strong>
|
||||
<span class="text-gray-400">(columns × chain length wide, rows × parallel tall)</span>
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="form-group" id="setting-display-brightness" data-setting-key="display.hardware.brightness">
|
||||
<label for="brightness" class="block text-sm font-medium text-gray-700">Brightness{{ ui.help_tip('Overall LED brightness (1–100%).\nLower is dimmer, higher is brighter. Recommended: 70–90 indoors, 90–100 in bright rooms.', 'Brightness') }}</label>
|
||||
<div class="flex items-center space-x-2">
|
||||
@@ -109,9 +116,7 @@
|
||||
<option value="regular-pi1" {% if main_config.display.hardware.hardware_mapping == "regular-pi1" %}selected{% endif %}>Regular Pi1</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="form-group" id="setting-display-led_rgb_sequence" data-setting-key="display.hardware.led_rgb_sequence">
|
||||
<label for="led_rgb_sequence" class="block text-sm font-medium text-gray-700">LED RGB Sequence{{ ui.help_tip('Order the panel expects color channels in.\nChange this only if reds/greens/blues look swapped. Default: RGB.', 'LED RGB Sequence') }}</label>
|
||||
<select id="led_rgb_sequence" name="led_rgb_sequence" class="form-control">
|
||||
@@ -123,7 +128,29 @@
|
||||
<option value="BGR" {% if main_config.display.hardware.get('led_rgb_sequence', 'RGB') == "BGR" %}selected{% endif %}>BGR</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Advanced hardware settings: niche-panel and deep tuning fields.
|
||||
Collapsed by default; reuses the same nested-section shell as
|
||||
plugin config forms, so toggleSection() and the settings
|
||||
search's auto-expand both work unchanged. -->
|
||||
<div class="nested-section border border-gray-300 rounded-lg mt-4">
|
||||
<button type="button"
|
||||
class="w-full bg-gray-100 hover:bg-gray-200 px-4 py-3 flex items-center justify-between text-left transition-colors rounded-t-lg"
|
||||
aria-controls="display-section-advanced-hardware"
|
||||
aria-expanded="false"
|
||||
onclick="toggleSection('display-section-advanced-hardware')">
|
||||
<div class="flex-1">
|
||||
<h4 class="font-semibold text-gray-900">
|
||||
<i class="fas fa-sliders-h mr-1 text-gray-500"></i>Advanced Hardware & Display Options (15)
|
||||
</h4>
|
||||
<p class="text-sm text-gray-600 mt-1">Multiplexing, panel variants, PWM tuning, and display options — the defaults work for standard HUB75 panels.</p>
|
||||
</div>
|
||||
<i id="display-section-advanced-hardware-icon" class="fas fa-chevron-right text-gray-500 transition-transform"></i>
|
||||
</button>
|
||||
<div id="display-section-advanced-hardware" class="nested-content bg-gray-50 px-4 py-4 space-y-4 hidden" style="display: none;">
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="form-group" id="setting-display-multiplexing" data-setting-key="display.hardware.multiplexing">
|
||||
<label for="multiplexing" class="block text-sm font-medium text-gray-700">Multiplexing{{ ui.help_tip('Pixel-mapping scheme used by outdoor/specialty panels.\nLeave at 0 (Direct) for most indoor panels. Only change if the image is scrambled — try values until it looks right.', 'Multiplexing') }}</label>
|
||||
<select id="multiplexing" name="multiplexing" class="form-control">
|
||||
@@ -255,47 +282,6 @@
|
||||
class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Double-Sided Display -->
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<h3 class="text-md font-medium text-gray-900 mb-1">Double-Sided Display</h3>
|
||||
<p class="text-sm text-gray-600 mb-4">Show the same content on every panel in the chain — e.g. two 64×32 panels mirrored, or four panels as two identical screens. Rendered once and duplicated, so it adds no extra CPU. Takes effect after a display restart.</p>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="form-group" id="setting-display-double_sided_enabled" data-setting-key="display.double_sided.enabled">
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox"
|
||||
id="double_sided_enabled"
|
||||
name="double_sided_enabled"
|
||||
value="true"
|
||||
{% if main_config.display.get('double_sided', {}).get('enabled') %}checked{% endif %}
|
||||
class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||||
<span class="text-sm font-medium text-gray-700">Enabled</span>
|
||||
{{ ui.help_tip('Show the same content mirrored across every panel in the chain.\nRendered once and duplicated, so it adds no extra CPU. Takes effect after a display restart.', 'Double-Sided Enabled') }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="setting-display-double_sided_copies" data-setting-key="display.double_sided.copies">
|
||||
<label for="double_sided_copies" class="block text-sm font-medium text-gray-700">Copies{{ ui.help_tip('How many identical screens to split the panel area into (2–8).\nMust divide the panel evenly — e.g. 2 for a two-sided cube.', 'Copies') }}</label>
|
||||
<input type="number"
|
||||
id="double_sided_copies"
|
||||
name="double_sided_copies"
|
||||
value="{{ main_config.display.get('double_sided', {}).get('copies', 2) }}"
|
||||
min="2"
|
||||
max="8"
|
||||
class="form-control">
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="setting-display-double_sided_axis" data-setting-key="display.double_sided.axis">
|
||||
<label for="double_sided_axis" class="block text-sm font-medium text-gray-700">Split Axis{{ ui.help_tip('Direction the display is divided into copies.\nHorizontal splits along the chained panels (side by side); Vertical splits along parallel chains (stacked).', 'Split Axis') }}</label>
|
||||
<select id="double_sided_axis" name="double_sided_axis" class="form-control">
|
||||
<option value="horizontal" {% if main_config.display.get('double_sided', {}).get('axis', 'horizontal') == 'horizontal' %}selected{% endif %}>Horizontal — chained panels (side by side)</option>
|
||||
<option value="vertical" {% if main_config.display.get('double_sided', {}).get('axis', 'horizontal') == 'vertical' %}selected{% endif %}>Vertical — parallel chains (stacked)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Display Options -->
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
@@ -368,6 +354,36 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div> <!-- /#display-section-advanced-hardware (nested-content) -->
|
||||
</div> <!-- /advanced hardware nested-section -->
|
||||
|
||||
<script>
|
||||
// Live "Your display: W x H" readout - width = cols x chain_length,
|
||||
// height = rows x parallel (same math as the chain-length tooltip).
|
||||
(function () {
|
||||
const ids = ['rows', 'cols', 'chain_length', 'parallel'];
|
||||
const out = document.getElementById('display-resolution-value');
|
||||
if (!out) return;
|
||||
function recompute() {
|
||||
const v = {};
|
||||
for (const id of ids) {
|
||||
const el = document.getElementById(id);
|
||||
v[id] = el ? parseInt(el.value, 10) : NaN;
|
||||
}
|
||||
if (Object.values(v).some(n => !Number.isFinite(n) || n <= 0)) {
|
||||
out.textContent = '—';
|
||||
return;
|
||||
}
|
||||
out.textContent = (v.cols * v.chain_length) + ' × ' + (v.rows * v.parallel) + ' pixels';
|
||||
}
|
||||
for (const id of ids) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.addEventListener('input', recompute);
|
||||
}
|
||||
recompute();
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
|
||||
<!-- Vegas Scroll Mode Settings -->
|
||||
<div class="bg-gray-50 rounded-lg p-4 mt-6">
|
||||
@@ -454,6 +470,52 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Double-Sided Display -->
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<h3 class="text-md font-medium text-gray-900 mb-1">Double-Sided Display</h3>
|
||||
<p class="text-sm text-gray-600 mb-4">Show the same content on every panel in the chain — e.g. two 64×32 panels mirrored, or four panels as two identical screens. Rendered once and duplicated, so it adds no extra CPU. Takes effect after a display restart.</p>
|
||||
|
||||
<div class="form-group mb-4" id="setting-display-double_sided_enabled" data-setting-key="display.double_sided.enabled">
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox"
|
||||
id="double_sided_enabled"
|
||||
name="double_sided_enabled"
|
||||
value="true"
|
||||
{% if main_config.display.get('double_sided', {}).get('enabled') %}checked{% endif %}
|
||||
class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||||
<span class="text-sm font-medium text-gray-700">Enabled</span>
|
||||
{{ ui.help_tip('Show the same content mirrored across every panel in the chain.\nRendered once and duplicated, so it adds no extra CPU. Takes effect after a display restart.', 'Double-Sided Enabled') }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Hidden (not disabled) when the feature is off, so the values are
|
||||
still submitted and round-trip through a save. -->
|
||||
<div id="double_sided_settings"
|
||||
class="grid grid-cols-1 md:grid-cols-2 gap-4"
|
||||
{% if not main_config.display.get('double_sided', {}).get('enabled') %}style="display: none;"{% endif %}>
|
||||
<div class="form-group" id="setting-display-double_sided_copies" data-setting-key="display.double_sided.copies">
|
||||
<label for="double_sided_copies" class="block text-sm font-medium text-gray-700">Copies{{ ui.help_tip('How many identical screens to split the panel area into (2–8).\nWhen enabled, this must divide the panel evenly — e.g. 2 for a two-sided cube.', 'Copies') }}</label>
|
||||
<input type="number"
|
||||
id="double_sided_copies"
|
||||
name="double_sided_copies"
|
||||
value="{{ main_config.display.get('double_sided', {}).get('copies', 2) }}"
|
||||
min="2"
|
||||
max="8"
|
||||
class="form-control">
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="setting-display-double_sided_axis" data-setting-key="display.double_sided.axis">
|
||||
<label for="double_sided_axis" class="block text-sm font-medium text-gray-700">Split Axis{{ ui.help_tip('Direction the display is divided into copies.\nHorizontal splits along the chained panels (side by side); Vertical splits along parallel chains (stacked).', 'Split Axis') }}</label>
|
||||
<select id="double_sided_axis" name="double_sided_axis" class="form-control">
|
||||
<option value="horizontal" {% if main_config.display.get('double_sided', {}).get('axis', 'horizontal') == 'horizontal' %}selected{% endif %}>Horizontal — chained panels (side by side)</option>
|
||||
<option value="vertical" {% if main_config.display.get('double_sided', {}).get('axis', 'horizontal') == 'vertical' %}selected{% endif %}>Vertical — parallel chains (stacked)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<!-- Multi-Display Sync Settings -->
|
||||
<div class="bg-gray-50 rounded-lg p-4 mt-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
@@ -549,6 +611,28 @@ if (typeof window.fixInvalidNumberInputs !== 'function') {
|
||||
};
|
||||
}
|
||||
|
||||
// Report the outcome of a display-settings save. XMLHttpRequest has no
|
||||
// `responseJSON` (that's a jQuery property) — read `responseText` and the real
|
||||
// status code, otherwise a failed save reports success.
|
||||
window.showDisplaySaveResult = function(xhr) {
|
||||
// Only 2xx counts as saved. A network failure reports status 0, which any
|
||||
// `>= 400` test would wave through as success.
|
||||
const httpSuccess = xhr.status >= 200 && xhr.status < 300;
|
||||
let message = httpSuccess
|
||||
? 'Display settings saved'
|
||||
: 'Display settings were not saved. Check your connection and try again.';
|
||||
let status = httpSuccess ? 'success' : 'error';
|
||||
try {
|
||||
const data = JSON.parse(xhr.responseText);
|
||||
if (data.message) message = data.message;
|
||||
// A body can refine a successful verdict but never overturn a failed one.
|
||||
if (httpSuccess && data.status) status = data.status;
|
||||
} catch {
|
||||
// Non-JSON body — fall back to the status-code verdict above.
|
||||
}
|
||||
showNotification(message, status);
|
||||
};
|
||||
|
||||
// Vegas Scroll Mode Settings
|
||||
(function() {
|
||||
// Escape HTML to prevent XSS
|
||||
@@ -573,6 +657,18 @@ if (typeof window.fixInvalidNumberInputs !== 'function') {
|
||||
});
|
||||
}
|
||||
|
||||
// Double-sided: copies/axis only mean anything while the feature is on.
|
||||
// Hidden rather than disabled so the fields keep submitting and the server
|
||||
// still sees an 'off' state to persist.
|
||||
const doubleSidedCheckbox = document.getElementById('double_sided_enabled');
|
||||
const doubleSidedSettings = document.getElementById('double_sided_settings');
|
||||
|
||||
if (doubleSidedCheckbox && doubleSidedSettings) {
|
||||
doubleSidedCheckbox.addEventListener('change', function() {
|
||||
doubleSidedSettings.style.display = this.checked ? 'grid' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
// Update scroll speed display
|
||||
const scrollSpeedSlider = document.getElementById('vegas_scroll_speed');
|
||||
const scrollSpeedValue = document.getElementById('vegas_scroll_speed_value');
|
||||
@@ -583,183 +679,32 @@ if (typeof window.fixInvalidNumberInputs !== 'function') {
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize plugin order list
|
||||
function initPluginOrderList() {
|
||||
// Initialize plugin order list via the shared drag-and-drop module
|
||||
// (static/v3/js/widgets/plugin-order-list.js) — the same component the
|
||||
// Durations tab uses for the primary rotation order.
|
||||
function initPluginOrderList(attempt) {
|
||||
const container = document.getElementById('vegas_plugin_order');
|
||||
if (!container) return;
|
||||
|
||||
// Fetch available plugins
|
||||
fetch('/api/v3/plugins/installed')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
// Handle both {data: {plugins: []}} and {plugins: []} response formats
|
||||
const allPlugins = data.data?.plugins || data.plugins || [];
|
||||
if (!allPlugins || allPlugins.length === 0) {
|
||||
container.innerHTML = '<p class="text-sm text-gray-500 italic">No plugins available</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current order and exclusions
|
||||
const orderInput = document.getElementById('vegas_plugin_order_value');
|
||||
const excludedInput = document.getElementById('vegas_excluded_plugins_value');
|
||||
let currentOrder = [];
|
||||
let excluded = [];
|
||||
|
||||
try {
|
||||
currentOrder = JSON.parse(orderInput.value || '[]');
|
||||
excluded = JSON.parse(excludedInput.value || '[]');
|
||||
} catch (e) {
|
||||
console.error('Error parsing vegas config:', e);
|
||||
}
|
||||
|
||||
// Build ordered plugin list (only enabled plugins)
|
||||
const plugins = allPlugins.filter(p => p.enabled);
|
||||
const orderedPlugins = [];
|
||||
|
||||
// First add plugins in current order
|
||||
currentOrder.forEach(id => {
|
||||
const plugin = plugins.find(p => p.id === id);
|
||||
if (plugin) orderedPlugins.push(plugin);
|
||||
});
|
||||
|
||||
// Then add remaining plugins
|
||||
plugins.forEach(plugin => {
|
||||
if (!orderedPlugins.find(p => p.id === plugin.id)) {
|
||||
orderedPlugins.push(plugin);
|
||||
}
|
||||
});
|
||||
|
||||
// Build HTML with display mode indicators
|
||||
let html = '';
|
||||
orderedPlugins.forEach((plugin, index) => {
|
||||
const isExcluded = excluded.includes(plugin.id);
|
||||
// Determine display mode (from plugin config or default)
|
||||
const vegasMode = plugin.vegas_mode || plugin.vegas_content_type || 'fixed';
|
||||
const modeLabels = {
|
||||
'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' }
|
||||
};
|
||||
const modeInfo = modeLabels[vegasMode] || modeLabels['fixed'];
|
||||
// Escape plugin metadata to prevent XSS
|
||||
const safePluginId = escapeAttr(plugin.id);
|
||||
const safePluginName = escapeHtml(plugin.name || plugin.id);
|
||||
html += `
|
||||
<div class="flex items-center p-2 bg-gray-50 rounded border border-gray-200 cursor-move vegas-plugin-item"
|
||||
data-plugin-id="${safePluginId}" draggable="true">
|
||||
<i class="fas fa-grip-vertical text-gray-400 mr-3"></i>
|
||||
<label class="flex items-center flex-1">
|
||||
<input type="checkbox"
|
||||
class="vegas-plugin-include h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded mr-2"
|
||||
${!isExcluded ? 'checked' : ''}>
|
||||
<span class="text-sm font-medium text-gray-700">${safePluginName}</span>
|
||||
</label>
|
||||
<span class="text-xs ${modeInfo.color} ml-2" title="Vegas display mode: ${modeInfo.label}">
|
||||
<i class="fas ${modeInfo.icon} mr-1"></i>${modeInfo.label}
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
container.innerHTML = html || '<p class="text-sm text-gray-500 italic">No enabled plugins</p>';
|
||||
|
||||
// Setup drag and drop
|
||||
setupDragAndDrop(container);
|
||||
|
||||
// Setup checkbox handlers
|
||||
container.querySelectorAll('.vegas-plugin-include').forEach(checkbox => {
|
||||
checkbox.addEventListener('change', updatePluginConfig);
|
||||
});
|
||||
|
||||
// Initialize hidden inputs with current state
|
||||
updatePluginConfig();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching plugins:', error);
|
||||
container.innerHTML = '<p class="text-sm text-red-500">Error loading plugins</p>';
|
||||
});
|
||||
}
|
||||
|
||||
function setupDragAndDrop(container) {
|
||||
let draggedItem = null;
|
||||
|
||||
container.querySelectorAll('.vegas-plugin-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;
|
||||
updatePluginConfig();
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function updatePluginConfig() {
|
||||
const container = document.getElementById('vegas_plugin_order');
|
||||
const orderInput = document.getElementById('vegas_plugin_order_value');
|
||||
const excludedInput = document.getElementById('vegas_excluded_plugins_value');
|
||||
|
||||
if (!container || !orderInput || !excludedInput) return;
|
||||
|
||||
const order = [];
|
||||
const excluded = [];
|
||||
|
||||
container.querySelectorAll('.vegas-plugin-item').forEach(item => {
|
||||
const pluginId = item.dataset.pluginId;
|
||||
const checkbox = item.querySelector('.vegas-plugin-include');
|
||||
|
||||
order.push(pluginId);
|
||||
if (checkbox && !checkbox.checked) {
|
||||
excluded.push(pluginId);
|
||||
if (!window.PluginOrderList) {
|
||||
// Widget script is deferred; retry briefly, then surface a real
|
||||
// error instead of waiting forever.
|
||||
if ((attempt || 0) < 50) {
|
||||
setTimeout(function() { initPluginOrderList((attempt || 0) + 1); }, 100);
|
||||
} else {
|
||||
container.textContent = 'Could not load the reorder widget — reload the page to try again.';
|
||||
container.className = 'text-sm text-red-500';
|
||||
}
|
||||
return;
|
||||
}
|
||||
window.PluginOrderList.init({
|
||||
containerId: 'vegas_plugin_order',
|
||||
orderInputId: 'vegas_plugin_order_value',
|
||||
excludedInputId: 'vegas_excluded_plugins_value',
|
||||
showVegasModeBadge: true
|
||||
});
|
||||
|
||||
orderInput.value = JSON.stringify(order);
|
||||
excludedInput.value = JSON.stringify(excluded);
|
||||
}
|
||||
|
||||
|
||||
// Initialize on DOM ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initPluginOrderList);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{% import 'v3/partials/_macros.html' as ui %}
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="border-b border-gray-200 pb-4 mb-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Display Durations</h2>
|
||||
<p class="mt-1 text-sm text-gray-600">Configure how long each screen is shown before switching. Values in seconds.</p>
|
||||
<h2 class="text-lg font-semibold text-gray-900">Rotation & Durations</h2>
|
||||
<p class="mt-1 text-sm text-gray-600">Set the order plugins rotate on the display and how long each screen is shown. Durations are in seconds.</p>
|
||||
</div>
|
||||
|
||||
{{ ui.settings_filter() }}
|
||||
@@ -16,22 +16,53 @@
|
||||
novalidate
|
||||
onsubmit="fixInvalidNumberInputs(this); return true;">
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{% for key, value in main_config.display.display_durations.items() %}
|
||||
<div class="form-group" id="setting-durations-{{ key }}" data-setting-key="display.display_durations.{{ key }}">
|
||||
<label for="duration_{{ key }}" class="block text-sm font-medium text-gray-700">
|
||||
{{ key | replace('_', ' ') | title }}{{ ui.help_tip('How long the ' ~ (key | replace('_', ' ')) ~ ' screen stays on before rotating to the next one, in seconds.\nRange: 5–600. Currently ' ~ value ~ 's.', key | replace('_', ' ') | title) }}
|
||||
</label>
|
||||
<input type="number"
|
||||
id="duration_{{ key }}"
|
||||
name="{{ key }}"
|
||||
value="{{ value }}"
|
||||
min="5"
|
||||
max="600"
|
||||
class="form-control">
|
||||
<!-- Primary rotation order: drag to reorder which plugin shows first,
|
||||
second, ... in the normal display rotation. Saved as
|
||||
display.plugin_rotation_order and applied by the display
|
||||
controller on startup and live plugin enable/disable. -->
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<h3 class="text-md font-medium text-gray-900 mb-1">Rotation Order</h3>
|
||||
<p class="text-sm text-gray-600 mb-3">Drag plugins to set the order they rotate on the display. Each plugin's screens keep their own order within its turn. Takes effect after saving and restarting the display.</p>
|
||||
<div id="rotation_plugin_order" class="space-y-2 bg-white rounded-lg p-3 border border-gray-200">
|
||||
<p class="text-sm text-gray-500 italic">Loading plugins…</p>
|
||||
</div>
|
||||
<input type="hidden" id="rotation_plugin_order_value" name="plugin_rotation_order"
|
||||
value='{{ main_config.display.get("plugin_rotation_order", [])|tojson }}'>
|
||||
</div>
|
||||
|
||||
{% if duration_groups %}
|
||||
<div class="bg-gray-50 rounded-lg p-4 space-y-5">
|
||||
<div>
|
||||
<h3 class="text-md font-medium text-gray-900 mb-1">Screen Durations</h3>
|
||||
<p class="text-sm text-gray-600">How long each screen stays on before rotating to the next one, in seconds (5–600, default 30).</p>
|
||||
</div>
|
||||
{% for group in duration_groups %}
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-gray-800 mb-2">{{ group.plugin_name }}</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{% for mode in group.modes %}
|
||||
<div class="form-group" id="setting-durations-{{ mode.key }}" data-setting-key="display.display_durations.{{ mode.key }}">
|
||||
<label for="duration__{{ mode.key }}" class="block text-sm font-medium text-gray-700">
|
||||
{{ mode.key | replace('_', ' ') | title }}{{ ui.help_tip('How long the ' ~ (mode.key | replace('_', ' ')) ~ ' screen stays on before rotating to the next one, in seconds.\nRange: 5–600. Currently ' ~ mode.value ~ 's.', mode.key | replace('_', ' ') | title) }}
|
||||
</label>
|
||||
<input type="number"
|
||||
id="duration__{{ mode.key }}"
|
||||
name="duration__{{ mode.key }}"
|
||||
value="{{ mode.value }}"
|
||||
min="5"
|
||||
max="600"
|
||||
class="form-control">
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<p class="text-sm text-gray-500 italic">No enabled plugins found — enable a plugin in the Plugin Manager to set its screen durations here.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div class="flex justify-end">
|
||||
@@ -43,3 +74,34 @@
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
// Shared drag-and-drop plugin list (static/v3/js/widgets/plugin-order-list.js,
|
||||
// same module the Vegas Scroll section uses).
|
||||
function initRotationOrderList(attempt) {
|
||||
const container = document.getElementById('rotation_plugin_order');
|
||||
if (!container) return;
|
||||
if (!window.PluginOrderList) {
|
||||
// Widget script is deferred; retry briefly, then surface a real
|
||||
// error instead of showing "Loading…" forever.
|
||||
if ((attempt || 0) < 50) {
|
||||
setTimeout(function() { initRotationOrderList((attempt || 0) + 1); }, 100);
|
||||
} else {
|
||||
container.textContent = 'Could not load the reorder widget — reload the page to try again.';
|
||||
container.className = 'text-sm text-red-500';
|
||||
}
|
||||
return;
|
||||
}
|
||||
window.PluginOrderList.init({
|
||||
containerId: 'rotation_plugin_order',
|
||||
orderInputId: 'rotation_plugin_order_value'
|
||||
});
|
||||
}
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initRotationOrderList);
|
||||
} else {
|
||||
initRotationOrderList();
|
||||
}
|
||||
}());
|
||||
</script>
|
||||
|
||||
@@ -4,6 +4,17 @@
|
||||
<p class="mt-1 text-sm text-gray-600">View real-time logs from the LED matrix service for troubleshooting.</p>
|
||||
</div>
|
||||
|
||||
<!-- Currently displayed plugin -->
|
||||
<div id="current-plugin-banner" class="hidden mb-4 flex items-center gap-2 text-sm bg-blue-50 border border-blue-200 text-blue-800 rounded-lg px-3 py-2">
|
||||
<i class="fas fa-tv"></i>
|
||||
<span>Now showing: <strong id="current-plugin-mode">-</strong>
|
||||
<span id="current-plugin-id-wrap" class="hidden">(plugin: <code id="current-plugin-id" class="bg-blue-100 px-1 rounded"></code>)</span>
|
||||
</span>
|
||||
<button id="current-plugin-filter-btn" class="hidden ml-auto btn bg-blue-600 hover:bg-blue-700 text-white px-2 py-0.5 rounded text-xs">
|
||||
Filter to this plugin
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="flex flex-wrap items-center justify-between gap-4 mb-6">
|
||||
<div class="flex items-center space-x-4">
|
||||
@@ -27,6 +38,11 @@
|
||||
<option value="INFO">Info & Above</option>
|
||||
</select>
|
||||
|
||||
<!-- Plugin Filter -->
|
||||
<select id="log-plugin-filter" class="form-control text-sm">
|
||||
<option value="">All Plugins</option>
|
||||
</select>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="relative">
|
||||
<input type="text" id="log-search" placeholder="Search logs..." class="form-control text-sm pl-8 pr-4 py-1 w-48">
|
||||
@@ -139,6 +155,7 @@ window._filteredLogs = [];
|
||||
const realtimeToggle = document.getElementById('log-realtime-toggle');
|
||||
const refreshBtn = document.getElementById('refresh-logs-btn');
|
||||
const levelFilter = document.getElementById('log-level-filter');
|
||||
const pluginFilter = document.getElementById('log-plugin-filter');
|
||||
const searchInput = document.getElementById('log-search');
|
||||
const autoscrollToggle = document.getElementById('log-autoscroll');
|
||||
const clearBtn = document.getElementById('clear-logs-btn');
|
||||
@@ -160,6 +177,11 @@ window._filteredLogs = [];
|
||||
levelFilter.parentNode.replaceChild(newFilter, levelFilter);
|
||||
newFilter.addEventListener('change', filterLogs);
|
||||
}
|
||||
if (pluginFilter) {
|
||||
const newFilter = pluginFilter.cloneNode(true);
|
||||
pluginFilter.parentNode.replaceChild(newFilter, pluginFilter);
|
||||
newFilter.addEventListener('change', filterLogs);
|
||||
}
|
||||
if (searchInput) {
|
||||
const newInput = searchInput.cloneNode(true);
|
||||
searchInput.parentNode.replaceChild(newInput, searchInput);
|
||||
@@ -180,6 +202,24 @@ window._filteredLogs = [];
|
||||
downloadBtn.parentNode.replaceChild(newBtn, downloadBtn);
|
||||
newBtn.addEventListener('click', downloadLogs);
|
||||
}
|
||||
const currentPluginFilterBtn = document.getElementById('current-plugin-filter-btn');
|
||||
if (currentPluginFilterBtn) {
|
||||
const newBtn = currentPluginFilterBtn.cloneNode(true);
|
||||
currentPluginFilterBtn.parentNode.replaceChild(newBtn, currentPluginFilterBtn);
|
||||
newBtn.addEventListener('click', function() {
|
||||
const pluginFilterEl = document.getElementById('log-plugin-filter');
|
||||
if (pluginFilterEl && window._currentPluginId) {
|
||||
pluginFilterEl.value = window._currentPluginId;
|
||||
filterLogs();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
refreshCurrentPluginStatus();
|
||||
if (window._currentPluginPollTimer) {
|
||||
clearInterval(window._currentPluginPollTimer);
|
||||
}
|
||||
window._currentPluginPollTimer = setInterval(refreshCurrentPluginStatus, 5000);
|
||||
|
||||
// Handle window resize for responsive height
|
||||
window.addEventListener('resize', function() {
|
||||
@@ -284,37 +324,25 @@ function processLogs(logsText, append = false) {
|
||||
// Skip empty lines
|
||||
if (!line.trim()) return;
|
||||
|
||||
// Try to parse journalctl format: "MMM DD HH:MM:SS hostname service[pid]: message"
|
||||
// Example: "Oct 13 14:23:45 raspberrypi ledmatrix[1234]: INFO: Starting display"
|
||||
|
||||
// journalctl (--output=short-iso) emits: "YYYY-MM-DDTHH:MM:SS+ZZZZ hostname service[pid]: message"
|
||||
// Example: "2024-01-15T10:23:45+0000 raspberrypi ledmatrix[1234]: INFO - plugin.nhl_scoreboard - [Plugin: nhl_scoreboard] Updated scores"
|
||||
// Also accept the older syslog-style "MMM DD HH:MM:SS" timestamp for compatibility.
|
||||
|
||||
let timestamp = '';
|
||||
let level = 'INFO';
|
||||
let message = line;
|
||||
|
||||
// Extract timestamp (first part before hostname)
|
||||
const timestampMatch = line.match(/^([A-Z][a-z]{2}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2})/);
|
||||
if (timestampMatch) {
|
||||
timestamp = timestampMatch[1];
|
||||
|
||||
// Find the message part (after service name and pid)
|
||||
const messageMatch = line.match(/:\s*(.+)$/);
|
||||
if (messageMatch) {
|
||||
message = messageMatch[1];
|
||||
|
||||
// Detect log level from message
|
||||
if (message.match(/\b(ERROR|CRITICAL|FATAL)\b/i)) {
|
||||
level = 'ERROR';
|
||||
} else if (message.match(/\b(WARNING|WARN)\b/i)) {
|
||||
level = 'WARNING';
|
||||
} else if (message.match(/\bDEBUG\b/i)) {
|
||||
level = 'DEBUG';
|
||||
} else if (message.match(/\bINFO\b/i)) {
|
||||
level = 'INFO';
|
||||
}
|
||||
|
||||
// Clean up level prefix from message if it exists
|
||||
message = message.replace(/^(ERROR|WARNING|WARN|INFO|DEBUG):\s*/i, '');
|
||||
}
|
||||
let plugin = '';
|
||||
let rest = null;
|
||||
|
||||
const isoMatch = line.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[+-]\d{2}:?\d{2}|Z))\s+(.*)$/);
|
||||
const syslogMatch = !isoMatch && line.match(/^([A-Z][a-z]{2}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2})\s+(.*)$/);
|
||||
|
||||
if (isoMatch) {
|
||||
timestamp = isoMatch[1];
|
||||
rest = isoMatch[2];
|
||||
} else if (syslogMatch) {
|
||||
timestamp = syslogMatch[1];
|
||||
rest = syslogMatch[2];
|
||||
} else {
|
||||
// If no timestamp, use current time
|
||||
timestamp = new Date().toLocaleString('en-US', {
|
||||
@@ -327,14 +355,58 @@ function processLogs(logsText, append = false) {
|
||||
});
|
||||
}
|
||||
|
||||
if (rest !== null) {
|
||||
// Find the message part (after hostname + service name/pid)
|
||||
const messageMatch = rest.match(/:\s*(.+)$/);
|
||||
if (messageMatch) {
|
||||
message = messageMatch[1];
|
||||
|
||||
// Detect log level from message
|
||||
if (message.match(/\b(ERROR|CRITICAL|FATAL)\b/i)) {
|
||||
level = 'ERROR';
|
||||
} else if (message.match(/\b(WARNING|WARN)\b/i)) {
|
||||
level = 'WARNING';
|
||||
} else if (message.match(/\bDEBUG\b/i)) {
|
||||
level = 'DEBUG';
|
||||
} else if (message.match(/\bINFO\b/i)) {
|
||||
level = 'INFO';
|
||||
}
|
||||
|
||||
// Clean up level prefix from message if it exists
|
||||
message = message.replace(/^(ERROR|WARNING|WARN|INFO|DEBUG)\s*[-:]\s*/i, '');
|
||||
|
||||
// journalctl already carries its own timestamp; strip the app's
|
||||
// internal "YYYY-MM-DD HH:MM:SS.mmm - LEVEL - logger.name - "
|
||||
// prefix (see ContextualFormatter in src/logging_config.py) so
|
||||
// it isn't duplicated in the displayed message.
|
||||
message = message.replace(
|
||||
/^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?\s*-\s*(ERROR|WARNING|WARN|INFO|DEBUG|CRITICAL)\s*-\s*[\w.]+\s*-\s*/i,
|
||||
''
|
||||
);
|
||||
|
||||
// Extract the "[Plugin: <id>]" context tag emitted by
|
||||
// ContextualFormatter for every plugin logger (see
|
||||
// src/logging_config.py PluginLoggerAdapter), and pull it out
|
||||
// of the displayed message into its own field.
|
||||
const pluginMatch = message.match(/^\[Plugin:\s*([^\]]+)\]\s*/);
|
||||
if (pluginMatch) {
|
||||
plugin = pluginMatch[1].trim();
|
||||
message = message.replace(pluginMatch[0], '');
|
||||
}
|
||||
} else {
|
||||
message = rest;
|
||||
}
|
||||
}
|
||||
|
||||
const logEntry = {
|
||||
timestamp: timestamp,
|
||||
level: level,
|
||||
plugin: plugin,
|
||||
message: message,
|
||||
raw: line,
|
||||
id: Date.now() + Math.random()
|
||||
};
|
||||
|
||||
|
||||
// Don't add duplicate entries when appending
|
||||
if (!append || !window._allLogs.find(log => log.raw === line)) {
|
||||
window._allLogs.push(logEntry);
|
||||
@@ -346,9 +418,26 @@ function processLogs(logsText, append = false) {
|
||||
window._allLogs = window._allLogs.slice(-window._MAX_LOGS);
|
||||
}
|
||||
|
||||
updatePluginFilterOptions();
|
||||
filterLogs();
|
||||
}
|
||||
|
||||
function updatePluginFilterOptions() {
|
||||
const pluginFilterEl = document.getElementById('log-plugin-filter');
|
||||
if (!pluginFilterEl) return;
|
||||
|
||||
const previousValue = pluginFilterEl.value;
|
||||
const plugins = Array.from(new Set(window._allLogs.map(log => log.plugin).filter(Boolean))).sort();
|
||||
|
||||
pluginFilterEl.innerHTML = '<option value="">All Plugins</option>' +
|
||||
plugins.map(p => `<option value="${escapeHtml(p)}">${escapeHtml(p)}</option>`).join('');
|
||||
|
||||
// Restore previous selection if it's still a valid option
|
||||
if (previousValue && plugins.includes(previousValue)) {
|
||||
pluginFilterEl.value = previousValue;
|
||||
}
|
||||
}
|
||||
|
||||
function renderLogs() {
|
||||
if (window._filteredLogs.length === 0) {
|
||||
showEmptyState();
|
||||
@@ -364,10 +453,14 @@ function renderLogs() {
|
||||
window._filteredLogs.forEach(log => {
|
||||
const logElement = document.createElement('div');
|
||||
logElement.className = `log-entry py-1 px-2 hover:bg-gray-800 rounded transition-colors duration-150 ${getLogLevelClass(log.level)}`;
|
||||
const pluginBadge = log.plugin
|
||||
? `<span class="log-plugin flex-shrink-0 px-2 py-0.5 rounded text-xs font-semibold bg-purple-700 text-white" title="Plugin: ${escapeHtml(log.plugin)}">${escapeHtml(log.plugin)}</span>`
|
||||
: '';
|
||||
logElement.innerHTML = `
|
||||
<div class="flex items-start gap-3 text-xs font-mono">
|
||||
<span class="log-timestamp text-gray-400 flex-shrink-0 w-32">${escapeHtml(log.timestamp)}</span>
|
||||
<span class="log-level flex-shrink-0 px-2 py-0.5 rounded text-xs font-semibold ${getLogLevelBadgeClass(log.level)}">${log.level}</span>
|
||||
${pluginBadge}
|
||||
<span class="log-message flex-1 ${getLogLevelTextClass(log.level)} break-words">${escapeHtml(log.message)}</span>
|
||||
</div>
|
||||
`;
|
||||
@@ -410,10 +503,12 @@ function getLogLevelTextClass(level) {
|
||||
|
||||
function filterLogs() {
|
||||
const levelFilterEl = document.getElementById('log-level-filter');
|
||||
const pluginFilterEl = document.getElementById('log-plugin-filter');
|
||||
const searchEl = document.getElementById('log-search');
|
||||
if (!levelFilterEl || !searchEl) return;
|
||||
|
||||
|
||||
const levelFilter = levelFilterEl.value;
|
||||
const pluginFilter = pluginFilterEl ? pluginFilterEl.value : '';
|
||||
const searchTerm = searchEl.value.toLowerCase();
|
||||
|
||||
window._filteredLogs = window._allLogs.filter(log => {
|
||||
@@ -430,8 +525,14 @@ function filterLogs() {
|
||||
}
|
||||
}
|
||||
|
||||
// Plugin filter
|
||||
if (pluginFilter && log.plugin !== pluginFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Search filter
|
||||
if (searchTerm && !log.message.toLowerCase().includes(searchTerm)) {
|
||||
if (searchTerm && !log.message.toLowerCase().includes(searchTerm) &&
|
||||
!(log.plugin && log.plugin.toLowerCase().includes(searchTerm))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -617,10 +718,45 @@ function escapeHtml(text) {
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function refreshCurrentPluginStatus() {
|
||||
fetch('/api/v3/display/current-status')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.status !== 'success' || !data.data) return;
|
||||
const state = data.data;
|
||||
const banner = document.getElementById('current-plugin-banner');
|
||||
const modeEl = document.getElementById('current-plugin-mode');
|
||||
const idWrap = document.getElementById('current-plugin-id-wrap');
|
||||
const idEl = document.getElementById('current-plugin-id');
|
||||
const filterBtn = document.getElementById('current-plugin-filter-btn');
|
||||
if (!banner || !modeEl) return;
|
||||
|
||||
window._currentPluginId = state.plugin_id || null;
|
||||
|
||||
modeEl.textContent = state.mode || 'unknown';
|
||||
if (state.plugin_id) {
|
||||
idEl.textContent = state.plugin_id;
|
||||
idWrap.classList.remove('hidden');
|
||||
if (filterBtn) filterBtn.classList.remove('hidden');
|
||||
} else {
|
||||
idWrap.classList.add('hidden');
|
||||
if (filterBtn) filterBtn.classList.add('hidden');
|
||||
}
|
||||
banner.classList.remove('hidden');
|
||||
})
|
||||
.catch(() => {
|
||||
// Silently ignore - banner just stays hidden/stale
|
||||
});
|
||||
}
|
||||
|
||||
// Cleanup on page unload
|
||||
window.addEventListener('beforeunload', function() {
|
||||
if (window._logsEventSource) {
|
||||
window._logsEventSource.close();
|
||||
}
|
||||
if (window._currentPluginPollTimer) {
|
||||
clearInterval(window._currentPluginPollTimer);
|
||||
window._currentPluginPollTimer = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -61,6 +61,149 @@
|
||||
}());
|
||||
</script>
|
||||
|
||||
<!-- Getting Started checklist: non-gating, dismissible (localStorage), items
|
||||
auto-check from existing config/endpoints — no new persisted state.
|
||||
Known heuristic limits (acceptable, disclosed): values left at legitimate
|
||||
defaults (e.g. a user actually in Tampa) read as "not done". -->
|
||||
{% set _hw = main_config.display.hardware if main_config and main_config.display else {} %}
|
||||
{% set _hw_done = (_hw.rows or 0) > 0 and (_hw.cols or 0) > 0 and (_hw.chain_length or 0) > 0 %}
|
||||
{% set _loc = main_config.location if main_config and main_config.location else {} %}
|
||||
{% set _loc_done = (main_config.timezone and main_config.timezone != 'America/New_York')
|
||||
or (_loc.city and _loc.city != 'Tampa') %}
|
||||
<div id="getting-started-card" class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4" style="display:none" role="region" aria-label="Getting started checklist">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-semibold text-blue-900"><i class="fas fa-rocket mr-1"></i>Getting Started</p>
|
||||
<p class="text-xs text-blue-700 mt-0.5 mb-2">A few steps to get your display up and running. Click a step to jump there, or click its checkbox to mark it done yourself. The card hides once everything is checked.</p>
|
||||
<ul class="space-y-1 text-sm" id="getting-started-items">
|
||||
<li><button type="button" class="gs-item text-left w-full" data-done="{{ '1' if _hw_done else '0' }}" data-tab="display">
|
||||
<i class="far fa-square mr-2"></i>Set your panel size (Display tab)</button></li>
|
||||
<li><button type="button" class="gs-item text-left w-full" data-done="{{ '1' if _loc_done else '0' }}" data-tab="general">
|
||||
<i class="far fa-square mr-2"></i>Set your timezone and location (General tab)</button></li>
|
||||
<li><button type="button" class="gs-item text-left w-full" data-done="0" data-check="installed" data-tab="plugins">
|
||||
<i class="far fa-square mr-2"></i>Install a plugin from the Plugin Store</button></li>
|
||||
<li><button type="button" class="gs-item text-left w-full" data-done="0" data-check="enabled" data-tab="plugins">
|
||||
<i class="far fa-square mr-2"></i>Enable a plugin</button></li>
|
||||
<li><button type="button" class="gs-item text-left w-full" data-done="0" data-check="configured" data-tab="plugins">
|
||||
<i class="far fa-square mr-2"></i>Configure it (each plugin gets its own tab)</button></li>
|
||||
</ul>
|
||||
</div>
|
||||
<button type="button" onclick="window.dismissGettingStarted()" class="ml-4 flex-shrink-0 text-blue-400 hover:text-blue-600" aria-label="Dismiss getting started checklist">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
var KEY = 'ledmatrix-getting-started-dismissed';
|
||||
var MANUAL_KEY = 'ledmatrix-getting-started-manual';
|
||||
var card = document.getElementById('getting-started-card');
|
||||
if (!card) return;
|
||||
try { if (localStorage.getItem(KEY) === '1') return; } catch (e) {}
|
||||
card.style.display = 'block';
|
||||
|
||||
// Manual per-item overrides: the auto-detection is heuristic (a value
|
||||
// saved AT its default — e.g. a user genuinely in the default timezone —
|
||||
// reads as "not done"), so clicking an item's checkbox marks it done by
|
||||
// hand, persisted per browser.
|
||||
var manual = {};
|
||||
try { manual = JSON.parse(localStorage.getItem(MANUAL_KEY) || '{}') || {}; } catch (e) {}
|
||||
function saveManual() {
|
||||
try { localStorage.setItem(MANUAL_KEY, JSON.stringify(manual)); } catch (e) {}
|
||||
}
|
||||
|
||||
function setDone(btn, done) {
|
||||
btn.dataset.done = done ? '1' : '0';
|
||||
var icon = btn.querySelector('i');
|
||||
if (icon) { icon.className = done ? 'fas fa-check-square mr-2 text-green-600' : 'far fa-square mr-2'; }
|
||||
btn.classList.toggle('text-gray-500', done);
|
||||
btn.classList.toggle('line-through', done);
|
||||
}
|
||||
|
||||
// Once every step is done (auto-detected or manually checked), the card
|
||||
// has served its purpose — hide it without requiring an explicit dismiss.
|
||||
function maybeAutoHide() {
|
||||
var items = card.querySelectorAll('.gs-item');
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
if (items[i].dataset.done !== '1') return;
|
||||
}
|
||||
card.style.display = 'none';
|
||||
}
|
||||
|
||||
function markDone(btn) {
|
||||
if (!btn) return;
|
||||
setDone(btn, true);
|
||||
maybeAutoHide();
|
||||
}
|
||||
|
||||
// Apply server-derived + manual states, wire deep links (same app-data
|
||||
// access pattern as settings-search.js). Clicking the checkbox icon
|
||||
// toggles manual done; clicking the text deep-links to the tab.
|
||||
Array.prototype.forEach.call(card.querySelectorAll('.gs-item'), function (btn, idx) {
|
||||
if (manual[idx] === 1) btn.dataset.done = '1';
|
||||
if (btn.dataset.done === '1') setDone(btn, true);
|
||||
btn.addEventListener('click', function (ev) {
|
||||
var icon = btn.querySelector('i');
|
||||
if (icon && ev.target === icon) {
|
||||
var nowDone = btn.dataset.done !== '1';
|
||||
setDone(btn, nowDone);
|
||||
manual[idx] = nowDone ? 1 : 0;
|
||||
saveManual();
|
||||
if (nowDone) maybeAutoHide();
|
||||
return;
|
||||
}
|
||||
var appEl = document.querySelector('[x-data="app()"]') || document.querySelector('[x-data]');
|
||||
// Same two-tier resolution as settings-search.js's getAppData():
|
||||
// _x_dataStack on current Alpine, __x.$data as an older-API fallback.
|
||||
var data = appEl && ((appEl._x_dataStack && appEl._x_dataStack[0]) ||
|
||||
(appEl.__x && appEl.__x.$data));
|
||||
if (data) {
|
||||
data.activeTab = btn.dataset.tab;
|
||||
if ('mobileNavOpen' in data) data.mobileNavOpen = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
maybeAutoHide();
|
||||
|
||||
// Plugin-derived states from the existing installed-plugins endpoint.
|
||||
fetch('/api/v3/plugins/installed')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (resp) {
|
||||
var plugins = (resp.data && resp.data.plugins) || [];
|
||||
if (plugins.length > 0) markDone(card.querySelector('[data-check="installed"]'));
|
||||
var enabled = plugins.filter(function (p) { return p.enabled; });
|
||||
if (enabled.length > 0) {
|
||||
markDone(card.querySelector('[data-check="enabled"]'));
|
||||
// "Configured" heuristic: the first enabled plugin has at least
|
||||
// one saved value that differs from its schema defaults.
|
||||
var pid = enabled[0].id;
|
||||
Promise.all([
|
||||
fetch('/api/v3/plugins/config?plugin_id=' + encodeURIComponent(pid)).then(function (r) { return r.json(); }),
|
||||
fetch('/api/v3/plugins/schema?plugin_id=' + encodeURIComponent(pid)).then(function (r) { return r.json(); })
|
||||
]).then(function (res) {
|
||||
// GET /plugins/config returns the config dict directly in .data
|
||||
var cfg = res[0].data || {};
|
||||
var props = (res[1].data && res[1].data.schema && res[1].data.schema.properties) || {};
|
||||
for (var k in cfg) {
|
||||
if (k === 'enabled' || !(k in props)) continue;
|
||||
if (props[k] && 'default' in props[k] &&
|
||||
JSON.stringify(cfg[k]) !== JSON.stringify(props[k]['default'])) {
|
||||
markDone(card.querySelector('[data-check="configured"]'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}).catch(function () {});
|
||||
}
|
||||
})
|
||||
.catch(function () {});
|
||||
|
||||
window.dismissGettingStarted = function () {
|
||||
card.style.display = 'none';
|
||||
try { localStorage.setItem(KEY, '1'); } catch (e) {}
|
||||
};
|
||||
}());
|
||||
</script>
|
||||
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="border-b border-gray-200 pb-4 mb-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900">System Overview</h2>
|
||||
@@ -221,7 +364,10 @@
|
||||
<h3 class="text-md font-medium text-gray-900 mb-4">
|
||||
<i class="fas fa-desktop"></i> Live Display Preview
|
||||
</h3>
|
||||
<div class="bg-gray-900 rounded-lg p-6 border border-gray-700" style="position: relative;">
|
||||
<!-- overflow-x-auto: on narrow screens a wide preview scrolls at its
|
||||
true pixel-perfect size instead of being squeezed (fractional
|
||||
downscaling of pixel art reads as blur) -->
|
||||
<div class="bg-gray-900 rounded-lg p-6 border border-gray-700 overflow-x-auto" style="position: relative;">
|
||||
<div id="previewStage" class="preview-stage" style="display:none; position:relative; display:inline-block;">
|
||||
<div id="previewMeta" style="position:absolute; top:-28px; left:0; color:#ddd; font-size:12px; opacity:0.85;"></div>
|
||||
<img id="displayImage" style="image-rendering: pixelated; display: block;" alt="LED Matrix Display">
|
||||
|
||||
@@ -893,6 +893,12 @@
|
||||
<p class="mt-1 text-sm text-gray-600">{{ plugin.description or 'Plugin configuration' }}</p>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<button type="button"
|
||||
onclick="window.previewPluginNow('{{ plugin.id }}')"
|
||||
class="btn bg-blue-600 hover:bg-blue-700 text-white px-3 py-1.5 text-sm rounded-md"
|
||||
title="Run this plugin on the display for 60 seconds and open the live preview">
|
||||
<i class="fas fa-play mr-1"></i>Preview on display
|
||||
</button>
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input type="checkbox"
|
||||
id="plugin-enabled-{{ plugin.id }}"
|
||||
@@ -1005,13 +1011,53 @@
|
||||
{# Use property order if defined, otherwise use natural order #}
|
||||
{# Skip 'enabled' field - it's handled by the header toggle #}
|
||||
{% set property_order = schema['x-propertyOrder'] if 'x-propertyOrder' in schema else schema.properties.keys()|list %}
|
||||
{# Flat (non-object) properties flagged "x-advanced": true are
|
||||
grouped into one collapsed "Advanced Settings" section after
|
||||
the basic fields. Object-type properties already render as
|
||||
their own collapsible sections, so the flag is ignored for
|
||||
them. Schemas without the flag render exactly as before. #}
|
||||
{% set tiers = namespace(basic=[], advanced=[]) %}
|
||||
{% for key in property_order %}
|
||||
{% if key in schema.properties and key != 'enabled' %}
|
||||
{% set prop = schema.properties[key] %}
|
||||
{% set value = config[key] if key in config else none %}
|
||||
{{ render_field(key, prop, value, '', plugin.id) }}
|
||||
{% set is_object = prop.type is defined and 'object' in prop.type %}
|
||||
{% if prop.get('x-advanced') and not is_object %}
|
||||
{% set tiers.advanced = tiers.advanced + [key] %}
|
||||
{% else %}
|
||||
{% set tiers.basic = tiers.basic + [key] %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% for key in tiers.basic %}
|
||||
{% set prop = schema.properties[key] %}
|
||||
{% set value = config[key] if key in config else none %}
|
||||
{{ render_field(key, prop, value, '', plugin.id) }}
|
||||
{% endfor %}
|
||||
{% if tiers.advanced %}
|
||||
{% set adv_section_id = (plugin.id ~ '-section-advanced-settings')|replace('.', '-')|replace('_', '-') %}
|
||||
<div class="nested-section border border-gray-300 rounded-lg mb-4">
|
||||
<button type="button"
|
||||
class="w-full bg-gray-100 hover:bg-gray-200 px-4 py-3 flex items-center justify-between text-left transition-colors rounded-t-lg"
|
||||
aria-controls="{{ adv_section_id }}"
|
||||
aria-expanded="false"
|
||||
onclick="toggleSection('{{ adv_section_id }}')">
|
||||
<div class="flex-1">
|
||||
<h4 class="font-semibold text-gray-900">
|
||||
<i class="fas fa-sliders-h mr-1 text-gray-500"></i>Advanced Settings ({{ tiers.advanced|length }})
|
||||
</h4>
|
||||
<p class="text-sm text-gray-600 mt-1">Optional fine-tuning — the defaults work for most setups.</p>
|
||||
</div>
|
||||
<i id="{{ adv_section_id }}-icon" class="fas fa-chevron-right text-gray-500 transition-transform"></i>
|
||||
</button>
|
||||
<div id="{{ adv_section_id }}" class="nested-content bg-gray-50 px-4 py-4 space-y-3 hidden" style="display: none;">
|
||||
{% for key in tiers.advanced %}
|
||||
{% set prop = schema.properties[key] %}
|
||||
{% set value = config[key] if key in config else none %}
|
||||
{{ render_field(key, prop, value, '', plugin.id) }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{# No schema - render simple form from config #}
|
||||
{% if config %}
|
||||
|
||||
@@ -466,65 +466,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Plugin Configuration Modal -->
|
||||
<div id="plugin-config-modal" class="fixed inset-0 modal-backdrop flex items-center justify-center z-50" style="display: none;">
|
||||
<div class="modal-content p-6 w-full max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 id="plugin-config-title" class="text-lg font-semibold">Plugin Configuration</h3>
|
||||
<div class="flex items-center space-x-2">
|
||||
<!-- View Toggle -->
|
||||
<div class="flex items-center bg-gray-100 rounded-lg p-1">
|
||||
<button id="view-toggle-form" class="view-toggle-btn active px-3 py-1 rounded text-sm font-medium transition-colors" data-view="form">
|
||||
<i class="fas fa-list mr-1"></i>Form
|
||||
</button>
|
||||
<button id="view-toggle-json" class="view-toggle-btn px-3 py-1 rounded text-sm font-medium transition-colors" data-view="json">
|
||||
<i class="fas fa-code mr-1"></i>JSON
|
||||
</button>
|
||||
</div>
|
||||
<!-- Reset Button -->
|
||||
<button id="reset-to-defaults-btn" class="px-3 py-1 text-sm bg-yellow-500 hover:bg-yellow-600 text-white rounded transition-colors" title="Reset to defaults">
|
||||
<i class="fas fa-undo mr-1"></i>Reset
|
||||
</button>
|
||||
<button id="close-plugin-config" class="text-gray-400 hover:text-gray-600">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Validation Errors Display -->
|
||||
<div id="plugin-config-validation-errors" class="hidden mb-4 p-3 bg-red-50 border border-red-200 rounded-md">
|
||||
<div class="flex items-start">
|
||||
<i class="fas fa-exclamation-circle text-red-600 mt-0.5 mr-2"></i>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-red-800 mb-2">Configuration Validation Errors</p>
|
||||
<ul id="validation-errors-list" class="text-sm text-red-700 list-disc list-inside space-y-1"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Form View -->
|
||||
<div id="plugin-config-form-view" class="plugin-config-view">
|
||||
<div id="plugin-config-content">
|
||||
<!-- Plugin config form will be loaded here -->
|
||||
</div>
|
||||
</div>
|
||||
<!-- JSON Editor View -->
|
||||
<div id="plugin-config-json-view" class="plugin-config-view hidden">
|
||||
<div class="mb-2">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Configuration JSON</label>
|
||||
<textarea id="plugin-config-json-editor" class="w-full border border-gray-300 rounded-md font-mono text-sm" rows="20"></textarea>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-2 pt-2 border-t border-gray-200">
|
||||
<button type="button" onclick="closePluginConfigModal()" class="btn bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" id="save-json-config-btn" class="btn bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md">
|
||||
<i class="fas fa-save mr-2"></i>Save Configuration
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- On-Demand Modal moved to base.html so it's always available -->
|
||||
|
||||
<style>
|
||||
|
||||
Reference in New Issue
Block a user